Skip to content
Go back

Anatomy of a Malicious GitHub Invite — The Brisova Build-Time Payload

Edit page

Why this exists

I received a GitHub collaboration invitation to zignaly-open-projects/Brisova (a private repo). Before pulling anything I reviewed it. It is not a legitimate real-estate app — it carries an obfuscated payload that runs in Node during the Tailwind/PostCSS build, immediately pulls a staged install, and loads a C2 + HTTP exfil toolset. This note records the whole investigation.

Blueprint-style infographic of the Brisova build-time payload: the plausible repo facade, the 4.5 MB obfuscated Tailwind plugin, the build-time execution trace, the capability assessment, and the zero-trust handling rules

Repo metadata (via gh api)

Layout summary

A full-stack scaffold: React 18 CRA frontend (src/), Express API (backend/src/, JSON store), Hardhat Solidity contracts (contracts/), plus a src/theme/ directory of Tailwind plugins. It looks plausible at a glance — that is the point.

Red flags

1. The payload file

src/theme/js/context/brisova-context.js is 14 lines / 4,507,936 bytes — one single line is 4.5 MB (line 8). It is javascript-obfuscator-style code (inferred from the rotated string array and offset-decoder pattern; tooling not confirmed): ~293,000 \xNN hex escapes plus a decode/offset function table. The payload logic appears small and the bulk looks like anti-analysis padding (inference from structure).

It is wired in as a Tailwind plugin in tailwind.config.js (line 46): require("./src/theme/js/context/brisova-context.js"). Tailwind/PostCSS loads that config in Node, so the code executes on npm run dev and npm run build (not in the browser sandbox).

The file even opens with a comment claiming it “runs in Node when CSS is built” — the malware is smuggled into the build step, not the app.

Bad effect: the file is not inert, and it executes far more widely than the app. Because it is a required build dependency, it runs in Node wherever tailwind.config.js is loaded — npm run dev/build, IDE Tailwind plugins, PostCSS/bundler tooling, and CI — as the developer, not in a browser sandbox. And the single 4.5 MB line with ~293,000 escapes is hostile to tooling itself: editors, linters, git diff, and grep hang or crash on it, and naive scans return nothing. Because it ships with every clone, any machine that builds the repo inherits both.

2. Suspicious token counts in the payload line

execSync x4, exec x4, spawn x2, require x18, and no literal child_process string — the module name is assembled from obfuscated fragments, so a naive grep child_process misses it. All of this sits inside a file that pretends to be a CSS plugin.

Bad effect: the danger here is a false assurance. The counts show real command-execution capability (execSync x4, exec x4, spawn x2 = shell execution) and runtime module loading (eighteen requires), yet the most dangerous primitive — child_process — is reassembled from obfuscated fragments precisely so that a casual grep child_process, or any scanner leaning on it, finds nothing. Buried in one 4.5 MB line dressed as a CSS plugin, the tokens are invisible to diffs, line-based tooling, and human review — so a reviewer concludes “clean” on a file that is not.

3. Install hook

package.json:36 has "postinstall": "npm run db:seed -w brisova-api". This runs on npm install. The seed script itself (backend/src/db/seed.js) looks like ordinary demo data (Unsplash image galleries, listings), so the install hook alone is not the payload — but it is an extra auto-execution surface.

Bad effect: npm install is the first thing anyone does, and npm runs postinstall automatically — so the repo executes code as you before you have reviewed anything, and without ever loading tailwind.config.js. The hook is dressed as a benign “seed the demo database” step, which lends the repo legitimacy and normalizes auto-execution, while the script’s real content was never fully audited (only 60 of 676 lines of seed.js were read). It is also a second, independent trigger: disabling the Tailwind plugin stops the build-time payload but not the postinstall hook, which still fires on npm install — the one thing npm install --ignore-scripts prevents.

4. Fake / generated commit history

Commit subjects do not match the files they touch, and in the sampled commits the author timestamps are pinned to exact 04:00:00Z / 17:00:00Z. Examples:

Authors seen: flaukowski <nikolaiflaukowski@gmail.com>, sihong <sihonghuey@163.com>, dougiebuckets <douglas.crescenzi@gmail.com>. This mismatch pattern is consistent with an automated/genned repo.

Bad effect: the history is engineered, and its purpose is trust. Mismatched subjects (e.g., “Add unit tests for user service in tailwind.config.js”) file the 4.5 MB plugin under an innocuous label, so git log and git blame never surface “added an obfuscated dropper.” Plausible-looking contributor names and exact round timestamps make the repo read as a normal, actively developed project — defeating the common “just check the commit history” trust step and spreading attribution so the malicious commit cannot be pinned to a real person. Skim the history and it looks legit: that is the deception layer the whole lure rests on.

Dynamic analysis (sandboxed, stubbed)

I cloned the repo to /tmp/opencode/Brisova and loaded the plugin under a Node harness that replaced child_process, net, tls, http, https, dns, and fetch with logging stubs, with NODE_ENV=development. The payload did execute in-process; its side-effecting primitives were replaced, so no real command, file write, or network call was possible (see the Execution ledger below).

Bad effect (methodology alert): in-process stubbing is not a sandbox. Replacing the side-effecting primitives stopped those calls, but process.env was not proxied and only a subset of fs was wrapped, so across all three runs the payload had read access to the shell environment and partial access to the host filesystem. “No real command, file write, or network call was possible” therefore holds only for the primitives that were stubbed; one missed API (another fs method, worker_threads, vm, or a native addon) would have been enough to read or write. Treat this as risk acceptance, not proof of safety — never run untrusted code in-process on a machine holding real secrets.

Captured, on plugin execution:

require(os) intercepted
require(fs) intercepted
require(child_process) intercepted
os.platform -> linux
execSync: npm install sql.js socket.io-client form-data axios --no-save --no-warnings --no-progress --loglevel silent
console.log("Brisova Context")
addBase({ "*, ::before, ::after": { boxSizing: "border-box" } })

Tracing module loads showed the payload then tries:

REQUIRE: path
REQUIRE: socket.io-client
REQUIRE: axios

Re-running with os.platform() forced to win32 produced the same behavior (branching exists but stage 1 is platform-independent).

Interpretation

The roles below are inferred from the import set, not directly observed in this run.

Bad effect: taken together, these dependencies describe how the harm would persist, not a one-off. --no-save (plus the silent flags) installs the stage into node_modules while hiding it from package.json, diffs, and build logs; sql.js stages collected data locally, so stolen data can survive on disk; socket.io-client opens a persistent two-way channel — remote control, not just theft; and axios + form-data exfiltrate over ordinary HTTPS that blends into normal traffic. Because the purpose is inferred, do not down-scope: a miner, backdoor, or ransomware dropper is not excluded.

Execution ledger

Everything actually executed during this investigation, for audit.

Malicious payload executions — 3 (all under JS interception)

The payload ran in-process three times; the bubblewrap re-run — our bwrap sandbox on the analyst’s machine, not anything from the repo — never loaded it.

#CommandPlatform branchResult
1node --no-warnings harness.jsreal linuxplugin loaded + invoked
2node --no-warnings harness2.js linux 6.0.0forced linuxplugin loaded + invoked
3node --no-warnings harness2.js win32 10.0.0forced win32plugin loaded + invoked

In those runs the payload: required os, fs, child_process (intercepted); called os.platform(); attempted execSync("npm install sql.js socket.io-client form-data axios --no-save --no-warnings --no-progress --loglevel silent") (caught by the stub — no shell, no npm); logged Brisova Context; called addBase(...); then required path, socket.io-client, axios (the latter two MODULE_NOT_FOUND, nothing installed). No payload file read or network call was observed.

Bad effect: “all under JS interception” is the weakness, not a reassurance. Each of the three runs was a real in-process execution with the residual env/fs read access noted above, so this is three exposure windows rather than three harmless observations; and because our hermetic bwrap sandbox never loaded the payload, none of the three was contained by a real sandbox — the ledger proves the payload runs, not that observing it was safe. The two re-runs (forcing linux/win32 on Linux) bought only the platform-branching observation, adding exposure for marginal gain. Read “no payload file read or network call was observed” as “nothing was intercepted,” not “nothing happened.”

Host commands (mine, non-payload)

These are the commands I ran on the host during the investigation — read-only reconnaissance, sandbox probes and self-tests, and local file operations — none of them the payload’s own actions. They are listed for the same audit reason as the payload runs: so the ledger accounts for every command executed.

Network actually performed

This separates the traffic the investigation caused from any the payload caused. The only connections are mine — GitHub for the API and clone, plus the docs pages — while the payload’s egress is none, because every network primitive was stubbed and the exfil modules never loaded.

Caveat: “Payload egress: none” describes this stubbed run, not the payload’s design or behavior in the wild. Nothing was observed only because the network primitives were stubbed and the exfil modules never loaded — the import set (socket.io-client, axios, form-data) is exactly the machinery for sending collected data out. On an unstubbed npm install/build, that egress would not be blocked; “no egress” is a limit of the test, not a property of the malware.

Not executed

Capability assessment (answer to “can it read my stuff?”)

Yes — it has the capability:

Bad effect: capability is the verdict. No read or send was observed, but the file can do all of it — read every secret in the environment and on disk, run arbitrary commands as you, stage what it takes locally (sql.js), and ship it out over a persistent C2/HTTP channel. Any one primitive would be serious; together they amount to a full remote-access-trojan profile, and that is enough on its own: treat any machine that built or ran this repo as compromised.

Evidence & caveats

What this investigation proves, and what it does not.

Proven (directly observed or reproducible):

That is enough to call it a malicious dropper / build-time code execution and to justify a malware report.

Not proven (so far):

Residual exposure (env/fs): in all three payload runs process.env was not proxied and only a subset of fs functions was wrapped, so the payload had read access to the shell environment and partially to the host filesystem. No such read was logged, and because child_process and every network primitive were stubbed there was no channel to send anything out — but this was interception, not a hermetic sandbox. The --clearenv bubblewrap run that would have closed the gap was validated and then aborted before loading the payload. Do not read “no egress” as “provably could not read local data.”

Other caveats: the full logic is hidden inside the 4.5 MB obfuscation, so absence of an observed read is not proof it never reads files. Unverified: npm install is expected to trigger only the postinstall seed and not the payload, and the payload is expected to fire when Tailwind compiles CSS (dev/build) — we never ran npm install, and only the first 60 of 676 lines of seed.js were read. No full deobfuscation was attempted, so the C2 endpoint is unknown.

To reach hard evidence: deobfuscate the string array to recover the C2 URL/host and any explicit target paths (.ssh, .env, wallet paths), then optionally confirm by running stage 2 inside the bubblewrap sandbox with fake modules. Even then, “steals my data” is only proven if a sensitive read is observed paired with an outbound send — which we deliberately never allowed.

Bad effect: this honesty cuts both ways. “Not proven” is not “not malicious” — reading the caveats as reassurance is how someone talks themselves into running the repo; the capability is already established, so the safe default is to treat it as malicious. Symmetrically, do not assert a demonstrated theft you cannot show. And the residual exposure is not only theoretical: because the payload had read access to the analyst’s shell environment and part of the host filesystem during all three runs, the investigation host itself should be treated as potentially exposed — by this article’s own rule, and therefore rotated/audited like any other machine that ran the repo.

Safe-use guidance

Indicators of compromise (IoCs)

Reporting to GitHub

Use GitHub’s web form — not an email address. The documented channel for abuse is their web form; I found no abuse email address. (GitHub’s own security.txt at https://github.com/.well-known/security.txt only points to https://hackerone.com/github, and that is for vulnerabilities in GitHub’s own products/services, not for reporting a malicious third-party repo.)

Category: Malware — file it as malware (GitHub Acceptable Use Policy §5, Active Malware or Exploits). It is not Phishing: phishing is AUP §4 (Spam and Inauthentic Activity) and means deceiving a person into revealing information; this artifact is malicious code, so malware is the correct category. The bogus collaboration invite is a social-engineering lure worth mentioning, but it does not change the category.

The inviting account could not be retrieved — GET /repos/{owner}/{repo}/invitations returns 403 without admin rights — so the inviter is unrecorded here; add it manually from the invitation if available.

Where to file it:

Full URLs to cite in the report:

Attach this article (or paste its body) as the report evidence; mention the stage 1 command and the fake commit history as corroboration.

Cleanup performed

A second, harder-containment attempt was prepared and then aborted before loading the malware: the repo was re-cloned into /tmp/opencode/sbx/work, fake socket.io-client / axios / form-data / sql.js modules were staged to reveal any C2 target, and a bwrap --unshare-all + --clearenv + nobody + Node --permission harness was written. A harmless self-test in that sandbox verified the boundary (uid nobody, 6 env vars, /home/jeff absent, no DNS, ERR_ACCESS_DENIED on /home/jeff). The payload-execution run was not performed; the whole sandbox was then deleted.

All test artifacts were removed:

(harness3.js was drafted but never created — the write was rejected — so it never existed to remove.)

No real npm install ran (the command was only captured by a stubbed execSync); the payload’s JS executed only under stubbed interception; no payload filesystem or network operation was observed, and no egress channel existed at any point.

Bad effect: cleanup erases artifacts, not exposure. Deleting the clones, sandbox, harnesses, and captured snippet removes the malware from disk — but it does not undo the env/fs read access the payload had during the three in-process runs, and it cannot prove nothing was left behind (shell history, ~/.npm/_logs, OS/editor caches, other tools’ output dirs). Because the hermetic bwrap run was aborted before loading the payload, no cleanup can claim the payload was ever proven inert. Treat “cleanup performed” as housekeeping, not an all-clear — the host still needs the rotation/audit in Safe-use guidance.

Handling untrusted code: zero-trust

The reusable procedure distilled from this incident. The complete SOP — the standing contract, the STOP + RAISE HANDS triggers, the trust ladder, detection commands, defang/--ignore-scripts tips, provenance checks, the bubblewrap containment recipe, the alerting skeleton, and the report channel — lives in the untrusted-code-safety skill:

Four rules:

Bad effect: these rules only protect if the operator actually stops. The chain fails at the first “it looks legit” shortcut — skip static triage (Rule 1) and you execute blind; run it outside real containment (Rule 3) and you get exactly the residual exposure this article reports. A checklist that is ignored, or a “sandbox” that is only interception, protects nothing.

Decision flow:

 UNTRUSTED INPUT (repo · package · invite · script · config)
                 │
                 ▼
 ┌───────────────────────────────────────────────┐
 │  RULE 1 — DETECT  (static, no execution)      │
 │  size · obfuscation · hooks · dynamic require │
 │  · commit-history smell · net/wallet/env refs │
 └───────────────┬───────────────────────────────┘
                 │ suspicious
                 ▼
 ┌───────────────────────────────────────────────┐
 │  RULE 2 — BE SENSITIVE (data inventory)       │
 │  env · ~/.ssh · cloud creds · wallets ·       │
 │  browsers · source · tokens                   │
 └───────────────┬───────────────────────────────┘
                 │ anything valuable is reachable
                 ▼
 ┌───────────────────────────────────────────────┐
 │  RULE 3 — BE ALERTED (contain + instrument)   │
 │  no network · cleared env · non-root ·        │
 │  fs allowlist (/tmp) · log+block fs/env/net/  │
 │  child_process · raise hands on violation     │
 └───────────────┬───────────────────────────────┘
                 │ violation (secret read / egress / exec)
                 ▼
 ┌───────────────────────────────────────────────┐
 │  VERDICT: MALICIOUS                           │
 │  quarantine · rotate · report · never run     │
 └───────────────┬───────────────────────────────┘
                 │
                 ▼
 ┌───────────────────────────────────────────────┐
 │  RULE 4 — NEVER TRUST                         │
 │  a pass at level N never grants level N+1;    │
 │  trust is per-artifact, per-hash, re-checked  │
 └───────────────────────────────────────────────┘

Codified policy. This session’s contract was hardened into two git-tracked places: the global opencode/AGENTS.md section “Untrusted code safety” (symlinked to ~/.config/opencode/AGENTS.md, loaded by every session), and the untrusted-code-safety skill above. In brief: /tmp is the only workspace for untrusted code; never read the user’s personal files (code, API keys, credentials, SSH/GPG keys, wallets, browser sessions); never trust an unknown source; STOP and RAISE HANDS — halt and report at once — if executed code reads outside /tmp, touches process.env/keys/credentials, collects or sends data off the machine, spawns child processes or opens unauthorized network connections, or shows any malicious, harmful, deceptive, cheating, or stealing behavior; confirm when unsure; delete all test clones and artifacts when done.

Boundary note: skills load only up to their git worktree root, so the skill auto-loads in ai-thoughts sessions only. The contract itself lives in the global AGENTS.md, which every session loads — that is why both exist.

btw, i use arch


Edit page
Share this post on:

Next Post
Choosing a Model for OpenCode via OpenRouter: Privacy, Price, and Performance