L3akCTF 2026 — catvault part 1 Writeup

A vault that only lets you read your own secrets, until the settings endpoint lets you overwrite the session user_id and the vault query filters on the wrong column.

4 min read
ibrahimsql
628 words

L3akCTF 2026 — catvault (part 1)

A "cat vault" where you stash secrets and the page promises "it's impawssible for anyone else to see your vault". Two small defects turn that promise into a lie: a settings endpoint that let me overwrite my own session user_id, and a vault query that filters on the wrong column. Together they read the admin's entry, which holds the flag.

Scope: local reproduction. Challenge source is public in the CTF archive; no external host was touched.

1. Source review

Flask app, MariaDB backend, sha256 password hashing. The obvious injection points are clean — login and register use parameterized queries:

Python
# db.py
def login(username, password):
    cursor.execute("SELECT id, name, password FROM users WHERE name = ? AND password = ?;",
                   (username, sha256(password).digest().hex()))

The session cookie is signed with a per-boot random key (app.secret_key = random.randbytes(32)), so forging cookies is off the table. But two other places are not so careful.

The vault read builds its SQL with an f-string, and it filters on the wrong column:

Python
# db.py
def get_vault_entries(user_id):
    cursor.execute(f"SELECT id, content FROM vault WHERE id = {user_id};")   # `id`, not `user_id`
    return [i for i in cursor]

And the settings endpoint writes any caller-supplied key into the session, rejecting only underscore-prefixed keys and non-string values:

Python
# app.py — POST /api/settings
for key, value in incoming.items():
    if not isinstance(key, str) or key.startswith("_") or not isinstance(value, str):
        continue
    session[key] = value          # `user_id` is a plain string -> allowed

user_id does not start with _ and is a string, so it sails straight into the session.

2. The chain

Admin setup inserts the flag as the admin's vault content, and that row is the first one in the table:

# db.py — create_admin()
cursor.execute("INSERT INTO users (name, password) VALUES ('admin', 'nologin');")
cursor.execute(f"INSERT INTO vault (user_id, content) VALUES ({cursor.lastrowid}, '{FLAG}');")

So the admin's vault row has id = 1. The plan:

  1. Register any user.
  2. POST /api/settings with {"user_id": "1"} — overwrites the session's user_id.
  3. GET /vault — get_vault_entries("1") runs SELECT id, content FROM vault WHERE id = 1 and returns the admin's row.

Because the query filters on the vault primary key id rather than user_id, pointing user_id at 1 is enough to read the admin's secret. The value is also string-interpolated, so a stacked query is on the table too — but the wrong-column read is the shortest path.

3. Reproduction

No MariaDB server was available, so I drove the real Flask app with the app.test_client() and a sqlite3 stand-in for db.py that keeps the SQL text identical (including the buggy f-string). The flag came from the FLAG environment variable, as in the original:

Text
register -> 302 /vault
vault(before) contains flag: False
POST /api/settings -> 200 {'ok': True, 'saved': {'theme': 'dark', 'user_id': '1'}}
vault(after) flag: L3AK{l0c4l_r3pr0_fl4g_n0t_th3_r34l_0n3}

The statement the server actually runs for the second vault load, and the row it returns:

SQL: SELECT id, content FROM vault WHERE id = 1;
rows: [(1, 'L3AK{l0c4l_r3pr0_fl4g_n0t_th3_r34l_0n3}')]

Note the archive ships a flag.txt whose content is a deliberate troll (...to_waste_your_time_if_youre_using_an_llm); for part 1 the real flag is the admin's vault entry (the FLAG env value), not that file.

4. Flag and lessons

Local stand-in flag, read exactly as the real one is:

L3AK{l0c4l_r3pr0_fl4g_n0t_th3_r34l_0n3}

Lessons:

  • Do not let request data write to reserved session keys. An allowlist of writable keys beats a blocklist of _-prefixed ones.
  • A query must filter on the column that carries the ownership (user_id), never on a global autoincrement id. A one-word typo here is an authorization bypass.
  • Still parameterize interpolated values, even when they "come from the session" — the session field was attacker-controlled a step earlier.
---
Share this post:

What do you think?

React to show your appreciation

Related Posts