CVE-2026-55173 · GHSA-wc3f-xc32-435f · WWBN/AVideo · CWE-78 · High (CVSS 8.1)
The lead
Someone else had already found the interesting bug. In March 2026, another researcher reported a command injection in AVideo's standalone encoder (CVE-2026-33482): the sanitizeFFmpegCommand() function let $(...) command substitution through to a shell. AVideo shipped a fix in commit 25c8ab90.
That fix commit is the lead. My lead ranker flagged it with a band-aid shape — "+12 lines, regex tweak" — which is the profile of a patch that treats the symptom, not the class. When a shell-injection fix is a denylist rather than a switch to argument arrays, the only question is: did they list every separator, or just the one in the report?
The bug
Shells have a lot of ways to chain a second command. The full set you have to worry about is roughly:
; | & && || $() ${} \n < > Here's what the patched sanitizeFFmpegCommand() actually removed:
str_replace('&&', '', ...); // only the doubled form
preg_replace('/\s*&?>.*(?:2>&1)?/', '', ...); // & only when before >
preg_replace('/[;|`<>$()\n\r{}]/', '', ...); // char class: no bare & Read the third line carefully. The character class blocks ; | < > $ ( ) { } and newlines. It does not contain a bare &. The && handler only kills the doubled form. The redirect handler only kills & when it's glued to >`.
A lone & — the shell's run this in the background operator — survives all three. And the command still passes the prefix gate (strpos(trim($command), 'ffmpeg') === 0), because it starts with ffmpeg. Then it lands in the unchanged sink:
// objects/functionsExec.php
addcslashes($command, '"'); // escapes only "
$commandWithKeyword = "nohup sh -c \"$command & echo $! > /tmp/kw.pid\" ...";
exec($commandWithKeyword, ...); // sh -c So: ffmpeg -i in.mp4 & <your command> out.mp4 starts ffmpeg in the background and runs your command in the foreground. RCE.
What almost killed it
Two things.
Dup risk. AVideo is a heavily-mined target — 30-plus advisories, and I could see other researchers had already filed "incomplete fix" reports against it. When a target is this picked-over, a new finding is more likely to collide with someone's open report than to land. I flagged it, and only filed after an explicit decision that the specific separator (&) hadn't been claimed. That's a judgment call every hunter has to make on crowded targets, and it's the kind that tanks your signal-to-noise ratio if you get it wrong.
Verify-at-sink, or it doesn't count. "There's no & in the denylist" is a code-reading observation, not a proof. I wrote a byte-faithful PHP harness that replayed sanitizeFFmpegCommand and execAsync's exact string-wrapping from HEAD, then fed it the payload:
input : ffmpeg -i input.mp4 & touch /tmp/avideo_amp_rce_proof & echo done out.mp4
ampersand survived: YES passes prefix: YES
>> injected touch executed: YES The rule I never break: a denylist gap is a hypothesis until the byte actually executes at the sink. Regex behaves differently than you remember; the sink may mangle your payload before it runs. You verify, or you hold.
The fix, and the lesson
The real fix isn't a bigger denylist — it's deleting the denylist. Reassemble the ffmpeg invocation from an argument array with escapeshellarg() per argument, so shell metacharacters are data, never syntax. A denylist over a shell sink loses one character at a time, forever: $() in March, & in June, and whatever's next after that.
That's the whole lesson, and it generalized into a checklist I now run on every shell-injection "fix": enumerate the entire separator set and check each one individually. Never trust that closing the obvious one closed the class.
Disclosed via GitHub Security Advisory; published as GHSA-wc3f-xc32-435f / CVE-2026-55173.
No comments yet