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
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.
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.
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.
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.
ParametersInterceptor
Binds request parameters onto the action through OGNL. The single most security-relevant step in the framework. Detailed below.
Action.execute()
Your method. Fields are already populated, converted and validated. Returns a
result name such as "success".
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.
| Expression | Meaning |
|---|---|
| name | property name on the stack top |
| user.address.city | chained property navigation |
| items[0].id | index 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.
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.
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:
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.
JakartaMultiPartRequest.parse() throws
The underlying file-upload library cannot parse the malformed header and raises an exception. Entirely expected behaviour.
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.
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.
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.
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":
alwaysSelectFullNamespaceistrue(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 literalnamespace. - No
namespace,actionormethodattribute 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.DynamicMethodInvocationisfalse. 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.
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.
In Struts parameter binding, which part of the OGNL call does the attacker influence — the expression or the value?
The expression.ParametersInterceptorcallsstack.setValue(name, value)wherenameis 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.Why is
excludeParamsdefence 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.S2-045 required no valid action and no authentication. Explain both.
Multipart request wrapping happens inPrepareOperations, before theActionMapperselects 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 inContent-Type, so parameter-inspecting filters and logs saw nothing unusual.paramsruns beforevalidation. 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 whetherexecute()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.