From Scanner to Stealer: Inside the trivy-action Supply Chain Compromise

The CrowdStrike Engineering team discusses how this activity was discovered, how the attack works, what the payload does, and how CrowdStrike helps organizations defend against this threat.

While investigating a spike in script execution detections across several CrowdStrike Falcon® platform customers, CrowdStrike’s Engineering team traced the activity to a compromised GitHub Action named aquasecurity/trivy-action. This popular open-source vulnerability scanner is frequently used in CI/CD pipelines. 

Our investigation found that 76 of the scanner’s 77 release tags had been retroactively poisoned via git tag repointing, replacing the legitimate entry point with a multi-stage credential stealer. The malicious code runs silently before the real scanner, so workflows appear to complete normally. 

Aqua Security has officially confirmed the compromise of the Trivy GitHub Action script, setup script, and binary, and has subsequently quickly removed all malicious artifacts from their repositories. Additionally, CrowdStrike coordinated with Aqua Security prior to this publication. In this blog, we discuss how this activity was discovered, how the attack works, what the payload does, and how CrowdStrike helps organizations defend against this threat.

GitHub Actions and Why They Matter

GitHub Actions is the CI/CD platform built into GitHub. When a developer pushes code, opens a pull request, or merges a branch, GitHub Actions can automatically build, test, and deploy that code. GitHub Actions is used by millions of repositories from small open-source projects to enterprise production pipelines.

Workflows are defined in YAML files that live in a repository's .github/workflows/ directory. Each workflow is composed of jobs, and each job runs a series of steps on a runner — a virtual machine or container provisioned to execute the work. These runners have access to the repository's code, its configured secrets (API keys, deploy tokens, cloud credentials), and often broad network access to internal infrastructure.

A key feature of GitHub Actions is the ability to reference reusable actions, which are prepackaged steps published by third parties. Instead of writing custom scripts for common tasks like checking out code, setting up a language runtime, or running a security scanner, teams reference shared actions by name and version:

  steps:
    - uses: actions/checkout@v4
    - uses: aquasecurity/trivy-action@0.24.0

When a workflow runs, the runner downloads the referenced action from GitHub, extracts it, and executes its entry point. This is where the trust model becomes critical: The runner executes whatever code the action contains, with full access to the runner's environment, secrets, and network.

Most teams reference actions by tag (e.g., @0.24.0 or @v2). Tags are human-readable and easy to update. But in Git, tags are mutable — they can be silently repointed to a different commit without any visible change to the release page, the tag name, or the published dates. This is the mechanism exploited in the compromise described below.

aquasecurity/trivy-action is a GitHub Action published by Aqua Security that wraps its open-source vulnerability scanner, Trivy. It's widely adopted for scanning container images, file systems, and infrastructure-as-code for known vulnerabilities as part of CI/CD pipelines.

In summary, if an action's code is modified, whether by its maintainers or by someone who gained write access, every pipeline that references it will trust and execute the new code on its next run, all with full access to that pipeline's secrets, credentials, and infrastructure.

Initial Discovery

An initial spike on Linux platforms linked to runners was observed on March 19, 2026. The detections were concentrated on GitHub Actions runners, and the script content didn't appear to align with benign CI/CD activity.

Digging into the process tree, the full execution chain was traced to one of the affected runners:

 runc
   └── /bin/bash /home/runner/run.sh
        └── /bin/bash /home/runner/run-helper.sh
             └── Runner.Listener run
                  └── Runner.Worker spawnclient 149 152
                       └── /usr/bin/bash --noprofile --norc -e -o pipefail /home/runner/_work/_temp/395479a1-…ded349.sh
                           └── /bin/bash /home/runner/_work/_actions/aquasecurity/trivy-action/0.24.0/entrypoint.sh

Reading from top to bottom: runc starts the container, which launches the runner's startup scripts (run.sh → run-helper.sh). These start Runner.Listener, which connects to GitHub and waits for jobs. When a workflow is triggered, the Listener spawns Runner.Worker to execute it. The Worker runs each workflow step as a shell script in a temp directory — the 395479a1-…ded349.sh wrapper. That wrapper calls the action's entrypoint.sh, which is where the malicious code lives.

From here, CrowdStrike began investigating the action itself.

What the Payload Does

The malicious entrypoint.sh prepends approximately 105 lines of attack code before the legitimate Trivy scanner logic. An example of one of the malicious commits can be viewed here.

It executes a multi-stage credential theft operation, then runs the real scanner so the workflow appears to succeed normally.

Stage 1: Runner Process Discovery

Initially, the script enumerates process IDs (PIDs) of GitHub Actions runner processes (Runner.Worker, Runner.Listener, runsvc, run.sh) and reads their /proc/{PID}/environ files, filtering for environment variables containing env or ssh in their names.

Stage 2: Platform-Specific Credential Theft

The attack branches based on the runner environment:

On GitHub-hosted Linux runners, a Base64-encoded Python script is decoded and executed with sudo. It locates the Runner.Worker process, reads its memory maps via /proc/{PID}/maps, and scrapes raw process memory via /proc/{PID}/mem looking for GitHub Actions secrets — specifically JSON structures matching "name":{"value":"...","isSecret":true}.

On self-hosted runners, a different Base64-encoded Python script performs broad filesystem credential harvesting. It targets the following:

  • SSH private keys and configs
  • Cloud credentials (AWS, Google Cloud, Azure)
  • Kubernetes configs and service account tokens
  • Docker registry credentials
  • Database credentials (MySQL, PostgreSQL, MongoDB, Redis)
  • CI/CD configs (GitLab CI, Travis, Jenkins, Drone)
  • Infrastructure-as-code secrets (Terraform state/vars, Ansible, Helm)
  • TLS/SSL private keys
  • .env files and API keys
  • Cryptocurrency wallet keys (Bitcoin, Ethereum, Solana validator keypairs, Cardano, and others)
  • System files (/etc/passwd, /etc/shadow, shell histories)

Stage 3: Encrypted Exfiltration

Collected data is encrypted with a randomly generated AES-256-CBC session key, and the session key is wrapped with a hardcoded 4096-bit RSA public key. The encrypted bundle (payload.enc + session.key.enc) is packaged as tpcp.tar.gz. This ensures only the adversary server with access to the corresponding RSA private key can decrypt the payload.

Stage 4: Dual Exfiltration Channels

The primary exfiltration channel is an HTTPS POST to scan.aquasecurtiy[.]org — a typosquatted domain mimicking Aqua Security's legitimate aquasecurity.com.

If the endpoint returns a non-2XX response and INPUT_GITHUB_PAT is available, the script falls back to exfiltrating through GitHub itself. It will create a public repository named tpcp-docs under the victim's GitHub account, create a timestamped release, and upload the encrypted bundle as a release asset.

Stage 5: Cleanup and Camouflage

All temporary files are removed. The remainder of the script is a functional copy of the real trivy-action entry point. It downloads and runs Trivy normally, producing expected scanner output. To an operator reviewing workflow logs, the step appears to have completed successfully.

Further Discoveries

Every release tag of aquasecurity/trivy-action from GitHub was downloaded and scanned. This includes 77 tags total, spanning from 0.0.1 to 0.35.0. Each entrypoint.sh was checked against five indicator of compromise (IOC) patterns derived from the malicious payload.

The result of this analysis is as follows:

MetricCount
Total tags scanned77
Compromised76
Clean1

The only clean tag is 0.35.0. Every other release from 0.0.1 through 0.34.2 currently serves a malicious entrypoint.sh.

The setup-trivy action was also compromised to execute the same malicious script.

In addition to the compromised trivy-action GitHub Action, the trivy scanner version 0.69.4 was also compromised. When it executes, a script is dropped to ~/.config/sysmon.py. The script acts as a lightweight stage-1 loader. After an initial five-minute sleep (likely intended to outlast sandbox analysis timeouts), it enters a polling loop that contacts a command-and-control (C2) server approximately every 50 minutes. 

The C2 is hosted on the Internet Computer (ICP) blockchain (hxxps[://]tdtqy-oyaaa-aaaae-af2dq-cai[.]raw[.]icp0[.]io), making it resistant to traditional domain takedowns.

On each cycle, the loader fetches a URL from the C2, compares it against a locally cached state file, and if the URL is new, downloads the referenced binary to /tmp/pglog. It then marks the file executable and launches it as a detached process with all output suppressed. The choice of filename mimics PostgreSQL logging, and the state file is dot-prefixed to stay hidden from casual directory listings.

Error handling is deliberately silent throughout. Every operation is wrapped in bare except blocks that discard failures, ensuring the loader never crashes or produces visible output. Combined with the spoofed browser User-Agent and the lack of any logging, the script is designed to operate invisibly on a compromised host while allowing the attacker to rotate payloads at will by simply updating the URL served by the C2.

The binary also exhibits the same credential-stealing behavior as the compromised action. It scans for credentials, bundles them, encrypts them, and then exfiltrates the data via a post request.

How It Was Done

Git tags are mutable references. A lightweight tag is just a pointer from a name to a commit SHA. With write access to a repository, an attacker can force-push a tag to point to a different commit:

  git tag -f 0.24.0 <new-commit>
  git push -f origin refs/tags/0.24.0

The release page on GitHub doesn't change — same name, same dates, same description. But the source archive that gets downloaded now contains different code.

We confirmed this by comparing what the 0.24.0 tag currently delivers against its parent commit:

Tag 0.24.0 resolves to commit e0198fd2b6e1679e36d32933941182d9afa82f6f:

  • entrypoint.sh is 17,592 bytes
  • First line after the shebang: _COLLECT_PIDS="$$" (the stealer)
  • This commit is not reachable from the master branch — it's a dangling commit that exists only because the tag points to it

The parent of that commit (57a97c7e7821a5776cebc9bb87c984fa69cba8f1) is the same commit that the clean 0.35.0 tag points to:

  • entrypoint.sh is 2,855 bytes
  • First line after the shebang: TRIVY_CMD="${TRIVY_CMD:-trivy}" (the legitimate scanner)
  • This commit is reachable from master

The SHA256 hashes tell the story:

Sourceentrypoint.sh SHA256Size
Tag 0.24.0 (compromised)18a24f83e807479438dcab7a1804c51a00dafc1d526698a66e0640d1e5dd671a17,592 bytes
Parent commit (legitimate)07500e81693c06ef7ac6bf210cff9c882bcc11db5f16b5bded161218353ba4da2,855 bytes
master branch (legitimate)07500e81693c06ef7ac6bf210cff9c882bcc11db5f16b5bded161218353ba4da2,855 bytes

The malicious commit's metadata — author name, commit message ("Upgrade trivy to v0.53.0 (#369)"), and timestamps (2024-07-09) — all mirror a legitimate prior commit. Git allows setting arbitrary author and committer dates via GIT_AUTHOR_DATE and GIT_COMMITTER_DATE environment variables, so these cannot be trusted.

The diagram below shows the full attack chain.

Figure 1. High-level attack chain of this threat Figure 1. High-level attack chain of this threat

CrowdStrike Falcon Detection and Protection 

CrowdStrike's script control detection caught this activity when the malicious entrypoint.sh executed on protected runners. The script content exhibited behaviors inconsistent with CI/CD — including credential collection, encrypted data staging, and outbound exfiltration. These signals were sufficient to prevent the script and surface the activity for investigation.

Figure 2. Process tree identified within the CrowdStrike Falcon Linux sensor Figure 2. Process tree identified within the CrowdStrike Falcon Linux sensor
Figure 3. Visualized process tree identified within the CrowdStrike Falcon Linux sensor Figure 3. Visualized process tree identified within the CrowdStrike Falcon Linux sensor

Identifying Exposed Workflows via GitHub Audit Logs

Organizations ingesting GitHub Enterprise audit logs into CrowdStrike Falcon® Next-Gen SIEM can proactively identify repositories exposed to the same attack vector. The initial compromise leveraged pull_request_target, a workflow trigger that, unlike pull_request, executes the workflow definition from the base branch with write access to repository secrets. Any public repository using this trigger allows external contributors to invoke workflows that have access to credentials. The following Falcon LogScale query surfaces these repositories across your GitHub Enterprise environment:

#Vendor="microsoft" #event.module="github"
| #event.dataset="github.workflows"
| event.action="workflows.created_workflow_run"
| Vendor.event="pull_request_target"
| Vendor.public_repo="true"
| ts := formatTime("%Y-%m-%d %H:%M:%S", field=@timestamp, locale=en_US, timezone=UTC)
| groupBy([Vendor.org, Vendor.repo], function=[
    count(as="total_runs"),
    count(user.name, distinct=true, as="distinct_actors"),
    collect([user.name, Vendor.name, Vendor.head_branch, ts])
], limit=max)

Each result represents a public repository where external pull requests can trigger workflows with elevated permissions, which is the precondition for the credential theft described above. Repositories where distinct_actors is high or includes unfamiliar user names warrant immediate review of their workflow YAML files for unsafe patterns, particularly actions/checkout referencing github.event.pull_request.head.sha, which would execute attacker-controlled code with access to the base repository's secrets.

Detecting Post-Compromise Token Abuse

If a runner was compromised and credentials were exfiltrated, the stolen tokens will likely be used from infrastructure the adversary controls, not the organization's CI runners. The following Falcon LogScale query creates a behavioral baseline to detect when a GitHub authentication token is used from a network subnet it has never been associated with to push code to a repository it has never pushed to. Matching events for a token pushing from a new network subnet to a new repo represent a high-confidence indicator of stolen credential abuse, since legitimate developer activity rarely introduces both dimensions simultaneously.

// Build 7-day baseline of known: token, /16 subnet prefix, and repo
  // Each unique combination of hashed_token, /16 subnet prefix, and repo seen in the baseline
  // This period is considered "established" — any triple not in this table is anomalous
  defineTable(
      query={
          #Vendor="microsoft" #event.module="github" #repo!="xdr*"
          | #event.kind="event"
          | event.action="git.push"
          | Vendor.hashed_token=* Vendor.hashed_token!="null"
          | source.ip=* Vendor.repo=*
          | ip_prefix := replace(field=source.ip, regex="^(\d+\.\d+)\.\d+\.\d+$", with="$1")
          | groupBy([Vendor.hashed_token, ip_prefix, Vendor.repo], function=[], limit=max)
      },
      include=[Vendor.hashed_token, ip_prefix, Vendor.repo],
      name="known_token_subnets_and_repo",
      start=7d,
      end=70m
  )

  // Detect pushes from a new subnet to a new repo in the current window
  | #Vendor="microsoft" #event.module="github" #repo!="xdr*"
  | #event.kind="event"
  | event.action="git.push"
  | Vendor.hashed_token=* Vendor.hashed_token!="null"

  // Check for any historical data for these hashed tokens (exlude new tokens)
  | match(file="known_token_subnets_and_repo", field=[Vendor.hashed_token])

  | source.ip=* Vendor.repo=*

  // Group source IPs by /16 prefix to absorb corporate NAT rotation while
  // preserving cross-network detection — IPs within the same /16 are treated
  // as the same network origin
  | ip_prefix := replace(field=source.ip, regex="^(\d+\.\d+)\.\d+\.\d+$", with="$1")

  // Drop events where (token, subnet and repo) already exists in baseline
  | NOT match(file="known_token_subnets_and_repo", field=[Vendor.hashed_token, ip_prefix, Vendor.repo])

  // TUNING: Exclude known automation accounts — bot tokens operate across many subnets and repos by design, which would generate excessive false positives
  | user.name != /\[bot\]$/
  | ts := formatTime("%Y-%m-%d %H:%M:%S", field=@timestamp, locale=en_US, timezone=UTC)

  | groupBy([Vendor.hashed_token, user.name], function=[
      new_subnets := count(ip_prefix, distinct=true),
      target_repos := count(Vendor.repo, distinct=true),
      total_pushes := count(),
      collect([
          Vendor.repo,
          source.ip,
          ip_prefix,
          source.geo.country_iso_code,
          Vendor.org,
          ts
      ])
  ], limit=max)

  // TUNING: Require minimum push volume to reduce noise from single-push FPs
  // | total_pushes >= 2

Conclusion

This compromise is a straightforward example of why mutable references in software supply chains are a risk. Git tags are convenient, but they're pointers and not guarantees. A tag that resolved to safe code yesterday can resolve to something malicious today, and nothing on the release page will reflect the change.

The mechanics here weren't novel. The attacker, with write access to the repository, force-pushed tags to a new commit containing a modified entry point and relied on the fact that most workflows reference actions by tag. The payload was well-constructed since it runs before the legitimate tool, cleans up after itself, and lets the real scanner complete, so the workflow looks normal. And the delivery mechanism was just git push -f.

For a defender, the takeaway is to pin your actions by commit SHA, monitor your CI/CD runners with the same rigor as production hosts, and treat any code that runs in your pipeline as code that runs in your infrastructure.

References

Malicious Commit Copying Legitimate Commit: https://api.github.com/repos/aquasecurity/trivy-action/git/commits/e0198fd2b6e1679e36d32933941182d9afa82f6f

Signed Original Commit: https://api.github.com/repos/aquasecurity/trivy-action/git/commits/6e7b7d1fd3e4fef0c5fa8cce1229c54b2c9bd0d8

Aquasecurity GitHub announcement: https://github.com/aquasecurity/trivy/discussions/10425