The GitHub repository SajidAlee1/expo-mcp presents itself as the legitimate Expo MCP server (expo/expo-mcp) built by 650 Industries. Both package.json files still list expo/expo-mcp.git as the repository URL, and the MIT license carries Expo’s copyright. The codebase is a functional monorepo: packages/expo-mcp provides MCP tools for Expo project management, EAS builds, OTA updates, and iOS device automation. packages/mcp-tunnel implements stdio and WebSocket tunnel proxy transports. All TypeScript source across both packages is clean. eslint.config.mjs, the shared ESLint configuration at the repository root, contains a 23-line ESLint flat config followed by 507 whitespace characters and an 8,626-byte minified JavaScript loader on the same line. The loader queries Ethereum RPC endpoints to resolve C2 addresses from a hardcoded wallet, downloads XOR-encrypted payloads, and executes them in-process and as a detached background process. Campaign identifier A9-2353-1 and all operational infrastructure match the agentgui, animotion-mcp, and stitch-mcp campaigns.
The artifact
The monorepo uses Bun as its runtime. packages/expo-mcp (v0.2.3) depends on @expo/mcp-tunnel, debug, glob, jimp-compact, xml2js, zx, and zod. Seven tool modules register MCP tools for Expo project management (init, install, prebuild, config inspection), EAS cloud builds, OTA updates, app submission, diagnostics, and documentation search. An automation module in packages/expo-mcp/src/automation/ implements iOS simulator and Android device interaction via XCTest and ADB, with compiled iOS test runner binaries shipped in assets/ios-automation-driver/. The npm files array includes only assets/, bin/, and dist/.
packages/mcp-tunnel (v0.2.3) exports StdioMcpServerProxy, TunnelMcpServerProxy, CompositeMcpServerProxy, and ReverseTunnelClientTransport. The tunnel transport connects to a remote WebSocket endpoint, sends a handshake with the project root and dev server URL, and forwards JSON-RPC messages between the client and the remote tunnel. The CompatStdioServerTransport provides Content-Length framed stdio for backward compatibility.
The git history contains a single commit by Sajid_Ali <sajidalee2006[@]gmail[.]com> on Feb 19, 2026: “Merge pull request #2 from SajidAlee1/feat-error-handling-cli-output.” The .claude/settings.local.json file is committed with permissions referencing a Windows path (c/Users/pumsh/.bun/bin/bun.exe) and an MCP server named zai-mcp-server. The README’s MCP setup example uses a Windows D:/ path.
What it does
Lines 1 through 23 of eslint.config.mjs define a standard ESLint flat config: it imports createRequire, loads eslint-config-universe/flat/node, and exports a config array with sort-imports and import/extensions rules. Line 24 closes the array with ]);, followed by 507 space characters, then the payload.
The payload sets a campaign identifier and exposes require and module on the global object:
global.i = "A9-2353-1";
global.r = require;
typeof module === "object" && (global.m = module);
Imports use Unicode escape sequences to evade string-based grep: require("\u0068\u0074\u0074\u0070") resolves to require("http"). The same technique encodes https, zlib, url, and child_process.
Four hardcoded Ethereum RPC endpoints plus an environment variable serve as the lookup infrastructure:
| Endpoint | Role |
|---|---|
process.env.ETH_RPC_URL | Developer’s own RPC (if set) |
1rpc[.]io/eth | Ethereum RPC |
eth[.]drpc[.]org | Ethereum RPC |
ethereum-rpc[.]publicnode[.]com | Ethereum RPC |
eth-mainnet[.]public[.]blastapi[.]io | Ethereum RPC |
eth[.]blockscout[.]com/api | Indexer API fallback |
The wallet address 0xa322e5f3d311d3080e6f0121063e9adc2490ef1a is stored as a Unicode-escaped string. The loader races requests across all endpoints using Promise.any. Block resolution starts by rounding the current block number to the nearest 1,000-block boundary, then checking six adjacent blocks for a transaction from the wallet. If that fails, a binary search narrows the block range using eth_getTransactionCount to find the wallet’s latest nonce. A final fallback queries the Blockscout txlist API. The to field of the matching transaction encodes two IPv4 addresses: bytes 0 through 3 become IP1, bytes 4 through 7 become IP2.
Two payloads download from the resolved IP over HTTP on port 443 (bypassing TLS):
| Path | XOR key | Execution |
|---|---|---|
/0x/cls | q4FZkxX{!h,Sr3=@ | eval() in current process |
/0x/ls | y-p_>d$0B&@^1aQk | Detached spawn("node", ["-e", ...]) |
The /0x/cls path runs via eval() in-process. The /0x/ls path spawns a detached Node.js child process with windowsHide: true that survives parent exit and does not appear in the parent’s stdio. Both receive require and module through global assignments.
If the HTTP GET response body is empty, the loader reads the payload from the x-payload-b64 response header, base64-decodes it, and applies the same XOR. The User-Agent is set to Chrome 131 on Windows 10. A Sec-V header carries the campaign identifier.
The campaign
Campaign A9-2353-1 shares every operational component with the agentgui (A9-2057), animotion-mcp (A9-4051-1), and stitch-mcp (A11—*) campaigns documented in earlier analyses. The Ethereum wallet 0xa322e5f3d311d3080e6f0121063e9adc2490ef1a, the C2 paths /0x/cls and /0x/ls, both XOR keys, the Sec-V header, the x-payload-b64 fallback mechanism, and the dual execution pattern (eval + detached spawn) are identical. The 507-character whitespace padding between the legitimate code and the payload matches animotion-mcp’s concealment on mcp-events.js line 393 exactly.
The payload hides in a different file type with each specimen: database.js in agentgui, mcp-events.js in animotion-mcp, postcss.config.mjs in stitch-mcp, eslint.config.mjs here. The ESLint config executes when a developer or CI pipeline runs the linter. The review workflow (.github/workflows/review.yml) runs bun run lint on every push to main and on pull requests, so CI would execute the payload. The release workflow does not run lint, but does run bun install and bun run build, neither of which loads the ESLint config.
The A9-2353-1 identifier falls between agentgui’s A9-2057 and animotion-mcp’s A9-4051-1 in the campaign ID sequence. One new capability appears: the RPC endpoint list checks process.env.ETH_RPC_URL before the four hardcoded endpoints, allowing the payload to use a developer’s own Ethereum RPC endpoint if one is configured.
Why the operation matters here
The ESLint configuration file is a standard part of any JavaScript monorepo. It does not appear in the files array published to npm, so a package consumer who installs expo-mcp from the registry receives none of the payload. The target is a developer who clones the repository to contribute, a CI pipeline that runs the linter, or an IDE with ESLint integration that loads the config on project open. The 507 whitespace characters push the payload off-screen in every standard editor, terminal, and diff view. A grep for eval or spawn on the codebase returns a hit, but the line’s visible portion shows only ]);.
What a defender can do
Search for references to SajidAlee1/expo-mcp in MCP configuration files and IDE settings. The legitimate upstream is expo/expo-mcp.
If the repository was cloned and bun run lint or any ESLint invocation ran, the payload executed. Check process trees for detached Node.js children that survive parent exit. The campaign identifier A9-2353-1 and the Sec-V header appear in outbound HTTP traffic to the resolved C2 addresses.
Monitor Ethereum wallet 0xa322e5f3d311d3080e6f0121063e9adc2490ef1a for new transactions. Each transaction update rotates the C2 IP addresses. The Blockscout explorer shows the wallet’s full transaction history and the IP addresses encoded in each to field.
Where Aephix fits
Four specimens from the same operation now target different config file types, rotating the hiding surface with each fork. Before you install a package or connect to a server, Aephix Vantage gives you a free, cross-ecosystem view of what is already known to be malicious, so a component with a hostile history is something you recognize before you connect. Every week, Weekly Sleuth links the malicious packages, models, skills, MCP servers, extensions, and containers confirmed that week to the wider operations behind them, with a confidence level and supporting evidence, so subscribers act against the whole operation rather than the single artifact.
Indicators of compromise
| Type | Indicator | Context |
|---|---|---|
| Repository | github[.]com/SajidAlee1/expo-mcp | Fork of legitimate expo/expo-mcp |
| Upstream | github[.]com/expo/expo-mcp | Legitimate Expo MCP server by 650 Industries |
| Payload file | eslint.config.mjs line 24 | 8,626-byte payload after 507 whitespace chars |
| Campaign ID | A9-2353-1 | Same A9- series as agentgui and animotion-mcp |
| Ethereum wallet | 0xa322e5f3d311d3080e6f0121063e9adc2490ef1a | Shared with agentgui, animotion-mcp, stitch-mcp |
| C2 path | /0x/cls | XOR-encrypted in-process eval payload |
| C2 path | /0x/ls | XOR-encrypted detached spawn payload |
| XOR key | q4FZkxX{!h,Sr3=@ | Decryption key for /0x/cls |
| XOR key | y-p_>d$0B&@^1aQk | Decryption key for /0x/ls |
| RPC endpoint | 1rpc[.]io/eth | Ethereum RPC for C2 resolution |
| RPC endpoint | eth[.]drpc[.]org | Ethereum RPC for C2 resolution |
| RPC endpoint | ethereum-rpc[.]publicnode[.]com | Ethereum RPC for C2 resolution |
| RPC endpoint | eth-mainnet[.]public[.]blastapi[.]io | Ethereum RPC for C2 resolution |
| Indexer API | eth[.]blockscout[.]com/api | Blockscout fallback for wallet tx lookup |
| Response header | x-payload-b64 | Fallback XOR payload delivery via base64 |
| Request header | Sec-V | Campaign version identifier |
| Publisher | SajidAlee1 | GitHub account |
| Publisher email | sajidalee2006[@]gmail[.]com | Git commit author |
| Local path | c/Users/pumsh/ | Windows username from .claude/settings.local.json |