Halloween Store

A field that only renders in an impossible state, and the coupon guard that remembers exactly one code

Author: Bram Brinkmeier (grantzero) · Event: Intigriti CTF 1026 (October 2026) · Category: Web / Business Logic · static edition

TL;DR. A sweet shop sells a €666 chest and gives you €66 of credit. Two public coupon codes get you 50% off, which is not enough. The "coupon already applied" guard remembers only the last code you used, not the set of codes on your cart, so a code is caught only when you submit it twice in a row. Alternate the two codes and each applies twice. 100% off. No payload: four words typed into the promo box, in the right order. Every widget below runs the real model in your browser — try the controls, don't just read about them.

A Halloween candy shop with a bag, a promo box and a newsletter. The whole challenge reduces to one question: how do you pay €666 with €66?

The idea in plain terms

Picture a shop assistant told "never accept the same voucher twice", who only remembers the last voucher they were handed. Give them voucher A, then B, then A again: the last one they saw was B, so A looks new. Swap back and forth and every voucher is accepted as often as you like. That is the whole bug. The rest of this write-up is how I found it, why my first explanation was wrong, and how I proved the second one.

The shop

Two numbers are put in front of you before you go looking for anything. The header carries a store credit:

The shop header showing €66.00 of store credit
Evidence — the real target. €66.00 of store credit.

And the announcement bar hands you a discount code before you have asked for one:

The announcement banner advertising NIGHT20 for 20% off The Midnight Collection
Evidence — the real target. NIGHT20, 20% off The Midnight Collection.

/api/products sets the price of the whole challenge: five sweets under €15, and one chest at €666.00.

Four of the shop's sweets, all under €15
Evidence — the real target. Four of the shop's sweets; the fifth sweet and the €666 chest sit further down the grid.

midnight-vault is "The Midnight Collection", the only item in its category, the only one NIGHT20 discounts, and the one described as "available until midnight—or until somebody claims it."

app.js confirms where the flag lives, and it is not on a product page:

${receipt.flag ? `<div class="flag-box">…<code>${escapeHtml(receipt.flag)}</code></div>` : ""}

The flag exists only inside a completed order. Checkout is gated server-side on total <= credit. So the whole challenge is: get €666.00 down to €66.00 or less. That is 90.1% off, and the shop is offering me 20%.

The tell

The interesting line in app.js is not in the checkout logic. It is in the cart renderer:

`Promotion ${escapeHtml(promotion.code)} · ${promotion.percent}% off${promotion.count > 1 ? ` × ${promotion.count}` : ""}`

promotion.count > 1. The client is built to render the same promotion more than once, a state the server claims is impossible, because it rejects a coupon that is already applied. A field that only renders in an impossible state is the challenge telling you where to push.

Directly beneath it, the UI volunteers two more facts:

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

Both are true. Both are dead ends, and I took them in order.

Two honest hints, two walls

model — try it

Type a code into the box below. The real lookup trims whitespace and ignores case before comparing, exactly like this one does. Try night20, then NiGhT20, then plain NIGHT20 again. Every spelling of a code submitted right after itself is caught — this hint is honest, and it's a dead end. (Not a general claim about the whole cart — stick to one code at a time here; the rest of this page is about what happens when you don't.)

—

Then removal, the classic accounting bug where a removed code's discount lingers and re-applying double-counts. Use the Remove chip above on a code, then apply it again — the discount resets cleanly both ways. One thing worth keeping from this stage: coupon_already_applied and invalid_coupon are different errors, so the endpoint tells you which stage rejected you. The dedupe stage was reachable and strict. Was it strict about everything?

The arithmetic, and a trap worth admiring

Before hunting further: how do discounts combine? The second public code comes from the newsletter box in the footer, which hands NEWS30 to any address you type. Not a per-subscriber code; I tried three addresses including a repeat and got the same string every time.

The footer newsletter returning the code NEWS30
Evidence — the real target. NEWS30, handed to any address.
arithmetic — try it

This one is the raw pricing formula, not the guarded cart — it lets you set any count of either code to explore the trap table below, unconstrained by the dedup guard the rest of this page is about. How many of each code? Push the steppers and watch where the total lands. The table below the steppers is the trap: it costs this challenge's author nothing to price the chest so that the obvious near-miss falls just short.

NIGHT20 (20%)
0
NEWS30 (30%)
0

Summed discount: 0% · Total: €666.00 · above the €66.00 credit line

total discounttotalclears €66.00?
50% (both codes, legitimately)€333.00no
70%€199.80no
90%€66.60no, by sixty cents
100%€0.00yes

666 is not chosen for the joke alone. If you find a way to duplicate a coupon and reach for the 20% code (the obvious one, the one printed in the banner), you land on 90%, which is €66.60 against €66.00 of credit. The button stays disabled and you conclude duplication was another dead end. You have to duplicate the 30% code. That is a trap built specifically for the player who is almost right, and I walked into the edge of it.

A false victory

So: the guard normalizes case. Does it normalize whitespace? The client trims before sending:

body: JSON.stringify({ code: input.value.trim() }),

That means through the real UI the server has never had to handle an untrimmed code. Exactly the kind of gap worth probing, and reachable the moment you stop using the form.

{"code":" NIGHT20"}   →  ACCEPTED.  count → 2,  discount €466.20
{"code":"NIGHT20 "}   →  coupon_already_applied
{"code":"\tNIGHT20"}  →  coupon_already_applied
{"code":"NIGHT20\n"}  →  coupon_already_applied

One shape out of eight. A single leading space. I had a tidy story (the lookup trims, the guard does not, so the same coupon registers twice) and was ready to write it up.

It was wrong. Not slightly wrong. The mechanism was wrong.

The flaw was in how I tested. I ran " NIGHT20" first, it succeeded, and every later variant was then tested against a cart that already held two entries. A control run against state you have already mutated is not a control. On a genuinely fresh session:

apply NIGHT20     →  ok
apply " NIGHT20"  →  coupon_already_applied

The leading space is trimmed and correctly rejected. It had never been the lever, and I had spent an hour explaining the wrong thing.

It was never the string. It was the order.

The difference between the two sessions was that in the first, I had applied NEWS30 in between.

model — try it

Run the exact comparison that broke my own theory. Both buttons apply NIGHT20 twice; only the middle request differs.

No crafted payload. No whitespace. Two public codes, typed in the right order.

Cart showing NIGHT20 applied twice: 70% off
Evidence — the real target. NIGHT20 × 2, 70% off.

A detail that sharpens it: re-applying an existing code increments its count without moving it to the end of the promotions list. In the full alternation (NIGHT20, NEWS30, NIGHT20, NEWS30), the fourth request re-applies NEWS30, which is still the second entry inserted into that list — insertion order never changes on a repeat apply — and it is accepted anyway. The guard is therefore not reading the list at all. It is a separate single-slot variable that drifts out of sync with the cart it is supposed to protect. Removal re-arms it: delete a coupon and the slot is set to whatever promotion now sits last, which you then cannot immediately re-apply.

Root cause, and checking I actually understand it

The shape that reproduces every observation:

PROMOS = {"NIGHT20": 20, "NEWS30": 30}

def apply_coupon(cart, submitted):
    code = submitted.strip().upper()                  # lookup normalizes: trims + casefolds
    if code not in PROMOS:
        return error("invalid_coupon")
    if code == "NEWS30" and not cart.session.subscribed:  # observed; outside the validated six below
        return error("newsletter_required")

    if cart.last_coupon == code:                      # ← ONE SLOT, not a set
        return error("coupon_already_applied")

    cart.promotions[code].count += 1                  # keyed by code; order unchanged
    cart.last_coupon = code
    recompute(cart)

def recompute(cart):
    percent  = sum(p.percent * p.count for p in cart.promotions.values())
    cart.discount = min(cart.subtotal * percent // 100, cart.subtotal)   # clamped
    cart.total    = cart.subtotal - cart.discount

One wrong comparison. cart.last_coupon == code where it should be code in cart.promotions.

Guessing at server code is cheap, so I wrote the model out as a runnable file (local-repro.py) and ran my six live observations through it. The same model drives every widget on this page — the table below is computed by your own browser, right now, not copied from a log.

model — self-checking
casemodellive (recorded)

Try your own sequence (comma-separated, e.g. NIGHT20,NIGHT20):

—

The same model runs the attack against a broken=False cart, where the single slot is replaced by a set-membership test. The alternation dies at the third request and the total stops at €333.00, comfortably above the credit. That is the entire fix, demonstrated rather than asserted — see the broken-vs-fixed cart below.

model — try it

The guard, side by side with its one-line fix. The yellow box is the only thing the broken cart remembers.

Broken (as shipped)

Last coupon: —

Sum: 0% · Total: €666.00

Fixed (one line)

Checks: {}

Sum: 0% · Total: €666.00

—
Cart with both coupons: 50% off, €333.00
Evidence — the real target, legitimate use: both codes once each, 50%, €333.00.

The full chain

Eight requests from a clean session, the first just opening it. All of it works by typing in the promo box, with no developer tools, no intercepting proxy and no crafted request. These are the real captured responses from the live challenge server, not the model's output — step through them.

evidence — captured from the real target
A minimal script that reproduces it, in full
#!/usr/bin/env python3
import json, urllib.request
B = "https://challenge-1026.challenges.intigriti.io"
op = urllib.request.build_opener(urllib.request.HTTPCookieProcessor())

def call(path, body=None):
    data = json.dumps(body).encode() if body is not None else None
    r = urllib.request.Request(B + path, data=data, method="POST" if data else "GET")
    if data: r.add_header("Content-Type", "application/json")
    try:
        with op.open(r) as resp: return json.load(resp)
    except urllib.error.HTTPError as e: return json.load(e)

call("/api/session")                                            # starts the shopping session
call("/api/newsletter", {"email": "[email protected]"})          # unlocks NEWS30
call("/api/cart/items", {"productId": "midnight-vault"})       # €666.00

for code in ["NIGHT20", "NEWS30", "NIGHT20", "NEWS30"]:        # 20+30+20+30 = 100%
    cart = call("/api/cart/coupon", {"code": code})["cart"]
    print(f"{code:<8} total €{cart['total']/100:,.2f}")

print("FLAG:", call("/api/checkout", {})["receipt"]["flag"])

Four applications is genuinely minimal: you cannot repeat a code consecutively, so three alternating applies cap out at 80%, which is €133.20 and still above the credit.

The order summary: both promotions applied twice, total €0.00, Complete order enabled
Evidence — the real target. Total €0.00. Note the caption sitting directly under the bug it failed to prevent.
The receipt: paid €0.00, store credit untouched, flag delivered
Evidence — the real target. Paid €0.00, credit €66.00 untouched, flag delivered.

The receipt reports "paid": 0 with "credit": 6600. The €666 chest was taken for nothing and the store credit was never even spent.

Impact

An unauthenticated visitor takes any item in the shop for free, and the credit balance survives intact. There is no account, no victim, no user interaction and no tooling: both codes are public (one printed in the site banner, the other handed to anyone who types an email address), and the attack is four words typed into the promo box.

The €66 credit is incidental, not a constraint. Because the summed discount has no ceiling, the same four requests zero out a basket of any size. In a real shop that is uncapped revenue loss through the front door, by customers who need no technical skill to rediscover it. Someone trying their newsletter code twice out of impatience lands on this by accident.

Remediation

  1. Compare against the set, not the last action. code in cart.promotions. Better still, key the check on the resolved promotion and make application idempotent per cart, so order cannot matter at all.
  2. Never keep a second copy of state the cart already holds. The single last_coupon slot exists only to answer a question the promotions list already answers. Two sources of truth drifted apart, and that is the whole bug.
  3. Cap the discount in percentage terms, not just in amount, so a 350% total is rejected instead of silently floored.
  4. Re-price at checkout from the cart's current contents rather than trusting a running total accumulated across requests.

Close

Two lessons, and the first is the one I will carry to real targets.

Read the client for states the server says are impossible. The front end here was written to display × 2 on a coupon, a state the server swears it never produces. Code paths that only make sense in a forbidden state are the cheapest map of where a guard is weaker than it claims, and they are sitting in every bundled JavaScript file you already download during recon.

Second: when a dedupe check fails, ask what the guard remembers, not only how it compares. Deduplication is usually attacked on the value (case, whitespace, Unicode, encoding), and this challenge dangled exactly that bait. The real defect was the scope of the guard's memory: one slot standing in for a set. I only found it because a tidy, well-evidenced, wrong theory collapsed when I re-ran it on a clean session — the "Run the control" widget above replays that exact collapse. The same mistake shows up whenever a check reads a cached summary (a remembered "last value", a stored isAdmin flag) instead of the authoritative record.

Thanks to Globules for building this one. Pricing the chest so the near-miss solution lands sixty cents short is a lovely piece of cruelty, and two honest hints that lead nowhere are better misdirection than any filter would have been.