| --- |
| 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 | |
|
|
|
|