luap.in/java
← Index · 04 · Framework Struts 6.x · historical analysis 2.3 / 2.5

Struts 2, OGNL and the S2 family

Struts binds request parameters by treating each parameter name as an expression in a language that can reach the whole JVM. Nearly every critical Struts advisory is a variation on one sentence: attacker text reached the OGNL parser in a position that gets evaluated.

01Why Struts is a filter

StrutsPrepareAndExecuteFilter is a jakarta.servlet.Filter, not a servlet. That single fact explains most of its interaction with the container.

<filter>
  <filter-name>struts2</filter-name>
  <filter-class>
    org.apache.struts2.dispatcher.filter.StrutsPrepareAndExecuteFilter
  </filter-class>
</filter>
<filter-mapping>
  <filter-name>struts2</filter-name>
  <url-pattern>/*</url-pattern>
</filter-mapping>

Consequences worth holding onto:

  • It sits inside the container's filter chain, so every filter-ordering concern applies. A filter declared before Struts sees raw requests; one declared after it may never run, because Struts does not call chain.doFilter() when it handles a request itself.
  • Mapped to /*, it inspects every request and decides whether the path resolves to an action. Non-actions are passed down the chain to static resources and JSPs.
  • It runs after Tomcat has already normalised the URI, so the path Struts matches on is the decoded one.

02The invocation and the stack

01
PrepareOperations

Creates the ActionContext (a ThreadLocal holding everything about this request), sets encoding and locale, wraps the request — including, for a multipart request, replacing it with a MultiPartRequestWrapper. That wrapping is where S2-045 happens, before any action is selected.

02
ActionMapper.getMapping()

Splits the URI into namespace, action name and optionally a method. DefaultActionMapper is the usual implementation. This is why one URL can reach different methods when dynamic method invocation is on.

03
Dispatcher.serviceAction()

Creates an ActionProxy, which creates a DefaultActionInvocation holding the action instance, the interceptor iterator and the eventual result. A new Action instance is created per request — unlike a servlet, Action fields are not shared across threads.

04
The interceptor stack

invocation.invoke() walks the iterator. Each interceptor does work, calls invocation.invoke() again, then does more work as the call unwinds — so the stack is entered in order and exited in reverse.

05
ParametersInterceptor

Binds request parameters onto the action through OGNL. The single most security-relevant step in the framework. Detailed below.

06
Action.execute()

Your method. Fields are already populated, converted and validated. Returns a result name such as "success".

07
Result rendering

dispatcher forwards to a JSP; json serialises the action; redirect issues a 302. A JSP result evaluates OGNL again through the Struts tag library — a second evaluation surface.

The default stack is roughly eighteen interceptors. The order that matters for review:

// defaultStack, abbreviated — declaration order IS execution order
exception → alias → servletConfig → i18n → prepare → chain
  → scopedModelDriven → modelDriven → fileUpload → checkbox
  → datetime → multiselect → staticParams → actionMappingParams
  → params → conversionError → validation → workflow

params runs before validation and workflow. Binding happens first; validation only decides whether execute() is reached. A field is populated with attacker data even when validation later rejects the request — so a setter with a side effect runs regardless.

03OGNL and the ValueStack

OGNL — Object-Graph Navigation Language — is a full expression language. It reads and writes properties, indexes collections, calls methods, constructs objects and references static members.

ExpressionMeaning
nameproperty name on the stack top
user.address.citychained property navigation
items[0].idindex then property
#session['user']# reaches named context objects
@java.lang.System@getenv()@Class@member — static access
new java.io.File('/x')constructor invocation
(#a=1).(#b=2)comma-style sequential evaluation

The last three rows are the problem. An expression language with constructors, static access and sequencing is a scripting language, and if a request value is parsed as one, the request is a script.

The ValueStack is the evaluation root: a stack of objects searched top-down for a property. During an action invocation it holds the action instance, so name resolves to the action's getName() / setName().

// What the framework ultimately performs, via OgnlUtil:
Object tree = Ognl.parseExpression(expression);
Ognl.setValue(tree, ognlContext, root, value);

Note which argument the attacker influences. In parameter binding it is expression — built from the parameter name — not just value.

04The params interceptor

This is the mechanism in one block:

// ParametersInterceptor, shape of setParameters()
for (Map.Entry<String, Parameter> entry : parameters.entrySet()) {
    String name = entry.getKey();          // the PARAMETER NAME
    if (!isAcceptableParameter(name, action)) continue;
    stack.setValue(name, entry.getValue().getObject());
    //        ^^^^ parsed as an OGNL expression
}

So an ordinary form post:

POST /contact/save.action
contact.name=Paul&contact.address.city=Chennai

becomes two OGNL assignments against the action:

stack.setValue("contact.name", "Paul");
stack.setValue("contact.address.city", "Chennai");
// -> action.getContact().setName("Paul")
// -> action.getContact().getAddress().setCity("Chennai")

This is a genuinely elegant binding mechanism and it is also mass assignment by construction. Any property reachable by navigation from the action is writable, including through getters that return shared or persistent objects.

Mass assignment in Struts

If the action exposes getContact() returning a JPA entity, then contact.owner.id=1 rewrites the owner. If a getter returns something reachable from the container, the reach extends further. The review question is not "which fields does the form post?" but "what is reachable by property navigation from this action?"

05What the guards actually guard

Struts has two distinct protections. They operate at different points and are frequently confused.

1. Parameter-name filtering — excludeParams

acceptableName() tests each parameter name against an accepted pattern and a set of excluded patterns before the expression is evaluated. The excluded set blocks names reaching class, session, request, application, servlet*, parameters, dojo, struts and java.*-ish shapes.

This is a deny-list over an expression grammar. It is applied to the parameter name as a string, and the grammar has many ways to spell the same navigation — bracket syntax, quoting, case variation, nested expressions. Its history is a sequence of bypasses followed by regex amendments.

2. Runtime member access — SecurityMemberAccess

The second guard sits inside OGNL evaluation. Before any member is accessed, isAccessible() consults excluded classes and package prefixes:

# struts-default.xml, abbreviated
excludedClasses =
    java.lang.Object, java.lang.Runtime, java.lang.System,
    java.lang.Class, java.lang.ClassLoader, java.lang.ProcessBuilder,
    java.lang.Shutdown, ognl.OgnlContext, ognl.MemberAccess, ...

excludedPackageNames =
    ognl., java.io., java.net., java.nio., javax., jakarta.,
    sun., java.lang.reflect., org.apache.struts2.,
    com.opensymphony.xwork2., ...

This is the stronger of the two because it applies at access time rather than to a string. It is still a deny-list, and historical bypasses have worked by reaching an equivalent capability through a class nobody listed.

The triage position

Neither guard is the control. The control is not letting attacker text reach the parser in an evaluated position. Treat excludeParams and SecurityMemberAccess as defence in depth — real, worth keeping current, and not something to certify an application on.

Concretely: "the Struts version has the S2-045 patch" is a fact about one entry point. It is not a statement about the application's own OGNL usage.

06S2-045, end to end

CVE-2017-5638. Remote code execution through the Content-Type header, with no authentication and no valid action required. Fixed in 2.3.32 and 2.5.10.1.

The chain is worth following because every step is ordinary code doing its job:

01
Request arrives with a malformed Content-Type

Struts checks whether the header starts with multipart/form-data to decide whether to use the multipart parser. The attacker makes it start that way, then appends an OGNL expression.

02
JakartaMultiPartRequest.parse() throws

The underlying file-upload library cannot parse the malformed header and raises an exception. Entirely expected behaviour.

03
An error message is built

Struts catches it and constructs a localised message to report back — and the message includes the offending Content-Type value, which is reasonable for diagnostics.

04
The message is processed for variables

The localisation path runs the message through TextParseUtil.translateVariables(), which evaluates ${...} and %{...} as OGNL so that message templates can interpolate. The attacker's header is now inside a string being parsed as an expression.

05
OGNL executes

The expression disables the member-access guard on the context it controls, then reaches ProcessBuilder. Code runs as the Tomcat user, before any action, interceptor or authentication has been involved.

// Shape of the header. Deliberately not a working payload.
Content-Type: %{(#nike='multipart/form-data').
  (#ctx=@com.opensymphony.xwork2.ActionContext@getContext()).
  (#ctx.setMemberAccess(#unguarded)).
  (#cmd='...').
  (@java.lang.ProcessBuilder@... )}

Three properties made this so damaging:

  • No valid action needed. Multipart wrapping happens in PrepareOperations, before action selection, so any URL on a Struts-mapped application worked.
  • No authentication needed. It executes before the interceptor stack, so authentication interceptors never ran.
  • It is a header. Logging, WAFs and request filters that inspected parameters saw nothing unusual.
The generalisable lesson

No single line here is wrong. Parsing failures should produce messages; messages should support interpolation; interpolation in this framework means OGNL. The vulnerability is the composition — an untrusted value flowed into a diagnostic string, and that string was fed to an evaluator.

So when you review, trace error paths as carefully as success paths. Exception messages that embed request data, then pass through any template or expression layer, are a recurring source of this class — in Struts, in SpEL, and in logging libraries.

07S2-057 and the namespace

CVE-2018-11776. Fixed in 2.3.35 and 2.5.17. Same root cause, different entry point: the namespace taken from the URL reaching OGNL evaluation.

It requires a specific configuration — which is why it is a useful illustration that "vulnerable version" is not the same as "vulnerable application":

  • alwaysSelectFullNamespace is true (the Convention plugin sets this), and
  • an action configuration specifies no namespace, or a wildcard namespace, and
  • a result is reached that renders a URL from that namespace.

Under those conditions the URL-supplied namespace is carried through and later evaluated, so a path segment becomes an expression.

<!-- Always declare an explicit namespace -->
<package name="contacts" extends="struts-default"
         namespace="/contacts">
  <action name="view" class="com.example.ViewContactAction">
    <result>/WEB-INF/views/contact.jsp</result>
  </action>
</package>

Review rules that fall out of this:

  • Every <package> declares a literal namespace.
  • No namespace, action or method attribute is built from a variable.
  • Wildcard action mappings (name="*", method="{1}") are read carefully — they are the mechanism behind dynamic method invocation issues as well.
  • struts.enable.DynamicMethodInvocation is false. It defaults to false in current versions; confirm rather than assume.

08Triaging a Struts application

Establish the version first, from the artifact

$ unzip -Z1 app.war 'WEB-INF/lib/struts2-core*'
WEB-INF/lib/struts2-core-2.5.10.jar

# Confirm against the JAR's own manifest, not just the filename
$ unzip -p app.war 'WEB-INF/lib/struts2-core*' > /tmp/s.jar \
  && unzip -p /tmp/s.jar META-INF/MANIFEST.MF | grep -i version

A filename can be renamed; the manifest usually cannot be, and a mismatch between the two is itself worth noting.

Then read the configuration

# Dynamic values in evaluated positions — the strongest signal
$ grep -rnE 'namespace="[^"]*\$|namespace="[^"]*%|action="[^"]*\{|method="[^"]*\{' \
    --include=struts.xml --include='struts-*.xml' .

# Constants that widen the surface
$ grep -rnE 'alwaysSelectFullNamespace|DynamicMethodInvocation|excludeParams' .

# OGNL in JSP tag attributes
$ grep -rnE '%\{|\$\{' --include='*.jsp' . | grep -vE 'c:out|fn:escapeXml'

Then prove reachability in a debugger

Static reading tells you what is configured. Breakpoints tell you what happened. Run the lab Tomcat with the debug agent and attach:

$ CATALINA_OPTS="-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=localhost:5005" \
    ~/tools/tomcat/bin/catalina.sh run

# Breakpoints that answer the three questions that matter:
com.opensymphony.xwork2.interceptor.ParametersInterceptor#setParameters
    // which parameters were accepted, and what expression each became
com.opensymphony.xwork2.ognl.OgnlValueStack#setValue
    // the expression actually handed to the parser
com.opensymphony.xwork2.ognl.SecurityMemberAccess#isAccessible
    // what the guard was asked about, and what it answered

Stepping through setParameters once makes the whole model concrete: you watch a string from the wire become an expression, and you watch the guard decide. After that the advisories read as variations rather than as separate mysteries.

What to write in the finding

State the version and where you read it. State which entry point you traced and how far you got. If you did not reach a sink, say that — "parameter binding reaches contact.owner.id, which is persisted without an ownership check" is a real finding; "Struts 2.5.10 is vulnerable to S2-045" is a version observation that may or may not describe this application. Keep the two separate in the report and the fix conversation stays honest.

Check yourself
  1. In Struts parameter binding, which part of the OGNL call does the attacker influence — the expression or the value?
    The expression. ParametersInterceptor calls stack.setValue(name, value) where name is the parameter name from the wire, parsed as OGNL. That is why a parameter name is an injection point and why the writable surface is everything reachable by property navigation from the Action.
  2. Why is excludeParams defence in depth rather than the control?
    It is a regex deny-list applied to a parameter name, guarding a language deliberately designed to be expressive — bracket syntax, quoting, case variation and nesting all spell the same navigation differently. Its history is a sequence of bypasses followed by regex amendments. The control is not letting attacker text reach the parser in an evaluated position.
  3. S2-045 required no valid action and no authentication. Explain both.
    Multipart request wrapping happens in PrepareOperations, before the ActionMapper selects anything and before the interceptor stack runs — so authentication interceptors never execute, and any URL on a Struts-mapped application reaches the vulnerable path. It also travelled in Content-Type, so parameter-inspecting filters and logs saw nothing unusual.
  4. params runs before validation. What does that mean for an Action setter with a side effect?
    The setter runs with attacker data even when validation subsequently rejects the request. Validation only decides whether execute() is reached; binding has already happened. A setter that writes to a cache, touches the database or mutates shared state is therefore reachable on requests you believe were rejected.
luap.in/java · source Java 21 · Tomcat 10.1.61 · Struts 6 · Spring Boot 3