Security Research

CVE-2026-42290 — OS Command Injection in protobufjs-cli pbts via Unsafe child_process.exec()

High-severity OS command injection in protobufjs-cli's pbts tool. User-supplied file paths are string-interpolated into a shell command and executed via child_process.exec(), enabling arbitrary command execution via crafted filenames.

Overview

While auditing the protobufjs-cli package I identified an OS command injection vulnerability in the pbts tool — the TypeScript declaration generator that ships alongside pbjs. The root cause is a straightforward but critical mistake: user-supplied file paths are string-interpolated directly into a shell command and executed via child_process.exec(). Because exec() hands the entire string to /bin/sh -c, any shell metacharacters embedded in a filename are evaluated by the shell rather than treated as literal path characters.

This means an attacker who can place a file with a crafted name in a directory that pbts processes — through a VCS commit, a pull request, a shared drive, or a malicious npm dependency — can achieve arbitrary command execution with the privileges of the running build process. In CI/CD environments, that typically means access to deployment keys, signing certificates, cloud credentials, and the ability to backdoor build artifacts.

Background — Why pbts Spawns a Shell

protobufjs-cli ships two tools: pbjs compiles .proto schema files into JavaScript modules; pbts generates TypeScript .d.ts declarations from their JSDoc annotations.

npx pbjs -t static-module -w commonjs -o compiled.js service.proto
npx pbts -o compiled.d.ts compiled.js

This pattern is used extensively in TypeScript + Protocol Buffers projects. pbts runs in developer terminals, CI pipelines, npm lifecycle hooks, and monorepo tooling — environments where it may process filenames it does not fully control.

The reason pbts spawned a shell is that JSDoc has no stable programmatic Node.js API, so the original implementation invoked the JSDoc CLI as a subprocess, building the invocation as a single command string. Every exec(someString) call where someString incorporates external data is a candidate for command injection.

Root Cause — Vulnerable Code

The vulnerable logic lives in callJsdoc() inside cli/pbts.js:

// ❌ VULNERABLE — cli/pbts.js (protobufjs-cli ≤ 1.2.0 / ≤ 2.0.1)

var cmd = "\"" + nodePath + "\" \""
    + require.resolve("jsdoc/jsdoc.js") + "\""
    + " -c \"" + path.join(basedir, "lib", "tsd-jsdoc.json") + "\""
    + " -q \"module=" + encodeURIComponent(moduleName)
    + "&comments=" + Boolean(argv.comments) + "\" "
    + files.map(function(file) {
        return "\"" + file + "\"";  // ← wrapped in double-quotes — NOT safe
    }).join(" ");

var child = child_process.exec(cmd, { /* ... */ });

The injection point is files.map(function(file) { return "\"" + file + "\""; }). Each filename from the user-supplied files array is wrapped in double-quotes and concatenated into a shell command string, then passed to /bin/sh -c via exec().

Why Double-Quoting Does Not Help

The code wraps each filename in double-quotes in an attempt to make it shell-safe — this is a common misconception. In POSIX, several characters remain active inside double-quoted strings. A " character in a filename closes the surrounding double-quote, allowing everything after it to be interpreted as raw shell syntax.

Shell metacharacters that enable injection: ;, &&, ||, |, $(...), backtick, >, <, &, \n, ", #.

Exploitation

Technique 1 — Double-quote Breakout

A filename structured as:

legitimate-name"; <command> #.js

When the vulnerable code processes it, the shell receives:

... "legitimate-name"; <command> #.js"

The shell runs the jsdoc invocation, then executes <command> as a separate command. #.js" becomes a comment and is ignored.

Technique 2 — Command Substitution Inside Double-Quotes

Because $(...) remains active inside double-quoted strings:

legitimate$(id > /tmp/pwned).js

No need to break out of the quoting context.

Why Automated Tooling Is the Most Dangerous Trigger

Build systems routinely invoke pbts with glob patterns like pbts *.js or pbts src/**/*.js. Shell glob expansion happens before pbts receives arguments. Any automated process that runs pbts over a directory it does not fully control is exploitable without user interaction.

Attack Scenarios

Developer workstation — malicious file in shared repo: Attacker commits a file whose name contains shell metacharacters. Developer pulls and runs npx pbts -o out.d.ts *.js. Injected payload executes with developer’s full user privileges.

CI/CD pipeline poisoning: Attacker opens a PR adding a file with a shell payload in its name. CI runner checks out the branch and runs the build pipeline. Injected commands execute with CI runner’s access to deployment keys, npm tokens, cloud credentials.

Malicious npm dependency: Compromised npm package publishes a file with a crafted name. When a consuming project installs the package and its build script runs pbts over node_modules, the malicious filename triggers injection.

The Fix — PR #2160

The fix replaces child_process.exec() with child_process.spawn() using an explicit argument array:

-var cmd = "\"" + nodePath + "\" \"" + require.resolve("jsdoc/jsdoc.js")
-        + "\" -c \"" + path.join(basedir, "lib", "tsd-jsdoc.json")
-        + "\" -q \"module=" + encodeURIComponent(moduleName)
-        + "&comments=" + Boolean(argv.comments) + "\" "
-        + files.map(function(file) { return "\"" + file + "\""; }).join(" ");
-var child = child_process.exec(cmd, { /* ... */ });
+var jsdocArgs = [
+    require.resolve("jsdoc/jsdoc.js"),
+    "-c",
+    path.join(basedir, "lib", "tsd-jsdoc.json"),
+    "-q",
+    "module=" + encodeURIComponent(moduleName) + "&comments=" + Boolean(argv.comments)
+].concat(files);
+var child = child_process.spawn(nodePath, jsdocArgs, { /* ... */ });

child_process.spawn(executable, argsArray) calls execve() directly — no shell parser, no metacharacter interpretation. A filename containing ; rm -rf / arrives at jsdoc as the literal string "; rm -rf /".

The Secure Coding Pattern

// ❌ Dangerous — any metacharacter in userInput causes injection
const { exec } = require("child_process");
exec(`tool --flag "${userInput}"`);

// ✅ Safe — no shell involved; each argument is an opaque string
const { spawn } = require("child_process");
spawn("tool", ["--flag", userInput]);

Use spawn when you need streaming output; use execFile when you prefer a buffered callback. The only safe use of exec() is with a fully static, hardcoded command string where no part is derived from external input.

Remediation

npm install protobufjs-cli@latest

# Or pin to specific safe versions
npm install protobufjs-cli@^1.2.1  # for v7.x projects
npm install protobufjs-cli@^2.0.2  # for v8.x projects

npm audit

References


Reporter: Stalin S (0x5t4l1n)  ·  Remediation: dcodeIO (protobuf.js maintainer)

“Curiosity drives research. Responsibility protects everyone.”