DUSTMAKER Malware Hijacks AI Coding Agents
Reverse engineer how DUSTMAKER turns trusted AI coding files and CI workflows into execution, credential theft, persistence, and propagation paths.

DUSTMAKER does not need to exploit a memory corruption bug in an AI model. It attacks the machinery around the model instead. The credential stealer places instructions, hooks, and startup behavior inside project directories that developers already expect tools such as Claude Code, Cursor, Visual Studio Code, and CI runners to read. A human opens a repository, an agent follows project context, or a workflow starts. The trusted automation then supplies execution, credentials, or a path into the software supply chain.
Google Threat Intelligence Group attributed this activity to UNC6780, also known as TeamPCP, a financially motivated actor that compromised npm, PyPI, Docker Hub, developer accounts, MCP packages, and source repositories during 2026. GTIG reported that several techniques were implemented inside DUSTMAKER. The malware could recognize CI/CD environments, obtain GitHub Actions OIDC material from runner process memory, create AI-themed workflow tasks, alter hidden workspace configuration, and place adversarial instructions above an obfuscated JavaScript loader.
This article reverse engineers that published behavior as an execution system. It does not claim access to a public DUSTMAKER binary. GTIG did not publish sample hashes, a complete loader, command-and-control indicators, or a function-level disassembly with its report. The useful target is therefore the observable contract between the malware, the repository, the coding agent, and the CI runner. That contract exposes durable detection points even when payload bytes, filenames, and infrastructure change.
What Google Actually Observed#
UNC6780 has operated as a supply-chain actor since at least March 2026. According to GTIG, the group compromised legitimate developer accounts, published trojanized forks of MCP servers, and injected malicious code into official organizational repositories. Named examples included tiktoken_mcp on PyPI and azure-functions-mcp-extension in GitHub. These assets placed attacker-controlled code and workspace hooks where developers and coding agents would ingest them during normal clone, install, or setup operations.
DUSTMAKER extended that access rather than acting as a conventional endpoint stealer alone. GTIG described five behaviors that interact directly with AI-assisted development. The malware wrote to hidden tool directories such as .claude, .cursor, and .vscode. It configured automatic build or startup actions. It used project instructions to persuade an agent to run a loader such as setup.mjs. In CI it created tasks with names such as Copilot Setup, searched for tokens and keys, propagated, and attempted to remove workflow execution records through API calls.
The fifth behavior targeted the reviewer. GTIG found extreme adversarial text in comments at the beginning of _index.js loaders. The apparent purpose was not to influence JavaScript execution. Comments do not execute. The text was positioned to trigger policy refusal or skipped analysis when an LLM security scanner examined the file, leaving the obfuscated code below it less likely to receive a complete review.
| Published behavior | Trust boundary crossed | Durable evidence |
|---|---|---|
| Write hidden agent files | Malware to model context | New or changed files in tool-owned directories |
| Add startup or build commands | Repository data to host execution | Hook, task, command, or shell configuration |
Instruct an agent to run setup.mjs | Natural language to tool invocation | Agent transcript plus child-process telemetry |
| Detect a CI runner | Endpoint process to build identity | Reads of CI variables, runner memory, or token endpoints |
| Obtain OIDC material | Build job to trusted publisher | Token request followed by unexpected publish activity |
Create Copilot Setup jobs | Malicious workflow to trusted automation | New YAML jobs and unexplained workflow dispatches |
| Delete run records | Workload identity to control plane | GitHub audit or API telemetry without a matching UI record |
| Poison loader comments | Source text to security-review policy | High-risk instructions adjacent to encoded JavaScript |
The phrase “hijacks AI coding agents” needs this precision. DUSTMAKER is not reported to compromise model weights or break the provider’s inference service. It poisons files and automation surfaces that an agent is designed to trust. The model becomes one interpreter in a larger attack chain.
The Repository Is the Execution Layer#
A modern coding environment contains more than source code. Project rules shape model behavior. Editor tasks can start programs. Extension recommendations influence installation. MCP configuration introduces external processes or services. CI workflows receive repository credentials and sometimes federated cloud identity. Treating all of those artifacts as harmless metadata creates the exact boundary DUSTMAKER exploits.
Cursor documents project rules as version-controlled files under .cursor/rules. Its CLI can also read AGENTS.md and CLAUDE.md from a repository. Claude Code supports project-scoped MCP configuration and hooks. Visual Studio Code has workspace tasks and settings. GitHub Actions interprets YAML under .github/workflows. The file formats differ, but their security property is the same: repository content changes what a privileged tool will read or do.
flowchart TD
A["Compromised package or repository"] --> B["Hidden agent and IDE files"]
B --> C["Developer opens workspace"]
B --> D["Coding agent loads instructions"]
B --> E["CI runner starts workflow"]
C --> F["Hook or task executes loader"]
D --> F
E --> G["OIDC and package identity exposed"]
F --> H["Credentials and propagation"]
G --> H
This creates two execution modes. In deterministic execution, an editor task, hook, workflow step, or configured command directly launches a process. In semantic execution, a natural-language instruction enters agent context and causes the model to select a tool. The first mode can often be found with configuration parsers. The second requires reviewing both the instruction and the permission boundary around agent tools.
The distinction matters during incident response. Removing a malicious sentence does not neutralize a task that still launches the loader. Removing the task does not remove an instruction that asks the agent to recreate or run it. The entire repository control plane must be reviewed together.
Reconstructing the DUSTMAKER Control Flow#
The published behavior supports a five-stage model. Initial access arrives through a compromised developer identity, package, fork, MCP integration, or repository. DUSTMAKER then fingerprints its environment. A workstation offers agent directories, editor configuration, local tokens, and developer credentials. A GitHub Actions runner offers ephemeral job state, workflow permissions, package-publishing context, and potentially OIDC request material.
The malware next establishes an execution trigger. On a workstation that may be a workspace hook, editor task, startup command, or agent instruction. In CI it may be a new or altered workflow job whose name resembles a legitimate AI setup step. The trigger launches or instructs the launch of a JavaScript loader. Obfuscation hides ordinary program structure from a quick review, while adversarial comments attempt to influence LLM-based analysis before the scanner reaches the code.
Credential access follows the environment. GTIG specifically reported that DUSTMAKER extracted GitHub Actions OIDC tokens from runner process memory. GitHub’s OIDC flow exposes request capability to authorized jobs through ACTIONS_ID_TOKEN_REQUEST_URL and ACTIONS_ID_TOKEN_REQUEST_TOKEN. A job needs id-token: write to request a JWT. That permission does not directly grant resource modification, but the resulting JWT can be exchanged according to the trust policy configured by a cloud or publishing service.
The final stage turns stolen build identity into propagation. GTIG reported that DUSTMAKER could publish compromised packages as a trusted publisher with valid SLSA Build Level 3 attestations. The cryptographic statement can still be valid for the workflow that produced it. The failure is that the workflow or runner was no longer trustworthy. Attestation verifies provenance claims and build identity; it does not prove that repository instructions, workflow code, or runner memory were clean.
| Layer | Attacker objective | Defender question |
|---|---|---|
| Package or repository | Deliver trusted-looking content | Which identity introduced the first changed artifact? |
| Workspace configuration | Survive and trigger execution | Which files can make an agent, IDE, or shell act? |
| JavaScript loader | Hide collection and control logic | What does decoded syntax do without executing it? |
| CI runner | Reach ephemeral credentials | Why could this job request OIDC or publish? |
| Registry | Distribute a signed compromise | Did the expected source, workflow, and reviewer produce this release? |
This chain explains why an endpoint-only view is incomplete. The filesystem may contain nothing more conspicuous than Markdown, JSON, YAML, and an obfuscated .js file. The meaningful behavior appears when those files are interpreted by an agent, IDE, runner, or package service.
Triage Workspace Files Without Running Them#
The scanner below inventories common agent, editor, MCP, and GitHub Actions surfaces. It reads files as bytes, computes SHA-256 hashes, identifies recently changed files through git diff, and scores combinations associated with command execution, secret access, OIDC use, obfuscation, and workflow-log deletion. It never imports, evaluates, parses as executable code, or launches content from the target repository.
#!/usr/bin/env python3
import argparse
import hashlib
import json
import re
import subprocess
from pathlib import Path
SURFACES = (
".claude",
".cursor",
".vscode",
".github/workflows",
".mcp.json",
"AGENTS.md",
"CLAUDE.md",
)
RULES = {
"process_execution": re.compile(
rb"(?:child_process|execFile|spawnSync|\bspawn\b|\bexec\b|shell\s*[:=]|run\s*:)",
re.IGNORECASE,
),
"network_fetch": re.compile(
rb"(?:\bcurl\b|\bwget\b|https?://|fetch\s*\(|Invoke-WebRequest)",
re.IGNORECASE,
),
"secret_access": re.compile(
rb"(?:process\.env|\.npmrc|\.aws|\.ssh|secrets\.json|config\.yaml|GITHUB_TOKEN)",
re.IGNORECASE,
),
"oidc_access": re.compile(
rb"(?:ACTIONS_ID_TOKEN_REQUEST_(?:URL|TOKEN)|id-token\s*:\s*write|getIDToken)",
re.IGNORECASE,
),
"obfuscation": re.compile(
rb"(?:fromCharCode|atob\s*\(|Buffer\.from\s*\([^\n]{0,160}base64|eval\s*\(|Function\s*\()",
re.IGNORECASE,
),
"run_deletion": re.compile(
rb"(?:actions/runs/.{0,100}|workflow run).{0,80}(?:DELETE|delete)",
re.IGNORECASE | re.DOTALL,
),
"agent_instruction": re.compile(
rb"(?:ignore (?:all )?(?:previous|prior)|system override|run (?:this|the) (?:command|script)|do not (?:show|tell|mention))",
re.IGNORECASE,
),
}
WEIGHTS = {
"process_execution": 3,
"network_fetch": 2,
"secret_access": 4,
"oidc_access": 5,
"obfuscation": 3,
"run_deletion": 6,
"agent_instruction": 4,
}
def git_changes(root: Path, base: str) -> set[str]:
command = ["git", "-C", str(root), "diff", "--name-only", "--diff-filter=ACMR", base, "--"]
try:
result = subprocess.run(command, check=True, capture_output=True, text=True)
except (OSError, subprocess.CalledProcessError):
return set()
return {line.strip() for line in result.stdout.splitlines() if line.strip()}
def candidates(root: Path):
seen = set()
for surface in SURFACES:
path = root / surface
paths = path.rglob("*") if path.is_dir() else (path,)
for candidate in paths:
if not candidate.is_file() or candidate in seen:
continue
seen.add(candidate)
yield candidate
def inspect(root: Path, path: Path, changed: set[str]) -> dict:
relative = path.relative_to(root).as_posix()
try:
data = path.read_bytes()
except OSError as error:
return {"path": relative, "error": str(error)}
sample = data[:2_000_000]
matches = sorted(name for name, pattern in RULES.items() if pattern.search(sample))
score = sum(WEIGHTS[name] for name in matches)
if relative in changed:
score += 2
return {
"path": relative,
"sha256": hashlib.sha256(data).hexdigest(),
"bytes": len(data),
"changed": relative in changed,
"signals": matches,
"score": score,
}
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("root", type=Path)
parser.add_argument("--base", default="HEAD~1")
parser.add_argument("--minimum-score", type=int, default=1)
args = parser.parse_args()
root = args.root.resolve()
changed = git_changes(root, args.base)
findings = [inspect(root, path, changed) for path in candidates(root)]
findings = [item for item in findings if item.get("score", 0) >= args.minimum_score]
findings.sort(key=lambda item: (-item["score"], item["path"]))
print(json.dumps({"root": str(root), "base": args.base, "findings": findings}, indent=2))
return 1 if any(item["score"] >= 8 for item in findings) else 0
if __name__ == "__main__":
raise SystemExit(main())
Save it outside the repository under investigation, then run it against a read-only checkout. The nonzero exit status indicates at least one high-score combination, not confirmed malware.
python3 agent_surface_audit.py /srv/repository --base origin/main > agent-surface.json || true
python3 -m json.tool agent-surface.json >/dev/null
sha256sum agent-surface.json
A match on run: inside a workflow is expected. A match on process.env in a build script may also be legitimate. The signal becomes valuable when boundaries combine: a newly added .cursor rule references a hidden loader, the loader contains process execution plus encoded strings, and the workflow grants id-token: write. Scoring preserves that context instead of turning one keyword into a malware verdict.
The scanner also avoids recursively searching the entire repository. DUSTMAKER’s reported advantage came from hiding inside configuration surfaces that defenders may review less closely. Starting with those surfaces provides a focused, explainable triage set. A second pass can inspect loaders referenced by the findings, but analysts should copy and decode them in an isolated environment rather than invoking the repository’s package manager or task runner.
Reverse Engineer the Loader Safely#
An obfuscated JavaScript loader should be treated as data first. Record the original hash, isolate the file, identify its runtime, and extract strings and syntax without calling node, npm install, or an IDE task. A parser can still be dangerous if it loads plugins or resolves dependencies, so use a disposable container without network access, credentials, mounted SSH agents, cloud metadata access, or write access to the evidence.
The first pass should answer structural questions. Determine whether the file imports child_process, networking modules, filesystem APIs, or environment variables. Find decoders such as atob, Buffer.from, string-array lookups, and dynamic Function construction. Record referenced paths and domains without connecting to them. Compare the loader with the instruction, task, hook, or workflow that reaches it.
The adversarial comment must not control the analysis. Strip comments only in a derived copy and retain the original as evidence. Then give a security scanner narrowly defined output requirements: enumerate imports, decoded constants, command-construction sites, environment reads, filesystem paths, and network destinations. Do not ask the model whether the whole file is “safe.” A refusal on one comment must not become a clean verdict for the remaining syntax.
| Question | Evidence source | Unsafe shortcut |
|---|---|---|
| What triggers the loader? | Rules, hooks, tasks, workflow steps | Opening the workspace normally |
| What runtime does it expect? | Extension, shebang, imports, package metadata | Installing missing dependencies |
| What secrets can it see? | Environment reads and referenced paths | Running it to observe output |
| How is content hidden? | Decoder calls and string transforms | Pasting the whole file into one scanner |
| Where can data leave? | Static URLs, DNS names, socket APIs | Allowing live callbacks in a sandbox |
| How does it persist? | Changed project configuration | Deleting only the JavaScript file |
GTIG’s description of _index.js provides a useful review pattern. If a file begins with policy-triggering prose and continues with obfuscated code, treat the two layers as separate mechanisms. The comment targets the reviewer. The JavaScript targets the machine.
Hunt the CI Identity Path#
GitHub Actions deserves separate triage because its credentials are short-lived and may no longer exist when responders arrive. Review the commit history of .github/workflows, not only the current tree. Search for new AI-themed job names, unexpected id-token: write, publishing steps, self-hosted runner labels, changes to pull_request_target, and API calls that manage workflow runs.
GitHub recommends granting the minimum required GITHUB_TOKEN permissions and pinning third-party actions to full-length commit SHAs. The same least-privilege rule applies to OIDC. Only the job that exchanges an identity should receive id-token: write. A repository-wide permission makes the request endpoint available to unrelated or newly injected jobs.
Correlate four timelines: the commit that changed automation, the workflow run, the OIDC or registry authentication event, and the package publication. A deleted workflow record is not absence of execution. Organization audit logs, cloud identity-provider logs, package-registry records, runner telemetry, and immutable external logs can preserve the chain that a repository API view no longer shows.
Attestations require contextual verification after a runner compromise. Confirm the repository, commit, workflow path, reusable-workflow identity, runner class, environment, and human approval that should have produced the release. A valid signature from an unexpected workflow is evidence of authenticated abuse, not evidence that the artifact is safe.
Detection That Survives a New Filename#
File indicators will age quickly. The stronger controls observe how repository data becomes execution. Monitor child processes whose ancestor is an AI coding CLI, editor extension host, or build runner. Alert when those processes read credential stores, request OIDC tokens outside approved jobs, modify agent configuration after checkout, or call workflow-management APIs.
Repository controls should require review for agent and automation paths. CODEOWNERS coverage can include .github/workflows/, .claude/, .cursor/, .vscode/, .mcp.json, AGENTS.md, and CLAUDE.md. Branch protection should prevent a compromised maintainer token from silently bypassing that review. CI should produce an inventory diff for these files before any setup hook or package lifecycle script runs.
On developer workstations, clone unknown repositories into a low-trust location and inspect their control files before launching an agent or IDE. Start agents with restricted tools and explicit approval for shell, network, MCP, and secret access. Workspace instructions are untrusted input even when they use the product’s expected filename.
| Control | DUSTMAKER behavior constrained | Verification |
|---|---|---|
| Review protected control paths | Hidden workspace modification | Required owner approval on every change |
| Default-deny agent tools | Prompt-driven command execution | Shell and network require explicit approval |
| Job-scoped OIDC permission | Runner token access | Only release job has id-token: write |
| Immutable external audit export | Workflow-record deletion | Control-plane events exist outside GitHub |
| SHA-pinned actions | Dependency replacement | Every external uses: resolves to a full SHA |
| Ephemeral isolated runners | Cross-job persistence | Clean image and no reused workspace |
| Registry publication policy | Trusted-publisher abuse | Expected workflow, environment, and approval |
The goal is not to ban repository instructions. They are useful because they carry project knowledge into an agent. The goal is to give them the same review status as build scripts. A Markdown file that can cause a privileged tool to execute a command is security-sensitive code, regardless of its extension.
Incident Response for an Agent-Poisoned Repository#
Do not open the suspected project in the normal development environment. Preserve the repository, local reflog, untracked files, editor storage, agent transcripts, shell history, process tree, sockets, runner logs, and control-plane audit events from a clean collection system. Hash each acquired artifact and record whether it came from Git, the working tree, an editor cache, or a runner filesystem.
Scope every clone and fork that may have consumed the poisoned files. Search commit history across all branches because the live tree may have been cleaned after execution. Identify which developers opened the repository, which agents loaded its context, which jobs ran the altered workflow, and which package versions were published from those runs.
Credentials must be rotated by capability, not by filename. Include developer tokens, package-registry credentials, cloud identities, AI provider keys, MCP credentials, SSH material, signing access, and secrets reachable from affected workflows. Revoke trusted-publisher sessions and investigate packages already released under valid attestations. Rotation should happen from clean systems after the malicious execution paths are disabled.
Rebuild compromised runners and workstations when execution is confirmed. Removing .cursor or .claude files does not prove that a loader failed to install a second persistence mechanism. Likewise, deleting one package release does not identify downstream consumers that already installed it.
The Reverse Engineering Lesson#
DUSTMAKER shifts the unit of analysis from one executable to a distributed interpreter chain. Markdown influences a model. JSON or editor settings influence a host tool. YAML controls a runner. JavaScript hides collection logic. OIDC turns the runner into a publisher. A package registry distributes the result. No single layer explains the compromise.
That is why behavioral reconstruction is useful even without public sample bytes. The attacker must still cross observable boundaries: create or change a control file, trigger a process, access an identity, publish an artifact, or erase a record. Those transitions are harder to rename than _index.js.
AI coding agents did not invent software supply-chain compromise. They added another trusted interpreter with access to code, terminals, credentials, and automation. DUSTMAKER’s important innovation is recognizing that instructions for this interpreter can live inside the same repository it is asked to trust. Defenders should respond by treating agent context as code, agent permissions as production privileges, and build identity as compromised until the entire control path is verified.
References#
GTIG AI Threat Tracker September 2026
GitHub Actions secure use reference
GitHub Actions OpenID Connect reference