CVE-2026-45413
Description
MaxKB is an open-source AI assistant for enterprise. Prior to 2.9.1, user passwords are stored using unsalted MD5 hashes, making them trivially crackable via rainbow tables or GPU-accelerated brute force (hashcat). This vulnerability is fixed in 2.9.1.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
MaxKB stored user passwords as unsalted MD5 hashes, allowing trivial cracking via rainbow tables or GPU brute force; fixed in version 2.9.1.
Vulnerability
MaxKB versions prior to 2.9.1 stored user passwords using unsalted MD5 hashes. The password_encrypt function in apps/common/utils/common.py directly computes hashlib.md5() on the plaintext password, without any salt [1]. This affects all user password operations, including login, user creation, and password changes. Identical passwords produce identical hashes.
Exploitation
An attacker who gains access to the password hash database can crack the hashes using precomputed rainbow tables or GPU-accelerated tools like hashcat. No additional authentication or network position is required beyond access to the stored hashes [1]. The absence of salt means that even a single compromised hash can be reversed instantly if the password is common.
Impact
Successful cracking reveals the plaintext password, leading to full account compromise. While the CVSS v4.0 score (7.3, High) indicates high confidentiality impact, the attacker gains the ability to impersonate affected users and access sensitive enterprise data managed by the AI assistant [1].
Mitigation
The vulnerability is fixed in MaxKB version 2.9.1, released on 2026-05-26. Users should upgrade immediately. No workaround is available, as the fix implements proper password hashing with salt (e.g., bcrypt or Argon2). Users with existing accounts should change passwords after upgrading to invalidate the old MD5 hashes [1].
AI Insight generated on May 26, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
1Patches
12b3ae6ea0f9cfix: replace unsalted MD5 password hashing with Django PBKDF2 (CWE-327) (#5212)
3 files changed · +66 −16
apps/common/utils/common.py+52 −5 modified@@ -18,6 +18,7 @@ from functools import reduce from typing import List, Dict +from django.contrib.auth.hashers import check_password, make_password from django.core.files.uploadedfile import InMemoryUploadedFile from django.db.models import QuerySet from django.utils.translation import gettext as _ @@ -27,16 +28,62 @@ from ..exception.app_exception import AppApiException +def _legacy_md5_hash(row_password): + """ + Legacy MD5 hashing — used only to detect old hashes during migration. + Do NOT use for new passwords. + """ + md5 = hashlib.md5() + md5.update(row_password.encode()) + return md5.hexdigest() + + def password_encrypt(row_password): """ - 密码 md5加密 + 密码加密(使用 Django PBKDF2) :param row_password: 密码 :return: 加密后密码 """ - md5 = hashlib.md5() # 2,实例化md5() 方法 - md5.update(row_password.encode()) # 3,对字符串的字节类型加密 - result = md5.hexdigest() # 4,加密 - return result + return make_password(row_password) + + +def password_verify(row_password, hashed_password): + """ + 验证密码是否匹配已存储的哈希值。 + 支持透明升级:如果存储的是旧版 MD5 哈希,也能正确验证。 + :param row_password: 明文密码 + :param hashed_password: 数据库中存储的密码哈希 + :return: 是否匹配 + """ + # First try Django's built-in check (PBKDF2, bcrypt, argon2, etc.) + if check_password(row_password, hashed_password): + return True + # Fall back to legacy MD5 comparison for not-yet-migrated hashes + if _is_legacy_md5_hash(hashed_password): + return _legacy_md5_hash(row_password) == hashed_password + return False + + +def _is_legacy_md5_hash(hashed_password): + """ + Detect legacy unsalted MD5 hex-digest hashes (exactly 32 hex chars). + Django password hashes always contain '$' separators. + """ + if hashed_password and len(hashed_password) == 32: + try: + int(hashed_password, 16) + return True + except ValueError: + pass + return False + + +def needs_password_upgrade(hashed_password): + """ + Check if a stored password hash should be upgraded to PBKDF2. + Returns True for legacy MD5 hashes. + """ + return _is_legacy_md5_hash(hashed_password) def group_by(list_source: List, key):
apps/users/serializers/login.py+11 −9 modified@@ -22,7 +22,7 @@ from common.constants.cache_version import Cache_Version from common.database_model_manage.database_model_manage import DatabaseModelManage from common.exception.app_exception import AppApiException -from common.utils.common import password_encrypt, get_random_chars +from common.utils.common import password_encrypt, password_verify, needs_password_upgrade, get_random_chars from common.utils.rsa_util import decrypt from maxkb.const import CONFIG from users.models import User @@ -65,7 +65,7 @@ def record_login_fail(username: str, expire: int = 600): def record_login_fail_lock(username: str, expire: int = 10): """ 使用 cache.incr 保证原子递增,并在不存在时初始化计数器并返回当前值。 - 这里的计数器用于判断是否应当进入“锁定”分支,避免依赖非原子 get -> set 的组合。 + 这里的计数器用于判断是否应当进入"锁定"分支,避免依赖非原子 get -> set 的组合。 """ if not username: return 0 @@ -144,16 +144,18 @@ def login(instance): if LoginSerializer._need_captcha(username, max_attempts): LoginSerializer._validate_captcha(username, captcha) - # 验证用户凭据 - user = User.objects.filter( - username=username, - password=password_encrypt(password) - ).first() + # 验证用户凭据:先按用户名查找,再用 password_verify 验证密码 + user = User.objects.filter(username=username).first() - if not user: + if not user or not password_verify(password, user.password): LoginSerializer._handle_failed_login(username, is_license_valid, failed_attempts, lock_time) raise AppApiException(500, _('The username or password is incorrect')) + # Transparently upgrade legacy MD5 hash to PBKDF2 + if needs_password_upgrade(user.password): + user.password = password_encrypt(password) + user.save(update_fields=['password']) + if not user.is_active: raise AppApiException(1005, _("The user has been disabled, please contact the administrator!")) @@ -213,7 +215,7 @@ def _handle_failed_login(username: str, is_license_valid: bool, failed_attempts: - 使用 record_login_fail / record_login_fail_lock 两个原子 incr 来记录失败; - 不再依赖精确等于 0 的比较来触发锁,而是基于原子计数 >= 阈值来决定进入锁定分支; - 使用 cache.add 原子创建锁键,cache.add 保证只有第一个成功创建者可写入该键; - 其他并发到达的请求若发现计数已到达阈值也应当返回“已锁定”响应,避免出现绕过。 + 其他并发到达的请求若发现计数已到达阈值也应当返回"已锁定"响应,避免出现绕过。 """ # 记录普通失败计数(供验证码触发使用) try:
apps/users/serializers/user.py+3 −2 modified@@ -26,9 +26,10 @@ from common.database_model_manage.database_model_manage import DatabaseModelManage from common.db.search import page_search from common.exception.app_exception import AppApiException -from common.utils.common import valid_license, password_encrypt, get_random_chars +from common.utils.common import valid_license, password_encrypt, password_verify, get_random_chars from common.utils.rsa_util import decrypt from maxkb import settings +from maxkb.const import CONFIG from maxkb.conf import PROJECT_DIR from system_manage.models import SystemSetting, SettingType, AuthTargetType, WorkspaceUserResourcePermission from users.models import User @@ -116,7 +117,7 @@ def profile(user: User, auth: Auth): 'source': user.source, 'role': auth.role_list, 'permissions': auth.permission_list, - 'is_edit_password': user.password == 'd880e722c47a34d8e9fce789fc62389d' if user.source == 'LOCAL' else False, + 'is_edit_password': password_verify(CONFIG.get('DEFAULT_PASSWORD', 'MaxKB@123..'), user.password) if user.source == 'LOCAL' else False, 'language': user.language, 'workspace_list': workspace_list, 'role_name': role_name
Vulnerability mechanics
Synthesis attempt was rejected by the grounding validator. Re-run pending.
References
1News mentions
0No linked articles in our index yet.