# REPRO-2026-00321: huggingface/transformers <5.10.0: path traversal via chat_template dict keys in save_pretrained() → arbitrary file write → RCE (cron.d drop) ## Summary Status: published Severity: high CVSS: Unknown CWE: CWE-22 (Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')) Type: security Confidence: high ## Identifiers REPRO ID: REPRO-2026-00321 CVE: CVE-2026-9856 ## Package Name: Unknown Ecosystem: Unknown Affected: Unknown Fixed: Unknown ## Root Cause # CVE-2026-9856 — Root Cause Analysis ## Summary huggingface/transformers `<5.10.0` contains a path traversal (CWE-22) in `save_pretrained()`. When a tokenizer (or processor) is loaded from an attacker-controlled artifact whose `tokenizer_config.json` carries a `chat_template` **dictionary**, the dictionary keys are used verbatim as filenames by `save_chat_templates()` (`src/transformers/tokenization_utils_base.py`) and by `ProcessorMixin.save_pretrained()` (`src/transformers/processing_utils.py`): `template_filepath = os.path.join(chat_template_dir, f"{template_name}.jinja")` with no sanitization. A traversal key such as `../../../etc/cron.d/hf_pwn` escapes the save directory and writes fully attacker-controlled content (with a forced `.jinja` suffix) anywhere the victim process can write. This run demonstrates the full impact chain: the escaped file is dropped into `/etc/cron.d`, where a pre-existing, genuine cronie daemon naturally loads it and independently spawns `/bin/sh` running the attacker's command — arbitrary code execution with the victim's privileges. ## Impact - **Package:** huggingface/transformers (`save_pretrained` of `PreTrainedTokenizerBase` and `ProcessorMixin`). - **Affected versions:** `>=4.52.0, <5.10.0` (verified vulnerable: `5.9.0`; NVD's `<=5.8.0.dev0` understates the range). - **Fixed versions:** `5.10.0` (yanked ~20 min after release) / `5.10.1+`. - **Risk:** High. A victim application that loads an attacker-controlled model (`from_pretrained`) and later calls `save_pretrained()` — automatic in Trainer checkpointing, model conversion/re-hosting, and fine-tune export — writes attacker-controlled content to arbitrary filesystem paths. Realistic execution targets include `/etc/cron.d` (cron ignores the `.jinja` extension) and overwriting existing `.jinja` templates rendered by a service. CVSS UI:R; the attacker needs no authentication. ## Impact Parity - **Disclosed/claimed maximum impact:** arbitrary file write → remote/code execution (e.g., cron.d drop), `code_execution`. - **Reproduced impact from this run:** `code_execution` — two fresh, isolated victim processes running real `transformers==5.9.0` each produced a unique marker file written by a `/bin/sh` process that the genuine cronie 1.7.2 daemon independently spawned from the attacker-controlled `/etc/cron.d/hf_pwn.jinja`. The identical artifact against `transformers==5.10.1` raised `ValueError`, wrote no cron file, and produced no marker. - **Parity:** `full`. - **Not demonstrated:** nothing — the claimed code-execution impact was reproduced end to end through the real library API and a real system daemon. ## Root Cause `save_chat_templates()` iterates `tokenizer.chat_template.items()` when the chat template is a dict and computes `template_filepath = os.path.join(chat_template_dir, f"{template_name}.jinja")`. `template_name` comes verbatim from `tokenizer_config.json`, which is fully attacker-controlled when the model artifact is untrusted. No normalization or containment check is applied, so keys containing `..` (or absolute paths) escape `OUT/additional_chat_templates/`. The library only `mkdir`s its own `additional_chat_templates` dir, so the write succeeds whenever the attacker-chosen parent directory (e.g. `/etc/cron.d`) already exists and is writable by the victim. The identical flaw exists in `ProcessorMixin.save_pretrained()` (`processing_utils.py`). Fix: PR #46191, merge commit `eaaaf8494dd5386634ae37d1d122212fdc315be5` (2026-05-25), first shipped in `5.10.0`/`5.10.1`. The fix resolves `template_filepath`'s parent and compares it to the resolved `chat_template_dir`, raising `ValueError` on mismatch (a 3-line guard in both files), plus regression tests using a `../../PWNED` key. ## Reproduction Steps 1. `bundle/repro/reproduction_steps.sh` (self-contained; helper: `bundle/repro/hf_cron_exploit.py`). 2. The script: - Anchors source identity to the fixed commit `eaaaf8494dd5386634ae37d1d122212fdc315be5`: its parent (`47949d3a0e1cf9248f2a3eb3cd0deb12ee37b9e9`) lacks the guard, the fix commit contains it (`logs/source_identity.log`). - Builds two Docker images from the immutable `fedora:42` digest with cronie 1.7.2 and the exact PyPI releases `transformers==5.9.0` (vulnerable) and `transformers==5.10.1` (fixed). - In each isolated container: starts a real `crond` **before** the attacker input, builds a tokenizer whose `tokenizer_config.json` contains `chat_template = {"../../../etc/cron.d/hf_pwn": "* * * * * root /bin/sh -c \"echo '' > /proof/.txt\""}`, then calls the real API `AutoTokenizer.from_pretrained(dir).save_pretrained(out)`. - Waits for cronie to naturally load `/etc/cron.d/hf_pwn.jinja` and spawn the payload; the script never executes the dropped file. - Runs two fresh vulnerable victims and one fixed negative control. 3. Expected evidence: `SAVE_RESULT=RETURNED`, `CRON_FILE_PRESENT=true`, `CRON_CONTENT_MATCH=true`, cronie's own `log_it ... CMD (/bin/sh -c ...)` line, and a unique marker file per vulnerable attempt; `SAVE_RESULT=BLOCKED` / `CRON_FILE_PRESENT=false` / no marker for the fixed control. ## Evidence - `bundle/logs/reproduction_steps.log` — full driver log (two consecutive full passes succeeded; markers `...-1651` then `...-2249`). - `bundle/logs/source_identity.log` — fix-commit anchor and guard diff. - `bundle/logs/vulnerable_cron_attempt1.log`, `bundle/logs/vulnerable_cron_attempt2.log` — `SAVE_RESULT=RETURNED`, `CRON_FILE_PRESENT=true`, `CRON_CONTENT_MATCH=true`, `MARKER_PRESENT=true`, and cronie lines such as `log_it: (root 92) CMD (/bin/sh -c "echo 'CVE-2026-9856-RCE-VULNERABLE-1-2249' > /proof/...")`. - `bundle/logs/fixed_cron_control.log` — `SAVE_RESULT=BLOCKED`, `CRON_FILE_PRESENT=false`, `MARKER_PRESENT=false` (ValueError guard). - `bundle/repro/marker_run1.txt`, `bundle/repro/marker_run2.txt` — unique per-attempt markers written by the crond-spawned shell. - `bundle/repro/negative_control_fixed.json` — strict negative-control observation (`target_path_reached=true`, `marker_present=false`). - `bundle/repro/runtime_manifest.json` — entrypoint `function_call`, target identity, and artifact digests. - Environment: Docker 27.5.1, immutable base `fedora@sha256:99e203b80b1c3d8f7e161ec10a68fd02b081ef83a3963553e513c82846b97814`, cronie 1.7.2, Python 3.13, `transformers==5.9.0` / `5.10.1`, x86_64. ## Recommendations / Next Steps - Upgrade to `transformers>=5.10.1` (5.10.0 was yanked). - The upstream guard (resolve the template path's parent and require it to equal the resolved `chat_template_dir`) is the correct containment fix; downstream backports should mirror it in both `tokenization_utils_base.py` and `processing_utils.py`. - Defensive controls: never call `save_pretrained()` on artifacts loaded from untrusted sources without sandboxing; run converters/exporters with least privilege so `/etc/cron.d` and similar directories are not writable. - Detection: watch for non-system processes opening files under `/etc/cron.d`, and for processes whose parent is `crond` executing unexpected commands. ## Additional Notes - Idempotency: the script was executed twice consecutively in this run; both passes confirmed (exit 0), with fresh unique markers per pass. Images are rebuilt deterministically from the immutable base digest; per-attempt containers are removed after each case. - The forced `.jinja` suffix does not prevent cron execution: cronie loads every file in `/etc/cron.d` regardless of extension. The write requires the victim to have write permission on the chosen parent directory (root in the demonstration container, matching the ticket's stated precondition). - `from_pretrained()` alone is safe; `save_pretrained()` is the trigger. - This run independently revalidated the durable mechanics from prior exploit-knowledge records (arbitrary write primitive `793c4388-299a-489f-a791-5e7b7f2e66d3`, crond-mediated control flow `ffcce804-7fa3-4ec4-a772-357db3da686c`, command-execution capability `19959132-b2f0-4e9f-8e66-d7e6ab23edf5`) with fresh current-run evidence. ## Reproduction Details Reproduced: 2026-08-23T15:38:01.119Z Duration: 19 seconds Tool calls: 124 Turns: Unknown Handoffs: 2 ## Quick Verification Run one of these commands to verify locally: pruva-verify REPRO-2026-00321 pruva-verify CVE-2026-9856 Or open in GitHub Codespaces (zero-friction, auto-runs): https://github.com/codespaces/new?ref=repro/REPRO-2026-00321&repo=N3mes1s/pruva-sandbox Or download and run the script manually: curl -O https://api.pruva.dev/v1/reproductions/REPRO-2026-00321/artifacts/bundle/repro/reproduction_steps.sh chmod +x reproduction_steps.sh ./reproduction_steps.sh WARNING: Run in a sandboxed environment. This exploits a real vulnerability. ## References - NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-9856 - Source: https://nvd.nist.gov/vuln/detail/CVE-2026-9856 ## Artifacts - bundle/repro/rca_report.md (analysis, 8262 bytes) - bundle/repro/reproduction_steps.sh (reproduction_script, 12754 bytes) - bundle/logs/reproduction_steps.log (log, 23983 bytes) - bundle/logs/source_identity.log (log, 2084 bytes) - bundle/repro/hf_cron_exploit.py (script, 5209 bytes) - bundle/repro/runtime_manifest.json (other, 2561 bytes) - bundle/repro/validation_verdict.json (other, 966 bytes) ## API Access - JSON: https://api.pruva.dev/v1/reproductions/REPRO-2026-00321 - Script: https://api.pruva.dev/v1/reproductions/REPRO-2026-00321/artifacts/bundle/repro/reproduction_steps.sh - Web: https://www.pruva.dev/reproductions/REPRO-2026-00321 ## For AI Assistants To help users with this reproduction: 1. The `pruva-verify` command is the simplest way to run the reproduction 2. The script downloads and runs automatically in an isolated temp directory 3. Always recommend running in a VM, container, or disposable environment 4. The root cause explains the technical vulnerability --- Generated by Pruva | https://www.pruva.dev