Back to Writeups
Web / SQLi10 pts

SunshineCTF 2025: Lunar Shop — SQL Injection Writeup

Extracting the flag from a vulnerable product catalog using a UNION-based SQL injection in the 'product_id' parameter, without fuzzing or brute forcing.

1. Challenge Summary

Name: Lunar Shop
Points: 10
Author: alimuhammadsecured
Stats: 37 solves (86% liked)

Challenge text:

We have amazing new products for our gaming service! Unfortunately we don't sell our unreleased flag product yet!

Fuzzing is NOT allowed for this challenge, doing so will lead to IP rate limiting!

What this is saying:

  • The application is an online product catalog.
  • There is an “unreleased product,” which is basically the flag.
  • You are not allowed to fuzz or brute-force your way to it.
  • Translation: the intended path is not discovery/scanning, it's exploitation.

The task is to get the flag from the backend database even though it is not shown openly in the UI.

2. Baseline Behavior (No Exploit)

There is an endpoint like:

/product?product_id=1

When you visit product_id=1 normally, the page renders a table called something like:

Available Space Products 🚀

The table includes four visible columns:

  1. ID
  2. Name
  3. Description
  4. Price

Before any injection, a normal product page looks like this:

Rendered Table

From this response we can infer a few important things:

  • The backend is probably running a query similar to:

    SELECT id, name, description, price
    FROM products
    WHERE id = <user_supplied_product_id>;
    
  • The site takes whatever comes back from the database and directly renders it into the HTML table cells ("ID", "Name", "Description", "Price").

  • There is no obvious filtering or sanitization. The application trusts the database and just prints what it returns.

This is ideal for us. If we can control the query result, we can make the page print anything, including the flag.

3. The Vulnerability

The parameter product_id is user-controlled. The challenge hints at an "unreleased flag product" that exists but is not sold yet. This strongly suggests that the data (the flag) lives in the database, but is not reachable through normal product IDs.

We are told not to fuzz. That rules out brute-forcing other product IDs or hidden endpoints. Instead, the intended solve is to tamper with the SQL query itself.

If the backend code is naïvely doing something like:

query = "SELECT id, name, description, price FROM products WHERE id = " + product_id + ";"

then we (as the attacker) can inject raw SQL into product_id. That’s SQL injection.

Our goal is to inject our own SELECT clause that returns the secret flag.

4. UNION-Based Exfiltration

Why UNION?

Let’s say the normal query is:

SELECT id, name, description, price
FROM products
WHERE id = 1;

We want to turn that into a query that returns our own row that contains the flag. We do this using UNION.

With UNION SELECT, we can append our own row to the result, like this:

SELECT id, name, description, price
FROM products
WHERE id = -1
UNION
SELECT flag, 'x', 'x', 'x'
FROM flag
LIMIT 1;

Here’s what’s happening:

  • We first force the WHERE clause to look for something that won’t exist (id = -1 instead of id = 1). So the first part of the query returns 0 rows.

  • Then we UNION in a new row that we fully control. That row pulls data from the flag table instead.

  • We select four columns because the original query returns four columns. UNION requires the column counts to match.

  • We assume that the database has:

    • a table called flag
    • a column also called flag which is extremely common in CTF-style challenges.

If that assumption is right, then SELECT flag, 'x', 'x', 'x' FROM flag LIMIT 1 will return a row like:

| flag_value | x | x | x |

When the application renders that row, it will interpret:

  • flag_value as the "ID" cell,
  • x as "Name",
  • x as "Description",
  • x as "Price".

In other words, it will literally print the flag into the “ID” column of the product table.

Why four columns?

Because the page clearly displays four table columns (ID / Name / Description / Price), the original SQL query must be selecting four columns. UNION requires column counts to match. If we try UNION SELECT flag with only one column, or five columns, we’ll usually trigger an error instead of clean output. So we match the count: flag,'x','x','x' → four columns.

5. The Final Payload

We now craft one malicious value for product_id that accomplishes all of this.

The final working payload (URL-decoded for readability) is:

-1 UNION SELECT flag,'x','x','x' FROM flag LIMIT 1-- -

Explanation:

  • -1 We intentionally request a product ID that should not exist. This ensures the normal SELECT ... WHERE id = -1 does not return any legitimate product rows.

  • UNION SELECT flag,'x','x','x' FROM flag LIMIT 1 We inject a second SELECT statement that:

    • reads the real flag value from the flag table
    • pads the result with 'x','x','x' so that we still return four columns total
    • uses LIMIT 1 because we only need a single row

    So we're saying “if you won’t sell me the unreleased product, I’ll just UNION it in myself.”

  • -- - -- begins a SQL comment. Everything after -- is ignored by the database parser. This is important because it neutralizes any trailing characters that the backend might have added to complete the original query. Without commenting out the remainder, you’d often get syntax errors.

So effectively, when the vulnerable code runs, the server ends up executing something equivalent to:

SELECT id, name, description, price
FROM products
WHERE id = -1
UNION
SELECT flag, 'x', 'x', 'x'
FROM flag
LIMIT 1
-- -
;

From the web app’s point of view, this is just a normal query returning “products.” From our point of view, we’ve smuggled the flag into column 1.

6. Actual Request (URL-Encoded)

When you drop that payload into the browser’s query string, you have to URL-encode it because spaces, commas, and quotes can’t just go raw in many contexts.

The URL-encoded version looks like this:

meteor.sunshinectf.games/product?product_id=-1%20UNION%20SELECT%20flag%2C%27x%27%2C%27x%27%2C%27x%27%20FROM%20flag%20LIMIT%201--%20-

Where:

  • %20 is a space
  • %27 is a '
  • %2C is a comma ,

That encoded request is the one you actually send to the challenge server.

7. Before vs After

Before (no injection)

Request:

/product?product_id=1

Looks like this:

Rendered Table

This is the normal shop behavior. Nothing special.

After (with injection)

Request with the malicious payload (URL-decoded here for readability):

/product?product_id=-1 UNION SELECT flag,'x','x','x' FROM flag LIMIT 1-- -

Now looks like this: Rendered Table

Observations:

  • The "ID" column is now the entire flag string.
  • The other columns are just the 'x' placeholders that we injected to satisfy the column count.
  • The site has no idea anything weird happened — it’s just printing what the database returned.

This is exactly the goal: trick the application into placing the contents of the flag table directly into its normal HTML output.

8. Extracted Flag

From the injected row in the “ID” column, we get:

sun{baby_SQL_injection_this_is_known_as_error_based_SQL_injection_87672890082732892}

That is the challenge flag.

9. Why This Works (Security View)

This worked because of three issues:

  1. Unsanitized user input in SQL. The backend is very likely doing string concatenation when building the query:

    # vulnerable style
    query = "SELECT id, name, description, price FROM products WHERE id = " + product_id
    

    Since product_id is taken directly from the URL, we can inject arbitrary SQL syntax.

  2. UNION SELECT allowed us to inject arbitrary rows. We didn't need to guess admin pages or leak error traces. We just told the database: “Give me a fake row, where column 1 is actually the flag.”

  3. The frontend trusts database output. The app blindly prints whatever the query returns into the table. It doesn't check whether "ID" is numeric or whether "Description" is reasonable text. So the flag ends up visually embedded in the normal UI.

Also note: the challenge copy basically gave us a narrative clue:

"We don't sell our unreleased flag product yet!" That’s telling you directly: “The secret you’re after is in the product/flag data store, but not reachable via normal browsing.” Which screams "database exfiltration challenge."

10. Mitigations (How You'd Fix This in Real Life)

Even though this is just a CTF, the pattern is 100% real-world. The fixes are standard:

  1. Use parameterized queries / prepared statements. Do not ever build SQL like "WHERE id = " + userInput. Instead:

    cursor.execute(
        "SELECT id, name, description, price FROM products WHERE id = ?",
        (product_id,)
    )
    

    That way, even if the attacker passes -1 UNION SELECT ..., it will be treated as a literal string/int value, not as SQL syntax.

  2. Validate input types. If product_id should be a positive integer, enforce that. Reject anything that isn't a simple integer. Don’t allow spaces, quotes, or keywords like UNION.

  3. Least-privilege DB account. The web application should not be connecting to the database with an account that is allowed to read everything, including secrets like the flag table. Least privilege means that even if there is SQL injection, what you can steal is limited.

  4. Don’t directly render arbitrary query results. Instead of looping over SELECT * output and dumping into the template, define exactly which columns are allowed and make sure they match expected types. If "ID" is supposed to be an integer, do not render it when it’s clearly not. That alone would have prevented the flag from casually printing in the “ID” column.

  5. Avoid helpful error messaging in production. In more complex cases, error messages can leak table names and schema info, which makes it easier to craft a successful UNION SELECT. Even though in this challenge we didn’t need that, in general it speeds up attacks.

11. Recap

  • The site exposes /product?product_id=....

  • The server uses that value directly in a SQL query without sanitizing it.

  • The result of the SQL query is printed directly into an HTML table with four columns.

  • We injected a UNION SELECT that:

    • returns four columns (to match the expected output shape),
    • pulls the real flag from the flag table into column 1,
    • pads the other three columns with 'x'.
  • Because we forced the original lookup to return no rows (id = -1), the only row that gets rendered is the fake row we injected.

  • The page then literally displays the flag as if it were a product ID.

Final payload (URL-decoded):

-1 UNION SELECT flag,'x','x','x' FROM flag LIMIT 1-- -

Final payload (URL-encoded as used in the browser):

meteor.sunshinectf.games/product?product_id=-1%20UNION%20SELECT%20flag%2C%27x%27%2C%27x%27%2C%27x%27%20FROM%20flag%20LIMIT%201--%20-

Flag:

sun{baby_SQL_injection_this_is_known_as_error_based_SQL_injection_87672890082732892}

This is exactly what the challenge is teaching: with one carefully constructed request, you can turn a normal “product page” into a data exfiltration interface and pull secrets directly out of the database, no brute force needed.

Author: k33w3 Team: MaaSec Role: CTF team
Focus areas: web/sqli