Skip to content

feat(scan): --prototype-pollution for Node.js deep-merge authz bypass#29

Merged
bugsyhewitt merged 1 commit into
mainfrom
worker-r32-possession
May 30, 2026
Merged

feat(scan): --prototype-pollution for Node.js deep-merge authz bypass#29
bugsyhewitt merged 1 commit into
mainfrom
worker-r32-possession

Conversation

@bugsyhewitt

Copy link
Copy Markdown
Owner

Summary

Ships one focused improvement: a new --prototype-pollution mutator targeting the canonical Node.js / browser prototype-pollution authz-bypass class (CVE-2018-3721 lodash, CVE-2019-10744 lodash, CVE-2019-11358 jQuery, the 2024 Express qs/parseUrl chains; OWASP Prototype Pollution Prevention cheat sheet).

Pivot note

The R32 task brief proposed --header-injection or --method-override as the next HTTP mutator, with --cache-deception already shipped in R31 and --request-smuggling deferred. Verification against the actual codebase showed that both --header-injection (PR #24) and --method-override (PR #21) have already shipped, along with every other HTTP mutator suggested in POST_V01.md (POST_V01.md was stale — the brief explicitly told me to verify). The shipped set is: swap-object, enumerate-id, mass-assign, xxe, graphql, forbidden-bypass, ws-hijack, csrf-header, method-override, host-header, cookie-tampering, header-injection, parameter-pollution, origin-spoof, content-type-confusion, cache-deception — plus the four deep-JWT mutators and the four classic ones from v0.1.

I pivoted to the next-best unimplemented gap. --prototype-pollution is the strongest remaining mutator-shaped slot:

  • High-signal authz-bypass class possession does not currently touch — explicitly distinct from --mass-assign, which sets is_admin at the top level of the model (server-side BOPLA at the object layer); prototype-pollution sets is_admin on Object.prototype (runtime-wide, every object inherits it). Different vuln class, different fix.
  • The Node.js ecosystem has been re-discovering this class for 8 years (lodash 2018, jQuery 2019, Express 2024) and the Snyk 2025 advisory landscape ranks it as a dominant access-control bypass vector for the runtime.
  • Fits the existing mutator architecture exactly: pure, deterministic, off-by-default, JSON-object body mutator, privesc finding class.
  • Disjoint from --mass-assign: the polluted field is buried under the vector key (__proto__, constructor.prototype, or prototype) and the test suite verifies it does NOT leak to the top level. A server that blocks one will not necessarily block the other.

What ships

  • Three vectors per privileged property, full cross-product, deterministic sorted order:
    • __proto__ — the direct CVE-2018-3721 vector.
    • constructor.prototype — bypasses guards that only block the literal __proto__ key (every object's constructor is a function whose .prototype IS Object.prototype).
    • prototype — the bare alias used by mongoose / handlebars / some hand-rolled merges.
  • Shares the canonical PrivilegedProperties set with --mass-assign (admin, is_admin, isAdmin, role, roles, verified) so the same authz-bypass surface is exercised against the prototype layer.
  • Caller credentials unchanged (Identity == nil), caller's existing top-level body fields preserved verbatim.
  • Arrays, scalars, non-JSON bodies skipped. Already-present vector keys skipped per-vector.
  • Off by default — process-wide effect persists across every concurrent user until the runtime restarts. Mirrors the gating of every other write-shaped mutator.

Files

  • internal/mutate/prototype_pollution.go (new, ~210 lines incl. docstring + 3-vector table + Generate + injectPollution helper)
  • internal/mutate/prototype_pollution_test.go (new, 13 tests)
  • internal/cli/scan.go — flag wiring (scanProtoPollution var, --prototype-pollution flag, resetScanFlags, buildRegistry signature & body)
  • internal/cli/buildregistry_forbidden_test.go — 2 new gating tests + every existing buildRegistry(…) call updated for the new 18th parameter
  • README.md — new "Prototype pollution (--prototype-pollution)" section
  • CHANGELOG.mdUnreleased / Added entry

Constraints honoured

  • One improvement — single new mutator, no refactors, no sweeps.
  • Tests updated — 13 new tests; full suite passes -race.
  • README updated — new dedicated section.
  • Architecture preservedDefaultRegistry order unchanged (mutator registered inert when disabled, the existing not-in-DefaultRegistry contract is enforced by the new test).
  • v0.1 criteria preserved — no changes to any existing detection / replay / parse code path; the mutator is purely additive and off-by-default.

Test plan

  • go test ./... — all 13 packages green
  • go test -race ./... — all 13 packages green
  • go vet ./... — clean
  • go build ./cmd/possession — binary builds
  • possession scan --help | grep prototype-pollution — flag exposed with full help text
  • 13 new unit tests cover: off-by-default, full cross-product cell coverage, per-vector body-shape invariants (with disjointness vs --mass-assign verified), caller-field preservation, non-JSON/array/empty body skip, already-present vector-key skip, determinism, credentials-unchanged, body non-aliasing, Name() stability, not-in-DefaultRegistry contract, end-to-end gating through buildRegistry (wordlist-on AND wordlist-off paths)

…to__/constructor.prototype/prototype for Node.js deep-merge authz bypass

New mutator in the privilege-escalation family targeting the canonical
Node.js / browser prototype-pollution authz-bypass class (CVE-2018-3721
lodash, CVE-2019-10744 lodash, CVE-2019-11358 jQuery, the 2024 Express
qs/parseUrl chains; OWASP Prototype Pollution Prevention cheat sheet).

Where --mass-assign attacks which top-level properties the model binds
(server-side BOPLA at the object layer — set is_admin on the model
instance), --prototype-pollution attacks which properties every object
in the JavaScript runtime inherits (set is_admin on Object.prototype so
every object answers true for it) — a distinct authz-bypass class with
a distinct fix. A backend that deep-merges attacker-controlled JSON
(lodash _.merge, $.extend(true, …), defaultsDeep, hand-rolled recursive
Object.assign) walks past the __proto__ / constructor / prototype keys
it should guard and writes onto Object.prototype; every subsequent
object the process creates inherits the polluted property and a
downstream authz check that reads req.user.is_admin finds true even
though the request never legitimately granted it.

For each privileged property in the canonical PrivilegedProperties set
(shared with --mass-assign — admin, is_admin, isAdmin, role, roles,
verified) and each of the three pollution vectors a separate variant is
emitted in deterministic sorted-by-field then sorted-by-vector order:
__proto__ (direct CVE-2018-3721 vector); constructor.prototype
(bypasses guards that block only the literal __proto__); and prototype
(the bare alias used by mongoose / handlebars / some hand-rolled
merges). Every variant keeps the caller's own credentials and preserves
the caller's existing top-level body fields verbatim; the pollution
payload is added alongside them, never replaces. Arrays, scalars, and
non-JSON bodies emit no variants. If the caller's own body already
contains a top-level __proto__/constructor/prototype key, that specific
vector is skipped for that input. Pure and deterministic.

Off by default — the polluted JSON reaches deep-merge code paths whose
effect is process-wide (the entire Node.js process answers the polluted
property thereafter, including for every concurrent user, until the
runtime restarts) — so it only fires when the operator opts in,
mirroring the gating of --mass-assign (its top-level counterpart) and
the rest of the off-by-default mutator family. Findings are class
'privesc'.

Wired through buildRegistry; the mutator is always registered (inert
when disabled) so the canonical DefaultRegistry order and the order
test stay unchanged.

Coverage: 13 new tests across internal/mutate/prototype_pollution_test.go
and internal/cli/buildregistry_forbidden_test.go —
disabled-by-default contract, full cross-product cell coverage,
per-vector body-shape invariants (with disjointness vs --mass-assign
verified), caller-field preservation, non-JSON/array/empty skip,
already-present vector-key skip, determinism (field-outer/vector-inner
monotonic sweep), credentials-unchanged contract, body non-aliasing,
Name() stability, not-in-DefaultRegistry contract, and end-to-end
gating through buildRegistry on both the wordlist-on and wordlist-off
construction paths.

README: new 'Prototype pollution (--prototype-pollution)' section.
CHANGELOG: Unreleased / Added entry.
@bugsyhewitt bugsyhewitt merged commit a9fabc0 into main May 30, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant