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里被记录着,内核一并释放掉就可以了。

相关推荐
2601_962294612 天前
AI 基础设施的“去 Python 化“:Rust 与 C# 的两条替代路径
ai·rust·c·基础设施·去python化
2601_962297253 天前
C# vs Java vs Python:YOLO工业部署性能对比实战
java·python·c·工业视觉·性能对比
知无不研5 天前
一个简单的2048小游戏
游戏·c·2048·小项目
k4m7v2pz9 天前
交叉编译 Rust 到 aarch64 Linux 掌机:glibc 版本与 sysroot 重建的完整踩坑记录
rust·glibc·交叉编译·aarch64·zigbuild·sysroot
牢姐与蒯12 天前
Linux进程(三)之进程状态_僵尸孤儿
linux·运维·服务器·ubuntu·c
半点闲19 天前
深入解析小端序:把 4 字节的小端序拼成一个 32 位无符号长整数
c·字节序·大小端字节序·小端字节序转32位无符号长整数
C++ 老炮儿的技术栈23 天前
基于Qt实现轻量化本地音乐播放器
开发语言·c++·qt·c·播放器·音乐
一韦以航.1 个月前
C/C++ 基础深挖:strlen 与 sizeof 全方位对比区别
c语言·jvm·c++·算法·面试·c
Ronin-Lotus1 个月前
蓝桥杯篇---EEPROM内部存储地址
职场和发展·蓝桥杯·c·eeprom·keil
C++ 老炮儿的技术栈1 个月前
海思嵌入式开发笔记|U‑Boot、JFFS2、TSLIB、SQLite 调试常用命令汇总
嵌入式·c·嵌入式linux·tftp·海思·jffs2