A glowing ampersand character slipping through a broken command filter into a shell prompt, dark cyberpunk terminal aesthetic, 16:9

The Ampersand They Forgot: CVE-2026-55173 in AVideo

AVideo patched a command injection by denylisting the obvious separators and forgot a lone ampersand — the shell background operator — which walked straight to sh -c. How I found CVE-2026-55173, and why denylisting a shell is a losing game.

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