The upload door that opened onto everyone's room
Metadata
- Title: The upload door that opened onto everyone's room
- Source of the case: Real HackerOne report #254200 (Unikrn)
- AWS service(s): S3 (presigned POST upload policy)
- Risk archetype: Latent broad write — a syntactically valid upload policy whose key scope spans every tenant instead of one
- One-line hook: Can you prove this upload policy keeps one user out of another user's files?
0. The challenge (what the reader does first)
Scenario given to the reader:
A gaming platform lets users upload files — avatars, clips, attachments — by
handing each authenticated user a presigned POST policy for the
unikrn-user-uploads bucket. The policy is well-formed and unexpired. Its only
key constraint is a starts-with condition on $key. You have the policy below
and nothing else.
Evidence they're handed (and nothing else):
{
"bucket": "unikrn-user-uploads",
"upload_policy": {
"conditions": [["starts-with", "$key", "uploads/"]],
"expiration": "2024-12-31T00:00:00Z"
}
}
- The JSON export above. No AWS credentials. No live account. No scripts.
The questions they must answer from the evidence alone:
- What set of object keys does this policy actually permit a holder to write — is it scoped to one user, or to every key under
uploads/? - No cross-tenant write has happened yet, but the prefix covers all tenants — what is the latent blast radius the moment any user decides to set
$keytouploads/<someone-else>/...? (The exposure is structural; it can be triggered at any time, by any holder, with no further misconfiguration.) - Which path makes this exploitable — the
starts-with $key, "uploads/"condition being broader than a single tenant? - Is there a second constraint anywhere in the policy (a per-user value, a stricter condition) that re-narrows the scope, or is the prefix the only gate?
- What single rule would have forced every issued upload policy to scope
$keyto the requesting user's own prefix?
1. The manual problem
To answer by hand you have to read the policy the way S3 enforces it: the only
thing standing between this user and the whole namespace is starts-with $key, "uploads/". That looks like a restriction — it is a prefix, after all — and
the eye reads "restricted to uploads" and moves on. The trap is that uploads/
is the root of every user's space, not one user's. To see the bug you have to
know the application's intended layout (one subfolder per user under uploads/)
and realize the policy never encodes that layout. Worse, there is nothing
dangerous in the snapshot yet — no malicious object, no cross-tenant write on
record. The vulnerability is purely in what the policy allows, which means
inspecting current state tells you nothing; you have to reason about the
reachable state. There is no clean way, by hand, to prove "every holder of this
policy can already reach every other holder's files."
2. The reasoning wall
| What they hit | What they said / would say |
|---|---|
starts-with uploads/ looks like a scope, so it reads as safe | "There's a prefix condition right there. I saw a constraint and assumed the constraint was per-user." |
| Nothing bad has happened, so nothing flags | "I went looking for a breach. There wasn't one. The policy was just... waiting. How do you flag a thing that hasn't gone wrong yet?" |
| The bug is in reachable state, not current state | "We can audit what's in the bucket. What I actually needed to know is what anyone could put there, and where." |
The insight the reader should reach on their own:
A prefix is only isolation if it is per-tenant; a shared prefix is a latent breach that any holder can trigger at will, and proving it means reasoning about reachable keys, not the keys that exist today.
3. Why scanners miss or flatten it
A per-setting scanner can confirm the upload policy is syntactically valid:
conditions are well-formed, the expiration is in the future, $key is
constrained by a starts-with. Every check passes, because every individual
field is correct. What the scanner cannot do is reason that uploads/ is a
tenant-shared prefix and therefore violates the isolation invariant the
application depends on. To the scanner, starts-with $key, "uploads/" and
starts-with $key, "uploads/${user_id}/" are the same shape — a non-empty
prefix condition — and both look fine. It has no model of "one tenant per
subfolder," so it cannot tell a scope that isolates from a scope that merely
looks scoped. The specific thing it cannot see is that this valid policy lets
any tenant write under any other tenant's path. That is a statement about the
relationship between the prefix and the tenancy model, not about any one field.
Pivot point. Everything above is the gap. Everything below is Stave filling it. The reader has now done the work and hit the wall. Only now does the tool appear.
4. The evidence Stave consumes
The same static export the reader had — no new privileges, no live cloud:
{
"bucket": "unikrn-user-uploads",
"upload_policy": {
"conditions": [["starts-with", "$key", "uploads/"]],
"expiration": "2024-12-31T00:00:00Z"
}
}
Stave normalizes this into an observation snapshot: an upload-policy asset whose key-scope condition and tenancy model are both evaluable facts, so the predicate can compare the granted prefix against the per-tenant boundary the application declares.
5. The reasoning Stave performs
- Control / invariant:
CTL.S3.TENANT.ISOLATION.001— a presigned upload policy must scope$keyto a single tenant's prefix; a tenant-shared prefix is a violation. - What it evaluates: The control reads the
starts-with $keycondition and tests whether the granted prefix is bounded by a per-tenant component (e.g.uploads/${user_id}/). A prefix that stops atuploads/— shared by all tenants — fails, because the policy permits writes across tenant boundaries. It reasons over reachable keys, not stored keys, so it fires even though no cross-tenant write has occurred yet. - Verdict produced: The control fires NON_COMPLIANT on the latent over-broad scope. If the tenancy model is unknown rather than provably per-tenant, the control reports the finding rather than assuming isolation holds.
Issue: unikrn-user-uploads — upload policy prefix is tenant-shared
CTL.S3.TENANT.ISOLATION.001 NON_COMPLIANT
asset: s3://unikrn-user-uploads (presigned upload policy)
evidence: condition starts-with $key "uploads/" is shared by all tenants;
no per-tenant component (expected uploads/${user_id}/)
verdict: any policy holder can write under any other tenant's prefix —
latent cross-tenant write, exploitable at will
security_state: NON_COMPLIANT
6. The prevention artifact Stave produces
- Artifact: A corrected presigned POST policy whose
starts-with $keycondition is scoped to the requesting user's own prefix, issued per-tenant at signing time. - What it forecloses: The latent state from question 2 — any holder setting
$keyto another user's path. Once the condition is bound touploads/${user_id}/, the signed policy mathematically cannot authorize a write outside the requester's own folder, so the cross-tenant write is unreachable rather than merely unobserved.
# Corrected presigned POST policy — per-tenant key scope
{
"expiration": "2024-12-31T00:00:00Z",
"conditions": [
{"bucket": "unikrn-user-uploads"},
["starts-with", "$key", "uploads/${user_id}/"],
["content-length-range", 0, 10485760]
]
}
# ${user_id} is substituted from the authenticated session at signing time,
# so each issued policy admits only that user's own prefix. A request with
# $key = "uploads/<other-user>/evil.png" no longer satisfies the condition
# and S3 rejects the upload.
7. What the team no longer does manually
| Before | After Stave |
|---|---|
| Read each upload policy and mentally compare its prefix against the intended per-user layout | One control proves the prefix is per-tenant or flags it deterministically |
| Try to assess risk by auditing what is already in the bucket | The control reasons over reachable keys, catching the latent breach before any object exists |
| Hope every code path that signs a policy remembered to append the user prefix | A corrected per-tenant policy template makes the scoped prefix the only issuable shape |
Positioning line for this case
Stave proves that
unikrn-user-uploadshands every user an upload policy reaching every other user's prefix, proves the cross-tenant write is reachable today even though none has happened, and emits the per-tenant policy that makes it unreachable.
Reuse checklist
- A reader could attempt section 0 with zero Stave knowledge
- Stave is not named or shown before the pivot point
- Section 2 quotes are real (or honestly plausible), not slogans
- Section 3 names the specific thing per-setting tools can't see
- Section 6 closes the exact latent state raised in section 0, question 2
- The title names the failure, not the product