dukesp69's picture
Triager-ergonomics: ship package.json + one-line npm install; updated PoC sections
2bd7e3b verified
|
Raw
History Blame Contribute Delete
6.38 kB
---
license: other
license_name: huntr-security-research-poc
tags:
- security
- poc
- tensorflow.js
- tfjs-node
- path-traversal
- cwe-22
- arbitrary-file-read
---
# 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](https://huntr.com/bounties/disclose/models?target=tensorflow.js).
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`](./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`)](https://github.com/tensorflow/tfjs/blob/7f5309fef0a47545e34049903dbdae0f97285f7e/tfjs-node/src/io/file_system.ts#L133-L163)
- [tfjs-node/src/io/file_system.ts L184-L200 (`loadWeights` shared helper)](https://github.com/tensorflow/tfjs/blob/7f5309fef0a47545e34049903dbdae0f97285f7e/tfjs-node/src/io/file_system.ts#L184-L200)
In `tfjs-node/src/io/file_system.ts:133-163`:
```ts
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
1. Victim service calls one of:
- `tf.io.fileSystem([pbPath, manifestPath]).load()`,
- `tf.loadGraphModel([pbPath, manifestPath])`,
on paths where the manifest is attacker-controlled.
2. Process uses `@tensorflow/tfjs-node` (or `@tensorflow/tfjs-node-gpu`).
## External Pre-conditions
None.
## Attack Path
1. Attacker writes any valid file (or a 1-byte placeholder) as `model.pb` and
a malicious `weights_manifest.json` with
`paths: ['../../../../../../etc/hostname']` (or an absolute path).
2. Attacker delivers both to the victim (model registry, upload field, etc.).
3. Victim calls `tf.io.fileSystem([pbPath, manifestPath]).load()`.
4. `loadBinaryModel` parses the manifest and passes it to `loadWeights`,
which reads `/etc/hostname` (or any other file) and stuffs its bytes into
`artifacts.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`):
```text
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`.
```bash
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 |