Description
A startup-funding web app where you make a contribution. The amount field is restricted to numbers in the browser, but the server drops it straight into a SQLite query unsanitized. Inject with SQLite's || string-concatenation operator to pull the flag out of the users table.
Setup
Open the site and find the contribution / donation amount field.
Use browser dev tools or curl/Burp to send a value the client-side 'numbers only' restriction would normally block.
# Browse to http://mercury.picoctf.net:<PORT_FROM_INSTANCE>/Solution
Want to try it yourself first?
The guided walkthrough reveals hints one step at a time.
Step 1
Get past the client-side number filterObservationI noticed the contribution amount field was restricted to numbers via a browser-side attribute, which suggested the validation was purely cosmetic and that sending a non-numeric string directly via curl or by editing the DOM would bypass it entirely before reaching the server.The donation amount input is constrained to numbers by a client-side class/attribute. That is cosmetic: remove the restriction in dev tools (delete the class or the pattern/type attribute), or bypass the browser entirely and POST the request directly with curl or Burp. The server does no such validation.bash# In dev tools, drop the 'number only' class/attribute on the amount field, then submit.bash# Or POST directly (inspect the form to get the exact field name, e.g. moneys):bashcurl -s -X POST http://mercury.picoctf.net:<PORT_FROM_INSTANCE>/ --data "moneys=1"What didn't work first
Tried: Trying to intercept and modify the request in the browser network tab instead of editing the DOM or using curl
The browser network panel shows requests but most browsers do not let you replay or edit them directly from that tab. Editing the DOM attribute (removing the type='number' or the restricting class) is faster - right-click the input, inspect, and delete the attribute in the Elements panel. curl bypasses the browser entirely and is the most reliable method.
Tried: Guessing the POST field name as 'amount' instead of inspecting the form for the actual name
curl sends the value but the server ignores it because the parameter name does not match what the form uses. Right-click the page, View Source or inspect the form element, and read the name attribute on the input - here it is 'moneys', not 'amount'. A mismatch means the field arrives as null and the query runs with an empty string.
Learn more
Client-side validation is not security. A "numbers only" restriction enforced in HTML/JS only shapes what a cooperative browser sends. An attacker edits the DOM or skips the browser, so any value that reaches the server must be validated server-side too. Here the server trusts the field, which is what makes the injection possible.
Step 2
Inject with SQLite string concatenationObservationI noticed the amount value was reflected back to me in the response, which indicated unsanitized string interpolation into a SQLite query and suggested using the || concatenation operator to splice a subquery that reads the flag from the users table directly into the echoed output.The amount is interpolated into a SQLite statement. Break out of the string and use the || operator to concatenate the result of a subquery that reads the flag column from the users table. The injected value is reflected back (as your displayed contribution), carrying the flag with it.bash# Payload in the amount field (SQLite || concatenation pulling the flag):bash' || (SELECT GROUP_CONCAT(wordpass) FROM startup_users) || 'bash# Narrower variant if the column dump is noisy:bash' || (SELECT wordpass FROM startup_users WHERE wordpass LIKE 'picoCTF%') || 'The flag lives in the
wordpasscolumn of thestartup_userstable (for thethe_real_flaguser). After submitting the injected value, view your contribution / profile page and the concatenated query result is rendered back to you.What didn't work first
Tried: Attempting a UNION-based injection like ' UNION SELECT wordpass FROM startup_users - to exfiltrate the flag
UNION injection requires knowing the exact number of columns in the original SELECT and matching their types. Without that, the query throws a 'SELECTs to the left and right of UNION do not have the same number of result columns' error. The || concatenation approach avoids this entirely by splicing the subquery result into an existing reflected value, so column count is irrelevant.
Tried: Using MySQL-style CONCAT() instead of SQLite's || operator, e.g. CONCAT('x', (SELECT wordpass FROM startup_users))
This server runs SQLite, not MySQL. SQLite does not have a CONCAT() function - calling it returns an error or null. SQLite's string concatenation operator is ||, so the correct form is 'x' || (SELECT wordpass ...) || 'y'. If you see no output or a 500 error, the dialect mismatch is the likely cause.
Learn more
Why || and not UNION. The injection point is a value that the app concatenates and then echoes, so the cleanest exfil is to make your value become the subquery result via
||. SQLite's||is string concatenation (unlike MySQL where||is logical OR), so'|| (SELECT ...) ||'closes the string, appends the subquery output, and reopens it. See SQL Injection for CTF for the broader injection toolkit.
Interactive tools
- Regex TesterTest regular expressions against a string with live match highlighting, flag toggles, and common CTF pattern shortcuts.
Flag
Reveal flag
picoCTF{1_c4nn0t_s33_y0u_...}
Not an HTML-comment or header hunt. The donation amount field is client-side-restricted to numbers but unsanitized server-side, so a SQLite injection using || concatenation, e.g. ' || (SELECT GROUP_CONCAT(wordpass) FROM startup_users) || ', reflects the flag from the startup_users.wordpass column back to you.