Description
This secret box is designed to conceal your secrets. It's perfectly secure - only you can see what's inside. Or can you? Try uncovering the admin's secret.
Setup
Launch the challenge instance and open the web application.
Register an account to explore how secrets are stored.
Solution
Walk me through it- Step 1Identify the injection pointThe application stores secrets associated with users. When a secret is created, the owner's username is used in the SQL query via string concatenation - this is the injection vector. Register a new account with a malicious username containing a SQL payload.
Learn more
Second-order SQL injection (also called stored SQL injection) is a more subtle and dangerous variant of classic SQL injection. Unlike first-order injection where the payload is executed immediately, second-order injection involves two separate steps: (1) store a malicious payload into the database through one interface, and (2) trigger its execution when another part of the application reads and uses that stored data in a SQL query.
In this challenge, the username is stored safely during registration (perhaps even properly escaped), but later when the application constructs SQL queries for secret creation or retrieval, it concatenates the username directly into the query string without sanitizing it again. Developers often sanitize input at the point of entry but forget that stored data retrieved from the database is also untrusted - it may have been injected by a malicious user.
This attack pattern is particularly insidious because security scanners and manual testers often miss it: they test the registration form and find it safe, then fail to trace how the stored username flows into subsequent SQL queries. A proper defense requires parameterized queries (prepared statements) at every point where data is used in SQL - not just at input boundaries.
- Step 2Inject SQL in the username to leak the admin UUIDRegister a normal account first and inspect the /secrets API response in DevTools - your owner_id is exposed there in the same shape the admin's would be. Once you have the admin UUID, register a second account whose username is a SQL payload that pulls the admin's content.bash
# Step 1: register normally and inspect the response in DevTools Network tab.bash# /secrets returns objects like: {"owner_id": "<your-uuid>", "content": ...}bash# Look for an admin-looking entry, or test login flows that reveal admin UUIDs.bash# Step 2: register a second account with the SQL payload.bash# URL-encode the payload: ' -> %27, space -> %20, | -> %7Cbashcurl -c cookie.jar -d "username=%27%20%7C%7C%20(SELECT%20content%20FROM%20secrets%20WHERE%20owner_id%3D%27<ADMIN_UUID>%27%20LIMIT%201)%20%7C%7C%20%27&password=test123" \ http://<HOST>:<PORT_FROM_INSTANCE>/registerbash# If the subquery returns NULL (wrong UUID), first verify injection works at all:bashcurl -c cookie.jar -d "username=%27%20%7C%7C%20(SELECT%201)%20%7C%7C%20%27&password=test123" \ http://<HOST>:<PORT_FROM_INSTANCE>/registerLearn more
Reconstructing the original query. The vulnerable code is almost certainly something like
INSERT INTO secrets (owner, content) VALUES ('USERNAME', 'CONTENT')whereUSERNAMEis your stored username spliced in unquoted. Your payload' || (SELECT content FROM secrets WHERE owner_id='...') || 'resolves like this once substituted:INSERT INTO secrets (owner, content) VALUES ('' || (SELECT content FROM secrets WHERE owner_id='<admin-uuid>' LIMIT 1) || '', 'CONTENT')The leading
'closes the empty string the app opened for the username.||is SQLite's string concatenation operator, so the subquery's result is concatenated. The trailing|| 'reattaches to the closing'and the rest of the original query (the, 'CONTENT')tail). Net effect: when this row is later read back as "your username", it contains the admin's flag.If the subquery returns NULL. Two possibilities: the injection itself isn't firing, or the UUID is wrong. Sanity-check the injection with
(SELECT 1)first - if your stored username comes back as1, the path is open and you just need a better UUID. Otherwise you need to trace where the injection is being escaped.See SQL injection for CTF for the full pattern catalogue and web challenges and real-world bug patterns for how this maps to production breaches.
- Step 3Trigger the injection and read the admin's secretAfter registering with the malicious username, log in and create a secret. When the app builds the SQL query with your username, the injected subquery runs and returns the admin's secret content (which contains the flag) instead of your own.bash
curl -b cookie.jar -d 'content=anything' http://<HOST>:<PORT_FROM_INSTANCE>/secrets/createbashcurl -b cookie.jar http://<HOST>:<PORT_FROM_INSTANCE>/secretsbash# The flag appears in the 'content' field of your created secretLearn more
Cookie-based session management is simulated here with
curl -c cookie.jar(save cookies) and-b cookie.jar(send cookies). After logging in, the session cookie identifies your user account for subsequent requests. This is how browsers maintain authentication state - each request carries the session cookie, and the server looks up the associated user from its session store.The key insight of second-order injection is timing: the SQL injection payload does not execute during registration. It only executes when your username is read from the database and used in another SQL query - in this case, when you create a secret. At that point, the application retrieves your username from the
userstable and concatenates it into theINSERT INTO secretsorSELECT FROM secretsquery, triggering the subquery and returning the admin's content as if it were your own.Automated SQL injection tools like sqlmapcan detect first-order injection but often miss second-order injection because the payload and its execution are separated across different HTTP requests. Manual testing and code review are the most reliable ways to find stored injection vulnerabilities. OWASP's Testing Guide has a dedicated section on second-order SQL injection detection techniques.
Flag
picoCTF{s3cr3t_b0x_0p3n3d_...}
Second-order SQL injection via the username field. The username is stored then inserted unsanitised into the secrets query. A subquery `(SELECT content FROM secrets WHERE owner_id='<admin-uuid>' LIMIT 1)` extracts the admin's flag when a secret is created.