SpEL表达式注入漏洞分析(以CNVD-2016-04742为例)

SpEL类似于JSP中的EL表达式,但是功能要更加丰富。并且SpEL不仅可以用于Web页面之中,也可以用于任何导入了SpEL包的项目之中。由于还没有了解过相关漏洞,借此机会学习了解一下SpEL表达式注入漏洞。

目录:

[toc]

SpEL的应用场景/使用方法

在Spring项目中,可以使用SpEL给bean赋值

一个在bean配置文件中使用SpEL的例子如下(参考链接):

<!--可以调用类中的静态变量-->
<property name="perimeter" value="#{T(java.lang.Math).PI*60}"></property>
<!--支持调用静态方法-->
<property name="price" value="#{T(java.lang.Math).round(100000.53452345234)}"></property>
<!--可以调用引用其它bean的变量,三元表达式。其中car2为另一个bean的id。-->
<property name="level" value="#{car2.price >= 300000?'金领':'白领'}"></property>
<!--支持正则表达式-->
<property name="email" value="#{car3.getBrand() matches '\w{3,5}@\w{3,8}.(com|cn)'}"></property>

在bean中的注解中使用SpEL的例子如下(参考链接):

1
2
@Value("#{表达式}")
public String arg;

也可以在任何其它地方使用:

直接调用SpEL解析器使用SpEL的例子如下:

1
2
3
4
5
6
7
8
9
public static void main(String[] args) {
//实例化表达式解析对象
ExpressionParser parser = new SPELExpressionParser();
//调用该对象的parseExpression方法来执行表达式
Expression expres = parser.parseExpression("3*3");
//获取表达式的执行结果,想要返回的结果类型可以以这种Type.class的形式传入
String message = expres.getValue(String.class);
System.out.println(message);
}

一个复杂一些的例子

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
User user = new User();
//实例化表达式解析对象
ExpressionParser parser = new SPELExpressionParser();
//实例化上下文,将user对象作为参数传入,这样就可以操作user对象的属性了
StandardEvaluationContext context = new StandardEvaluationContext(user);
/**
如果不想在实例化上下文的时候就传入对象的话就可以使用下面的代码进行等价替换

StandardEvaluationContext context = new StandardEvaluationContext();
context.setRootObject(user);

之所以可以这么替换是因为StandardEvaluationContext在构造方法中还是通过调用了setRootObject方法
通过setRootObject方法传入的参数会被放入StandardEvaluationContext.rootObject属性中

//向上下文中添加元素
context.setVariable("newUserName","Jone");
//这里的userName就是user.userName属性,#newUserName就是上一步中添加的,newUserName为key,而value为Jone,所以这一步是将newUserName的值赋值给user.userName属性
parser.parseExpression("userName=#newUserName").getValue(context);
System.out.println(user.getUserName());

//这一步是通过SPEL表达式直接给user.userName属性赋值
parser.parseExpression("userName='Tom'").getValue(context);
System.out.println(user.getUserName());

//通过setVariable传入上下文中的参数会被放入StandardEvaluationContext.variables属性中,该属性为HashMap类型,传入的字符串“user”,就是他的key值,value就是user这个对象
context.setVariable("user",user);

//通过setVariable方法存放入上下文中的对象,就可以通过 #+key+属性的方式进行调用
String name = (String)parser.parseExpression("#user.userName").getValue(context);
//通过setVariable方法传入的对象和通过setRootObject方法传入的对象是不一样的,通过setRootObject传入的对象可以直接通过“属性名称”来进行调用,而通过setVariable方法传入的对象,只能通过“#+key+属性的方式进行调用”

调用成员方法的例子:

1
2
3
4
5
6
7
8
User user = new User();
ExpressionParser parser = new SPELExpressionParser();
StandardEvaluationContext context = new StandardEvaluationContext(user);
context.setVariable("user",user);
//如下可以使用 #+Key+MethodName的形式进行调用
//这种方法不仅可以调用动态方法,也可以调用静态方法
String result = (String) parser.parseExpression("#user.sayHi('jack')").getValue(context);
System.out.println(result);

调用静态方法的例子1:

1
2
3
4
ExpressionParser parser = new SPELExpressionParser();
//使用“T(Type)”来表示java.lang.Class类的实例,即如同java代码中直接写类名。此方法一般用来引用常量或静态方法
String result = parser.parseExpression("T(com.SPEL.pojo.User).sayBye('Jack')").getValue(String.class);
System.out.println(result);

调用静态方法的例子2:

1
2
3
4
5
6
7
8
9
ExpressionParser parser = new SPELExpressionParser();
StandardEvaluationContext context = new StandardEvaluationContext();
//通过反射拿到User类的sayBye方法对象,
Method sayBye = User.class.getMethod("sayBye", String.class);
//将sayBye方法对象注册进上下文中
context.registerFunction("sayBye",sayBye);
//然后就可以通过#+MehtondName的形式进行调用
String result = (String) parser.parseExpression("#sayBye('jack')").getValue(context);
System.out.println(result);

漏洞成因

既然可以调用静态类的静态方法或其它类的成员方法,若该字符串可被用户控制,即可导致漏洞产生。

1
2
3
4
public static void main(String[] args) {
ExpressionParser parser = new SpelExpressionParser();
parser.parseExpression("T(java.lang.Runtime).getRuntime().exec('calc')").getValue();
}
1
2
3
4
public static void main(String[] args) {
ExpressionParser parser = new SpelExpressionParser();
parser.parseExpression("new java.lang.ProcessBuilder("open", "-a","Calculator").start()").getValue();
}

以上两种方式都能导致命令执行。

漏洞复现/调试分析

复现

以Spring boot 报错页面SpEL注入(CNVD-2016-04742)为例

先写一个会将用户输入添加到异常信息的controller

image-20221220100552582

然后运行,进行测试

image-20221220100223880

发现用#号无效,如果直接spel=${T(java.lang.Runtime).getRuntime().exec(%27calc%27)}尝试执行命令,会报错500

用参数传入spel=${5*9},发现异常信息中出现了45。

调试

调试一下,直接在throw new Excptoin(spel)处打断点,看看这一切是如何发生的。

首先是在Handler的doInvoke中抛出了异常,然后再次被throw

在DispatcherServlet中的doDispatch方法中抛出了异常,其中doDispatch在Dispatcher的doService方法中被调用。捕获异常后,进入processDispatchResult()方法,并将dispatchException传了进去。

image-20221220101743554

其中判断了是否是ModelAndView的Exceptoin,在这里不是,进入processHandlerException

image-20221220102012442

然后会用DispatcherServlet中的handlerExceptionResolvers依次去解析exception

image-20221220102859282

在handlerExceptionResolver中,又有多个revsolver,也是依次去解析

image-20221220103309612

在其中处理了日志输出,响应中的Cache策略,是否是一些指定的Exception等,在这里我们是一个Exception(spel),所以都没匹配到,在catch的代码中又被throw了出来

image-20221220104055704

继续进入下一个catch看看

image-20221220104252601

处理了所有拦截器的afterCompletion,搜了下,调用时间是在进行视图渲染之后。但是我想漏洞触发时间应该是在试图渲染时。

image-20221220104418442

然后又throw了出来,进入finally,在finally中没有对exception进行处理了。所以我猜测可能应该是跳过了漏洞触发点。不过,这次调试了解了一点点Controller中的异常处理全流程。


再重新调试下。

这次调试,一直单步进行并对页面进行观察,观察是哪一步加载出来了页面。

发现是在StandardHostValve中,这一步加载出了页面,但是这已经是tomcat中的代码了。

image-20221220113610555

于是去搜索并阅读了相关内容,参考SpringMVC异常处理流程源码分析。原因是在Spring对异常没有处理的时候,允许冒泡到容器,交给tomcat处理。

在其中,tomcat根据响应码500找到对应报错页面/error,将之前的请求和响应做了转发。转发之后,那就又走DispatcherSrevlet到对应的Controller。

复习一下DispatcherServlet的流程:
复习一下DispatcherServlet的流程

于是在DispatcherServlet打断点,发现其mappedHadner是org.springframework.boot.autoconfigure.web.BasicErrorController

image-20221220121919779

打开相关代码

image-20221220122336915

找到了/error的Controller,继续在此打断点调试。

发现在转发前,将错误信息添加到了request.attributes

image-20221220123928649

然后将其读入model,去渲染页面。

image-20221220124140362

拿到modelAndView之后的渲染流程,也就是上面流程图的8和9请求视图解析器,拿到view对象。

先是在doService->doDispatch->processDispatchResult->调用render(mv,request,response)

image-20221220153533465

通过resolveViewName获得了View,View的类型是ErrorMvcAutoConfiguration$SpelView,也就是ErrorMvcAutoConfiguration中的内部类SpelView。其中包含页面模板,内容如下

image-20221220155012805

1
<html><body><h1>Whitelabel Error Page</h1><p>This application has no explicit mapping for /error, so you are seeing this as a fallback.</p><div id='created'>${timestamp}</div><div>There was an unexpected error (type=${error}, status=${status}).</div><div>${message}</div></body></html>

可以看到其中有${timestamp}、${error}、${status}、${message}

继续执行render()方法,得到了view后,就是用model向view中填充数据

image-20221220155434604

进入view.render

在其中通过this.helper.replacePlaceholders进行占位符替换

image-20221220155928079

在其中,对占位符中的内容会递归调用自己,也就是直到解析到里面没有占位符。(所以之后其中的${message}先会被解析成我们传入参数${5*9},然后再次递归解析成45。)

image-20221220160515327

继续往下执行,通过占位符获得其对应的值。

image-20221220161107733

其中调用的就是我们最开始学习到的ExpressionParser.parseExpression(name).getValue(context),漏洞触发。

继续向下执行,还会发现,递归不仅发生在解析之前,在解析出占位符后,又一次递归。

image-20221220162055049


那为什么之前在web中传参spel=${T(java.lang.Runtime).getRuntime().exec(%27calc%27)}会报错500呢

我们将参数改为spel=${T(java.lang.Runtime).getRuntime().exec(%27calc%27)},重新调试

image-20221220162550032

可以看到第一次解析${message}是解析出了${T(java.lang.Runtime).getRuntime().exec(‘calc’)}的,但是下面return的时候,有HtmlUtils.htmlEscape(String spel)。return后变成了

image-20221220162813152

单引号没了,所以需要想办法绕过一下。

'calc'改成new java.lang.String(new byte[]{39,99,97,108,99,39})

完整POC

1
2
3
4
5
6
7
8
9
10

依然不行,直接先本地测试一下。发现报错了

![image-20221220164054830](SpEL表达式注入漏洞分析-以CNVD-2016-04742为例/image-20221220164054830-1686141320277.png)

emmm,研究了一下,懵了。用byte[]构造String的时候不用带单引号。

重新尝试

```spel=${T(java.lang.Runtime).getRuntime().exec(new java.lang.String(new byte[]{99,97,108,99}))}

成功弹出计算器

image-20221220164449213

漏洞修复

更新后取消了递归,只对模板中的占位符进行解析,不对值进行解析。

总结

因为各种原因跟了很多次tomcat和Spring的代码,虽然是不同的部分。但是经过这么多次磕磕绊绊的调试,对自己有了些信心。基本上可以先自己纯跟代码,遇到问题再看别人的分析,感觉已经没有最开始那么难了。对于Spring和tomcat的理解也在随之加深。之后有感兴趣的漏洞也可以深入跟一下,逐步增加对漏洞和各种web组件的理解。