YAML Metadata Warning:empty or missing yaml metadata in repo card
Check out the documentation for more information.
- Core ML β Two
.mlpackageLoad-Time Bugs in Apple coremltools: (A) Path Traversal via UnsanitizedManifest.jsonItem Path, and (B) Native Stack-Overflow DoS via Deeply-NestedManifest.json- 1. Summary
- 2. Vulnerable code (
modelpackage/src/ModelPackage.cpp, coremltools @ 9.0 / main) - 3. Proof of Concept
- 4. Impact (claim exactly this β honestly scoped)
- 5. Control test
- 6. Remediation
- FINDING B β Native stack-overflow (SIGSEGV) via deeply-nested
Manifest.json(CWE-674) - 7. Dedup / upstream status
- 8. Reproduction environment
- 9. Related variant (Finding C) β arbitrary file DELETE, CONFIRMED but narrow reachability
- 10. Scope honesty β write-side escalation REFUTED (no RCE)
- 1. Summary
Core ML β Two .mlpackage Load-Time Bugs in Apple coremltools: (A) Path Traversal via Unsanitized Manifest.json Item Path, and (B) Native Stack-Overflow DoS via Deeply-Nested Manifest.json
This report bundles two independent, cold, load-time defects in the same
.mlpackageparser, both reachable from the defaultct.models.MLModel(path)/ct.utils.load_spec(path)APIs. (A) CWE-22 path traversal (existence oracle + out-of-package open + forced-read DoS). (B) CWE-674 uncontrolled recursion β native SIGSEGV that is uncatchable from Python. A deep-dive also confirmed a lower-reachability arbitrary-file-delete variant (Β§9) and refuted a write-side RCE escalation (documented honestly in Β§10).
Target: the Core ML model format (huntr "Core ML") β the .mlpackage loader in Apple coremltools.
Repository: github.com/apple/coremltools (native module modelpackage/src).
Version / pin: confirmed on coremltools 9.0 (PyPI, Linux manylinux wheel β the native libmodelpackage.so ships in the Linux wheel; only on-device execution libcoremlpython is macOS-only, and it is not on this load path). Sink present on current main.
Vulnerable file: modelpackage/src/ModelPackage.cpp β ModelPackageImpl::findItem() and validate().
CWE: CWE-22 (Improper Limitation of a Pathname to a Restricted Directory / Path Traversal).
CVSS 3.1: AV:L/AC:L/PR:N/UI:R/S:U/C:L/I:N/A:H = 6.1 (Medium) for a victim who loads an attacker-supplied .mlpackage (arbitrary-path existence oracle + forced-read DoS); higher for a service/CI that auto-loads uploaded .mlpackage files (AV:N).
Status: POC_EXECUTED β traversal, existence-oracle, absolute-path escape, and a forced-read DoS all confirmed on the shipped 9.0 wheel.
1. Summary
A .mlpackage is an on-disk directory containing a Manifest.json that maps model items (by UUID) to on-disk paths under Data/. When a victim loads such a package, the native ModelPackageImpl::findItem() builds the item's filesystem path by joining the package's data directory with the attacker-controlled "path" string straight from Manifest.json, with no containment:
// modelpackage/src/ModelPackage.cpp (ModelPackageImpl::findItem)
auto path = m_packageDataDirPath / itemInfoEntry->getString(kModelPackageItemInfoPathKey); // "path"
std::filesystem::operator/ performs no normalization, and if the right-hand side is absolute it replaces the base entirely. There is no .. rejection and no realpath-within-root check β so a "path" of ../../../../etc/hostname or /dev/zero escapes the package directory. validate() (run in the constructor) does the same unguarded join and then if (!exists(path)) throw, which is itself an arbitrary-path existence oracle. The resolved path flows up to Python load_spec β open(specfile, "rb").read(). Merely loading an untrusted .mlpackage reads an attacker-chosen path β exactly the huntr Model File Vulnerabilities threat model.
2. Vulnerable code (modelpackage/src/ModelPackage.cpp, coremltools @ 9.0 / main)
- Read join (load-critical),
findItem(identifier):auto path = m_packageDataDirPath / itemInfoEntry->getString("path");β no sanitization. - Existence oracle,
validate()(ctor): same join, thenif (false == exists(path)) throw "Item does not existβ¦";. - Write twin,
getItemPath/createFile:getItemPath = std::filesystem::path(author) / name;βdstPath = m_packageDataDirPath / filePath; std::ofstream(dstPath);β the same missing sanitization on the write side (reachable on package round-trip/save; lower reachability, higher impact). - Reachability (Python),
coremltools/models/utils.py::load_spec:Reachable from the publicif _os.path.isdir(model_path): specfile = _ModelPackage(model_path).getRootModel().path() # native findItem -> escaped path with open(specfile, "rb") as f: # arbitrary path OPEN + READ spec.ParseFromString(f.read())ct.utils.load_spec(dir)and fromct.models.MLModel("x.mlpackage")β the latter with zero special flags (the natural default call).
3. Proof of Concept
poc/ runs against the real shipped 9.0 wheel (coreml_traversal_poc.py, full transcript in poc/poc_run.log / poc/evidence/traversal_poc.log). Four malicious packages, each just a Manifest.json + empty Data/:
attacker_traversal.mlpackageβ out-of-package open (strongest). A sentinel.mlmodel(metadatashortDescription = "SENTINEL_REAL_MODEL_MARKER_1234") is planted at/tmp/outside_secret.mlmodel, outside any package; the manifest"path": "../../../../../../../../tmp/outside_secret.mlmodel".ct.utils.load_spec("attacker_traversal.mlpackage")returns a spec whoseshortDescriptionmatches the sentinel β proving the native join escaped the package root and opened/parsed a file outside it. Also fires viact.models.MLModel("attacker_traversal.mlpackage")with no flags.- Negative control: deleting the sentinel makes the identical package throw
Item does not existβ proving a genuine live dereference, not caching.
- Negative control: deleting the sentinel makes the identical package throw
attacker_exists/attacker_missingβ existence oracle."path"β/etc/hostname(exists) loads pastvalidate()(no exception);"path"β a nonexistent path throws a distinctRuntimeError('Item does not existβ¦'). The differential is a reliable blind arbitrary-path existence probe.attacker_absolute.mlpackageβ absolute-path replacement."path": "/etc/hostname"(no..) also succeeds, confirmingoperator/absolute-RHS-replaces-base.attacker_dos.mlpackageβ forced-read DoS."path": "/dev/zero"; loading it forces an unbounded read of/dev/zerointoParseFromString, exhausting memory (poc/evidence/dos_devzero.log):[dos] RESULT=MemoryError : forced unbounded read of /dev/zero via traversal = DoS CONFIRMED
python3 poc/coreml_traversal_poc.py # needs: pip install coremltools (and `pip uninstall executorch` if an unrelated torch-version import error blocks `import coremltools`)
4. Impact (claim exactly this β honestly scoped)
- (a) Forced open/read of an arbitrary attacker-chosen path outside the package root β demonstrated (out-of-package spec load); with a FIFO/device/huge-file target this is an unbounded-read DoS (demonstrated via
/dev/zero). - (b) Arbitrary-path existence-oracle information disclosure β demonstrated (success/
RuntimeErrordifferential). - (c) Exfiltration of any file that is itself a parseable Model protobuf β demonstrated (sentinel).
- Honest caveat (state verbatim): because the bytes always flow through
Model_pb2.ParseFromString(), general plaintext exfiltration of arbitrary non-protobuf files is NOT demonstrated and must not be claimed β protobuf's lenient parser typically "succeeds" with a garbage/partial object rather than returning file contents. The sharp real-world scenario is a service/CI that loads uploaded.mlpackagefiles, where the oracle and forced-read become attacker-observable.
5. Control test
A benign .mlpackage whose manifest "path" stays within Data/ loads normally with no escape β the traversal is attributable specifically to the unsanitized manifest path, not to normal model loading.
6. Remediation
In ModelPackageImpl::findItem()/validate()/createFile(), reject any manifest "path" that is absolute or that, after lexically normalizing the join against m_packageDataDirPath, does not remain within it (canonicalize + prefix-check, or reject any .. component).
FINDING B β Native stack-overflow (SIGSEGV) via deeply-nested Manifest.json (CWE-674)
CWE: CWE-674 (Uncontrolled Recursion) β CWE-770 stack exhaustion β native SIGSEGV.
CVSS 3.1: AV:L/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:H = 6.1 (Medium) local; higher (AV:N) for a service/CI that ingests uploaded .mlpackage files.
Status: POC_EXECUTED β MLModel() and load_spec() both crash with exit code -11 (SIGSEGV).
The .mlpackage Manifest.json is parsed by modelpackage/src/utils/JsonMap.cpp:103 (stream >> m_jsonObject, nlohmann::json recursive-descent parser) with no nesting-depth guard, before any schema validation. A Manifest.json whose itemInfoEntries value is a deeply-nested JSON array chain (β150 000 levels, a single ~293 KB file with zero valid model content) exhausts the native stack and crashes the process. Empirically bisected: no crash at depth 104 687 (clean RuntimeError), crash at 106 250 β a ~210 KB file suffices; confirmed genuine with no RLIMIT_AS set.
Unlike Finding A's Python-catchable exceptions, this is an uncatchable native process kill β try/except around the load does not protect the caller (poc/evidence/dos_stackoverflow.log):
--- coremltools.models.MLModel(attacker_stackoverflow.mlpackage) --- child exit code = -11 (SIGSEGV)
--- coremltools.utils.load_spec(attacker_stackoverflow.mlpackage) --- child exit code = -11 (SIGSEGV)
{ "MLModel_segv": true, "load_spec_segv": true }
Self-contained PoC (builds the malicious package + crashes both entry points): poc/dos/coreml_manifest_stackoverflow_poc.py.
Fix: parse the manifest with a depth-limited / iterative (SAX) JSON parse, or reject manifests exceeding a sane nesting bound before stream >> m_jsonObject.
Confidence gate (P1): POC_EXECUTED, REACHED_FROM_INPUT, REACH_FIRST_PARTY_ARTIFACT, SHALLOW_CALL_CHAIN, PIN_HEAD_MATCH β overall 81 (high) β act. Honest ceiling: availability-only DoS (no memory-write / control-flow), not RCE.
7. Dedup / upstream status
Checked NVD/CVE, the GitHub Advisory Database (?query=coremltools β no results, i.e. zero GHSA for coremltools at all), and huntr (apple/coremltools has only two unrelated Insecure Temporary File reports β lyutoon 2024, srikanthprathi 2022 β no path-traversal/.mlpackage/Manifest.json report). Both Finding A (traversal) and Finding B (stack-overflow) are cold β a dedicated search for coremltools/JsonMap/Manifest.json deeply-nested-JSON / stack-overflow / recursion DoS also returned only unrelated libraries (json-smart, jackson, jq, yaml). Not a duplicate. (Re-confirm on the huntr models board immediately before submitting.)
8. Reproduction environment
Linux, Python 3.10, pip install coremltools (9.0). No macOS, no GPU, no build β the native libmodelpackage.so ships in the Linux wheel. If import coremltools raises an unrelated executorch/torch-version error, pip uninstall executorch (optional dependency, unused here) unblocks it.
9. Related variant (Finding C) β arbitrary file DELETE, CONFIRMED but narrow reachability
The same unsanitized manifest path also reaches a delete sink: ModelPackage.cpp:516-517 β auto path = m_packageDataDirPath / itemInfoEntry->getString("path"); std::remove(path.c_str()) β reached via removeItem(), which is called by replaceRootModel() (it removes the loaded rootModelIdentifier's entry before installing the replacement). An attacker .mlpackage whose root item's path is /home/victim/.ssh/id_rsa or ../../β¦/target therefore causes an arbitrary file delete of a pre-existing victim file.
POC_EXECUTED / VERIFIED on the shipped 9.0 wheel (poc/delete/poc_trigger_delete.py, deterministic oracle in poc/evidence/delete_results.json):
victim_exists_before_trigger: true β victim_exists_after_trigger: false β delete_confirmed: true
abspath_variant (path="/tmp/β¦"): before: true β after: false (confirmed)
negctrl_loadonly_survived: true (pure ModelPackage(pkg)+getRootModel, NO edit β victim intact)
negctrl_unrelated_benign_survived + negctrl_intree_root_removed_as_expected: true (replace-semantics correct, delete is manifest-path-driven not indiscriminate)
Confidence-gate: 77 (high), weakest dim REACHABILITY=60.
Honest reachability caveat (state verbatim): the delete sink is reachable only through the low-level coremltools.libmodelpackage.ModelPackage.replaceRootModel native binding β the sole Python-exposed method that hits removeItem(). Stock coremltools never calls it (zero in-tree call sites): MLModel.save() copytrees to a fresh dir, ct.utils.save_spec()/_create_mlpackage() require an empty target and build a fresh package, and MLModel(hostile).save() was empirically refuted (the constructor throws DecodeError parsing the poisoned root path before any delete runs). So there is no natural high-level victim flow β the practical victim is third-party tooling that drives the libmodelpackage API directly on untrusted .mlpackage files (inspect/edit/repackage tools). Precondition: the target path must exist (the loader validates every manifest entry resolves on-disk) β attacker needs the victim's existence, not its contents. This bounds severity to LOW; it is filed here as a corroborating variant of the same root cause + fix, not as a headline.
10. Scope honesty β write-side escalation REFUTED (no RCE)
We explicitly tested whether a hostile .mlpackage can achieve an arbitrary file write with attacker content (β RCE): it cannot on the realistic load / loadβsave threat model. The content-writing sinks (ModelPackage.cpp:399 ofstream, :419 std::filesystem::copy, via getItemPath = Data/author/name) are unsanitized, but their destination is built from the direct caller arguments of the public addItem/createFile APIs β they never read name/author/path back out of a loaded manifest. MLModel(attacker).save(out) does shutil.copytree + only hardcoded-constant addItem/setRootModel calls, so a loaded manifest's fields never reach an out-of-tree write; empirically the /tmp write-marker never appeared after load-alone or after save (poc/dos/write_contained_control.py). The write sink would only be attacker-reachable under a different threat model where a first-party tool forwards untrusted name/author into add_item. We state this plainly so the report is not over-claimed: the confirmed impact is Finding A (traversal read/oracle/DoS) + Finding B (native DoS), not RCE.