文章总结: CVE-2026-43948披露了wger健身管理系统中的高危授权绕过漏洞,因Python中None!=None返回False导致权限守卫静默失效。攻击者利用此漏洞可重置任意gym字段为None的用户密码并直接获取明文密码,实现账户接管。漏洞CVSS评分9.9,影响版本至wger2.6之前。修复建议包括替换比较逻辑为显式处理None值、采用白名单授权策略及避免响应返回敏感信息。 综合评分: 92 文章分类: 漏洞分析,WEB安全,代码审计,实战经验,安全开发
CVE-2026-43948 None != None 为 False:一个让授权守卫静默失效的 Python 陷阱
原创
CVE-SEC CVE-SEC
CVE-SEC
2026年5月13日 09:30 新疆
在小说阅读器读本章
去阅读
None != None 为 False:一个让授权守卫静默失效的 Python 陷阱
引言
在安全代码审计中,最危险的漏洞往往不是复杂的逻辑错误,而是那些看起来”理所当然正确”的代码。CVE-2026-43948 正是这样一个案例——开发者使用了完全符合直觉的 Python 比较运算符,却在特定条件下制造了一个 CVSS 9.9 分的严重授权绕过漏洞,最终导致攻击者可以一步完成任意账户接管,且受害者原密码永久失效。
这个漏洞的核心,是一行在 Python 中求值为 False 的表达式:None != None。
漏洞概述
CVE 编号: CVE-2026-43948
受影响产品: wger,一款基于 Python/Django 构建的开源健身与健身房管理系统,广泛用于健身房会员管理、训练计划制定、营养追踪等场景。
漏洞类型: 授权绕过(Authorization Bypass)导致账户接管(Account Takeover)
CVSS 评分: 9.9(Critical,CVSS 3.x)
修复版本: wger 2.6
披露日期: 2026-05-12
参考: https://github.com/wger-project/wger/security/advisories/GHSA-mhc8-p3jx-84mm
漏洞存在于 wger 的两个视图函数中:reset_user_password 和 gym_permissions_user_edit。这两个视图均依赖对 gym 字段的比较来实施授权控制,但由于使用了 != 运算符比较两个 None 值,导致授权守卫在特定条件下被静默绕过。
技术深度分析
Python 对象比较的语义陷阱
Python 中存在两种相等性比较机制:
==/!=:值相等性比较,调用对象的__eq__方法is/is not:身份比较,判断两个变量是否指向同一个对象
对于 None 这一单例对象,Python 保证整个解释器生命周期内只存在一个 None 实例。因此:
>>> None is None
True
>>> None == None
True
>>> None != None
False
上述三个表达式的结果均符合预期,None != None 确实求值为 False,这在数学逻辑上是正确的。
那么问题出在哪里?
问题不在于 Python 的行为本身,而在于开发者将这个比较结果用于授权守卫时,对”False”的语义理解出现了偏差。
授权逻辑缺陷的本质
wger 的健身房管理模型中,用户和健身房之间存在关联关系。gym 字段用于标识用户所属的健身房。当用户不属于任何健身房时,该字段值为 None。
授权逻辑的设计意图是:持有 gym.manage_gym 权限的管理员只能操作与自己同属一个健身房的用户。用伪代码表示,预期逻辑如下:
# 预期:攻击者的 gym 与目标用户的 gym 不同时,拒绝操作
if attacker.gym != target.gym:
raise PermissionDenied
这段逻辑在正常情况下工作良好。当攻击者属于 gym_A,目标用户属于 gym_B 时,gym_A != gym_B 为 True,权限检查触发,操作被拒绝。
然而,当两者的 gym 字段均为 None 时:
attacker.gym = None
target.gym = None
None != None # 求值为 False
条件为 False,权限检查不触发,操作被放行。
这意味着:任何持有 gym.manage_gym 权限且 gym=None 的攻击者,可以对所有 gym=None 的用户执行受保护操作,而授权守卫对此毫无察觉。
为何这是一个”静默”绕过
“静默”二字至关重要。授权守卫没有抛出异常,没有记录警告,没有返回错误响应——它只是安静地判断”条件不满足,无需拦截”,然后让请求继续执行。从日志和监控的角度看,这与一次合法的授权操作没有任何区别。
这类静默失效(silent failure)是授权漏洞中最难被发现的一类,因为它不会在测试中产生明显的异常行为,只有在特定的边界条件(gym=None)下才会显现。
漏洞代码逻辑还原
基于已公开的漏洞信息,受影响的视图函数授权逻辑可还原如下(以 reset_user_password 为例):
# 存在漏洞的代码逻辑(还原)
def reset_user_password(request, user_pk):
target_user = get_object_or_404(User, pk=user_pk)
# 授权检查:确保操作者与目标用户属于同一健身房
if request.user.userconfig.gym != target_user.userconfig.gym:
raise PermissionDenied
# 生成新密码并重置
new_password = generate_random_password()
target_user.set_password(new_password)
target_user.save()
# 将新明文密码返回至 HTML 响应体
return render(request, 'password_reset_confirm.html', {
'new_password': new_password
})
当 request.user.userconfig.gym 和 target_user.userconfig.gym 均为 None 时,第 5 行的条件求值为 False,PermissionDenied 不被触发,函数继续执行密码重置操作。
尤为严重的是最后一步:新生成的明文密码被直接渲染至 HTML 响应体并返回给攻击者。这意味着攻击者在单次 HTTP 请求中即可完成:
- 绕过授权检查
- 强制重置目标用户密码
- 获取新明文密码
- 使用新密码登录目标账户
受害者的原密码在此过程中被永久覆盖,账户被完全接管,且受害者在未收到任何通知的情况下即被锁定在自己的账户之外。
gym_permissions_user_edit 视图存在类似的授权逻辑缺陷,允许攻击者修改目标用户的权限配置,进一步扩大攻击面。
攻击场景与影响
攻击前提条件
攻击者需满足以下条件:
- 在目标 wger 实例上拥有一个合法账户
- 该账户持有
gym.manage_gym权限 - 该账户的
gym字段为None(即不属于任何具体健身房)
在 wger 的权限模型中,gym.manage_gym 是一个相对常见的管理权限,通常授予健身房管理员。在多租户部署场景下,存在 gym=None 的管理账户并非罕见情况(推测:可能用于平台级管理员或初始化阶段的账户)。
攻击步骤
- 攻击者使用持有
gym.manage_gym且gym=None的账户登录 - 枚举或猜测目标用户的
user_pk(Django 默认使用自增整数主键,可预测) - 向
reset_user_password视图发送包含目标user_pk的请求 - 从 HTML 响应体中提取新明文密码
- 使用目标用户的用户名和新密码登录,完成账户接管
整个攻击过程无需任何漏洞利用工具,仅需标准 HTTP 请求即可完成。
影响范围
- 直接影响: 所有
gym=None的用户账户均可被接管,包括其他管理员账户 - 数据泄露: 明文密码在响应体中暴露,若存在中间人或日志记录,泄露范围进一步扩大
- 持久影响: 受害者原密码被永久覆盖,即使攻击被发现,受害者也需通过其他渠道恢复账户访问
- 权限提升: 通过接管高权限账户,攻击者可进一步获取平台管理权限
检测与应急响应
检测方法
日志审计: 检查 wger 应用日志中 reset_user_password 和 gym_permissions_user_edit 端点的访问记录,重点关注:
- 短时间内对多个不同
user_pk的重置请求 - 来自同一账户的批量操作
- 非工作时间的密码重置操作
数据库审计: 检查用户表中密码哈希的变更记录(如启用了数据库审计日志),识别非用户本人发起的密码变更。
账户异常: 排查近期出现登录失败后立即成功登录的账户,可能指示密码被重置后攻击者登录。
应急响应
- 立即升级: 将 wger 升级至 2.6 版本,这是消除漏洞的根本措施
- 权限审查: 审查所有持有
gym.manage_gym权限且gym=None的账户,评估是否存在可疑账户 - 强制重置: 对所有
gym=None的用户账户强制要求重新设置密码,以应对可能已发生的账户接管 - 会话失效: 使所有活跃会话失效,强制重新认证
- 日志留存: 保留完整的访问日志用于后续取证分析
修复建议
官方修复方向
正确的修复方式是将 != 运算符替换为能够正确处理 None 值的比较逻辑。在 Python/Django 中,推荐的做法是:
# 修复后的授权检查
if request.user.userconfig.gym_id != target_user.userconfig.gym_id:
raise PermissionDenied
使用数据库层面的外键 ID(整数或 None)进行比较,而非 ORM 对象实例。对于两个均为 None 的 gym_id,None != None 仍为 False,但此时需要额外的逻辑明确处理这一边界情况:
# 更严格的修复方式
attacker_gym_id = request.user.userconfig.gym_id
target_gym_id = target_user.userconfig.gym_id
# 明确拒绝 gym=None 的跨用户操作,或要求 gym 必须匹配
if attacker_gym_id is None or attacker_gym_id != target_gym_id:
raise PermissionDenied
或者采用白名单思路,明确定义”允许操作”的条件,而非”拒绝操作”的条件:
# 白名单思路:仅当 gym 非空且匹配时允许
if not (attacker_gym_id is not None and attacker_gym_id == target_gym_id):
raise PermissionDenied
明文密码响应的修复
除授权逻辑外,将明文密码返回至 HTML 响应体本身也是一个独立的安全问题。正确的做法是:
- 密码重置后仅返回操作成功的状态,不返回密码本身
- 通过已验证的邮件或其他带外渠道通知用户新密码,或引导用户自行设置新密码
总结:Python 对象比较的安全编码思考
CVE-2026-43948 给安全开发者提供了一个值得深思的案例。
None != None 求值为 False 在 Python 语言规范中是完全正确的行为,没有任何 bug。问题在于开发者在设计授权逻辑时,没有将 None 作为一个需要特殊处理的边界值加以考虑。
在安全敏感的比较场景中,以下原则值得遵循:
原则一:对 None 保持警惕。 任何用于授权判断的字段,都应明确定义 None 值的语义,并在代码中显式处理这一情况,而非依赖默认的比较行为。
原则二:使用 is/is not 比较 None。 Python 官方风格指南(PEP 8)明确建议使用 is None 和 is not None 来检查 None 值,而非 == None 或 != None。这不仅是风格问题,在某些自定义 __eq__ 的对象上,== None 可能产生意外结果。
原则三:授权逻辑优先使用白名单。 基于”拒绝”条件的授权逻辑(黑名单思路)容易在边界条件下产生静默绕过。基于”允许”条件的白名单思路更为安全,因为任何未被明确允许的情况都会被默认拒绝。
原则四:敏感操作的响应不应包含秘密信息。 即使授权逻辑正确,将明文密码返回至响应体也是独立的安全风险,应在设计阶段即予以排除。
原则五:边界值测试是安全测试的必要组成部分。 对于涉及授权的代码,测试用例应覆盖所有字段为 None、空字符串、零值等边界情况,而非仅测试正常业务流程。
一个字符的差异——!= 与 is not——在特定条件下造就了 CVSS 9.9 分的严重漏洞。这提醒我们,安全编码的细节决定成败,对语言特性的深入理解是写出安全代码的前提。
漏洞信息来源: https://github.com/wger-project/wger/security/advisories/GHSA-mhc8-p3jx-84mm
披露日期: 2026-05-12
修复版本: wger 2.6
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:CVE-SEC CVE-SEC CVE-SEC《CVE-2026-43948 None != None 为 False:一个让授权守卫静默失效的 Python 陷阱》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。










评论