Skip to content

Security Incident Report — 31 May 2026 — Force-Push Supply-Chain Backdoor

A stolen GitHub credential was used to force-push credential-stealing malware across the organisation on 31 May 2026. It was never seen running, and there is no evidence of customer, patient, or production impact. Here is what happened, why it stayed contained, and how to spot the scam that started it.

Status: CONTAINED

Full investigation methodology & per-thread analysis →

~7 min

total attack window

180

force-pushes, 1 stolen login

15

repos hit (of ~87 scanned)

0

executions seen in CI / builds

0

evidence of customer / patient / prod impact

The malicious commits were stamped with the names and dates of real teammates. Those names are forged — git lets anyone write any value into a commit’s “author” field. Where a real person is named here it is to record that their identity was impersonated, or that they are a victim. Nothing in this report attributes fault to any individual. The only actor we can speak about with certainty is one stolen credential; the human operating it is external and unidentified.

Four phases: the lure (months before), the attack (7 minutes on 31 May), the defences that held, and the response.

🎣

6 Feb 2026

Nikhil receives an Upwork “Invitation to Interview” for a “Python Machine Learning Engineer (Senior/Staff)” role described as a “long-term crypto trading prediction project,” from a user called “Princes A.” An ML-plus-crypto freelance approach is the signature opening of the DPRK-linked “Contagious Interview” campaign.

⚠️

27 Mar 2026 — last contact in evidence

Upwork emails Nikhil: “You may have received a malicious file or link… another Upwork user may have shared malware with you,” naming the same actor (“Princes Ampalayo”). He runs a virus scan and moves his passwords to a password manager — the reasonable response. But this campaign’s payload silently steals invisible login material (browser sessions, OAuth tokens) that a scan can’t undo and that no one would know to revoke. The theft happened on a personal device, off N1’s systems — outside what N1 can control; the login stayed usable until N1 made logins like it unusable. (This is the most recent email in the evidence; there is no later contact.)

🔓

Feb – May 2026 (inferred)

The most consistent explanation for everything that follows: the scam stole one of Nikhil’s pre-existing GitHub credentials off his personal surface. We cannot prove the exact credential or the moment of theft — but because nothing structurally invalidated it, a credential taken in this window would still have been valid weeks later. The screenshots prove he was solicited and warned; they do not prove he downloaded or ran anything.

💥

31 May 2026 · 13:52:45Z

An external operator replays the stolen numberyy login remotely (not from Nikhil’s machine) and starts force-pushing. No new token is created and no sign-in event appears — consistent with replaying a credential that already existed.

🗜️

13:52 – 13:59Z · ~7 minutes

180 force-pushes, 15 repos, two malware families

Section titled “180 force-pushes, 15 repos, two malware families”

An automated script plants two related credential-stealing payloads into build/config files (ESLint, Vite, Playwright, PostCSS, Eleventy…), spraying them across every branch it can reach. It forges ~20 teammates’ identities and back-dates the commits to look like ordinary history, burying the payload under whitespace-only line-ending churn.

🛡️

During the burst — automatic

On every repo with a protected default branch, the force-push is rejected at the mainline — the malware is pushed only onto dormant feature/PR branches. Just 3 repos had unprotected defaults the attacker reached directly. This is the single reason the blast radius stayed small.

👀

Discovery

Zabir — whose name was itself forged onto malicious commits — notices a poisoned branch during a routine git pull --rebase, recognises the malware, and isolates it in a clean branch. A human caught what the tooling did not. He is a victim and the first responder, not a culprit.

🔒

Response

Locked down and closed at the protocol level

Section titled “Locked down and closed at the protocol level”

The account was blocked and removed; the org was set to read-only; reachable poisoned branches were cleaned; malicious workflows disabled; and — the control that actually answers this incident — signed commits are now required on every branch in every repo. A stolen token alone can no longer land a commit (verified live: an unsigned push is rejected even for an owner).

🔁

Now

All poisoned branches removed (feature and default) and the affected default branches cleaned; staging + production clusters and the full Cloudflare account scanned clean. The Prevention + Detection hardening program is now the active work.

✅ Were we harmed? No — and here is how we know

Section titled “✅ Were we harmed? No — and here is how we know”

We did not assume we were fine. We checked the evidence.

never ran

It only does damage when built or executed. Every post-attack run was already disabled, died at dependency install because the attacker’s own code was incomplete (luck, not a safeguard), or ran a build step that never loads the poisoned file.

login system

Across the window and the days after, sign-ins behaved completely normally — no anomalies.

our cloud

We don’t store long-lived keys in the cloud to steal, and records show no attacker set up any new access. Clusters and Cloudflare scanned clean.

no second attacker

Only the one stolen login pushed any payload; every other push in the window was ordinary developer work. No malicious dependency in our code; all internal artifacts verified untampered.

Two payload families arrived in the same burst, both hidden in files that run as a side effect of normal development, both pointing at the same external infrastructure.

Family A

A giant single-line obfuscated blob appended to config files (eslint.config.js, vite.config.ts, etc.). It smuggles Node’s require into an ES module and runs code via the Function constructor (an eval that dodges lint rules).

Family B

A small snippet that base64-decodes a hidden URL, fetches it, and evals whatever comes back. It carries none of Family A’s markers — so a marker-only scan missed it entirely. That is why broad, behaviour-based detection is mandatory.

Both families converge on the same on-chain payload (Family A inlines it; Family B fetches it from the C2). We decoded that loader and recovered its stages in an isolated sandbox. The C2 was contacted exactly once — a single manual request that captured, not ran, the payload — and the stealer’s own command-and-control socket was never contacted, so nothing was exfiltrated. It is a three-stage “EtherHiding” chain that hides its later stages inside blockchain transactions:

Stage 1

Runs in the build process; pulls its next stage out of a blockchain transaction. Reads no credentials itself.

Stage 2

Fetches and decrypts the next stage from another on-chain transaction.

Stage 3

An InvisibleFerret-class remote-access trojan: fingerprints the host and waits on a remote socket. Never reached in our sandbox.

The same command-and-control string appears in unrelated public repositories that were force-pushed the same day. This was an automated, multi-victim sweep that hit many organisations at once — not infrastructure built for us. The toolchain matches the publicly documented DPRK-linked “Contagious Interview” campaign (an external attribution we did not independently confirm).

🎯 Blast radius — and the one thing that contained it

Section titled “🎯 Blast radius — and the one thing that contained it”

~70

repos fully clean

15

repos carried payload

173

branch tips (mostly duplicates)

3

unprotected default branches hit

The big branch-tip number is mostly replication — the attacker sprayed the same file onto every branch, so one poisoned config shows up across twenty-odd tips rather than being twenty separate compromises. (173 tips = 170 feature branches + 3 default branches; of ~87 repos scanned, 70 were clean, 2 empty, 15 hit.)

🛡️ Branch protection decided what got hit

Section titled “🛡️ Branch protection decided what got hit”

On repos with a protected default branch, the force-push was rejected at the mainline and the malware was confined to dormant side-branches. Only three repos — whose defaults were unprotected — were reached directly; all three have since been cleaned. Org-wide hardening so that every repo’s default is protected is the headline follow-up.

🧑‍🤝‍🧑 The people named — all victims or impersonated

Section titled “🧑‍🤝‍🧑 The people named — all victims or impersonated”

Because the attacker forged ~20 identities, real names appear in the malicious history. To be unambiguous:

N

Victim — credential stolen

His personal GitHub credential was stolen off-platform (on a personal device, off N1’s systems) and replayed remotely. His work machine and entire Claude Code archive (2,260 sessions) were examined and are clean — the malware never transited N1’s surface.

Z

Victim & first responder

His identity was forged onto malicious commits, yet he is the one who caught and contained it during a routine rebase. The hero of the story, not a suspect.

S

Impersonated only

His name was used as a forged label, but he made no pushes at all in the entire dataset. Purely impersonated.

J

Impersonated · co-investigator

Jasper & others (Puneet, Paawan, and ~16 more)

Section titled “Jasper & others (Puneet, Paawan, and ~16 more)”

Names copied into forged commit metadata. Jasper helped investigate; cryptographic signatures prove the overwhelming majority of his real commits are genuine and clean. A forged author name is not a record of who did anything.

🎣 How to spot a campaign like this — everyone, please read

Section titled “🎣 How to spot a campaign like this — everyone, please read”

This incident did not begin with a clever exploit. It began with a job offer. The single most valuable habit the whole team can build is recognising the lure before interacting with it.

💼

On Upwork, LinkedIn, Telegram, or email — for a senior/staff dev or ML/AI role.

🪙

A “software” role tied to crypto trading or web3 is the campaign’s signature. (Ours was a “crypto trading prediction project.”)

💻

A take-home task, a demo repo to clone and npm install, an SDK or “meeting app” to install. The install step is the attack.

📎

Files or links inside a recruiting message; a push to move to WhatsApp/Telegram; urgency or flattery. Sender handles that feel slightly off (“Princes A.” / “Princes Ampalayo”).

  • Never run interview / take-home / “candidate” code on your work machine — or any machine that holds logins. If you must run it, use a disposable VM with no credentials.
  • Don’t npm install / pip install an unknown repo “just to see.”
  • Report it to Arun right away (and report the user on the platform) — even if you didn’t run anything. Early reporting shrinks the blast radius.

⚠️ If you think you already interacted with one

Section titled “⚠️ If you think you already interacted with one”
  • Tell Arun immediately — even if you’ve already run a virus scan. A silent stealer takes login material you can’t see (session cookies, OAuth tokens), so only revoking your GitHub sessions reliably shuts it off; a scan does not.
  • Then rotate what you can — GitHub tokens (PATs and the OAuth logins behind gh / Git Credential Manager), SSH keys, cloud keys — but fast session revocation is the real safety net, not personal cleanup.

🔑 A stolen credential must be made useless by design

Section titled “🔑 A stolen credential must be made useless by design”

You can’t rely on catching a silent, signal-less theft, and 2FA doesn’t stop a replayed token. The fix is structural: required signed commits (now enforced — a stolen token can’t push) plus short-lived tokens and stopping the theft at the endpoint.

eslint.config.js, vite.config.ts, playwright.config.ts, a committed .env — these are executable surfaces, not inert text. A surprising diff in one (a giant one-line blob, or an atob/fetch/eval sequence) is malware until proven otherwise.

  • Attacker account blocked and removed from the org
  • Organisation locked to read-only by default
  • Signed commits required on every branch, every repo — verified live
  • Two-factor authentication enforced org-wide
  • All three poisoned default branches cleaned (developer-portal reset earlier; n1-qa + skills-marketplace cleaned forward with signed commits); all poisoned feature branches deleted
  • main and develop protected (no force-push, no deletion, PR required) on every repo — current and future
  • Malicious workflows disabled
  • Staging + production clusters and full Cloudflare account scanned clean
  • Persistence sweep clean — no planted webhook, deploy key, or secret at the repo or org level
  • Vestigial teams removed (writers / maintenance-admin / readonly)
  • Immutable evidence backups of all affected repos preserved; credential rotation started
  • Finish precautionary credential + CI-secret rotation (Darpan, DevOps); re-grant write per team
  • Obtain the account’s security log to fully close the entry-vector story
  • Ask GitHub Support to purge the orphaned malicious commits

🚀 What we’re doing next — owned by Arun & Eyad

Section titled “🚀 What we’re doing next — owned by Arun & Eyad”

The root cause is closed. The forward work is a focused, ~2-week, open-source program in two tracks — a small set of standing capabilities that catch what actually threatened us, not a tooling estate.

Track A — Prevention

A single security-gate that chains a shape-based malware/obfuscation scanner (the upgrade that would have caught the loader), dependency-CVE scanning, secret scanning, a trusted-registry assertion, Actions hardening, and the repo’s own tests (with required failure-mode coverage) — enforced org-wide as a required status check, mirrored by pre-commit hooks, with commit-signing onboarding so the keystone isn’t fought.

Plus closing the live audit findings: the public wheel registry (a seamless developer + CI access solution is in the works), Cloudflare TLS + token TTLs, ClickUp webhooks, over-broad app grants, and pinning shared CI by SHA.

Track B — Detection & Response

See it in minutes; trip on stolen-credential use

Section titled “See it in minutes; trip on stolen-credential use”

A GitHub org-webhook → Slack alert on the events that mattered (force-push burst, new collaborator, new app, ruleset change); a nightly snapshot-diff of apps/keys/webhooks/members/ rulesets to fill the audit-log gap; an AWS honey-key tripwire that fires the instant a stolen credential is used; and a read-only, PHI-free Slack bot as the one place to ask “is anything wrong right now.” Plus a one-page IR runbook and a lure-awareness note for the team.

Tracked separately, on their own budget (owner decision): SignOz log PHI-redaction (a HIPAA gap), and endpoint EDR / device imaging.

🔎 Indicators of compromise (for security tooling)

Section titled “🔎 Indicators of compromise (for security tooling)”

Show the full IOC list

  • PolinRider: global['!'] (value '9-0315-1'), _$_1e42, rmcej%otb%; a createRequire(import.meta.url) call plus a single

    2000-character line in a *.config.* file; the Function constructor used as eval.

  • eval-loader: atob(process.env.AUTH_API_KEY)node-fetcheval; committed .env setting AUTH_API_KEY; log string Auth Error!.
  • C2: auth-confirm-six.vercel.app; sibling ipcheck-six.vercel.app; the <word>-six.vercel.app pattern.
  • Anomalous from build/CI (legitimate services — alert, don’t necessarily block): api.trongrid.io, fullnode.mainnet.aptoslabs.com, bsc-dataseed.binance.org, bsc-rpc.publicnode.com.
  • Stage-3 socket C2 on port 1224.

eslint.config.*, postcss.config.mjs, vite.config.*, playwright.config.ts, server.ts, eleventy.config.js, tailwind.config.js.

Upwork user “Princes A.” / “Princes Ampalayo”; ML + crypto-trading freelance pretext. Delivery signature: a burst of force-pushes from one account with back-dated author dates and forged identities — trust the push event in the activity log, never the commit metadata.

Marker-scanning alone is insufficient — it missed the eval-loader family entirely. Pair marker strings with behaviour-based rules (dynamic decode-and-execute, oversized one-line config blobs, build processes making network calls).

Confidential — Internal Only

N1 Healthcare · Security Incident of 31 May 2026 · Report dated 4 June 2026 · Status: Contained. This document contains no live malware. Do not share externally.