Wagtail 2.7.3 安全版本深度解析:CVE-2020-11037 密码保护页面时序攻击漏洞的修复原理与实践
2026/9/14 8:13:21 网站建设 项目流程

Wagtail 2.7.3 安全版本深度解析:CVE-2020-11037 密码保护页面时序攻击漏洞的修复原理与实践

【免费下载链接】wagtailA Django content management system focused on flexibility and user experience项目地址: https://gitcode.com/GitHub_Trending/wa/wagtail

Wagtail 2.7.3(发布于 2020 年 5 月 4 日)是一个针对安全问题的紧急补丁版本,其核心工作是修复 CVE-2020-11037:通过 Wagtail "Privacy"(隐私)控件使用共享密码保护的页面与文档,其密码校验存在潜在时序攻击(timing attack)风险。本文以 2.7.3 发布说明 为主线,结合当前仓库中密码校验、视图限制与相关测试的源码实现,完整还原漏洞成因、攻击面、修复手段与升级注意事项,帮助开发者理解"共享密码保护"机制的正确打开方式。

一、漏洞概述:CVE-2020-11037 是什么

发布说明(docs/releases/2.7.3.rst)明确给出了该漏洞的核心信息:

  • 影响对象:通过 Wagtail "Privacy" 控件以共享密码(shared password)保护的页面(Page)或文档(Document);
  • 漏洞性质:密码校验使用逐字符字符串比较(character-by-character string comparison),攻击者若能以较高精度测量该校验耗时,便可利用时间差异逐步推断出密码内容;
  • 实际可行性:官方评估认为该攻击"在局域网(local network)上可行,但在公网(public internet)上不可行"——因为公网上的网络抖动会淹没纳秒/微秒级的时间差异;
  • 致谢:该问题由 Thibaud Colas 报告。

值得注意的是,这一修复随后被同步回滚到仍在维护的 2.8.x 分支(见 2.8.2 发布说明 与 2.9 发布说明 中的同一条目),意味着所有使用 2.7.x 及更早版本、并启用了密码保护功能的站点都应尽快升级。

二、漏洞根源:密码保护机制与"逐字符比较"的隐患

要理解这个漏洞,需要先搞清楚 Wagtail 中"共享密码保护"从模型到校验的完整链路。

2.1 视图限制模型:Privacy 控件的底层数据

页面和文档的隐私控制统一由视图限制(view restriction)模型承载,其基类定义在 wagtail/models/view_restrictions.py:

class BaseViewRestriction(models.Model): NONE = "none" PASSWORD = "password" GROUPS = "groups" LOGIN = "login" RESTRICTION_CHOICES = ( (NONE, _("Public")), (PASSWORD, _("Private, accessible with a shared password")), (LOGIN, _("Private, accessible to any logged-in users")), (GROUPS, _("Private, accessible to users in specific groups")), ) restriction_type = models.CharField(max_length=20, choices=RESTRICTION_CHOICES) password = models.CharField( verbose_name=_("shared password"), max_length=255, blank=True, help_text=_( "Shared passwords should not be used to protect sensitive content. " "Anyone who has this password will be able to view the content." ), ) groups = models.ManyToManyField(Group, verbose_name=_("groups"), blank=True)

从源码可以确认三个事实:

  1. 密码保护只是 Privacy 控件的四种选项之一(另外三种是 Public、登录用户可见、指定用户组可见);
  2. password是一个最长 255 字符的共享明文密码,即"一把钥匙开多把锁",任何知道该密码的人都能访问受保护内容——这决定了它只能用于轻量级的内容隔离,不能保护敏感数据(模型自身的help_text也明确提示了这一点);
  3. 模型是抽象的(abstract = True),实际由PageViewRestriction(页面)等具体模型继承实现。

2.2 密码校验表单:漏洞所在的代码

漏洞的根源位于密码校验表单 wagtail/forms.py 中的PasswordViewRestrictionForm

class PasswordViewRestrictionForm(forms.Form): password = forms.CharField( label=gettext_lazy("Password"), widget=forms.PasswordInput ) return_url = forms.CharField(widget=forms.HiddenInput) def __init__(self, *args, **kwargs): self.restriction = kwargs.pop("instance") super().__init__(*args, **kwargs) def clean_password(self): data = self.cleaned_data["password"] if not constant_time_compare(data, self.restriction.password): raise forms.ValidationError( _("The password you have entered is not correct. Please try again.") ) return data

在 2.7.3 之前,clean_password使用的是普通字符串相等比较(data != self.restriction.password==)。Python 的字符串比较一旦发现第一个不同的字符就会提前返回,比较消耗的时间与"前多少个字符相同"成正比。攻击者据此可以:

  1. 尝试第一位字符的所有可能取值,记录每次校验的耗时;
  2. 耗时最长的那次,说明该字符与真实密码第一位相同(比较在第二位才失败);
  3. 逐位推进,最终在不依赖字典、不暴力枚举完整密码的情况下,仅靠时间侧信道还原整个密码。

这正是发布说明所描述的"逐字符比较 + 时间差异"的组合攻击。修复后的代码改用 Django 提供的constant_time_compare(来自django.utils.crypto),该函数保证无论比较结果如何,执行时间都恒定,从而从根源上消除时间侧信道。

2.3 校验入口:authenticate_with_password 视图

表单在何处被提交?页面侧的入口是 wagtail/views.py 中的authenticate_with_password视图:

def authenticate_with_password(request, page_view_restriction_id, page_id): restriction = get_object_or_404(PageViewRestriction, id=page_view_restriction_id) page = get_object_or_404(Page, id=page_id).specific if request.method == "POST": form = PasswordViewRestrictionForm(request.POST, instance=restriction) if form.is_valid(): return_url = form.cleaned_data["return_url"] if not url_has_allowed_host_and_scheme( return_url, request.get_host(), request.is_secure() ): return_url = settings.LOGIN_REDIRECT_URL restriction.mark_as_passed(request) return redirect(return_url) else: form = PasswordViewRestrictionForm(instance=restriction) action_url = reverse( "wagtailcore_authenticate_with_password", args=[restriction.id, page.id] ) return page.serve_password_required_response(request, form, action_url)

值得注意的细节:

  • 视图通过url_has_allowed_host_and_schemereturn_url做了开放重定向防护,校验失败时回退到settings.LOGIN_REDIRECT_URL
  • 校验成功后调用restriction.mark_as_passed(request),将限制 ID 写入 session(见 wagtail/models/view_restrictions.py 的mark_as_passed实现),后续访问直接放行;若此前没有 session cookie,还会把 session 设为浏览器会话结束时过期;
  • 密码输入错误的响应仍渲染密码页(测试中"wrong password 应重新显示密码页"的断言见 wagtail/tests/test_page_privacy.py),避免泄露密码是否正确这一信息本身。

文档侧的入口逻辑相同,位于 wagtail/documents/views/serve.py(serve_password_required相关处理),同样复用PasswordViewRestrictionForm,因此页面与文档两类资源共享同一个漏洞与同一处修复

三、访问控制链路:密码保护是如何"挡"在页面前的

了解了校验点,再看整条访问控制链路,可以更完整地评估漏洞的攻击面。页面路由与限制判定围绕 wagtail/models/pages.py 展开:

def get_view_restrictions(self): """ Return a query set of all page view restrictions that apply to this page. This checks the current page and all ancestor pages for page view restrictions. ... """ page_ids_to_check = set() def add_page_to_check_list(page): # If the page is an alias, add the source page to the check list instead if page.alias_of: add_page_to_check_list(page.alias_of) else: page_ids_to_check.add(page.id) # Check current page for view restrictions add_page_to_check_list(self) # Check each ancestor for view restrictions as well for page in self.get_ancestors().only("alias_of"): add_page_to_check_list(page) return PageViewRestriction.objects.filter(page_id__in=page_ids_to_check)

结合 BaseViewRestriction.accept_request 可以还原完整语义:

  • 限制会向下继承get_view_restrictions同时检查当前页及其所有祖先页,因此对父页面设置的密码保护会作用于整个子树(对应测试test_view_restrictions_apply_to_subpages);
  • 别名页沿用源页限制:如果页面是别名(alias),会解析到源页面再查询限制,别名页不能设置自己的限制(对应测试test_view_restrictions_apply_to_aliases);
  • 密码型限制的判定依赖 sessionaccept_request检查passed_view_restrictions_session_key中是否已记录该限制 ID,未通过则返回False,随后框架渲染密码输入页。

Page上还提供了渲染密码页的钩子(wagtail/models/pages.py):

password_required_template = None def serve_password_required_response(self, request, form, action_url): password_required_template = self.password_required_template or getattr( settings, "WAGTAIL_PASSWORD_REQUIRED_TEMPLATE", "wagtailcore/password_required.html", ) ... context = self.get_context(request) context["form"] = form context["action_url"] = action_url return TemplateResponse(request, password_required_template, context)

模板解析顺序为:Page子类上的password_required_template属性(如 wagtail/test/testapp/models.py 中EventPage的自定义模板tests/event_page_password_required.html)→ 全局设置WAGTAIL_PASSWORD_REQUIRED_TEMPLATE→ 默认模板wagtailcore/password_required.html。文档侧对应WAGTAILDOCS_PASSWORD_REQUIRED_TEMPLATEwagtaildocs/password_required.html(见 wagtail/documents/templates/wagtaildocs/password_required.html 与 wagtail/documents/views/serve.py)。

也就是说,攻击者只要访问任一受密码保护的页面,就会进入"提交密码 → 表单clean_password校验"这条路径,而 2.7.3 之前的该校验正是时序攻击的靶点。

四、修复验证:源码与测试如何保证不再回归

4.1 修复落点

2.7.3 的实际修复就是把 wagtail/forms.py 中clean_password的普通比较替换为constant_time_compare

from django.utils.crypto import constant_time_compare if not constant_time_compare(data, self.restriction.password): raise forms.ValidationError(...)

django.utils.crypto.constant_time_compare底层使用hmac.compare_digest实现,而后者在 CPython 中基于恒定时间的 C 实现,不依赖 Python 字符串比较的短路特性。同一模式也用于其他安全敏感的比较,例如 wagtail/images/utils.py 中的校验逻辑(同样导入constant_time_compare),可作为团队在 Wagtail 生态内处理此类问题的参照。

4.2 测试佐证

密码保护的完整行为由测试锁定,见 wagtail/tests/test_page_privacy.py:

  • test_anonymous_user_must_authenticate:匿名用户访问受保护页返回wagtailcore/password_required.html
  • 提交错误密码("wrongpassword")后仍重新渲染密码页,且页面保持在原模板;
  • 提交正确密码("swordfish")后重定向回return_url,并可通过后续请求验证 session 已放行;
  • test_view_restrictions_apply_to_subpages/test_view_restrictions_apply_to_aliases:验证限制的子树继承与别名解析行为;
  • test_password_protected_page_headers:验证密码页的 HTTP 头行为。

文档侧对应的测试位于 wagtail/documents/tests/test_collection_privacy.py,其中还包含自定义模板WAGTAILDOCS_PASSWORD_REQUIRED_TEMPLATE的覆盖测试。这些测试共同保证:修复后,密码校验的外部行为(错误提示、重定向、模板渲染)完全不变,变化的只有内部比较方式的恒定时间复杂度——这正是安全补丁的典型形态:对用户零感知,对攻击者关上门。

五、升级与实践建议

5.1 升级路径

  • 受影响范围:使用 2.7.x 及更早版本、且通过 Privacy 控件启用了"共享密码"保护的站点。未使用密码保护的站点不暴露在该漏洞下;
  • 修复版本:2.7.3(本版本),以及后续同步修复的 2.8.2、2.9 等维护分支;
  • 执行方式:按常规方式升级 Wagtail 依赖即可(例如pip install wagtail==2.7.3),本次修复不涉及数据库迁移或配置变更,无破坏性改动。

5.2 安全使用密码保护的建议

结合源码与发布说明,建议如下:

  1. 不要用共享密码保护敏感内容BaseViewRestriction.password字段的help_text已明确指出"共享密码不应被用于保护敏感内容",因为知道密码即可访问、无法审计到个人;敏感内容应改用"登录用户可见"(LOGIN)或"指定用户组可见"(GROUPS)限制;
  2. 保持版本更新:安全修复通常以点版本(patch release)发布,应关注 docs/releases/index.rst 与各版本发布说明,及时跟进 2.7.3 这类安全补丁;
  3. 理解攻击边界:该时序攻击在局域网场景下才被认为可行,公网环境下网络噪声会掩盖时间差异;但这不构成延后升级的理由——部署在网络内部的 Wagtail 实例(内网 CMS、企业站点)同样在攻击面内;
  4. 如需定制密码页:通过WAGTAIL_PASSWORD_REQUIRED_TEMPLATE(页面)或WAGTAILDOCS_PASSWORD_REQUIRED_TEMPLATE(文档)全局配置,或为特定页面类型设置password_required_template属性,注意自定义模板仍应输出表单与action_url(参考默认模板 wagtail/templates/wagtailcore/password_required.html)。

六、小结

CVE-2020-11037 是 Wagtail 历史上一次典型的"小洞大修":漏洞点只有一行字符串比较,但其背后是完整的共享密码保护链路——BaseViewRestriction模型存储共享密码、PasswordViewRestrictionForm执行校验、authenticate_with_password视图处理提交、Page与文档 serve 逻辑决定渲染与放行。Wagtail 2.7.3 通过引入constant_time_compare消除了比较时间与密码内容的关联,并以 wagtail/tests/test_page_privacy.py 与 wagtail/documents/tests/test_collection_privacy.py 锁定行为,在保持用户体验不变的前提下关闭了时间侧信道。对于任何启用了 Privacy 密码保护的 Wagtail 站点,将版本升级到 2.7.3(或 2.8.2 / 2.9+)是最直接、最稳妥的处置方式。

【免费下载链接】wagtailA Django content management system focused on flexibility and user experience项目地址: https://gitcode.com/GitHub_Trending/wa/wagtail

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询