For the complete documentation index, see llms.txt. This page is also available as Markdown.

intigriti 0726 duplicate key json

Program

Intigriti Challenge 0726

Code

INTIGRITI-W10RNNAA

Asset

https://challenge-0726.intigriti.io (Tier 2)

Component

POST /api/manifests/signPOST /api/publications

Type

Improper Access Control (Generic)

Severity

Medium

Status

Accepted

Created

27/07/2026

Last updated

01/08/2026

Summary

Any registered user can get a valid signed approval for the restricted core namespace and use it to read @core/security-notes v1.0.0, the protected report holding the flag.

The signing pipeline splits the authorization check from the action:

1

Sign the manifest

POST /api/manifests/sign parses the manifest, checks that package.scope belongs to the caller, and signs sha256(raw manifest bytes).

2

Publish the manifest

POST /api/publications verifies that signature, parses the same bytes again, and builds the report from whatever scope it reads.

The two stages handle duplicate JSON keys differently. Give the manifest two top-level "package" keys and the signer takes the first while the publisher takes the last. One unmodified byte string then means "my namespace" to the check and core to the report generator.

The digest itself is solid. I signed an honest manifest, swapped in the core body before publishing, and got back 400 "Publication approval is invalid." Nothing here forges or tampers with a signature. Every check passes for real. The two halves just answer different questions about the same document.

Steps to reproduce

Environment

  • Attacker: freshly registered account touhemi-zx91k4, namespace @touhemi-zx91k4-9b9ffe17. No special role, no elevated permission.

  • Target: @core/security-notes v1.0.0, found via /api/observatory/advisories, /catalog and /references.

  • Tested: 27/07/2026

Step 2 uses hello-world because every new namespace is seeded with compat-sample, hello-world and legacy-adapter.

The approval expires in about 15 minutes, counted down in Manifest Studio. Publish before it lapses, or step 4 fails on expiry instead.

1

Control: asking for the restricted scope directly is refused

Manifest:

Request:

Response: 400 {"error":"Manifest could not be approved."}

2

Build the dual-reading manifest

Two "package" keys, own namespace first and core second. Exact bytes, single line, no whitespace between tokens:

The sha256 of the decoded bytes is 8f94c0a774cfaa922f18039b6a0c56b918e6129c189fb2c605739b3d34808d11. That matches the manifest_sha256 returned in the next step and the digest shown in the screenshot, so you can confirm nothing was altered between signing and publishing:

3

Sign it

Same endpoint as step 1, with the base64 of the manifest above.

The signer read key #1, my own scope, and approved.

4

Publish the identical bytes

Nothing modified between signing and publishing.

Response: 201 {"publication_id":"db72ec7c-35b9-43fb-b71d-e865361b5e8b","status":"ready"}

The digest verifies, then the publisher reads key #2.

5

Read the report

Expected: 400 at step 3. The caller does not own the core scope. Actual: 201, and the restricted report comes back at this step.

The report also renders in the normal UI under Publication history > View. See the attached screenshot.

Flag

Payload

The whole exploit is the two "package" keys:

Full reproduction from a fresh account. Register, then paste this into the DevTools console on https://challenge-0726.intigriti.io:

The digest changes with the namespace, since key #1 carries your own namespace string. A different digest on your reproduction does not indicate a failed run.

Control cases confirming the root cause

Manifest sent to /manifests/sign

Result

plain, own scope

201 (harness works)

plain, scope:"core"

400 (scope check works)

duplicate package, own first, core second

201

duplicate package, core first, own second

400

"core" (unicode-escaped core)

400

own manifest signed, then core body swapped in at publish

400, approval invalid

Rows 3 and 4 are the finding. Same structure, only the key order differs, opposite results. A filter that simply missed core would let both orders through, so only a first-key-wins parser explains this. Row 5 shows the check runs on decoded values rather than raw text. Row 6 shows the digest binding is intact.

Impact

Anyone who registers a free account can read a report from the restricted core namespace. The app clearly means to withhold it, since asking for that scope directly returns a 400. In this instance the report holds the flag, INTIGRITI{019f8700-4613-74fb-923e-781903e4bee9}.

The namespace boundary is what keeps tenants apart. Approvals get issued for one scope and honoured as another, so that boundary fails for any private namespace on the instance, not just core. The scope in the second "package" key is entirely attacker-chosen.

What it takes: one free account, no victim interaction, no elevated role, no race or timing window, two requests.

Registry data is untouched, so this is a confidentiality issue only.

Worth noting for detection. Each publication is created under the attacker's own approval and appears in their own history, so nothing about the traffic looks different from normal use.

Parse the manifest once. Derive both the authorization decision and the report target from that single parsed object, instead of letting two components read the same bytes independently.

Reject manifests with duplicate keys rather than quietly resolving them. Either parse strictly, or canonicalize with RFC 8785 (JCS) and sign the canonical form. RFC 8259 leaves duplicate-key behaviour undefined, so any design that depends on two parsers agreeing about it will break sooner or later.

Defence in depth: re-check scope ownership against the session at /api/publications, so a mis-issued approval on its own is not enough to read another namespace's data.