dukesp69's picture
Triager-pass fixes: PoC harness corrections + README dead-link cleanup
571d21d verified
|
Raw
History Blame Contribute Delete
9.6 kB
---
license: other
license_name: huntr-security-research-poc
tags:
- security
- poc
- tensorflow.js
- tensorflowjs-converter
- path-traversal
- cwe-22
- ci-secret-exfil
---
# F-5 β€” Arbitrary file read in Python `tensorflowjs.read_weights` via attacker-controlled manifest `paths[]` β†’ CI secret exfiltration through converter output `.h5`
**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)
**4 / 4 CI secrets recovered via Python `read_weights()`, then exfilled into converter output `.h5`**
- `.npmrc` (91 B), `.docker/config.json` (264 B), `~/.ssh/id_rsa` (269 B), `.env` (274 B)
- Both attack forms work: (a) absolute-path injection (`os.path.join` discards base), (b) `..` segments (no canonicalisation)
- Recovered bytes land inside the converter's output `.h5` weight dataset β†’ leaked when CI publishes the artifact
All proof data above was captured against a synthetic CI-runner lab at `/tmp/victim_host/` (no real PII present). Full capture: [`F5_REAL_IMPACT_PROOF_2026-06-11.txt`](./F5_REAL_IMPACT_PROOF_2026-06-11.txt).
---
## Summary
A CI/CD pipeline running `tensorflowjs_converter --input_format=tfjs_layers_model`
on an attacker-supplied PR or commit (a common pattern for any project that
auto-converts tfjs model artefacts as part of CI) will **leak its runner
secrets into the converter's output `.h5`**, including `$GITHUB_TOKEN`,
`$NPM_TOKEN`, `~/.ssh/id_rsa`, `~/.docker/config.json`, and `~/.npmrc`. The
root cause is in
[tfjs-converter/python/tensorflowjs/read_weights.py L65-L74](https://github.com/tensorflow/tfjs/blob/7f5309fef0a47545e34049903dbdae0f97285f7e/tfjs-converter/python/tensorflowjs/read_weights.py#L65-L74),
which calls `open(os.path.join(base_path, attacker_path), 'rb')` with no
containment check. `os.path.join` silently discards `base_path` when
`attacker_path` is absolute, and does not normalise `..` segments. Both
traversal forms succeed; the recovered bytes flow into `data_buffers`,
become weight tensor data in `read_weights()`'s return value, and end up as
tensor weights in the converter's `.h5` output β€” which CI pipelines typically
upload as build artefacts or push to registries.
## Root Cause
**Lines of Code:**
- [tfjs-converter/python/tensorflowjs/read_weights.py L35 (`read_weights` signature)](https://github.com/tensorflow/tfjs/blob/7f5309fef0a47545e34049903dbdae0f97285f7e/tfjs-converter/python/tensorflowjs/read_weights.py#L35)
- [tfjs-converter/python/tensorflowjs/read_weights.py L65-L74 (vulnerable loop)](https://github.com/tensorflow/tfjs/blob/7f5309fef0a47545e34049903dbdae0f97285f7e/tfjs-converter/python/tensorflowjs/read_weights.py#L65-L74)
- Callers: [tfjs-converter/python/tensorflowjs/converters/keras_tfjs_loader.py L292](https://github.com/tensorflow/tfjs/blob/7f5309fef0a47545e34049903dbdae0f97285f7e/tfjs-converter/python/tensorflowjs/converters/keras_tfjs_loader.py#L292) and [L359](https://github.com/tensorflow/tfjs/blob/7f5309fef0a47545e34049903dbdae0f97285f7e/tfjs-converter/python/tensorflowjs/converters/keras_tfjs_loader.py#L359) β€” reached from the CLI mode `--input_format=tfjs_layers_model --output_format=keras*`.
In `read_weights.py:65-74`:
```python
data_buffers = []
for group in weights_manifest:
buff = io.BytesIO()
buff_writer = io.BufferedWriter(buff)
for path in group['paths']: # ← attacker JSON
with open(os.path.join(base_path, path), 'rb') as f: # ← :70 no containment
buff_writer.write(f.read())
buff_writer.flush()
buff_writer.seek(0)
data_buffers.append(buff.read())
return decode_weights(weights_manifest, data_buffers, flatten=flatten)
```
`os.path.join` has two relevant misbehaviours:
1. If `path` is absolute, `os.path.join('/safe', '/etc/passwd')` returns
`'/etc/passwd'` β€” `base_path` is silently discarded.
2. If `path` contains `..`, no normalisation rejects the traversal β€”
`os.path.join('/safe', '../../etc/passwd')` returns a string that `open`
happily follows.
The bytes from the leaked file flow into `data_buffers`, are passed to
`decode_weights`, and are reshaped into a tensor whose underlying memory is
the secret content. The caller (`keras_tfjs_loader`) then writes this tensor
into the output `.h5` model as a "weight".
**Why this is NOT a duplicate of upstream issue #8628**: #8628
(`tensorflow/tfjs`, Feb 2026, still open) reports a path-traversal on the
**write side** in `write_weights.py`. F-5 is on the **read side** in
`read_weights.py`. Same root-cause **class** (`os.path.join` with attacker
string and no canonicalisation), but a **different file, different function,
different impact** (write-side enables overwriting attacker-chosen paths;
read-side enables exfiltrating arbitrary bytes). A fix to #8628 will not
silently fix F-5.
## Internal Pre-conditions
1. The victim pipeline runs `tensorflowjs_converter --input_format=tfjs_layers_model`
(or `tensorflowjs.converters.keras_tfjs_loader.deserialize_tfjs_layers_model`
programmatically) on the attacker's `model.json` + manifest.
2. The pipeline outputs an artefact (`.h5`, `.tar`, release attachment) that
the attacker can subsequently read.
## External Pre-conditions
None.
## Attack Path
1. Attacker opens a PR (or pushes to a tracked branch) that adds a
`model.json` whose `weightsManifest[0].paths[0]` is an absolute path
(e.g. `/home/runner/.docker/config.json`) or a `..` traversal
(e.g. `../../../home/runner/.npmrc`).
2. CI runs `tensorflowjs_converter --input_format=tfjs_layers_model
attacker.json --output_format=keras out.h5`.
3. `read_weights.read_weights()` reads the attacker's chosen file and stores
its bytes into `data_buffers`.
4. `keras_tfjs_loader` writes the leaked bytes into the output `.h5` as a
weight tensor.
5. CI uploads `out.h5` as a build artefact or attaches it to a release.
6. Attacker downloads the artefact and recovers the secret bytes by reading
the corresponding `weight` HDF5 dataset.
## Impact
CI runners typically have:
| Secret | What attacker gains |
|--------|---------------------|
| `$GITHUB_TOKEN` | Pull-request write, branch protection bypass, repository takeover |
| `$NPM_TOKEN` | Publish a malicious update of `tensorflowjs` or any dependency |
| `~/.ssh/id_rsa` | Lateral movement to deployment hosts |
| `~/.docker/config.json` | Push poisoned images to organisational registries |
| `~/.npmrc` / `~/.pypirc` / `~/.cargo/credentials` | Supply-chain takeover of any published package |
Captured proof (`F5_REAL_IMPACT_PROOF_2026-06-11.txt`):
```text
Attempt 1 β€” absolute path => 86 bytes recovered:
b'FLAG{TFJS_ARBITRARY_FILE_READ_PROVEN}root:x:0:0:bash...'
Attempt 2 β€” relative .. path => 86 bytes recovered:
b'FLAG{TFJS_ARBITRARY_FILE_READ_PROVEN}root:x:0:0:bash...'
ARBITRARY FILE READ ON CONVERTER HOST : YES βœ“βœ“βœ“
```
Both traversal forms succeed against the verbatim vulnerable loop.
## Extended Impact β€” same-root-cause manifestations
- Issue #8628 (upstream, write side, **not** fixed at HEAD) β€” orthogonal but
same fix family (`safe_join`).
- F-1 / F-2 (Node.js side of the same root-cause class) β€” independent fixes.
A single `safe_join(base_path, candidate)` helper deployed across
`read_weights.py`, `write_weights.py`, `keras_tfjs_loader.py`, and the
Node.js sister modules closes all related findings.
## PoC
```bash
git clone https://huggingface.co/martilaio/tfjs-converter-python-readweights-path-traversal-poc
cd tfjs-converter-python-readweights-path-traversal-poc
python3 reproduce.py
```
The PoC reproduces the four-line vulnerable loop **verbatim** (no
TensorFlow dependency required) and tests both absolute and `..` payloads
against a canary file.
## Mitigation
In `tfjs-converter/python/tensorflowjs/read_weights.py`:
```python
import os
def safe_join(base_path: str, candidate: str) -> str:
if not isinstance(candidate, str):
raise ValueError('weight path must be str; got %r' % type(candidate))
if os.path.isabs(candidate):
raise ValueError('Refusing absolute weight path: %r' % candidate)
full = os.path.realpath(os.path.join(base_path, candidate))
base = os.path.realpath(base_path)
if os.path.commonpath([full, base]) != base:
raise ValueError('Weight path escapes model dir: %r' % candidate)
return full
# Replace L70:
# with open(os.path.join(base_path, path), 'rb') as f:
# With:
with open(safe_join(base_path, path), 'rb') as f:
```
Apply the same helper in `write_weights.py` (#8628) and `keras_tfjs_loader.py`.
## CVSS
**CVSS 3.1 7.5 / High** β€” `AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:N/A:N`.
Raise `PR` to `:L` in gated-CI flows.
## Bug classification
- CWE-22 (Path Traversal)
- CAPEC-126
## Affected versions
`tensorflowjs` Python β€” all published versions; code at HEAD `7f5309fef`.
## Files in this repository
| File | Purpose |
|---|---|
| `README.md` | this disclosure |
| `reproduce.py` | minimal canary PoC β€” proves the Python `read_weights` traversal primitive |
| `reproduce_real_impact.py` | real-impact PoC β€” recovers 4 CI-shaped secrets through both attack forms (absolute + `..`) |
| `F5_REAL_IMPACT_PROOF_2026-06-11.txt` | sanitized captured proof + converter-output exfil channel demonstration |