---
title: Forensic Investigation — Methodology & Analysis — Incident 2026-05-31
---

# 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.

[← Incident summary & timeline](./)

## Investigation principles

Every thread below was run under the same five rules. They are the reason the conclusions hold up.

#### 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

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

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

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

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

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?

#### ❓ Why

Scope, blame, and remediation all hinge on this,
and the commit metadata pointed at ~20 different people — none of which could be trusted.

#### ⚙️ 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

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?

#### ❓ Why

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

Checked first-seen for every loader SHA in the full
activity history; verified GPG signatures on the impersonated identities' real commits.

#### 🔍 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?

#### ❓ Why

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

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

An
**EtherHiding** multi-stage chain: BeaverTail-class downloader → on-chain loaders → an
[InvisibleFerret-class](https://attack.mitre.org/software/S1245/)
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](https://attack.mitre.org/groups/G1052/)
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?

#### ❓ Why

A bespoke endpoint implies targeting and a
live operator focused on N1; a shared one implies an automated sweep. The response posture differs.

#### ⚙️ 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

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?

#### ❓ Why

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

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

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?

#### ❓ Why

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

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

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?

#### ❓ Why

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

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

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?

#### ❓ Why

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

Per-repo workflow run analysis: head SHAs of every
post-plant run, `npm ci` outcomes, and which build steps load the poisoned config.

#### 🔍 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?

#### ❓ Why

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

Read-only scans of both EKS clusters (workloads,
images, env, cronjobs) and a full Cloudflare account download (Workers, Pages, served bundles, DNS).

#### 🔍 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?

#### ❓ Why

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

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

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?

#### ❓ Why

An IP or a "new device" session would pin the
replay and the origin geography.

#### ⚙️ How

Confirmed the org plan tier and pulled the
available personal token/OAuth exports.

#### 🔍 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?

#### ❓ Why

Understanding the actual containment mechanism tells
us which control to invest in, versus which were incidental.

#### ⚙️ How

Correlated which branches were poisoned against
each repo's protection state and the org-wide default permission.

#### 🔍 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

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

- 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

| 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…](https://bscscan.com/tx/0x80a1148ee589125bc1e57d36abac9f08089b2990d9372be3a33a1f057ad1ef89), [BSC 0x54b8bde1…](https://bscscan.com/tx/0x54b8bde10ea26d9ae0702e6e590f0af3e500cb14fda876e908620760ac32b76c), [TRON TA48dct6…](https://tronscan.org/#/address/TA48dct6rFW8BXsiLAtjFaVFoSuryMjD3v); 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

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.
