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 1Get past the client-side number filter
ObservationThe contribution amount field is restricted to numbers by a browser-side attribute, which is cosmetic validation only. Send a non-numeric string with curl, or edit the DOM, and it never applies.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 network panel shows requests, but most browsers will not let you replay or edit one from that tab. Editing the DOM is faster: inspect the input and delete the restricting attribute in the Elements panel. curl skips the browser entirely and is the most reliable route.
Tried: Guessing the POST field name as 'amount' instead of inspecting the form for the actual name
curl sends the value and the server ignores it, because the parameter name does not match the form's. View source or inspect the form element and read the input's name attribute: here it is 'moneys', not 'amount'. A mismatch means the field arrives null and the query runs on 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 2Inject with SQLite string concatenation
ObservationThe amount value comes back reflected in the response, which means unsanitized string interpolation into a SQLite query. SQLite's || operator can splice a subquery reading the flag out of the users table straight into that 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 needs the exact column count of the original SELECT and matching types, or the query errors out complaining the two sides do not have the same number of result columns. The || approach sidesteps that entirely, splicing the subquery result into a value that is already reflected, so column count stops mattering.
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 only gained a concat() function in version 3.44 (late 2023) and this service predates it, so CONCAT() comes back as 'no such function: CONCAT'. Its portable string concatenation operator is ||. If you get no output or a 500, a 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
- SQL Injection Payload GeneratorGenerate SQL injection payloads for auth bypass, UNION extraction, blind SQLi, NoSQL operator injection, and sqlmap commands. Supports MySQL, PostgreSQL, SQLite, and MSSQL.
- URL Encoder / DecoderEncode and decode URL-encoded (percent-encoded) strings. Useful for web exploitation challenges involving query parameters, form data, and HTTP headers.
- 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.