The scanner became the attack

2026-03-25

On March 19th, 2026, thousands of CI/CD pipelines ran a credential stealer. None of their teams knew. The stealer came from Trivy, the open-source vulnerability scanner they'd installed to protect themselves.

Here's what happened, and what it means for every pipeline running security tooling.


How it started: an AI attacker, not a person

On February 27th, 2026, a GitHub account named MegaGame10418 sent a pull request to aquasecurity/trivy. The account operated with apparent automation: it systematically scanned repositories for exploitable GitHub Actions workflows (reads YAML configs, identifies misconfigurations, crafts convincing PRs, exfiltrates secrets) without a human deciding each step.

The misconfiguration was a pull_request_target workflow named "API Diff Check" (apidiff.yaml). This trigger type is one of the more dangerous patterns in GitHub Actions: unlike the standard pull_request trigger, pull_request_target runs in the context of the target branch (full repository write permissions, access to secrets) even for PRs from external forks. The agent exploited it to exfiltrate an org-scoped Personal Access Token belonging to aqua-bot, Aqua Security's service account.

Org-scoped means write access across all repositories in the organization, not just the one that was targeted.

This was a systematic scan of the open-source CI/CD ecosystem. Trivy was one result. If your repository has a pull_request_target workflow, this class of agent will find it. Visibility and obscurity don't factor in.


The first incident, and why it didn't end things

On March 1st, Aqua Security detected and disclosed the first breach. The attacker had used the stolen credentials to briefly privatize the Trivy repository, delete releases, and publish malicious VS Code extensions. Aqua rotated credentials and considered the incident contained.

The rotation wasn't atomic.

When you rotate credentials under time pressure, the sequence matters: revoke the old token, then issue the new one. If you issue the new token first (to avoid a gap in service), there's a window where both exist. An attacker monitoring for new token issuance can capture the replacement during that window and maintain access even after the original credential is invalidated.

TeamPCP captured the new tokens during Aqua's rotation. They had access for 18 more days.


March 19th

At approximately 17:43 UTC, the attacker used the retained write access to force-push malicious commits to aquasecurity/trivy-action. Not to the main branch — to the tags.

GitHub version tags are mutable. When your workflow file says:

uses: aquasecurity/trivy-action@0.33.0

GitHub resolves that tag to whatever commit it currently points to at runtime. If someone with write access force-updates that tag to point at a different commit, your workflow starts running that commit the next time it triggers. Nothing in your repository changed. No PR was opened. No notification was sent. Your workflow file still says 0.33.0.

TeamPCP force-updated 76 of 77 version tags in trivy-action: every version from 0.0.1 through 0.34.2. At 18:22 UTC they triggered release automation to publish a malicious Trivy binary as v0.69.4 across all distribution channels: GitHub Releases, GHCR, Docker Hub, ECR Public.

The only safe version of trivy-action was 0.35.0. The only safe Trivy binary was v0.69.3 or earlier.


What the payload did, in five stages

The compromised entrypoint.sh had approximately 105 lines of attack code prepended before the legitimate Trivy invocation. Five stages, all completing in the time it takes your pipeline to appear to start a normal scan.

Stage 1: Process discovery. Enumerate GitHub Actions runner processes: Runner.Worker, Runner.Listener, runsvc, run.sh. Extract environment variables containing "env" or "ssh" keywords via /proc/{PID}/environ. Build a target map.

Stage 2: Credential theft, two paths.

For GitHub-hosted runners: a base64-encoded Python script read /proc/{PID}/maps and then /proc/{PID}/mem, parsing for secrets matching the JSON structure "name":{"value":"...","isSecret":true}. This is GitHub's internal in-memory secret storage format. GitHub Actions' built-in secret masking redacts values from log output. It does nothing about reading them from process memory. Every secret injected into a hosted runner via env: or secrets: was potentially readable through this path.

For self-hosted runners: filesystem sweep across 50+ paths, including ~/.aws/credentials, /run/secrets/kubernetes.io/serviceaccount/token, ~/.docker/config.json, SSH private keys, CI/CD configs for GitLab/Travis/Jenkins/Drone, Terraform and Helm secrets, database passwords (MySQL, PostgreSQL, MongoDB, Redis), and Solana validator keys.

Stage 3: Encryption. AES-256-CBC with a randomly generated session key, wrapped in a hardcoded 4096-bit RSA public key. Output: tpcp.tar.gz.

Stage 4: Exfiltration. Primary target: HTTPS POST to scan.aquasecurtiy.org (typosquatted; extra 'r' in "security"), resolving to 45.148.10.212. Fallback: if a GitHub PAT was available, create a public repository named tpcp-docs in the victim's organization and upload the encrypted bundle as a release asset. Persistent, hosted on GitHub infrastructure, accessible to anyone.

Stage 5: Cleanup. All temporary files deleted. Legitimate Trivy scan runs normally. The workflow completes. Status checks pass.

CVE-2026-33634. CWE-506 (Embedded Malicious Code).

On developer machines that ran the compromised binary directly (not just via GitHub Actions), the payload additionally dropped ~/.config/systemd/user/sysmon.py, a persistence script with a systemd unit for automatic execution, and wrote payloads to /tmp/pglog (named after PostgreSQL log output as camouflage). The script polls an ICP blockchain canister (tdtqy-oyaaa-aaaae-af2dq-cai.raw.icp0.io) approximately every 50 minutes. ICP blockchain C2 is designed to survive infrastructure takedowns: no registrar to contact, no hosting provider to compel. At time of initial analysis the C2 was serving a RickRoll, but the payload is rotatable.


The cascade

The Trivy breach was a starting point.

On March 22nd, TeamPCP used stolen npm credentials to launch CanisterWorm across 47 npm packages. On March 23rd, they reused credentials from the Trivy breach to compromise Checkmarx's ast-github-action v2.3.28, the same payload with a different vendor-specific typosquat domain (checkmarx.zone). On March 24th, LiteLLM packages v1.82.7 and v1.82.8 were poisoned on PyPI.

Also on March 22nd, Aqua Security's internal GitHub organization was defaced: all 44 repositories renamed with a tpcp-docs- prefix and made public.

StepSecurity's Harden-Runner was the only external detection during the exposure window, catching the payload via anomalous outbound connections to the C2 domain. Most security tooling wasn't watching runner traffic at all.


The incident response problem

If your pipeline ran trivy-action between March 19 17:43 UTC and March 20 05:40 UTC on any version except 0.35.0, treat it as total compromise.

That phrase, "total compromise," has a specific operational meaning here. It means: don't investigate whether the payload ran before rotating credentials. Map every credential the affected runners could access, deactivate all of them simultaneously, then reissue. The investigation happens afterward, not as a prerequisite.

The reason is the same reason atomic rotation matters: if you investigate first and the attacker is monitoring your activity, they'll use the credentials before you rotate them. Deactivation buys you the window the investigation needs.

Three zones require separate treatment:

CI/CD pipelines. Rotate AWS credentials, GitHub PATs, Kubernetes service account tokens, Docker registry credentials, any secrets injected as environment variables. Check runner logs for connections to scan.aquasecurtiy.org or 45.148.10.212.

GitHub org. Search for a repository named tpcp-docs. Its existence confirms the fallback ran and secrets were exfiltrated to a public location. Delete it, but document it first: its contents are evidence.

Developer machines. Check for ~/.config/systemd/user/sysmon.py. If present, the machine has a persistence mechanism that survived the C2 takedown and may still be receiving instructions. This requires separate remediation from the CI/CD response.


What to actually do going forward

Pin GitHub Actions to commit SHAs.

# Before — mutable, attackable
uses: aquasecurity/trivy-action@0.33.0

# After — immutable
uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0

The SHA identifies a specific commit. The tag is a pointer that can be moved, force-pushed to any commit by anyone with write access, with no audit trail visible to consumers. Dependabot and Renovate both support SHA-pinned actions and open PRs when upstream versions change, so you get immutability without manual tracking.

Audit your pull_request_target workflows.

grep -r "pull_request_target" .github/workflows/

Automated accounts like MegaGame10418 are systematically scanning public repositories for this pattern. Any workflow using this trigger that also checks out PR code or runs with write permissions is a potential PAT exfiltration surface. Use pull_request instead, or strictly separate the trusted execution context from any untrusted code checkout.

Verify binary integrity with cosign.

Official Trivy releases are signed via sigstore. The key check: the signing timestamp must predate March 19, 2026.

cosign verify-blob \
  --certificate-identity-regexp 'https://github\.com/aquasecurity/' \
  --certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
  --bundle trivy_0.69.2_Linux-64bit.tar.gz.sigstore.json \
  trivy_0.69.2_Linux-64bit.tar.gz

Known-malicious SHA256 for v0.69.4 Linux-64bit: 822dd269ec10459572dfaaefe163dae693c344249a0161953f0d5cdd110bd2a0.

Treat CI/CD runners as monitored workloads.

StepSecurity caught this because they baseline runner behavior — scanners aren't supposed to make arbitrary HTTPS calls to external infrastructure. If you have no behavioral baseline for your CI/CD environment, you have no signal when something unexpected runs.

Check for persistence on developer machines.

ls ~/.config/systemd/user/sysmon.py ~/.config/sysmon.py /tmp/pglog 2>/dev/null

If any of these exist, the incident scope extends beyond CI/CD. Credential rotation doesn't address an active persistence mechanism. Developer machine compromise is a separate investigation.

Treat everything in your execution context as supply chain.

Not just your application dependencies. Not just your security scanners. Your builder image. Your base OS layer. Maven, Gradle, the Go toolchain. Your test runner. The action that sets up the action that runs the test runner. All of it gets pinned to immutable references, all of it gets scanned, all of it is treated as a potential compromise vector. ARMO's team, who build Kubescape (also deployed in pipelines), put it directly: "A security scanner is not inherently trustworthy just because it's a security scanner." Neither is anything else.

Design for the assumption that something will be compromised.

When a runner gets owned, what can the attacker reach? That question should have a good answer before the incident, not after. Use short-lived, scoped credentials, not org-scoped PATs sitting in environment variables. Scope tokens to exactly what each job needs. Set expiry. Separate credentials across pipelines so a compromise in one doesn't cascade to everything. The Trivy attacker got an org-scoped PAT and used it to compromise Checkmarx, LiteLLM, and 47 npm packages. Repo-scoped wouldn't have given them that. Expired wouldn't have given them 18 days. Minimal scope is not a best practice. It's the architectural control that limits blast radius when prevention fails.


This is exactly what I've been saying

I've been researching supply chain security and SLSA for about a year and a half. My position (which I've been making in conference rooms and in code reviews) is that everything in your execution context is a supply chain dependency. Not just your application packages. Everything.

SHA-pin your scanner. SHA-pin your builder image. SHA-pin Maven, Gradle, the Go toolchain. SHA-pin the action that sets up the Go toolchain. SHA-pin the busybox sidecar that cats a config file into a volume. If it runs in your pipeline, it has access, and if it has access, it can be compromised. There is no trusted category. There is no "this one is a security tool so we can skip it." Trivy was a security tool.

The field's default framing is: put a scanner in the pipeline, shift left, catch compromised dependencies before they reach production. That framing treats the scanner as infrastructure, not as supply chain. Trivy was managed with less rigor than the application code it scanned, referenced by mutable tags that got force-pushed to malicious commits, while the application dependencies it checked were SHA-pinned.

SHA-pinning your application deps while referencing your build toolchain by mutable tag shifts the gap. It doesn't close it.

Hardening reduces the probability of compromise. It's necessary and not sufficient. The other half is designing for the assumption that something gets through anyway. The exploitable window between "flagged" and "fixed" is going to keep getting shorter because the thing closing it from the attacker's side is software, running at scale, all the time.

SHA-pin everything. Assume breach. Those aren't separate strategies. They're both required.


Sources and further reading