A crafted DICOMDIR can set ReferencedFileID 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.
Verified on pydicom 3.1.0.dev0.
Relevant logic is in src/pydicom/fileset.py:
RecordNode._file_id converts ReferencedFileID directly to Path(...)FileSet.load() checks only (root / file_id).resolve(strict=True) to confirm existenceFileSet.load() does not verify that the final resolved path is contained within the File-set rootFileInstance.path returns self.file_set.path / self.node._file_idFileSet.copy() uses shutil.copyfile(instance.path, dst)FileSet.write() uses Path(instance.path).unlink() and shutil.move(...)Because there is no containment check such as resolved.relative_to(root.resolve(strict=True)), a malicious DICOMDIR can reference:
/etc/passwd../...This is not limited to obviously invalid VR input. Even when pydicom emits warnings for invalid ReferencedFileID values, the operation is not blocked. I also confirmed a symlink-based variant using a conformant file ID.
A realistic server-side scenario is:
DICOMDIR using FileSetFileSet.copy() or FileSet.write()DICOMDIR is included in the exported resultMinimal reproduction:
DICOMDIRDirectoryRecordSequence item so that ReferencedFileID = "/etc/passwd" (or /tmp/secret.txt)FileSet(ds) or FileSet(path_to_dicomdir)FileSet.copy(new_root)Example:
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)
I also verified the issue in a simple web import/export demo where an uploaded malicious File-set caused /etc/passwd to be copied into the exported result.
If useful, I can provide the exact malicious sample and the demo environment separately.
This is a path traversal / root containment bypass in FileSet handling.
Observed impact:
arbitrary file read/copy outside the File-set root via FileSet.copy() arbitrary file move outside the File-set root via FileSet.write() arbitrary file delete outside the File-set root via FileSet.remove(...); write(use_existing=True) Affected applications are those that accept untrusted DICOMDIR / File-set input and then call public FileSet workflows such as load(), copy(), write(), or remove().
A realistic impact is server-side file disclosure in import/export workflows.
{
"github_reviewed_at": "2026-03-20T15:57:01Z",
"github_reviewed": true,
"cwe_ids": [
"CWE-22"
],
"nvd_published_at": "2026-03-20T02:16:33Z",
"severity": "HIGH"
}