资讯动态

glibc线程arena劫持实现任意地址分配

发布时间:2026/10/15 1:20:08 来源:尧图企业网站定制
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会失败并abort0x18last_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_main240的地址是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.04glibc 2.31上跑malloc直接在arena_get2里报corrupted size vs. prev_size然后exit。所以第一步必须搞定环境下载Ubuntu 18.04.6 LTS Server ISO官方镜像md5:e1015308b84251ac7a05e1b45ecb4291虚拟机配置2GB内存、20GB磁盘、关闭KSM避免内存去重干扰堆布局安装后执行sudo apt update sudo apt install -y build-essential gdb python3-pip pip3 install pwntools ropper # 下载对应libchttps://libc.blukat.me/?qglibc%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读入的数据若包含\x00strcpy会在那里截断导致buf后半段残留旧数据。而buf上方不远处恰好存着thread_arena的TLS指针。我用pwndbg在strcpy后下断点x/gx $rsp0x200发现0x7fffffffe2a0: 0x00007ffff7dd4040—— 这正是thread_arena的当前值而buf起始在$rsp0x100溢出8字节刚好覆盖到$rsp0x108离thread_arena还有0x2a0-0x1080x198≈408字节。所以需要精心构造payload让第408字节是\x00前面填满A这样strcpy截断后thread_arena位置就被A覆盖成了0x4141414141414141。但我们的目标不是覆盖成A而是覆盖成伪造arena的地址。所以payload结构是[0x100字节填充][0x198-0x1000xf8字节 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必须为0fake_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 0x602090fake_arena p64(0x602090) # toplast_remainder0x18偏移设为0fake_arena p64(0) # last_remainderbins数组0x20偏移必须对齐到8字节且前几个fastbin要可控。fastbin[0]对应size0x20的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字节。加上前面0x2032字节整个malloc_state要2080字节。但我只分配了0x10004096字节足够。最终fake_arena长度3220482080字节完美。注意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_main240) 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**)($rsp0x200)确实看到0x7fffffffd290。完美。4.2 步骤二堆喷射与伪造arena部署现在要往0x7ffff7dd5010写入2080字节的fake_arena。但read只能读0x108字节怎么办用malloc分配堆块再用read把fake_arena写进去。题目允许多次malloc/free所以我malloc(0x1000)→ 得到heap_addr 0x7ffff7dd5010read(0, heap_addr, 0x1000)→ 把fake_arenapayload写入确保heap_addr地址不含\x000x7ffff7dd5010符合这里有个隐藏坑malloc(0x1000)分配的chunk其prev_size和size字段会被malloc写入。所以fake_arena不能从heap_addr开始而要从heap_addr 0x10开始跳过chunk头。因此thread_arena最终要指向heap_addr 0x10 0x7ffff7dd5020。修正后的fake_arena起始地址0x7ffff7dd5020thread_arena覆写值p64(0x7ffff7dd5020)4.3 步骤三劫持thread_arena——8字节精准覆盖构造最终payload# 填充到thread_arena位置 payload bA * 0x100 # buf[0x100] payload bB * (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设为systemplt地址再把0x6020a8win_ptr8设为/bin/sh地址。首先systemplt地址 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_addrmalloc(0x10)→ 返回0x6020b0但我要的是0x6020a8所以用read直接往0x6020a8写p64(0x602100)触发调用win_ptr()即call system(/bin/sh)。5. 常见问题与排查技巧实录5.1 问题速查表从崩溃日志反推故障点现象可能原因排查命令解决方案malloc(): memory corruptiontop字段指向不可写页或非法地址p/x *(char**)($rdi-0x100x10)检查top值是否在可写段readelf -S binary | grep bssAborted (core dumped)mutex或flags非零触发__libc_lock_lockp/x *(int*)($rdi-0x10)和p/x *(int*)($rdi-0x100x8)确保前8字节全\x00malloc(): unaligned fastbin chunk detectedbins[0]未8字节对齐或指向非法地址p/x *(long**)($rdi-0x100x20)bins[0]必须是8字节对齐地址且*(bins[0]-0x8)应为合法sizeSegmentation fault在malloc内thread_arena地址计算错误指向不可读内存x/2gx $rdi-0x10用vmmap确认$rdi-0x10所在页权限malloc返回NULLtop-size太小无法满足请求p/x *(long*)($rdi-0x100x10)top-size至少要比请求size大0x10chunk头实操心得我曾因top-size设为0x100而malloc(0x200)失败。malloc要求top-size request_size 0x10所以top-size至少设为0x210。但设太大也不行——malloc会尝试mmap新页而伪造arena没实现sysmalloc逻辑。稳妥做法top-size 0x10004KB够用且安全。5.2 独家避坑技巧3个让成功率翻倍的细节技巧1用mprotect提前加固伪造arena页即使fake_arena在堆上malloc内部也会检查top地址是否在arena-system_mem范围内。glibc 2.27中system_mem字段malloc_state0x40默认为0导致检查失败。解决方案把system_mem设为一个超大值比如0x10000000256MB。这样top地址只要小于它就通过。fake_arena中加入fake_arena b\x00*0x30 # 到system_mem偏移 fake_arena p64(0x10000000) # system_mem技巧2fastbin[0]不要直接指向目标而指向一个“跳板”chunk直接设fastbin[0] 0x6020a0malloc会检查0x6020a0是否在当前arena管理范围内通过chunk_is_mmapped等宏。绕过方法在.bss段伪造一个合法chunk头// 在0x602090放size0x20, fd0x6020a0 // 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发现size0x20再取fd0x6020a0返回——完美绕过检查。技巧3劫持后立即malloc(0x10)测试别等最后很多选手写完全部payload才运行结果失败还不知哪步错。正确做法每完成一个子步骤如泄露、堆喷、覆写立刻malloc(0x10)看返回值。用gdb的catch syscall brk监控系统调用若看到brk被调用说明mallocfallback到了sysmalloc意味着top失效——立刻回头检查top字段。5.3 调试工具链gdbpwndbggef三件套实战配置单靠gdb原生命令效率太低。我用的组合是pwndbgvmmap看内存布局heap看堆状态telescope看指针链gefsearch-pattern找gadgetdereference自动解引用自定义脚本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或mprotectRELRO (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 学习路径建议如何系统掌握此类底层利用如果你被这篇内容点燃了兴趣我建议按这个顺序深挖第一周精读malloc/malloc.c中_int_malloc、sysmalloc、arena_get2三段源码用gdb单步跟踪malloc(0x10)全过程画出内存布局图。第二周在Ubuntu 18.04上复现3个经典arena攻击house_of_einherjar伪造top、house_of_force篡改top size、n1ctf2018_null劫持thread_arena对比它们的触发条件和适用场景。第三周尝试在glibc 2.31上改造此攻击。你会发现arena_get2增加了check_heap校验但thread_arena仍可被覆写——只是需要额外伪造arena-next和arena-system_mem。这是真正的进阶挑战。最后分享一个小技巧每次调试前先在/etc/default/grub中添加mitigationsoff并update-grub关闭所有内核级缓解措施。不是为了偷懒而是为了看清漏洞本质——就像医生做手术前要先剥离所有纱布才能看清病灶本体。当你能在无防护环境下稳定复现再一层层加回ASLR、Stack Canary、NX观察每层防护如何改变崩溃现象这才是真正的内功修炼。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑