Talos Vulnerability Report

TALOS-2023-1900

WWBN AVideo salt generation insufficient entropy vulnerability

January 10, 2024
CVE Number

CVE-2023-49599

SUMMARY

An insufficient entropy vulnerability exists in the salt generation functionality of WWBN AVideo dev master commit 15fed957fb. A specially crafted series of HTTP requests can lead to privilege escalation. An attacker can gather system information via HTTP requests and brute force the salt offline, leading to forging a legitimate password recovery code for the admin user.

CONFIRMED VULNERABLE VERSIONS

The versions below were either tested or verified to be vulnerable by Talos or confirmed to be vulnerable by the vendor.

WWBN AVideo dev master commit 15fed957fb

PRODUCT URLS

AVideo - https://github.com/WWBN/AVideo

CVSSv3 SCORE

9.8 - CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

CWE

CWE-331 - Insufficient Entropy

DETAILS

AVideo is a web application, mostly written in PHP, that can be used to create an audio/video sharing website. It allows users to import videos from various sources, encode and share them in various ways. Users can sign up to the website in order to share videos, while viewers have anonymous access to the publicly-available contents. The platform provides plugins for features like live streaming, skins, YouTube uploads and more.

AVideo allows users to recover their account access when they forget their password. This functionality is implemented by objects/userRecoverPass.php:

    ...
    $user = new User(0, $_REQUEST['user'], false);
[1] if (!(!empty($_REQUEST['user']) && !empty($_REQUEST['recoverpass']))) {
        $obj = new stdClass();
        $obj->user = $_REQUEST['user'];
[2]     $obj->captcha = $_REQUEST['captcha'];
[2]     $obj->reloadCaptcha = false;
        $obj->session_id = session_id();
        
        header('Content-Type: application/json');
[3]     if(empty($user->getStatus())){
            $obj->error = __("User not found");
            die(json_encode($obj));
        }
[3]     if($user->getStatus() !== 'a'){
            $obj->error = __("The user is not active");
            die(json_encode($obj));
        }
        if (!empty($user->getEmail())) {
[4]         $recoverPass = $user->setRecoverPass();
            if (empty($_REQUEST['captcha'])) {
                $obj->error = __("Captcha is empty");
            } else {
                if ($user->save()) {
                    require_once 'captcha.php';
[5]                 $valid = Captcha::validation($_REQUEST['captcha']);
                    if ($valid) {
                        //Create a new PHPMailer instance
                        $mail = new \PHPMailer\PHPMailer\PHPMailer();
                        setSiteSendMessage($mail);
                        //Set who the message is to be sent from
                        $mail->setFrom($config->getContactEmail(), $config->getWebSiteTitle());
                        //Set who the message is to be sent to
                        $mail->addAddress($user->getEmail());
                        //Set the subject line
                        $mail->Subject = __('Recover Pass from') .' '. $config->getWebSiteTitle();

                        $msg = __("You asked for a recover link, click on the provided link") . " <a href='{$global['webSiteRootURL']}recoverPass?user={$_REQUEST['user']}&recoverpass={$recoverPass}'>" . __("Reset password") . "</a>";

                        $mail->msgHTML($msg);

                        //send the message, check for errors
[6]                     if (!$mail->send()) {
                            $obj->error = __("Message could not be sent") . " " . $mail->ErrorInfo;
                        } else {
    ...

The purpose of the code block above is to create a recovery code and send it as a link to the user’s email address. Once the user receives the link, they can click on it so they’ll reach an interface where they can set a new password for their account.

The code block can be entered when user is set but recoverpass is not [1]. Then, user and captcha parameters are retrieved from the request [2].
If the user is valid [3], a recovery code ($recoveryPass) is generated via setRecoverPass() [4]. Finally, the captcha is checked [5] and an email containing the recovery link is sent to the user [6].

Let’s see how setRecoverPass() is implemented:

    public function setRecoverPass($forceChange = false)
    {
        // let the same recover pass if it was 10 minutes ago
[7]     if (!$this->isRecoverPassExpired($this->recoverPass) && empty($forceChange) && !empty($this->recoverPass) && !empty($recoverPass) && !empty($this->modified) && strtotime($this->modified) > strtotime("-10 minutes")) {
            return $this->recoverPass;
        }
[8]     $this->recoverPass = $this->createRecoverPass();
        return $this->recoverPass;
    }

    private function createRecoverPass($secondsValid = 600)
    {
        $json = new stdClass();
[9]     $json->valid = strtotime("+{$secondsValid} seconds");
[10]    return encryptString(json_encode($json));
    }

At [7] the function makes sure that codes are not regenerated in intervals shorter than 10 minutes. However, this is irrelevant to the current issue.
At [8] the recoverPass field is set via createRecoverPass(), which calls encryptString() [10] with a JSON object containing the current time plus 600 seconds [9]. encryptString() is a generic function that encrypts strings:

    function encryptString($string)
    {
        if (is_object($string) || is_array($string)) {
            $string = json_encode($string);
        }
[11]    return encrypt_decrypt($string, 'encrypt');
    }

    function encrypt_decrypt($string, $action)
    {
        global $global;
        $output = false;
        if (empty($string)) {
            return false;
        }
        $encrypt_method = "AES-256-CBC";
        $secret_key = 'This is my secret key';
[12]    $secret_iv = $global['systemRootPath'];
        while (strlen($secret_iv) < 16) {
            $secret_iv .= $global['systemRootPath'];
        }
        if (empty($secret_iv)) {
            $secret_iv = '1234567890abcdef';
        }
        // hash
[13]    $key = hash('sha256', $global['salt']);

        // iv - encrypt method AES-256-CBC expects 16 bytes - else you will get a warning
        $iv = substr(hash('sha256', $secret_iv), 0, 16);

        if ($action == 'encrypt') {
[14]        $output = openssl_encrypt($string, $encrypt_method, $key, 0, $iv);
[15]        $output = base64_encode($output);
        } elseif ($action == 'decrypt') {
            $output = openssl_decrypt(base64_decode($string), $encrypt_method, $key, 0, $iv);
        }

        return $output;
    }

encryptString() is just a wrapper for encrypt_decrypt() [11].

encrypt_decrypt() performs an AES-256 encryption (or decryption, depending on $action) in CBC mode.
The IV corresponds to the installation path [13]. The secret key corresponds to the $salt global variable [13], which is set during installation and can be changed by the user in videos/configuration.php.
At [14] encryption is performed using an OpenSSL’s openssl_encrypt function [14] and the result is returned encoded as base64.

As previously mentioned, a link containing the recoverPass is sent to the e-mail address of the user whose password is being recovered. The e-mail contains a link to reset the password, which looks like the following:

You asked for a recover link, click on the provided link
<a href='https://localhost/recoverPass?user=admin&recoverpass=123456'>Reset password</a>

If an attacker were able to forge a valid recoverPass, they could request a password recovery for the user admin, for example, and reset their password without receiving the recovery e-mail.

In order to forge a recoverPass, an attacker would need to know 3 components:

  • the plaintext value to encrypt
  • the IV
  • the secret key (derived from salt)
  • an encrypted string to brute force against

The plaintext value is straightforward: As we can see from the code at [9], it’s a simple JSON that generates exactly this string, {"valid":12345}, where 12345 is the timestamp corresponding to the date the code has been created server-side, plus 600.

The IV, as we can see at [12] is derived from AVideo’s install path. If using the provided docker-compose.yml file for deployment, that path is by default /var/www/html/AVideo/. Otherwise, it is possible to force AVideo to error out, which, depending on the server configuration, might reveal internal file paths. Alternatively, a more reliable method is to use another vulnerability, for example TALOS-2023-1869, to read the PHP session file for the current user. PHP session files are, by default, stored in /var/lib/php/sessions/sess_<sessionid>, where sessionid is known by the attacker, as it’s simply the session ID associated with their browser session. Inside the sess_* file, there will be many references to the internal AVideo paths that will easily reveal the base installation path.

The secret key is the SHA256 hash of the salt. The salt is generated at installation time via install/checkConfiguration.php:

[16] if (empty($_POST['salt'])) {
         $_POST['salt'] = uniqid();
     }
     $content = "<?php
     \$global['configurationVersion'] = 3.1;
     \$global['disableAdvancedConfigurations'] = 0;
     \$global['videoStorageLimitMinutes'] = 0;
     \$global['disableTimeFix'] = 0;
     ...
     \$global['systemRootPath'] = '{$_POST['systemRootPath']}';
[17] \$global['salt'] = '{$_POST['salt']}';
     ...
     ";
 
     ... 
     error_log("Installation: ".__LINE__);
[18] $fp = fopen("{$videosDir}configuration.php", "wb");
     fwrite($fp, $content);
     fclose($fp);
     error_log("Installation: ".__LINE__);
     ...

At [16] the salt is generated using uniqid(). In the standard installation process, there’s no salt variable sent via post, so generation via uniqid() is the default. At [17] the salt is added to $content, which is eventually written to videos/configuration.php [18]. This process basically sets up global variables that are used throughout the AVideo application.

The usage of uniqid() for salt is the issue, as uniqid() should not be used for cryptographic operations, as stated in the PHP manual:

> This function does not generate cryptographically secure values,
> and must not be used for cryptographic purposes, or purposes 
> that require returned values to be unguessable.

Indeed, let’s see how uniqid() generate its values, from uniq.c:

     /* {{{ Generates a unique ID */
     PHP_FUNCTION(uniqid)
     {
         char *prefix = "";
         bool more_entropy = 0;
         zend_string *uniqid;
         int sec, usec;
         size_t prefix_len = 0;
         struct timeval tv;
 
         ZEND_PARSE_PARAMETERS_START(0, 2)
             Z_PARAM_OPTIONAL
             Z_PARAM_STRING(prefix, prefix_len)
             Z_PARAM_BOOL(more_entropy)
         ZEND_PARSE_PARAMETERS_END();
 
         /* This implementation needs current microsecond to change,
          * hence we poll time until it does. This is much faster than
          * calling usleep(1) which may cause the kernel to schedule
          * another process, causing a pause of around 10ms.
          */
         do {
[19]         (void)gettimeofday((struct timeval *) &tv, (struct timezone *) NULL);
         } while (tv.tv_sec == prev_tv.tv_sec && tv.tv_usec == prev_tv.tv_usec);
 
         prev_tv.tv_sec = tv.tv_sec;
         prev_tv.tv_usec = tv.tv_usec;
 
[20]     sec = (int) tv.tv_sec;
[20]     usec = (int) (tv.tv_usec % 0x100000);
 
         /* The max value usec can have is 0xF423F, so we use only five hex
          * digits for usecs.
          */
         if (more_entropy) {
             uint32_t bytes;
             double seed;
             if (php_random_bytes_silent(&bytes, sizeof(uint32_t)) == FAILURE) {
                 seed = php_combined_lcg() * 10;
             } else {
                 seed = ((double) bytes / UINT32_MAX) * 10.0;
             }
             uniqid = strpprintf(0, "%s%08x%05x%.8F", prefix, sec, usec, seed);
         } else {
[21]         uniqid = strpprintf(0, "%s%08x%05x", prefix, sec, usec);
         }
 
         RETURN_STR(uniqid);
     }

The code is pretty straightforward: At [19] the current system time is fetched, and at [20] the seconds and microseconds are extracted. At [21] a hex-encoded representation of seconds and microseconds is returned, where microseconds correspond to the last 5 hex digits.

This means that if an attacker is able to figure out the installation timestamp with enough accuracy, the salt can be calculated.

In practice, it’s simple to figure out the installation time, as we have this code in install/checkConfiguration.php:

error_log("Installation: ".__LINE__);
$sql = "INSERT INTO `plugins` VALUES (NULL, 'a06505bf-3570-4b1f-977a-fd0e5cab205d', 'active', now(), now(), '', 'Gallery', 'Gallery', '1.0');";
try {
    $mysqli->query($sql);
} catch (Exception $exc) {
    $obj->error = "Error deleting user: " . $mysqli->error;
    echo json_encode($obj);
}

Basically during installation, the Gallery plugin is enabled and the creation date is set to now(). By requesting the objects/plugins.json.php page, a list of plugins together with their creation date is returned. Note that this request does not need authentication:

...
$row = Plugin::getAll();
if (!User::isAdmin()) {
    foreach ($row as $key => $value) {
        if (!empty($row[$key]->installedPlugin['object_data'])) {
            $row[$key]->installedPlugin['object_data'] = '';
        }
    }
}
$total = Plugin::getTotal();
echo '{  "current": '.$_POST['current'].',"rowCount": '.$_POST['rowCount'].', "total": '.$total.', "rows":'. json_encode($row).'}';
...

For example:

$ curl -s -k https://localhost/objects/plugins.json.php | jq -r '.rows[] | select(.name == "Gallery")'
{
  "id": 1,
  "uuid": "a06505bf-3570-4b1f-977a-fd0e5cab205d",
  "status": "active",
  "created": "2023-11-08 14:10:03",
  "modified": "2023-11-08 14:10:03",
  "object_data": "",
  "name": "Gallery",
  "dirName": "Gallery",
  "pluginversion": "1.0"
}

This gives an attacker the timestamp, in seconds, for the installation, without the microseconds part. As said before, microseconds are just 5 hex digits, so they are trivially brute forced. For reference, simple (non-optimized) C implementation on 1-core (on any relatively recent machine) can brute force more than 1 million uniqid()-generated keys per second. So, the time taken to find the salt is at most equivalent to the time taken to install AVideo itself, which is clearly within the possibilities of any attacker.

In practice, brute forcing the salt requires an encrypted string to decrypt. This can easily be done by requesting a recoveryCode for an unprivileged user. The same can be done without any user privileges, since encryptString() is used in many other spots in the code. For example view/url2Embed.json.php returns an encrypted string with JSON-encoded contents:

...
$evideo = new stdClass();
$evideo->videos_id = 0;
$evideo->videoLink = $obj->url;
$evideo->title = '';
$evideo->description = '';
$evideo->webSiteRootURL = $global['webSiteRootURL'];
$evideo->thumbnails = false;
$evideo->poster = false;
$evideo->filename = '';
$evideo->type = 'embed';
$evideo->users_id = User::getId();
$evideo->thumbnails = false;

$obj->playLink = "{$global['webSiteRootURL']}evideo/" . encryptString(json_encode($evideo));
$obj->playEmbedLink = "{$global['webSiteRootURL']}evideoEmbed/" . encryptString(json_encode($evideo));

die(json_encode($obj));
...

This allows for brute forcing a returned encrypted string, for example the one in the playLink field, as we know the plaintext content will start with {"videos_id":.

As we clarified how to gather all the missing components, the salt can be calculated offline, and a privilege escalation attack can be carried out by following the steps below:

  • get an encrypted string, by requesting the view/url2Embed.json.php page
  • retrieve the installation date in seconds, by requesting the objects/plugins.json.php page
  • retrieve the installation path, using TALOS-2023-1869 to read our PHP session file inside the webserver
  • calculate the salt: brute force the microsecond part of the salt and try to decrypt the encrypted string we got in the first step. If the decrypted content contains the string {"videos_id":, then the salt has been guessed correctly
  • create a recovery code for the admin, optionally using TALOS-2023-1897 to prevent the admin from being notified, and note the current time + 600 (code creation timestamp)
  • forge the recovery code for the admin, as we know key, IV and plaintext contents, and use it to reset the admin password.
TIMELINE

2023-12-15 - Vendor Disclosure
2023-12-15 - Vendor Patch Release
2024-01-10 - Public Release

Credit

Discovered by Claudio Bozzato of Cisco Talos.