Security Incident Report — 31 May 2026 — Force-Push Supply-Chain Backdoor
🛡️ Security Incident Report
Section titled “🛡️ Security Incident Report”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 →
📊 At a glance
Section titled “📊 At a glance”~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
📌 How to read this report
Section titled “📌 How to read this report”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.
🕐 How it unfolded
Section titled “🕐 How it unfolded”Four phases: the lure (months before), the attack (7 minutes on 31 May), the defences that held, and the response.
🎣
6 Feb 2026
The lure lands
Section titled “The lure lands”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 itself warns him
Section titled “Upwork itself warns him”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)
A credential is stolen and sits unused
Section titled “A credential is stolen and sits unused”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
The burst begins
Section titled “The burst begins”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
Branch protection holds the line
Section titled “Branch protection holds the line”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 catches it
Section titled “Zabir catches it”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
Cleanup complete; hardening under way
Section titled “Cleanup complete; hardening under way”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
The malware never executed
Section titled “The malware never executed”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
Authentication was untouched
Section titled “Authentication was untouched”Across the window and the days after, sign-ins behaved completely normally — no anomalies.
our cloud
Nothing stolen from infrastructure
Section titled “Nothing stolen from infrastructure”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
One credential, no hidden tenant
Section titled “One credential, no hidden tenant”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.
🦠 The malware
Section titled “🦠 The malware”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
“PolinRider” — obfuscated blob
Section titled ““PolinRider” — obfuscated blob”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
The eval-loader
Section titled “The eval-loader”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.
What it does, once it runs
Section titled “What it does, once it runs”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
Downloader
Section titled “Downloader”Runs in the build process; pulls its next stage out of a blockchain transaction. Reads no credentials itself.
→
Stage 2
Second loader
Section titled “Second loader”Fetches and decrypts the next stage from another on-chain transaction.
→
Stage 3
Info-stealer (RAT)
Section titled “Info-stealer (RAT)”An InvisibleFerret-class remote-access trojan: fingerprints the host and waits on a remote socket. Never reached in our sandbox.
Why this was not aimed at N1 specifically
Section titled “Why this was not aimed at N1 specifically”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
Nikhil
Section titled “Nikhil”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
Syamil
Section titled “Syamil”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.
Red flags in the approach
Section titled “Red flags in the approach”💼
Unsolicited recruiter DM
Section titled “Unsolicited recruiter DM”On Upwork, LinkedIn, Telegram, or email — for a senior/staff dev or ML/AI role.
🪙
Crypto / trading / web3 framing
Section titled “Crypto / trading / web3 framing”A “software” role tied to crypto trading or web3 is the campaign’s signature. (Ours was a “crypto trading prediction project.”)
💻
“Just run our code”
Section titled ““Just run our code””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.
📎
Attachments & external links
Section titled “Attachments & external links”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”).
🚫 Before you interact
Section titled “🚫 Before you interact”- 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 installan 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.
Two lessons that generalise
Section titled “Two lessons that generalise”🔑 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.
⚙️ Build/config files run code
Section titled “⚙️ Build/config files run code”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.
🧭 What we’ve done & what’s left
Section titled “🧭 What we’ve done & what’s left”Done (and verified)
Section titled “Done (and verified)”- 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
Immediate close-out
Section titled “Immediate close-out”- 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
One blocking CI check on every repo
Section titled “One blocking CI check on every repo”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
Source-code markers
Section titled “Source-code markers”- PolinRider:
global['!'](value'9-0315-1'),_$_1e42,rmcej%otb%; acreateRequire(import.meta.url)call plus a single2000-character line in a
*.config.*file; theFunctionconstructor used aseval. - eval-loader:
atob(process.env.AUTH_API_KEY)→node-fetch→eval; committed.envsettingAUTH_API_KEY; log stringAuth Error!.
Network / host
Section titled “Network / host”- C2:
auth-confirm-six.vercel.app; siblingipcheck-six.vercel.app; the<word>-six.vercel.apppattern. - 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.
Target files
Section titled “Target files”eslint.config.*, postcss.config.mjs,
vite.config.*, playwright.config.ts, server.ts,
eleventy.config.js, tailwind.config.js.
Lure & delivery
Section titled “Lure & delivery”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.
Detection lesson
Section titled “Detection lesson”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.
