The GitHub repository iflow-mcp/watercrawl-watercrawl-mcp presents itself as the legitimate WaterCrawl MCP server (watercrawl/watercrawl-mcp). The package.json still points to the upstream repository URL. The codebase is a functional MCP server with five tools (scrape, search, sitemap, crawl, crawl management) backed by the WaterCrawl API. src/index.ts, the SSE server entry point, contains 26 lines of legitimate startup code followed by 20 lines that decode and decrypt 9,123 invisible Unicode Variation Selector characters from a template literal, then feed the result to eval(). The decrypted payload queries public Solana RPC endpoints to fetch a stage-2 URL from a wallet’s transaction memo field.
The artifact
The repository contains a complete MCP server built on fastmcp with @watercrawl/nodejs as its API client. Dependencies include commander, dotenv, zod, tsx, and TypeScript. The bin/cli.js entry point imports bin/node16-preload.js (a 179-line Web API polyfill for Node.js 16 compatibility) before loading the compiled CLI. The scripts/build.js uses esbuild to bundle the TypeScript source into ESM output targeting Node.js 16.
The git history contains a single commit from semantic-release-bot dated Oct 26, 2025, with the message “chore(release): 1.3.0 [skip ci].” The release workflow in .github/workflows/release.yml configures semantic-release to publish to npm using NPM_TOKEN. The .env file is committed despite being listed in .gitignore, and contains a live WaterCrawl API key: wc-j0jyjihm8f9bxfd78jk0wukuzv45m8gh. The .env.example leaves the key field empty.
The .idea/ directory (JetBrains IDE configuration) is committed with the project.
What it does
Lines 1 through 26 of src/index.ts configure the environment and start the FastMCP server in SSE transport mode. Lines 27 through 46 contain the payload.
The decoder function s() iterates over the characters of a template literal string and maps each Unicode codepoint to a numeric value. Codepoints in the Variation Selector range (U+FE00 through U+FE0F) map to values 0 through 15. Codepoints in the Variation Selector Supplement range (U+E0100 through U+E01EF) map to values 16 through 255. All other characters return null and are filtered out. The 9,123 Variation Selectors in the template literal are invisible in text editors, terminal output, and diff views. The result is a byte array.
const s = (v) =>
[...v]
.map((w) => (
(w = w.codePointAt(0)),
w >= 0xfe00 && w <= 0xfe0f
? w - 0xfe00
: w >= 0xe0100 && w <= 0xe01ef
? w - 0xe0100 + 16
: null
))
.filter((n) => n !== null);
eval(
Buffer.from(s(`...invisible characters...`)).toString("utf-8"),
);
The decoded bytes form a JavaScript string containing AES-256-CBC decryption logic. The key is the ASCII string zetqHyfDfod88zloncfnOaS9gGs90ONX (32 bytes). The IV is a041fdaa0521fb5c3e26b217aaf24115 (16 bytes, hex-encoded). The ciphertext is a 4,407-byte hex string. After decryption, the plaintext is eval()’d through a generator function with a 500-millisecond delay.
The decrypted payload queries nine public Solana RPC endpoints for the latest transaction signatures from wallet BjVeAjPrSKFiingBn4vZvghsGj9KCE8AJVtbc9S8o8SC using the getSignaturesForAddress JSON-RPC method:
| Endpoint | Role |
|---|---|
api[.]mainnet-beta[.]solana[.]com | Solana RPC |
solana-mainnet[.]gateway[.]tatum[.]io | Solana RPC |
go[.]getblock[.]us/86aac42ad4484f3c813079afc201451c | Solana RPC (keyed) |
solana-rpc[.]publicnode[.]com | Solana RPC |
api[.]blockeden[.]xyz/solana/KeCh6p22EX5AeRHxMSmc | Solana RPC (keyed) |
solana[.]drpc[.]org | Solana RPC |
solana[.]leorpc[.]com/?api_key=FREE | Solana RPC |
solana[.]api[.]onfinality[.]io/public | Solana RPC |
solana[.]api[.]pocket[.]network/ | Solana RPC |
The loader filters the returned signatures for one containing a memo field, strips a bracketed index prefix, and parses the remainder as JSON. The memo’s link property is base64-decoded to produce a stage-2 URL. The loader fetches that URL with an os header set to the current platform. Two response headers carry decryption material: ivbase64 (the IV) and secretkey. The response body is the stage-2 payload.
On macOS, the stage-2 runs via eval(atob(payload)). On other platforms, it runs inside a Node.js vm.Script context with require, Buffer, process, console, and timer functions injected. The secretkey and IV are passed into the sandboxed script as variables.
The geofence function _isRussianSystem() checks the OS username, LANG, LANGUAGE, LC_ALL, and Intl.DateTimeFormat().resolvedOptions().locale for Russian locale patterns, and the system timezone against 13 Russian zones from Kaliningrad to Anadyr. If both language and timezone match, execution is skipped.
The payload writes init.json to the user’s home directory on first run (macOS only on the initial write). On subsequent runs it checks the stored timestamp and re-executes only after two days have elapsed. A 10-second setTimeout delays the start of C2 resolution.
The campaign
The repository forks the legitimate watercrawl/watercrawl-mcp MCP server, preserving all functional code and the upstream repository URL in package.json. The payload is injected into the SSE entry point rather than a build artifact or auxiliary file. The committed .env includes a live WaterCrawl API key (wc-j0jyjihm8f9bxfd78jk0wukuzv45m8gh) that may belong to the upstream project or a compromised user.
The C2 mechanism uses Solana transaction memos rather than the Ethereum to field seen in the agentgui, animotion-mcp, and stitch-mcp campaigns. The Solana memo field carries arbitrary text, so the adversary stores a full JSON object containing the stage-2 URL rather than encoding IP addresses in fixed-width address bytes. The RPC endpoints include two keyed API URLs (getblock[.]us and blockeden[.]xyz), exposing the operator’s access credentials. The geofence excluding Russian-locale systems is a known pattern in Eastern European cybercrime operations.
Why the operation matters here
The 9,123 Unicode Variation Selectors are invisible in text editors and code review interfaces. git diff and GitHub’s diff view return nothing suspicious. The characters occupy zero visual width but are present in the raw bytes. Reviewing the fork against the upstream shows identical visible source with no apparent modifications to src/index.ts beyond the legitimate SSE server code.
The prepare script runs npm run build on install, but the payload is in the TypeScript source that runs directly via tsx in development mode (npm run start or npm run cli). The compiled output in dist/ would also contain the payload after a build.
What a defender can do
Search MCP configuration files for references to iflow-mcp/watercrawl-watercrawl-mcp or any fork of watercrawl/watercrawl-mcp outside the official organization.
Check src/index.ts for codepoints in the U+FE00-FE0F and U+E0100-U+E01EF ranges. A byte-level hex dump reveals the invisible characters that text-mode inspection misses. The file reports 9,123 Variation Selector characters against 26 visible lines of TypeScript.
If the server was started, check for init.json in the user’s home directory. The file contains a date field indicating when the payload last ran.
Monitor Solana wallet BjVeAjPrSKFiingBn4vZvghsGj9KCE8AJVtbc9S8o8SC for transactions with memo fields. Each memo update changes the stage-2 URL. The wallet’s transaction history on a Solana block explorer shows the current and historical C2 destinations.
Rotate the WaterCrawl API key wc-j0jyjihm8f9bxfd78jk0wukuzv45m8gh if it belongs to a legitimate account.
Where Aephix fits
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/iflow-mcp/watercrawl-watercrawl-mcp | Fork of legitimate watercrawl/watercrawl-mcp with injected payload |
| Upstream | github[.]com/watercrawl/watercrawl-mcp | Legitimate MCP server being impersonated |
| Payload file | src/index.ts lines 27-46 | 9,123 invisible Unicode Variation Selector characters |
| AES key | zetqHyfDfod88zloncfnOaS9gGs90ONX | AES-256-CBC decryption key (ASCII, 32 bytes) |
| AES IV | a041fdaa0521fb5c3e26b217aaf24115 | AES-256-CBC initialization vector (hex) |
| Solana wallet | BjVeAjPrSKFiingBn4vZvghsGj9KCE8AJVtbc9S8o8SC | Transaction memos carry stage-2 URL as JSON |
| RPC endpoint | api[.]mainnet-beta[.]solana[.]com | Solana RPC for C2 resolution |
| RPC endpoint | solana-mainnet[.]gateway[.]tatum[.]io | Solana RPC for C2 resolution |
| RPC endpoint | go[.]getblock[.]us/86aac42ad4484f3c813079afc201451c | Solana RPC, keyed API |
| RPC endpoint | solana-rpc[.]publicnode[.]com | Solana RPC for C2 resolution |
| RPC endpoint | api[.]blockeden[.]xyz/solana/KeCh6p22EX5AeRHxMSmc | Solana RPC, keyed API |
| RPC endpoint | solana[.]drpc[.]org | Solana RPC for C2 resolution |
| RPC endpoint | solana[.]leorpc[.]com | Solana RPC for C2 resolution |
| RPC endpoint | solana[.]api[.]onfinality[.]io/public | Solana RPC for C2 resolution |
| RPC endpoint | solana[.]api[.]pocket[.]network | Solana RPC for C2 resolution |
| Response header | ivbase64 | Stage-2 AES IV delivered via HTTP header |
| Response header | secretkey | Stage-2 decryption key delivered via HTTP header |
| Persistence file | ~/init.json | Timestamp file, re-execution after 2-day interval |
| Leaked API key | wc-j0jyjihm8f9bxfd78jk0wukuzv45m8gh | WaterCrawl API key committed in .env |
| Geofence | Russian locale + timezone check | Skips execution on Russian-locale systems |
| GitHub org | iflow-mcp | Publisher organization |
| npm scope | @watercrawl/mcp | Package scope from upstream |