Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **keras:** add CVE-2024-3660 attribution to Lambda layer detection in .keras and .h5 scanners (CVSS 9.8)
- **security:** detect CVE-2025-10155 pickle protocol 0/1 payloads disguised as `.bin` files by extending `detect_file_format()` to recognize GLOBAL opcode patterns and adding `posix`/`nt` internal module names to binary code pattern blocklist
- **security:** detect CVE-2022-25882 ONNX external_data path traversal with CVE attribution, CVSS score, and CWE classification in scan results
- **security:** detect CVE-2024-27318 ONNX nested external_data path traversal bypass via path segment sanitization evasion

### Security

Expand Down
26 changes: 26 additions & 0 deletions modelaudit/config/explanations.py
Original file line number Diff line number Diff line change
Expand Up @@ -1009,6 +1009,32 @@ def get_cve_2022_25882_explanation(vulnerability_type: str) -> str:
)


def get_cve_2024_27318_explanation(vulnerability_type: str) -> str:
"""Get specific explanation for CVE-2024-27318 (ONNX nested path traversal bypass)."""
explanations = {
"nested_traversal": (
"CVE-2024-27318 (CVSS 7.5): ONNX external_data locations that start "
"with a legitimate directory prefix followed by '../' segments bypass "
"the lstrip-based sanitization added for CVE-2022-25882. For example, "
"'subdir/../../etc/passwd' passes the lstrip('.') check but still "
"resolves outside the model directory."
),
"onnx_version": (
"CVE-2024-27318 affects ONNX versions through 1.15.x where the "
"external_data path validation relied on lstrip-based sanitization. "
"Upgrade to ONNX >= 1.16.0 and validate that external_data paths "
"resolve within the model directory."
),
}

return explanations.get(
vulnerability_type,
"CVE-2024-27318: ONNX nested path traversal bypasses CVE-2022-25882 "
"sanitization via paths like 'subdir/../../etc/passwd'. Validate "
"external_data paths resolve within the model directory.",
)


def get_cve_2019_6446_explanation(vulnerability_type: str) -> str:
"""Get specific explanation for CVE-2019-6446 (NumPy allow_pickle RCE)."""
explanations = {
Expand Down
65 changes: 44 additions & 21 deletions modelaudit/scanners/onnx_scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -284,45 +284,68 @@ def _check_external_data(self, model: Any, path: str, result: ScanResult) -> Non
)
continue
external_path = (model_dir / location).resolve()
# Check for path traversal BEFORE file existence so
# traversal attempts are flagged even for non-existent targets.
# CVE-2024-27318: Detect nested path traversal (e.g.
# "subdir/../../etc/passwd") which bypasses naive lstrip
# sanitization. Check the raw location for ".." BEFORE
# the existence check so traversal attempts against
# non-existent targets are still flagged.
has_traversal_raw = ".." in location.replace("\\", "/").split("/")
escapes_model_dir = not _is_contained_in(external_path, model_dir)
if escapes_model_dir:
# Track for per-file CVE-2025-51480 (write direction) reporting
traversal_files.setdefault(location, []).append(tensor.name)
# Determine specific CVE attribution
normalized_parts = [p for p in location.replace("\\", "/").split("/") if p]
starts_with_parent = bool(normalized_parts and normalized_parts[0] == "..")
if has_traversal_raw and not starts_with_parent:
# Nested traversal (subdir/../../) - CVE-2024-27318
cve_id = "CVE-2024-27318"
cve_desc = (
"ONNX external_data path contains nested "
"traversal sequences that bypass naive "
"sanitization (lstrip fix for CVE-2022-25882)"
)
cvss = 7.5
else:
# Direct traversal (../../) - CVE-2022-25882
cve_id = "CVE-2022-25882"
cve_desc = (
"ONNX external_data location uses path "
"traversal to access files outside the "
"model directory"
)
cvss = 7.5
result.add_check(
name="CVE-2022-25882: External Data Path Traversal",
name=f"{cve_id}: External Data Path Traversal",
passed=False,
message=(
f"CVE-2022-25882: External data path traversal "
f"for tensor '{tensor.name}' - path '{location}' "
f"resolves outside model directory"
f"{cve_id}: External data path traversal "
f"for tensor '{tensor.name}' - path "
f"'{location}' resolves outside model "
f"directory"
),
severity=IssueSeverity.CRITICAL,
location=str(external_path),
details={
"tensor": tensor.name,
"file": location,
"cve_id": "CVE-2022-25882",
"cvss": 7.5,
"cve_id": cve_id,
"cvss": cvss,
"cwe": "CWE-22",
"description": (
"ONNX external_data location fields can use "
"path traversal sequences to read arbitrary "
"files outside the model directory"
),
"description": cve_desc,
"remediation": (
"Validate that external_data paths do not "
"contain '..' or resolve outside the model "
"directory before loading"
"Validate that external_data paths do "
"not contain '..' or resolve outside "
"the model directory before loading. "
"Update to ONNX >= 1.16.0."
),
},
why=(
"This ONNX model references external data that "
"resolves outside the model directory, which is "
"a path traversal attack (CVE-2022-25882). An "
"attacker can craft an ONNX model that reads "
"arbitrary files from the filesystem."
f"This ONNX model references external data "
f"via path '{location}' which contains "
f"directory traversal sequences. An "
f"attacker can craft an ONNX model that "
f"reads arbitrary files ({cve_id})."
),
)
elif not external_path.exists():
Expand Down
120 changes: 120 additions & 0 deletions tests/scanners/test_keras_zip_scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -1323,3 +1323,123 @@ def test_allows_known_safe_model_classes_in_zip(self, tmp_path):
subclass_checks = [c for c in result.checks if "subclassed" in c.name.lower()]
assert len(subclass_checks) > 0
assert all(c.status == CheckStatus.PASSED for c in subclass_checks)


class TestCVE20259906UnsafeDeserialization:
"""Test CVE-2025-9906: enable_unsafe_deserialization config bypass detection."""

def _make_keras_zip(self, config_str: str, tmp_path: Path) -> str:
"""Helper to create a .keras ZIP with raw config string."""
keras_path = os.path.join(str(tmp_path), "model.keras")
with zipfile.ZipFile(keras_path, "w") as zf:
zf.writestr("config.json", config_str)
zf.writestr("metadata.json", json.dumps({"keras_version": "3.0.0"}))
return keras_path

def test_enable_unsafe_deserialization_detected(self, tmp_path):
"""Config referencing enable_unsafe_deserialization should be CRITICAL."""
scanner = KerasZipScanner()
config = {
"class_name": "Sequential",
"config": {
"layers": [
{
"class_name": "Dense",
"name": "dense_1",
"module": "keras.config",
"config": {
"fn": "enable_unsafe_deserialization",
},
}
]
},
}
result = scanner.scan(self._make_keras_zip(json.dumps(config), tmp_path))

cve_issues = [i for i in result.issues if "CVE-2025-9906" in i.message]
assert len(cve_issues) >= 1, "Should detect enable_unsafe_deserialization reference"
assert cve_issues[0].severity == IssueSeverity.CRITICAL

def test_enable_unsafe_deserialization_in_nested_value(self, tmp_path):
"""enable_unsafe_deserialization anywhere in config should be detected."""
scanner = KerasZipScanner()
# Embed the string in a deeply nested config value
config_str = json.dumps(
{
"class_name": "Model",
"config": {
"layers": [],
"metadata": {"loader": "keras.config.enable_unsafe_deserialization"},
},
}
)
result = scanner.scan(self._make_keras_zip(config_str, tmp_path))

cve_issues = [i for i in result.issues if "CVE-2025-9906" in i.message]
assert len(cve_issues) >= 1

def test_no_false_positive_normal_config(self, tmp_path):
"""Normal config without enable_unsafe_deserialization should be clean."""
scanner = KerasZipScanner()
config = {
"class_name": "Sequential",
"config": {
"layers": [
{
"class_name": "Dense",
"name": "dense_1",
"config": {"units": 10},
}
]
},
}
result = scanner.scan(self._make_keras_zip(json.dumps(config), tmp_path))

cve_issues = [i for i in result.issues if "CVE-2025-9906" in i.message]
assert len(cve_issues) == 0, "Normal config should not trigger CVE-2025-9906"

def test_cve_attribution_details(self, tmp_path):
"""CVE details should be present in issue details."""
scanner = KerasZipScanner()
config_str = json.dumps(
{
"class_name": "Sequential",
"config": {
"layers": [
{
"class_name": "Dense",
"name": "d",
"module": "keras.config",
"config": {"fn": "enable_unsafe_deserialization"},
}
]
},
}
)
result = scanner.scan(self._make_keras_zip(config_str, tmp_path))

cve_issues = [i for i in result.issues if "CVE-2025-9906" in i.message]
assert len(cve_issues) >= 1
details = cve_issues[0].details
assert details["cve_id"] == "CVE-2025-9906"
assert details["cvss"] == 8.8
assert details["cwe"] == "CWE-693"
assert "description" in details
assert "remediation" in details

def test_plain_text_mention_without_keras_context_not_flagged(self, tmp_path):
"""A plain text mention should not trigger when no keras.config context exists."""
scanner = KerasZipScanner()
config_str = json.dumps(
{
"class_name": "Model",
"config": {
"layers": [],
"notes": "This doc mentions enable_unsafe_deserialization for awareness only",
},
}
)
result = scanner.scan(self._make_keras_zip(config_str, tmp_path))

cve_issues = [i for i in result.issues if "CVE-2025-9906" in i.message]
assert len(cve_issues) == 0
Loading
Loading