Skip to content
PublishedThreat Intelligence · v1.0 · 16 min

How the GitHub Investigation Was Verified

Pinned commits, fixed inclusion rules, exclusions, independent checks, evidence provenance and research limits.

Cristian Chiriac

Published:
2 November 2026
Version:
1.0
Last observed:
30 October 2026
Dataset frozen:
1 November 2026
Collection cut-off:
31 October 2026, 17:59 UTC
Tracked items rechecked:
1 November 2026, 18:23 UTC
Time zone
All dates UTC.
Contents

The headline count is built from fixed inclusion rules and pinned repository states, with a metadata-only exception for Shai-Hulud.

The research in 30 seconds

What I found
The headline count is built from fixed inclusion rules and pinned repository states, with a metadata-only exception for Shai-Hulud.
What this adds
The method records rejected candidates and an independent second-API recheck instead of treating search results as proof.
Verified scope
43,448 unique repositories across the counted activities; an independent stratified sample of 555 was re-read.
What remains unknown
Proof establishes code or metadata at a recorded snapshot. It does not establish execution, compromise counts or operator identity.

The numbers, with their boundaries

Current count

43,448

Unique proven repositories

30–31 Oct 2026 · UTC

Counts:
Counted by repository id across the included activity sets.

Context · separate measure

555 / 555

Independent sample matches

30 Oct 2026 · UTC

Counts:
Stratified re-read through REST at pinned commits; not a full second audit.
Technical detail:Long tables, code and the evidence register are folded; nothing is left out.

This methodology supports the investigation series. Repository-level proof rows remain private because their permalinks identify affected accounts.

Where this stands (1 November 2026)

#

The proven set was closed on 1 November 2026 with what had been found up to 31 October 2026, 17:59 UTC; repositories found after that are tracked separately and are not in these figures. The full proof pass at pinned commits ran on 30–31 October. On 1 November 2026, 18:23 UTC a read-only GitHub API recheck covered the 162 repositories, 73 accounts and 12 issues that are tracked one by one (operator repositories, Shai-Hulud accounts, phishing issues), not the whole set.

  • Frozen set: 43,448 repositories under 4,250 accounts; no row is added or removed after the freeze.
  • After the freeze: the daily tracker keeps proving new repositories with the same rules; they go to a separate post-freeze list, never into these figures.
  • One judgement call kept: a repository whose loader sits under a tests/ path stays in the set because an .env in its git history decodes to a proven host; the dataset-path rule would otherwise have excluded it.

How every repository was proven

#

The first searches produced candidates, not proof. On 30 October every candidate was read again from scratch:

  1. Pin the commit. Read the repository by its numeric id, so a rename cannot change the answer. Record the current default-branch commit.
  2. Read the file at that commit. Record its path, git object id and size. For a task that runs another file, read that target too.
  3. Apply a rule fixed in advance. Each activity's rule is stated in its dedicated article. A file that only resembles the pattern does not count.
  4. Keep a permalink to the commit. Anyone can open the same file at the same commit.

Shai-Hulud is the exception. Those repositories hold stolen secrets, so I never opened them. They are proven from repository metadata instead: the worm writes its own marker into the description.

No file contents were stored. The per-repository proof table stays private, because each permalink names a victim's account. This article gives the rules, the counts, the hashes and the indicators.

The re-check also caught a mistake in my first rule. Seven sampled PolinRider tasks pointed at an empty file, which cannot run anything. I tightened the rule: the target must exist, must not be empty and must look like JavaScript. That removed 806 repositories before the final count.

Later on 30 October three more corrections were made. A paged code search added 384 AUTH_API_KEY loader repositories, each checked against the proven files with rules fixed before counting. Six research or defensive files that had matched a rule were removed (five repositories, plus one loader label). Six hook lures whose hook sits in .github/githooks/ were added. The 555-repository re-check was drawn before these corrections. On 31 October seven more were added from a wider read of lure and tool repositories: three PolinRider build configs and four published Discord token stealers. A removal tool and two crawler datasets matched the same rules and were left out. Later that day an extended search added 421 more under the same rules and removed one false positive. On 1 November 32 more were added from the daily tracker's run of 31 October evening (29 Shai-Hulud, 3 AUTH_API_KEY loaders), each read again at a pinned commit. Each activity below says what it added. The set was closed on 1 November 2026 with what had been found up to 31 October, 17:59 UTC. Repositories found after that are tracked separately and are not part of the total.

PolinRider candidates removed by the proof pass

30 October 2026 proof pass, PolinRider only. None of these is counted anywhere in this article.

Table view
PolinRider candidates removed by the proof pass
ItemValueNote
Task points at an empty file806
No evidence at the current commit360Cleaned or moved
Task target deleted131
Security datasets and research copies78
Repository deleted25
Needs manual review3

Why it is malicious, and why it might not be

#

Code that downloads or evaluates other code is not malicious by itself. Build tools do it, and so do security datasets and detection rules that quote malware. For each activity, the table gives the rule that made the call, the harmless explanation I checked for, and what stays unproven.

Full data table · 10 rows
Activity Why it is malicious What could make it harmless, and how that was handled Not proven
Contagious Interview Loader byte-identical to analysed samples; decoded configs point to operator staging hosts; payloads are a stealer family A template copied by a curious developer. Copies are counted separately from operator repositories Whether a given candidate ran it
PolinRider A "font" that is JavaScript, started on folder open; or a payload hidden after hundreds of spaces in a build config Security datasets, detection rules, real fonts, empty targets. All excluded: 78 dataset repositories, 806 empty targets Who pushed most infections; execution on any machine
AUTH_API_KEY loader A committed .env value decodes to a web address; the file evaluates code with it A real API key, or a test fixture. Ruled out: the value decodes to a URL and is passed to eval or Function The payload itself; the fetch step file by file
Older families Hard-coded Discord webhook with token plus code reading Discord's local storage; loaders identical in form to Contagious Interview Educational repositories. Counted as published tools, not victims Whether each stealer works
Shai-Hulud The worm's own description marker on repositories created in bursts in victims' accounts Hobby repositories named after the novel, detection tools. Excluded by exact marker matching Which package infected each account
D1337 A workflow downloads XMRig and mines to one wallet with a per-owner worker name An owner mining on purpose. Unlikely: one identity planted it in many unrelated accounts Whether the runs happened and what was earned
Fake CVE exploits Archives contain no exploit, only a launcher, a renamed interpreter and an obfuscated script; 12 of 14 detected, 5 as SmartLoader A real proof-of-concept. None of the archives contains one Who downloaded them; the two archives unknown to VirusTotal
Lure hubs Not yet decided Thin repositories that link real downloads Being verified
Trading-bot pages Page code builds a hidden download link and refuses to for scanners A legitimate download page. Cloaking from scanners argues against it What the download installs
Fake security alerts Fake "[GitHub]" alert issues that mention one user each, with shortened links A real security notice. GitHub's own account alerts do not arrive as issues in a third-party repository Where the links lead

Data notes and reconciliation

#
Expand technical detail · complete record
Measure Figures Why they differ
Headline total 42,604 → 42,983 → 42,989 → 42,996 → 43,416 → 43,448 30 Oct morning proof pass → +384 AUTH_API_KEY, −5 research datasets (30 Oct evening) → +6 armed hook lures → +7 from a wider read (31 Oct) → +421 extended search, −1 (31 Oct) → +32 daily-tracker finds up to the cut-off, added on 1 Nov; frozen
Accounts 4,015 → 4,141 → 4,143 → 4,149 → 4,248 → 4,250 Same steps
PolinRider 39,995 → 39,998 → 40,136 30 Oct proof pass → +3 build configs → +138 from reading every repository of newly proven owners (31 Oct)
Shai-Hulud 1,830 → 1,815 → 1,796 → 1,966 → 1,995 29 Oct inventory → final inventory → 30 Oct recheck → +170 (31 Oct) → +29 created on 31 Oct before the cut-off, added on 1 Nov
AUTH_API_KEY loader 231 → 614 → 646 → 649 See the loader article
Older families 229 → 233 → 273 +4 wider read, +39 Discord stealers and +1 Python loader (31 Oct)
Independent re-check 555 of 555 Drawn from the 30 Oct set; later additions were pinned again individually, not sampled

The inclusion rules, in one place

#

Each dedicated article states its rule; this is the same list side by side, as applied on 30 October and to every later addition. A file that only resembles a pattern does not count.

Full data table · 11 rows
Activity What must be true at the pinned commit
PolinRider, task form .vscode/tasks.json has a task with "runOn": "folderOpen" that starts node on a file whose name looks like a font (.woff, .woff2, .ttf, .eot, .otf); that file exists, is not empty and is JavaScript
PolinRider, config form A build configuration file (vite, next, nuxt, postcss, tailwind, astro) carries JavaScript after a long run of whitespace, with the loader's global-variable pattern
AUTH_API_KEY loader A code file calls atob on process.env.<…>API_KEY<…> and passes the result to eval or Function; the committed .env value, or one in the git history, decodes to a web address
Contagious Interview A loader file byte-identical to an analysed sample, a post-checkout hook under .githooks/ or .github/githooks/ that pipes a download into a shell, or the server source itself
Discord token stealer A hard-coded Discord webhook address and code that reads Discord's local storage (leveldb, Local Storage) for tokens
errorHandler copy A controller or bootstrap file with the loader in the same form as the Contagious Interview templates, outside the operators' template set
Python Fernet loader exec(Fernet(<key in the file>).decrypt(<ciphertext>)), with the decrypted stage reading as a remote-execution loader
Shai-Hulud The worm's exact description marker on the repository; contents never opened
D1337 A workflow file that downloads XMRig and mines to the cluster's wallet
Fake CVE exploits A release archive whose SHA-256 is known malware and whose file list has no exploit, only a launcher, a renamed interpreter and an obfuscated script
Cheat and crack lures A README that links a release file whose hash is known malware; 1,422 lures that link only to hubs stay suspected

Three exclusions apply to every rule: paths that mark tests, fixtures, samples, corpora, datasets, documentation and research (tests/, fixtures/, samples/, corpus/, docs/, research/, malware/, rules/, reports/), and files ending in .md, .txt, .json, .yaml or .html unless the rule itself names that file. Two rules do: the PolinRider task form reads .vscode/tasks.json, and the cheat and crack rule reads the README that links the archive; a match in any other file of those types counts for nothing; repositories whose only match is a security dataset, a detection rule or a removal tool; and matches inside a coding-assistant notes file with no loader in any code file. One exception is kept and named in "Where this stands".

Counting rules

#
  • One row per repository id. A repository that carries two activities (118 do, mostly PolinRider with the AUTH_API_KEY loader) is one row listing both; its file columns describe the PolinRider file. Per-activity figures can therefore add up to more than the total.
  • Accounts by owner id. Logins seen under the same id are listed together; 203 owners appear in two activities.
  • Forks compared with their parent. 6,884 of the repositories are forks; in 719 the malicious file is byte-identical to the parent's and was not modified in the fork. They are counted and flagged, because removing the parent does not clean them. Accounts whose only infected repositories are such forks (53 for PolinRider) are listed as inherited, not compromised.
  • Non-default branches are not counted. 22,870 infected branches in 6,530 repositories were recorded on 29 October and are counted separately.
  • Dates come from the platform, not from commits: PolinRider operators rewrite history, so a commit's author date is not an infection date. "New" means first proven, except for Shai-Hulud, where the repository creation time is exact.
  • Frozen at a cut-off. The set closed on 1 November 2026 with what was found up to 31 October 2026, 17:59 UTC. Later finds go to a dated post-freeze list.

Environment and tooling

#
  • Every read of the platform (GraphQL for repository and owner ids, REST for commits, trees and contents, code and repository search) ran inside an isolated virtual machine with no access to the host. The token entered the machine on standard input for each run and was never written to disk; caches were cleared after each run.
  • File text was held in memory only. Stored per repository: ids, commit, path, object id, size, rule name, booleans and dates. No file contents, no decoded payloads, no victims' data.
  • Decoding and deobfuscation (base64, hex, the Fernet stage, obfuscated JavaScript) were static, inside the same machine. Nothing was executed; no attacker host was contacted; no link from an issue or file was opened.
  • The scripts that produced each count, the query lists and the rule texts are kept with the evidence, so a figure can be recomputed from the same inputs. The independent re-check used a recorded random seed.

Timeline of the proof work

#
Date (2026) Step
16 Oct Antivirus alert on a security note: the starting point
26–27 Oct First sweeps; 16 server-source repositories and the hook lures identified
29 Oct First lists compiled (7,332 PolinRider candidates among them); Shai-Hulud burst recorded
30 Oct Proof pass: every candidate re-read at a pinned commit under fixed rules; 555-sample independent re-check; rule tightened after the sample; evening corrections
31 Oct Wider read of lure and tool repositories; extended searches and an owner round; daily tracker started
1 Nov Tracker finds (up to the cut-off) pinned again and added; the 162 tracked repositories, 73 accounts and 12 issues re-read; set frozen

Detection rules and indicator export

#

The inclusion rules above are also available as detection rules, written for source files rather than binaries. They are experimental. On 2 November 2026 each was run against a sample of the proven files and about 105,700 benign files. Four of the first versions missed most of their proven files and were rewritten the same day; the rewritten rules were also run on proven files they were not written against. Every rule now matches all, or all but a few, of its proven files, with no benign hit. For the PolinRider, AUTH_API_KEY and Discord rules, the proof pass used similar markers, so these runs show that the rules find the proven set, not variants without those markers. Each rule card lists the runs and their limits. The Contagious Interview payload rules stay on the infrastructure page.

Rule Activity Download
VS Code task that runs node on a disguised file when the folder opens PolinRider polinrider-vscode-task-autorun.yar
Obfuscated loader appended to a build configuration file PolinRider polinrider-build-config-appended-loader.yar
atob on an environment variable, evaluated with eval or Function AUTH_API_KEY loader auth-api-key-env-eval-loader.yar
Semgrep rule: code fetched from an environment-variable URL and evaluated (JavaScript/TypeScript) AUTH_API_KEY loader auth-api-key-env-fetch-eval.yml
Repository-shipped post-checkout hook that pipes a download into a shell Contagious Interview git-hook-post-checkout-pipe-to-shell.yar
Published Discord token stealer reading local storage and posting to a webhook Discord token stealer discord-token-stealer-leveldb.yar
Python file that decrypts an embedded Fernet stage and executes it Python Fernet loader python-fernet-exec-stage.yar
Indicators of each article, defanged all indicators.json and indicators.csv in that article's research files

Each article's indicator files are built from its indicator blocks at build time, so they never contain anything the article does not show.

VS Code task that runs node on a disguised file when the folder opens· yaraDownload

Matches .vscode/tasks.json files with the PolinRider trigger: a folderOpen task whose command runs node on a file with a font, image or made-up extension (.woff2, .ttf, .orc, .lkf and others). Proof additionally requires the target to be a non-empty JavaScript file.

Status
experimental
Family
PolinRider
Platforms
none recorded
Engines tested
YARA 4.5.0
Known benign cases
none recorded
Testing
Rewritten on 2 November: the first version matched none of 199 proven tasks, because it expected node and the target as separate JSON strings and knew only font extensions. The proven tasks run node inside one shell command string, and many target made-up extensions such as .orc. This version looks for node followed by a path with a font, image or made-up extension in any folderOpen task. It matched all 199 files it was written against, all 300 held-out files, and none of 88 benign tasks.json files. The proof pass chose the proven files with a similar check (a folderOpen task running node on a non-JavaScript file), so these runs show that the rule finds the proven set; they do not show that it finds variants without those markers.

Check results

DateEngineCorpusResult
2026-11-02YARA 4.5.0Proven PolinRider .vscode/tasks.json files the rule was written against (199)199 of 199 matched
2026-11-02YARA 4.5.0Proven tasks.json files not used to write the rule, random sample (300)300 of 300 matched
2026-11-02YARA 4.5.0The other proven sets of this investigation (1,194 files)0 matches
2026-11-02YARA 4.5.0Benign source code: shallow clones of next.js, microsoft/vscode and vscode-extension-samples (56,864 files, 88 of them tasks.json)0 hits
2026-11-02YARA 4.5.0Benign files in the analysis VM: npm tools (19,474), Python standard library and packages (29,360), git sample hooks (14)0 hits
rule CMPC_PolinRider_VSCode_Task_Autorun
{
    meta:
        author = "Cristian Chiriac / CMPC Research"
        description = "PolinRider trigger: VS Code folderOpen task that runs node on a file with a font, image or made-up extension"
        status = "experimental"
        date = "2026-11-02"
        scope = "Run on .vscode/tasks.json; confirm the target file is JavaScript before acting"
        targets = "woff2 woff ttf otf eot orc lkf lfk llf glb fml svg png jpeg sol: every extension in the proven tasks"
    strings:
        $open = "folderOpen" ascii
        $node = /\bnode(\.exe)?\s+(\\?["'])?(\.{0,2}[\/\\]{1,2})?[\w.\-\/\\\[\]()@]+\.(woff2?|ttf|otf|eot|orc|lkf|lfk|llf|glb|fml|sol|svg|png|jpe?g|gif|ico|bmp|webp)\b/ ascii
    condition:
        filesize < 200KB and $open and $node
}
Obfuscated loader appended to a build configuration file· yaraDownload

Matches files that carry the PolinRider loader on one long line: the global['_V'] or global['!'] marker, or an _$_xxxx obfuscator name, followed by at least 300 more characters on the same line. In build configurations the line is usually pushed off-screen by spaces or tabs; the rule does not depend on that padding.

Status
experimental
Family
PolinRider
Platforms
none recorded
Engines tested
YARA 4.5.0
Known benign cases
none recorded
Testing
Rewritten on 2 November: the first version found 8 of 200, because it required files over 20 KB and a 400-character run of spaces, and it hit 13 benign files. This version drops both conditions and requires the marker to be followed by at least 300 characters on the same line. It matched all 200 files it was written against, all 297 held-out files, and no benign file, 1,685 of them configuration files. The proof pass chose the proven files with the same markers plus a long line or a long run of spaces, so these runs show that the rule finds the proven set; they do not show that it finds variants without those markers.

Check results

DateEngineCorpusResult
2026-11-02YARA 4.5.0Proven PolinRider files carrying the appended loader, mostly build configurations (200; the files the rule was written against)200 of 200 matched
2026-11-02YARA 4.5.0Proven files of the same set not used to write the rule, random sample (297; 3 no longer available)297 of 297 matched
2026-11-02YARA 4.5.0The other proven sets of this investigation (1,196 files)0 matches
2026-11-02YARA 4.5.0Benign source code: shallow clones of next.js, microsoft/vscode and vscode-extension-samples (56,864 files, 1,673 of them *.config.* files)0 hits
2026-11-02YARA 4.5.0Benign files in the analysis VM: npm tools (19,474), Python standard library and packages (29,360), git sample hooks (14)0 hits
rule CMPC_PolinRider_BuildConfig_Appended_Loader
{
    meta:
        author = "Cristian Chiriac / CMPC Research"
        description = "PolinRider loader appended to a file: global['_V'] / global['!'] or an _$_xxxx name, then the rest of the loader on the same line"
        status = "experimental"
        date = "2026-11-02"
        scope = "Run on *.config.(js|ts|mjs|cjs), postcss/tailwind/vite/next/eslint configurations and font-named files that read as text"
    strings:
        $g = /global\[\s*['"](_V|!)['"]\s*\][^\n\r]{300}/ ascii
        $s = /_\$_[0-9a-f]{4}[^\n\r]{300}/ ascii
    condition:
        filesize < 5MB and any of them
}
atob on an environment variable, evaluated with eval or Function· yaraDownload

Matches server or build files that decode AUTH_API_KEY (or a sibling variable) from the environment and evaluate the result. The decoded value in .env is a URL; the article lists the hosts seen.

Status
experimental
Family
AUTH_API_KEY loader
Platforms
none recorded
Engines tested
YARA 4.5.0
Known benign cases
none recorded
Testing
Matched every proven file in both samples (200 and 172), no other proven set and no benign file. The proof pass chose the proven files with the same strings (atob on process.env plus eval or Function), so these runs show that the rule finds the proven set; they do not show that it finds variants without those markers.

Check results

DateEngineCorpusResult
2026-11-02YARA 4.5.0Proven AUTH_API_KEY loader files, random sample (200)200 of 200 matched
2026-11-02YARA 4.5.0Proven AUTH_API_KEY loader files not in that sample (172)172 of 172 matched
2026-11-02YARA 4.5.0The other proven sets of this investigation (1,321 files)0 matches
2026-11-02YARA 4.5.0Benign source code: shallow clones of next.js, microsoft/vscode and vscode-extension-samples (56,864 files)0 hits
2026-11-02YARA 4.5.0Benign files in the analysis VM: npm tools (19,474), Python standard library and packages (29,360), git sample hooks (14)0 hits
rule CMPC_AuthApiKey_Env_Eval_Loader
{
    meta:
        author = "Cristian Chiriac / CMPC Research"
        description = "AUTH_API_KEY loader: atob(process.env.<VAR>) passed to eval or Function"
        status = "experimental"
        date = "2026-11-01"
        scope = "Run on *.ts, *.js, *.mjs, *.cjs in application and build-config paths"
    strings:
        $decode = /atob\(\s*process\.env\.[A-Z_]*API_KEY[A-Z_]*\s*\)/ ascii
        $eval = /\beval\s*\(/ ascii
        $fn = /\bnew\s+Function\s*\(|\bFunction\s*\(/ ascii
    condition:
        filesize < 512KB and $decode and ($eval or $fn)
}
Semgrep rule: code fetched from an environment-variable URL and evaluated (JavaScript/TypeScript)· semgrepDownload

Taint rule: follows the value of atob(process.env.<VAR>) through the request that uses it and the response it returns, and reports when that data reaches eval, new Function or Function. For CI pipelines that scan source before build.

Status
experimental
Family
AUTH_API_KEY loader
Platforms
none recorded
Engines tested
Semgrep 1.183.0
Known benign cases
none recorded
Testing
Rewritten on 2 November: the first version matched none of 200, because the decoded value is a URL: it is fetched, and eval runs the response, not the decoded value. This version is a taint rule that follows the value through the request and its response to eval, new Function or Function. It matched 199 of the 200 files it was written against (the miss has the loader commented out, so not flagging it is right) and 166 of 172 held-out files (2 misses are Markdown files, 3 did not parse fully, 1 parsed file with the usual structure was missed for a reason not identified). No benign hits, but only 17,474 benign JavaScript and TypeScript files were scanned: Semgrep's default ignore list skips node_modules. The proof pass chose the proven files with atob on process.env plus eval or Function, so these runs show that the rule finds the proven set; they do not show that it finds variants without those markers.

Check results

DateEngineCorpusResult
2026-11-02Semgrep 1.183.0Proven AUTH_API_KEY loader files the rule was written against (200; 3 parsed only partially)199 of 200 matched; the miss has the loader commented out
2026-11-02Semgrep 1.183.0Proven AUTH_API_KEY loader files not used to write the rule (172)166 of 172 matched: 2 are Markdown files Semgrep does not scan, 3 did not parse fully, 1 missed for a reason not identified
2026-11-02Semgrep 1.183.0The other proven sets of this investigation (1,321 files; 560 of them JavaScript or TypeScript that Semgrep scanned)0 matches
2026-11-02Semgrep 1.183.0Benign source code: shallow clones of next.js, microsoft/vscode and vscode-extension-samples (Semgrep scanned 17,088 JavaScript/TypeScript files; 8 timed out)0 hits
2026-11-02Semgrep 1.183.0Benign files in the analysis VM (Semgrep scanned 386: its default ignore list skips node_modules; 3 timed out or failed to parse)0 hits
rules:
  - id: cmpc-auth-api-key-env-fetch-eval
    message: Code fetched from a URL decoded out of an environment variable reaches eval or Function (AUTH_API_KEY loader pattern)
    severity: ERROR
    languages: [javascript, typescript]
    metadata:
      author: Cristian Chiriac / CMPC Research
      status: experimental
      date: 2026-11-02
    mode: taint
    pattern-sources:
      - pattern: atob(process.env.$VAR)
    pattern-sinks:
      - pattern: eval(...)
      - pattern: new Function(...)
      - pattern: Function(...)
Repository-shipped post-checkout hook that pipes a download into a shell· yaraDownload

Matches .githooks/post-checkout and .github/githooks/post-checkout files that fetch a script with curl or wget and run it. Git does not run hooks from these paths by itself; the repositories set core.hooksPath or ship a .git directory in an archive.

Status
experimental
Family
Contagious Interview
Platforms
none recorded
Engines tested
YARA 4.5.0
Known benign cases
none recorded
Testing
Matched every available proven hook and no benign file, including git's own sample hooks. Six files is a small set. YARA warns that $bg may slow scanning.

Check results

DateEngineCorpusResult
2026-11-02YARA 4.5.0Proven repository-shipped post-checkout hooks (6 of the 7 sampled; 1 no longer available)6 of 6 matched
2026-11-02YARA 4.5.0The other proven sets of this investigation (917 files)0 matches
2026-11-02YARA 4.5.0Benign source code: shallow clones of next.js, microsoft/vscode and vscode-extension-samples (56,864 files)0 hits (includes the clones' .git/hooks samples)
2026-11-02YARA 4.5.0Benign files in the analysis VM: npm tools (19,474), Python standard library and packages (29,360), git sample hooks (14)0 hits
rule CMPC_Git_Hook_PostCheckout_Pipe_To_Shell
{
    meta:
        author = "Cristian Chiriac / CMPC Research"
        description = "Committed post-checkout hook downloading and executing a remote script"
        status = "experimental"
        date = "2026-11-01"
        scope = "Run on files named post-checkout under .githooks/ or .github/githooks/"
    strings:
        $fetch = /\b(curl|wget)\b[^\n]{0,200}(https?:\/\/|\/settings\/)/ ascii
        $pipe = /\|\s*(sh|bash|zsh)\b/ ascii
        $flag = "flag=" ascii
        $bg = /(\bnohup\b|&\s*$|setsid)/ ascii
    condition:
        filesize < 8KB and $fetch and $pipe and ($flag or $bg)
}
Published Discord token stealer reading local storage and posting to a webhook· yaraDownload

Matches source files that hold a complete Discord webhook URL and read the Local Storage leveldb folder of Discord or a browser, where Discord keeps its login token. These are published tools; the owner is not necessarily a victim or an operator.

Status
experimental
Family
Discord token stealer
Platforms
none recorded
Engines tested
YARA 4.5.0
Known benign cases
none recorded
Testing
Rewritten on 2 November: the first version found 2 of 196, because it required a literal token-shaped string; most of these tools hold the token pattern as regular-expression source instead. This version requires a complete webhook URL and a Local Storage or leveldb read. It matched all 196 files it was written against, the one remaining proven file, and no benign file. With one held-out file, this is close to a test on the development set only. The proof pass chose the proven files with the same strings (a webhook URL plus Local Storage or leveldb), so these runs show that the rule finds the proven set; they do not show that it finds variants without those markers.

Check results

DateEngineCorpusResult
2026-11-02YARA 4.5.0Proven Discord token stealer files the rule was written against (196)196 of 196 matched
2026-11-02YARA 4.5.0The one other proven Discord token stealer file1 of 1 matched
2026-11-02YARA 4.5.0The other proven sets of this investigation (1,496 files)0 matches
2026-11-02YARA 4.5.0Benign source code: shallow clones of next.js, microsoft/vscode and vscode-extension-samples (56,864 files)0 hits
2026-11-02YARA 4.5.0Benign files in the analysis VM: npm tools (19,474), Python standard library and packages (29,360), git sample hooks (14)0 hits
rule CMPC_Discord_Token_Stealer_LevelDB_Webhook
{
    meta:
        author = "Cristian Chiriac / CMPC Research"
        description = "Discord token stealer: hard-coded webhook URL plus a read of Discord or browser Local Storage (leveldb)"
        status = "experimental"
        date = "2026-11-02"
        scope = "Run on source files (py, js, ps1, cs, java, rs); expect hits in research corpora too"
    strings:
        $hook = /discord(app)?\.com\/api\/webhooks\/\d{15,}\/[\w\-]{30,}/ ascii
        $ls1 = "Local Storage" ascii
        $ls2 = "leveldb" ascii nocase
    condition:
        filesize < 5MB and $hook and any of ($ls*)
}
Python file that decrypts an embedded Fernet stage and executes it· yaraDownload

Matches Python sources that call exec on Fernet(key).decrypt(ciphertext) with the key in the same file. One proven repository; the same shape is common in sample collections, so check the repository is not a research corpus.

Status
experimental
Family
Python Fernet loader
Platforms
none recorded
Engines tested
YARA 4.5.0
Known benign cases
none recorded
Testing
Matched the only proven file and no benign file. With one proven file, recall cannot be estimated.

Check results

DateEngineCorpusResult
2026-11-02YARA 4.5.0The one proven Python Fernet loader file1 of 1 matched
2026-11-02YARA 4.5.0The other proven sets of this investigation (922 files)0 matches
2026-11-02YARA 4.5.0Benign source code: shallow clones of next.js, microsoft/vscode and vscode-extension-samples (56,864 files)0 hits
2026-11-02YARA 4.5.0Benign files in the analysis VM: npm tools (19,474), Python standard library and packages (29,360), git sample hooks (14)0 hits, including 29,360 Python library files
rule CMPC_Python_Fernet_Exec_Stage
{
    meta:
        author = "Cristian Chiriac / CMPC Research"
        description = "exec(Fernet(<literal key>).decrypt(b'<ciphertext>')) in a Python source file"
        status = "experimental"
        date = "2026-11-01"
        scope = "Run on *.py; exclude test, fixture, sample and dataset paths"
    strings:
        $import = "from cryptography.fernet import Fernet" ascii
        $exec = /exec\s*\(\s*Fernet\s*\(\s*b?['\"][A-Za-z0-9_\-=]{40,48}['\"]\s*\)\s*\.decrypt\s*\(\s*b?['\"][A-Za-z0-9_\-=]{200,}/ ascii
    condition:
        filesize < 4MB and $import and $exec
}

Evidence for the whole investigation

#
E-ALL-PROOFProof rows for every proven repository

One row per repository with repository id, owner id, pinned commit and commit permalink; for code-based activities also file path, git object id, size, rule and task-target checks; for Shai-Hulud the description marker. No file contents. Kept private because permalinks name victims' accounts.

Source: GitHub GraphQL and REST, read from an isolated virtual machine · Collected 30 October 2026

E-ALL-SPOTIndependent spot check

555 proven repositories in a stratified random sample with a recorded seed, re-read through the REST contents API at the pinned commit and compared by object id and, for task targets, by size and file type.

Source: GitHub REST · Collected 30 October 2026

Limitations of the whole investigation

#
  • Totals are a floor. Code search covers default branches only. Seven accounts holding 7,500–89,800 repositories each, where every infected repository is a fork, were recorded but not scanned.
  • Proof covers code, not execution. Whether a given loader ran on someone's machine is not known.
  • Accounts are counted by owner id. One person can hold several accounts.
  • Attribution is not mine. Where an activity is linked to North Korea, that link comes from public reporting and is labelled as such.
  • The lure, trading-bot and fake-alert activities are not proven malware. Their chapters say what is established.

Claims, evidence and limits

ClaimAssessmentEvidenceLimit
43,448 repositories proven at a pinned commitConfidence: Confirmed malicious
E-ALL-PROOFNot stated in the finding
An independent re-check agreed on every sampleConfidence: Confirmed malicious
E-ALL-SPOTNot stated in the finding

Terms used on this page

pinned commit
A specific commit, identified by its hash, at which a repository was read. Every counted repository was proven at the default-branch commit recorded in the proof pass, so a later change to the repository does not change what was proven.
git object id
The hash Git assigns to a file's exact contents. Recording it identifies the precise file that matched, independently of its path or of later edits.
default branch
The branch a repository shows by default. Counts are taken on default branches only; infected non-default branches were recorded separately and are not counted.
proof pass
The 30–31 October 2026 re-read of every candidate at a pinned commit under fixed inclusion rules, followed by an independent re-read of a random sample of 555 repositories through a different API.
defanged
Written so it cannot be clicked or resolved as written, for example example[.]com or hxxps://. Every network indicator on this site is defanged.

All terms: glossary.

Corrections & discussion

Found an error, or have evidence that supports or contradicts a finding? Send a correction. Name the section and what you observed.

← All research