# 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 '<unique-marker>' > /proof/<unique-marker>.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.
