Startup Company picoCTF 2021 Solution

Published: April 2, 2026

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.

Remote (web)

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.

bash
# 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.

Walk me through it
  1. Step 1
    Get past the client-side number filter
    Observation
    I 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):
    bash
    curl -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.

  2. Step 2
    Inject with SQLite string concatenation
    Observation
    I 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 wordpass column of the startup_users table (for the the_real_flag user). 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.

Key takeaway

SQL injection happens when user input is concatenated into a query as text rather than bound as a parameter. Client-side restrictions (HTML attributes, JavaScript validation) are purely cosmetic and trivially bypassed by editing the DOM or sending requests directly with curl or Burp. Parameterized queries separate SQL syntax from data at the protocol level, making injection structurally impossible regardless of what the user supplies. The same class of injection affects NoSQL queries, LDAP filters, and OS command strings.

Related reading

Want more picoCTF 2021 writeups?

Useful tools for Web Exploitation

What to try next