2094 words
10 minutes
Intigriti 1026 — Promotion Stacking to €0

Challenge: Intigriti Monthly Web Security Challenge 1026 — “Halloween Store”, by Globules
Target: https://challenge-1026.challenges.intigriti.io
Vulnerability type: Business logic — broken duplicate-promotion check (CWE-840)
Root cause: The duplicate check only looks at the last applied promotion
Flag: INTIGRITI{094638e6-ba2f-4c50-8bb4-41ced0d5debd}


1. Executive Summary

Most monthly challenges are a hunt for a parser quirk. This one is a hunt for an assumption. There is no injection here, no XSS sink, no deserialization gadget — just a shop that lets you apply a discount code once, politely refuses the second attempt, and then quietly forgets it ever refused you.

The Halloween Store hands every visitor €66.00 of store credit. The flag lives inside one product, The Midnight Collection, which costs €666.00, and the receipt only carries the flag when the order is genuinely paid for with that credit. So the whole challenge reduces to a single question: how do you get a €666.00 cart down to €66.00 or less?

The store has two promotion codes, worth 20% and 30%. Applied once each, they take the cart to €333.00 — still a long way short. Applying either one twice is rejected with a clean 409 Conflict. That rejection is the thing worth looking at, because it is not wrong, it is just short-sighted: the server compares the code you submit against the last promotion in the cart, and nothing else. Alternate the two codes and every one of them is “new” again.

Four requests, two codes, no payload: 20 + 30 + 20 + 30 = 100% off, total €0.00, order paid, flag in the receipt.

This writeup walks the whole thing through Burp Repeater, because the bug only becomes obvious when you can see the same request produce two different answers depending on what came before it. It also keeps the wrong theory I solved it with first, because that mistake is the most instructive part of the challenge.


2. Reading the Shop

The store front already tells you two of the three things you need. The announcement bar publishes a promotion code, and the header shows the budget you have to work with.

The Halloween Store header: the NIGHT20 promotion in the announcement bar, €66.00 of store credit in the corner, empty bag

NIGHT20 — 20% off The Midnight Collection — and €66.00 of credit. Then the product itself:

The Midnight Collection product page, priced at €666.00

€666.00 against €66.00 of credit — a gap of roughly ten to one, which is a deliberate bit of design: no single discount in the shop can close it, so the challenge is telling you up front that the intended solution involves applying more discount than you are supposed to have.

Adding it to the bag confirms the arithmetic and shows how the checkout gate works:

Cart summary: subtotal €666.00, total €666.00, available store credit €66.00, checkout button disabled and labelled "Still needed: €600.00"

The checkout button is disabled and replaced by “Still needed: €600.00”. The wording is drawn by the front end, but the gate behind it is cart.canCheckout, which the server computes and returns on every cart response — so this is not a client-side lock you can toggle in DevTools. The flag is issued server-side, only when the order actually settles.

The second code is one request away. The footer newsletter form returns it on subscribe:

The newsletter form returning "Subscription confirmed. Your welcome code: NEWS30"

So the full inventory is: NIGHT20 (20%), NEWS30 (30%), €66.00 of credit, and a €666.00 target. Applied once each, the two codes stack additively to 50%, which leaves the cart at €333.00 — about five times more than the credit can pay.

There is also one line of copy under the promotion box that will matter later:

Codes are case-insensitive and can be removed before checkout.


3. The Control: What the Store Refuses

Before trying to break something, it is worth proving it is defended. Every API step from here on runs through Burp Suite: the shop’s own session cookie, the shop’s own requests, replayed in Repeater so each attempt is identical except for the one thing being changed. The screenshots of the shop itself are the same actions seen from the front end.

Apply NIGHT20 once — accepted, count: 1. Send the exact same request again:

Burp Repeater: POST /api/cart/coupon with {"code":"NIGHT20"} returning HTTP/2 409 Conflict, coupon_already_applied

POST /api/cart/coupon HTTP/2
Host: challenge-1026.challenges.intigriti.io
Content-Type: application/json
Content-Length: 18
{"code":"NIGHT20"}
HTTP/2 409 Conflict
{"ok":false,"error":"coupon_already_applied","message":"Coupon already applied."}

A dedicated error code, a dedicated status code. Somebody thought about this. The same thing happens in the browser, with the toast message rather than the JSON:

The store UI rejecting a repeated code with the toast "Coupon already applied."

Note the code typed in that screenshot is night20, in lower case, and it is still recognised as the same promotion — the lookup really is case-insensitive, exactly as the UI claims. Keep that in mind, it becomes the trap in section 6.

So: a repeat of the last code is refused. The obvious question is what the word last is doing in that sentence.


4. The Bug: A Check With a One-Item Memory

Apply NEWS30 in between. It is a different promotion, so it is accepted on its own merits — the cart now holds NIGHT20 × 1 and NEWS30 × 1, total €333.00.

Cart summary with both promotions applied once: −€133.20 and −€199.80, total €333.00

Now re-send the byte-for-byte identical NIGHT20 request that was rejected a moment ago:

Burp Repeater: the identical NIGHT20 request now returns HTTP/2 200 OK with NIGHT20 count 2 and total 19980

HTTP/2 200 OK
{"ok":true,"message":"20% promotion applied.","cart":{
"subtotal":66600,"discount":46620,"total":19980,"credit":6600,"canCheckout":false,
"promotions":[{"code":"NIGHT20","percent":20,"amount":26640,"count":2},
{"code":"NEWS30","percent":30,"amount":19980,"count":1}]}}

"count": 2. Same request, same session, same cart, same code — rejected a minute earlier, accepted now. The only thing that changed is which promotion was applied most recently.

That is the whole vulnerability. The duplicate check is not asking “is this promotion already in the cart?” It is asking “is this promotion the one I just applied?” A cart that remembers only its last entry cannot enforce uniqueness across the set, and the way through it is simply to alternate:

NIGHT20 -> accepted (NIGHT20 x1) total €532.80
NEWS30 -> accepted (NIGHT20 x1, NEWS30 x1) total €333.00
NIGHT20 -> accepted (NIGHT20 x2, NEWS30 x1) total €199.80
NEWS30 -> accepted (NIGHT20 x2, NEWS30 x2) total €0.00

Cart summary after the third application: NIGHT20 × 2 for −€266.40, total €199.80

The fourth request closes it. 20 + 20 + 30 + 30 = 100%, and the server says so itself:

Burp Repeater: the fourth coupon request returns total 0, canCheckout true, NIGHT20 count 2 and NEWS30 count 2

"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}]

"canCheckout": true. In the browser the disabled button has turned into a live Complete order:

Cart summary with a €0.00 total and an enabled "Complete order" button

Nothing stops at 100%, either. A fifth application pushes NIGHT20 to count: 3 and the discount to 120% of the subtotal; the total simply clamps at zero. The stacking is unbounded — it just has nothing left to discount.


5. Checkout, and the Flag

The order now costs nothing at all, which is comfortably inside the €66.00 the shop hands out for free, so the checkout settles and the receipt is a genuine paid one:

Burp Repeater: POST /api/checkout returning the receipt with paid 0 and the flag

POST /api/checkout HTTP/2
Host: challenge-1026.challenges.intigriti.io
Content-Type: application/json
Content-Length: 2
{}
HTTP/2 200 OK
{"ok":true,"receipt":{"orderId":"BLE-706CADC8AF","paid":0,"credit":6600,
"message":"Your order is confirmed and being prepared.",
"flag":"INTIGRITI{094638e6-ba2f-4c50-8bb4-41ced0d5debd}"}}

And the same thing through the shop’s own checkout, for the version with the pumpkins:

The store receipt overlay: order confirmed, paid with store credit €0.00, flag shown in the "Inside the archive" box

INTIGRITI{094638e6-ba2f-4c50-8bb4-41ced0d5debd} — static, identical across sessions.


6. The Wrong Theory That Still Produced the Flag

I did not find it this way, and the detour is worth keeping.

The promotion box advertises that codes are case-insensitive, which means the server is normalising them. Case normalisation in Unicode is not symmetric, and there is a small family of characters that uppercase to an ASCII letter but survive lowercasing untouched:

Characterupper()lower()casefold()
ı U+0131 LATIN SMALL LETTER DOTLESS IIıı
ſ U+017F LATIN SMALL LETTER LONG SSſs

So if a lookup uppercases and a duplicate check lowercases, nıght20 would resolve to the real NIGHT20 while comparing as a different string — a classic mixed-normalisation dedupe bypass. I built that theory, sent nıght20 and newſ30 as the third and fourth codes, watched both counts go to 2, took the flag, and wrote the report around the case-mapping mismatch.

The theory was wrong. Those two requests worked for the same boring reason a plain repeat of NIGHT20 works: something else had been applied in between. Re-running the whole thing with a control settles it — a fresh cart, NIGHT20 applied once, then the dotless-i variant:

Burp Repeater: {"code":"nıght20"} with U+0131 returning HTTP/2 409 coupon_already_applied

HTTP/2 409 Conflict
{"ok":false,"error":"coupon_already_applied","message":"Coupon already applied."}

nıght20 is caught by the duplicate check. That single response proves two things at once: the lookup really does fold ı into I and resolve the code to NIGHT20, and the duplicate check sees through the substitution perfectly well. There is no normalisation mismatch anywhere in this application. The Unicode variants were never necessary and never helped — plain NIGHT20, or even lower-case night20, does exactly the same job.

For completeness, codes that the uppercase fold does not reach are rejected outright with a different error, which is how you tell “unknown code” from “duplicate code” apart:

{"ok":false,"error":"invalid_coupon","message":"That promotion code is not recognised."}

Fullwidth NIGHT20, circled ⓃⒾⒼⒽⓉ②⓪, the Greek capital nu in ΝIGHT20 and the dotted capital nİght20 all land there. The server folds case; it does not apply NFKC.

The lesson is the one that keeps costing people reports: a payload that works is not a proof of why it works. Two variables moved in my original chain — the code was Unicode and a different promotion had been applied in between — and I attributed the result to the interesting-looking one. The negative control, the test designed to make the theory fail, takes about forty seconds in Repeater and is the only thing that separates an explanation from a coincidence. A triager who runs NIGHT20 twice and sees a 409 has every reason to close a report built on the wrong model, even when the flag in it is correct.


7. Root Cause and Fix

The behaviour is consistent with a duplicate check scoped to the most recent promotion rather than to the applied set — something shaped like:

// vulnerable: only the last promotion is consulted
const last = cart.promotions[cart.promotions.length - 1];
if (last && last.code === code) {
return conflict("coupon_already_applied");
}

Three things need to change, and the first one alone kills the exploit:

1. Compare against the whole set, once normalised. Normalise the submitted code a single time, and reuse that one value for both the catalogue lookup and the uniqueness check, so the two can never disagree:

const key = String(input).trim().toUpperCase(); // one canonical form
if (!CATALOGUE.has(key)) return badRequest("invalid_coupon");
if (cart.promotions.some(p => p.code === key)) { // the whole set
return conflict("coupon_already_applied");
}

Rejecting anything outside [A-Z0-9] before the lookup is a cheap extra: promotion codes have no business containing U+0131, and an allow-list removes a whole class of normalisation arguments permanently. Note that casefold() is not a sufficient answer on its own here — it folds ſ to s, but leaves ı as ı. Consistency between the two comparisons is what matters, not which transformation you pick.

2. Make the stacking policy explicit and server-side. count exists in the cart model, so repeat application is a supported state rather than an accident. If a promotion is meant to apply once, that is a constraint the server must assert — maxUses per code, per cart — not something implied by a check that happens to fire most of the time.

3. Validate the money, not just the codes. A cart whose total discount reaches or exceeds its subtotal should be refused before checkout, independently of how it got there. Defence at the promotion layer is a prevention; a floor on the order total is a detection, and it is the one that catches the next stacking bug nobody has thought of yet.

The real-world version of this bug is not a Halloween store. It is a referral credit applied twice, a first-order discount alternated with a loyalty code, a free-shipping voucher that reappears whenever something else is added to the basket. The amount is small, the exploit is four HTTP requests, and the uniqueness check that was supposed to prevent it genuinely exists — it is just asking the wrong question.


8. Reproduction

POST /api/cart/items {"productId":"midnight-vault"} -> total 66600
POST /api/newsletter {"email":"<anything>"} -> {"code":"NEWS30"}
POST /api/cart/coupon {"code":"NIGHT20"} -> count 1, total 53280
POST /api/cart/coupon {"code":"NEWS30"} -> count 1, total 33300
POST /api/cart/coupon {"code":"NIGHT20"} -> count 2, total 19980
POST /api/cart/coupon {"code":"NEWS30"} -> count 2, total 0, canCheckout true
POST /api/checkout {} -> receipt.flag

All responses above were captured live on 21 October 2026 against challenge-1026.challenges.intigriti.io, replayed through Burp Suite Community 2026.9.2 on the store’s own session cookie.