GlibC 在线程里引发use-after-free退出时才崩溃原因与分析

背景

最近在做Blackberry时遇到了一个错误tcache_thread_shutdown(): unaligned tcache chunk detected,这个错误是发生在多线程情况下没有做好互斥时发生一个指针被free之后再次使用也就是比较经典的use-after-free的错误,触发了Abort,虽然不是什么大问题,很容易修复,但是这引发了我一个比较好奇的事情,就是为什么会这样?为什么free之后继续使用没报错但是线程退出时导致崩溃了?

原理

我使用的是GCC编译器,标准库是Glibc,对于Glibc而言,每次malloc的内存都要比预期申请的要大一点,因为里面要放入一些malloc的元数据,基本上malloc的内存布局是这样的:

bash 复制代码
[ malloc chunk header ][ 用户区 64 bytes ]
                       ^
                       |
                       p

p是返回给我们使用的user地址,每次当我们释放时Glibc并不会把它归还给系统,而是自己维护一个内存chunk把这次的Malloc内存放入一个tcache链表维护起来,这样后续在使用可以从这里拿内存提高分配效率,这里就有一个点,就是如果没有归还给系统的话页表的映射还在当前进程上面,也就是说你free之后依然可以随意访问这块地址,并不会发生什么非法地址访问的情况,下面是一个示例:

c 复制代码
#include <stdio.h>
#include <stdlib.h>

int main(void)
{
    char *p = malloc(64);
    if (!p) {
        return 1;
    }

    free(p);
    p[0] = 0x01;
    p[1] = 0x02;
    
    printf("hello world!\n");
    return 0;
}

此时hello world会正常打印并且也不会出现任何问题,这种做法与我们平时接触到的C语言教学背道而驰,平常教科书上的教学是针对C语言这门语言特性而Glibc是C语言的标准实现库,它有自己的行为,并且在不同系统上也有不同区别,但是这里我们换一种方式用线程来执行:

c 复制代码
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

static void *worker(void *arg)
{
    char *a = malloc(64);
    printf("[worker] a=%p\n", a);
    free(a);
    memset(a, 0x55, 64);
    printf("[worker] corrupted freed chunk, now return\n");
    return NULL;
}

int main(void)
{
    pthread_t tid;
    if (pthread_create(&tid, NULL, worker, NULL) != 0) {
        perror("pthread_create");
        return 1;
    }
    pthread_join(tid, NULL);
    printf("[main] worker joined\n");
    return 0;
}

运行结果:

bash 复制代码
[worker] a=0x79d9f0000b70
[worker] corrupted freed chunk, now return
tcache_thread_shutdown(): unaligned tcache chunk detected
[1]    29787 IOT instruction (core dumped)  ./a.out

可以看到被异常结束了,是Glibc主动调用了Abort函数发送SIGABRT异常信号结束当前进程并且打印了tcache_thread_shutdown(),这个原因是C语言的线程检查行为,对于Main而言它虽然也是一个线程但是Glibc对它行为是它是主线程,结束时Glibc调用exit来回收,它与普通线程行为不同,Main的链路是:

_start

-> __libc_start_main(...)

-> 调用main(argc, argv, envp)

-> main return

-> exit(main_return_value)

-> 执行 atexit/on_exit 回调

-> flush/close stdio

-> glibc 内部清理

-> _exit(status)

-> exit_group 系统调用

-> 内核销毁整个进程

普通线程行为调用的是:

pthread_create()

-> glibc clone创建线程

-> 新线程从glibc 的 start_thread入口开始

-> start_thread 调用你的 thread_func(arg)

-> thread_func return

-> glibc 执行 pthread_exit 收尾逻辑

-> 清理当前线程资源

-> 当前线程结束

注意这里调用的是pthread_exit,而不是系统的exit,这里它会去调用tcache_thread_shutdown来检查chunk有没有被破坏,这点你可以在它的源码里面找到,这里我找的是2.x:malloc/malloc.c的源码:

c 复制代码
static void
tcache_thread_shutdown (void)
{
  ......
    for (i = 0; i < TCACHE_MAX_BINS; ++i) {
        while (tcache_tmp->entries[i]) {
            tcache_entry *e = tcache_tmp->entries[i];

            if (__glibc_unlikely (!aligned_OK (e)))
                malloc_printerr ("tcache_thread_shutdown(): "
                                 "unaligned tcache chunk detected");

            tcache_tmp->entries[i] = REVEAL_PTR (e->next);
            __libc_free (e);
        }
    }
......
}

可以看到它在循环调用aligned_OK来检查tcache里面的指针是否被破坏的,也就是判断是不是Malloc合法边界,这个是看当前CPU位数,64位是16字节,32位是8字节,用MALLOC_ALIGNMENT 宏表示,在不同平台位数上它的内存颗粒不同,它会多分配一些字节一部分用于维护Malloc元数据一部分给用户使用,在64位上,即使你分配没有16个字节,它仍然会将你的分配按16字节对齐,如果tcache记录的地址里面有一块不是按16字节对齐的,那么就会立马出错,它认为当前线程破坏了tcache,导致内存模型出现了问题可能引发异常错误,就会直接abort,在malloc_printerr里面,会调用abort。

因此当我们平时调试的时候不要总是依靠C语言的标准答案,而是去参考具体平台和标准实现细节,例如对着Glibc库源码分析原因。

Main之所以没有做这层检查是因为Main结束整个应用都要结束了,因此没必要在做这些事情了,直接调用exit让系统一并将这个task全部清理掉,因为它所有的内存数据这些都在task_struct里被记录着,内核一并释放掉就可以了。

相关推荐
少司府1 天前
C++基础入门:初识模板
开发语言·c++·c·模板·函数模板·类模板·泛型编程
REDcker1 天前
跨平台编译详解 工具链配置与工程化实践
linux·c++·windows·macos·c·跨平台·编译
小辉同志2 天前
Epoll+线程池
开发语言·c++·c·线程池·epoll
光电笑映4 天前
深入C++异常:栈展开、异常安全与工程规范
开发语言·c++·c
程序员-King.7 天前
【基础分析】—— 条件变量wait(lock, 谓词)
c++·c·多线程·条件变量
REDcker7 天前
C++ std::move实现原理与vector扩容移动语义
开发语言·c++·c
itman3019 天前
C语言、C++与C#深度研究:从底层到现代开发演进全解析
c语言·c++·c·内存管理·编译模型
charlie1145141919 天前
嵌入式现代C++教程实战篇第12篇:C宏时代的LED驱动 —— 能跑但不优雅
c语言·c++·stm32·单片机·嵌入式硬件·c
咸鱼不用加盐10 天前
HC32F460 USB CDC通信异常:非对齐访问异常排查
单片机·arm·c·cm4