Challenge Information
Challenge: Canonically Yours | Registry Observatory
Platform: Intigriti
Author: 0×2458
Vulnerability: Duplicate JSON keys / inconsistent parsing across security-sensitive stages
Impact: Unauthorized access to a protected package through the publication pipeline
First look at the challenge
I opened the challenge page started and looking around the application.
There was a Registration Form, so I went ahead and Registerd myself.
After registering, I reached a small dashboard. Each account was assigned a private package namespace, something along the lines of:
username-<suffix>
The main functionality revolved around package manifests and a feature called Manifest Studio.
The general flow looked like:
Create manifest
↓
Preview
↓
Request approval
↓
Run preflight / publication
↓
View report
The challenge also made it clear that a protected report contained the flag.
So rather than trying to attack the infrastructure itself, I focused on understanding how the application decided which package I was allowed to operate on.
Mapping the API
I opened Caido and watched the requests made by the frontend.
The authentication mechanism was fairly straightforward:
Register / Login
↓
cy_session cookie
↓
GET /api/me
↓
user information + csrf_token
State-changing requests required the CSRF token in:
x-csrf-token: <token>
I identified these endpoints for the manifest workflow.
POST /api/manifests/preview
POST /api/manifests/sign
POST /api/publications
GET /api/publications/:id
Conceptually, the application was doing something like:
manifest JSON
│
▼
base64-encode it
│
▼
/api/manifests/sign
│
▼
approval + signature
│
▼
/api/publications
│
▼
final report
After sometime of clicking things and looking at other endpoints, I started rechecking the manifest flow, after sometime I question popped-
If the server signs the manifest first and acts on it later, is the exact same interpretation of the manifest used at every stage?
Establishing a normal baseline
Before looking for anything unusual, I ran a completely normal preflight against a package in my own namespace.
For example:
{
"package": {
"scope": "my-namespace",
"name": "hello-world",
"version": "1.0.0"
},
"metadata": {
"description": "x",
"visibility": "private"
},
"operation": "preflight"
}
The normal flow worked.
The report was generated for my own package and contained ordinary release information.
Finding the interesting package
I decided to check the Observatory section One of the records mentioned a package/component that had been moved into a platform-maintained namespace. The important scope was:
core
The record also pointed toward:
security-notes
and described it as restricted. Thit gave me a very obvious target to investigate.
I tried requesting it directly:
GET /api/packages/core/security-notes
The server responded with:
{
"error": "System package details are restricted."
}
So now I knew that:
- the package existed,
- the application knew about it,
- but normal users were explicitly prevented from accessing it.
That made core/security-notes worth keeping in mind.
Trying the obvious attack
My first thought was simply to create a manifest targeting the restricted package:
{
"package": {
"scope": "core",
"name": "security-notes",
"version": "1.0.0"
},
"metadata": {
"description": "x",
"visibility": "private"
},
"operation": "preflight"
}
I sent this through the normal preview/signing workflow hoping to see something good, But It failed. The server returned generic preview/approval errors. I tried a few common ways of getting around the scope check:
- Unicode lookalikes for
core
- leading/trailing spaces
- case variations
- path traversal-style values such as
my-ns/../core
- null bytes
- random publication UUIDs
- prototype-pollution-style JSON fields such as
__proto__
constructor
None of these produced anything useful. The direct request to the protected package remained blocked.
At this point I started looking less at the value of scope itself and more at how the application represented and interpreted the manifest. After some tries and a bit of research a really interesting bug came to my mind: JSON serialization
The application wasn’t just receiving a normal object from the browser. The frontend was building a JSON document, encoding the raw JSON string as base64, and sending that blob to the backend.
JSON objects are supposed to have unique member names, but parsers do not necessarily enforce this consistently.
For example:
{
"package": {
"scope": "first"
},
"package": {
"scope": "second"
}
}
There are two package keys. Different JSON parsers or processing layers can disagree about what this means.
A common outcome is:
Parser A → first occurrence wins
Parser B → last occurrence wins
The exact behavior depends on the implementation. and that sounded promising because the application had multiple stages processing the same manifest:
preview / approval
↓
publication
↓
report generation
If those stages disagreed about duplicate keys, I could potentially get:
Authorization sees A
Execution sees B
First duplicate-key experiment
I first tried putting duplicate keys inside the package object:
{
"package": {
"scope": "my-namespace",
"name": "hello-world",
"version": "1.0.0",
"scope": "core",
"name": "security-notes"
}
}
That didn’t work. The application rejected it. So I changed the approach:
Instead of duplicating fields inside the package object, I duplicated the entire top-level package key.
The “PAYLOAD”
I constructed this JSON manually:
{
"package": {
"scope": "my-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"
}
There is one important detail here:
I kept this as a raw string.
I did not do:
JSON.parse(manifest)
followed by:
JSON.stringify(...)
because parsing and serializing it would collapse the duplicate keys into a single representation. The whole point was to preserve the exact bytes:
package = my namespace
package = core
I then base64-encoded that raw JSON string.
The approval unexpectedly worked
I sent the base64-encoded manifest to:
POST /api/manifests/sign
The server returned a successful approval containing values such as:
approval_id
manifest_sha256
nonce
expires_at
signature
The manifest contained a core package, yet approval succeeded. The behavior strongly suggested that the approval path was interpreting the duplicate keys using the first occurrence:
FIRST package
↓
my-namespace/hello-world
↓
my package
↓
authorization passes
So from the approval layer’s point of view, I was still requesting something I was allowed to access.
Reusing the exact same signed manifest
For the next step, I didn’t modify the manifest after signing. I reused the exact same manifest_b64 together with all the approval values:
manifest_b64
approval_id
manifest_sha256
nonce
expires_at
signature
and submitted them to:
POST /api/publications
The publication was accepted.
The response indicated that the publication was ready.
I then fetched the resulting report:
GET /api/publications/<publication_id>
And this time the target was:
@core/security-notes
The exact same raw document that had been approved as:
my-namespace/hello-world
was being used later as:
core/security-notes
Getting the flag
The report’s release notes contained:
INTIGRITI{019f8700-4613-74fb-923e-781903e4bee9}
Success!!!
What actually happened?
The easiest way to understand the vulnerability is to follow the same manifest through the application.
Our raw JSON was:
{
"package": {
"scope": "my-namespace",
"name": "hello-world",
"version": "1.0.0"
},
"package": {
"scope": "core",
"name": "security-notes",
"version": "1.0.0"
}
}
Conceptually, the application behaved like this:
SAME RAW JSON
│
▼
┌──────────────────┐
│ Approval / Sign │
└────────┬─────────┘
│
first "package"
│
▼
my-namespace/hello-world
│
▼
AUTHORIZED
│
▼
signature issued
│
│
│ exact same
│ manifest_b64
▼
┌──────────────────┐
│ Publication │
└────────┬─────────┘
│
last "package"
│
▼
core/security-notes
│
▼
PROTECTED PACKAGE
│
▼
FLAG
Why the signature didn’t save the application
At first glance, this can sound like a cryptographic attack. It wasn’t. The signature was doing exactly what it was supposed to do. The application signed the raw manifest bytes. Later, those same bytes were presented again. The hash and signature still matched. The problem was what the bytes were interpreted to mean.
Think of it this way:
Signature says:
"I approve these exact bytes."
But the security decision effectively became:
Approval stage:
"These bytes mean package A."
Publication stage:
"These bytes mean package B."
The signature cannot protect against that kind of semantic disagreement. The cryptography can prove:
same bytes
It cannot automatically prove:
same interpretation
That is the core of the vulnerability.
Why duplicate keys are dangerous
Duplicate JSON keys are a classic source of parser inconsistencies.
Consider:
{
"role": "user",
"role": "admin"
}
What should the application do?
There isn’t a single universally enforced behavior across all JSON-processing implementations. One layer might effectively produce:
role = user
while another produces:
role = admin
This becomes especially dangerous when the document crosses trust boundaries or processing layers.
For example:
Parser A
↓
Authorization
↓
"Looks safe"
Parser B
↓
Execution
↓
"Do something dangerous"
The vulnerability is therefore not simply:
“JSON allows duplicate keys.”
The real security issue is:
Two security-sensitive consumers interpret the same ambiguous document differently.
Why I couldn’t just use JSON.parse()
This was also an important part of the exploit. If I created the malicious document as a JavaScript object:
const manifest = {
package: {
scope: "my-namespace",
name: "hello-world"
},
package: {
scope: "core",
name: "security-notes"
}
};
JavaScript would not preserve both keys as two independent properties.
Likewise, doing:
JSON.parse(raw)
and then:
JSON.stringify(parsed)
would normalize the document.
The duplicate-key structure would disappear.
So the attack required preserving the original serialized representation:
raw JSON
↓
base64
↓
server
rather than:
raw JSON
↓
parse
↓
object
↓
stringify
↓
base64
That is why manually constructing the raw string was important.
Reproduction
Once the issue was understood, the complete attack could be reproduced. The essential sequence is:
1. Get my namespace + CSRF token
2. Build raw JSON containing duplicate package keys
3. Base64 the raw string
4. Ask the server to sign it
5. Reuse the exact same base64 string
6. Submit the approval data to /api/publications
7. Fetch the publication report
The important payload is simply:
{
"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"
}
The first package is deliberately safe:
YOUR_NAMESPACE/hello-world
The second package is the protected target:
core/security-notes
The exploit relies on the approval and publication stages disagreeing about which one is authoritative.
Putting everything together:
Find restricted package
│
▼
core/security-notes
│
▼
Direct access → 403
│
▼
Normal manifest targeting core → rejected
│
▼
Notice "Canonically Yours"
│
▼
Try duplicate top-level package keys
│
▼
First package = my namespace
Second package = core/security-notes
│
▼
Approval sees first package
│
▼
Authorization passes
│
▼
Raw manifest is signed
│
▼
Reuse EXACT same manifest_b64
│
▼
Publication sees last package
│
▼
Report generated for core/security-notes
│
▼
Flag
And the flag was:
INTIGRITI{019f8700-4613-74fb-923e-781903e4bee9}
21. The main lesson
The most important takeaway for me was:
Don’t confuse “the bytes are authentic” with “the meaning of the bytes is unambiguous.”
A valid signature only proves that the signed bytes haven’t changed. If one part of the application interprets those bytes as:
my-namespace/hello-world
and another interprets them as:
core/security-notes
Closing
I initially expected the challenge to involve some client-side trickery, but the interesting part turned out to be a clean server-side trust-boundary problem.
Huge thanks to zerodaysbooks for the challenge and to Intigriti for another great monthly CTF.
Write-up by 0×2458