xaccefyblogOctober 2026

Writeup · intigriti challenge 1026

Coupon stacking at the Halloween Store

October 2026 · business logic · accepted as Medium

Intigriti runs a monthly challenge: they ship a small vulnerable app, you find the bug, you get a flag. October’s was a fake Halloween store. The flag was sitting in the checkout receipt, and the way in turned out to be the shift key.

The setup

The store hands you €66.00 of credit and sells exactly one interesting thing: The Midnight Collection at €666.00. Two coupon codes are floating around the shop. The About page advertises NIGHT20 for 20% off, and subscribing to the newsletter with a throwaway address returns NEWS30 for 30% off.

Halloween Store About page advertising the NIGHT20 coupon code
The About page hands out NIGHT20 for free.
Newsletter subscription response returning the NEWS30 coupon code
The newsletter flow returns NEWS30.

Stacked honestly, the two codes take 50% off €666, which leaves €333. Still nowhere near the €66.00 credit, and the checkout confirms it: POST /api/checkout with an empty body comes back 402 insufficient_credit. So the intended path is a dead end. Something in the discount math has to give.

Cart containing The Midnight Collection at 666 euro before any coupon is applied
The cart before coupons: €666.00 with €66.00 of credit.

The duplicate-check hole

Coupons go to POST /api/cart/coupon as JSON, {"code":"..."}. Applying each code once works as advertised. Then I started poking at the duplicate check:

  • NIGHT20 a second time: rejected, “Coupon already applied.”
  • Night20, capitalized like a polite human: also rejected.
  • night20, all lowercase: accepted. The cart now shows NIGHT20 with a count of 2.

Same story with NEWS30 and news30. So the duplicate check and the discount engine disagree about what “the same code” means, and the disagreement is worth exactly 100% off.

The sequence

Four requests, one endpoint, in order:

POST /api/cart/coupon
Content-Type: application/json

{"code":"NIGHT20"}
{"code":"NEWS30"}
{"code":"night20"}
{"code":"news30"}

The cart after the fourth request:

{
  "subtotal": 66600,
  "discount": 66600,
  "total": 0,
  "credit": 6600,
  "canCheckout": true,
  "promotions": [
    {"code": "NIGHT20", "percent": 20, "amount": 26640, "count": 2},
    {"code": "NEWS30", "percent": 30, "amount": 39960, "count": 2}
  ]
}

Discount equals subtotal. Both promotions show up under their uppercase names with a count of 2, so the cart itself is admitting it applied each code twice.

Cart showing a 666 euro discount and a zero euro total after the four coupon requests
After the four requests: discount equals subtotal, total €0.00.

Checkout

POST /api/checkout, body {}, same as the attempt that got a 402 earlier. This time: 200, and the receipt prints the flag.

flagINTIGRITI{094638e6-ba2f-4c50-8bb4-41ced0d5debd}
Checkout receipt displaying the challenge flag
The receipt after a €0.00 checkout.

What is probably wrong

Black box, so this is a guess, not a source review. The check stops an exact repeat and the title-case form Night20, yet lets the all-lowercase form through. That reads like a comparison against the stored code plus its title-cased label, something like:

// guessed, not reviewed:
// rejects input === "NIGHT20" and input === "Night20"
// (the stored code and its title-cased label)
// misses every other casing

A check written that way catches the strings the developer typed while testing, not the strings a user can send. The cart then normalizes both spellings back to the canonical code, which is why the response reports count: 2 under NIGHT20 instead of two separate entries.

The fix is the usual one: normalize once at the boundary (uppercase, trimmed), dedup and store that form, and recompute the discount and total server-side. Regression tests should cover exact, lowercase, and mixed-case repeats.