Key Takeaway
Hugging Face's July 2026 incident began with a malicious dataset that ran code through a remote-code loader and template injection in its configuration. From the worker, the attacker reached the node, then cluster credentials. Defenders should treat uploaded datasets and configs as untrusted programs, isolate workers, restrict admission and rotate tokens.
On 16 July 2026 Hugging Face published a short disclosure that most of the ML world will have read as someone else's problem. A dataset, the thing every training script downloads without a second thought, had been used to run code inside the platform's own infrastructure. I read the post twice before I noticed what it does not contain: no CVE, no exact dates, no named library version. The detail is thin, and the useful part is the shape of the attack.
This is not a re-telling of the incident, and I have no inside knowledge of Hugging Face's systems. I read the disclosure itself, the release notes and docs for the tools it implicates, and the Kubernetes and Jinja documentation for the controls that apply. Every claim below traces to one of those pages, and where the disclosure is silent I say so rather than fill the gap. No exploit payloads appear here, only the defender's side.
What the disclosure says, and what it leaves out
The post describes a malicious dataset that abused two code-execution paths in dataset processing: a remote-code dataset loader and template injection in a dataset configuration. The attacker ran code on a processing worker, escalated to node-level access, harvested cloud and cluster credentials, and then moved laterally into several internal clusters. Hugging Face says that movement happened over a weekend and that it detected and responded earlier in the week of publication.
The outcome it reports is narrower than the path. The attacker got unauthorised access to a limited set of internal datasets and several service credentials. Hugging Face says it found no evidence of tampering with public, user-facing models, datasets or Spaces, and that it verified its container images and published packages as clean. It is still assessing whether partner or customer data was affected and says it will contact affected parties directly.
Two details deserve more attention than they got. The campaign was run by an autonomous agent framework, with many thousands of actions across short-lived sandboxes and command-and-control staged on public services, and the model behind it is unknown. And the incident was first surfaced through AI-assisted detection, where LLM-based triage correlated the signals. What is absent matters for how much you should infer: there are no dates, no library names, no version numbers and no cloud provider. Anything more specific than the paragraph above is speculation, including mine.
A dataset is an input to a program, and some inputs are programs
The useful reading is not 'datasets are dangerous'. It is that every stage between upload and the node is a place where data is interpreted, and each interpretation grants the uploader something. Laid out as a chain, the attack stops looking exotic. Each row below is a stage the disclosure names or implies, what that stage trusted, and the control that would have narrowed it.
| Stage | What it trusted | Control that narrows it |
|---|---|---|
| Dataset loader | That a repository's own loading code is safe to execute | Never opt in to remote code; name the packaged format yourself |
| Dataset configuration | That a string in the config is data, not a template to evaluate | No template engine on uploaded config; sandbox only if unavoidable |
| Processing worker | That code running in a pod stays in the pod | Pod Security Admission and admission policy on the worker namespace |
| Node and cloud identity | That a container cannot read the node's cloud credentials | Metadata service hardening, least-privilege node roles |
| Internal clusters | That one cluster's credentials are not valid in another | Per-cluster identities, short-lived credentials, rotation on suspicion |
Read the right-hand column as a list of questions to ask of your own pipeline, not as a claim about what Hugging Face lacked. The disclosure does not say which of these controls were missing, only that it afterwards closed the code-execution paths, rebuilt nodes and added stricter admission controls. The pattern is also not new for ML specifically: Hugging Face's own Hub documentation says plainly that pickle, the default format for PyTorch weights, can execute arbitrary code at load time, and that its scanner is not foolproof. Datasets turned out to have the same property.
The remote-code loader: opt-in is a trust decision
A dataset loading script is Python that the dataset's author ships alongside the data. The Hugging Face datasets documentation for the main branch says that for security reasons the library does not run these scripts by default and that you must pass trust_remote_code=True to load a dataset that needs one. That flag is the whole boundary: it is a person, or a pipeline, saying 'run what this repository told me to run'. On a processing worker that ingests other people's datasets, that sentence is the vulnerability.
# 1. Find every place your own code opts in to running a dataset's code.
grep -rnE "trust_remote_code\s*=\s*True" --include="*.py" .
# 2. Know which datasets release each worker image really has. Release notes
# for 4.0.0 list "Remove scripts altogether" and say trust_remote_code is
# no longer supported - but the main docs page still describes scripts.
# Believe the installed package, not either page.
pip show datasets | grep -i "^Version"
# 3. A third-party dataset with data files needs no code at all. Name the
# packaged builder yourself so a repo-supplied script is never consulted.
python - <<'EOF'
from datasets import load_dataset
# Wrong: the repo decides how it gets loaded (script, builder, config).
# ds = load_dataset("some-org/some-dataset")
# Right: you decide the format; the repo only supplies bytes.
ds = load_dataset(
"parquet",
data_files="hf://datasets/some-org/some-dataset/data/train-00000-of-00001.parquet",
split="train",
)
print(ds.num_rows)
EOFThere is a wrinkle I could not resolve from the pages I read. The 4.0.0 release notes for the datasets library list 'Remove scripts altogether' as a breaking change and state that trust_remote_code is no longer supported, yet the main-branch docs page still describes scripts and the flag. The disclosure does not say that the library was involved at all, or which version a worker ran. So the honest advice is the boring one: check the version installed in every worker image, and do not assume an old image or a pinned dependency has the newer behaviour.
Two practical rules follow. First, search your own code for the opt-in, because a flag set once in a helper function quietly applies to every dataset passing through it. Second, move the decision about format out of the repository: if the data is Parquet, JSON or CSV, load it with the packaged builder and a file path, so there is no code path in which the uploader's script is consulted. The block above does both. I have not run it against Hugging Face's stack or anyone else's, so test it on a dataset you control.
Put the grep in CI as a failing check on any change that adds trust_remote_code=True. A reviewer skimming a diff will wave through one extra keyword argument; a red build will not.
Template injection in configuration: the second path
The second path was template injection in a dataset's configuration. The disclosure names the class, not the engine, so I will not guess at which one was involved. The generic mechanism is the same everywhere: a config field is meant to be a string, someone passes it to a template renderer to fill in a name or a path, and a template language that can reach attributes and functions becomes a way to run code. Jinja is the common Python example, and its documentation is useful because it is candid about the limits of the fix.
# Wrong: a string from an uploaded config becomes a program.
from jinja2 import Environment
def render_name(cfg: dict, row: dict) -> str:
return Environment().from_string(cfg["name_template"]).render(row=row)
# Better: Jinja's sandbox. Insecure access raises SecurityError, but the docs
# say plainly that "The sandbox alone is not a solution for perfect security."
from jinja2.exceptions import SecurityError
from jinja2.sandbox import ImmutableSandboxedEnvironment
SANDBOX = ImmutableSandboxedEnvironment()
def render_name_sandboxed(cfg: dict, row: dict) -> str:
try:
return SANDBOX.from_string(cfg["name_template"]).render(row=row)
except SecurityError:
raise ValueError("template rejected") # reject the dataset, do not retry
# Best: no template engine at all. Substitution over a fixed key list cannot
# reach an attribute, a filter or a function, because there is nothing to reach.
ALLOWED_KEYS = ("id", "split", "language")
def render_name_plain(cfg: dict, row: dict) -> str:
out = cfg["name_pattern"]
for key in ALLOWED_KEYS:
out = out.replace("@" + key + "@", str(row.get(key, "")))
return outThe Jinja sandbox docs say the sandbox can be used to render untrusted templates, that insecure access raises SecurityError, and, in the same breath, that other exceptions may occur during rendering so the caller has to catch them. They also say 'The sandbox alone is not a solution for perfect security.' ImmutableSandboxedEnvironment is the stricter variant that blocks changes to the builtin list, set and dict. The code above shows the three tiers: the version that causes incidents, the sandboxed version, and the version with no template engine at all.
I prefer the third. A fixed list of substitution keys cannot reach an attribute, a filter or a function, so there is no sandbox to escape from and nothing to keep patched. The cost is real: you lose conditionals and loops in config, and users who liked them will complain. If you keep a template engine, treat a SecurityError as a reason to reject the whole dataset, never to retry with a looser environment.
From a worker pod to the node: where blast radius is decided
Code execution on a processing worker is bad but bounded. What turned it into an incident was the next step: node-level access, then cloud and cluster credentials, then lateral movement. The controls here are all Kubernetes-native and none are exotic. Pod Security Admission, stable since Kubernetes v1.25, applies the Pod Security Standards per namespace through labels. Its enforce mode rejects the pod, while audit and warn only record or tell the client. One detail from the docs bites people: enforce applies to Pod objects, not to Deployments or Jobs, and the page warns against exempting controller service accounts because that exempts anyone who can create the workload.
# Namespace that runs untrusted-input workers. Pod Security Admission, stable
# since Kubernetes v1.25. enforce rejects the Pod; warn only tells the client.
apiVersion: v1
kind: Namespace
metadata:
name: dataset-workers
labels:
pod-security.kubernetes.io/enforce: baseline
pod-security.kubernetes.io/warn: restricted
pod-security.kubernetes.io/audit: restricted
---
# Your own rule on top, with ValidatingAdmissionPolicy (stable since v1.30).
# This one refuses any Pod that mounts a hostPath volume. My example, not the docs'.
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
name: no-hostpath.example.com
spec:
failurePolicy: Fail
matchConstraints:
resourceRules:
- apiGroups: [""]
apiVersions: ["v1"]
operations: ["CREATE", "UPDATE"]
resources: ["pods"]
validations:
- expression: "!has(object.spec.volumes) || !object.spec.volumes.exists(v, has(v.hostPath))"
message: "hostPath volumes are not allowed in dataset-workers"
---
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicyBinding
metadata:
name: no-hostpath-binding.example.com
spec:
policyName: no-hostpath.example.com
validationActions: [Deny]
matchResources:
namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: dataset-workersThe Baseline level of the Pod Security Standards forbids the pod-to-host bridges that matter most here: it requires hostNetwork, hostPID and hostIPC to be unset or false, requires securityContext.privileged to be unset or false, and forbids hostPath volumes outright. ValidatingAdmissionPolicy, stable since v1.30, lets you add your own rule in CEL, with validationActions of Deny, Warn or Audit. The manifest above enforces Baseline on a worker namespace, audits against Restricted, and adds a hostPath rule that is my own example, not one from the docs.
# If the nodes are EC2 instances: require IMDSv2 and keep the PUT response hop
# limit at 1 (the default), so a process inside a container cannot mint a
# metadata token. A request without a valid token then gets 401.
# Hugging Face did not say which cloud it runs on - this is one provider's lever.
aws ec2 modify-instance-metadata-options \
--instance-id i-0123456789abcdef0 \
--http-tokens required \
--http-put-response-hop-limit 1The node's cloud identity is the other half. The disclosure says credentials were harvested but not from where, so I cannot say a metadata service was involved. If your nodes are EC2 instances, AWS documents that IMDSv2 is session-oriented, that a request without a valid token gets a 401 once tokens are required, and that the PUT response hop limit defaults to 1 at the IP protocol level. Setting tokens to required and leaving the hop limit at 1 is the lever, shown in the second block above. AWS also notes you might raise the hop limit for backward compatibility with container services, which is exactly the setting to audit.
The trade-offs, and what this does not fix
Every control above has a cost, and pretending otherwise is how these checklists get abandoned. Enforcing Baseline on a worker namespace will reject legitimate pods that use hostPath for a scratch volume or a device plugin, so you will spend a week finding them. Refusing remote code means some older datasets simply cannot be ingested, and the answer is to ask the author to convert to a standard format, not to carve an exception. Removing templating from config breaks a feature someone uses.
There is also a limit to what the disclosure lets you conclude. It does not say whether the exploited loader was in the open-source library, an internal fork or custom processing code. It gives no version, no date and no provider. Hugging Face also says its assessment of partner and customer data is still open. A defender who copies a control list from a disclosure this short is guessing which of the controls would have mattered, and should say so in the design review.
Do not read 'no evidence of tampering with public models, datasets or Spaces' as 'nothing to do'. It is a statement about what Hugging Face verified on its side. Your own tokens, your own cached datasets and any pipeline that pulled from the platform during the incident window are still yours to review, and the disclosure's own advice is to rotate access tokens and check recent account activity.
One more caveat concerns forensics. Hugging Face says its analysis was initially blocked by guardrails on hosted commercial models, so it ran the analysis on the open-weight model zai-org/GLM-5.2 on its own infrastructure, keeping attacker data and credentials inside its environment. It recommends having a capable self-hosted model vetted before an incident. That is a planning decision with its own costs, hardware and review, not something to improvise at the moment you need it.
What Hugging Face changed, and your equivalent
The remediation list in the disclosure is a better template than most vendor post-mortems because it is concrete. The table maps each item to what a team running its own dataset or model ingestion could do. The right-hand column is my translation, not Hugging Face's.
| Hugging Face did | Your equivalent |
|---|---|
| Closed the dataset code-execution paths used for initial access | Remove trust_remote_code and config templating from every ingestion path |
| Eradicated the foothold across affected clusters and rebuilt compromised nodes | Rebuild nodes from images instead of cleaning them; treat a node that ran untrusted code as burned |
| Revoked and rotated affected credentials, then began a broader precautionary rotation | Rotate in blast-radius order, starting with anything the worker or node could read |
| Added guardrails and stricter cluster admission controls | Pod Security Admission plus a ValidatingAdmissionPolicy on worker namespaces |
| Improved detection and alerting for high-severity signals | Alert on a worker pod spawning shells, reaching metadata addresses or calling other clusters |
If you use Hugging Face, the disclosure's own instruction is to rotate access tokens as a precaution and review recent activity on your account, and to report concerns to security@huggingface.co. If you run your own ingestion, rotate in this order, because the order is what limits damage if you are interrupted halfway.
- Credentials the worker could read directly: environment variables, mounted secrets and service account tokens.
- Credentials the node could read: its cloud role, instance profile and any registry or image pull secrets.
- Cluster credentials that worker or node identities could use against other clusters, including kubeconfigs and bearer tokens.
- Personal and CI access tokens for the Hub or any dataset source the pipeline pulled from, issued with the narrowest scope that works.
- Everything else, as a precaution, once the first four are done and the old values are confirmed dead rather than merely replaced.
What to do on Monday
None of this needs a project. It needs an afternoon, a list of where your pipeline ingests data you did not write, and the willingness to find out you have some.
- Run the grep for trust_remote_code across every repository and notebook that feeds a shared worker, and record the installed datasets version in each worker image.
- List every config field that reaches a template renderer. For each, decide: remove the engine, sandbox it, or confirm the field is never user-supplied.
- Label the namespace that runs ingestion with Pod Security Admission enforce at baseline and warn and audit at restricted, then read what the warnings say for a week before tightening.
- Check what a pod can reach from inside: the node's metadata endpoint, other clusters' API servers and mounted credentials. If you are on EC2, require IMDSv2 and confirm the hop limit.
- Write down your rotation order and who can execute it at 03:00. A checklist nobody has rehearsed is a document, not a control.
Then re-read the Hugging Face disclosure with your own pipeline in mind and mark each sentence as 'we are exposed', 'we are not' or 'we do not know'. The third pile is the real result.
The lesson I take is a rule, not a list: anything a stranger uploads that is later opened by a program is a program, whatever file extension it wears. Loaders, config templates and weights all fit. Decide the format yourself, give the interpreter nothing to evaluate, assume the worker will eventually run something hostile, and make sure that what it can reach from there is small, short-lived and rotatable.
Sources and further reading
- Hugging Face: Security incident disclosure, July 2026
- Hugging Face datasets 4.0.0 release notes (breaking changes)
- Hugging Face datasets docs: create a dataset loading script
- Jinja documentation: the sandbox
- Kubernetes docs: Pod Security Admission
- Kubernetes docs: Pod Security Standards
- Kubernetes docs: ValidatingAdmissionPolicy
- AWS docs: use the Instance Metadata Service to access instance metadata
- Hugging Face Hub docs: pickle scanning

