Challenge: Expensey Eats · CTF: DalCTF 2026 · Category: Web · Difficulty: Medium
TL;DR
Three bugs, one chain:
alg:noneJWT forgery → become the admin user.- UNION-based SQL injection in the admin food search → map the database and discover a hidden
$99,999vault dish. - Broken authorization in the order flow → the admin role skips both the balance check and the hidden-item check, so the admin can “buy” the vault dish for free. The flag is returned in a one-time flash message, not stored anywhere in the database.
Flag: dalctf{7h4t_w45_3xp3n5Iv3_4f}
Recon
We’re handed a single URL:
| |
A quick look at the response headers tells us what we’re dealing with:
| |
gunicorn → a Python web app, almost certainly Flask. The landing page is a tongue-in-cheek luxury food-delivery site (“premium hunger, premium invoice”) with Login and Create account links.
Probing common paths gives us the shape of the app:
| |
So there’s a /menu and an /admin area, both gated behind authentication. The registration form is interesting:
| |
“Admin accounts are not available from public registration.”
Naturally, the first thing to try is registering with role=admin:
| |
| |
Blocked. Fine — we register a normal customer and log in instead.
The login response is a goldmine
Logging in sets two cookies:
| |
The session cookie is a standard signed Flask session. But auth_token is a JWT — and the header is already screaming at us. Decoding the two Base64URL segments:
| |
"alg":"none" with an empty signature (note the trailing dot and nothing after it). This is the textbook JWT none-algorithm vulnerability: if the server accepts alg: none, anybody can mint a token with any claims they like, because no signature is verified.
We have a sub (subject / user id) and an is_admin boolean. Let’s flip is_admin to true.
Bug #1 — Forging an admin JWT
A tiny helper to Base64URL-encode JSON without padding:
| |
First attempt against /admin… 403 Forbidden. Confusing — the token was accepted (we get logged in), but admin is still denied.
The gotcha: I was sending both cookies. The app prefers the signed Flask session cookie when it’s present, and that session belongs to a plain customer. Drop the session cookie and send only the forged JWT:
| |
| |
We’re in the admin panel.
Lesson: when two auth mechanisms coexist, test them in isolation. A “fix” on one path means nothing if the other path still trusts attacker-controlled input.
Inside the admin panel
The admin dashboard exposes Users, Restaurants, Food Items, and Orders. The user table confirms our forged identity worked and lists the seeded accounts:
| |
No flag in plain sight. But the Food Items section has a search box:
| |
Any time a CTF web app hands you a search field labelled “type a letter to look up…”, it’s an invitation. We test it.
A baseline search food_lookup=a returns matching dishes. Now the classic:
| |
| |
A hidden dish appears that the normal listing never shows — “Flag Foie Fantasia”, id 7, $99,999. SQL injection confirmed, and it just leaked something the UI was trying to hide.
Bug #2 — UNION-based SQL injection
The injection sits inside a LIKE clause, roughly:
| |
Quirk: the trailing wildcard
My first ORDER BY/UNION attempts returned “No foods matched”. The reason is the trailing %:
| |
Commenting out the rest with -- - also kills the closing %', turning the pattern into '%Flag' (must end in “Flag”), which matches nothing. The fix is to add our own wildcard:
| |
Counting columns
ORDER BY n tells us the query has 9 columns (it breaks at 10):
| |
Mapping columns to the table
A 9-column UNION with string markers rendered nothing, because the template doesn’t print all 9 columns and it’s picky about types (it formats price with $%.2f, which throws on a string). Using typed markers instead reveals exactly which positions are displayed:
| |
renders as:
| |
Decoding the mapping:
| Display column | Source position |
|---|---|
| ID | 1 |
| Name | 3 |
| Price | 5 |
| Stock | 7 |
| Restaurant | 9 |
So column 3 is our exfiltration channel. Anything we put there shows up in the “Name” cell.
Dumping the database
With a reliable channel, we enumerate everything:
| |
| |
It’s SQLite. Pulling each table’s columns:
| |
We dump everything looking for the flag — password_hash (scrypt, not crackable in CTF time), delivery_note, tagline, and crucially the description of dish #7:
| |
There is no dalctf{...} anywhere in the database. The flag isn’t stored — it’s generated when you successfully purchase the vault dish. That reframes the whole challenge.
Things I ruled out along the way:
- Stacked queries (
'; UPDATE users SET balance=...) → HTTP 500. Python’ssqlite3.execute()runs one statement only.- The admin user-edit form (
POST /admin/users/<id>) is properly parameterized — injecting into theusernamefield just stored the payload as a literal string. No SQLi there.- Mass-assigning
balanceon that form → ignored (not a writable field).
The purchase problem
To buy “Flag Foie Fantasia” we hit two walls:
| |
- Money. It costs
$99,999; every account has$5. - Visibility. It’s
is_hidden=1, and the order endpoint refuses hidden items for normal users.
There’s no top-up / deposit / refund endpoint (enumerated and confirmed). The quantity parameter is ignored server-side (fixed at 1), so no negative-quantity money trick. Restaurants don’t get paid out either, so there’s no way to farm balance.
The restaurant owner angle (a near miss)
sub=3 (truffle_tower) owns the restaurant that serves dish #7. Logging in as that owner reveals /restaurant, with the ability to edit menu items (/restaurant/foods/7) and update order statuses (/orders/<id>/status).
I tried to lower dish #7’s price to $1 so a customer could afford it:
| |
stock updated to 99, but price stayed at 99999 — testing on a normal dish confirmed the route silently ignores price edits entirely. Dead end. Marking the existing order delivered didn’t write a flag into delivery_note either.
The actual key: the admin bypass
Recall that when we ordered dish #7 as the forged admin, the response was different:
| |
It worked. A purchases row appeared: user_id=1, food_id=7, status=ordered. The admin role skips both the balance check and the hidden-item check — broken function-level authorization. So the admin can purchase the $99,999 vault dish with a $0 balance.
But the order row’s delivery_note was empty, and the dashboard showed no flag. So where did the “access granted” message go?
Bug #3 — The flag lives in a flash message
Flask’s flash() stores one-time messages in the session cookie, displayed on the next page render and then cleared. When the admin POSTed /order/7, the server set a flash and 302-redirected to /menu. If you don’t carry the freshly-set session cookie through the redirect, you never see it.
So we capture the session cookie from the POST and replay it on the follow-up GET:
| |
| |
🎉
The flag
| |
(“that was expensive af” — fitting.)
Why this was a fun chain
Each bug on its own is a known classic, but the challenge forces you to compose them and, more importantly, to change your mental model mid-solve:
- The
alg:noneJWT gets you in. - The SQLi tempts you to think the flag is in the database — and it deliberately isn’t. The DB dump’s real value is intel: it reveals the hidden dish, the
is_hiddenflag, and the purchase mechanic. - The real finish is an authorization bug (admin skips payment) plus an understanding of Flask flash messages living in the session cookie. If you don’t follow the redirect with the right cookie, you stare at an empty
delivery_noteand assume you failed.
The “expensive” theme is the hint all along: the whole puzzle is about paying for something you can’t afford — and the answer is to abuse the role that never has to pay.
Defensive takeaways
| Bug | Fix |
|---|---|
alg:none JWT accepted | Pin the expected algorithm server-side; reject none; verify signatures with a secret. Don’t run two auth systems that disagree. |
| SQL injection in search | Use parameterized queries everywhere — the app already did for the user-edit form, just not for food_lookup. |
| Admin bypasses payment + hidden checks | Authorization checks (balance, visibility, ownership) must apply to every role. “Admin” should mean more scrutiny on money-moving actions, not a free pass. |
| Secret in a flash message | Don’t gate secrets on a payment that privileged roles can skip; enforce the business rule (funds actually deducted) before granting access. |