
不知道你有没有过这种体验拿到一道 PWN 题先是看到if (v3 10)这种看似严密的长度校验心里一喜觉得稳了然后往下一翻发现传进去的这个长度是个__int16再后面居然又赋值给了int顿时就开始思路混乱。这其实基本就是int_overflow这类题目的典型开局了。int_overflow是 CTF PWN 入门阶段绕不开的一类考点。它和传统的stack overflow不太一样堆溢出可能靠什么fastbin、unsorted bin一路打下去而整数溢出本身通常不是为了直接拿 shell它的核心作用藏在“绕过”上绕过一个看起来写得很严密的长度检查让原本执行不到的栈溢出或堆溢出变得可以执行。简单说它更像是攻击链上负责开门的那把钥匙。这篇文章我会完全站在一个实际操作者的角度把这个知识点掰开讲清楚从 C 语言底层的整数溢出规则讲起到真实题目怎么构造 payload、怎么在 gdb 里验证偏移再到我最常踩的坑和排查方法。如果你是刚接触 PWN 的新手看完这篇文章再回来看题应该能少走不少弯路。1. 从一道签到题说起int_overflow 到底在考什么1.1 初遇 int_overflow它和自己想象的那种溢出不太一样刚入门 PWN 的时候大部分人最先接触的是最基本的栈溢出。那类题目的套路特别直接程序读取你的输入栈上有固定大小的缓冲区你塞多了就直接覆盖返回地址然后跳转到system或者shellcode一气呵成。但int_overflow这类题第一次做的时候很多人会有点懵原因在于它给你设了一个很显眼的“门禁”在拷贝数据到栈缓冲区之前程序明确做了长度检查看起来似乎不让超过缓冲区大小。可你一眼又能猜到这个程序后面大概是有栈溢出的机会的那个长度检查断在中间非常碍事。我印象很深刻的一道模拟题大概长这样char buf[10]; unsigned short len 0; read(0, len, 2); if (len 10) { read(0, buf, len); }乍看之下逻辑完美先读一个两字节长度校验长度不超过 10然后再往 10 字节的缓冲区里读数据。你输入的长度只要大于 10 就会被拦下。可这里藏着猫腻read(0, len, 2)给len赋值len是unsigned short两字节可以表示的范围是 0 到 65535。如果输入一个长度值大于 65535 会怎样实际上不会怎样——因为read只读取两个字节输入原本是字符串的话需要把字符串解释成数字后再进一步缩小为两个字节。但如果是直接发送两个字节的原始数据那 65535 的上限很容易被突破。真正的思路是想办法输入一个“看起来很大”的值既能让len 10为真又让后续真正传递给read的那个长度非常大。这就是整数溢出存在的意义数值在小类型中发生回绕导致检查值和使用值不一致。1.2 题目的核心套路检查绕过怎么办int_overflow题目的灵魂是同一个数值在不同类型的变量之间传递时因为位宽不同、有无符号不同产生了“检查时是一个值使用时是另一个值”的结果。具体到代码里经常出现这样的组合char buf[0x10]; int v5; read(0, buf, 8); v5 *(int *)buf; if (v5 10) { char tmp[10]; strcpy(tmp, buf); }表面上看v5 是个有符号int检查它是否小于等于 10 再往下走。乍一看没有绕过空间啊可是如果数据在被读取的时候经历了截断、类型提升情况就完全变了。再比如int v2; if (v2 0x10) { read(0, buf, v2); }如果v2是unsigned int而程序在表示负数时直接按无符号解析会发生什么比如说你让v2 -1有符号视角下-1肯定小于0x10但无符号视角下它等于0xffffffffread会把缓冲区直接写爆。所以做这类题的时候首要任务不是急着找偏移而是先把程序中每个中间变量的类型吃透。读代码的时候眼光要像编译器一样去追踪一轮类型转换赋值前后分别是什么类型、位宽差多少、符号性是否改变、通过转换后值域变成多少。这条路走通了后面的利用就跟普通栈溢出没区别了。2. 先把 C 语言的整数规则吃透2.1 有符号整数的溢出行为与未定义行为先说有符号整数溢出。C 标准里有符号整数溢出属于未定义行为undefined behavior。啥意思呢就是编译器被允许随便处理有的平台上是回绕有的平台直接拿异常停机有的编译器在优化的时候会假设这种情况不会发生从而把包含溢出的分支直接删掉。我之前就吃过一次亏写过一段代码if (a 1 a)用来判断是否溢出本来以为很聪明结果编译器在开-O2之后直接把这个条件恒判成真因为编译器认为“如果 a 是合法有符号整数a1 永远大于 a 不可能发生溢出”于是就把相关分支优化掉了。这告诉我们一个重要的结论自己在写 C 程序时不要依赖有符号溢出的任何行为但在逆向和 PWN 分析时要根据编译产物汇编看它实际编译成了什么。在实际 CTF 程序中最常见的有符号溢出体现为使用int存储长度然后用乘法或者加法计算缓冲区大小。例如int size input_len 8; char *dst malloc(size);如果输入input_len非常接近INT_MAX比如0x7fffffff加 8 之后就变成了0xffffffff 7实际计算为负数回绕到负值malloc接收到的 size_t 参数被解释成巨大的无符号值分配失败返回 NULL后续就可能直接对 NULL 指针进行写入。这种场景在真实 CVE 里不少见。2.2 无符号整数的回绕与截断无符号整数溢出是“定义良好”的C 标准规定它按 2^N 取模回绕。也就是说unsigned char的范围是 0 到 255如果加 1 变成 256实际存储的值就会变回 0。理解无符号回绕最简单的方式就是想象一个只有几位数字的里程表走满一圈之后清零。int_overflow里最典型的利用是利用无符号数的回绕构造一个“极小值”从而绕过长度检查。比如unsigned int len; read(0, len, 4); if (len 0x10) { strcpy(buf, src); // 不执行直接返回 }假如代码逻辑没有直接read(0, buf, len)而是在别的地方用到了一个减法剪完之后长度反而变得巨大那就又是另一类问题了。更要命的是截断。截断是指把一个宽位宽的整数赋值给窄位宽的变量低位保留高位被丢弃。int是 4 字节short是 2 字节把int赋值给short时直接砍掉高 2 字节。例如int x 0x10010; // 65552 short y x; // y 0x0010也就是 16如果某道题里攻击者可以控制int x程序校验x的高位思路但真正使用y作为长度那么只需要让x等于0x10000 目标长度标准检查就可能骗过。这在真实题目中是超级经典的构造手段。2.3 sizeof 运算的坑再来一个容易忽略的sizeof返回类型是size_t无符号的 8 字节64 位下。在比较的时候如果一边是int另一边是size_t那么int会被隐式转成size_t。一旦你的int是负数转换之后会变成一个极大的无符号值。常见误判代码char buf[64]; int len -1; if (len sizeof(buf)) { // sizeof(buf) 是 64size_t 类型 // 这里永远不会执行因为 -1 转换成无符号是 0xffffffffffffffff }然而在实际漏洞里利用的往往是反过来的效果攻击者控制一个变量值想让它通过和sizeof的比较。比如if (input_len sizeof(buf)) { read(0, buf, input_len); }input_len是intsizeof(buf)是size_t比较时input_len被转成无符号。如果攻击者输入input_len -1-1转成size_t是0xffffffffffffffff显然大于sizeof(buf)这个检查不会通过看起来白想了。但转念一想如果攻击者输入input_len 0呢小于 64 通过检查read只读 0 字节也没多大用。真正有威胁的是代码写成unsigned int input_len; if (input_len - 1 sizeof(buf)) { read(0, buf, input_len); }当input_len 0时0 - 1在无符号下变成0xffffffff但无符号整数和sizeof比较显然大于 64检查不通过。那input_len 0xffffffff呢0xffffffff - 1等于0xfffffffe也大于 64。这个例子说明无符号减法回绕之后是否小于某值取决于具体数值做题时要穷举数据流不能只看一眼就下结论。3. 绕过 strlen 检查的典型攻击面3.1 经典 strlen 绕过手法int_overflow类和strlen相关的题可以说是亲兄弟。很多题目喜欢做这样的逻辑先要求用户输入的一一段字符串必须满足strlen(input) 某个小值然后再把输入拷贝到栈缓冲区。strlen的实现很简单从指针位置开始数一直数到\x00为止。因此想绕过strlen的长度限制关键就一个字提前截断。假设代码这样写char input[0x100]; read(0, input, 0xff); if (strlen(input) 0x10) { strcpy(dest, input); }前面read读了 0xff 个字节但strlen看到第一个\x00就停止了所以我们可以构造前 0x10 个字节是正常数据第 0x11 个字节写\x00后面继续写真正的攻击载荷。这样strlen返回 16通过检查而strcpy会一直复制到它自己的终止条件才停——但这里复制源是input同样遇到\x00就停单纯这样并不能实现溢出。于是题目往往会加一层用read拷贝而不是用strcpy拷贝。比如read(0, input, 0x100); if (strlen(input) 0x10) { strncpy(dest, input, 0x80); }这时strncpy按照指定的长度 0x80 复制但源数据中开头部分的\x00反而不会阻止它继续从源地址读取后面内容——strncpy会把\x00之后的字节也复制过去。于是我们做的 payload 里嵌入\x00可以让strlen误认为是短字符串而strncpy却可以把长内容完整搬到栈上形成溢出。如果需要把strlen的返回值绕过到一个特定的大值还有一个思路是利用strlen的误判如果输入的第一个字节就不是\x00strlen会持续数直到遇到下一个\x00。在利用中可以把\x00放在故意构造的偏移上让strlen数出一个精确的、刚好满足检查条件的长度。3.2 具体场景演示0xffffffff 长度绕过再深入一点看一个经典的手工欺骗unsigned int长度的场景。伪代码大致是unsigned short len; char buf[128]; len read_input_len(); // 输入任意内容程序解析成 unsigned short if ((unsigned short)(len - 1) 0x7f) { read(0, buf, len); }你看检查条件是(unsigned short)(len - 1) 0x7f也就是“长度减一小于等于 127”表面上是防溢出的但注意len是unsigned short截断也在unsigned short上发生。如果我们想让read(0, buf, len)拷贝非常大的长度就需要len本身比较大同时(unsigned short)(len - 1)又必须小于等于 127。取len为 0。0 - 1在unsigned short中等于 655350xffff不满足。取len为 1。1 - 1等于 0满足检查但read只拷 1 字节。取len为 0x10001。0x10001是以int形式存在的但赋值给unsigned short时发生截断变成 1再过len - 1 0满足但read收到的是len参数它会在调用处重新转换为size_t这里要看编译行为。在典型的考试题目中如果read(0, buf, len)的参数len类型就是unsigned short函数调用时会发生整数提升integer promotion变成int或size_t。由于是传入变长参数通常unsigned short会先提升到int如果有符号能表示所有值再转为size_t。于是一旦len被截断为小值实际读取也是小值。这说明想靠“大整数传给 short 再传回 int”实现大长度需要注意截断发生的时间点。真正有效的利用往往是中途存在一次“假截断”或者通过整数运算回绕。再举一个更容易打通的构造unsigned int len; char buf[0x100]; if (len 0x100) { read(0, buf, len); }len是四字节无符号输入 0x00000000检查通过读 0 字节没有意义。但如果前面的代码是len n * 4而n可以从输入构造为极大值那么len回绕成一个极小的数检查通过然而后面的循环可能按n的次数继续拷贝数据这就形成了逻辑性溢出。在具体讲利用之前先记住这样一个结论整数溢出最重要的不是数值本身而是它经过了哪几步、每一步的位宽和符号性如何变化。把数据流理清楚绕过就不难。4. 实战模拟一个函数栈溢出的例题4.1 题目环境与伪代码为了讲清楚完整链路的构造我在这里设计一个模拟题场景完全是虚拟的但逻辑结构和实际比赛中int_overflow题目一样适合拿来一步一步复现。模拟程序功能很简单程序开启标准输入循环。先接收 4 字节作为带符号 int 的登录密钥校验是否为某个固定值。再接收一段字符串送入处理函数。处理函数内部校验字符串长度通过一个char类型局部变量存储截断后的结果然后把数据复制到一个 0x20 字节的栈缓冲区。对应的伪代码void vuln(char *input) { unsigned char v1; char buf[0x20]; v1 strlen(input); if (v1 0x10) { strcpy(buf, input); } } int main() { int key; char input[0x400]; read(0, key, 4); if (key ! 0xdeadbeef) exit(1); read(0, input, 0x400); vuln(input); }现在来分析利用。4.2 一步一步构造 payload第一步定位漏洞点。v1 strlen(input)注意左侧是unsigned char范围 0 到 255。strlen(input)返回size_t8 字节无符号。把 8 字节值截断成 1 字节丢弃高 7 字节。于是strlen(input) 0x110272时v1 0x10检查v1 0x10成立进入strcpy。而strcpy复制的源是input它会一直复制到源串结束的\x00。如果我们构造 272 字节的非零数据再补一个\x00strcpy就会复制 272 字节到 0x20 的栈缓冲区造成覆盖。第二步构建绕过strlen的布局。strlen(input)这里不能提前放\x00否则strlcpy也停了无法造成溢出。我们要让strlen正好数到 272那么源字符串前 272 个字节都不能有\x00并在第 273 字节放\x00收尾。第三步构造栈溢出载荷。目标是把vuln函数的返回地址覆盖为后门函数地址模拟题中为了方便一般都会给一个直接system(/bin/sh)的后门函数或者覆盖为 one_gadget。这需要知道buf到返回地址的偏移。先在本地 gdb 里看vuln的栈布局pwndbg disass vuln通常能看到类似push rbp mov rbp, rsp sub rsp, 0x30buf在rbp-0x20返回地址在rbp8。所以偏移是0x20到 rbp 8到返回地址 0x28也就是 40 字节。 前 40 字节填充任意数据第 41 到 48 字节写上后门函数地址。4.3 栈布局与返回地址偏移计算偏移计算是栈溢出里最基础也最关键的一步。先用 gdb 下一个断点在strcpy调用之后查看buf的地址。gdb-peda$ b *0x400xxx gdb-peda$ run payload gdb-peda$ x/20gx $rbp-0x20看清楚buf首地址再查看$rbp值两者差值就是到rbp的距离。返回地址在$rbp8。所以buf到返回地址 (rbp) - (buf地址) 8。有了偏移payload 结构如下[padding 40 字节] [后门地址 8 字节] [剩余填充直到把 strlen 凑够 272 字节] [\x00]这里有个细节填地址时地址中往往包含\x00字节比如地址0x400xxx的低两个字节可能是\x00。这些\x00会被strlen提前截断导致strlen的返回值变小无法通过检查。解决手段通常是把后门地址挪到strlen计数的最后一个部分或者在前面用无\x00的填充填充到 271 字节然后最后 1 字节放\x00。这样做的话strlen数到 271 时停下strlen(input)返回 271截断成unsigned char后是0x0f即 15小于等于 16依然能通过检查。strcpy复制源时数的是它自己的一套同样遇到\x00停因此只能复制 271 字节。能不能覆盖到返回地址取决于偏移。如果偏移是 40那 271 字节足够覆盖很远完全没问题。这种做法的好处就是不需要在地址部分里避开\x00因为地址部分在最后 8 字节只要它在第 272 字节之前不是\x00strcpy就会正常复制它而strlen可能在第 272 字节就碰到\x00提前停止但这不影响我们的目的——我们只需要strlen返回值落在检查范围内。更稳的办法是把后门地址安排在 272 个字节的中间然后最后一个字节补\x00于是strncpy遇到源末尾\x00照样能复制 272 字节。这里需要控制好strlen的实际返回值确保它截断后仍然小于等于 16。你可以通过精心布局让strlen数到一个自定义的较小值比如 16然后后面继续铺数据最后一个字节是0x00。这样strlen返回 16检查通过strcpy从源一直复制到末尾\x00共复制到 272 字节返回地址完美覆盖。5. 调试与排错经验5.1 gdb 里的关键命令做这类题目调试是逃不掉的。分享几个我常用的调试点位。查看断点处寄存器与栈gdb-peda$ b *0x400xxx gdb-peda$ x/30gx $rsp gdb-peda$ info registers rbp rsp rip查看strlen返回值在vuln函数里执行完strlen到movzx eax, al这种截断指令时先stepi完成截断再看eax值。如果eax为 0x10说明截断成功检查可以通过。验证拷贝后栈内容gdb-peda$ x/20gx buf_addr查看是否已经写入了目标地址。注意 gdb 里地址字节序是小端看的时候心里要翻一下。用 cyclic 生成模式寻找偏移gdb-peda$ pattern create 300跑起来后若发生崩溃pattern search可以直接算出偏移。当年我个人偏好用pwntools的cyclic()和cyclic_find()在题目调试脚本里直接算from pwn import * payload cyclic(0x200) # 崩溃后拿到溢出点的地址 offset cyclic_find(rsp_value_or_crash_addr)5.2 常见报错和应对第一类段错误发生在strcpy内部。原因多半是输入字符串最后没有\x00strcpy一直在往后读直到碰到不可读内存。解决办法是在 payload 末尾加\x00。第二类程序打印“stack smashing detected”触发了 canary 检测。这种情况下vuln函数栈帧里存在 canary直接覆盖返回地址是不可行的。要么程序中存在泄漏 canary 的途径要么找其他攻击面。int_overflow题目不一定都有 canary但如果遇到先考虑另一个漏洞点不要硬刚。第三类明明偏移算对了但运行后 RIP 没跳到目标地址。排查思路是检查地址本身是否被破坏。比如地址里含有\x00而复制函数碰到\x00提前终止或者地址中的字节被某个中间逻辑改写过。还有一种情况是目标地址在 PIE 开启的二进制里函数地址每次运行都会变化。这种情况要么程序里有泄露地址的接口要么在关闭 PIE 的模拟题环境下直接使用固定地址。第四类strlen返回值和预期不一致。有可能是 payload 中无意带了\x00导致长度缩减。排查时用一个简单的 Python 脚本打印payload的字节和长度确认\x00出现的位置是否符合预期。5.3 常见盲区编译器优化影响还有一个容易踩的盲区是编译器优化。同一个漏洞代码gcc -O0下函数栈布局可能是sub rsp, 0x30-O2下可能完全重排甚至vuln函数被内联进main导致原本的栈帧消失。新手做题时经常出现这种情况源码逻辑一样但是用不同版本编译器编译出来的二进制偏移完全不同漏洞利用姿势也随之改变。在真实 PWN 题中出题人一般不会开高优化级别因为那会让漏洞不太稳定。但作为练习建议你也试一下用-O2重编同一份代码观察栈布局和指令序列的变化这样你对“代码到汇编”的转化会更有感觉。调试时推荐用checksec先看一下保护情况checksec ./pwn重点看Canary、PIE、NX、RELRO四项。如果 NX 开启就不能直接执行栈上 shellcode可能需要 ROPRELRO 全开时GOT 表不可写改写 GOT 的思路也会受限。6. int_overflow 的扩展地形6.1 宽度溢出16-bit 截断前面提到的unsigned char截断是 8-bit 截断而更常见的是unsigned short16-bit截断。这类代码在真实漏洞里也经常遇到常见写法uint16_t len (uint16_t)atol(input); if (len 0x100) { read(fd, buf, len); }这里atol返回long如果输入字符串是655360转到uint16_t后可能变成 0检查通过read数据 0 字节没意义。但如果输入字符串能让len截断后是一个比较小的数而read最终接收到的参数又是按size_t提升的需小心提升规则。在 64 位下调用变参函数时16 位无符号数提升成int再转成 64 位。如果len截断后是0xffff即 65535传给read的还是 65535不算巨大。真正巨大的长度一般得配合负号或者无符号回绕。实际利用中最容易出问题的模式是一边用解引用指针读取 2 字节整数作为长度另一边用 4 字节有符号整数做校验之后又把这个 4 字节数值直接传给read。比如uint16_t len; memcpy(len, input, 2); int alen len; // 零扩展alen 不会是负数 if (alen 0 alen 0x10) { read(0, buf, alen); }想绕过这种检查硬走长度是不可能了得结合别的漏洞点比如alen被后面代码当成负数参与运算int size alen - 0x20; char *dst malloc(size); read(0, dst, alen);当alen 0x10时size -16malloc(-16)内部转成无符号的 0xfffffffffffffff0分配失败返回 NULL后面read(0, dst, 0x10)就变成了向 NULL 附近写数据如果 NULL 页被 mmap 了照样可以利用。6.2 有符号比较大的坑符号扩展与零扩展在做逆向分析时最怕的就是把符号扩展和零扩展搞混。movzx零扩展把高位置零。用于unsigned char/short到int/long。movsx符号扩展高位按原符号填充。用于signed char/short到int/long。很多题目会在关键检查处用movzx或movsx。如果你只看了if条件而没看对应的汇编指令就可能把数值大小的判断搞反。举个例子char n; if (n 0) { // 处理负数分支 }如果n是从输入构造的-1n 0为真但后续代码可能执行len -n或者把n当成size_t参与运算符号扩展后会变成巨大值。这类问题在真实漏洞里经常成为突破点。做题的时候一旦发现局部变量是char或short就要特别警惕在赋值、比较、传参三个环节编译器都可能插入movsx或movzx你只有通过objdump或 gdb 确认了指令才能准确预判数值走向。6.3 算术错误带来的逻辑漏洞还有一种常见int_overflow利用不直接攻击栈而是利用算术回绕导致逻辑漏洞。典型代码int total 0; for (int i 0; i n; i) { int sz; sz packet_size(i); if (total sz MAX) { break; } total sz; copy_packet(i, total); }如果total sz在加到某个值的时候溢出了绕过MAX的检查后续的数据就会写出边界。这类逻辑经常出现在“分块处理”的程序里CTF 中有时会伪装成服务端解析器之类。利用方式也简单粗暴输入多个大小总和正好让int回绕的包例如total先到0x7ffffff0再加0x20变成负数或回绕到0x80000010比较时可能小于MAX而被放行然后copy_packet在错误的偏移上写入。做题识别特征源码里有乘法、加法、减法之后的检查检查之后又有写入操作。这种逻辑你要自己画一个简单数值流向图把每个节点的值和位宽都标出来就很容易看出漏洞。7. 我的个人心得与练习路线int_overflow单独拿出来讲是因为它几乎不需要多少堆利用知识非常适合练基本功。我在带别人入门的时候一般建议按这个顺序练习先熟练 C 语言类型转换规则随手写出各种转换的值包括负数转无符号、大 int 截断成 short、unsigned char 回绕。在本地写几个对应漏洞的小程序亲手验证栈偏移和 payload 的成功率。再用 pwntools 完成从连接到远程、发送 payload、获取 shell 的完整流程。最后试着把同类型题目做 5 道以上总结出“看到长度校验先看类型声明、看到 strcpy 先看截断点”的条件反射。另外想做几个提醒。第一永远先检查数据流不要急着找system地址。int_overflow的攻击入口常常藏在函数的参数传递过程中而不是某一个显眼的gets。先用readelf -s ./pwn或者objdump -d把函数调用关系理清楚。第二payload 构造时用 Python 直接以 bytes 为单位拼别用字符串拼接。因为地址里可能有\x00和不可见字符用字符串拼接容易把长度算错。第三养成在本地用 gdb 验证的习惯不要本地还没打通就直接打远程。本地通与否、远程是否通这两件事没有任何可以“赌一把”的空间。最后再说一个我反反复复提醒自己的点int_overflow题目里strlen往往只是其中一环真正可怕的不是这个函数本身而是程序作者以为strlen返回的一定是实际长度——事实上它只数到第一个\x00。理解了这一点很多看似固若金汤的检查就是一层窗户纸。写到这里我还是挺有感慨的。当初我调试第一道int_overflow的时候卡在strlen提前截断的问题上折腾了整整一个下午后来终于搞明白\x00在 C 字符串语义里的地位才把所有环节串联起来。之后再做类似的绕过题目基本上不再发怵了。这条路就是这样很多知识点单独看都不难难点其实是把编译原理、体系结构、程序逻辑三样东西缝在一起。希望这篇文章也能帮你把这一针给缝上。