CVE-2026-25861
Description
QloApps 1.7.0 and earlier use weak MD5 hashing for passwords, allowing attackers to easily recover user credentials via offline brute-force attacks.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
QloApps 1.7.0 and earlier use weak MD5 hashing for passwords, allowing attackers to easily recover user credentials via offline brute-force attacks.
Vulnerability
QloApps versions through 1.7.0 are vulnerable due to the use of the MD5 hashing algorithm for password storage in the Tools::encrypt() function within classes/Tools.php. This function concatenates a static cookie key with the user's password before hashing. The vulnerability was fixed in commit 64e9722 [2].
Exploitation
Attackers can exploit this vulnerability by obtaining MD5 hashes of user passwords, which can be achieved through various means such as database compromise or by intercepting password reset requests. Once the hashes are obtained, attackers can perform offline brute-force attacks. The risk is exacerbated by the application's feature of auto-generating 8-character passwords during guest-to-customer account conversion, making credential recovery significantly easier [3].
Impact
Successful exploitation allows attackers to recover user credentials, potentially leading to account takeover. This can result in unauthorized access to sensitive user information and compromise the integrity of the application [3].
Mitigation
The vulnerability was fixed in commit 64e9722 [2], which upgraded the password hashing algorithm from MD5 to bcrypt. Users are advised to update to a version of QloApps that includes this fix. Information regarding a specific patched version release date is not yet available in the provided references.
AI Insight generated on Jun 3, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
1Patches
164e9722e7e6aMerge pull request #1689 from amitsharma322/gli-2987
16 files changed · +183 −29
classes/AdminTab.php+3 −2 modified@@ -1179,9 +1179,10 @@ protected function copyFromPost(&$object, $table) if ($key == 'passwd' && Tools::getValue('id_'.$table) && empty($value)) { continue; } - /* Automatically encrypt password in MD5 */ + /* Automatically hash password */ if ($key == 'passwd' && !empty($value)) { - $value = Tools::encrypt($value); + $objHash = new PasswordHashing(); + $value = $objHash->passwordHash($value); } $object->{$key} = $value; }
classes/controller/AdminController.php+4 −3 modified@@ -3842,9 +3842,10 @@ protected function copyFromPost(&$object, $table) if ($key == 'passwd' && Tools::getValue('id_'.$table) && empty($value)) { continue; } - /* Automatically encrypt password in MD5 */ + /* Automatically hash password */ if ($key == 'passwd' && !empty($value)) { - $value = Tools::encrypt($value); + $objHash = new PasswordHashing(); + $value = $objHash->passwordHash($value); } $object->{$key} = $value; } @@ -4821,4 +4822,4 @@ public function authorizationLevel() return 0; } } -} \ No newline at end of file +}
classes/Customer.php+16 −5 modified@@ -167,7 +167,7 @@ class CustomerCore extends ObjectModel 'lastname' => array('type' => self::TYPE_STRING, 'validate' => 'isName', 'required' => true, 'size' => 32), 'firstname' => array('type' => self::TYPE_STRING, 'validate' => 'isName', 'required' => true, 'size' => 32), 'email' => array('type' => self::TYPE_STRING, 'validate' => 'isEmail', 'required' => true, 'size' => 128), - 'passwd' => array('type' => self::TYPE_STRING, 'validate' => 'isPasswd', 'required' => true, 'size' => 32), + 'passwd' => array('type' => self::TYPE_STRING, 'validate' => 'isPasswd', 'required' => true, 'size' => 60), 'last_passwd_gen' => array('type' => self::TYPE_STRING, 'copy_post' => false), 'id_gender' => array('type' => self::TYPE_INT, 'validate' => 'isUnsignedId'), 'birthday' => array('type' => self::TYPE_DATE, 'validate' => 'isBirthDate'), @@ -383,19 +383,28 @@ public function getByEmail($email, $passwd = null, $ignore_guest = true) FROM `'._DB_PREFIX_.'customer` WHERE `email` = \''.pSQL($email).'\' '.Shop::addSqlRestriction(Shop::SHARE_CUSTOMER).' - '.(isset($passwd) ? 'AND `passwd` = \''.pSQL(Tools::encrypt($passwd)).'\'' : '').' AND `deleted` = 0 '.($ignore_guest ? ' AND `is_guest` = 0' : '')); if (!$result) { return false; } + if (isset($passwd)) { + $objHash = new PasswordHashing(); + if (!$objHash->validateHash($passwd, $result['passwd'])) { + return false; + } + } $this->id = $result['id_customer']; foreach ($result as $key => $value) { if (property_exists($this, $key)) { $this->{$key} = $value; } } + if (isset($passwd) && !$objHash->isPrimaryHash($passwd, $result['passwd'])) { + $this->passwd = $objHash->passwordHash($passwd); + $this->update(); + } return $this; } @@ -560,7 +569,7 @@ public static function getAddressesTotalById($id_customer) */ public static function checkPassword($id_customer, $passwd) { - if (!Validate::isUnsignedId($id_customer) || !Validate::isMd5($passwd)) { + if (!Validate::isUnsignedId($id_customer)) { die(Tools::displayError()); } $cache_id = 'Customer::checkPassword'.(int)$id_customer.'-'.$passwd; @@ -852,7 +861,8 @@ public function transformToCustomer($id_lang, $password = null) } $this->is_guest = 0; - $this->passwd = Tools::encrypt($password); + $objHash = new PasswordHashing(); + $this->passwd = $objHash->passwordHash($password); $this->cleanGroups(); $this->addGroups(array(Configuration::get('PS_CUSTOMER_GROUP'))); // add default customer group $this->id_default_group = (int) Configuration::get('PS_CUSTOMER_GROUP'); @@ -887,7 +897,8 @@ public function transformToCustomer($id_lang, $password = null) public function setWsPasswd($passwd) { if ($this->id == 0 || $this->passwd != $passwd) { - $this->passwd = Tools::encrypt($passwd); + $objHash = new PasswordHashing(); + $this->passwd = $objHash->passwordHash($passwd); } return true; }
classes/Employee.php+18 −6 modified@@ -102,7 +102,7 @@ class EmployeeCore extends ObjectModel 'firstname' => array('type' => self::TYPE_STRING, 'validate' => 'isName', 'required' => true, 'size' => 32), 'email' => array('type' => self::TYPE_STRING, 'validate' => 'isEmail', 'required' => true, 'size' => 128), 'id_lang' => array('type' => self::TYPE_INT, 'validate' => 'isUnsignedInt', 'required' => true), - 'passwd' => array('type' => self::TYPE_STRING, 'validate' => 'isPasswdAdmin', 'required' => true, 'size' => 32), + 'passwd' => array('type' => self::TYPE_STRING, 'validate' => 'isPasswdAdmin', 'required' => true, 'size' => 60), 'last_passwd_gen' => array('type' => self::TYPE_STRING), 'active' => array('type' => self::TYPE_BOOL, 'validate' => 'isBool'), 'optin' => array('type' => self::TYPE_BOOL, 'validate' => 'isBool'), @@ -281,18 +281,29 @@ public function getByEmail($email, $passwd = null, $active_only = true) SELECT * FROM `'._DB_PREFIX_.'employee` WHERE `email` = \''.pSQL($email).'\' - '.($active_only ? ' AND `active` = 1' : '') - .($passwd !== null ? ' AND `passwd` = \''.Tools::encrypt($passwd).'\'' : '')); + '.($active_only ? ' AND `active` = 1' : '')); if (!$result) { return false; } + + if ($passwd !== null) { + $objHash = new PasswordHashing(); + if (!$objHash->validateHash($passwd, $result['passwd'])) { + return false; + } + } $this->id = $result['id_employee']; $this->id_profile = $result['id_profile']; foreach ($result as $key => $value) { if (property_exists($this, $key)) { $this->{$key} = $value; } } + + if ($passwd !== null && !$objHash->isPrimaryHash($passwd, $result['passwd'])) { + $this->passwd = $objHash->passwordHash($passwd); + $this->update(); + } return $this; } @@ -316,7 +327,7 @@ public static function employeeExists($email) */ public static function checkPassword($id_employee, $passwd) { - if (!Validate::isUnsignedId($id_employee) || !Validate::isPasswd($passwd, 8)) { + if (!Validate::isUnsignedId($id_employee)) { die(Tools::displayError()); } @@ -364,19 +375,20 @@ public function setWsEmail($email) // validate and set password for the employee public function setWsPasswd($passwd) { + $objHash = new PasswordHashing(); if ($this->id != 0) { if ($this->passwd != $passwd) { if (!Validate::isPasswd($passwd, Validate::ADMIN_PASSWORD_LENGTH)) { WebserviceRequest::getInstance()->setError(400, 'The password must be at least '.Validate::ADMIN_PASSWORD_LENGTH.' characters long.', 134); } else { - $this->passwd = Tools::encrypt($passwd); + $this->passwd = $objHash->passwordHash($passwd); } } } else { if (!Validate::isPasswd($passwd, Validate::ADMIN_PASSWORD_LENGTH)) { WebserviceRequest::getInstance()->setError(400, 'The password must be at least '.Validate::ADMIN_PASSWORD_LENGTH.' characters long.', 134); } else { - $this->passwd = Tools::encrypt($passwd); + $this->passwd = $objHash->passwordHash($passwd); } }
classes/ObjectModel.php+2 −1 modified@@ -1188,7 +1188,8 @@ public function validateController($htmlentities = true) } if ($field == 'passwd') { if ($value = Tools::getValue($field)) { - $this->{$field} = Tools::encrypt($value); + $objHash = new PasswordHashing(); + $this->{$field} = $objHash->passwordHash($value); } } else { $this->{$field} = $value;
classes/PasswordHashing.php+122 −0 added@@ -0,0 +1,122 @@ +<?php +/** +* NOTICE OF LICENSE +* +* This source file is subject to the Open Software License version 3.0 +* that is bundled with this package in the file LICENSE.md +* It is also available through the world-wide-web at this URL: +* https://opensource.org/license/osl-3-0-php +* If you did not receive a copy of the license and are unable to +* obtain it through the world-wide-web, please send an email +* to support@qloapps.com so we can send you a copy immediately. +* +* DISCLAIMER +* +* Do not edit or add to this file if you wish to upgrade this module to a newer +* versions in the future. If you wish to customize this module for your needs +* please refer to https://store.webkul.com/customisation-guidelines for more information. +* +* @author Webkul IN +* @copyright Since 2010 Webkul +* @license https://opensource.org/license/osl-3-0-php Open Software License version 3.0 +*/ + + + +class PasswordHashingCore +{ + /** @var array Available password hashing methods. */ + private $passwordHashMethods = array(); + + /** + * Check whether the stored hash matches the primary hashing method. + * + * @param string $passwd Plain-text password to validate. + * @param string $hash Stored password hash. + * @param string $staticSalt Static salt used by legacy hash methods. + * + * @return bool + */ + public function isPrimaryHash($passwd, $hash, $staticSalt = _COOKIE_KEY_) + { + if (!count($this->passwordHashMethods)) { + $this->initPasswordHashMethods(); + } + + $closure = reset($this->passwordHashMethods); + + return $closure['verify']($passwd, $hash, $staticSalt); + } + + /** + * Validate a password against all supported hashing methods. + * + * @param string $passwd Plain-text password to validate. + * @param string $hash Stored password hash. + * @param string $staticSalt Static salt used by legacy hash methods. + * + * @return bool + */ + public function validateHash($passwd, $hash, $staticSalt = _COOKIE_KEY_) + { + if (!count($this->passwordHashMethods)) { + $this->initPasswordHashMethods(); + } + + foreach ($this->passwordHashMethods as $closure) { + if ($closure['verify']($passwd, $hash, $staticSalt)) { + return true; + } + } + + return false; + } + + /** + * Hash a plain-text password with the primary hashing method. + * + * @param string $plaintextPassword Password to hash. + * @param string $staticSalt Static salt reserved for legacy hash methods. + * + * @return string + */ + public function passwordHash($plaintextPassword, $staticSalt = _COOKIE_KEY_) + { + if (!count($this->passwordHashMethods)) { + $this->initPasswordHashMethods(); + } + + $closure = reset($this->passwordHashMethods); + + return $closure['hash']($plaintextPassword, $staticSalt, $closure['option']); + } + + /** + * Initialize the supported password hashing methods. + * + * @return void + */ + private function initPasswordHashMethods() + { + $this->passwordHashMethods = array( + 'bcrypt' => array( + 'option' => array(), + 'hash' => function ($passwd, $staticSalt, $option) { + return password_hash($passwd, PASSWORD_BCRYPT); + }, + 'verify' => function ($passwd, $hash, $staticSalt) { + return password_verify($passwd, $hash); + }, + ), + 'md5' => array( + 'option' => array(), + 'hash' => function ($passwd, $staticSalt, $option) { + return md5($staticSalt.$passwd); + }, + 'verify' => function ($passwd, $hash, $staticSalt) { + return md5($staticSalt.$passwd) === $hash; + }, + ), + ); + } +}
classes/Referrer.php+1 −1 modified@@ -53,7 +53,7 @@ class ReferrerCore extends ObjectModel 'primary' => 'id_referrer', 'fields' => array( 'name' => array('type' => self::TYPE_STRING, 'validate' => 'isGenericName', 'required' => true, 'size' => 64), - 'passwd' => array('type' => self::TYPE_STRING, 'validate' => 'isPasswd', 'size' => 32), + 'passwd' => array('type' => self::TYPE_STRING, 'validate' => 'isPasswd', 'size' => 60), 'http_referer_regexp' => array('type' => self::TYPE_STRING, 'validate' => 'isCleanHtml', 'size' => 64), 'request_uri_regexp' => array('type' => self::TYPE_STRING, 'validate' => 'isCleanHtml', 'size' => 64), 'http_referer_like' => array('type' => self::TYPE_STRING, 'validate' => 'isCleanHtml', 'size' => 64),
classes/Validate.php+1 −1 modified@@ -1140,4 +1140,4 @@ public static function isOrderInvoiceNumber($id) { return (preg_match('/^(?:'.Configuration::get('PS_INVOICE_PREFIX', Context::getContext()->language->id).')\s*([0-9]+)$/i', $id)); } -} \ No newline at end of file +}
classes/webservice/WebserviceSpecificManagementBookings.php+2 −1 modified@@ -1392,7 +1392,8 @@ public function processCustomer($customerDetails) $objCustomer->firstname = $customerDetails['firstname']; $objCustomer->lastname = $customerDetails['lastname']; $objCustomer->email = $customerDetails['email']; - $objCustomer->passwd = md5(time()._COOKIE_KEY_); + $objHash = new PasswordHashing(); + $objCustomer->passwd = $objHash->passwordHash(Tools::passwdGen(32, 'RANDOM')); $objCustomer->phone = (isset($customerDetails['phone']) ? $customerDetails['phone'] : ''); $objCustomer->cleanGroups(); $objCustomer->add();
controllers/admin/AdminImportController.php+2 −1 modified@@ -2835,7 +2835,8 @@ public function customerImport() AdminImportController::arrayWalk($info, array('AdminImportController', 'fillInfo'), $customer); if ($customer->passwd) { - $customer->passwd = Tools::encrypt($customer->passwd); + $objHash = new PasswordHashing(); + $customer->passwd = $objHash->passwordHash($customer->passwd); } $customers_shop = array();
controllers/admin/AdminLoginController.php+2 −1 modified@@ -263,7 +263,8 @@ public function processForgot() if (!count($this->errors)) { $pwd = Tools::passwdGen(10, 'RANDOM'); - $employee->passwd = Tools::encrypt($pwd); + $objHash = new PasswordHashing(); + $employee->passwd = $objHash->passwordHash($pwd); $employee->last_passwd_gen = date('Y-m-d H:i:s', time()); $params = array(
controllers/front/IdentityController.php+1 −1 modified@@ -71,7 +71,7 @@ public function postProcess() $this->errors[] = Tools::displayError('This email address is not valid'); } elseif ($this->customer->email != $email && Customer::customerExists($email, true)) { $this->errors[] = Tools::displayError('An account using this email address has already been registered.'); - } elseif (!Tools::getIsset('old_passwd') || (Tools::encrypt($old_passwd) != $this->context->cookie->passwd)) { + } elseif (!Tools::getIsset('old_passwd') || !(new PasswordHashing())->validateHash($old_passwd, $this->context->cookie->passwd)) { $this->errors[] = Tools::displayError('The password you entered is incorrect.'); } elseif (Tools::getValue('passwd') != Tools::getValue('confirmation')) { $this->errors[] = Tools::displayError('The password and confirmation do not match.');
controllers/front/PasswordController.php+2 −1 modified@@ -73,7 +73,8 @@ public function postProcess() } elseif ((strtotime($customer->last_passwd_gen.'+'.(int)Configuration::get('PS_PASSWD_TIME_FRONT').' minutes') - time()) > 0) { Tools::redirect('index.php?controller=authentication&error_regen_pwd'); } else { - $customer->passwd = Tools::encrypt($password = Tools::passwdGen(MIN_PASSWD_LENGTH, 'RANDOM')); + $objHash = new PasswordHashing(); + $customer->passwd = $objHash->passwordHash($password = Tools::passwdGen(MIN_PASSWD_LENGTH, 'RANDOM')); $customer->last_passwd_gen = date('Y-m-d H:i:s', time()); if ($customer->update()) { Hook::exec('actionPasswordRenew', array('customer' => $customer, 'password' => $password));
install/data/db_structure.sql+3 −3 modified@@ -593,7 +593,7 @@ CREATE TABLE `PREFIX_customer` ( `firstname` varchar(32) NOT NULL, `lastname` varchar(32) NOT NULL, `email` varchar(128) NOT NULL, - `passwd` varchar(32) NOT NULL, + `passwd` varchar(60) NOT NULL, `last_passwd_gen` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, `birthday` date DEFAULT NULL, `newsletter` tinyint(1) unsigned NOT NULL DEFAULT '0', @@ -779,7 +779,7 @@ CREATE TABLE `PREFIX_employee` ( `lastname` varchar(32) NOT NULL, `firstname` varchar(32) NOT NULL, `email` varchar(128) NOT NULL, - `passwd` varchar(32) NOT NULL, + `passwd` varchar(60) NOT NULL, `last_passwd_gen` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, `stats_date_from` date DEFAULT NULL, `stats_date_to` date DEFAULT NULL, @@ -1781,7 +1781,7 @@ CREATE TABLE `PREFIX_range_weight` ( CREATE TABLE `PREFIX_referrer` ( `id_referrer` int(10) unsigned NOT NULL auto_increment, `name` varchar(64) NOT NULL, - `passwd` varchar(32) DEFAULT NULL, + `passwd` varchar(60) DEFAULT NULL, `http_referer_regexp` varchar(64) DEFAULT NULL, `http_referer_like` varchar(64) DEFAULT NULL, `request_uri_regexp` varchar(64) DEFAULT NULL,
install/fixtures/fashion/install.php+2 −1 modified@@ -35,7 +35,8 @@ class InstallFixturesFashion extends InstallXmlLoader public function createEntityCustomer($identifier, array $data, array $data_lang) { if ($identifier == 'John') { - $data['passwd'] = Tools::encrypt('123456789'); + $objHash = new PasswordHashing(); + $data['passwd'] = $objHash->passwordHash('123456789'); $data['last_passwd_gen'] = date('Y-m-d H:i:s'); $data['birthday'] = date('Y-m-d', strtotime('-30 years')); $data['newsletter_date_add'] = date('Y-m-d H:i:s');
install/models/install.php+2 −1 modified@@ -566,7 +566,8 @@ public function configureShop(array $data = array()) $employee->firstname = Tools::ucfirst($data['admin_firstname']); $employee->lastname = Tools::ucfirst($data['admin_lastname']); $employee->email = $data['admin_email']; - $employee->passwd = md5(_COOKIE_KEY_.$data['admin_password']); + $objHash = new PasswordHashing(); + $employee->passwd = $objHash->passwordHash($data['admin_password']); $employee->last_passwd_gen = date('Y-m-d h:i:s', strtotime('-360 minutes')); $employee->bo_theme = 'default'; $employee->default_tab = 1;
Vulnerability mechanics
Root cause
"The application uses the weak MD5 hashing algorithm for password storage, which is susceptible to offline brute-force attacks."
Attack vector
An attacker can exploit this vulnerability by obtaining MD5 hashes of user passwords, which are concatenated with a static cookie key before hashing. This allows for offline brute-force attacks to recover credentials. The risk is compounded by auto-generated 8-character passwords during guest-to-customer account conversion, making credential recovery trivial [ref_id=1].
Affected code
The vulnerability lies in the `Tools::encrypt()` function within `classes/Tools.php` (not provided in diff but implied by context) and its usage in various parts of the application. Specifically, the `EmployeeCore::getByEmail()` and `CustomerCore::getByEmail()` methods, as well as password setting functions like `EmployeeCore::setWsPasswd()` and `CustomerCore::setWsPasswd()`, were updated to use the new `PasswordHashing` class instead of the vulnerable `Tools::encrypt()` method [patch_id=4549446].
What the fix does
The patch introduces a new `PasswordHashing` class that utilizes bcrypt for password hashing, a significantly stronger algorithm than MD5. The `Tools::encrypt()` function, which previously used MD5, is replaced with calls to the new `PasswordHashing::passwordHash()` method. This change ensures that passwords are now hashed using a more secure and modern cryptographic standard, mitigating the risk of offline brute-force attacks [patch_id=4549446].
Preconditions
- inputAttacker must be able to obtain user password hashes.
Generated on Jun 2, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
3News mentions
0No linked articles in our index yet.