Midnight Sun CTF 2024 の決勝戦に参加してきましたので、参加記として現地の様子と出題された問題の一部Writeupを紹介します。
はじめに
Midnight Sun CTF はチーム HackingForSoju によって運営されている大会です。 毎年予選はオンラインで開催され、上位チームはスウェーデンの首都ストックホルムで開催される決勝戦へ招待されます。 予選本戦共にJeopardyと呼ばれる形式で、ジャンルごとに出題されるクイズのようなものです。 今年は予選から決勝戦までの間隔が2ヶ月足らずと短かったこともあり、査証の発給が間に合わないことが直前に発覚し、来られなくなってしまったチームもあったようです。
今回私は、チーム TokyoWesterns の一員として決勝戦に参加してきました。 実は当該大会が立ち上がった2018年の初回も現地に赴いていましたので、6年ぶり2回目のストックホルム訪問です。
久方ぶりの訪問ということで、前回訪れた際に購入したSL Cardと呼ばれる公共交通機関用のカード(日本のSuica等にあたるもの)を折角なので持参しました。 しかし、なんと数年前に丸ごとカードが刷新されたようで利用も交換すらもできず、少し悲しい思いをしました。 残高消失です。
大会の様子
会場は、ストックホルム中央駅から通りを挟んですぐ目の前という好立地です。 ロゴが "ス" に見えて仕方ありませんでした。
よくあるCTFの決勝大会では、大きなホールのような会場に各チームごとのテーブルが用意されていて、そこで競技に臨むことが一般的でしょう。 今大会では各チームにはそれぞれ鍵付きの個室が割り当てられ、大きなディスプレイや自由に書き込めるボードなどが用意されており、非常に集中しやすい環境でした。 (写真は撤収直前の様子なので、机上が多少散らかっています)
大会日程は、現地時間の正午から翌日正午までの丸々24時間でした。 その間の行動に特段の制限は無いので、夜も通しで問題に取り組むこととなりました。 夜は宿に戻って仮眠をとっていたチームもあったそうです。
最終スコアは下記画像の通りです。 我々は出題された19問を全問完答し、1位を獲得することが出来ました。
出題された問題の紹介
本大会で出題された問題の一部を、技術的背景も交えながら紹介します。
私は Pwnable と呼ばれるジャンルの問題に取り組みました。 このジャンルでは主に、低レベルで動作するプログラムの脆弱性を利用したExploit(攻撃コード)を作成し、任意の動作を起こさせたり権限の昇格を狙ったりすることが求められます。
今回出題された Pwnable の問題は次の通りです。
- babypac (arm64e, Mach-O, iOS User)
- diaperpac (arm64e, Mach-O, iOS User)
- HFSPoolParty (x86_64, PE, Windows Kernel)
- speedpwn (x86_64, ELF, Linux User)
ここでは、iPhoneで動作するプログラムの問題である babypac と diaperpac を取り上げます。 これらの問題に取り組むにあたり、ポインタ認証に関する知識が求められました。
ポインタ認証コード (Pointer Authentication Code; PAC) とは
ポインタ認証1はARMv8.3-A から導入されたセキュリティ機構で、ポインタの改竄を検知するための仕組みです。 ポインタ値に対する署名及び認証を、ハードウェアレベルで実現します。
プロセスの仮想アドレス空間は64bitの全ては使われず、上位ビットは未使用となっています。 ポインタ認証では、ポインタのこの未使用のビットにポインタ認証コード(PAC)と呼ばれる値を格納します。 iOS や MacOS では仮想アドレスサイズは47bitですので、47:54 bit および 56:63 bit の計16bitが利用されます。 ただし、Memory Tagging 拡張機能(MTE)が有効である場合は 56:63 bit はタグ付けに利用されるため、PACの格納には利用できなくなります。
PAC はポインタ値、64bitのコンテキスト、128bitの鍵から生成されます。
修飾子としてのコンテキストには、スタックポインタなどが利用されます。
鍵は用途別に5つ用意されており、命令アドレス用のAPIAKey
とAPIBKey
、データアドレス用のAPDAKey
とAPDBKey
、汎用のAPGAKey
が存在します。
MacOSではAP{I,D}BKey
はプロセスごとに異なりますが、AP{I,D,G}AKey
はシステムを再起動しない限り同じ値が利用されているようです。
これらの鍵は EL1
のシステムレジスタに保持されており、EL0
のユーザプロセスから参照や変更を加えることはできません。
このような設計のため、PACの予測や偽造は非常に困難になっています。 正しくないPACを付加されたポインタを認証しようとすると、例外が発生しプロセスは終了します。 ポインタ認証を利用することで、Return-oriented programming (ROP) や Jump-oriented programming (JOP) に挙げられるコード再利用攻撃が難しくなります。
署名(PAC*
命令)
PACを算出し、PACを付加したポインタをレジスタに格納します。
署名の処理は、下記の15種類の PAC*
命令によって行われます。
- 命令アドレス用:
PACI{A,B}{,1716,SP,Z}
,PACIZ{A,B}
- データアドレス用:
PACD{,Z}{A,B}
- 汎用:
PACGA
利用される鍵は命令によってそれぞれ異なります。
ポインタや修飾子を格納したレジスタの指定は、オペランドとして与えるものや元から決まっているものがあります。
PACIBSP
を例として挙げると、この命令ではリンクレジスタx30(lr)
に格納されたアドレスに対して、スタックポインタsp
と鍵APIBKey
を利用してPACを付加します。
同様の処理は、PACIB x30,sp
でも実現することが出来ます。
認証(AUT*
命令, RETA*
命令)
PACが付加されたポインタに対して認証を行います。 PACが合致した場合は付与前の元のポインタが求まり、不一致であった場合は無効なポインタが生成され例外が発生します。
認証の処理は、下記の14種類の AUT*
命令によって行われます。
- 命令アドレス用:
AUTI{A,B}{,1716,SP,Z}
,AUTIZ{A,B}
- データアドレス用:
AUTD{,Z}{A,B}
リンクレジスタ、若しくは例外リンクレジスタのポインタに対して認証を行い、そのアドレスにリターンを行う命令も存在します。
- リターン:
{,E}RETA{A,B}
利用される修飾子はスタックポインタです。 これらのリターン命令で認証後に生成されたポインタは、レジスタには書き戻されません。
検証環境
大会期間中は Corellium という仮想 iPhone のシェルにアクセスできる環境が用意されており、その中でExploitの作成と試行を繰り返していました。 しかしながら、本記事執筆の時点に於いてはその環境が存在しないため、MacBook を利用して環境を再現し、検証を行うこととしました。
機器
Model Name: MacBook Air
Model Identifier: Mac14,2
Chip: Apple M2
System Version: macOS 14.3.1 (23D60)
Kernel Version: Darwin 23.3.0
PACを利用するための arm64e ABI
は iPhone XS 以降および Apple silicon を積んでいる MacBook で利用できます。
iOS ではデフォルトで有効とされているものの、MacBook ではまだ開発者向けのプレビュー機能という立ち位置のようですので、arm64e
を手動で有効化する必要があります。
また、システム保護領域外に存在する arm64e
バイナリはシステム整合性保護により実行が制限されるため、下記手順で制限を排します。
-
「システム整合性保護(System Integrity Protection; SIP)」を無効化 システムをリカバリーモードで起動し、ターミナルで下記コマンドを実行
# csrutil disable
-
arm64e
を有効化 下記コマンドでブート引数に-arm64e_preview_abi
を追加し、再起動$ sudo nvram boot-args=-arm64e_preview_abi
バイナリ生成
コンパイラには clang
を利用しました。
Apple clang version 15.0.0 (clang-1500.3.9.4)
Target: arm64-apple-darwin23.3.0 / arm64e-apple-darwin23.3.0
PAC 利用の有無は、ブランチ保護に関する挙動を決定する -mbranch-protection
オプション2で指定することが可能です。
これらのオプションは +
記号によって併記することができます。
オプション | 役割 |
---|---|
none | 全てのブランチ保護を無効 |
standard | 標準のブランチ保護を有効(bti+pac-ret と同等) |
bti | BTI (Branch Target Identification) を有効にする |
pac-ret | 非リーフ関数に対して Key A を利用したPACを有効にする |
leaf | リーフ関数(LRをスタックに退避しない)でもPACを有効にする |
b-key | Key A の代わりに Key B を利用する |
生成バイナリのターゲットアーキテクチャは、-arch
オプションで arm64
若しくは arm64e
を指定します。
今回の問題では iPhone で動作する arm64e
バイナリが提供されたため、環境を出題時のものに近づけるようにしました。
なお、arm64
の実行バイナリでも 鍵 APIBKey
を利用した署名や認証の命令は正常に動作するため、本問の検証自体は可能です。
ただしコンパイル時に出力される命令列は、arm64e
向けと arm64
向けとで多少の差異がありました。
例えば前者では PACIBSP
となっていた箇所が、後者では PACIB x30,sp
となっていました。
APIAKey
や APD{A,B}Key
を利用した命令はarm64
で実行してもPACが付加されず、nop と同等の動作となるようです。
認証命令も同様で、縦えポインタやPACが利用する範囲のビットを改竄していたとしても例外は発生しません。
babypac: ポインタ変数の保護
5つのコマンドが用意されており、それらを繰り返し指定して実行が可能なプログラムです。 各コマンド名と引数、それら機能はそれぞれ次の通りです。
- do_work <len>
- 局所変数のバッファ(64 byte)に標準入力から len byte 読み込む
- スタックベースバッファオーバーフローが発生
- eat_feelings
- 局所変数のバッファ(64 byte)に標準入力から 68 byte 読み込む
- 隣接する、出力長(64)を納めた局所変数を破壊
- スタックから任意長のデータをリークさせられる
- find_meaning
- 未実装
- drink_soup <off>
- コマンドバッファの引数文字列 off の位置を起点に off byte オフセットした位置に、読み込んだ文字数を1byteで格納 [0,0xff]
- get_money
- "flag.txt" を開いて中身を出力
コマンド文字列を比較し、その機能の関数を呼び出す部分の疑似コードは以下の通りです。 分かりやすさのため、IDA Pro によるデコンパイル結果に手を加えています。
for ( i = 0LL; i != v10; ++i ) {
if ( cmd[i] == 32 ) {
cmd[(unsigned int)i] = 0;
arg = &cmd[(unsigned int)(i + 1)];
}
}
if ( !strcmp(func_list[0].name /* do_work */, cmd) )
v13 = 0LL;
else if ( !strcmp(func_list[1].name /* eat_feelings */, cmd) )
v13 = 1LL;
else if ( !strcmp(func_list[2].name /* find_meaning */, cmd) )
v13 = 2LL;
else if ( !strcmp(func_list[3].name /* drink_soup */, cmd) )
v13 = 3LL;
else if ( !strcmp(func_list[4].name /* get_money */, cmd) )
v13 = 4LL;
else {
ctf_writef(fd1, "Command not found: %s\n", cmd);
goto LABEL_38;
}
v15 = &func_list[v13];
func_cmd = AUTIB(v15->func, X23);
v15->func = func_cmd;
func_cmd(arg);
v15->func = PACIB(v15->func, X23);
一見すると、"get_money" コマンドを実行すれば良いだけに思われます。
しかし、実はfunc_list[4].func
にはPACが付加されていない生のget_money()
のアドレスが格納されています。
つまりこの状態で "get_money" コマンドを選択したとしても、AUTIB
命令で例外が発生してしまうため呼び出すことが出来ません。
解法
func_list[v13].func
は呼び出し直前でPACが外され、関数から戻った直後に再度PACが付加されている点に着目します。
なんらかの手段で PACIB
前のfunc_list[v13].func
を改竄することができれば、任意のアドレス値に対して鍵APIBKey
と修飾子x23
を利用してPACを付加することが可能となります。
コマンドの中には、スタックベースバッファオーバーフローなどで上位のスタックフレーム内のデータを破壊することが可能なものが存在しています。
コマンド文字列を保持する配列 cmd
と、コマンド名と関数ポインタの構造体リストfunc_list
は下記の位置関係にあります。
char cmd[256]; // [xsp+50h] [xbp-1B0h] BYREF
struct funcs {
char *name;
void (__fastcall *func)(char *);
} func_list[5]; // [xsp+150h] [xbp-B0h] BYREF
また、各コマンド関数のオフセットは次の通りです。 PIEおよびASLRという機構が有効であるため、実際のアドレスはページ(0x1000 byte)単位でランダマイズされていることに注意が必要です。
関数 | オフセット |
---|---|
do_work() | 0x1000047e4 |
eat_feelings() | 0x1000048a0 |
find_meaning() | 0x1000049dc |
drink_soup() | 0x100004a40 |
get_money() | 0x100004adc |
get_money()
とアドレスの差異が最下位の1byteで収まっている関数はdrink_soup()
のみです。
このポインタ変数を狙うのであれば、縦えASLRが有効であってもブルートフォース(総当たり)
を要することなく正確なアドレスに改竄が可能です。
drink_soup()
は次のコードのような動作をします。
引数文字列の先頭を起点に、任意のオフセット位置に 1byte だけ値を書き込むことが可能です。
ターゲットとして、条件が揃っているこのコマンドを利用することにします。
__int64 __fastcall drink_soup(char *arg) {
__int64 n = 0; // x21
__int64 off; // x0
char c; // [xsp+Fh] [xbp-21h] BYREF
ctf_writef(fd1, "Not as tasty as bleach\n");
if ( fd0 >= 0 )
while ( read(fd0, &c, 1uLL) >= 1 && c != 10 )
if ( ++n == 256 ) {
n = 0;
break;
}
off = atoi(arg);
arg[(int)off] = n;
return off;
}
ターゲットまでのオフセットは(void*)func_list[3].func - ((void*)cmd + strlen("drink_soup "))
を計算して301と求まります。
書き込む値はget_money()
のアドレスの最下位バイト0xdcです。
drink_soup()
でポインタの改竄を終えてから返ると、func_list[3].func
にはPACが付加されたget_money()
のアドレスが格納されます。
その後に再度コマンド "drink_soup" を指定して実行することで、get_money()
が呼び出されフラグを得ることができます。
diaperpac: リターンアドレスの保護
fork-server 型として動作する echo プログラムです。
プログラム自身がポートを開いて待ち受け、接続が来たらsys_fork
で自身を複製し、複製後のプロセスがサービスを提供します。
メインのサービスを提供する child_main()
の疑似コードを示します。
この他にも、"flag.txt" を開いて中身を出力する関数 flag()
が用意されていますが、ここでは割愛します。
分かりやすさのため、IDA Pro によるデコンパイル結果に手を加えています。
__int64_t child_main(int fd_in) {
unsigned int read_pos; // w25
ssize_t read_n; // x0
int fd_out; // w20
size_t outstr_len; // x21
__uint64_t write_pos; // x8
ssize_t write_n; // x0
char buf[0x100]; // [xsp+0h] [xbp-140h] BYREF
global_fd = fd_in ?: 1;
memset(buf, 0, sizeof(buf));
if ( (fd_in & 0x80000000) != 0 )
return 0LL;
read_pos = 0;
while ( 1 ) {
read_n = read(fd_in, buf + read_pos, 0x400 - read_pos);
if ( read_n > 0 ) {
read_pos += read_n;
if ( read_pos < 0x400 )
continue;
}
else if ( !read_pos )
return 0LL;
fd_out = global_fd;
outstr_len = strlen(buf);
read_pos = 0;
if ( (global_fd & 0x80000000) != 0 || !outstr_len)
continue;
write_pos = 0LL;
do {
write_n = write(fd_out, buf + write_pos, outstr_len - write_pos);
write_pos += (unsigned int)write_n;
}
while ( write_n >= 1 && outstr_len > write_pos );
read_pos = 0;
}
}
このプログラムは 0x400 byte の文字列を受け取り、その内容を送り返します。
入力文字数が 0x400 byte 未満であったとしても、fd_in
が途中で切断されるなどして read()
が失敗した場合にはその時点までの入力文字列を送り返します。
脆弱性は以下の通りです。
- 0x100 byte しか用意されていない局所変数配列
buf
に、最大 0x400 byte 入力できる- Stack Smashing Protector が無効にされている
- スタックベースバッファオーバーフローを引き起こすことが可能
- 入力文字列をNUL文字終端していない
- 返送する文字列長は
strlen()
で決定している- スタック上に残されたデータを流出させることが可能
フラグを表示させる関数flag()
が用意されていることから、このプログラムの攻略は容易なように思われます。
スタック上データの流出を用いてバイナリのベースアドレスを求め、flag()
のオフセットを加えることで関数のアドレスは判明します。
通常であれば、リターンアドレスに該当する x30(lr)
レジスタのバックアップが保存されているスタック上の領域を上書きするだけで、関数から返る際に目的の関数を呼び出すことが出来ます。
しかし、今回の問題ではPACによってこのリターンアドレスは保護されています。
child_main()
の関数プロローグとエピローグからコードを一部を示します。
__text:00000001000047AC PACIBSP
__text:00000001000047B0 SUB SP, SP, #0x150
(snip)
__text:00000001000047C4 STP X29, X30, [SP,#0x140+var_s0]
(snip)
__text:0000000100004898 LDP X29, X30, [SP,#0x140+var_s0]
(snip)
__text:00000001000048AC ADD SP, SP, #0x150
__text:00000001000048B0 RETAB
関数プロローグでは PACIBSP
でリンクレジスタの値にPACを付加し、その後スタックにストアしています。
関数エピローグでは、スタックからリンクレジスタにバックアップ値をロードし、RETAB
でPACを検証してリターンしています。
正しいPACを付加していない状態のアドレスを格納したとしても、RETAB
命令で例外が発生してしまうため flag()
を呼び出すことはできません。
解法
RETAB
で検証されるPACの値に影響を与える要素は、x30(lr)
に格納された対象のアドレスと修飾子として利用されるスタックポインタ sp
、 鍵 APIBKey
の3つです。
この問題では、プロセスが fork-server 型で動作している点が重要になります。
接続が行われる度に親プロセスから sys_fork
が行われるということは、即ち本来ならランダマイズされているはずのアドレス空間などが毎度全て同一であるということを意味します。
sys_fork
後の子プロセスも、それ以前にスタックに置かれたPAC付きのリターンアドレスを用いて正常に検証とリターンが行われなければならない点から、鍵も同一の値が引き継がれます。
以上の事より、PACの値に影響を与える3要素は何度接続をし直しても全て同一であるということが判ります。
iOS および MacOS では、PAC は 16 bit 空間に収まります。 つまり、生成されるPACは高々 0x10000 (65536) 通りに過ぎないのです。
PACを0から順にインクリメントしながら flag()
関数のアドレスに付与し、繰り返し RETAB
を突破できるか否かを試行します。
誤った値で例外が発生してプロセスが終了してしまったとしても、もう一度接続をし直せば再試行ができます。
すると、いずれ合致したPACで認証を通過して、flag()
に実行が遷移します。
ネットワーク越しにブルートフォースを行うため、突破にはそれなりの時間を要します。
大会期間中には、問題が動作するサーバと物理的に近いと思われる地域にあるサーバをチームメイトがレンタルし、スクリプトを3時間程度回し続けました。
もしブルートフォースの最中に親プロセスが再起動してしまうと、これまでの試行は全て水泡に帰し始めからやり直さなくてはならないのですが、幸いそのような事態は起こりませんでした。
結果としてflag()
に遷移させ、フラグを得ることが出来ました。
おわりに
スウェーデン、とても綺麗
イッテルビー村3にて