F-2 β Arbitrary file read in @tensorflow/tfjs-node.loadBinaryModel via attacker-controlled binary-manifest path (sister sink of F-1)
Authorized security research artifact disclosed via huntr.com's
TensorFlow.js Model Format Vulnerability program.
Source commit 7f5309fef0a47545e34049903dbdae0f97285f7e. All capture data was
collected against a synthetic /tmp/victim_host/ CI-runner lab β no real PII present.
Real impact captured (sanitized)
Sister sink of F-1: 8 / 8 secrets recovered through a separate code path
- Same 8 files as F-1, this time through
weights[*].paths[*](binary-format models) - Confirms F-1 + F-2 must be patched independently β one fix does not cover both
All proof data above was captured against a synthetic CI-runner lab at /tmp/victim_host/ (no real PII present). Full capture: F2_REAL_IMPACT_PROOF_2026-06-11.txt.
Summary
A Node.js service that calls tf.io.fileSystem([pbPath, manifestPath]).load()
or tf.loadGraphModel(url) where url is the two-element form
[pbPath, manifestPath] (used for TF1-style frozen GraphDef + separate JSON
manifest) will read arbitrary files from the host filesystem and return
their bytes through artifacts.weightData. The root cause is that
@tensorflow/tfjs-node's NodeFileSystem.loadBinaryModel passes the
attacker-controlled JSON manifest into the same loadWeights helper as the
JSON-model path β which joins paths[] entries with path.join and reads
the result via fs.readFile, with no containment check for absolute paths
or .. segments.
Root Cause
Lines of Code:
- tfjs-node/src/io/file_system.ts L133-L163 (
loadBinaryModel) - tfjs-node/src/io/file_system.ts L184-L200 (
loadWeightsshared helper)
In tfjs-node/src/io/file_system.ts:133-163:
protected async loadBinaryModel(): Promise<tf.io.ModelArtifacts> {
const topologyPath = this.path[0]; // .pb file
const weightManifestPath = this.path[1]; // JSON manifest
// ... stat checks ...
const modelTopology = await readFile(this.path[0]);
const weightsManifest = JSON.parse(await readFile(this.path[1], 'utf8'));
const [weightSpecs, weightData] =
await this.loadWeights(weightsManifest, this.path[1]); // β attacker JSON
// ...
}
The loadWeights helper (L184-L200) iterates each
weightsManifest[i].paths[j] and calls readFile(join(dirName, path)) β no
containment. Both absolute paths (which discard dirName entirely) and ..
traversals reach readFile.
Why this is NOT a duplicate of F-1: F-1 targets the one-argument entry
point reached from loadJSONModel at L166-L181 β used by tf.loadGraphModel(url)
when url is a single string and by tf.loadLayersModel(url). F-2 targets the
two-argument entry point reached from loadBinaryModel at L133-L163 β used by
tf.loadGraphModel(url) when url is [pbPath, manifestPath] (TF1-style
frozen GraphDef pattern) and by direct callers of
tf.io.fileSystem([pbPath, manifestPath]).load(). Different caller,
different user-facing API. A maintainer patching only one will leave the
other open.
Internal Pre-conditions
- Victim service calls one of:
tf.io.fileSystem([pbPath, manifestPath]).load(),tf.loadGraphModel([pbPath, manifestPath]), on paths where the manifest is attacker-controlled.
- Process uses
@tensorflow/tfjs-node(or@tensorflow/tfjs-node-gpu).
External Pre-conditions
None.
Attack Path
- Attacker writes any valid file (or a 1-byte placeholder) as
model.pband a maliciousweights_manifest.jsonwithpaths: ['../../../../../../etc/hostname'](or an absolute path). - Attacker delivers both to the victim (model registry, upload field, etc.).
- Victim calls
tf.io.fileSystem([pbPath, manifestPath]).load(). loadBinaryModelparses the manifest and passes it toloadWeights, which reads/etc/hostname(or any other file) and stuffs its bytes intoartifacts.weightData.
Impact
Arbitrary file read on the Node.js process β same primitive as F-1, reached
through an independent entry point. Captured proof (F2_REAL_IMPACT_PROOF_2026-06-11.txt):
weightData head: ci-runner-01
(ci-runner-01 = sanitized victim_host's /etc/hostname.)
All chain-attack scenarios from F-1 apply: cloud credentials, K8s service
account tokens, SSH keys, /proc/self/environ, .env files.
PoC
The repository ships a package.json so install is one step. Tested on
Node 22 + @tensorflow/tfjs-node@4.22.0.
git clone https://huggingface.co/martilaio/tfjs-node-loadbinarymodel-path-traversal-poc
cd tfjs-node-loadbinarymodel-path-traversal-poc
npm install # pulls every dep from package.json
node reproduce.js # minimal canary PoC β primitive proven
node reproduce_real_impact.js
Captured signal lands in F2_REAL_IMPACT_PROOF_2026-06-11.txt (sanitized; collected against the
synthetic /tmp/victim_host/ CI-runner lab).
Mitigation
Same shared safeJoinInsideDir helper recommended for F-1 β applied at the
single loadWeights sink closes both F-1 and F-2 simultaneously.
CVSS
CVSS 3.1 7.5 / High β AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:N/A:N (same as F-1).
Bug classification
- CWE-22 (Path Traversal)
- CAPEC-126
Affected versions
@tensorflow/tfjs-node and @tensorflow/tfjs-node-gpu β€ 4.22.0.
Files in this repository
| File | Purpose |
|---|---|
README.md |
this disclosure |
reproduce.js |
minimal PoC for the loadBinaryModel sink (the sister of F-1's loadJSONModel) |
reproduce_real_impact.js |
real-impact PoC β recovers 8 sensitive files byte-perfect from /tmp/victim_host/ |
F2_REAL_IMPACT_PROOF_2026-06-11.txt |
sanitized captured proof from running the real-impact PoC |