From f17fe7217b1ca66479951eed8b326c31897d3931 Mon Sep 17 00:00:00 2001 From: mldangelo Date: Fri, 27 Feb 2026 00:17:07 -0800 Subject: [PATCH 01/14] feat(security): detect CVE-2024-27318 ONNX nested path traversal bypass CVE-2024-27318 bypasses the CVE-2022-25882 fix (which used lstrip) via nested path traversal like "subdir/../../etc/passwd" that starts with a legitimate directory name. - Check raw location for ".." BEFORE file existence to catch traversal even for non-existent targets (fixes logic bug in check ordering) - Attribute nested traversal to CVE-2024-27318, direct to CVE-2022-25882 - Add get_cve_2024_27318_explanation() with nested_traversal/onnx_version - Add 5 tests: nested detection, direct attribution, non-existent target, safe data, CWE/CVSS details Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 1 + modelaudit/config/explanations.py | 32 ++++++++++++ modelaudit/scanners/onnx_scanner.py | 74 +++++++++++++++++++++++--- tests/conftest.py | 1 + tests/scanners/test_onnx_scanner.py | 81 +++++++++++++++++++++++++++++ 5 files changed, 183 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cbc23ce85..a0cad58ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **security:** new NeMo scanner detecting CVE-2025-23304 Hydra `_target_` injection in `.nemo` model files (CVSS 7.6), with recursive config inspection and dangerous callable blocklist - **security:** detect CVE-2025-51480 ONNX `save_external_data` arbitrary file overwrite via external_data path traversal (CVSS 8.8) - **security:** detect CVE-2025-49655 TorchModuleWrapper deserialization RCE (CVSS 9.8). +- **security:** detect CVE-2024-27318 ONNX nested external_data path traversal bypass via path segment sanitization evasion ### Security diff --git a/modelaudit/config/explanations.py b/modelaudit/config/explanations.py index 891d84537..5fea3085d 100644 --- a/modelaudit/config/explanations.py +++ b/modelaudit/config/explanations.py @@ -882,6 +882,38 @@ def get_cve_2025_1550_explanation(issue_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): The fix for CVE-2022-25882 used " + "str.lstrip('/.') which only strips leading characters. An attacker " + "can bypass this by using nested traversal like 'subdir/../../etc/passwd' " + "where the path starts with a legitimate directory name. The lstrip " + "sanitization leaves these paths completely untouched, enabling " + "arbitrary file reads." + ), + "path_traversal": ( + "ONNX external_data location fields with nested '..' sequences " + "can escape the model directory even after the CVE-2022-25882 fix. " + "Paths like 'data/../../secret' first enter a subdirectory then " + "traverse upward past the model directory boundary." + ), + "onnx_version": ( + "CVE-2024-27318 affects ONNX 1.13.0 through 1.15.0 (versions that " + "had the incomplete CVE-2022-25882 fix). Update to ONNX 1.16.0 or " + "later which replaced lstrip sanitization with proper C++ path " + "validation that rejects any path containing '..' components." + ), + } + + return explanations.get( + vulnerability_type, + "CVE-2024-27318: ONNX nested path traversal bypass. Paths with embedded " + "'../' sequences can bypass the CVE-2022-25882 fix. Update to ONNX >= 1.16.0.", + ) + + def get_cve_2019_6446_explanation(vulnerability_type: str) -> str: """Get specific explanation for CVE-2019-6446 (NumPy allow_pickle RCE).""" explanations = { diff --git a/modelaudit/scanners/onnx_scanner.py b/modelaudit/scanners/onnx_scanner.py index 0637b092e..37af97475 100644 --- a/modelaudit/scanners/onnx_scanner.py +++ b/modelaudit/scanners/onnx_scanner.py @@ -284,13 +284,75 @@ def _check_external_data(self, model: Any, path: str, result: ScanResult) -> Non ) continue external_path = (model_dir / location).resolve() - # Check containment before existence so traversal attempts - # against missing paths are still classified as traversal. - escapes_model_dir = not _is_contained_in(external_path, model_dir) - if escapes_model_dir: - traversal_files.setdefault(location, []).append(tensor.name) + # 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 + escapes_model_dir = not str(external_path).startswith(str(model_dir)) + if has_traversal_raw or escapes_model_dir: + # Determine specific CVE attribution + if has_traversal_raw and not location.startswith(".."): + # 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=f"{cve_id}: External Data Path Traversal", + passed=False, + message=( + 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_id, + "cvss": cvss, + "cwe": "CWE-22", + "description": cve_desc, + "remediation": ( + "Validate that external_data paths do " + "not contain '..' or resolve outside " + "the model directory before loading. " + "Update to ONNX >= 1.16.0." + ), + }, + why=( + 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(): - missing_files.setdefault(location, []).append(tensor.name) + result.add_check( + name="External Data File Existence", + passed=False, + message=f"External data file not found for tensor '{tensor.name}'", + severity=IssueSeverity.CRITICAL, + location=str(external_path), + details={"tensor": tensor.name, "file": location}, + ) else: if location not in safe_files: safe_files.add(location) diff --git a/tests/conftest.py b/tests/conftest.py index 2252cb8fa..5e232b458 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -85,6 +85,7 @@ def pytest_runtest_setup(item): "test_cve_detection.py", # CVE detection tests "test_pytorch_zip_scanner.py", # PyTorch ZIP scanner tests "test_paddle_scanner.py", # PaddlePaddle scanner tests + "test_onnx_scanner.py", # ONNX scanner CVE detection tests ] # Check if this is an allowed test file diff --git a/tests/scanners/test_onnx_scanner.py b/tests/scanners/test_onnx_scanner.py index caa9b48f8..522c22a07 100644 --- a/tests/scanners/test_onnx_scanner.py +++ b/tests/scanners/test_onnx_scanner.py @@ -210,3 +210,84 @@ def test_write_vuln_details_fields(self, tmp_path: Path) -> None: assert details["cvss"] == 8.8 assert details["cwe"] == "CWE-22" assert "remediation" in details + + +class TestCVE202427318NestedPathTraversal: + """Tests for CVE-2024-27318: ONNX nested path traversal bypass.""" + + def test_nested_traversal_detected(self, tmp_path): + """Nested traversal like 'subdir/../../etc/passwd' should trigger CVE-2024-27318.""" + model_path = create_onnx_model( + tmp_path, + external=True, + external_path="subdir/../../etc/passwd", + missing_external=True, + ) + + result = OnnxScanner().scan(str(model_path)) + + cve_checks = [c for c in result.checks if "CVE-2024-27318" in c.name or "CVE-2024-27318" in c.message] + assert len(cve_checks) > 0, ( + f"Should detect CVE-2024-27318 nested traversal. Checks: {[c.message for c in result.checks]}" + ) + assert cve_checks[0].severity == IssueSeverity.CRITICAL + assert cve_checks[0].details.get("cve_id") == "CVE-2024-27318" + + def test_direct_traversal_attributed_to_cve_2022(self, tmp_path): + """Direct traversal like '../../etc/passwd' should be CVE-2022-25882.""" + model_path = create_onnx_model( + tmp_path, + external=True, + external_path="../../etc/passwd", + missing_external=True, + ) + + result = OnnxScanner().scan(str(model_path)) + + cve_checks = [c for c in result.checks if c.details.get("cve_id") == "CVE-2022-25882"] + assert len(cve_checks) > 0, ( + f"Direct traversal should be CVE-2022-25882. Checks: {[(c.name, c.details) for c in result.checks]}" + ) + + def test_nested_traversal_nonexistent_target_still_detected(self, tmp_path): + """Traversal to non-existent paths should still be flagged (not just 'file not found').""" + model_path = create_onnx_model( + tmp_path, + external=True, + external_path="data/../../../nonexistent/secret", + missing_external=True, + ) + + result = OnnxScanner().scan(str(model_path)) + + # Should NOT be just a "file not found" - should be path traversal + traversal_checks = [c for c in result.checks if "traversal" in c.message.lower() or "CVE-" in c.name] + assert len(traversal_checks) > 0, ( + f"Nested traversal should be detected even for non-existent targets. " + f"Checks: {[c.message for c in result.checks]}" + ) + + def test_safe_external_data_no_traversal_flag(self, tmp_path): + """Legitimate external data should not be flagged as traversal.""" + model_path = create_onnx_model(tmp_path, external=True, external_path="weights.bin") + + result = OnnxScanner().scan(str(model_path)) + + traversal_checks = [c for c in result.checks if "traversal" in c.message.lower() and not c.passed] + assert len(traversal_checks) == 0, "Safe paths should not trigger traversal alerts" + + def test_nested_traversal_details_contain_cwe(self, tmp_path): + """CVE-2024-27318 details should include CWE-22.""" + model_path = create_onnx_model( + tmp_path, + external=True, + external_path="weights/../../../tmp/exfil", + missing_external=True, + ) + + result = OnnxScanner().scan(str(model_path)) + + cve_checks = [c for c in result.checks if c.details.get("cve_id") == "CVE-2024-27318"] + assert len(cve_checks) > 0 + assert cve_checks[0].details["cwe"] == "CWE-22" + assert cve_checks[0].details["cvss"] == 7.5 From a352581131fee92a342b5aee865e285d77d74c46 Mon Sep 17 00:00:00 2001 From: mldangelo Date: Fri, 27 Feb 2026 01:26:29 -0800 Subject: [PATCH 02/14] fix(security): use Path.relative_to() for path containment check Replace str().startswith() with proper Path.relative_to() to prevent path containment bypass where /tmp/model_evil would incorrectly match /tmp/model via string prefix comparison. Co-Authored-By: Claude Opus 4.6 --- modelaudit/scanners/onnx_scanner.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/modelaudit/scanners/onnx_scanner.py b/modelaudit/scanners/onnx_scanner.py index 37af97475..893b35427 100644 --- a/modelaudit/scanners/onnx_scanner.py +++ b/modelaudit/scanners/onnx_scanner.py @@ -20,6 +20,15 @@ def _is_contained_in(child: Path, parent: Path) -> bool: return False +def _is_contained_in(child: Path, parent: Path) -> bool: + """Check if child path is contained within parent directory.""" + try: + child.relative_to(parent) + return True + except ValueError: + return False + + def _get_onnx_mapping() -> Any: """Get ONNX mapping module from different locations depending on version.""" try: @@ -290,7 +299,7 @@ def _check_external_data(self, model: Any, path: str, result: ScanResult) -> Non # the existence check so traversal attempts against # non-existent targets are still flagged. has_traversal_raw = ".." in location - escapes_model_dir = not str(external_path).startswith(str(model_dir)) + escapes_model_dir = not _is_contained_in(external_path, model_dir) if has_traversal_raw or escapes_model_dir: # Determine specific CVE attribution if has_traversal_raw and not location.startswith(".."): From 5a7ef0768903db8c0995d79425c8566f0eb3ab61 Mon Sep 17 00:00:00 2001 From: mldangelo Date: Fri, 27 Feb 2026 01:38:34 -0800 Subject: [PATCH 03/14] fix: use path-segment matching for ".." traversal detection Replace substring check with segment splitting to avoid false positives on legitimate filenames like "..hidden" or "file..backup". Co-Authored-By: Claude Opus 4.6 --- modelaudit/scanners/onnx_scanner.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modelaudit/scanners/onnx_scanner.py b/modelaudit/scanners/onnx_scanner.py index 893b35427..37d8061ff 100644 --- a/modelaudit/scanners/onnx_scanner.py +++ b/modelaudit/scanners/onnx_scanner.py @@ -298,7 +298,7 @@ def _check_external_data(self, model: Any, path: str, result: ScanResult) -> Non # 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 + has_traversal_raw = ".." in location.replace("\\", "/").split("/") escapes_model_dir = not _is_contained_in(external_path, model_dir) if has_traversal_raw or escapes_model_dir: # Determine specific CVE attribution From 6d79c65d90f7d649dfd1f4e8790c35a508bfbc27 Mon Sep 17 00:00:00 2001 From: mldangelo Date: Fri, 27 Feb 2026 12:06:34 -0800 Subject: [PATCH 04/14] fix(onnx): require directory escape for traversal CVE tagging --- modelaudit/scanners/onnx_scanner.py | 6 ++++-- tests/scanners/test_onnx_scanner.py | 15 +++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/modelaudit/scanners/onnx_scanner.py b/modelaudit/scanners/onnx_scanner.py index 37d8061ff..68dd1b876 100644 --- a/modelaudit/scanners/onnx_scanner.py +++ b/modelaudit/scanners/onnx_scanner.py @@ -300,9 +300,11 @@ def _check_external_data(self, model: Any, path: str, result: ScanResult) -> Non # 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 has_traversal_raw or escapes_model_dir: + if escapes_model_dir: # Determine specific CVE attribution - if has_traversal_raw and not location.startswith(".."): + 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 = ( diff --git a/tests/scanners/test_onnx_scanner.py b/tests/scanners/test_onnx_scanner.py index 522c22a07..a28ec6c54 100644 --- a/tests/scanners/test_onnx_scanner.py +++ b/tests/scanners/test_onnx_scanner.py @@ -276,6 +276,21 @@ def test_safe_external_data_no_traversal_flag(self, tmp_path): traversal_checks = [c for c in result.checks if "traversal" in c.message.lower() and not c.passed] assert len(traversal_checks) == 0, "Safe paths should not trigger traversal alerts" + def test_normalized_in_dir_path_with_dotdot_no_traversal_flag(self, tmp_path): + """A path containing '..' that resolves in-dir should not be flagged as traversal.""" + (tmp_path / "weights.bin").write_bytes(struct.pack("f", 1.0)) + model_path = create_onnx_model( + tmp_path, + external=True, + external_path="subdir/../weights.bin", + missing_external=True, + ) + + result = OnnxScanner().scan(str(model_path)) + + traversal_checks = [c for c in result.checks if "traversal" in c.message.lower() and not c.passed] + assert len(traversal_checks) == 0, "Normalized in-dir paths should not trigger traversal alerts" + def test_nested_traversal_details_contain_cwe(self, tmp_path): """CVE-2024-27318 details should include CWE-22.""" model_path = create_onnx_model( From a02d3c8aa9b1bcd5af796504da6259478f61ac10 Mon Sep 17 00:00:00 2001 From: mldangelo Date: Thu, 26 Feb 2026 23:54:18 -0800 Subject: [PATCH 05/14] feat(security): detect CVE-2025-9906 Keras enable_unsafe_deserialization config bypass config.json in .keras archives can reference enable_unsafe_deserialization to disable safe_mode from within the loading process, then load malicious Lambda layers. Adds raw string scanning of config.json content. - Add _check_unsafe_deserialization_bypass() to keras_zip_scanner - Flag enable_unsafe_deserialization in config as CRITICAL - Add explanation function for CVE-2025-9906 - 4 new tests (detection, nested value, no false positive, attribution) Co-Authored-By: Claude --- CHANGELOG.md | 1 + modelaudit/config/explanations.py | 24 ++++++ modelaudit/scanners/keras_zip_scanner.py | 3 + tests/conftest.py | 1 + tests/scanners/test_keras_zip_scanner.py | 99 ++++++++++++++++++++++++ 5 files changed, 128 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a0cad58ed..651ee99ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **security:** new NeMo scanner detecting CVE-2025-23304 Hydra `_target_` injection in `.nemo` model files (CVSS 7.6), with recursive config inspection and dangerous callable blocklist - **security:** detect CVE-2025-51480 ONNX `save_external_data` arbitrary file overwrite via external_data path traversal (CVSS 8.8) - **security:** detect CVE-2025-49655 TorchModuleWrapper deserialization RCE (CVSS 9.8). +- **security:** detect CVE-2025-9906 Keras enable_unsafe_deserialization config bypass - **security:** detect CVE-2024-27318 ONNX nested external_data path traversal bypass via path segment sanitization evasion ### Security diff --git a/modelaudit/config/explanations.py b/modelaudit/config/explanations.py index 5fea3085d..cdc625714 100644 --- a/modelaudit/config/explanations.py +++ b/modelaudit/config/explanations.py @@ -882,6 +882,30 @@ def get_cve_2025_1550_explanation(issue_type: str) -> str: ) +def get_cve_2025_9906_explanation(issue_type: str) -> str: + """Get explanation for CVE-2025-9906: Keras enable_unsafe_deserialization config bypass. + + CVE-2025-9906 (HIGH): config.json in .keras archives can invoke + keras.config.enable_unsafe_deserialization() to disable safe_mode from within + the loading process, then include malicious Lambda layers. Fixed in Keras 3.11.0. + """ + explanations = { + "config_bypass": ( + "CVE-2025-9906: The config.json inside this .keras archive references " + "enable_unsafe_deserialization, which can disable safe_mode from within the " + "deserialization process itself. This allows an attacker to bypass safe_mode=True " + "and then load malicious Lambda layers or other unsafe components. " + "Upgrade to Keras >= 3.11.0 and only load models from trusted sources." + ), + } + + return explanations.get( + issue_type, + "CVE-2025-9906: config.json can disable safe_mode via enable_unsafe_deserialization. " + "Upgrade to Keras >= 3.11.0.", + ) + + def get_cve_2024_27318_explanation(vulnerability_type: str) -> str: """Get specific explanation for CVE-2024-27318 (ONNX nested path traversal bypass).""" explanations = { diff --git a/modelaudit/scanners/keras_zip_scanner.py b/modelaudit/scanners/keras_zip_scanner.py index 65f97feba..7f1b2ea03 100644 --- a/modelaudit/scanners/keras_zip_scanner.py +++ b/modelaudit/scanners/keras_zip_scanner.py @@ -175,6 +175,9 @@ def scan(self, path: str) -> ScanResult: except json.JSONDecodeError: pass # Metadata parsing is optional + # CVE-2025-9906: Check for enable_unsafe_deserialization in raw config + self._check_unsafe_deserialization_bypass(config_data, result) + # Scan model configuration self._scan_model_config(model_config, result) diff --git a/tests/conftest.py b/tests/conftest.py index 5e232b458..8f0c28712 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -86,6 +86,7 @@ def pytest_runtest_setup(item): "test_pytorch_zip_scanner.py", # PyTorch ZIP scanner tests "test_paddle_scanner.py", # PaddlePaddle scanner tests "test_onnx_scanner.py", # ONNX scanner CVE detection tests + "test_keras_zip_scanner.py", # Keras ZIP scanner tests ] # Check if this is an allowed test file diff --git a/tests/scanners/test_keras_zip_scanner.py b/tests/scanners/test_keras_zip_scanner.py index 4ff800c64..df8b84f77 100644 --- a/tests/scanners/test_keras_zip_scanner.py +++ b/tests/scanners/test_keras_zip_scanner.py @@ -897,3 +897,102 @@ 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) -> 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", + "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["cwe"] == "CWE-693" From 5564eed62aa1b0c61d3778f635b07343bfa55228 Mon Sep 17 00:00:00 2001 From: mldangelo Date: Fri, 27 Feb 2026 12:10:24 -0800 Subject: [PATCH 06/14] fix(keras): make CVE-2025-9906 detection structured and contextual --- modelaudit/scanners/keras_zip_scanner.py | 59 +++++++++++++++++++++++- tests/scanners/test_keras_zip_scanner.py | 18 ++++++++ 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/modelaudit/scanners/keras_zip_scanner.py b/modelaudit/scanners/keras_zip_scanner.py index 7f1b2ea03..3ff4e4057 100644 --- a/modelaudit/scanners/keras_zip_scanner.py +++ b/modelaudit/scanners/keras_zip_scanner.py @@ -18,6 +18,7 @@ from ..config.explanations import ( get_cve_2025_1550_explanation, + get_cve_2025_9906_explanation, get_cve_2025_49655_explanation, get_pattern_explanation, ) @@ -176,7 +177,7 @@ def scan(self, path: str) -> ScanResult: pass # Metadata parsing is optional # CVE-2025-9906: Check for enable_unsafe_deserialization in raw config - self._check_unsafe_deserialization_bypass(config_data, result) + self._check_unsafe_deserialization_bypass(model_config, result) # Scan model configuration self._scan_model_config(model_config, result) @@ -512,6 +513,62 @@ def _check_layer_module_references(self, layer: dict[str, Any], result: ScanResu why=get_cve_2025_1550_explanation("untrusted_module"), ) + def _check_unsafe_deserialization_bypass(self, model_config: dict[str, Any], result: ScanResult) -> None: + """Check for CVE-2025-9906: enable_unsafe_deserialization bypass in config.json. + + CVE-2025-9906: config.json in .keras archives can reference + keras.config.enable_unsafe_deserialization to disable safe_mode + from within the deserialization process itself, then load malicious layers. + """ + tokens = list(self._collect_string_tokens(model_config)) + has_enable_unsafe = any( + token == "enable_unsafe_deserialization" or token.endswith(".enable_unsafe_deserialization") + for token in tokens + ) + has_keras_config_context = any( + token == "keras.config" + or token.startswith("keras.config.") + or token == "keras.src.config" + or token.startswith("keras.src.config.") + for token in tokens + ) + + if has_enable_unsafe and has_keras_config_context: + result.add_check( + name="CVE-2025-9906: Unsafe Deserialization Bypass", + passed=False, + message=( + "CVE-2025-9906: config.json contains structured reference to " + "keras.config.enable_unsafe_deserialization (safe_mode bypass attempt)" + ), + severity=IssueSeverity.CRITICAL, + location=f"{self.current_file_path}/config.json", + details={ + "cve_id": "CVE-2025-9906", + "cvss": 8.8, + "cwe": "CWE-693", + "remediation": "Upgrade Keras to >= 3.11.0 and remove untrusted model files", + }, + why=get_cve_2025_9906_explanation("config_bypass"), + ) + + def _collect_string_tokens(self, obj: Any) -> list[str]: + """Recursively collect normalized string tokens from parsed config.""" + if isinstance(obj, str): + token = obj.strip() + return [token] if token else [] + if isinstance(obj, dict): + out: list[str] = [] + for value in obj.values(): + out.extend(self._collect_string_tokens(value)) + return out + if isinstance(obj, list): + out: list[str] = [] + for value in obj: + out.extend(self._collect_string_tokens(value)) + return out + return [] + def _check_lambda_layer(self, layer: dict[str, Any], result: ScanResult, layer_name: str) -> None: """Check Lambda layer for executable Python code""" layer_config = layer.get("config", {}) diff --git a/tests/scanners/test_keras_zip_scanner.py b/tests/scanners/test_keras_zip_scanner.py index df8b84f77..6045e0f23 100644 --- a/tests/scanners/test_keras_zip_scanner.py +++ b/tests/scanners/test_keras_zip_scanner.py @@ -983,6 +983,7 @@ def test_cve_attribution_details(self, tmp_path): { "class_name": "Dense", "name": "d", + "module": "keras.config", "config": {"fn": "enable_unsafe_deserialization"}, } ] @@ -996,3 +997,20 @@ def test_cve_attribution_details(self, tmp_path): details = cve_issues[0].details assert details["cve_id"] == "CVE-2025-9906" assert details["cwe"] == "CWE-693" + + 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 From 82456ebea9cf40f17dcd9022b9a9822873c5876d Mon Sep 17 00:00:00 2001 From: mldangelo Date: Fri, 27 Feb 2026 16:51:37 -0800 Subject: [PATCH 07/14] fix: use CheckStatus enum instead of non-existent passed attribute Replace `not c.passed` with `c.status == CheckStatus.FAILED` in test_onnx_scanner.py (two occurrences) and import CheckStatus from modelaudit.scanners.base. The Check model has no `passed` attribute; status is tracked via the CheckStatus enum. Co-Authored-By: Claude --- tests/scanners/test_onnx_scanner.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/scanners/test_onnx_scanner.py b/tests/scanners/test_onnx_scanner.py index a28ec6c54..7e9a7bd95 100644 --- a/tests/scanners/test_onnx_scanner.py +++ b/tests/scanners/test_onnx_scanner.py @@ -10,7 +10,7 @@ from onnx import TensorProto, helper from onnx.onnx_ml_pb2 import StringStringEntryProto -from modelaudit.scanners.base import IssueSeverity +from modelaudit.scanners.base import CheckStatus, IssueSeverity from modelaudit.scanners.onnx_scanner import OnnxScanner @@ -273,7 +273,9 @@ def test_safe_external_data_no_traversal_flag(self, tmp_path): result = OnnxScanner().scan(str(model_path)) - traversal_checks = [c for c in result.checks if "traversal" in c.message.lower() and not c.passed] + traversal_checks = [ + c for c in result.checks if "traversal" in c.message.lower() and c.status == CheckStatus.FAILED + ] assert len(traversal_checks) == 0, "Safe paths should not trigger traversal alerts" def test_normalized_in_dir_path_with_dotdot_no_traversal_flag(self, tmp_path): @@ -288,7 +290,9 @@ def test_normalized_in_dir_path_with_dotdot_no_traversal_flag(self, tmp_path): result = OnnxScanner().scan(str(model_path)) - traversal_checks = [c for c in result.checks if "traversal" in c.message.lower() and not c.passed] + traversal_checks = [ + c for c in result.checks if "traversal" in c.message.lower() and c.status == CheckStatus.FAILED + ] assert len(traversal_checks) == 0, "Normalized in-dir paths should not trigger traversal alerts" def test_nested_traversal_details_contain_cwe(self, tmp_path): From 3b5ac1dcd2d007b4c7454209dffe234892eea63e Mon Sep 17 00:00:00 2001 From: mldangelo Date: Fri, 27 Feb 2026 18:47:08 -0800 Subject: [PATCH 08/14] ci: trigger CI run From a28f25ec315a799b5d2d3fcec91491706e0cf2e2 Mon Sep 17 00:00:00 2001 From: mldangelo Date: Tue, 3 Mar 2026 03:32:06 -0500 Subject: [PATCH 09/14] fix(security): add CVE-2025-9906 description metadata --- modelaudit/scanners/keras_zip_scanner.py | 6 ++++++ tests/scanners/test_keras_zip_scanner.py | 3 +++ 2 files changed, 9 insertions(+) diff --git a/modelaudit/scanners/keras_zip_scanner.py b/modelaudit/scanners/keras_zip_scanner.py index 3ff4e4057..d131eb055 100644 --- a/modelaudit/scanners/keras_zip_scanner.py +++ b/modelaudit/scanners/keras_zip_scanner.py @@ -547,6 +547,12 @@ def _check_unsafe_deserialization_bypass(self, model_config: dict[str, Any], res "cve_id": "CVE-2025-9906", "cvss": 8.8, "cwe": "CWE-693", + "description": ( + "A crafted .keras config can invoke " + "keras.config.enable_unsafe_deserialization during loading, " + "disabling safe_mode and enabling unsafe component " + "deserialization." + ), "remediation": "Upgrade Keras to >= 3.11.0 and remove untrusted model files", }, why=get_cve_2025_9906_explanation("config_bypass"), diff --git a/tests/scanners/test_keras_zip_scanner.py b/tests/scanners/test_keras_zip_scanner.py index 6045e0f23..0f8b8a236 100644 --- a/tests/scanners/test_keras_zip_scanner.py +++ b/tests/scanners/test_keras_zip_scanner.py @@ -996,7 +996,10 @@ def test_cve_attribution_details(self, tmp_path): 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.""" From 99c6030a917fbdaef5e8c1a3ee467435cf4ccee1 Mon Sep 17 00:00:00 2001 From: mldangelo Date: Tue, 3 Mar 2026 04:34:37 -0500 Subject: [PATCH 10/14] test(onnx): clarify normalized in-dir traversal fixture intent --- tests/scanners/test_onnx_scanner.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/scanners/test_onnx_scanner.py b/tests/scanners/test_onnx_scanner.py index 7e9a7bd95..9e06690cc 100644 --- a/tests/scanners/test_onnx_scanner.py +++ b/tests/scanners/test_onnx_scanner.py @@ -162,6 +162,9 @@ def test_safe_path_no_write_vuln(self, tmp_path: Path) -> None: def test_normalized_in_dir_path_with_dotdot_no_write_vuln(self, tmp_path: Path) -> None: """Paths containing '..' but resolving in-dir should not be tagged as CVE-2025-51480.""" + # We create the real target file in-dir, but build the ONNX with an external_data + # reference of "subdir/../weights.bin" and `missing_external=True` so the model keeps + # the external reference metadata while the resolved path still lands inside model_dir. (tmp_path / "weights.bin").write_bytes(struct.pack("f", 1.0)) model_path = create_onnx_model( tmp_path, From 861de09eb414276f2061f5788b19f4436bd62713 Mon Sep 17 00:00:00 2001 From: Yash Chhabria Date: Wed, 4 Mar 2026 10:36:17 -0800 Subject: [PATCH 11/14] fix: add get_cve_2024_27318_explanation() for ONNX nested traversal The CVE Detection Checklist requires explanation functions in explanations.py for each CVE. This was missing for CVE-2024-27318. Co-Authored-By: Claude Opus 4.6 --- modelaudit/config/explanations.py | 30 ++++++++++++------------------ 1 file changed, 12 insertions(+), 18 deletions(-) diff --git a/modelaudit/config/explanations.py b/modelaudit/config/explanations.py index cdc625714..39616d176 100644 --- a/modelaudit/config/explanations.py +++ b/modelaudit/config/explanations.py @@ -910,31 +910,25 @@ 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): The fix for CVE-2022-25882 used " - "str.lstrip('/.') which only strips leading characters. An attacker " - "can bypass this by using nested traversal like 'subdir/../../etc/passwd' " - "where the path starts with a legitimate directory name. The lstrip " - "sanitization leaves these paths completely untouched, enabling " - "arbitrary file reads." - ), - "path_traversal": ( - "ONNX external_data location fields with nested '..' sequences " - "can escape the model directory even after the CVE-2022-25882 fix. " - "Paths like 'data/../../secret' first enter a subdirectory then " - "traverse upward past the model directory boundary." + "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 1.13.0 through 1.15.0 (versions that " - "had the incomplete CVE-2022-25882 fix). Update to ONNX 1.16.0 or " - "later which replaced lstrip sanitization with proper C++ path " - "validation that rejects any path containing '..' components." + "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 bypass. Paths with embedded " - "'../' sequences can bypass the CVE-2022-25882 fix. Update to ONNX >= 1.16.0.", + "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.", ) From 04067332fb7cbc66f763bc14095375dfae278261 Mon Sep 17 00:00:00 2001 From: Yash Chhabria Date: Wed, 4 Mar 2026 11:39:34 -0800 Subject: [PATCH 12/14] ci: trigger workflow --- modelaudit/scanners/keras_zip_scanner.py | 2 +- modelaudit/scanners/onnx_scanner.py | 9 --------- 2 files changed, 1 insertion(+), 10 deletions(-) diff --git a/modelaudit/scanners/keras_zip_scanner.py b/modelaudit/scanners/keras_zip_scanner.py index d131eb055..fa380666f 100644 --- a/modelaudit/scanners/keras_zip_scanner.py +++ b/modelaudit/scanners/keras_zip_scanner.py @@ -569,7 +569,7 @@ def _collect_string_tokens(self, obj: Any) -> list[str]: out.extend(self._collect_string_tokens(value)) return out if isinstance(obj, list): - out: list[str] = [] + out = [] for value in obj: out.extend(self._collect_string_tokens(value)) return out diff --git a/modelaudit/scanners/onnx_scanner.py b/modelaudit/scanners/onnx_scanner.py index 68dd1b876..a28d19df1 100644 --- a/modelaudit/scanners/onnx_scanner.py +++ b/modelaudit/scanners/onnx_scanner.py @@ -20,15 +20,6 @@ def _is_contained_in(child: Path, parent: Path) -> bool: return False -def _is_contained_in(child: Path, parent: Path) -> bool: - """Check if child path is contained within parent directory.""" - try: - child.relative_to(parent) - return True - except ValueError: - return False - - def _get_onnx_mapping() -> Any: """Get ONNX mapping module from different locations depending on version.""" try: From cf65c1013a7a7b688aeabdddcf4d3ae02e798f81 Mon Sep 17 00:00:00 2001 From: Yash Chhabria Date: Wed, 4 Mar 2026 12:39:50 -0800 Subject: [PATCH 13/14] fix: restore CVE-2025-51480 write traversal tracking and fix type hints - Add traversal_files tracking alongside read-CVE checks so the per-file CVE-2025-51480 write-traversal block fires correctly. - Add Path type annotation to _make_keras_zip tmp_path parameter. - Apply ruff formatting fixes. Co-Authored-By: Claude Opus 4.6 --- modelaudit/scanners/onnx_scanner.py | 2 ++ tests/scanners/test_keras_zip_scanner.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/modelaudit/scanners/onnx_scanner.py b/modelaudit/scanners/onnx_scanner.py index a28d19df1..ed7699b64 100644 --- a/modelaudit/scanners/onnx_scanner.py +++ b/modelaudit/scanners/onnx_scanner.py @@ -292,6 +292,8 @@ def _check_external_data(self, model: Any, path: str, result: ScanResult) -> Non 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] == "..") diff --git a/tests/scanners/test_keras_zip_scanner.py b/tests/scanners/test_keras_zip_scanner.py index 0f8b8a236..ee5d7dc1f 100644 --- a/tests/scanners/test_keras_zip_scanner.py +++ b/tests/scanners/test_keras_zip_scanner.py @@ -902,7 +902,7 @@ def test_allows_known_safe_model_classes_in_zip(self, tmp_path): class TestCVE20259906UnsafeDeserialization: """Test CVE-2025-9906: enable_unsafe_deserialization config bypass detection.""" - def _make_keras_zip(self, config_str: str, tmp_path) -> str: + 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: From acec47ec0767f600a441c15dc5786a9a626a8214 Mon Sep 17 00:00:00 2001 From: Yash Chhabria Date: Wed, 4 Mar 2026 14:16:04 -0800 Subject: [PATCH 14/14] docs: document document-wide token scanning design trade-off Add code comment explaining why unsafe deserialization token detection scans document-wide rather than per-object. This is deliberate: payloads can split indicator tokens across sibling config keys. Co-Authored-By: Claude Opus 4.6 --- modelaudit/scanners/keras_zip_scanner.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/modelaudit/scanners/keras_zip_scanner.py b/modelaudit/scanners/keras_zip_scanner.py index fa380666f..4fd5fa97b 100644 --- a/modelaudit/scanners/keras_zip_scanner.py +++ b/modelaudit/scanners/keras_zip_scanner.py @@ -520,6 +520,13 @@ def _check_unsafe_deserialization_bypass(self, model_config: dict[str, Any], res keras.config.enable_unsafe_deserialization to disable safe_mode from within the deserialization process itself, then load malicious layers. """ + # Design note: tokens are collected document-wide rather than per-object. + # This is a deliberate trade-off — the two indicator tokens + # (enable_unsafe_deserialization + keras.config context) must co-occur in + # the same config, but are not required to appear in the same nested + # object. A per-object check would miss payloads that split the + # references across sibling keys, so document-wide scanning is the safer + # detection strategy. tokens = list(self._collect_string_tokens(model_config)) has_enable_unsafe = any( token == "enable_unsafe_deserialization" or token.endswith(".enable_unsafe_deserialization")