CTF pwnネタです。古いやつ。
数年前に気づいたんですが、急に思い出して、なんとなく記事にしてみることにしました。古い話だし全然CTF最近してないので、今どれくらい通じる話か知りません。最近はカーネル問が流行ってそうだからだいぶ役に立たなさそう。
↑というところまで書いた下書き記事がはてなブログに保存されてから、また数年経ちました。雑に完成させて公開するぞするぞ。
前提知識
one gadget
CTF pwn文脈において、まず「one gadget」という概念があります。簡単にまとめておきます。
「RIPをとる」(x86ならEIP)(より一般にはProgram Counter)という感覚はもうpwn界において十分知られていると思いますが、RIPが取れた後に、「シェルをとるにはどうするか」ということに頭を悩ますことがあります。そこでのひとつのテクニックがone gadgetです。かなり容易に満たされ得る条件(e.g. スタックトップから少し下がNULLである、RCXがNULLである)だけの制限で、RIPを「特定のアドレス」(そこから始まる一連のロジックをone gadgetと呼びます)にできさえすれば、シェルが立ち上がってくる、というものです*1。
david942j/one_gadgetが、特定のlibc (バージョンの違いを含め)に対してone gadgetを探すのに使えるツールです。
本編
one gadgetのしくみ
one gadgetは、ぱっと見かなり魔法なのですが、どうやって動いているのでしょうか。
まず、通常のshellcodeが何をしているかを思い出しましょう。min-caml pwnのときもお世話になったshell-storm #827なんかもそうですが、結局、"/bin/sh"を第1引数に、第2, 第3引数をうまいことargv, envpっぽくして、execve syscallを発行する、みたいな感じです。
RIPをとったあとは、これを実行するべく、shellcodeをrwx領域に書き込んで実行したり、"/bin/sh"だけどうにかしてsystem関数へret2libcしたり、ROPをがちゃがちゃやったりするわけです。
one gadgetは、この動作を勝手にやってくれるアドレスを見つける、という行為です。そんなに運がいい場所がたまたまあるのかというと、まあ、よく考えると少なくともsystem関数の中のフローを解析して、全機械語単位からの動作をシミュレーションしてみれば、ありそうな気はします。少なくともsystem関数の実装のために"/bin/sh\0"という文字列はglibcに埋まっています。
system関数の先頭アドレスがone gadgetとなれないのは、第一引数の制限が厳しいからです。system関数は、第一引数を、["/bin/sh", "-c"]に続けて挿入し、execve syscallを発行する、というような挙動をします。ですからsystemへの第一引数(x86ならstack top、x64ならrdi)はNULLでも空文字列へのポインタでもだめで、最低"sh"へのポインタとかでないとだめなわけです*3。
david942j/one_gadgetが見つけてくるone gadgetは、だいたい、続く処理が最終的にexecve(path="/bin/sh", argv=NULL, envp=NULL)みたいなのをexecするための、開始地点とそのときの制約条件を出力します。
この辺でargvがNULLであるかNULLへのポインタを指しているかなどを調べています:
https://github.com/david942j/one_gadget/blob/6dc634daba06792badd5260d02395780f2eaed5c/lib/one_gadget/fetchers/base.rb#L97-L113
この辺にx64の簡易エミュレータを持っています:
https://github.com/david942j/one_gadget/blob/6dc634daba06792badd5260d02395780f2eaed5c/lib/one_gadget/emulators/x86.rb#L37
ざっとRubyコードを読んだ感じ、たぶん、objdump -Dの結果からexecをgrepしてきて、その直前30行をとってきて、それぞれの行から簡易エミュレータを動かしてみてexecの行まで達したときに、argvやenvpがNULLになるみたいな条件を満たすための制約を出す、みたいな実装っぽいです。
/bin/shに-c含めコマンドラインオプションが一切渡されなかったとき、単純にシェルが起動するので、CTF pwnの「シェルを取る」が達成できたね、めでたし、になります。
BusyBoxの仕組み、あるいはシングルバイナリの仕組み
一旦one gadgetのことは忘れて、BusyBoxの仕組みについて考えましょう。
BusyBoxの何が非自明かというと、シングルバイナリがどうやって動いているかです。上でbusybox ls -l
をするとls -lが動くと書いたけれど、そんなbusyboxを動かすときに毎回busyboxって打ってますか?普通、busyoxを使うときは、busyboxバイナリのコピーを大量に/binに生やすはずです。シンボリックリンクでもハードリンクでもファイルコピーでもいいですが。
$ docker run --rm busybox sh -c 'sha1sum /bin/*' | head 16ce3cb4f2c5fe3f12de55cb7262a839292563ac /bin/[ 16ce3cb4f2c5fe3f12de55cb7262a839292563ac /bin/[[ 16ce3cb4f2c5fe3f12de55cb7262a839292563ac /bin/acpid 16ce3cb4f2c5fe3f12de55cb7262a839292563ac /bin/add-shell 16ce3cb4f2c5fe3f12de55cb7262a839292563ac /bin/addgroup 16ce3cb4f2c5fe3f12de55cb7262a839292563ac /bin/adduser 16ce3cb4f2c5fe3f12de55cb7262a839292563ac /bin/adjtimex 16ce3cb4f2c5fe3f12de55cb7262a839292563ac /bin/ar 16ce3cb4f2c5fe3f12de55cb7262a839292563ac /bin/arch 16ce3cb4f2c5fe3f12de55cb7262a839292563ac /bin/arp
全部おんなじファイルですね。まあこれハードリンクなんですけど。
docker run --rm busybox sh -c 'stat /bin/arp /bin/acpid' File: /bin/arp Size: 1025504 Blocks: 2008 IO Block: 4096 regular file Device: 38h/56d Inode: 2120300 Links: 401 Access: (0755/-rwxr-xr-x) Uid: ( 0/ root) Gid: ( 0/ root) Access: 2022-11-17 20:00:00.000000000 +0000 Modify: 2022-11-17 20:00:00.000000000 +0000 Change: 2022-12-17 01:54:57.807497313 +0000 File: /bin/acpid Size: 1025504 Blocks: 2008 IO Block: 4096 regular file Device: 38h/56d Inode: 2120300 Links: 401 Access: (0755/-rwxr-xr-x) Uid: ( 0/ root) Gid: ( 0/ root) Access: 2022-11-17 20:00:00.000000000 +0000 Modify: 2022-11-17 20:00:00.000000000 +0000 Change: 2022-12-17 01:54:57.807497313 +0000
じゃあ全く同じバイナリを/bin/arpと起動したときと/bin/acpidと起動されたときとで挙動を変えなければならなくて、どうやるの?という話になります。
これは典型的なテクで、argv[0]を使うというやつです。
シェルにコマンドを渡して実行するとき、普通起動バイナリのパスを一番最初に置くと思いますが、どこの慣習なのか仕様なのか標準なのかは知らないんですがargv[0]もそれに対応して実行可能ファイルのパスになっていがちです。
busyboxではこのargv[0]を利用してどういう起動のされ方をされたかを調べて、それをapplet名として期待された機能を果たしています。コードはたぶんこのへん: https://github.com/mirror/busybox/blob/02ca56564628de474f7a59dbdf3a1a8711b5bee7/libbb/appletlib.c#L1107-L1128
BusyBoxでone_gadgetは動かない。
ということでタイトル回収です。
one_gadgetは基本的にexecve("/bin/sh", argv=NULL, envp=NULL)のような点を探します。このときはargv[0]は存在しません。というかargvがNULLなのでアクセス違反です。
BusyBoxはargv[0]でappletの判断を行います。
よって、/bin/shがBusyBoxであったとき、one_gadgetは動きません。 ←結論
ただ、実は(CTFにおいて)あまりこれが問題になることはなかったりします。
そのひとつはたぶん、「one_gadgetはたいてい、glibcにしか効かない。BusyBoxのような極限環境の場合、あんまりglibcが動いてることはない。muslとかが多いがち。」
BusyBoxの利用者のひとつとして、記事タイトルの通り、Alpine Linuxがあり、Alpine Linuxでone gadgetが動かなかったので謎に思って100年前に調査した結果の記事なのでした。
*1:ぼくは「うさぎ小屋」 https://kmyk.github.io/blog/blog/2016/09/16/one-gadget-rce-ubuntu-1604/あたりで初めてノリを知りました。
*2:busyboxを持ってない状態でbinを消し飛ばすと... wgetもcurlもなくてどうしようね
*3:-cに続くargumentがなければコマンドラインオプションパースエラーだし、空文字列なら無を実行してすぐ戻ってきてしまいます。
*4:ちなみに、Linuxくんどうやってるのかあんまり知らないですが、psの表示名を書き換えるためにargv[0]を書き換えるというテクというのも存在しますね: https://higepon.hatenablog.com/entry/20050706/1120646217