//nefariousplan

CVE-2026-43873: AVideo's Reject Branch Has Been Echoing the Federation Key Since 2019

patterns

cve

proof of concept

CVE-2026-43873 is one line removed from plugin/CloneSite/cloneClient.json.php in WWBN AVideo. The line was echo "$objClone->myKey !== $argv[1]";. It sat inside the reject branch of an auth check that runs on every unauthenticated request to the endpoint. $objClone->myKey is the federation secret the local server uses to authenticate itself to its CloneSite peers. The line was added on April 9, 2019, in commit a6ccbc3b, titled Fix clone site, add more shared social buttons. It was removed on April 24, 2026, in commit e6566f5, titled feat: Sanitize command line input for clone key validation. Neither commit message mentions the line. It printed the secret into the HTTP response body for seven years and fifteen days. The reject branch is the auth check. It told the caller what would have passed it. The reject branch in cloneClient.json.php was exercised seven years and fifteen days by attackers who knew what to read and zero times by the project's developers who knew what was in it.

The bug is the reject branch

CVE-2026-43873 was assigned to WWBN AVideo, an open-source video-platform project, for a bug in plugin/CloneSite/cloneClient.json.php. The CloneSite plugin federates two AVideo instances. An admin on the local server triggers plugin/CloneSite/cloneClient.json.php to pull a fresh database dump and media set from a configured peer. The endpoint supports two invocation modes: a CLI mode for cron-driven backups (which passes the federation key as argv[1]) and a web mode that requires an authenticated admin session. Before April 24, 2026, the auth check at the top of the file read:

$objClone = AVideoPlugin::getObjectDataIfEnabled("CloneSite");

$argv[1] = preg_replace("/[^A-Za-z0-9 ]/", '', empty($argv[1])?'':$argv[1]);

if (empty($objClone) || empty($argv[1]) || $objClone->myKey !== $argv[1]) {
    if (!User::isAdmin()) {
        $resp->msg = "You can't do this";
        $log->add("Clone: {$resp->msg}");
        echo "$objClone->myKey !== $argv[1]";
        die(json_encode($resp));
    }
}

PHP's web SAPI has no $argv. The empty($argv[1]) check short-circuits true on every HTTP caller. The outer condition is true. The inner User::isAdmin() check is false for any caller without an authenticated admin session. The body of the inner condition runs. The echo runs. The die(json_encode($resp)) runs.

The Content-Type header was set to application/json at the top of the file. The body on an unauthenticated request is the concatenation of the echo and the JSON:

4f8a1c2b3d4e5f6789abcdef01234567 !== {"error":true,"msg":"You can't do this"}

The response is malformed JSON. myKey is the first token in it.

The patch is three lines added and one line removed:

-$argv[1] = preg_replace("/[^A-Za-z0-9 ]/", '', empty($argv[1])?'':$argv[1]);
+$cliKey = '';
+if (isCommandLineInterface() && !empty($argv[1])) {
+    $cliKey = preg_replace("/[^A-Za-z0-9 ]/", '', $argv[1]);
+}

-if (empty($objClone) || empty($argv[1]) || $objClone->myKey !== $argv[1]) {
+if (empty($objClone) || empty($cliKey) || !hash_equals((string) $objClone->myKey, (string) $cliKey)) {
     if (!User::isAdmin()) {
         $resp->msg = "You can't do this";
         $log->add("Clone: {$resp->msg}");
-        echo "$objClone->myKey !== $argv[1]";
         die(json_encode($resp));
     }
 }

Three changes ride in the diff. The isCommandLineInterface() gate around the argv[1] read is defense-in-depth: it prevents a hypothetical caller setting argv[1] via query parameters under a non-default register_argc_argv configuration. The hash_equals upgrade is timing-safe comparison, which matters in some auth contexts and does not in this one, because the outer key-equality check has already failed before any timing channel could be observed. The third change is the deletion of the echo. The third change is the CVE. The other two are dressing.

What $objClone->myKey authorizes

The myKey is computed once per install. Its definition is in the CloneSite plugin's data object: md5($global['systemRootPath'] . $global['salt']). Both inputs live in videos/configuration.php and are constant for the install's lifetime. The hash is the credential the local server presents to every CloneSite peer it federates with.

The presentation is in line 77 of the same file:

$url = $objClone->cloneSiteURL
     . "plugin/CloneSite/cloneServer.json.php?url=" . urlencode($global['webSiteRootURL'])
     . "&key={$objClone->myKey}"
     . "&useRsync=" . intval($objClone->useRsync);

The local client calls the peer's server endpoint, passing the local's own webSiteRootURL as the caller identity and the local's own myKey as the credential. The peer reads both off the query string in cloneServer.json.php:

$resp->url = $_GET['url'];
$resp->key = $_GET['key'];

// ...

$canClone = Clones::thisURLCanCloneMe($resp->url, $resp->key);
if (empty($canClone->canClone)) {
    $resp->msg = $canClone->msg;
    die(json_encode($resp));
}

Clones::thisURLCanCloneMe looks up the caller's URL in the peer's clone_SitesAllowed table and compares its stored key against the inbound key:

public static function thisURLCanCloneMe($url, $key) {
    // ...
    if ($clone->getKey() !== $key) {
        $resp->msg = "Invalid Key";
        return $resp;
    }
    if ($clone->getStatus() !== 'a') {
        $resp->msg = "The URL {$url} is inactive in our Clone Server";
        return $resp;
    }
    $resp->clone = $clone;
    $resp->canClone = true;
    return $resp;
}

The stored key on the peer's row is whatever the local sent the first time it called the peer. The local sends its own myKey. The peer's record of the local is keyed by the local's myKey. The leak from the local's reject branch is the credential the peer requires.

When the check passes, the peer runs:

$cmd = "mysqldump -u {$mysqlUser} -p'{$mysqlPass}' --host {$mysqlHost} "
     . " --default-character-set=utf8mb4 {$mysqlDatabase} {$tablesList} > $sqlFile";
exec($cmd . " 2>&1", $output, $return_val);

$sqlFile is {$clonesDir}{$resp->sqlFile}, where $clonesDir is videos/clones/ under the peer's webroot and $resp->sqlFile is uniqid('Clone_mysqlDump_') . ".sql". The dump lands in a directory the peer's webserver serves publicly. The filename returns to the caller as the sqlFile field of the JSON response. An attacker holding the leaked myKey, calling the peer's cloneServer.json.php with the local's webSiteRootURL, receives the filename of a fresh mysqldump and downloads it with a GET to videos/clones/Clone_mysqlDump_<uniqid>.sql.

The leak is on the local. The exfiltration is on the peer. The blast radius is every federation relationship the local maintains.

The commit message describes everything except the fix

The commit that closed the GHSA is titled feat: Sanitize command line input for clone key validation. The body is the URL of the advisory and nothing else. The advisory is named after the security impact; the commit is named after one of the dressing changes.

Three things changed in the diff:

  1. The $argv[1] read is now CLI-only. A feat change. No effect on the CVE-2026-43873 scenario, because the web SAPI never read $argv[1] in a way that mattered.
  2. The string comparison is now hash_equals. A feat change. No effect on the CVE-2026-43873 scenario, because the comparison's timing channel is unreachable from the unauthenticated web path.
  3. The echo on the reject branch is gone. A fix change. The CVE record.

A reader of the commit message learns that command-line input is now sanitized. A reader of the diff learns that one of the three changes is the removal of a line that printed the secret to the HTTP response. WWBN's GHSA text describes the bug accurately. The commit log next to the change does not.

The 2019 commit that introduced the line, a6ccbc3b, changed thirty-five lines of cloneClient.json.php and bundled in social-share buttons elsewhere in the same change. The diff that added the echo:

-if (!User::isAdmin()) {
-    $resp->msg = "You cant do this";
-    $log->add("Clone: {$resp->msg}");
-    die(json_encode($resp));
+$obj = YouPHPTubePlugin::getObjectDataIfEnabled("CloneSite");
+
+if (empty($obj) || empty($argv[1]) || $obj->myKey !== $argv[1]) {
+    if (!User::isAdmin()) {
+        $resp->msg = "You cant do this";
+        $log->add("Clone: {$resp->msg}");echo "$obj->myKey !== $argv[1]";
+        die(json_encode($resp));
+    }
 }

The new statement on the formatted line reads $log->add("Clone: {$resp->msg}");echo "$obj->myKey !== $argv[1]";. Two statements, semicolon, no space, no newline. This is the formatting of a print added in place to verify a code path during development, committed without cleanup. The intent of the 2019 change was to add a CLI-callable auth mode for cron-driven clones. The print was the developer checking that the new comparison rejected the caller correctly. The comparison did. The print also did what it said.

The file was re-touched eight months later in commit d976f5b, a rename pass that changed every reference to $obj to $objClone throughout the function, including the echo line. The rename touched the offending statement. The reviewer touched the offending statement. The echo survived the touch.

The reject branch is the path nobody re-reads

User::isAdmin() === false is the failure case of the auth check. By construction it is the path no developer exercises after first writing it, because nobody asks "what does the rejection look like in the response body?" once they have confirmed the rejection happens. Tests, when they exist, assert that the unauthenticated call returns the error and stop reading the body. Manual QA, when it happens, opens an admin session and exercises the success path. The reject branch in cloneClient.json.php was exercised seven years and fifteen days by attackers who knew what to read and zero times by the project's developers who knew what was in it.

This is the Rejection Echoes The Secret shape, named for what it does: an auth check that fails the caller, then writes the expected value into the response body before terminating. The reject branch performs the comparison, computes the value the caller should have sent, and emits it. The bug originates as a debug print added during initial development to verify the comparison works, and ships because the reject branch is the path no developer re-reads.

It is also the same shape as todo-that-shipped with the artifact swapped. There, the fix is in a // TODO comment and the code shipped without it. Here, the bug is in an echo and the code shipped with it. Both originate in the same developer state, the moment of writing the path the first time, leaving a placeholder for future cleanup, moving on to the next feature, and both ship because the path is not the path anyone walks back to.

The pattern at the architectural level is trust-inversion. myKey sits in the trusted lane of every CloneSite peer relationship. Validation on the distrusted lane (the inbound HTTP request to cloneClient.json.php) is not the gate, because the gate is on the peer, and the peer trusts the local's identity by the local's possession of myKey. The local's leak is the peer's compromise. The local was not asked whether the leak was safe; the local was running an echo in its reject branch because a developer added it in April 2019 to confirm a key check worked. The peer is on the receiving end of seven years of impersonation potential because the local's reject branch was not designed as part of the peer's threat model and the peer's mysqldump path was not designed as part of the local's.

The reject branch was the receipts

The federation key authorizes the federation peer. The reject branch echoed the federation key. The reject branch was reachable without authentication. The patch is one line removed. The commit message does not mention the line. The release notes do not mention the file.

PoC: GHSA-qm9p-p5pw-jrx2

The reject branch is the auth check. It told the caller what would have passed it.