Aephix accepted into the Databricks Startup Program
← Research
Threat report Sep 12, 2026

watercrawl-mcp fork: invisible Unicode characters in TypeScript source hide a Solana C2 loader

A fork of the legitimate watercrawl-mcp MCP server (GitHub org iflow-mcp, npm scope @watercrawl/mcp) carries a steganographic payload in src/index.ts encoded as 9,123 Unicode Variation Selector characters invisible in editors and diffs. A decoder function extracts byte values from the codepoints, assembles an AES-256-CBC encrypted blob, decrypts it, and eval()s the result. The decrypted payload queries nine public Solana RPC endpoints for the latest transaction memo from wallet BjVeAjPrSKFiingBn4vZvghsGj9KCE8AJVtbc9S8o8SC, extracts a stage-2 URL from the memo field, and executes the downloaded code. A geofence skips Russian-locale systems.

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:

EndpointRole
api[.]mainnet-beta[.]solana[.]comSolana RPC
solana-mainnet[.]gateway[.]tatum[.]ioSolana RPC
go[.]getblock[.]us/86aac42ad4484f3c813079afc201451cSolana RPC (keyed)
solana-rpc[.]publicnode[.]comSolana RPC
api[.]blockeden[.]xyz/solana/KeCh6p22EX5AeRHxMSmcSolana RPC (keyed)
solana[.]drpc[.]orgSolana RPC
solana[.]leorpc[.]com/?api_key=FREESolana RPC
solana[.]api[.]onfinality[.]io/publicSolana 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.

src/index.ts lines 27-46 9,123 invisible Variation Selector chars AES-256-CBC eval() decrypted payload Queries Solana RPC Solana wallet BjVeAj...o8SC Memo field contains stage-2 URL Fetches stage-2 from memo URL macOS: eval(atob(payload)) Other: vm.runInContext() Aephix
Invisible Unicode Variation Selectors encode the payload in a template literal. After byte extraction and AES decryption, the loader queries Solana RPC endpoints for a transaction memo carrying the stage-2 URL.

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

TypeIndicatorContext
Repositorygithub[.]com/iflow-mcp/watercrawl-watercrawl-mcpFork of legitimate watercrawl/watercrawl-mcp with injected payload
Upstreamgithub[.]com/watercrawl/watercrawl-mcpLegitimate MCP server being impersonated
Payload filesrc/index.ts lines 27-469,123 invisible Unicode Variation Selector characters
AES keyzetqHyfDfod88zloncfnOaS9gGs90ONXAES-256-CBC decryption key (ASCII, 32 bytes)
AES IVa041fdaa0521fb5c3e26b217aaf24115AES-256-CBC initialization vector (hex)
Solana walletBjVeAjPrSKFiingBn4vZvghsGj9KCE8AJVtbc9S8o8SCTransaction memos carry stage-2 URL as JSON
RPC endpointapi[.]mainnet-beta[.]solana[.]comSolana RPC for C2 resolution
RPC endpointsolana-mainnet[.]gateway[.]tatum[.]ioSolana RPC for C2 resolution
RPC endpointgo[.]getblock[.]us/86aac42ad4484f3c813079afc201451cSolana RPC, keyed API
RPC endpointsolana-rpc[.]publicnode[.]comSolana RPC for C2 resolution
RPC endpointapi[.]blockeden[.]xyz/solana/KeCh6p22EX5AeRHxMSmcSolana RPC, keyed API
RPC endpointsolana[.]drpc[.]orgSolana RPC for C2 resolution
RPC endpointsolana[.]leorpc[.]comSolana RPC for C2 resolution
RPC endpointsolana[.]api[.]onfinality[.]io/publicSolana RPC for C2 resolution
RPC endpointsolana[.]api[.]pocket[.]networkSolana RPC for C2 resolution
Response headerivbase64Stage-2 AES IV delivered via HTTP header
Response headersecretkeyStage-2 decryption key delivered via HTTP header
Persistence file~/init.jsonTimestamp file, re-execution after 2-day interval
Leaked API keywc-j0jyjihm8f9bxfd78jk0wukuzv45m8ghWaterCrawl API key committed in .env
GeofenceRussian locale + timezone checkSkips execution on Russian-locale systems
GitHub orgiflow-mcpPublisher organization
npm scope@watercrawl/mcpPackage scope from upstream