DirtyBlanket: Nine Perfect NPM Clones and a Worm Built to Poison the AUR
By Juan Aguirre
On September 29, SafetyHax flagged nine npm packages: xeprews, express-javascript, express-nodejs, exprdd, exprrdd, exptrdd, exptred, exptredd, all at version 5.2.1, and react-nodejs at 19.3.0. Same publisher on all nine, an NPM account called dirtyblanket, which is also what we're calling the campaign.
The first thing I did was diff them against the real thing, and the diff is the whole story of the package. Every file is byte-identical to express@5.2.1 or react@19.3.0. Not "similar", not "lightly modified", identical. The only changes are the name in package.json and one added line:
So if you typo your way into exprdd, you get a working Express. require() it and it behaves exactly as documented. You also get whatever that URL serves, piped straight into node, before a single line of Express is ever loaded.
That's the thing about this campaign. There's almost nothing in the packages. Scan the tarball and you find one line and a lot of very well-tested web framework. Everything interesting is one hop away, and once I followed that hop, it turned out to be a lot more interesting than a typosquat has any right to be.
The timeline
All times UTC, all on September 29:
Hold on to the order of 05:22 and 05:26. It comes back, and it fooled me for a while.
The part that made me put the coffee down: poisoning the AUR
Stage two is linux.sh, a bash worm. It steals, it persists, it spreads, and I'll get to all of that. But the spreading routine I keep coming back to isn't the npm one. It's the one aimed at the Arch User Repository.
If you don't live in Arch land: the AUR is a community repository of build recipes. Each package is a git repo with a PKGBUILD (how to build it) and, optionally, a .install file, a set of hooks pacman runs when the package is installed, upgraded or removed. Pacman runs those hooks as root. Maintainers push to the AUR over SSH, with an SSH key registered to their account.
Now here's the routine, summarized:
Read that commit message again. upgpkg: is the prefix a lot of AUR maintainers use for routine version bumps. So a victim who maintains, say, a couple of popular AUR packages gets a new release of each, pushed with their own key, authored under the name of the last person who committed, with a commit message that looks like every other bump in the history. Nothing about it says "stranger".
And the payload isn't where most people look. The change to the PKGBUILD is a pkgrel bump and, if the package didn't have one already, a pointer to an install file. The line that actually matters goes into the .install file. That line fetches the whole worm and runs it, as root, whenever pacman runs the install file on a machine that picks up the update. Which then goes looking for its owner's AUR keys.
It's worth being precise about why this is such a clever target. The AUR has never pretended to be curated. Its trust model is, famously, "read it before you install it". The Arch wiki tells you to. Most AUR helpers offer to show you the diff. And in practice, when people do read, they read the PKGBUILD, because that's the file everyone talks about. A pkgrel bump in the PKGBUILD is about the most boring diff that exists. The interesting part is sitting in the other file.
It's like a code review where the PR description says "bump version", the diff on the first file is one line, and you approve before scrolling down to the second file. We've all done it. This worm is betting that you will.
I want to be really clear about one thing: this is a capability we found in the code. We have no evidence that any AUR package was actually poisoned. The worm is built to do this. Whether it ever did depends on whether any of the people who installed these nine packages in their seven-and-a-half hours online also happened to maintain AUR packages, and that's still an open question. If you maintain AUR packages, the remediation section has a specific check for you.
Delivery: the Font Rendering Service that renders no fonts
Back to the start of the chain. On a Linux victim, linux.sh does everything in the background with all output thrown away, so a developer running npm install sees nothing unusual. What it does next depends on whether it's running as root.
As root, it first installs what it needs: tor, openssh, git, npm and build tools. On Arch it loops on pacman until the install succeeds. On Debian it gets one apt-get attempt and moves on. Then it drops the payload as /usr/lib/systemd/systemd-fontrenderd and creates a system unit for it:
Then it sets the immutable flag, chattr +i, on the binary, the unit file and the multi-user.target.wants symlink that enables it. Immutable files can't be deleted, renamed or edited, even by root, until the flag is cleared. If you've never run into chattr, the first time you try to rm one of these as root and get "Operation not permitted" is a genuinely disorienting experience.
KillMode=none is the other nasty bit. It tells systemd that when the service is stopped, it shouldn't kill the process. So systemctl stop returns cleanly, the unit reports inactive, and the RAT keeps running.
Without root, it builds the same thing in userland. It downloads the Tor Expert Bundle 15.0.23 from dist.torproject.org into ~/.config/systemd/systemd-fontrenderd/, drops the payload as ~/.config/systemd/systemd-fontcached, and creates two user units: "Font Rendering Service", which is actually Tor, and "Font Caching Service", which is actually the RAT. Both use KillMode=none. The immutable flag isn't used in this path, since it needs root.
I have to admit the naming made me laugh. I have never once checked what my font rendering service was up to. Nobody has. That's the point.
The payload: CHAOS, lightly customized
systemd-fontd (the file name in the repo) is a stripped Go 1.27.1 ELF, about 7.6 MB. Its strings include the github.com/tiagorlampert/CHAOS client packages, plus kbinani/screenshot, jezek/xgb and gorilla/websocket. That makes it a build of CHAOS, an open-source remote administration tool: file explorer, downloads, file deletion, screenshots, the usual remote-admin toolkit.
Stock CHAOS keeps its server address and token as plaintext variables in the binary. This build doesn't. The config is a 356-byte Base64 JSON blob at file offset 0x6ee506, with the keys renamed, decoded at runtime by a ReadConfigFile routine. It's not strong obfuscation, but it's enough that a quick strings | grep onion doesn't hand you the answer. Decoded and normalized:
A Tor hidden service. The RAT's HTTP and WebSocket transports honour the standard proxy environment variables, which is exactly why the unit sets HTTP_PROXY=socks5://127.0.0.1:9050: every C2 connection goes out through the local Tor instance. The token travels as a Cookie: jwt= header, which you'll never see on the wire because it's inside Tor.
The Web Archive twist (or: how I read the wrong commit)
This is the bit that fooled me.
When I first opened the Codeberg repo, every URL in linux.sh and node.js pointed at web.archive.org. The npm hook fetches from the Archive, the stager fetches from the Archive, the worm fetches its binary from the Archive. My first read was, honestly, "this is kind of cool": the attacker had found a way to serve every stage from infrastructure nobody is going to block, and a Codeberg takedown wouldn't matter at all because the Archive already had copies. Bulletproof hosting on a public library. Clever.
Then I looked at the timestamps. The repo has exactly two commits. The first, at 05:11, has every URL pointing straight at codeberg.org. The second, at 05:26, is called "use Web Archive" and rewrites them. And the Wayback captures were taken at 05:22, 05:23 and 05:24. Every capture predates the rewrite.
So the archived node.js is the first version, the one that still points at Codeberg. In practice, the chain goes: npm hook, through the Archive, to the old node.js, which pulls linux.sh directly from Codeberg, which fetches the binary and spreads using Codeberg URLs. Only the very first hop goes through the Archive. Everything after it depends on Codeberg staying up.
You know "works on my machine"? This is its cousin, "works on main", and every developer has lived through it. You read the code on the branch, you reason about it, you're confident, and then it turns out production is running last Tuesday's build. I had read HEAD. The victims were running what the Archive had, and the Archive had the first commit.
Which leads to a warning I really want researchers to hear. The URLs in the hook have no timestamp, just /web/<url>, and for URLs like that the Wayback Machine serves the latest capture. Right now, the latest capture is the old version. If anyone presses "Save Page Now" on these URLs, out of curiosity or to preserve evidence, the Archive will capture the current version from Codeberg, the one that routes every hop through web.archive.org, and start serving that instead. You'd be upgrading the worm for them.
Please don't re-archive these URLs.
The other ways out: npm and SSH
The AUR routine is the one I led with because it's the most unusual, but it's one of three.
npm. The worm finds every directory on disk with a package.json, skipping node_modules. For each one, it adds the same node.js stager to the preinstall script, chaining it after any existing script so your own hook still runs. It bumps the patch version and then publishes the package once for every .npmrc it can find (home directories, the current directory, /root, the npm global config, and Windows user profiles mounted under /mnt/c/Users/, so WSL users are in scope). Afterwards it puts the original package.json back.
That last step is the craft detail here. Your working copy looks untouched. git diff shows nothing on package.json. Meanwhile there's a new patch release of your package on npm that you never published, and it has a preinstall hook you never wrote.
It isn't perfect, though, and this is good news for defenders. Bumping the version with npm version also makes a git commit and a tag when the directory is a git repo, and the worm doesn't undo that. So a victim's repos may have a stray version commit and tag they don't remember making. That's your forensic trace.
SSH. The worm merges every known_hosts file it can read (every home directory, /root, /etc/ssh, and again the WSL-mounted Windows profiles), then finds every OpenSSH private key on the machine. Then it tries every key against every host, as root and with each user's SSH config, non-interactively so nothing ever prompts. On any Linux host that accepts, it runs the whole worm again in the background, with the remote run's output going to /tmp/log on that host.
So the blast radius of one infected laptop isn't one laptop. It's every box that laptop has ever SSH'd into with a key that still works.
Six days after MemTensor
If this pattern feels familiar, it should. Six days earlier, on September 23, an attacker used publish tokens stolen from MemTensor's release pipelines to push malicious releases of MemoryOS on PyPI and @memtensor/memos-cloud-openclaw-plugin on NPM.
The core idea is the same in both: take a developer's publishing rights and turn them into the next infection. The details line up in places:
To be explicit: we are not linking these two campaigns. There's no shared infrastructure, no shared code and no shared implant. What they share is an idea, and ideas travel. Two campaigns in one week built around "your publish rights are the payload" says more about where the ecosystem is heading than about who's behind either one.
A rehearsal?
Here's where I have more questions than answers, so read this section with the hedges attached.
Some things about this campaign look rushed, or unfinished:
example.com and example.org placeholders where the real URLs would go./root/*/.ssh/config, a glob that never matches the real /root/.ssh/config.uniq without sorting first, so duplicates survive and get retried.None of that proves intent. Sloppy attackers exist, and so do attackers who ship on a deadline. But it fits with a first run, a test of the plumbing.
And here's the thing that I think matters more than any of those bugs. The attacker doesn't need to compromise an established package. The worm is built to make that jump on its own. The typosquat is patient zero. It only has to land on one developer who maintains something real, an NPM package people depend on, an AUR package people build as root, a server fleet reachable over SSH. That developer's keys and tokens are the actual prize, and the worm is designed to spend them automatically.
MemTensor started with stolen tokens for trusted packages. This one is built to go find its own.
So, a rehearsal, with a high-value target coming soon? I don't know. I'd like to be wrong. But if I maintained anything that other people install, I'd make sure I'm not the developer this thing is waiting for.
Removing the package does not fix this
I say this in nearly every one of these posts. The packages were unpublished at 13:38, and that stops new installs. It does nothing for anyone who installed one before then, because the preinstall hook already ran and the worm doesn't live in node_modules. Deleting the dependency removes a perfectly good copy of Express and leaves everything else in place.
If any of these nine names shows up in a lockfile, an install log or a CI run between 06:05 and 13:38 UTC on September 29, treat the machine as compromised:
KillMode=none, stopping the units won't kill the RAT. Look for running systemd-fontrenderd, systemd-fontcached and a tor process started from /.config/systemd/systemd-fontrenderd/ nd kill them after disabling the units./usr/lib/systemd/systemd-fontrenderd, /etc/systemd/system/systemd-fontrenderd.service and /etc/systemd/system/multi-user.target.wants/systemd-fontrenderd.service are all chattr +i. Run chattr -i on each before trying to delete them, then systemctl daemon-reload. On the user path, remove ~/.config/systemd/user/systemd-fontrenderd.service, ~/.config/systemd/user/systemd-fontcached.service, their enable symlinks, ~/.config/systemd/systemd-fontcached and the ~/.config/systemd/systemd-fontrenderd/ directory. Check both paths on every machine; which one ran depends on whether the install ran as root. On the root path, also note that tor was installed and enabled by the worm, not by you.authorized_keys files on every server..npmrc on the machine, including the ones under /root, the global npm config and any WSL-mounted Windows profiles.upgpkg: commits you didn't make (remember, they'll carry a real maintainer's name), a second pkgrel= line at the bottom of the PKGBUILD, and any .install line that fetches a script from the internet.preinstall hits web.archive.org or codeberg.org.npm version leaves them behind, and the worm doesn't clean them up.known_hosts. Any Linux box the machine could reach with a working key may have been infected the same way. Look for /tmp/log on those hosts, and then run this whole list on each one that has it.IoCs
Packages (npm, publisher dirtyblanket, s7dwzxru4z@ooynib[.]com):
xeprews@5.2.1express-javascript@5.2.1express-nodejs@5.2.1exprdd@5.2.1exprrdd@5.2.1exptrdd@5.2.1exptred@5.2.1exptredd@5.2.1react-nodejs@19.3.0Preinstall hook (identical in all nine):
Network
codeberg[.]org/hellscripter/install-scripts (linux.sh, node.js, systemd-fontd)web[.]archive[.]org/web/hxxps[:]//codeberg[.]org/hellscripter/install-scripts/...s5n2uyo6gb6dhirsm5pihwohi6e7ayrwojx4xjow4cqabmbowpezenid[.]onion:80 (CHAOS C2, via Tor)dist[.]torproject[.]org Tor Expert Bundle 15.0.23, linux-i686 (legitimate, suspicious in context)aur[.]archlinux[.]org over SSH from hosts that don't maintain AUR packagesFiles
/usr/lib/systemd/systemd-fontrenderd (immutable)/etc/systemd/system/systemd-fontrenderd.service (immutable, "Font Rendering Service")/etc/systemd/system/multi-user.target.wants/systemd-fontrenderd.service (immutable)~/.config/systemd/systemd-fontcached~/.config/systemd/systemd-fontrenderd/ (user-mode Tor)~/.config/systemd/user/systemd-fontrenderd.service ("Font Rendering Service")~/.config/systemd/user/systemd-fontcached.service ("Font Caching Service")/tmp/log (on hosts reached over SSH)Hunt for
KillMode=none and HTTP_PROXY=socks5://127.0.0.1:9050upgpkg: <ver>-<rel>, unsigned, with an .install line fetching linux.shpreinstall scripts that pipe web.archive.org or codeberg.org into nodepm versioncmmits and tags you didn't makeHashes (SHA-256)
How Can Safety Help Protect You?
The whole attack hinges on one preinstall line running before anyone looks. The package code is clean, so scanning what lands in node_modules finds a perfectly good Express. By then the hook has already run. The Safety Firewall checks every package installation request before it reaches the public registry, so a known-malicious package like these is blocked before its install hook ever runs, on developer machines and in CI alike. The best time to stop a worm that spreads through your publish rights is before it gets them.
Want to try Safety Firewall? You can sign up and get started yourself, no call required.
Stay curious. And maybe GO check what your font rendering service has been up to.