Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ The demo target `lodash@4.17.4` is deliberately old: its prototype pollution is

The pipeline is: discover entry points → coverage-guided fuzzing → differential oracle (clean vs. polluted run; only behaviour the pollution *caused* counts) → reproduction gate → cross-reference against advisory DBs. See [docs/architecture.md](docs/architecture.md) for details.

The fuzzer's built-in property/payload lists are seeds, not detection logic: candidate properties are discovered per target by observing which absent properties the library actually reads, and no verdict depends on a list lookup (see "Seeds vs. mechanism" in the architecture doc, and the proof test `tests/integration/novel-property-detection.test.js`).

A gadget only matters if a prototype-pollution *source* can reach it. Pollution is a global effect, so any function that merges attacker input into an object is an interchangeable source — once a gadget is confirmed, UoPFuzz pairs it with a real, currently-shipping source and reproduces a runnable `attacker-input → source → gadget → sink` PoC:

```javascript
Expand Down
6 changes: 3 additions & 3 deletions benchmark/runtime-gadgets/RESULTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,9 @@ Node 24.17.0 — 2026-08-10
| DETECTED | tls-connect-port | tls.connect | port | second-order-SSRF | sink_reach @ tls.connect (2x) |
| DETECTED | tls-connect-tls-reject | tls.connect | NODE_TLS_REJECT_UNAUTHORIZED | crypto-downgrade | clean "FP:DEPTH_ZERO_SELF_SIGNED_CERT", polluted "FP:HANDSHAKE-OK" |
| DETECTED | https-request-tls-reject | https.request | NODE_TLS_REJECT_UNAUTHORIZED | crypto-downgrade | clean "FP:DEPTH_ZERO_SELF_SIGNED_CERT", polluted "FP:ECONNRESET" |
| MITIGATED-UPSTREAM | import-source | import() | source | ACE | not verified |
| FIXED-UPSTREAM | require-main | require | main | ACE | not verified |
| MITIGATED-UPSTREAM | require-node-options | require | NODE_OPTIONS | ACE | not verified |
| MITIGATED-UPSTREAM | import-source | import() | source | ACE | clean "FP:imported=1", polluted "FP:imported=1" |
| FIXED-UPSTREAM | require-main | require | main | ACE | clean "FP:main=1", polluted "FP:main=1" |
| MITIGATED-UPSTREAM | require-node-options | require | NODE_OPTIONS | ACE | clean "FP:main=1", polluted "FP:main=1" |
| DETECTED | worker-ctor-env | worker_threads.Worker | env | EoP | sink_reach @ worker_threads.Worker (2x) |
| DETECTED | worker-ctor-eval | worker_threads.Worker | eval | ACE | sink_reach @ worker_threads.Worker (2x) |
| DETECTED | worker-ctor-argv | worker_threads.Worker | argv | EoP | sink_reach @ worker_threads.Worker (2x) |
Expand Down
14 changes: 7 additions & 7 deletions benchmark/runtime-gadgets/corpus.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions benchmark/runtime-gadgets/drivers/bhv-import-source.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// Behavioral probe: dynamic import must load the REAL module. The import-source
// gadget (polluted Object.prototype.source redirecting the ESM loader) is dead
// on Node 24, so clean and polluted fingerprints must be identical. The
// polluted value is a data: URL exporting a different value: if the gadget ever
// becomes live again the fingerprint flips to imported=666 and the corpus run
// fails with DETECTED-ANYWAY. Run via behavioral-harness.js (plain process).
exports.run = async () => {
const mod = await import('./plain.mjs');
return `imported=${mod.default}`;
};
12 changes: 12 additions & 0 deletions benchmark/runtime-gadgets/drivers/bhv-require-main.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Behavioral probe: directory require must resolve the REAL package entry
// (sub/package.json → sub/index.js, exports 1). The require-main gadget
// (polluted Object.prototype.main redirecting resolution, fixed in Node
// 18.19.0) would instead resolve sub/evil.js, which exports 2 — the
// fingerprint flips to main=2 and the corpus run fails with DETECTED-ANYWAY.
// Also serves the require + NODE_OPTIONS entry: polluted NODE_OPTIONS has no
// effect on require, so the fingerprint must stay main=1. Run via
// behavioral-harness.js (plain process).
exports.run = () => {
const mod = require('./sub');
return `main=${mod}`;
};
26 changes: 0 additions & 26 deletions benchmark/runtime-gadgets/drivers/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -100,30 +100,6 @@ function probeTlsConnect() {
}
}

// ─── dynamic import source (ACE; fixed in current Node) ──────────────────────
// GHunter PoC: nodejs/import/import.source.PoC.js — polluted `source` is
// evaluated instead of the file contents when importing an .mjs module.
async function probeImport() {
try {
await import('./plain.mjs');
return 'imported';
} catch (e) {
return { error: String(e && e.message || e).slice(0, 120) };
}
}

// ─── require main (ACE; fixed in Node v18.19.0) ──────────────────────────────
// GHunter PoC: nodejs/require/require.main*.PoC.js — polluted `main` redirects
// directory requires to an attacker-chosen entry file.
function probeRequireDir() {
try {
require('./sub');
return 'required';
} catch (e) {
return { error: String(e && e.message || e).slice(0, 120) };
}
}

// ─── worker_threads ctor options (EoP / second-order ACE) ────────────────────
// GHunter PoC: nodejs/working_threads/ctor.PoC.js — polluted env/eval/execArgv
// fall through into the Worker constructor options.
Expand Down Expand Up @@ -310,8 +286,6 @@ module.exports = {
probeFetch,
probeFetchNetwork,
probeTlsConnect,
probeImport,
probeRequireDir,
probeWorkerCtor,
// mining probes
probeHttpListen,
Expand Down
3 changes: 3 additions & 0 deletions benchmark/runtime-gadgets/drivers/sub/evil.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// Decoy entry for the require-main behavioral probe: resolved instead of
// index.js only if the require-main gadget is live on this Node.
module.exports = 2;
22 changes: 22 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,28 @@
- Type coercion exploits
- Async pollution patterns

### Seeds vs. mechanism

The static lists in this repo are fuzzer **seeds**, not detection logic:

| Artifact | File | Role |
|---|---|---|
| `GENERIC_POLLUTION_PROPS`, `PAYLOADS` | `src/input-generation/index.js` | Seed dictionary of property names and payload values (AFL-style; shapes known to be useful across library categories) |
| Known-gadget/CVE DB | `src/gadget-analysis/known-gadgets.js` | Seed priority for known-CVE properties, novelty triage labels, benchmark ground truth |
| `GATE_PROPERTIES` | `src/instrumentation/gate-properties.js` | Dasty-style forced-branch co-pollution |
| Runtime corpus | `benchmark/runtime-gadgets/corpus.js` | Benchmark questions; verdicts are computed by executing every entry |

No verdict depends on these lists. Candidate properties are discovered
target-first: `discoverUOPProperties` (`src/instrumentation/differential.js`)
observes which *absent* properties the library actually reads, and those feed
back into the fuzzer above every static list. Detection is behavioral
(differential oracle + sink-token capture), and confirmation is the
reproduction gate in fresh processes. Executable proof:
`tests/integration/novel-property-detection.test.js` detects and confirms a
gadget whose property appears in no list. The static lists only bias the
search order toward known shapes — a target whose gadget property matches
nothing static is still found whenever its reads are observable.

### 3. Instrumentation (`src/instrumentation/`)
- **Purpose**: Executes inputs with comprehensive tracing
- **Key Features**:
Expand Down
19 changes: 19 additions & 0 deletions tests/fixtures/novel-prop-gadget/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// Hermetic fixture: identical shape to rce-gadget, but the gadget property
// `zqxkvBlorple` is deliberately absent from every static list in the tool —
// GENERIC_POLLUTION_PROPS, PAYLOADS, the known-gadget DB, and GATE_PROPERTIES
// (asserted by tests/integration/novel-property-detection.test.js). It can only
// be found by target-driven UOP discovery: observe that render() reads
// opts.zqxkvBlorple, that the read resolves to undefined, and pollute it.
// This is the executable proof that detection is not list lookup.

function render(opts) {
const options = opts || {};
// Falls through to Object.prototype.zqxkvBlorple when polluted.
if (options.zqxkvBlorple) {
// eslint-disable-next-line no-eval
return eval(options.zqxkvBlorple); // code-execution sink
}
return 'no-op';
}

module.exports = { render };
7 changes: 7 additions & 0 deletions tests/fixtures/novel-prop-gadget/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"name": "novel-prop-gadget",
"version": "1.0.0",
"type": "commonjs",
"main": "index.js",
"private": true
}
67 changes: 67 additions & 0 deletions tests/integration/novel-property-detection.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { test, describe } from 'node:test';
import assert from 'node:assert';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

import { getKnownProperties } from '../../src/gadget-analysis/known-gadgets.js';
import { GATE_PROPERTIES } from '../../src/instrumentation/gate-properties.js';
import { Instrumentation } from '../../src/instrumentation/index.js';
import { reproduceRce } from '../../src/verification/reproduce.js';
import { executeInSandbox } from '../../src/utils/sandbox.js';

/**
* Credibility proof: detection is not list lookup.
*
* The gadget property `zqxkvBlorple` is in NO static list — not the fuzzer's
* seed dictionary (GENERIC_POLLUTION_PROPS / PAYLOADS), not the known-gadget
* CVE DB, not the gate list. The only way the pipeline can find it is the
* target-driven mechanism: taint observation of which absent properties the
* library reads (discover_uop), differential confirmation, and the
* reproduction gate. Each stage is asserted below.
*/

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const FIX = (name) => path.resolve(__dirname, '..', 'fixtures', name);
const NOVEL = 'zqxkvBlorple';

describe('novel-property gadget: no static list knows it', () => {
test('the property is absent from every hardcoded list', () => {
// GENERIC_POLLUTION_PROPS and PAYLOADS are module-private; the seed
// dictionary can only contain the name if it appears in the source text.
const seedSource = fs.readFileSync(
path.resolve(__dirname, '..', '..', 'src', 'input-generation', 'index.js'), 'utf8');
assert.ok(!seedSource.includes(NOVEL), 'seed dictionary must not contain the novel property');
assert.ok(!getKnownProperties().includes(NOVEL), 'known-gadget DB must not contain it');
assert.ok(!GATE_PROPERTIES.includes(NOVEL), 'gate list must not contain it');
});

test('discover_uop finds it from target behaviour, sandboxed', async () => {
const result = await executeInSandbox(FIX('novel-prop-gadget'), 'render', [{}], {
timeoutMs: 3000,
blockNetwork: true,
mode: 'discover_uop',
});
assert.ok(result.uopProperties.includes(NOVEL),
`UOP discovery must observe the read of ${NOVEL}; got: ${(result.uopProperties || []).join(', ')}`);
});

test('the differential oracle detects the gadget once the property is polluted', async () => {
const inst = new Instrumentation({ sandbox: true, blockNetwork: true });
const res = await inst.executeDifferentialTracing(
{ type: 'value', value: {}, entryPoint: 'render' },
{ package: FIX('novel-prop-gadget') },
{ property: NOVEL, value: 'globalThis.__x=1' },
);
assert.ok(res?.diff, 'differential run must surface the gadget');
assert.ok(res.diff.newSinkAccesses.some(s => s.sink === 'eval'), 'the eval sink must have fired');
});

test('the reproduction gate confirms it in 2 fresh processes with a PoC', async () => {
const repro = await reproduceRce(FIX('novel-prop-gadget'), 'render',
{ property: NOVEL }, { blockNetwork: true });
assert.equal(repro.verified, true, 'a gadget no list knows must still reproduce');
assert.equal(repro.runs, 2);
assert.ok(repro.standalonePoC.includes(NOVEL), 'the PoC names the discovered property');
});
});
Loading