
刚把pwntools跑通的时候我以为PWN入门最难的是“写脚本”本身。直到遇到int_overflow这一类题我才发现自己错了真正的分水岭不是会不会调API而是能不能一眼看穿程序里的“逻辑裂缝”。这类题在CTFShow、攻防世界、BUUCTF上都有近亲版本ctfshow pwn 074就是很典型的一例看起来只是一个普通栈溢出实际却在长度校验环节埋了一个整数溢出的雷。你如果只用肉眼盯着if条件看永远想不通为什么3到8字节的密码能塞进几百字节的payload这种“卡住”的感觉几乎每个PWN新手都经历过。这篇文章我就拿int_overflow这个经典考点讲完整条链路程序怎么设计、漏洞怎么形成、栈布局怎么分析、exp怎么写、本地和远程调试会遇到哪些坑。适合刚学完PWN基础、准备自己动手写第一个像样利用脚本的人。看完你会发现所谓“PWN trick”拆开之后不过是一层窗户纸。1. 为什么int_overflow是PWN入门里绕不过去的一关1.1 一个会“隐身”的漏洞整数溢出到底长什么样先说结论int_overflow这个名字不是随便起的它强调的不是栈溢出本身而是“如何逃过程序的长度检查”。这里面藏着一个常见的C语言隐式类型转换问题——strlen返回的是size_t也就是无符号整数但很多程序会把它的结果塞给一个位数更小的变量比如char或者unsigned __int8。这一步赋值看起来人畜无害实际已经把数据拦腰截断高字节全部丢掉了。打个比方一个数字写成十进制是259写成二进制是1 0000 0011如果你只保留低8位得到的就是0000 0011也就是3。程序本来想检查“你的密码长度在3到8之间”结果看到一个被截断后的长度3就放行了可实际上你的payload已经写入了259个字节。这个过程对攻击者来说是可控的你只要算出合适的总长度让它的低8位落在3到8的区间里就能精准骗过检查。这是典型的“用整数溢出绕过逻辑校验再用栈溢出劫持控制流”的组合拳。有些新手会问那为什么不直接用gets读取超长字符串反正它没有长度限制问题在于程序在校验通过之前根本不会调用strcpy你就算输入再长只要长度检查不满足流程直接走else分支返回地址碰不到。所以这个“8位截断”不是边角料它是整道题的钥匙。1.2 题目结构和考点拆解这类题的二进制结构一般非常简单通常是一个32位小端程序保护很少有NX但没有canary没有PIE。程序从main进入login要求输入用户名接着进入check_passwd要求输入密码。密码通过gets读入一个栈缓冲区然后用strlen计算长度赋值给一个unsigned __int8类型的变量再用这个被截断后的数值做3到8的判断。判断通过后调用strcpy把密码拷贝到另一个目标缓冲区。注意这里的三个关键点第一目标缓冲区可能很小但gets和strcpy都不检查目标大小这给溢出创造了物理条件第二长度变量是8位无符号类型这是绕过的逻辑条件第三返回地址紧跟在缓冲区之后一旦strcpy越界就可以覆盖返回地址把流程劫持到程序里已经存在的后门函数比如一个打印flag的函数。这种后门函数在题目里很常见它一般没有参数直接调用system(cat /flag)之类作用是让你省去构造ROP链的麻烦。所以这道题适合作为“从看writeup到自己写exp”的过渡。它不涉及libc泄露不涉及ROP链拼接只考察两件事你能不能看懂反编译代码里的类型宽度以及你能不能手算出栈偏移。2. 环境准备与工具链从零搭起你的PWN操作台2.1 pwntools核心库安装与验证工欲善其事必先利其器。写PWN exp绕不开pwntools它集成了远程连接、字节打包、渗透测试自动化等一堆功能。在Kali或者Ubuntu上安装很简单用pip装就行但强烈建议用Python 3环境别再用老教程里的Python 2写法了。安装命令如下pip3 install pwntools装完以后在终端里输入python3然后执行from pwn import *如果没报错就说明装好了。常见的问题有两个一是系统里同时存在多个Python版本导致pip装到了别的环境这种请用python3 -m pip install pwntools强制指定二是缺依赖比如libcapstone相关报错这种直接apt install libcapstone-dev就能解决。第一次跑PWN题的人最稳妥的做法是在一个干净的虚拟环境或者虚拟机里装避免和系统自带的包起冲突。启动本地程序用process(./int_overflow)远程连接用remote(host, port)这两个函数会返回一个进程对象之后统一用sendline、recvuntil、interactive这些方法交互。新手经常搞混的一点是本地和远程的交互代码几乎一模一样区别只在于第一行是process还是remote所以调试时先用本地跑通再切远程效率最高。2.2 分析工具三件套checksec、IDA、gdbpwndbg环境里还需要三样分析工具缺一不可。第一是checksec它来自pwntools库用来查看二进制文件开了哪些保护机制。用法是checksec ./int_overflow输出会列出Arch、RELRO、StackCanary、NX、PIE这几项。对于这道题你通常会看到ARCH: i386-32-littleNX enabled其他都是disabled这说明程序没有canary可以直接考虑覆盖返回地址同时栈上不能执行shellcode但我们可以直接跳转到后门函数所以NX不影响利用。第二是反编译工具IDA是首选但如果条件有限用Ghidra或者radare2也可以。这个阶段最重要的不是工具多花哨而是你能不能看到反编译后的C伪代码里变量声明是什么类型。int_overflow这道题如果你用的是Ghidra一样能清楚看到unsigned __int8这个类型。第三是调试器推荐GDB装上pwndbg插件。它会把当前栈布局、寄存器、反汇编信息直接高亮显示比裸GDB舒服太多。安装pwndbg的脚本网上有装好之后进GDB输入checksec也能看到保护信息。调试时最常用到的命令是r运行、c继续、x/50wx $esp查看栈内存、cyclic生成测试字符串这些后面实战部分我会详细说。2.3 第一次跑通本地样例环境准备好了别急着分析先手动跑一下程序观察交互流程。执行./int_overflow程序会先打印---- MUMS, PLEASE GIVE ME FLAG ----然后让你输入用户名再让你输入密码。随便输入一个短密码比如123456大概率会提示密码错误输入123提示密码错误输入abcde这种提示登录成功。这个简单的测试能帮你确认长度检查的阈值也方便你后面确认校验逻辑。如果是打远程题目交互输出可能略有不同但核心流程是一样的。拿到题目后先把这个流程跑通是分析漏洞的第一步。很多新手一上来就急着用脚本打结果payload发出去毫无反应最后才发现连提示语都没对齐。记住一句话交互对不齐后面全是白忙。3. 静态分析定位int_overflow的漏洞点3.1 用checksec和file读保护拿到题目的二进制文件第一步永远不是破解而是看保护。我用checksec ./int_overflow会得到类似下面的输出Arch: i386-32-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x8048000)看到No canary found和No PIE心里就该有底了栈上覆盖返回地址这条路是通的而且所有函数地址都是固定的可以直接写死。再看NX enabled意味着栈区没有执行权限所以别费劲往栈上塞shellcode找程序里现成的后门函数跳转过去才是正解。接着用file int_overflow看一下文件类型确认是32位还是64位。32位程序用p32打包地址64位程序用p64搞反了地址会乱成一团。这道题大部分版本都是32位所以等会写exp的时候用context.arch i386让pwntools自动处理架构相关的细节。3.2 IDA反编译的核心代码还原用IDA打开这个二进制找到check_passwd函数F5反编译之后你看到的代码结构大致是这样的int check_passwd() { char buf[256]; // [esp0h] [ebp-0x100] unsigned __int8 len; // [espFFh] [ebp-0x11] ? char dest[16]; // [esp?] [ebp-?] puts(请输入密码); gets(buf); len strlen(buf); if ( len 3 len 8 ) { puts(登录成功); strcpy(dest, buf); } else { puts(密码错误); return 0; } return 0; }这段代码就是整个挑战的心脏。gets(buf)把输入直接读到栈上的256字节缓冲区完全没有限制长度。strlen(buf)算出字符串长度然后赋值给len问题就出在这个unsigned __int8 len上——它是一个8位无符号整数最大值只有255。如果strlen的返回值超过255高位的比特会被丢弃只保留低8位。而后面if ( len 3 len 8 )判断的就是这个被截断后的值。还要注意的是strcpy(dest, buf)把buf中的内容复制到dest这两个缓冲区在栈上是连续的一旦源字符串长度超过目标缓冲区大小就会直接覆盖到栈上的其他变量一路烧到保存的EBP和返回地址。程序里有一个后门函数通常是flag或者get_flag它内部直接调用system(cat /flag)我们的目标就是让程序跳转到这个函数。3.3 栈布局与临界点计算现在我们来算一算payload到底要覆盖到哪里才能精准改写返回地址。从反编译代码的栈偏移可以知道buf起始位置距离返回地址是0x100字节也就是说从缓冲区开头数到返回地址一共是256字节。如果你在缓冲区里写上256字节的填充再写4字节的地址那这4字节就会正好落在返回地址的位置。计算过程不长但新手经常在这里翻车。把payload拆开看前256字节填充数据把缓冲区到返回地址之间的空间全部填满接下来4字节后门函数地址所以payload的总长度是260字节。回头看长度检查strlen会返回什么260的低8位是4因为260 0x104低8位是0x04恰好落在3到8的合法区间里。检查通过strcpy开始执行栈被破坏返回地址被我们覆盖程序顺利跳转到后门函数。这不是运气而是设计出来的只要payload总长度取256的模后落在3到8就能同时满足两个条件既骗过检查又到达返回地址。这里还要补一点如果某些变体的缓冲区到返回地址距离不是0x100比如是0x104或者0xFC计算方法完全一样无非是让总长度模256后的值落在3到8区间。拿到题目后先看反编译代码的栈偏移再算总长度不要硬套别人的exp数字。4. 整数溢出原理绕过长度校验的关键4.1 strlen的返回值与变量截断机制很多人学C语言的时候从来没注意过strlen的返回类型。在32位程序里strlen返回size_t这是一个32位无符号整数范围是0到4294967295。unsigned __int8却是8位无符号整数范围只有0到255。把一个32位数赋值给8位数编译器会直接砍掉高24位只保留低8位这是C语言隐式类型转换的规则不是编译器报错也不是未定义行为它会“安静”地执行。回到题目len strlen(buf)这条语句C语义上是把strlen的返回值截断到低8位再存进len。所以当strlen(buf)返回260时len得到的值是4当返回259时len得到3当返回264时len得到8。只要是260到264这五个数低8位分别是4、3、5、6、7、8都能满足后面len 3 len 8的判断。这就是“整数溢出”的全部秘密。这不是什么高深的技术但它提醒了我们一件事在逆向分析中变量的“声明类型”和“实际字节宽度”同样重要。一个反编译代码里的类型声明往往就是漏洞的提示。你在IDA里看到unsigned __int8就该条件反射地想到截断、溢出、绕过这一类攻击面。4.2 为什么长度落在3到8就能绕过我们用一个表格把输入长度、截断后的值、检查结果、实际效果串起来这样一目了然实际输入长度截断后的len长度检查实际执行效果3~83~8通过能过检查但无法覆盖返回地址9~2559~255失败直接走else无法利用2560失败长度变成0直接GG2571失败长度小于32582失败长度小于32593通过字符串很长可尝试覆盖2604通过典型利用长度覆盖返回地址2615通过也可以覆盖但细节要调2648通过边界值注意最高位额度2659失败低8位超出8注意看256这个值它有整整256个字节但截断后长度是0等于什么都没输入检查直接失败。所以不是输入越长越好而是要控制总长度让低8位落在3到8的窗口里。这也是为什么有些人在本地测试随手发一个ba * 300结果程序只回一句“密码错误”就是因为300的低8位是44明显不在范围内。这道题的核心逻辑就是允许你输入一个“看起来不符合长度要求”的字符串但由于截断它“看起来”又符合了要求。这种“看起来”比“实际”更重要的感觉就是PWN里很多逻辑洞的共通点。4.3 关于有符号与无符号的延伸坑这道题用的是无符号8位截断但我在其他题目里还遇到过有符号版本。有符号8位的范围是-128到127如果strlen返回的值低8位是0x84也就是132如果变量类型是char它会变成-124。这时候如果你的判断条件是len 3 len 128那-124肯定不满足但如果判断条件是len 8 || len 100那-124也能混过去。所以分析时一定要看清楚变量的类型是有符号还是无符号这直接决定了payload长度怎么选。还有一些老题目喜欢用atoi把字符串转成长度再用这个长度去read或者memcpy。atoi遇到负数、溢出、超大数时的行为完全不同利用思路又变了一茬。这里不展开但想提醒你int_overflow教会你的不只是“8位截断”而是“类型宽度和比较逻辑不匹配”这个更底层的漏洞模式换一套壳核心还是一样的。5. 编写exp从偏移计算到拿下flag5.1 确定返回地址覆盖位置先用工具确认后门函数的位置。在IDA里看函数窗口找到类似flag、get_flag、what_is_this这样的函数。如果手头没有IDA用objdump -t int_overflow | grep flag或者nm int_overflow | grep flag也能找到。假设我们找到的地址是0x08048649这个值会写进payload的最后4个字节。然后确定偏移。上面我们算出来栈上缓冲区到返回地址的偏移是0x100也就是256字节。那么payload就是payload ba * 0x100 p32(flag_addr)这句话的意思是先用0x100个a把缓冲区一直铺到返回地址的门槛前然后用p32(flag_addr)放4字节的地址正好落在返回地址上。这里p32是pwntools的打包函数会把一个32位整数按照小端序转成4字节比如0x08048649会变成b\x49\x86\x04\x08。有的新手会问为什么不直接填0x101个a再放地址因为偏移是256你多写一个字节就会把返回地址的第一个字节提前覆盖掉地址就错位了。一切以反编译代码里的栈偏移为准不要靠猜。5.2 完整exp脚本本地调试时脚本可以写得很简洁from pwn import * context.arch i386 context.log_level info # 本地调试不指定flag路径时程序可能读当前目录的flag p process(./int_overflow) # 远程打题时改成 # p remote(node.example.com, 30000) # 注意远程不同的libc和flag路径可能导致行为不同 e ELF(./int_overflow) flag_addr e.symbols[flag] # 如果没有符号直接写 0x08048649 p.sendlineafter(b请输入你的用户名, badmin) p.recvuntil(b请输入密码) payload ba * 0x100 p32(flag_addr) # payload总长度 0x100 4 260低8位 4正好满足3~8 p.sendline(payload) p.interactive()这段脚本有几个细节值得说。第一sendlineafter会先接收指定的提示符再发送内容能避免你盲目sleep等待输出稳定性高很多。第二p32(flag_addr)之前我特意说了context.arch i386这样pwntools打包时按32位处理不会出幺蛾子。第三p.interactive()把控制权交给你这样能看到程序打印flag的完整输出如果你只想要原始结果也可以用p.recvall()。5.3 测试运行与结果分析本地运行时先确保当前目录下有flag文件否则后门函数执行cat /flag或者cat flag会报文件不存在。你可以在本地创建一个测试文件echo flag{this_is_a_test} flag。然后运行exp正常情况下会看到程序输出登录成功然后打印flag内容。如果只输出密码错误先检查payload总长度。你可以用len(payload)打印出来然后用hex(len(payload))看低8位是否落在3到8区间。之前我见过有人把偏移算成0x104结果总长度变成264低8位是8也能过但如果再手滑多写4字节变成268低8位是12直接失败。这种“差一点”的糊涂账调试的时候最坑建议第一件事就是验算长度。远程打题时把process换成remote然后注意后门函数里读flag的路径。有些题目写的是cat flag要求你当前目录有flag有些写的是cat /flag要求系统根目录有flag。这个差异对利用本身没有影响但会影响你在本地复现时能不能看到输出。6. 调试与常见问题新手最容易翻车的几个瞬间6.1 本地通远程挂flag路径和文件权限新手最崩溃的瞬间往往是本地exp一发入魂换成远程地址以后程序确实输出了“登录成功”但后面什么都没有或者直接报错退出。这一般不是利用链的问题而是环境不一致。首当其冲的是flag路径后门函数可能写死cat flag远程的flag却放在/flag你打过去当然看不到输出。可以在本地自己试两种路径或者直接看反编译代码里system的参数字符串确定远程环境更可能是哪一种。还有一个隐蔽坑flag文件权限。如果题目环境里flag文件权限是600而程序是以普通用户身份运行的即便调用cat /flag也读不到内容。这种问题本地很难复现因为你自己创建的flag通常是可读的。遇到远程无输出先用ls -l /flag看看权限或者用id看看当前用户再对症下药。对这类挑战来说不要慌先用pwntools连上去手动执行几条命令能节省很多时间。6.2 p32和sendline地址字节里的坑p32打包时如果地址本身就带有0x0a这种字节就会踩到gets和sendline的红线。gets遇到换行符会结束读取你payload里一旦出现\x0a程序只读到一半后面全被截断sendline函数又会在尾部自动加一个换行如果地址字节和这个换行撞在一起情况会更乱。所以写exp之前先print(p32(flag_addr).hex())看一眼如果里面有0a就麻烦了可能需要换一个跳板地址或者用send代替sendline但后者会留下换行没有发送交互上要小心。另一个常见坑是忘记context.arch i386。有些符号函数的地址在32位下用p32但如果你忘了设置archpwntools默认走64位p32会报错或者打出8字节的地址与栈上4字节的返回地址完全错位。我见过有人在整个exp写完后所有逻辑都对就是死活打不通最后发现是少了一行context.arch。这种低级错误写代码时顺手加一行就能避免。6.3 gdb下断点与交叉验证如果exp还是打不通那就别瞎猜上调试器。在GDB里运行程序用gdb ./int_overflow然后设置断点在strcpy调用处或者ret指令处。比如先disas check_passwd找出strcpy的地址然后b *地址运行到断点处用x/40wx $esp查看栈上的数据肉眼确认填充字符串和地址排列是否正确。更高效的办法是用pwntools的循环模式。先用一个很长的cyclic字符串当payload发进去程序崩溃后用gdb里的cyclic -l检查崩溃地址对应的偏移。这个偏移就是缓冲区到返回地址的真实距离用这个值替换你算出来的0x100然后重新生成payload。虽然这道题的反编译代码来看偏移很清楚但养成用cyclic验证的习惯对你后面打更复杂的栈题非常有帮助。调试时稳住心态一次改一个变量不要“改三处然后看结果”那样排查效率极低。7. 从int_overflow到更深的PWN世界你的技能树长出了哪几根分支7.1 整数溢出在真实漏洞中的变体int_overflow只是整数溢出这个家族里最简单的一个分支。真实世界里整数溢出还常见于“整数符号问题”“整数宽度问题”“整数运算溢出”三类。比如一个程序用int接收用户输入的长度然后用这个长度作为memcpy的第三个参数如果用户输入一个负数int会把它解释成负数但memcpy的第三个参数是无符号数负数的二进制表示会被当成一个很大的正数结果就是直接复制几百MB数据这就是典型的memcpy长度绕过漏洞。再看“整数宽度问题”就是这道题展示的一个函数返回size_t赋值给unsigned __int8或者unsigned short高位数直接丢失。这在低级语言和二进制逆向中非常常见尤其在解析网络协议、文件格式、图片头部字段的时候。程序员写uint8_t len strlen(input)这种代码脑子里想的是“输入长度不会太长”但攻击者只需要构造一个超长输入就能绕过校验。理解了这些你就知道为什么PWN圈子里常说“漏洞往往长在类型转换和边界判断交界的缝里”。int_overflow这道题就是让你在模拟环境里把这个“缝”看明白后面遇到真实漏洞你能条件反射地把目光投向那些赋值语句。7.2 后续题型衔接栈迁移、ret2libc、格式化字符串拿下int_overflow之后你的PWN技能树大致会长出这么几根分支。第一是更纯粹的栈溢出利用比如ret2libc需要你先泄露libc基址再构造ROP链调用system这要求你理解GOT、PLT、延迟绑定这些动态链接机制。第二是栈迁移当溢出字节数不够覆盖完整ROP链时你可以把栈指针迁移到可控的堆或全局区这道题的payload长度受8位检查限制但掌握了栈迁移之后你可以在更苛刻的长度限制下完成利用。第三是格式化字符串漏洞它和整数溢出是两条完全不同的思路核心是利用printf的参数解析读取或改写内存。还有一个值得研究的方向是“绕过长度检查的通用思路”。int_overflow是通过整数截断绕过的但也有题目通过字符串截断、负索引、Off-By-One等方式绕过。你可以在CTF平台上专门找“长度检查类”的题集把每一种绕过方式都刷一遍这个主题刷完你再看到任何if (len 32)类似的代码都会下意识去问这个len到底是什么类型从哪来的能不能被我控制这种“下意识”就是PWN手和普通做题者的本质区别。我现在做CTF辅导的时候经常跟新人说不要把int_overflow当一道题刷完就丢它是一道值得反复咀嚼的“母题”。你从它身上学到的类型分析习惯、栈偏移计算、payload设计、调试排错方法会跟着你一路用到后面的堆利用和内核利用。这道题真正宝贵的地方不是那个flag而是你在这道题上建立起来的整套分析思维。先把这个思维练扎实后面的路会越走越宽。