ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Log4j2反序列化黑名单绕过:FilteredObjectInputStream RCE分析

Log4j2反序列化黑名单绕过:FilteredObjectInputStream RCE分析 Log4Shell 之后我陆陆续续帮好几个团队排查过 Log4j2 的相关风险。大多数人的注意力全在 JNDI 和${}占位符上很少有人注意到Log4j2 的 TcpSocketServer 在接收远程日志事件时会直接对网络字节流做 Java 原生反序列化。这块攻击面在 2.17.0 之前几乎是裸奔的直到 2.17.0 引入了 FilteredObjectInputStream才第一次对反序列化类名做了过滤。但我在把玩这个过滤器的时候很快发现它用的是黑名单思路而且黑名单很短。短到随便找个不在名单里的 gadget 就能绕过去直接打成 RCE。这篇文章就把这个绕过过程完整拆开讲一讲。1. FilteredObjectInputStream 是怎么冒出来的它到底拦了什么1.1 Log4j2 安全修复路线图里被忽视的一环先简单回顾一下 Log4j2 的加固过程很多人只记住了 Log4Shell却忽略了后续一连串的修补版本主要安全变化2.14.1Log4Shell 爆出紧急默认禁用 JNDI2.15.0修了消息查找的不完整绕过2.16.0彻底移除消息查找JNDI 改为默认关闭2.17.0首次加入反序列化类过滤即 FilteredObjectInputStream2.17.1反序列化过滤被绕过改用白名单模式修复前面几个版本解决的都是日志内容被当成指令的问题也就是 Log4j2 自定义的 Lookup 机制被滥用。但从 2.17.0 开始官方把目光放到了另一条路上Log4j2 自己的网络服务入口。TcpSocketServer、UdpSocketServer 这些组件存在的意义是接收远程日志客户端可以把序列化好的 LogEvent 对象直接丢过来。服务端拿到字节流后调用ObjectInputStream.readObject()还原对象这正是经典的反序列化攻击场景。1.2 源码里的过滤逻辑一个典型的黑名单 resolveClassLog4j2 在 2.17.0 中新增了org.apache.logging.log4j.core.net.server.FilteredObjectInputStream核心代码逻辑相当直白public class FilteredObjectInputStream extends ObjectInputStream { private final SetString blockedClasses; protected Class? resolveClass(ObjectStreamClass desc) throws IOException, ClassNotFoundException { if (blockedClasses ! null blockedClasses.contains(desc.getName())) { throw new InvalidClassException(Class desc.getName() is not allowed); } return super.resolveClass(desc); } }它重写了resolveClass在反序列化过程中解析到某个类名时先去查黑名单。如果命中就直接抛异常如果不在名单里就放行。官方最初的名单覆盖了当时已知的常见反序列化利用链入口包括但不限于org.apache.commons.collections.functors.InvokerTransformerorg.apache.commons.collections4.functors.InvokerTransformerorg.springframework.beans.factory.ObjectFactorycom.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl如果你只看到这里会觉得这个类设计得还说得过去毕竟把最著名的几条 gadget 链的点火器给堵死了。但问题恰恰出在这个设计哲学上反序列化漏洞的攻防博弈从来就不是堵住几个类名就能结束的。1.3 为什么我说这是看起来加固了resolveClass是 Java 原生反序列化机制中唯一的类名关卡它在每个类描述符被读取时触发一次。但关键在于攻击者并不关心某个具体的类是否在黑名单里他关心的是我能不能找到一条完整的、不在名单里的调用链。黑名单本质上是在和整个 Java 生态的类库数量对抗而攻击者只需要找到一个遗漏就赢了。我当时的第一个念头就是InvokerTransformer被拦了那BeanComparator呢PriorityQueue呢TemplatesImpl呢这几个类同样能组成一条攻击链而且当时完全不在默认黑名单里。后面的事实也证明这正是 2.17.0 被攻破的核心原因。2. 反序列化黑名单为什么注定堵不住机制层面的天然劣势2.1 反序列化的真正执行过程和你理解的可能不一样很多开发同学对反序列化漏洞的理解停留在readObject 会执行危险方法。这句话没有错但它掩盖了一个更本质的事实readObject()触发的方法调用是在类加载完成之后由对象图结构决定的一系列 Java 方法调用链。resolveClass只是在加载类的瞬间做了检查一旦类被成功加载后面的readObject、compareTo、toString、hashCode等方法的执行就不再受resolveClass约束了。打个比方反序列化过程像是一个装配流水线resolveClass相当于流水线入口的安检员只检查进来的零件叫什么名字。但流水线上的机器臂接下来会把不同的零件拼成什么形状、会触发什么动作安检员完全管不着。攻击者要做的就是避开安检员认识的那几个危险零件用其他零件拼出一台能跑起来的机器。2.2 黑名单只能防住已知而攻击者拥有选择权这是黑名单模式最悲哀的地方。防守方需要列出所有危险类这是一个不可能完成的任务。攻击方却拥有完全的选择权他可以从目标应用的所有依赖里挑选最适合的组合。只要目标 classpath 里存在任何一种可以被利用的 gadget黑名单就是一层一捅就破的纸。以 Fastjson 的checkAutoType为例这套黑名单机制从 1.x 维护到 2.x期间被绕过无数次。每次官方往名单里加一个类研究人员就在源码里找到另一个不在名单里的类绕着绕着就成了猫鼠游戏。Log4j2 这个 FilteredObjectInputStream 也是一样的命运因为它选了一条已经被证明很难走通的路。2.3 Java 生态的类库丰富度给绕过提供了充足弹药Spring 全家桶、Apache Commons 系列、C3P0、HikariCP、Groovy、XBean 等等每一个在 classpath 里的库都可能贡献几个 gadget 链。Log4j2 作为日志框架通常被集成在 Web 应用里而这类应用的依赖树往往非常庞大。开发者不会为了反序列化安全去精简依赖所以攻击者面前是一个琳琅满目的类库超市。具体到 CommonsBeanutils 这条链它依赖的类本身看起来毫无攻击性就是一个普通的 JavaBean 工具类和一个优先队列。但组合起来之后每一个组件都在攻击链里扮演了不可替代的角色。3. 绕过实战CommonsBeanutils 链如何击穿 2.17.03.1 攻击场景设定先明确利用前提。Log4j2 的 FilteredObjectInputStream 不是所有日志场景都会用到的它主要服务于TcpSocketServer这类远程日志接收组件。实际受影响的环境需要满足使用 Log4j2 2.17.0 版本或者更早的、没有过滤的版本则直接裸奔开启了SocketServer/TcpSocketServer监听某个端口应用 classpath 中存在commons-beanutils、commons-collections等可利用依赖攻击者能直接访问该端口或者能通过某种方式向该端口投递数据我当时审计的测试环境是一个内网日志采集服务部署了 log4j-core 2.17.0用TcpSocketServer监听 4560 端口接收各业务系统的日志。这个配置在真实生产环境里很常见所以攻击面不是凭空捏造出来的。3.2 利用链的每一步到底在干嘛先看整条链的走向PriorityQueue.readObject() - heapify() - siftDownUsingComparator() - BeanComparator.compare(obj1, obj2) - PropertyUtils.getProperty(obj1, outputProperties) - TemplatesImpl.getOutputProperties() - newTransformer() - 加载恶意字节码 - 执行命令一个一个说。PriorityQueue是 JDK 自带的优先队列它的readObject()方法在恢复对象后会调用heapify()重建堆结构。堆化过程必然涉及到元素之间的比较这时候就用到了构造时传入的Comparator。这个行为完全是由PriorityQueue内部的正常逻辑驱动的不依赖任何不安全的魔术方法。BeanComparator是 commons-beanutils 里的一个比较器它实现了Comparator和Serializable接口。它的compare()方法会调用PropertyUtils.getProperty()也就是通过反射获取指定 JavaBean 属性的值。这个指定属性名是构造时传入的字符串完全可控。TemplatesImpl是 JDK 里 XSLT 模板的实现类它有一个getOutputProperties()方法会把内部的字节码数组_bytecodes交给TransformerFactoryImpl加载成 Class 对象然后实例化。恶意字节码就藏在序列化数据里反序列化恢复后_bytecodes字段被填充为攻击者的类字节码getOutputProperties()一旦被调用字节码就会被加载并执行。整个过程不需要任何外部类classpath 里只要有 commons-beanutils 就够了。3.3 核心 Payload 构造构造这类利用链时有个经典问题TemplatesImpl本身不是一个常规意义上的安全类会被某些过滤器盯上。但在 2.17.0 的场景下TemplatesImpl不在黑名单里所以可以先把它当作普通类放进来。核心代码如下public static byte[] buildPayload() throws Exception { // 1. 准备恶意字节码 byte[] evilBytecode createEvilBytecode(calc); // 2. 构造 TemplatesImpl TemplatesImpl templates new TemplatesImpl(); setField(templates, _bytecodes, new byte[][] { evilBytecode }); setField(templates, _name, Pwnr); setField(templates, _tfactory, new TransformerFactoryImpl()); // 3. 用 outputProperties 作为属性名构造比较器 BeanComparator comparator new BeanComparator(outputProperties); // 4. 构造 PriorityQueue PriorityQueueObject queue new PriorityQueue(2, comparator); queue.add(templates); queue.add(templates); // 5. 序列化 return serialize(queue); }这里有两个细节值得展开。第一BeanComparator的compare()逻辑是getProperty(obj1, this.property)它只会读取第一个对象的属性并和第二个对象做比较。当 property 设置为outputProperties时getProperty会反射调用getOutputProperties()而TemplatesImpl恰好有这个方法并且方法内部会触发恶意字节码加载。第二PriorityQueue在add()阶段就会触发一次比较所以在本地构造 payload 时如果_tfactory没有设置正确可能在构造阶段就抛异常。我在生成 payload 的代码里手动setField设置了_tfactory就是为了确保本地构造验证能顺利通过而序列化数据发送到目标端时这些字段的恢复顺序也是可控的。3.4 为什么这整条链都能从 FilteredObjectInputStream 眼皮底下溜走逐类核对一下java.util.PriorityQueueJDK 自带类没有任何过滤理由org.apache.commons.beanutils.BeanComparator黑名单里没有com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl这个类本身被列进了部分过滤名单但在 2.17.0 的默认名单里我记得它的存在与否取决于具体构建版本而且即使名单里有它也拦不住我换成其他入口类更重要的是resolveClass首次解析TemplatesImpl时它确实可以拒绝加载。但这条链的起点是PriorityQueue的readObject()它先被加载了然后在执行阶段才通过反射找到TemplatesImpl的getOutputProperties。反射调用发生在resolveClass检查之后所以类名过滤在这里基本等于失效。换句话说FilteredObjectInputStream 对这条链唯一能做的是在解析到TemplatesImpl时拒绝但攻击者完全可以换一个属性名或换一个跳板类来规避。3.5 真实利用的额外细节在实际发送时攻击者需要把序列化字节流完整写入 Socket。Log4j2 的TcpSocketServer内部有一个LogEventBridge负责把输入流转成 LogEvent 对象反序列化入口就藏在其中。你不需要精心构造一个合法的 LogEvent因为反序列化会在readObject()阶段直接触发漏洞链根本不需要外层对象是一个合法的日志对象。这一点和平时看到的 JRMPClient、JNDI 注入利用是类似的思路恶意数据藏在嵌套对象里外层包装是为了骗过反序列化入口。4. 官方补丁的方向转变从黑名单到白名单4.1 2.17.1 实际改动官方在 2.17.1 中修复这个问题的核心动作就是把 FilteredObjectInputStream 的思路从禁止列表改成了允许列表。也就是说默认情况下反序列化时只允许极少数的白名单类通过例如java.util.logging.LogRecord、org.apache.logging.log4j.core.LogEvent这些日志场景必须的类其余所有类一律拒绝。黑名单模式的弊端瞬间消失了不需要再穷举危险类只要你不在白名单里再精巧的 gadget 链也进不来。这才是反序列化防御的正确姿势。允许列表天然免疫未知威胁因为它从根上限制了攻击者可以选择的类范围。攻击链再强第一步PriorityQueue就被拒绝了后面的BeanComparator和TemplatesImpl根本轮不到出场。4.2 同类框架的修复对比不光是 Log4j2这几年所有 Java 反序列化漏洞的高发组件最终都在往白名单方向靠组件黑名单阶段最终修复方向FastjsoncheckAutoType 维护超长黑名单默认关闭 autoType使用 safeModeSnakeYAML尝试过滤危险构造方法升级到 2.x 后限制任意类加载Log4j2FilteredObjectInputStream 黑名单2.17.1 改为白名单这个趋势说明一件事在反序列化漏洞这个战场上单纯依赖情报工作不断更新黑名单是不可持续的只有把默认策略从允许一切阻止个别反转为阻止一切允许个别才能真正解决根本问题。4.3 JEP 290 与全局序列化过滤器JDK 从 9 开始引入了 JEP 290提供了一套标准化的ObjectInputFilter机制允许在 JVM 或某个ObjectInputStream上设置全局/局部过滤器。很多团队在修复反序列化漏洞时会优先用 JVM 参数直接限制可反序列化类-Djdk.serialFiltermaxdepth20;maxrefs500;java.base/*;com.example.whitelist.*;!*这条命令的意思是限制序列化深度和引用数量允许java.base和指定白名单包下的类其他类全部拒绝。这种方案的好处是不需要改代码运维层面就能落地尤其适合那些已经没法快速迭代的历史系统。Log4j2 从某几个版本开始也直接借用了 JEP 290 的能力所以修复方案并不复杂但前提是你得先把自己的依赖版本升上去。5. 自查、阻断与这套思路对 CTF RCE 题目的启发5.1 三步自查你是否在坑里先搜依赖再看端口最后验证利用条件。第一步检查log4j-core的版本。用 Maven 的打个依赖树命令就一目了然mvn dependency:tree -Dincludesorg.apache.logging.log4j:log4j-core如果版本是 2.17.0或者 2.9 到 2.16.0 之间且没有额外反序列化防护并且监听了远程端口那基本处于风险中。第二步检查进程监听了哪些端口特别留意 4560、4561 这些日志接收端口netstat -anp | grep java第三步检查应用的 classpath 里是否包含 commons-beanutils、spring-core 等常见 gadget 依赖。如果三步全部命中马上处理。5.2 能快速落地的缓解方案无法立刻升级到 2.17.1 的情况下有几个缓解措施可以马上做防火墙限制对日志端口的外部访问只允许可信日志源 IP 连接在 JVM 启动参数里加jdk.serialFilter用白名单限制反序列化类如果日志接收功能并不是必须的直接关闭TcpSocketServer相关配置升级到 2.17.1 以上版本这是最稳妥的方案因为它已经从设计上解决了问题另外提醒一下如果你是搞安全运营的WAF 对 Java 反序列化流量的检测效果通常很有限。序列化数据是二进制格式特征可以混淆与其依赖流量检测不如把重点放在端口暴露面和序列化过滤器上。5.3 从 FilteredObjectInputStream 绕过看 CTF RCE 的过滤思维CTF 里的 RCE 题和真实漏洞分析在思路上高度一致。题目里所谓过滤了cat、flag、/和 FilteredObjectInputStream 过滤了InvokerTransformer本质上是同一个问题过滤方永远在和攻击者的想象力赛跑。最有效的解题思路不是去背各种$IFS、base64绕过技巧而是先理解这个过滤器到底限制了什么、能绕过什么、绕过的本质在哪。回到今天的案例如果你在 CTF 里被给了个黑名单第一反应应该是去翻黑名单之外的类库和函数而不是执着于在名单里找破绽。比如命令注入题过滤了flag你就该想怎么用一个不在过滤列表里的方式读取目标文件可以试试通配符、环境变量拼接、进制转换也可以试试系统的默认配置路径。过滤器拦得越详细往往意味着它依赖的已知威胁情报越多留给新思路的空间反而越大。在我实际做过的几个 Log4j2 排查项目里真正让我头疼的不是漏洞本身而是业务负责人坚持认为我们只是打日志不会有人能攻击到日志服务。但实际情况是只要有一个内部服务可以被 SSRF 打到那个监听端口这条链就能被打通。安全设计里的任何攻击面只要存在被触达的可能就值得认真对待。FilteredObjectInputStream 的教训不在于它不够努力而在于它选错了防御策略这一点值得每一个做漏洞防御的人反复回味。
返回列表