diff --git a/README.md b/README.md index e10526f..c19cb56 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/benchmark/runtime-gadgets/RESULTS.md b/benchmark/runtime-gadgets/RESULTS.md index 7938285..0a14ca0 100644 --- a/benchmark/runtime-gadgets/RESULTS.md +++ b/benchmark/runtime-gadgets/RESULTS.md @@ -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) | diff --git a/benchmark/runtime-gadgets/corpus.js b/benchmark/runtime-gadgets/corpus.js index 3bdbb45..2ddb32d 100644 --- a/benchmark/runtime-gadgets/corpus.js +++ b/benchmark/runtime-gadgets/corpus.js @@ -103,15 +103,15 @@ export const RUNTIME_GADGETS = [ { id: 'https-request-tls-reject', api: 'https.request', bhvProbe: 'bhv-https-reject', property: 'NODE_TLS_REJECT_UNAUTHORIZED', value: '0', category: 'crypto-downgrade', via: 'behavioral', ghunter: 'KTH README table' }, // ─── module loaders — ACE (both fixed upstream; verified empirically) ────── - // import-source: NOT observable on Node 24 — direct experiment imports the - // real file, the polluted `source` is never evaluated (ESM loader rewrite). - // No documented fix version (Node treats gadgets as out of threat model). - { id: 'import-source', api: 'import()', entryPoint: 'probeImport', property: 'source', category: 'ACE', via: 'repro', expectSink: null, ghunter: 'nodejs/import/import.source.PoC.js', mitigated: 'ESM loader no longer reads prototype source (verified Node 24.17)' }, - { id: 'require-main', api: 'require', entryPoint: 'probeRequireDir', property: 'main', category: 'ACE', via: 'repro', expectSink: null, fixedIn: '18.19.0', ghunter: 'nodejs/require/require.main2.PoC.js' }, + // import-source: NOT observable on Node 24 — behavioral probe imports the + // real module both ways; the polluted `source` is never used (ESM loader + // rewrite). If it ever becomes live, the fingerprint flips to imported=666. + { id: 'import-source', api: 'import()', bhvProbe: 'bhv-import-source', property: 'source', value: 'data:text/javascript,export default 666', category: 'ACE', via: 'behavioral', ghunter: 'nodejs/import/import.source.PoC.js', mitigated: 'ESM loader no longer reads prototype source (verified Node 24.17)' }, + { id: 'require-main', api: 'require', bhvProbe: 'bhv-require-main', property: 'main', value: 'evil.js', category: 'ACE', via: 'behavioral', ghunter: 'nodejs/require/require.main2.PoC.js', fixedIn: '18.19.0' }, // require + NODE_OPTIONS: the ACE required the `main` resolution gadget to // load the attacker's file; with that fixed (18.19.0) the NODE_OPTIONS half - // is inert — verified directly on Node 24.17 (no effect on require). - { id: 'require-node-options', api: 'require', entryPoint: 'probeRequireDir', property: 'NODE_OPTIONS', category: 'ACE', via: 'repro', expectSink: null, ghunter: 'KTH README table', mitigated: 'ACE mechanism was the require-main gadget, fixed in 18.19.0; no effect verified Node 24.17' }, + // is inert — same probe, fingerprint must stay main=1. + { id: 'require-node-options', api: 'require', bhvProbe: 'bhv-require-main', property: 'NODE_OPTIONS', value: '--inspect-brk=127.0.0.1:0', category: 'ACE', via: 'behavioral', ghunter: 'KTH README table', mitigated: 'ACE mechanism was the require-main gadget, fixed in 18.19.0; no effect verified Node 24.17' }, // ─── worker_threads ctor options — EoP / second-order ACE ───────────────── { id: 'worker-ctor-env', api: 'worker_threads.Worker', entryPoint: 'probeWorkerCtor', property: 'env', category: 'EoP', via: 'repro', expectSink: 'worker_threads.Worker', ghunter: 'nodejs/working_threads/ctor.PoC.js' }, diff --git a/benchmark/runtime-gadgets/drivers/bhv-import-source.js b/benchmark/runtime-gadgets/drivers/bhv-import-source.js new file mode 100644 index 0000000..e061d54 --- /dev/null +++ b/benchmark/runtime-gadgets/drivers/bhv-import-source.js @@ -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}`; +}; diff --git a/benchmark/runtime-gadgets/drivers/bhv-require-main.js b/benchmark/runtime-gadgets/drivers/bhv-require-main.js new file mode 100644 index 0000000..d055a46 --- /dev/null +++ b/benchmark/runtime-gadgets/drivers/bhv-require-main.js @@ -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}`; +}; diff --git a/benchmark/runtime-gadgets/drivers/index.js b/benchmark/runtime-gadgets/drivers/index.js index 0da84fa..2700ac9 100644 --- a/benchmark/runtime-gadgets/drivers/index.js +++ b/benchmark/runtime-gadgets/drivers/index.js @@ -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. @@ -310,8 +286,6 @@ module.exports = { probeFetch, probeFetchNetwork, probeTlsConnect, - probeImport, - probeRequireDir, probeWorkerCtor, // mining probes probeHttpListen, diff --git a/benchmark/runtime-gadgets/drivers/sub/evil.js b/benchmark/runtime-gadgets/drivers/sub/evil.js new file mode 100644 index 0000000..dfbc6e6 --- /dev/null +++ b/benchmark/runtime-gadgets/drivers/sub/evil.js @@ -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; diff --git a/docs/architecture.md b/docs/architecture.md index b589d85..5639ca7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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**: diff --git a/tests/fixtures/novel-prop-gadget/index.js b/tests/fixtures/novel-prop-gadget/index.js new file mode 100644 index 0000000..5073cb3 --- /dev/null +++ b/tests/fixtures/novel-prop-gadget/index.js @@ -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 }; diff --git a/tests/fixtures/novel-prop-gadget/package.json b/tests/fixtures/novel-prop-gadget/package.json new file mode 100644 index 0000000..c70a78f --- /dev/null +++ b/tests/fixtures/novel-prop-gadget/package.json @@ -0,0 +1,7 @@ +{ + "name": "novel-prop-gadget", + "version": "1.0.0", + "type": "commonjs", + "main": "index.js", + "private": true +} diff --git a/tests/integration/novel-property-detection.test.js b/tests/integration/novel-property-detection.test.js new file mode 100644 index 0000000..bcbb8da --- /dev/null +++ b/tests/integration/novel-property-detection.test.js @@ -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'); + }); +});