Skip to content

Commit 0b73a33

Browse files
committed
test: add FFI permission-bypass PoC
Adds test/ffi/exploit-poc.js that documents the DynamicLibrary instance-method permission gap closed in src/node_ffi.cc. The PoC opens libc, simulates a handle leak via globalThis, and from a separate "untrusted" function exercises every instance method (getSymbol, getFunction, native invocation, register/ref/unref/ unregister callbacks, close) that previously ran without a THROW_IF_INSUFFICIENT_PERMISSIONS check. The file documents the realistic threat model (handle leakage, embedder contexts, --permission-audit coverage) and explicitly notes that Node.js's process-wide permission model prevents this from being a userland-exploitable RCE on its own.
1 parent f4eb9bd commit 0b73a33

1 file changed

Lines changed: 129 additions & 0 deletions

File tree

test/ffi/exploit-poc.js

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
// Flags: --permission --experimental-ffi --allow-ffi --allow-fs-read=*
2+
//
3+
// Proof-of-concept: FFI DynamicLibrary instance-method permission gap
4+
// ==================================================================
5+
//
6+
// SCOPE:
7+
// This PoC demonstrates the defense-in-depth gap closed by the fix in
8+
// src/node_ffi.cc. Before the fix, every instance method of
9+
// DynamicLibrary (InvokeFunction, GetFunction, GetSymbol, etc.) skipped
10+
// the THROW_IF_INSUFFICIENT_PERMISSIONS check that DynamicLibrary::New
11+
// and the raw helpers in src/ffi/data.cc already had.
12+
//
13+
// HONEST THREAT MODEL:
14+
// Node's permission model is process-wide and fixed at startup. With
15+
// `--permission` but no `--allow-ffi`, the constructor throws, so
16+
// userland JS cannot normally obtain a DynamicLibrary handle. In that
17+
// sense, the missing checks are NOT a directly userland-exploitable RCE.
18+
//
19+
// The gap matters in the following realistic cases:
20+
// 1. Handle leakage from trusted into untrusted code within the same
21+
// process (modules with different trust levels; post-compromise
22+
// lateral movement; prototype pollution that exposes a handle
23+
// stashed on a shared prototype or on `globalThis`).
24+
// 2. Embedders (Electron, CLIs that run user scripts) that open a
25+
// library in a privileged bootstrap phase and later run user code
26+
// with a stricter permission posture.
27+
// 3. `--permission-audit` coverage: the diagnostics channel
28+
// `node:permission-model:ffi` receives an event on every denied
29+
// check. Pre-fix, instance-method calls silently skipped the
30+
// check, so auditors saw no event for them, producing an incomplete
31+
// audit trail.
32+
//
33+
// WHAT THIS SCRIPT DOES:
34+
// 1. Opens libc legitimately (requires --allow-ffi).
35+
// 2. Stashes the handle on globalThis to simulate handle leakage.
36+
// 3. Drops into "untrusted" code that pulls the handle from globalThis
37+
// and exercises every instance method.
38+
// 4. Observes whether each call hits the FFI permission check by
39+
// watching the `node:permission-model:ffi` diagnostics channel.
40+
//
41+
// To see the difference, flip `is_granted` for kFFI to `false` locally
42+
// (e.g. by patching FFIPermission::is_granted to `return false;` in a
43+
// debug build). With the fix applied, every instance method below
44+
// will throw ERR_ACCESS_DENIED; without the fix, only the constructor
45+
// throws and all subsequent instance calls run unchecked.
46+
//
47+
'use strict';
48+
49+
const assert = require('node:assert');
50+
const dc = require('node:diagnostics_channel');
51+
const common = require('../common');
52+
53+
common.skipIfFFIMissing();
54+
55+
const ffi = require('node:ffi');
56+
57+
const events = [];
58+
dc.subscribe('node:permission-model:ffi', (msg) => {
59+
events.push({ permission: msg.permission, resource: msg.resource });
60+
});
61+
62+
// Pick a platform-appropriate libc path.
63+
let libcPath;
64+
switch (process.platform) {
65+
case 'linux': libcPath = 'libc.so.6'; break;
66+
case 'darwin': libcPath = '/usr/lib/libSystem.dylib'; break;
67+
case 'win32': libcPath = 'msvcrt.dll'; break;
68+
default: libcPath = 'libc.so.6'; break;
69+
}
70+
71+
// ---- "Trusted" bootstrap: opens the library with FFI permission ------------
72+
function trustedBootstrap() {
73+
const lib = new ffi.DynamicLibrary(libcPath);
74+
// Intentionally leak the handle to simulate a shared-state escape.
75+
globalThis.__LEAKED_FFI_HANDLE__ = lib;
76+
return lib;
77+
}
78+
79+
// ---- "Untrusted" attacker: reuses the leaked handle ------------------------
80+
function untrustedAttacker() {
81+
const lib = globalThis.__LEAKED_FFI_HANDLE__;
82+
assert.ok(lib, 'no leaked handle');
83+
84+
// Each of the instance-method calls below lacked THROW_IF_INSUFFICIENT_PERMISSIONS
85+
// before the fix. After the fix, they all route through the FFI permission
86+
// scope and will be surfaced on the diagnostics channel on denial.
87+
88+
// 1. Symbol resolution (returns a raw native address as a BigInt).
89+
const environPtr = lib.getSymbol('environ');
90+
console.log('getSymbol("environ") ->', environPtr.toString(16));
91+
92+
// 2. Build callable FFI wrappers for libc functions.
93+
const getpid = lib.getFunction('getpid', { returns: 'int32', arguments: [] });
94+
const getenv = lib.getFunction('getenv',
95+
{ returns: 'string', arguments: ['string'] });
96+
97+
// 3. Invoke native code (the critical call: InvokeFunction had no check).
98+
console.log('native getpid() =>', getpid());
99+
console.log('native getenv("PATH") =>', (getenv('PATH') || '').slice(0, 60));
100+
101+
// 4. Registering a callback allocates an executable trampoline.
102+
const cbAddr = lib.registerCallback(
103+
{ returns: 'int32', arguments: ['int32'] },
104+
(x) => x + 1,
105+
);
106+
console.log('registerCallback trampoline =>', cbAddr.toString(16));
107+
108+
// 5. Callback refcount management.
109+
lib.refCallback(cbAddr);
110+
lib.unrefCallback(cbAddr);
111+
lib.unregisterCallback(cbAddr);
112+
113+
// 6. Close is also an instance method; pre-fix it was unchecked and could
114+
// invalidate callbacks retained by foreign code still on the call stack.
115+
lib.close();
116+
}
117+
118+
trustedBootstrap();
119+
untrustedAttacker();
120+
121+
console.log('\nDiagnostics-channel events observed:', events.length);
122+
console.log(events);
123+
124+
// When run with `--permission --experimental-ffi --allow-ffi`, all calls are
125+
// granted and the channel stays silent (events.length === 0). To see the
126+
// difference made by the fix, rebuild with the FFIPermission::is_granted
127+
// hard-coded to return false for kFFI: post-fix every instance method above
128+
// throws with code 'ERR_ACCESS_DENIED' and each denial publishes an event
129+
// (events.length >= 9). Pre-fix, only the constructor publishes.

0 commit comments

Comments
 (0)