NNS International Lounge
A hardcoded HMAC key plus one unvalidated field, compounded by a one-time bonus escape hatch.
NNS{1_l0ve_H4CkiNg_1ouNge5_4Nd_gettiN6_4ccess_t0_pl4Ce5_1_5HoUlD_not_r34lly_be_1N} Challenge
A Flask web app (“NNS International Lounge”) gives every new member exactly 4 free lounge visits. Each visit is redeemed by uploading a QR-code “ticket” image to POST /lounge. The flag is only shown once a user’s visit count exceeds the 4-visit allowance (visits_used > VISIT_ALLOWANCE).
Source review (app.py)
- Tickets are HMAC-signed strings of 9 ”/“-joined fields plus a 6-hex-char MAC:
TICKET_SECRET = hashlib.sha256(b"lounge").digest()
def sign(fields):
return hmac.new(TICKET_SECRET, "/".join(fields).encode(),
hashlib.sha256).hexdigest()[:6]
TICKET_SECRET is NOT a random per-deployment secret - it is the SHA-256 digest of the hardcoded literal string “lounge”. Since the source is distributed with the challenge, this “secret” is fully known to us, so we can forge arbitrarily-valued, validly-signed tickets ourselves without ever needing the server to sign anything for us.
- POST /lounge validates an uploaded ticket like this:
fields = decode_pass(read_qr(...)) # HMAC-verified, 9 fields
encoded_remaining = int(fields[-1]) # attacker-controlled value
if fields[:-1] != ticket_fields(user)[:-1]: # first 8 fields (identity:
raise Rejected(...) # LS/issued/expires/member_id/
# name) must match server truth
...
activating_bonus = user["visits_used"] == 0 and encoded_remaining > VISIT_ALLOWANCE
if encoded_remaining != user["remaining_visits"] and not activating_bonus:
raise Rejected("This pass is stale...")
...
user["visits_used"] += 1
user["remaining_visits"] = encoded_remaining - 1
return render_template(..., flag=FLAG if user["visits_used"] > VISIT_ALLOWANCE else None)
The only field NOT cross-checked against server state is the 9th field, “remaining_visits” - it only has to be internally consistent with a valid HMAC. Two bugs compound: a) Since we know TICKET_SECRET, we can set this field to anything and sign it ourselves. b) There’s a one-time “activating_bonus” escape hatch: on a user’s very first visit (visits_used == 0), a ticket claiming MORE than the 4-visit allowance is accepted even though it doesn’t match the real stored remaining_visits (which starts at 4). This is meant to let a real “bonus” ticket override the counter once - but combined with our forged signature, it lets us set the counter to an arbitrary large number on the very first swipe.
Exploit
- Register a normal account (random name/password) and log in (requests.Session keeps the Flask session cookie).
- GET /pass.png once and decode the QR (OpenCV’s built-in
cv2.QRCodeDetector, no native zbar dependency needed) to learn our own real identity fields: LS, issued[:6], issued, expires, member_id, “NNS”, “201”, full name - the first 8 of the 9 signed fields, all of which are server-assigned and must match exactly. - Forge ticket #1: identity fields (from step 2) + remaining_visits=“999”,
signed with hmac_sha256(sha256(b”lounge”), ”/“.join(fields))[:6].
Encode as a QR PNG (python
qrcodelibrary) and POST it to /lounge. Because visits_used == 0 and 999 > 4, the “activating_bonus” branch accepts it: visits_used -> 1, remaining_visits -> 998. - Forge tickets #2-#5 the same way, each time setting remaining_visits to exactly the value the server now holds (998, 997, 996, 995) - trivial since we control the signing key. Each POST decrements the counter by one and increments visits_used.
- On the 5th accepted visit, visits_used (5) > VISIT_ALLOWANCE (4), so the server renders the lounge page with the flag embedded.
Script: exploit.py (uses requests + qrcode + opencv-python-headless to generate/decode the QR images end-to-end against the live instance).
Flag
NNS{1_l0ve_H4CkiNg_1ouNge5_4Nd_gettiN6_4ccess_t0_pl4Ce5_1_5HoUlD_not_r34lly_be_1N}