Forensic Investigation — Methodology & Analysis — Incident 2026-05-31
Forensic Investigation — Methodology & Analysis
Section titled “Forensic Investigation — Methodology & Analysis”The full investigation behind the 31 May 2026 force-push incident: each line of inquiry, why it was worth pursuing, how it was run, and what it returned. Read-only forensics across 87 repositories, two cloud environments, and two developer endpoints.
Investigation principles
Section titled “Investigation principles”Every thread below was run under the same five rules. They are the reason the conclusions hold up.
Trust events, not metadata
Section titled “Trust events, not metadata”Commit author names and dates are attacker-writable. The unforgeable record is the push/activity event (actor, before, after, timestamp). Every attribution rests on that, never on commit metadata.
Read-only, clone-first
Section titled “Read-only, clone-first”Code analysis ran against full local bare clones of all 87 repos; the GitHub API was used only for data that exists nowhere else (activity, settings, check-runs). Nothing was mutated during analysis.
Assume more than one tenant
Section titled “Assume more than one tenant”A stolen credential is a shared commodity — several unrelated actors routinely reuse the same access. Detection was never scoped to one malware family; we tried to find a second and disprove a single-payload assumption.
Detonate only in isolation
Section titled “Detonate only in isolation”The payload was decoded statically, then
executed only under --network none --read-only --cap-drop ALL with every dangerous
primitive hooked. The C2 was never contacted from a live host.
Verify, don’t guess
Section titled “Verify, don’t guess”Every load-bearing number was re-derived from primary evidence; the prose was independently fact-checked. Open questions are stated as open, with the exact datum that would close them.
Lines of inquiry
Section titled “Lines of inquiry”Twelve threads. Each is framed as the question that opened it, the reasoning for spending effort on it, the method, and the result.
Thread 1 Which credential pushed the payload?
Section titled “Thread 1 Which credential pushed the payload?”Scope, blame, and remediation all hinge on this, and the commit metadata pointed at ~20 different people — none of which could be trusted.
⚙️ How
Section titled “⚙️ How”Matched every payload-bearing commit to the push that introduced it via the activity log (actor / before / after / timestamp), across all 87 repos.
🔍 Findings
Section titled “🔍 Findings”A single credential —
numberyy — landed every payload, in one 13:52:45–13:59:44Z burst. dependabot[bot]
force-pushes were collateral (its tips reset onto poisoned bases). zabir-n1 and
syamil-n1 pushed no payload.
Thread 2 Is any of this genuinely old malware?
Section titled “Thread 2 Is any of this genuinely old malware?”Author-dates ran Nov 2025–Jun 2026; an early reading proposed a months-old “patient-zero” and a long-standing internal pattern. If true, scope and attribution change completely — and real people’s names sat on those commits.
⚙️ How
Section titled “⚙️ How”Checked first-seen for every loader SHA in the full activity history; verified GPG signatures on the impersonated identities’ real commits.
🔍 Findings
Section titled “🔍 Findings”Every loader commit first
appears as the after of a 05-31 numberyy force-push — zero appearances
before. All back-dating is forged. 1,379 of the most-impersonated identity’s commits are
GPG-verified genuine; 61 are forgeries. No old malware exists.
Thread 3 What does the payload actually do?
Section titled “Thread 3 What does the payload actually do?”Two distinct families were planted (an obfuscated
blob and an atob→fetch→eval loader). We needed the real capability and whether it harvests
credentials — and a marker-only scan had already missed the second family.
⚙️ How
Section titled “⚙️ How”Static deobfuscation, then isolated-sandbox detonation with hooked primitives. The loader’s staged on-chain payloads were recovered by reading public blockchain records read-only.
🔍 Findings
Section titled “🔍 Findings”An
EtherHiding multi-stage chain: BeaverTail-class downloader → on-chain loaders → an
InvisibleFerret-class
socket-C2 RAT (host fingerprint, fs+child_process, port 1224). Both families
run this same chain — Family A (PolinRider blob) inlines the loader; Family B (eval-loader) fetches it
from the C2. The toolchain is publicly attributed to the DPRK-aligned
Contagious Interview
campaign (external attribution, not independently confirmed). Stage 3 self-aborted with no C2 reachable; nothing
was exfiltrated in the sandbox. The on-chain dead-drops (IOC table below) are publicly inspectable on
BscScan / Tronscan.
Thread 4 Was the C2 built for us, or are we one of many?
Section titled “Thread 4 Was the C2 built for us, or are we one of many?”A bespoke endpoint implies targeting and a live operator focused on N1; a shared one implies an automated sweep. The response posture differs.
⚙️ How
Section titled “⚙️ How”Traced the C2 string through our full git history and web-archive; cross-referenced the identical base64 C2 against unrelated public repos pushed the same day.
🔍 Findings
Section titled “🔍 Findings”No history of the endpoint in
our environment before the plant; the same C2 string appears in unrelated repos force-pushed the same
day, with a sibling ipcheck-six.vercel.app. This was an automated, multi-victim sweep —
not infrastructure built for N1.
Thread 5 How was the credential stolen?
Section titled “Thread 5 How was the credential stolen?”The burst was a replay — the credential was used remotely, not minted. Without the theft mechanism we can’t say what else the actor holds or whether the vector is still open.
⚙️ How
Section titled “⚙️ How”Ranked candidate vectors against verified negatives (no new PAT, no new OAuth grant, no auth event in the burst window, IP/location-blind exports), then weighed the victim-reported lure and the platform’s own malware notice.
🔍 Findings
Section titled “🔍 Findings”Most-consistent hypothesis (not proven): an off-platform fake-recruiter lure (ML / “crypto trading” role) ran a browser/credential infostealer that lifted a pre-existing credential — invisible material (a session cookie / OAuth token) the victim had no signal to revoke — later replayed on 31 May. The screenshots prove solicitation and a platform malware warning, not that any file was run. N1 can’t “invalidate” a stolen session cookie/OAuth token — but required signed commits now mean it can no longer land a commit in N1’s repos, which is the gap that mattered. The theft itself happened on a personal device, off N1’s surface — outside N1’s reach by design. Exact credential and theft event remain unconfirmed; the closing datum is the account’s IP-stamped personal security log.
Thread 6 Is there a second campaign hiding behind the same credential?
Section titled “Thread 6 Is there a second campaign hiding behind the same credential?”Stolen creds are resold and reused; one prior compromise elsewhere drew 4+ competing malware families. Scoping detection to one family would miss a second implant.
⚙️ How
Section titled “⚙️ How”Broad-spectrum signature sweep across every branch tip (reverse shells, miners, env/token exfil, generic obfuscation/eval, suspicious network calls), plus a review of every in-window force-push by every actor and a GitHub-persistence check.
🔍 Findings
Section titled “🔍 Findings”No second campaign and no third
payload family. Aside from numberyy, every in-window force-push was ordinary developer
rebasing. A repo-level persistence sweep across all 87 repos (webhooks, deploy keys, Actions secrets)
found no object planted in the window: no webhook created/modified, no deploy key
created (the one write-capable key predates the incident by three months), and zero secrets created
— the only in-window secret updates were the team’s own 06-03 credential rotation. Org-level
webhooks: zero. No persistence object exists across the swept surface (the 87 active repos + org level;
the ~10 non-code org repos were not separately swept).
Thread 7 Did a malicious dependency get us — the campaign’s usual entry?
Section titled “Thread 7 Did a malicious dependency get us — the campaign’s usual entry?”This actor’s documented entry path is a poisoned npm package. If one was in our tree, the blast radius is far larger than a config-file plant.
⚙️ How
Section titled “⚙️ How”3,206 npm packages at HEAD + 2,258 historical names checked against the campaign’s known-bad lists; every lockfile resolution confirmed registry-sourced; Python/Go across 58 repos against OSV’s live malicious-package set; all 86 internal R2 wheels content- inspected against their manifests and pinned hashes.
🔍 Findings
Section titled “🔍 Findings”Clean. No campaign package
present, no off-registry resolution, no tampered internal wheel. litellm specifically is
not pinned to a compromised version. (Coverage rests on OSV’s live set — the bundled scanners’ DBs were
stale.)
Thread 8 Did it execute or harvest anything in CI?
Section titled “Thread 8 Did it execute or harvest anything in CI?”The whole point of the payload is credential theft at lint/build/test time. “Present in a repo” and “ran” are different blast radii.
⚙️ How
Section titled “⚙️ How”Per-repo workflow run analysis: head SHAs of every
post-plant run, npm ci outcomes, and which build steps load the poisoned config.
🔍 Findings
Section titled “🔍 Findings”No execution, no observed
harvest. Runs were disabled, died at npm ci (the attacker’s own loader deps were absent
from our lockfiles), or ran a build that never loads the poisoned eslint config. Breadth came from the
stolen personal credential, not a harvested CI token.
Thread 9 Did it reach production, staging, or the edge?
Section titled “Thread 9 Did it reach production, staging, or the edge?”Source-repo poison is contained; a tainted running workload or deployed bundle is not. This is the question that maps to customer/patient impact.
⚙️ How
Section titled “⚙️ How”Read-only scans of both EKS clusters (workloads, images, env, cronjobs) and a full Cloudflare account download (Workers, Pages, served bundles, DNS).
🔍 Findings
Section titled “🔍 Findings”Clean. No C2 host, no
AUTH_API_KEY, no markers, no rogue workloads; all images from trusted registries; no DNS
record points at the C2. (Honest limit: a manifest scan can’t read image-layer contents or secret
values.)
Thread 10 Where is the implant on the developers’ machines?
Section titled “Thread 10 Where is the implant on the developers’ machines?”The credential was stolen somewhere; the endpoints are the only place that holds the dropper and the vector. Both engineers use Claude Code heavily, so their session archives are a rich detection surface.
⚙️ How
Section titled “⚙️ How”Examined the full Claude Code archive (2,260
sessions) and machine config for the numberyy holder; the endpoint-IR bundle and session
transcript for the other.
🔍 Findings
Section titled “🔍 Findings”Both are victims. No payload ever transits the archives; one engineer was the first responder who caught the poisoned branch on a rebase and isolated it. The actual theft executed off the Claude surface (a plain terminal / IDE / personal context), which these archives can’t see — that trail is cold.
Thread 11 Can we recover the attacker’s source IP from GitHub?
Section titled “Thread 11 Can we recover the attacker’s source IP from GitHub?”An IP or a “new device” session would pin the replay and the origin geography.
⚙️ How
Section titled “⚙️ How”Confirmed the org plan tier and pulled the available personal token/OAuth exports.
🔍 Findings
Section titled “🔍 Findings”Not recoverable from GitHub: the Team plan records no per-push source IP and exposes no audit-log API; the exports are IP- and location-blind. The only remaining lead is the account’s owner-only security log (sign-in/session events).
Thread 12 Why did the blast radius stay small?
Section titled “Thread 12 Why did the blast radius stay small?”Understanding the actual containment mechanism tells us which control to invest in, versus which were incidental.
⚙️ How
Section titled “⚙️ How”Correlated which branches were poisoned against each repo’s protection state and the org-wide default permission.
🔍 Findings
Section titled “🔍 Findings”Branch protection was the primary
determinant: protected defaults rejected the force-push at the mainline, confining payload to dormant
feature branches; only three repos with unprotected defaults were hit directly. The amplifier was the
org default permission being write.
Where it stands now
Section titled “Where it stands now”Closing the root cause and the structural gaps the investigation surfaced. Current org ruleset posture:
| Control | State |
|---|---|
| Verified signatures required — every commit, every branch, every repo | active, no bypass |
main + develop protected (no force-push/deletion, PR required) — all repos, current and future |
active, owner-only emergency bypass |
Default branch (develop) — no force-push/deletion |
active, no bypass |
| Org default permission | read |
| Org-wide 2FA requirement | enforced |
| All poisoned default branches | cleaned (signed commits); attacker account removed |
| Poisoned feature branches | all deleted |
| Forward work — a focused ~2-week, open-source program owned by Arun and Eyad, in two | |
tracks. Prevention: one blocking security-gate CI check on every repo |
|
| (current and future) — a shape-based malware/obfuscation scanner (the upgrade that would have caught the | |
| loader) chained with dependency-CVE, secret, trusted-registry, Actions, and test/coverage checks; mirrored | |
| by pre-commit hooks; plus commit-signing onboarding and the discrete audit-finding fixes (public wheel | |
| registry, CF TLS/token TTLs, app least-privilege, bastion pinning). Detection: a GitHub | |
| org-webhook → Slack alert on force-push burst / new collaborator / new app / ruleset change, a nightly | |
| snapshot-diff to fill the audit-log gap, an AWS honey-key tripwire for stolen-credential use, and a | |
| read-only metadata Slack bot. Tracked separately (own budget): SignOz PHI-redaction and endpoint EDR. |
Honest limits
Section titled “Honest limits”- The exact stolen credential and the attacker’s IP are not recoverable from GitHub Team telemetry.
- The lure is the most-consistent entry hypothesis, not a proven fact — no malicious file or its hash was recovered.
- A repo scan cannot see a local-only “take-home” repository; the dropper is confirmable only from the endpoint.
- Stage-3 exfil targets arrive over a live operator socket that was deliberately never contacted, so they are not observable.
- Manifest/registry scans cannot read container-image layer contents or secret values.
- The nation-state attribution rests on indicator overlap with public reporting, not independent confirmation.
Indicators of compromise
Section titled “Indicators of compromise”| Type | Value |
|---|---|
| C2 | auth-confirm-six.vercel.app/api; sibling ipcheck-six.vercel.app; <word>-six.vercel.app |
| PolinRider markers | global['!'] ('9-0315-1'), _$_1e42, rmcej%otb%; createRequire + a single >2000-char line in *.config.* |
| Loader | atob(process.env.AUTH_API_KEY) → node-fetch → eval; committed .env; log string Auth Error! |
| On-chain | BSC 0x80a1148e…, BSC 0x54b8bde1…, TRON TA48dct6…; stage-3 socket port 1224 |
| Lure | Upwork “Princes A.” / “Princes Ampalayo”; ML + crypto-trading freelance pretext |
| Delivery | force-push burst from one account with back-dated author dates + forged identities — trust the push event, not commit metadata |
Detection note
Section titled “Detection note”Marker-scanning alone missed the loader family entirely. Pair marker strings with behaviour-based rules: dynamic decode-and-execute, oversized single-line config blobs, and build processes making outbound network calls.
Confidential — Internal Only
N1 Healthcare · Forensic analysis of the incident of 31 May 2026 · Report dated 4 June 2026 · Contains no live malware.
