Skip to content
Back to the Lab
Reverse Engineering

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.

A dark Egnworks banner representing malicious instructions taking control of an AI coding agent.

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 behaviorTrust boundary crossedDurable evidence
Write hidden agent filesMalware to model contextNew or changed files in tool-owned directories
Add startup or build commandsRepository data to host executionHook, task, command, or shell configuration
Instruct an agent to run setup.mjsNatural language to tool invocationAgent transcript plus child-process telemetry
Detect a CI runnerEndpoint process to build identityReads of CI variables, runner memory, or token endpoints
Obtain OIDC materialBuild job to trusted publisherToken request followed by unexpected publish activity
Create Copilot Setup jobsMalicious workflow to trusted automationNew YAML jobs and unexplained workflow dispatches
Delete run recordsWorkload identity to control planeGitHub audit or API telemetry without a matching UI record
Poison loader commentsSource text to security-review policyHigh-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.

LayerAttacker objectiveDefender question
Package or repositoryDeliver trusted-looking contentWhich identity introduced the first changed artifact?
Workspace configurationSurvive and trigger executionWhich files can make an agent, IDE, or shell act?
JavaScript loaderHide collection and control logicWhat does decoded syntax do without executing it?
CI runnerReach ephemeral credentialsWhy could this job request OIDC or publish?
RegistryDistribute a signed compromiseDid 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.

QuestionEvidence sourceUnsafe shortcut
What triggers the loader?Rules, hooks, tasks, workflow stepsOpening the workspace normally
What runtime does it expect?Extension, shebang, imports, package metadataInstalling missing dependencies
What secrets can it see?Environment reads and referenced pathsRunning it to observe output
How is content hidden?Decoder calls and string transformsPasting the whole file into one scanner
Where can data leave?Static URLs, DNS names, socket APIsAllowing live callbacks in a sandbox
How does it persist?Changed project configurationDeleting 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.

ControlDUSTMAKER behavior constrainedVerification
Review protected control pathsHidden workspace modificationRequired owner approval on every change
Default-deny agent toolsPrompt-driven command executionShell and network require explicit approval
Job-scoped OIDC permissionRunner token accessOnly release job has id-token: write
Immutable external audit exportWorkflow-record deletionControl-plane events exist outside GitHub
SHA-pinned actionsDependency replacementEvery external uses: resolves to a full SHA
Ephemeral isolated runnersCross-job persistenceClean image and no reused workspace
Registry publication policyTrusted-publisher abuseExpected 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

Cursor project rules documentation

Claude Code MCP documentation

GitHub Actions script injection guidance