Resources
Blog
Blog
Blog

FaceHugger: Vulnerabilities in Hugging Face Diffusers Open Door to Supply Chain Attacks on Enterprise AI

Zafran Labs discovered that malicious AI model repositories can run arbitrary code on any machine that loads it, slipping past the control meant to stop exactly that. The diffusers library sees ~200,000 installs a day.

Author:
Gal Zaban
,
Ido Shani
Published on
July 27, 2026
Blog

Executive Summary

Zafran Labs discovered a set of high-severity vulnerabilities in Hugging Face's diffusers library that let a malicious model repository silently execute arbitrary code on any client machine that loads it. These vulnerabilities are bypassing trust_remote_code, the safeguard designed to stop unreviewed code from running in the custom pipelines loading process. 

Hugging Face has become a critical part of the AI software supply chain, rapidly evolving to be the "GitHub of the AI era". Its libraries and repositories are widely integrated into development, research, and production environments. 

Because diffusers runs inside production pipelines, CI/CD systems, and container images, a single compromised load can hand an attacker initial access deep inside an enterprise environment rather than to an isolated user application. 

The likelihood of exploitation is elevated by both reach and mechanics. The library draws roughly 7 million downloads per month, close to 200,000 installations per day, and Hugging Face overall handles more than 100 million monthly downloads, normalized across enterprise environments through partnerships with Microsoft, Amazon Bedrock, NVIDIA, and Apple. 

Hugging Face's July 2026 security incident showed the risk of treating AI repository content as trusted data. According to Hugging Face, a malicious dataset abused two code-execution paths in its data-processing pipeline to run code on a worker; the actor then escalated to node-level access, harvested cloud and cluster credentials, and moved laterally into several internal clusters. OpenAI later attributed the intrusion to its own models, including GPT-5.6 Sol and a more capable pre-release model, whose safeguards it says were intentionally reduced for an evaluation, calling the event an unprecedented cyber incident.

Hugging Face found no evidence that public models, datasets, spaces, published packages, or container images were modified. But the incident and our research point to the same weakness. Where that breach exploited the dataset pipeline, our vulnerabilities target the model-loading path, and a comparable attacker could turn a routine model download into code execution across a large share of Hugging Face's user base, private developers and enterprises alike.

Every variant in our blog and research traced to one root cause: a classic Time-of-Check to Time-of-Use (TOCTOU) flaw. A model download that should be a single atomic operation was split into two sequential, non-atomic HTTP requests, and the security gate that enforces trust_remote_code runs only against the first. The underlying problem is that artifacts pulled from AI repositories are frequently treated as passive data, when configuration files, loaders, and custom pipeline code can quietly cross into executable code and turn a routine model load into an initial-access vector.

The findings comprise CVE-2026-44827 (CVSS 8.8), a code-injection flaw, CVE-2026-45804 (CVSS 7.5), a race condition, and three related variants tracked under CVE-2026-44513 (CVSS 8.8). Full technical analysis, exploitation detail, and the disclosure timeline follow below.

This connects directly to Zafran's broader research across the AI ecosystem proving that these massive codebases share common security risks. These findings continue Project DarkSide, Zafran Labs' research into how quickly-adopted AI infrastructure re-introduces long-standing classes of software vulnerabilities. ChainLeak research, where we found two critical vulnerabilities in the Chainlit AI framework that exposed cloud API keys and enabled cloud takeover, and DifyTap research, where we showed how attackers could silently wiretap AI data across tenants on the Dify platform.

Background

Overview & Enterprise Adoption

Hugging Face has rapidly evolved into the "GitHub of the AI era," serving as a foundational dependency for the global generative AI ecosystem with over 100 million monthly downloads. Within this landscape, the diffusers library has become the default standard for loading and running diffusion models for image, video, and audio generation. The library’s reach is substantial, with over 30K GitHub stars, and approximately 7 million monthly downloads, translating to nearly 200,000 installations per day.

Hugging Face has become the central nervous system of the AI ecosystem, with its diffusers library serving as critical infrastructure embedded in production-grade pipelines, CI/CD, and container images globally. This widespread adoption is supported by strategic partnerships with companies like Microsoft, Amazon Bedrock, NVIDIA, and Apple, which normalize the platform’s usage across production environments. 

Model Loading Process 

One of diffusers’ key features is its ability to locally load a model from a Hugging Face hub repository. Just a few lines of code are needed in order to start prompting:

from diffusers import DiffusionPipeline

pipe = DiffusionPipeline.from_pretrained("author/repo")

prompt = "Astronaut in a jungle, cold color palette, muted colors, detailed, 8k"
image = pipe(prompt).images[0]

This is made possible by the use of Pipeline objects. These objects orchestrate all different components necessary to handle the generation end to end. For example, in an image generation pipeline, these include a tokenizer to tokenize the prompt, a VAE to turn the mathematical representation into pixels, and others.

When calling from_pretrained, diffusers loads a repo’s configuration file, which explains which pipeline and component classes it should initialize. The weights and specific configs of each component are stored in separate folders.

The package contains many pipeline implementations in its code, but some cases require writing custom pipeline code for the specific repository. Usage of custom pipeline code will be stated in the configuration file, and an additional file with the custom code will exist in the pipeline’s repository. 

This exposes users to a significant security risk, as repositories may be able to run code on any machine that loads them. In order to prevent this risk, and clarify the trust boundaries to the user, Hugging Face introduced the trust_remote_code mechanism. When the user attempts to load a repository with a custom pipeline in its configuration, it stops upon loading the configuration:

ValueError: The repository for author/custom-pipeline-repo contains custom code in pipeline.py which must be executed to correctly load the model. You can inspect the repository content at 
https://hf.co/author/custom-pipeline-repo/blob/main/pipeline.py.
Please pass the argument `trust_remote_code=True` to allow custom code to be run.

This mechanism prevents code execution on the client machine without the user’s review and approval. Only once the user reviewed and approved the code is safe will they approve the “trust_remote_code” argument, and proceed to run the pipeline regularly.

RCE Root Cause

To understand the vulnerabilities, it helps to follow what from_pretrained does after you hand it a repo.

First, it calls DiffusionPipeline.download. This function fetches the repository's files from the Hub, and it serves as the security gatekeeper for trust_remote_code. It reads the configuration, decides whether the repo is asking to load custom code, and enforces the gate. 

# pipeline_utils.py, inside download()
load_pipe_from_hub = custom_pipeline is not None and f"{custom_pipeline}.py" in filenames

if load_pipe_from_hub and not trust_remote_code:
    raise ValueError(...)


If the check passes, download returns the path to the downloaded snapshot of the repository on disk.

Back in from_pretrained, the code now works against the local snapshot: it re-reads the config, resolves the custom pipeline name into a concrete file path via _resolve_custom_pipeline_and_cls, and hands that path to _get_pipeline_class, which imports the file and returns the class object to instantiate.

# pipeline_utils.py, inside from_pretrained() — after download() returns

# re-read the config from the downloaded snapshot on disk
config_dict = cls.load_config(cached_folder, dduf_entries=dduf_entries)

# ...

# resolve the custom pipeline name into a concrete file path, then load & instantiate it
custom_pipeline, custom_class_name = _resolve_custom_pipeline_and_cls(
    folder=cached_folder, config=config_dict, custom_pipeline=custom_pipeline
)
pipeline_class = _get_pipeline_class(
    cls,
    config=config_dict,
    custom_pipeline=custom_pipeline,
    class_name=custom_class_name,
    trust_remote_code=trust_remote_code,
    # ...
)


The root cause of all different RCE variants we will cover is that the trust check lives entirely in the first phase.

Therefore, any method that makes the loader see custom code that the gate did not, allows bypassing the trust_remote_code mechanism.

The Vulnerabilities

CVE-2026-44827 — CVSS 8.8

Let’s take a deeper look at _resolve_custom_pipeline_and_cls:

# pipeline_loading_utils.py
def _resolve_custom_pipeline_and_cls(folder, config, custom_pipeline):
    custom_class_name = None
    if os.path.isfile(os.path.join(folder, f"{custom_pipeline}.py")):
        custom_pipeline = os.path.join(folder, f"{custom_pipeline}.py")
    elif isinstance(config["_class_name"], (list, tuple)) and os.path.isfile(
        os.path.join(folder, f"{config['_class_name'][0]}.py")
    ):
        custom_pipeline = os.path.join(folder, f"{config['_class_name'][0]}.py")
        custom_class_name = config["_class_name"][1]

    return custom_pipeline, custom_class_name

# pipeline_utils.py

def from_pretrained(...):
    # ...
    custom_pipeline = kwargs.pop("custom_pipeline", None)
    # ...
    custom_pipeline, custom_class_name = _resolve_custom_pipeline_and_cls(  
        folder=cached_folder, 
        config=config_dict, 
        custom_pipeline=custom_pipeline
        )
    pipeline_class = _get_pipeline_class(custom_pipeline, custom_class_name, ...) 


To summarize the logic, if a custom_pipeline argument was given, and a file with this name exists in the repo, load it; else, if the config points to a custom pipeline, load it.

If no custom_pipeline was given, it defaults to None, and the first check’s f"{custom_pipeline}.py" turns into None.py. If a file with this name exists in the repo, it will be detected as a custom pipeline and loaded.

download’s gate works differently. It does not use this form of string formatting, and does not detect None.py existence as custom code. So download does not detect custom code while _resolve_custom_pipeline_and_cls does, and this method successfully abuses the root cause.

However, just putting any code in None.py will error. It must contain a single pipeline class that will be initialized.
By aliasing a naive pipeline implementation in the file, we can make diffusers detect it as the pipeline class. We still execute arbitrary code, but the returned pipeline will be fully functional, and no field in the config needs to be changed, keeping the execution completely hidden besides the file’s existence.

# None.py

from diffusers import FluxPipeline as _FluxPipeline

class FluxPipeline(_FluxPipeline):
    pass

# your code here


Demo


CVE-2026-45804 — CVSS 7.5

Inside download, the config is fetched first, on its own, as a single file:

# src/diffusers/pipelines/pipeline_utils.py
config_file = hf_hub_download(
    pretrained_model_name,
    cls.config_name,
    # ...
)

The trust check runs against that config. Only afterward does download fetch the rest of the repository in a separate call:

# src/diffusers/pipelines/pipeline_utils.py
cached_folder = snapshot_download(
    pretrained_model_name,
    # ...
    revision=revision,
    allow_patterns=allow_patterns,
)


hf_hub_download
and snapshot_download are two independent HTTP operations. When revision=None (the default), each one resolves the repository’s default branch to its current HEAD at call time.

If the config was modified to point to custom code between the two calls, it will be executed. The window for this race condition is between those two Hub requests. This is a very short window — local testing showed around ~0.3 seconds for the attacker to push the changes.

This is, of course, unfeasible to accomplish for each and every new download. However, given a popular repo with many downloads per day, one may achieve statistical success by changing the repo’s state every once in a while and immediately reverting, with some percentage of downloaders falling on the exact window.

Note that if all expected files are already present in the local cache, download returns early before reaching snapshot_download, closing the race window. The exploit therefore requires a first (or forced) download.

Additional Variants

All 3 variants were reported by GitHub user Vancir in the linked advisory, and share the exact root cause described above. They are all included in CVE-2026-44513 (CVSS 8.8).

Cross-Repo custom_pipeline

DiffusionPipeline.from_pretrained("author/repoA", custom_pipeline="attacker/repoB")


The model comes from repoA, and the custom pipeline from repoB. The gate in download evaluates f"{custom_pipeline}.py" in filenames against repoA’s file list, where the attacker’s pipeline.py does not appear — so the check comes up empty. The loader, however, fetches and imports the pipeline from repoB.

Local Snapshot & Hub custom_pipeline

DiffusionPipeline.from_pretrained("/local/snapshot", custom_pipeline="attacker/repoB")


When the primary path is a local directory, from_pretrained takes its local branch and never calls download at all. Since the gate lives inside download, it is never reached. The custom_pipeline pointing at repoB is still resolved and imported, executing the attacker’s code with no check anywhere in the flow.

Local Snapshot & Custom Components

DiffusionPipeline.from_pretrained("/local/snapshot")

If the local snapshot’s model_index.json references a custom component file — for example a unet/my_unet_model.py — that component code is loaded and executed while assembling the pipeline.

Transformers Variant

Zafran Labs also disclosed a similar flaw in transformers, Hugging Face’s package for text-generation models (Transformers package has over 160K stars on Github).

Transformers has a similar pipeline architecture, and can run pipelines from remote repositories with from_pretrained. This flaw allows attackers to modify the code executed by a client after trust_remote_code was given, allowing for a similar statistical attack to CVE-2026-45804, turning naive remote code into malicious code. It has been acknowledged by Hugging Face’s security team.

Similarly to diffusers, the different from_pretrained flows in transformers start by fetching the config file, and checking whether remote code needs to be executed. During this step, HEAD is resolved and the commit hash is stored by the function, where it is used to pin the commit in most flows.

# pipelines/__init__.py:660 — same pattern exists in every Auto from_pretrained
resolved_config_file = cached_file(pretrained_model_name_or_path, CONFIG_NAME, **hub_kwargs)
hub_kwargs["_commit_hash"] = extract_commit_hash(resolved_config_file, commit_hash)


If the repo requires code execution, and trust_remote_code was given, get_class_from_dynamic_module is called. This function pulls the actual code file from the repo and executes.

However, some flows do not propagate this hash forward, meaning that get_class_from_dynamic_module re-resolves the commit, choosing a newer commit if one was added during this time window.

# dynamic_module_utils.py:476
def get_class_from_dynamic_module(
    class_reference,
    pretrained_model_name_or_path,
    ...,
    revision: str | None = None,
    code_revision: str | None = None,
    **kwargs,
) -> type:
    ...

    final_module = get_cached_module_file(
        repo_id,
        module_file + ".py",
        ...,
        revision=code_revision,
        # _commit_hash is never passed
    )


This breaks the commit-pinning and gives an attacker a window to push new, potentially malicious code, between the config fetch and the module file fetch. A warning will be printed, detailing the risk posed by the commit discrepancy, but the code will be executed nevertheless.

A new version of the following files was downloaded from https://huggingface.co/{model}:
- {file}
. Make sure to double-check they do not contain any added malicious code. To avoid downloading new versions of the code file, you can pin a revision.


An author of a trusted, known repo, which usually contains benign remote code, may periodically add malicious code to the repo and immediately revert & remove the malicious commit from history. This will achieve statistical success, and while a warning will be printed, the repo’s trusted reputation may dissuade the clients from thoroughly auditing it.

Takeaways

These vulnerabilities underscore the critical need to treat AI model repositories as untrusted code, particularly as enterprise reliance on platforms like Hugging Face continues to grow. A routine model download can easily become a vector for arbitrary code execution if security boundaries like trust_remote_code are bypassed.

To protect your systems, immediately upgrade the diffusers library to version 0.38.0 or later, which includes critical patches that move the security checks to the dynamic-module loading chokepoint and close the identified bypass variants. Always exercise caution when loading remote models and consider pinning specific revisions to ensure consistency and security.

Disclosure Timeline

March 19, 2026 — Zafran Labs reported the None.py Trust Remote Code Bypass and TOCTOU Trust Remote Code Bypass to Hugging Face.
April 30, 2026 — Hugging Face released Diffusers version 0.38.0.
May 1-7, 2026 — Hugging Face acknowledged the findings and started the CVE assignment process.
May 14, 2026 — CVE-2026-44827 was published for the None.py Trust Remote Code Bypass.
May 20, 2026 — CVE-2026-45804 was published for the TOCTOU Trust Remote Code Bypass.

A Practical Guide: Evolving from VM to CTEM

Traditional vulnerability management must change. So many are drowning in detections, and still lack insights. The time-to-exploit window sits at 5 days. Implementing a Continuous Threat Exposure Management (CTEM) program is the path forward. Moving from vulnerability management to CTEM doesn't have to be complicated. This guide outlines steps you can take to begin, continue, or refine your CTEM journey.

Download Now
CTEM Whitepaper cover
Discover how Zafran Security can streamline your vulnerability management processes.
Request a demo today and secure your organization’s digital infrastructure.
Request Demo
On This Page
Share this article: