Poisoned Pipeline

Poisoned Pipeline / Incidents / AS304

AS304 · 2026-10

GhostAction steals CI secrets, then autoruns a stealer from a fake font

Git / GitHubGitHub ActionsVS Code / IDEDeveloper workstation

On 9 October 2026 StepSecurity, Socket, and OpenSourceMalware
published the same GhostAction wave. On 8 October two maintainer
accounts pushed a workflow named Security Audit
(.github/workflows/security-audit.yml) into every repository they
could write. StepSecurity times the kitao sweep at 13:20-13:44 UTC
(27 repositories, including kitao/pyxel, about 18,400 stars) and the
henrywoo sweep at 21:10-21:26 UTC (318 repositories: 39 source and
279 forks). uber/athenadriver was the last commit of that sweep, at
21:25:54 UTC, straight to master. henrywoo is the original author
and still had write access after the project moved under Uber.
Socket counts the same burst as 346 repositories by keeping
uber/athenadriver outside the henrywoo namespace. Commit messages
were "Add security audit workflow" and "Update security audit
workflow". Author and committer were the victim identities. The
athenadriver commit e3c0dfa is unsigned. Socket did not observe how
the credentials were taken. StepSecurity's working hypothesis is a
leaked personal access token. That is not established.

The October payload keeps the older named-secret theft and adds a
working-tree and full-history sweep. It triggers on push, with no
branch filter, and on workflow_dispatch. Checkout uses fetch-depth
0. Named secrets found in the repo's own workflows are templated
into the script before the commit. pyxel's copy names
CARGO_REGISTRY_TOKEN, PERSONAL_ACCESS_TOKEN, PYPI_PASSWORD, and
PYPI_USERNAME. Repos with those names POST to
http://193.32.204.199/?c=monami. Repos with an empty secret list
POST to /?c=new. The new half greps the tree for thirteen patterns
(AWS access key ids and secrets, AWS session tokens, Anthropic,
OpenAI, and OpenRouter keys, GitHub PATs, GitLab tokens, Google
API keys, Slack tokens, SendGrid keys) and runs git log -p --all,
capped at 200,000 lines, so a secret deleted from the current tree
is still collected. AWS key ids are shipped with two lines of
context so the secret key can be paired. The body is one cleartext
HTTP POST. There is no DNS lookup.

StepSecurity read the athenadriver Actions log (run 37846728441).
The server answered OK at 21:26:04 UTC, four seconds after the run
started. That copy used /?c=new, so the delivered body was the
tree and history sweep, not a list of named Actions secrets. The
job's GITHUB_TOKEN was contents:read. pyxel's workflow completed
five times, three from the attacker's pushes and two from
workflow_dispatch at 13:36:32 and 13:42:35 UTC, issued from the
kitao session. Socket says no malicious PyPI or crates.io version
was published from this activity. pyxel's last release is v2.9.9
on 12 August 2026. On 7 October the already-compromised
xxyangyoulin account pushed an empty commit and a README edit to
typecho-fans/plugins, infected since 5 September, to re-fire the
older workflow. Both runs stopped in action_required because that
repository requires approval.

This is the same operation GitGuardian published on 7 October, not
a second actor. Between 31 August and 30 September the workflow
github_actions_security.yml was pushed to 772 public repositories
belonging to 373 accounts, targeting 2,577 secret names, and posted
to the same IP. Of 3,669 collected runs, 336 completed and
GitGuardian counts 26 secrets from 13 repositories. A 7 September
variant, security-check.yml, in 7 repositories posts to port 3000
with an injection id. By 5 October, 124 of those 772 repositories
(16 percent) had a public cleanup commit. In 92 cases the attacker
only retargeted a workflow left from an earlier wave. StepSecurity
first sees 193.32.204.199 in a public repo on 5 September and in
honeypot records on 4 September. On 9 October a GitHub code search
for that IP under .github/workflows returned 378 default-branch
hits, 182 with the history-mining marker and 88 with ?c=monami.
Code search omits forks, so the 279 henrywoo forks sit outside
that 378. A Socket update the same day, clock time left blank on
the page, says more than 500 accounts have committed the workflow
to tens of thousands of repositories since 7 October. The two
named accounts remain the ones both labs reconstructed.

The campaign GitGuardian named in September 2025 used the same
workflow shape against 817 repositories and at least 3,325 secrets,
then to bold-dhawan.45-139-104-115.plesk.page and
carte-avantage.com. Later endpoints were 170.39.218.2 and
Interactsh *.oast.fun. OpenSourceMalware treats the October posts
as an escalation of that campaign. my-github.com, registered 9
October, resolves to 193.32.204.199. my-gitlab.com, registered 22
September, is a separate registration. OpenSourceMalware does not
claim common ownership of that second domain.

On 11 October OpenSourceMalware published a phishing email shown
as coming from noreply@my-github.com. It impersonated a GitHub
security alert, named the recipient, and used the path /SDRVuhYw,
the same path public scans saw hours after the domain was
registered. The page is a GitHub reverse proxy. Its script posts
to /login/device/code for client id Ov23ctrqCqZ79UaYKwyq with
scopes repo, workflow, and read:org, then submits the device-flow
authorization through the proxied session. The access token is
sent to my-github.com on TCP/8443. Static markers are a vh_device
cookie, /vh_beacon, and console strings VH1 through VH5.
OpenSourceMalware did not prove the recipient submitted the form.
The HAR they kept does not contain the device-code or token
requests. This is the same server as the workflow exfiltration,
not a second operation.

On 16 October OpenSourceMalware published a second stage that
landed on 15 October. It persists after the workflow is deleted.
The same GitHub access commits .vscode/tasks.json and
public/fonts/fa-solid-400.woff2. The task is labeled eslint-check,
hidden, and set to runOn folderOpen. The command is
node ./public/fonts/fa-solid-400.woff2. The file is 38 lines of
plain Node.js, byte-identical in the 79 repositories they could
clone. It is not obfuscated JavaScript, and it does not use a
blockchain loader. OpenSourceMalware labels it a copy of the
PolinRider launcher, not an extension of that campaign. No shared
indicator with DPRK malware was found.

The cluster is 94 repositories and 66 owners. Fifteen were
deleted, renamed, or made private before analysis. Of the 79
that remained, 65 had gone more than a year without a commit
when the first malicious commit landed. On 8 October, between
13:03 and 22:41 UTC, 72 of those 79 received the first Stage 1
workflow. Four more received it on 12-14 October. Stage 2's
main burst was 15 October, 01:00-10:31 UTC. In 76 repositories
the second workflow, the font, and tasks.json landed together.
The median gap from Stage 1 was 6.3 days. psm-2/ai-artifacts
received Stage 2 at 10:30 UTC with no Stage 1. cashpost and
bangcv-flutter removed the Stage 1 workflow on 9 October and
still received Stage 2 on 15 October, at 02:32 and 07:48 UTC.
Deleting the workflow did not revoke the push credential.

The font reads the first 3,000 bytes of ~/.aws/credentials,
~/.pypirc, ~/.npmrc, ~/.git-credentials, ~/.netrc,
~/.claude/.credentials.json, ~/.gemini/oauth_creds.json, and
~/.kube/config. It reads ~/.ssh/id_rsa and ~/.ssh/id_ed25519
in full. It greps the home directory for AWS, Anthropic, GitHub,
npm, and PyPI token shapes, capped at 30 matches and a 10 second
timeout. It appends environment variables whose names match
AWS_, ANTHROPIC, OPENAI, HF_TOKEN, or GITHUB_TOKEN. The same
expression also lists names that begin with an underscore. One
cleartext HTTP POST goes to 193.32.204.199 port 8080. Batch
tags on the copies are c=reinject, c=auto_20261013, and
c=security. c=security is the 12 October tag on
raihanlimit/ainebot-md, the first move to port 8080.

The same script installs a cron line marked ghc2. Every five
minutes the machine fetches
http://193.32.204.199:8080/c2/ plus its hostname and pipes the
body to bash. OpenSourceMalware had not retrieved what that
path returns. Deleting the font does not remove the cron job.

The same burst adds a second workflow. It is Stage 1 with more
patterns, a wiki scan that uses the job's GITHUB_TOKEN, and a
secret-gist upload if the POST fails. Filenames differ:
lint.yml, ci-check.yml, build-check.yml, repo-health.yml,
audit.yml, code-review.yml, and test-suite.yml, each in 5 to
16 repositories. mammarraza/reactapp also has cache-cleanup.yml,
display name Runner Maintenance, added 15 October at 06:24 UTC,
set to run on self-hosted runners. That copy reads the instance
metadata IAM role, reads SSH keys, and installs the same cron.
It is the only workflow in the set that reaches the host under
the runner.

daxitzadafiya/bagisto-dhs is in this cluster and also has a
PolinRider Tailwind-config loader. The force-push is 25 June
2026. OpenSourceMalware found no shared indicator with the 15
October files. That loader stays on AS45.

Mapped techniques

supply-chain Stolen maintainer identity, workflow pushed to every writable repo

T0113 · Initial Access · Compromised user account

Commits used the victim accounts, including kitao, henrywoo,
and xxyangyoulin. How the credential was taken was not
observed.

T0147 · Reconnaissance · Scan public CI/CD configurations for secrets and vulnerable actions

Secret names in the payload match names already referenced
in that repository's workflows. The list is filled in before
the commit.

T0169 · Lateral Movement · Push implants across repositories

One credential is used on every repository it can write,
including forks and repositories dormant for years. The 8
October bursts were minutes long.

T0166 · Execution · Trigger pipeline execution

The workflow runs on push and on workflow_dispatch. The
injection push itself starts a run. On pyxel the attacker
also dispatched the workflow twice by hand.

supply-chain Named secrets plus git history, posted in cleartext

T0140 · Credential Access · Harvest tokens from environment variables

${{ secrets.NAME }} values are placed in the curl body.
This is not a dump of the whole environment. athenadriver's
copy had an empty name list.

T0184 · Credential Access · Harvesting sensitive information from files

The October workflow greps the checkout and git log -p
--all. A secret removed from the current tree is still in
scope. fetch-depth 0 is what makes that history available
on the runner.

T0199 · Impact · Secret leak

athenadriver's run received OK from 193.32.204.199.
GitGuardian counts 26 secrets from 13 repositories in the
August-September window. No malicious registry publish was
observed after the pyxel token names were templated.

supply-chain Fake font autoruns on folder open, then a five-minute cron

T0138 · Persistence · Backdoor in code

tasks.json and the fake font stay in the repo after the
workflow is deleted. cashpost and bangcv-flutter removed
Stage 1 on 9 October and still received these files on
15 October.

T0210 · Persistence · Poison AI coding-agent config or workspace

.vscode/tasks.json is hidden, labeled eslint-check, and
set to runOn folderOpen. The command is node on
public/fonts/fa-solid-400.woff2.

T0152 · Execution · IDE

Opening the folder in VS Code runs that node command.
The file is plain JavaScript with a font extension.
Node does not check the extension.

T0189 · Credential Access · Secrets in configuration files

The script reads AWS, PyPI, npm, git, netrc, Claude,
Gemini, and kube config files, plus both SSH private
keys. A home-directory grep keeps 30 token matches.

T0140 · Credential Access · Harvest tokens from environment variables

Names matching AWS_, ANTHROPIC, OPENAI, HF_TOKEN, or
GITHUB_TOKEN are appended to the same report. This is
the developer process environment, not Actions secrets.

T0199 · Impact · Secret leak

The report is one cleartext POST to 193.32.204.199
port 8080. Tags are c=reinject, c=auto_20261013, and
c=security.

T0172 · Execution · Runtime backdoor

A crontab line marked ghc2 runs every five minutes,
fetches /c2/ plus the hostname, and pipes the body to
bash. The returned script was not recovered. Deleting
the font does not remove the job.

T0148 · Persistence · Scheduled tasks on self hosted runner

mammarraza/reactapp cache-cleanup.yml, display name
Runner Maintenance, is runs-on self-hosted and installs
the same cron. One workflow, not a campaign-wide runner
compromise.

T0168 · Exfiltration · Exfiltration to code repositories

The second workflow uploads findings to a secret gist
if the POST fails. OpenSourceMalware did not show a
gist that was created.

Framework gaps noted

Sources