feat(scan): --prototype-pollution for Node.js deep-merge authz bypass#29
Merged
Conversation
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Ships one focused improvement: a new
--prototype-pollutionmutator 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 Expressqs/parseUrlchains; OWASP Prototype Pollution Prevention cheat sheet).Pivot note
The R32 task brief proposed
--header-injectionor--method-overrideas the next HTTP mutator, with--cache-deceptionalready shipped in R31 and--request-smugglingdeferred. 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-pollutionis the strongest remaining mutator-shaped slot:--mass-assign, which setsis_adminat the top level of the model (server-side BOPLA at the object layer); prototype-pollution setsis_adminon Object.prototype (runtime-wide, every object inherits it). Different vuln class, different fix.privescfinding class.--mass-assign: the polluted field is buried under the vector key (__proto__,constructor.prototype, orprototype) 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
__proto__— the direct CVE-2018-3721 vector.constructor.prototype— bypasses guards that only block the literal__proto__key (every object'sconstructoris a function whose.prototypeIS Object.prototype).prototype— the bare alias used by mongoose / handlebars / some hand-rolled merges.PrivilegedPropertiesset with--mass-assign(admin,is_admin,isAdmin,role,roles,verified) so the same authz-bypass surface is exercised against the prototype layer.Identity == nil), caller's existing top-level body fields preserved verbatim.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 (scanProtoPollutionvar,--prototype-pollutionflag,resetScanFlags,buildRegistrysignature & body)internal/cli/buildregistry_forbidden_test.go— 2 new gating tests + every existingbuildRegistry(…)call updated for the new 18th parameterREADME.md— new "Prototype pollution (--prototype-pollution)" sectionCHANGELOG.md—Unreleased / AddedentryConstraints honoured
-race.DefaultRegistryorder unchanged (mutator registered inert when disabled, the existing not-in-DefaultRegistry contract is enforced by the new test).Test plan
go test ./...— all 13 packages greengo test -race ./...— all 13 packages greengo vet ./...— cleango build ./cmd/possession— binary buildspossession scan --help | grep prototype-pollution— flag exposed with full help text--mass-assignverified), caller-field preservation, non-JSON/array/empty body skip, already-present vector-key skip, determinism, credentials-unchanged, body non-aliasing,Name()stability, not-in-DefaultRegistrycontract, end-to-end gating throughbuildRegistry(wordlist-on AND wordlist-off paths)