浅析OGNL的攻防史

 

在分析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中,这些对象包括application、session、request context的上下文映射。下面是一个图例:

1.2 ActionContext

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

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

可以看到其中有三个常见的作用域request、session、application。

  • 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的关系

可以看到其实ActionContext和valueStack是“相互包含”的关系,当然准确点来说,valueStack是ActionContext中的一部分,而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中的_memberAccess与securityMemberAccess是同一个SecurityMemberAccess类的实例,而且内容相同,也就是说全局的OgnlUtil实例都共享着相同的设置。如果利用OgnlUtil更改了设置项(excludedClasses、excludedPackageNames、excludedPackageNamePatterns)则同样会更改_memberAccess中的值。

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

  1. 首先ValueStack本身是个OgnlContext
  1. 之后调用setOgnlUtil添加黑名单:
  1. 然后OgnlUtil中的这些值赋给SecurityMemberAccess

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

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

2.3 创建拦截器(Interceptor)

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

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

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

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

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

接下来就跟进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是:

(#_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:

(#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.MemberAccess和ognl.DefaultMemberAccess,而这两个对象其实就是2.3.20-2.3.28版本的通用绕过方法,具体的思路就是利用_memberAccess调用静态对象DefaultMemberAccess,然后用DefaultMemberAccess覆盖_memberAccess。那么为什么说这样就可以使用静态方法了呢? 我们先来看一下可以在S2-032、S2-033、S2-037通用的poc:

(#_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)之后的版本,我们可以使用的_memberAccess和DefaultMemberAccess都进入到黑名单中了,覆盖的方法看似就不行了,而这个时候S2-045的payload提供了一种新的思路:

(#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,来执行静态方法了。

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

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

poc为:

(#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包了。
  1. 切断了动态引用的方式,需要利用构造函数生成
  • 标题: fig:
  • 不谈重写了setExcludedClasses和setExcludedPackageNamePatterns,单单黑名单的改进就极大的限制了利用。

4.2 2.5.19的改进

  1. ognl包的升级,从3.1.15升级到3.1.21
  1. 黑名单改进
  1. 在OgnlUtil中setXWorkConverter、setDevMode、setEnableExpressionCache、setEnableEvalExpression、setExcludedClasses、setExcludedPackageNamePatterns、setExcludedPackageNames、setContainer、setAllowStaticMethodAccess、setDisallowProxyMemberAccess都从public方法变成了protected方法了:

也就是说没有办法显式调用setExcludedClasses、setExcludedPackageNamePatterns、setExcludedPackageNames了。

4.3 master分支的改变

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

  • https://cloud.tencent.com/developer/article/1024093
  • https://lgtm.com/blog/apachestrutsCVE-2018-11776-exploit
  • 《Struts2技术内幕》
(完)