Skip to main content
BlogSecurity

Transformers trust_remote_code Vulnerability: CVE-2026-4372

CVE-2026-4372: a crafted config.json made transformers before 5.3.0 run Hub code with trust_remote_code off. What the fix changed, plus a loading checklist.

Matthews Wong

Software Engineer

October 202611 min read
Transformers trust_remote_code Vulnerability: CVE-2026-4372

Key Takeaway

CVE-2026-4372 affects Hugging Face transformers before 5.3.0: a crafted config.json can point an internal attention field at an attacker-controlled Hub repository, and from_pretrained then downloads and runs its Python code even though trust_remote_code is off. Upgrade to 5.3.0 or later, pin model revisions to reviewed commit hashes, and load from pre-downloaded snapshots.

The sentence in the CVE-2026-4372 advisory that stopped me is the one saying the flaw is reached through standard, documented usage. Not a hand-built exploit chain, not an exotic flag: an ordinary from_pretrained call on a model repository, with trust_remote_code left at its default of False. Most inference hosts I would audit rely on exactly that default as their only gate.

This post reads the primary sources closely: the GitLab and GitHub advisories, the fix commit a7f8e7f, and the current Hugging Face documentation on remote code, kernels, offline mode and pickle scanning. It does not include an exploit, and it separates what the advisory states from what the diff shows and from what I could not confirm. The aim is a loading checklist you can apply to a self-hosted inference host today.

What the advisory actually says

The GitLab advisory, published 26 May 2026 and updated 1 July 2026, titles the issue a remote code execution in HuggingFace transformers. It says a malicious config.json can set the _attn_implementation_internal field to an attacker-controlled Hub repository ID. When a victim loads that model with AutoModelForCausalLM.from_pretrained(), the library downloads and runs Python code from that repository with the victim's operating system privileges. The advisory attributes this to unfiltered deserialization of config attributes, weak sanitization of internal fields and unsandboxed execution of downloaded kernels.

Three facts decide your exposure. Every version before 5.3.0 is affected, and 5.3.0 is the only fixed version the advisory lists. The CVSS 3.0 base score is 7.8 (High), vector AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H, with CWE-502 and CWE-1066 assigned. And the advisory says the flaw bypasses the trust_remote_code mechanism and occurs during standard usage, so the person loading the model may not notice anything. The GitHub Advisory Database entry GHSA-29pf-2h5f-8g72 agrees on versions and score, credits an analyst, links a huntr bounty report, and lists an EPSS of 0.599 percent when I read it.

Why trust_remote_code was the wrong place to look

A review that greps for trust_remote_code=True and stops there treats one flag as the whole boundary. The documentation shows at least three separate ways a model load can run code, each with its own gate or none at all. The table lists them, and the last row is the one this CVE added to the picture.

PathWhat triggers itWhat gates itWhat the gate does not cover
Custom modelling codeLoading a repo that ships its own modelling filestrust_remote_code, default False; the docs say to set it only for repositories you trust and whose code you have readNothing else: it guards only this path
Hub attention kernelsAn attn_implementation string shaped like org/repoCurrent docs: kernels-community is trusted, any other repo needs allow_all_kernels=TrueOnly helps if the code enforcing it runs on the value that reached it
Pickled weightsLoading a pickle-format checkpoint such as pytorch_model.binNo flag. The Hub shows pickle imports per file and runs ClamAV scansThe Hub's own docs call the scan not 100 percent foolproof
config.json internal field (CVE-2026-4372)A Hub config carrying the _attn_implementation_internal keyPer the advisory, trust_remote_code did not gate it in versions before 5.3.0Everything: the value arrived as data, not as a flag you passed

The lesson is about where untrusted data becomes a code path. A flag you pass yourself is easy to audit. A string that arrives inside a downloaded file and is treated as configuration is not, which is why the fix had to change how the config is read rather than add another warning.

What the fix commit changes

The commit is a7f8e7f, titled only fix security vuln, touching two files for 22 added and 9 removed lines. Its message names no CVE, so everything below comes from the diff and its code comments, not from a write-up. Two guards were added, and the second is the more informative.

# Condensed from the diff of commit a7f8e7f. Not a verbatim patch.

# src/transformers/configuration_utils.py, in the loop that applies extra
# kwargs from a Hub config as attributes on the config object.
# Wrong (before): every key from the downloaded config became an attribute.
setattr(self, key, value)

# Right (after): the two internal fields are skipped. The diff comment says
# these should use the public field instead.
if key not in ("_attn_implementation_internal", "_experts_implementation_internal"):
    setattr(self, key, value)   # still inside the existing try/except

# src/transformers/integrations/hub_kernels.py, is_kernel(attn_implementation)
# Before: one regex; any string shaped like org/repo[@rev][:name] was a Hub kernel.
# After:  None returns False; the org name is captured; anything outside
#         "kernels-community" logs warning_once ("For security reasons, we
#         currently only accept kernels from the kernels-community ...")
#         and returns False.

The first guard makes configuration loading ignore the two internal fields, so a Hub config can no longer assign them. A comment in the diff says those fields should use the public field instead. The second guard narrows what counts as a Hub kernel: the org name is now captured, and anything outside kernels-community logs a warning and returns False, with a comment giving the reason as loading random kernels leading to arbitrary code execution. Together they cut the path at its source and again at its sink.

Do not read this as proof that nothing else is reachable. The advisory attributes the flaw to three causes, and the commit addresses the two I can see in the diff. I did not audit 5.3.0 beyond that commit, and the advisory lists no patched release on the 4.x line, so a host pinned to 4.x has no fix to take other than the major upgrade.

Check whether a host is exposed

Two things are worth checking on every machine that loads models: the installed library version, and any cached config that carries the internal key with a repository-shaped value. The script below uses only the standard library. It reads the cache location the way the Hugging Face documentation describes, HF_HUB_CACHE first, then HF_HOME with a hub subfolder, and it does not try to reproduce anything harmful.

#!/usr/bin/env python3
"""Audit one host for CVE-2026-4372 exposure. Read-only; standard library only.
Exit code 1 means: upgrade, or review a flagged cache entry."""
import json
import os
import re
import sys
from importlib.metadata import PackageNotFoundError, version
from pathlib import Path

FIXED = (5, 3, 0)  # the advisories list 5.3.0 as the fixed version
INTERNAL_KEYS = {"_attn_implementation_internal", "_experts_implementation_internal"}
TRUSTED_ORG = "kernels-community"


def parse(v: str) -> tuple:
    # "5.3.0rc1" -> (5, 3, 0). re.match avoids reading "0rc1" as 1.
    return tuple(int(re.match(r"\d+", p).group()) if re.match(r"\d+", p) else 0
                 for p in v.split(".")[:3])


def cache_root() -> Path:
    # Order from the huggingface_hub docs: HF_HUB_CACHE, else HF_HOME/hub.
    # XDG_CACHE_HOME only matters when HF_HOME is unset and is not handled here.
    if os.environ.get("HF_HUB_CACHE"):
        return Path(os.environ["HF_HUB_CACHE"])
    home = os.environ.get("HF_HOME") or str(Path.home() / ".cache" / "huggingface")
    return Path(home) / "hub"


def internal_values(node, path=""):
    if isinstance(node, dict):
        for key, val in node.items():
            if key in INTERNAL_KEYS:
                yield f"{path}/{key}", val
            yield from internal_values(val, f"{path}/{key}")
    elif isinstance(node, list):
        for i, val in enumerate(node):
            yield from internal_values(val, f"{path}[{i}]")


def main() -> int:
    bad = 0
    try:
        installed = version("transformers")
        status = "OK" if parse(installed) >= FIXED else "VULNERABLE"
        bad += status == "VULNERABLE"
        print(f"transformers {installed}: {status} (fixed in 5.3.0)")
    except PackageNotFoundError:
        print("transformers not installed in this interpreter")

    for cfg in cache_root().rglob("config.json"):
        try:
            data = json.loads(cfg.read_text())
        except (OSError, ValueError):
            continue
        for where, val in internal_values(data):
            # A repo-shaped value outside the trusted org is a lead, not proof.
            if isinstance(val, str) and "/" in val and val.split("/")[0] != TRUSTED_ORG:
                bad += 1
                print(f"REVIEW {cfg} {where} = {val}")
    return 1 if bad else 0


if __name__ == "__main__":
    sys.exit(main())

Treat a hit as a lead for review rather than proof of compromise. I could not confirm from the sources whether a legitimate saved config ever contains these keys, so the script only flags a repo-shaped value from outside kernels-community. If it flags something, find which model repository that cache entry came from and who published the commit. This script also does not see XDG_CACHE_HOME, which the docs say applies only when HF_HOME is unset.

The failure mode: auditing the flag instead of the data

The named failure here is a review that passes because every call site is clean. Every from_pretrained in the codebase omits trust_remote_code, a grep proves it, and the host is signed off as safe. Under the advisory that grep is not enough, because the dangerous input was a field inside a file fetched at load time.

There is a second, quieter trade-off in pinning. The documentation says the revision argument accepts a branch, a tag or a commit id. A branch or tag can move, so a config.json that was clean at review can be replaced later, while a commit hash cannot. The installation docs add that with a commit hash Transformers uses the local cache for that commit's files, whereas a branch or tag costs one Hub lookup at load. Pinning to a hash trades convenience for a config you can reason about.

Upgrading carries its own cost. The only fixed release in the advisory is 5.3.0, so a team on an older major version takes a major upgrade to close this. I did not verify what else changed between those lines, so run your model-load tests on a staging host first and do not assume the jump is free.

A loading checklist for self-hosted inference

The pattern I would use splits loading into an online build step and an offline runtime. In the build step, an authenticated CI job downloads a snapshot at a commit hash a human reviewed. At runtime the container has HF_HUB_OFFLINE=1, which the docs say prevents HTTP calls to the Hub, plus local_files_only so the load fails loudly if anything is missing instead of reaching out.

# --- build stage (CI, online): download once, at a commit a human reviewed ---
from huggingface_hub import snapshot_download

REVIEWED = {
    # model repo -> full commit hash recorded in version control, never "main"
    "meta-llama/Llama-3.2-1B": "PASTE_THE_REVIEWED_40_CHAR_COMMIT_HASH",
}
for repo_id, commit in REVIEWED.items():
    snapshot_download(repo_id=repo_id, revision=commit)

# --- runtime stage (container, offline) ---
# Dockerfile, not Python: huggingface_hub reads its variables at import time,
# so os.environ["HF_HUB_OFFLINE"] set after the import does nothing.
#   ENV HF_HUB_OFFLINE=1 USE_HUB_KERNELS=0 HF_HOME=/models/hf

from transformers import AutoModelForCausalLM

MODEL_ID = "meta-llama/Llama-3.2-1B"
model = AutoModelForCausalLM.from_pretrained(
    MODEL_ID,
    revision=REVIEWED[MODEL_ID],   # a hash cannot move; a branch or tag can
    local_files_only=True,         # fail loudly instead of reaching out
    attn_implementation="sdpa",    # built-in PyTorch attention, no Hub kernel
    # trust_remote_code stays at its default False. Do not add allow_all_kernels.
)

Two details matter. The docs state that huggingface_hub reads its environment variables at import time, so setting HF_HUB_OFFLINE inside Python after the import does nothing; put it in the container environment. And the offline docs note that a branch or tag can still resolve through the cache, which is one more reason to pin a hash. I did not confirm whether offline mode also blocks the kernels library from fetching a kernel, so the checklist also disables Hub kernels by setting USE_HUB_KERNELS=0, which the kernels guide says disables them everywhere without changing code.

Add a CI check that fails the build on a literal trust_remote_code=True or allow_all_kernels=True anywhere in your repository. It will not catch a value arriving through data, but it stops the two documented opt-ins from being added quietly in a config template.

What each control stops, and what it leaves open

No single control closes every path, and some of them overlap. This table is my own reading of the documentation, with the limits stated, so you can decide which ones to adopt and which gaps to accept.

ControlWhat it stopsWhat it leaves openBased on
Upgrade to 5.3.0 or laterThe config.json path described in CVE-2026-4372Pickled weights and any path not yet foundAdvisory
Pin revision to a reviewed commit hashA repository changing its config after you reviewed itA commit you reviewed carelesslyTransformers auto classes and installation docs
Pre-download, then HF_HUB_OFFLINE=1 and local_files_onlySurprise fetches from the Hub at startupA bad artefact baked into the image. Kernel fetching is unconfirmedInstallation and huggingface_hub docs
USE_HUB_KERNELS=0Hub kernels everywhere, per the kernels guideAnything that is not a kernel, and the speed those kernels gave youLoading kernels guide
Never pass allow_all_kernels or trust_remote_code as TrueThe two documented opt-ins to third-party codeData that reaches the code path without an opt-in, which is this CVELoading kernels guide and auto classes docs
Trusted publishers and signed commits for weightsCasual use of unknown pickle checkpointsA signature proves origin, not that the file is safeHub pickle scanning doc

One extra control comes from me rather than the sources: restrict outbound network access from the inference host to the artefact store you actually use. Code downloaded at load time has to come from somewhere, and an egress allow-list turns a silent fetch into a blocked and logged one. I have not tested this against this CVE, so count it as defence in depth, not a fix.

What to do on Monday

Work through these in order. The first two take minutes and tell you whether the rest is urgent.

  1. Run the audit script on every host that loads models, including laptops that build images, and record the library version it reports.
  2. Upgrade anything below 5.3.0 on a staging host, run your model-load and generation tests, then promote. Add a version floor to your constraints file so a later resolve cannot go backwards.
  3. Replace branch and tag references in your loaders with reviewed commit hashes, and keep the hashes in version control next to the code.
  4. Move downloading into CI, bake the snapshot into the image or volume, and set HF_HUB_OFFLINE=1, USE_HUB_KERNELS=0 and local_files_only in the runtime.
  5. Add the CI grep for the two opt-ins, and decide who owns reviewing a model repository before its hash is allowed into the list.
# 1. What is installed, and is it below 5.3.0?
python -m pip show transformers | grep -i '^version'
python audit_transformers_cve.py ; echo "exit=$?"

# 2. Hold the floor so a later resolve cannot go backwards.
echo 'transformers>=5.3.0' >> constraints.txt
python -m pip install -c constraints.txt -U transformers

# 3. CI guard: fail the build on the two documented opt-ins to third-party code.
if grep -rnE 'trust_remote_code *= *True|allow_all_kernels *= *True' src/ ; then
  echo "third-party code opt-in found" >&2 ; exit 1
fi

If the audit finds a suspicious cached config, treat the host as potentially affected: identify the source repository, rotate any credentials that host could read, and check what the process was allowed to reach. That is general incident practice, not something the advisory prescribes.

The rule I take from this is that a security flag only protects the paths it was written for. Upgrade to 5.3.0 or later, load pinned snapshots from an offline runtime, and audit the data a loader consumes as carefully as the arguments you pass it.

FAQ

Frequently Asked Questions

Short answers to what readers ask most about this topic.

What is CVE-2026-4372 in Hugging Face transformers?

It is a remote code execution flaw in transformers versions before 5.3.0. A malicious config.json sets an internal attention field to an attacker-controlled Hub repository ID, and from_pretrained then downloads and runs Python code from it. The advisory rates it CVSS 7.8 (High) and says it bypasses trust_remote_code.

Does setting trust_remote_code=False protect me from CVE-2026-4372?

Not in affected versions. The advisory says the flaw bypasses the trust_remote_code mechanism during standard usage, so the default value was not a defence. The documented fix is upgrading to transformers 5.3.0 or later.

Which transformers version fixes CVE-2026-4372?

The GitLab and GitHub advisories both list 5.3.0 as the fixed version and every earlier version as affected. Neither lists a patched release on an older line, so a host on 4.x needs the upgrade. Test model loading on staging first, because I did not verify what else changed between the lines.

How can I pin a Hugging Face model so its config.json cannot change?

Pass the revision argument as a full commit hash, since the docs say it accepts a branch, a tag or a commit id and only the hash is immutable. Download that snapshot in CI, then run the host with HF_HUB_OFFLINE=1 and local_files_only so nothing is fetched at runtime.

Are Hub kernels and pickled weights separate code execution risks?

Yes. The current docs say kernels outside kernels-community need allow_all_kernels=True because loading a kernel can run arbitrary code, and the Hub's pickle documentation says pickle checkpoints can execute code on load and its scan is not foolproof. Treat them as separate paths with separate controls.