CVE-2023-49810
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.
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
AVideo - https://github.com/WWBN/AVideo
7.3 - CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:L/A:L
CWE-307 - Improper Restriction of Excessive Authentication Attempts
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.
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.
2023-12-14 - Vendor Disclosure
2023-12-15 - Vendor Patch Release
2024-01-10 - Public Release
Discovered by Claudio Bozzato of Cisco Talos.