{"repro_id":"REPRO-2026-00321","version":6,"title":"huggingface/transformers <5.10.0: path traversal via chat_template dict keys in save_pretrained() → arbitrary file write → RCE (cron.d drop)","repro_type":"security","status":"published","severity":"high","description":"Path traversal (CWE-22) in huggingface/transformers save_pretrained() enables arbitrary file write with attacker-controlled content, leading to RCE when a victim application saves a model/tokenizer whose tokenizer_config.json contains a crafted chat_template dictionary.","root_cause":"# CVE-2026-9856 — Root Cause Analysis\n\n## Summary\n\nhuggingface/transformers `<5.10.0` contains a path traversal (CWE-22) in\n`save_pretrained()`. When a tokenizer (or processor) is loaded from an\nattacker-controlled artifact whose `tokenizer_config.json` carries a\n`chat_template` **dictionary**, the dictionary keys are used verbatim as\nfilenames by `save_chat_templates()`\n(`src/transformers/tokenization_utils_base.py`) and by\n`ProcessorMixin.save_pretrained()` (`src/transformers/processing_utils.py`):\n`template_filepath = os.path.join(chat_template_dir, f\"{template_name}.jinja\")`\nwith no sanitization. A traversal key such as `../../../etc/cron.d/hf_pwn`\nescapes the save directory and writes fully attacker-controlled content (with a\nforced `.jinja` suffix) anywhere the victim process can write. This run\ndemonstrates the full impact chain: the escaped file is dropped into\n`/etc/cron.d`, where a pre-existing, genuine cronie daemon naturally loads it\nand independently spawns `/bin/sh` running the attacker's command — arbitrary\ncode execution with the victim's privileges.\n\n## Impact\n\n- **Package:** huggingface/transformers (`save_pretrained` of\n  `PreTrainedTokenizerBase` and `ProcessorMixin`).\n- **Affected versions:** `>=4.52.0, <5.10.0` (verified vulnerable: `5.9.0`;\n  NVD's `<=5.8.0.dev0` understates the range).\n- **Fixed versions:** `5.10.0` (yanked ~20 min after release) / `5.10.1+`.\n- **Risk:** High. A victim application that loads an attacker-controlled model\n  (`from_pretrained`) and later calls `save_pretrained()` — automatic in\n  Trainer checkpointing, model conversion/re-hosting, and fine-tune export —\n  writes attacker-controlled content to arbitrary filesystem paths. Realistic\n  execution targets include `/etc/cron.d` (cron ignores the `.jinja`\n  extension) and overwriting existing `.jinja` templates rendered by a\n  service. CVSS UI:R; the attacker needs no authentication.\n\n## Impact Parity\n\n- **Disclosed/claimed maximum impact:** arbitrary file write → remote/code\n  execution (e.g., cron.d drop), `code_execution`.\n- **Reproduced impact from this run:** `code_execution` — two fresh,\n  isolated victim processes running real `transformers==5.9.0` each produced a\n  unique marker file written by a `/bin/sh` process that the genuine cronie\n  1.7.2 daemon independently spawned from the attacker-controlled\n  `/etc/cron.d/hf_pwn.jinja`. The identical artifact against\n  `transformers==5.10.1` raised `ValueError`, wrote no cron file, and produced\n  no marker.\n- **Parity:** `full`.\n- **Not demonstrated:** nothing — the claimed code-execution impact was\n  reproduced end to end through the real library API and a real system daemon.\n\n## Root Cause\n\n`save_chat_templates()` iterates `tokenizer.chat_template.items()` when the\nchat template is a dict and computes\n`template_filepath = os.path.join(chat_template_dir, f\"{template_name}.jinja\")`.\n`template_name` comes verbatim from `tokenizer_config.json`, which is fully\nattacker-controlled when the model artifact is untrusted. No normalization or\ncontainment check is applied, so keys containing `..` (or absolute paths)\nescape `OUT/additional_chat_templates/`. The library only `mkdir`s its own\n`additional_chat_templates` dir, so the write succeeds whenever the\nattacker-chosen parent directory (e.g. `/etc/cron.d`) already exists and is\nwritable by the victim. The identical flaw exists in\n`ProcessorMixin.save_pretrained()` (`processing_utils.py`).\n\nFix: PR #46191, merge commit\n`eaaaf8494dd5386634ae37d1d122212fdc315be5` (2026-05-25), first shipped in\n`5.10.0`/`5.10.1`. The fix resolves `template_filepath`'s parent and compares\nit to the resolved `chat_template_dir`, raising `ValueError` on mismatch (a\n3-line guard in both files), plus regression tests using a `../../PWNED` key.\n\n## Reproduction Steps\n\n1. `bundle/repro/reproduction_steps.sh` (self-contained; helper:\n   `bundle/repro/hf_cron_exploit.py`).\n2. The script:\n   - Anchors source identity to the fixed commit\n     `eaaaf8494dd5386634ae37d1d122212fdc315be5`: its parent\n     (`47949d3a0e1cf9248f2a3eb3cd0deb12ee37b9e9`) lacks the guard, the fix\n     commit contains it (`logs/source_identity.log`).\n   - Builds two Docker images from the immutable `fedora:42` digest with\n     cronie 1.7.2 and the exact PyPI releases `transformers==5.9.0`\n     (vulnerable) and `transformers==5.10.1` (fixed).\n   - In each isolated container: starts a real `crond` **before** the attacker\n     input, builds a tokenizer whose `tokenizer_config.json` contains\n     `chat_template = {\"../../../etc/cron.d/hf_pwn\": \"* * * * * root /bin/sh -c \\\"echo '<unique-marker>' > /proof/<unique-marker>.txt\\\"\"}`,\n     then calls the real API\n     `AutoTokenizer.from_pretrained(dir).save_pretrained(out)`.\n   - Waits for cronie to naturally load `/etc/cron.d/hf_pwn.jinja` and spawn\n     the payload; the script never executes the dropped file.\n   - Runs two fresh vulnerable victims and one fixed negative control.\n3. Expected evidence: `SAVE_RESULT=RETURNED`, `CRON_FILE_PRESENT=true`,\n   `CRON_CONTENT_MATCH=true`, cronie's own `log_it ... CMD (/bin/sh -c ...)`\n   line, and a unique marker file per vulnerable attempt; `SAVE_RESULT=BLOCKED`\n   / `CRON_FILE_PRESENT=false` / no marker for the fixed control.\n\n## Evidence\n\n- `bundle/logs/reproduction_steps.log` — full driver log (two consecutive\n  full passes succeeded; markers `...-1651` then `...-2249`).\n- `bundle/logs/source_identity.log` — fix-commit anchor and guard diff.\n- `bundle/logs/vulnerable_cron_attempt1.log`,\n  `bundle/logs/vulnerable_cron_attempt2.log` —\n  `SAVE_RESULT=RETURNED`, `CRON_FILE_PRESENT=true`, `CRON_CONTENT_MATCH=true`,\n  `MARKER_PRESENT=true`, and cronie lines such as\n  `log_it: (root 92) CMD (/bin/sh -c \"echo 'CVE-2026-9856-RCE-VULNERABLE-1-2249' > /proof/...\")`.\n- `bundle/logs/fixed_cron_control.log` — `SAVE_RESULT=BLOCKED`,\n  `CRON_FILE_PRESENT=false`, `MARKER_PRESENT=false` (ValueError guard).\n- `bundle/repro/marker_run1.txt`, `bundle/repro/marker_run2.txt` — unique\n  per-attempt markers written by the crond-spawned shell.\n- `bundle/repro/negative_control_fixed.json` — strict negative-control\n  observation (`target_path_reached=true`, `marker_present=false`).\n- `bundle/repro/runtime_manifest.json` — entrypoint `function_call`,\n  target identity, and artifact digests.\n- Environment: Docker 27.5.1, immutable base\n  `fedora@sha256:99e203b80b1c3d8f7e161ec10a68fd02b081ef83a3963553e513c82846b97814`,\n  cronie 1.7.2, Python 3.13, `transformers==5.9.0` / `5.10.1`, x86_64.\n\n## Recommendations / Next Steps\n\n- Upgrade to `transformers>=5.10.1` (5.10.0 was yanked).\n- The upstream guard (resolve the template path's parent and require it to\n  equal the resolved `chat_template_dir`) is the correct containment fix;\n  downstream backports should mirror it in both\n  `tokenization_utils_base.py` and `processing_utils.py`.\n- Defensive controls: never call `save_pretrained()` on artifacts loaded from\n  untrusted sources without sandboxing; run converters/exporters with\n  least privilege so `/etc/cron.d` and similar directories are not writable.\n- Detection: watch for non-system processes opening files under\n  `/etc/cron.d`, and for processes whose parent is `crond` executing\n  unexpected commands.\n\n## Additional Notes\n\n- Idempotency: the script was executed twice consecutively in this run; both\n  passes confirmed (exit 0), with fresh unique markers per pass. Images are\n  rebuilt deterministically from the immutable base digest; per-attempt\n  containers are removed after each case.\n- The forced `.jinja` suffix does not prevent cron execution: cronie loads\n  every file in `/etc/cron.d` regardless of extension. The write requires the\n  victim to have write permission on the chosen parent directory (root in the\n  demonstration container, matching the ticket's stated precondition).\n- `from_pretrained()` alone is safe; `save_pretrained()` is the trigger.\n- This run independently revalidated the durable mechanics from prior\n  exploit-knowledge records (arbitrary write primitive\n  `793c4388-299a-489f-a791-5e7b7f2e66d3`, crond-mediated control flow\n  `ffcce804-7fa3-4ec4-a772-357db3da686c`, command-execution capability\n  `19959132-b2f0-4e9f-8e66-d7e6ab23edf5`) with fresh current-run evidence.\n","cve_id":"CVE-2026-9856","cwe_id":"CWE-22","source_url":"https://nvd.nist.gov/vuln/detail/CVE-2026-9856","reproduced_at":"2026-08-23T15:38:01.119892+00:00","duration_secs":19.0,"tool_calls":124,"handoffs":2,"total_cost_usd":0.04477,"agent_costs":{"claim_matcher":0.024141,"learning_policy":0.020629},"cost_breakdown":{"claim_matcher":{"gpt-5.4-mini-2026-03-17":0.024141},"learning_policy":{"gpt-5.4-mini-2026-03-17":0.020629}},"quality":{"confidence":"high","idempotent_verified":false,"community_verifications":0},"evidence":{"workflow":{"profile":"known_vulnerability","schema_version":2,"stages":["support","claim_contract","repro","judge","vuln_variant"]}},"environment":{"sandbox_image":"ghcr.io/n3mes1s/pruva-sandbox@sha256:8096b2518d6022e13d68f885c3b8ded6b4fe607098b1a1ccbfb99abc004d1dc1"},"published_at":"2026-08-23T15:38:01.647438+00:00","retracted":false,"artifacts":[{"path":"bundle/repro/rca_report.md","filename":"rca_report.md","size":8262,"category":"analysis"},{"path":"bundle/repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":12754,"category":"reproduction_script"},{"path":"bundle/logs/reproduction_steps.log","filename":"reproduction_steps.log","size":23983,"category":"log"},{"path":"bundle/logs/source_identity.log","filename":"source_identity.log","size":2084,"category":"log"},{"path":"bundle/repro/hf_cron_exploit.py","filename":"hf_cron_exploit.py","size":5209,"category":"script"},{"path":"bundle/repro/runtime_manifest.json","filename":"runtime_manifest.json","size":2561,"category":"other"},{"path":"bundle/repro/validation_verdict.json","filename":"validation_verdict.json","size":966,"category":"other"}]}