OS command injection via document URL extension reaches pdfinfo/pdftoppm shell exec (unauthenticated RCE)
Summary
zerox accepts a document by URL or path (zerox({ filePath })) and, for PDFs, shells out to poppler (pdfinfo, pdftoppm) with the local file path interpolated inside a double-quoted shell string. The local file name is built from the URL using path.extname, which preserves shell metacharacters, so an attacker who controls the document URL can place a command substitution in the file extension and execute arbitrary OS commands on the host running zerox. The command runs before any LLM call. This was reproduced end to end against the real zerox() entry point: a marker file was created by the injected command.
Details
downloadFile (node-zerox/src/utils/file.ts) derives the temp file name straight from the URL:
const fileNameExt = path.extname(filePath.split("?")[0]); // ~line 39: keeps $(), backticks
const localPath = path.join(tempDir, `${uuidv4()}${fileNameExt}`);For input whose magic bytes are %PDF, checkIsPdfFile is true and pdfPath = localPath (src/index.ts). The aspect-ratio/conversion step then runs the path through a shell:
// src/utils/file.ts ~line 347 (getPdfAspectRatio)
exec(`pdfinfo "${pdfPath}"`)
// ~line 289 (poppler fallback)
pdftoppm ... "${pdfPath}" "${outputPrefix}"Command substitution $(...) and backticks execute inside double quotes, so an extension like .pdf$(touch${IFS}zx_pwn) runs the embedded command. The only constraint is that the injected token cannot contain a literal / (that makes path.extname return empty), which is sidestepped with $IFS, ${HOME}, $(printf ...), or commands that need no path. The original filename is replaced by a UUID, so the extension is the sole injection point, but it is fully attacker-controlled and unsanitized.
The trust boundary is the input document URL, which zerox is designed to accept from users (a document-to-markdown service ingesting user/remote-supplied documents).
PoC
End-to-end on node-zerox 1.1.20 with real poppler installed. Host a valid %PDF-prefixed file at a URL whose basename ends in a command substitution (the basename must contain no literal /, otherwise path.extname returns '' and the payload is dropped, so use $IFS/${HOME}/$(printf ...) instead of spaces and slashes):
// 1. serve a real PDF at this exact URL (basename ends with the SLASH-FREE payload):
// http://attacker/a.pdf$(touch${IFS}zx_pwn)
const { zerox } = require('zerox');
await zerox({ filePath: "http://attacker/a.pdf$(touch${IFS}zx_pwn)", openaiAPIKey: "sk-dummy" });The real chain fires before any LLM/API call:
zerox() -> downloadFile (file.ts:39 path.extname(url) keeps $(...) so the local file is named <uuid>.pdf$(touch${IFS}zx_pwn)) -> getPdfAspectRatio (file.ts:347) -> exec(\pdfinfo "${pdfPath}"`)`. The shell runs the substitution.
Observed: pdfinfo "<uuid>.pdf$(touch${IFS}zx_pwn)" executed and created the marker file /tmp/zx_pwn before any LLM/API call (the dummy-key 401 occurred later). pdftoppm at file.ts:289 is the same exec pattern. Fix: execFile("pdfinfo", [pdfPath]) (no shell) and allowlist the extension.
Impact
Any application that passes a user- or remote-supplied document URL to zerox is vulnerable to arbitrary OS command execution on the host, with no authentication and no LLM round-trip. Given zerox's role as a document-ingestion service, this is unauthenticated remote code execution.
Remediation
Do not interpolate file paths into shell strings. Use execFile/spawn with an argument array (execFile("pdfinfo", [pdfPath])) so the path is never parsed by a shell. Independently, sanitize fileNameExt to a strict allowlist (for example ^\.[A-Za-z0-9]{1,8}$) before using it in any file name, and validate/normalize the derived local path.
Source: getomni-ai/zerox