十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

CTF Pwn入门:整数安全漏洞原理与实战解题指南

CTF Pwn入门:整数安全漏洞原理与实战解题指南 聊到 pwn 入门很多人第一关就栽在整数安全上。原因很简单这玩意儿表面看是“小学数学”实际上在 C 语言和内存模型的加成下处处都是反直觉的坑。CTFshow 的 Pwn 入门系列应该是不少新手接触的第一套成体系题单100 分段走到 101-110主题正是整数安全Integer Security。这篇文章我想把这十道题背后的东西彻底拆开从整数在内存里怎么存、漏洞为什么会出现到每类题型的标准解题套路再到实际调试中容易踩的坑一次性讲清楚。这套题适合什么人一句话总结已经会写简单栈溢出、能看懂基本反汇编但一见到unsigned int、size_t、类型转换就开始头晕的新手。刷完 101-110你对“整数”这件事会有完全不一样的敏感度后面再看堆题、看 glibc 源码也会轻松不少。1. 先看整体整数安全考什么难点又在哪1.1 “整数安全”不等于“整数溢出”很多人第一次听到“整数安全”会以为这就是溢出攻击。其实范围比溢出大得多。CTF 里常说的整数安全包括了四类问题整数溢出Integer Overflow有符号或无符号整数在加减乘除时超出类型边界发生回绕。整数截断Integer Truncation把一个较大类型窄化为较小类型高位被丢弃。有符号与无符号混用Signed/Unsigned Confusion比较或传参时隐式转换导致检查被绕过。除法与取模的边界问题除零、取模符号、最小值除以 -1 等非典型场景。为什么入门阶段要把它们归成一个专题因为这几类漏洞本身几乎不涉及复杂的利用链通常只需要你理解“整数在内存里是按补码存储、运算会发生回绕”这两条铁律就能直接构造出绕过或改写内存的攻击。它们很适合作为 Pwn 的基础能力训练让你把注意力放在“数据如何被解释”上而不是一开始就陷入 ROP、堆布局这些复杂构件。1.2 为什么这个系列放在栈溢出之前或之后都有道理CTFshow 的 Pwn 入门序列里100 分段前面大概讲的是签到、nc 连接、简单的重定向等热手内容101-110 扎堆出整数安全说明出题人是故意在这个时间点把“类型敏感度”立起来。我之前在带人入门的时候也发现一个规律直接教栈溢出很多人都能照着 exp 模板跑通但一旦题目的漏洞点从strcpy变成read的长度由用户控制八成新手会卡住——因为read的第三个参数是size_t无符号 64 位你输入 -1它会变成一个天文数字。所以整数安全本质上是“更底层的判断力训练”。它要求你不能再照着脚本抄而是真正明白代码里写的是int还是unsigned、比较时有没有隐式转换、赋值时有没有截断这些细节最终会影响内存读写范围。2. 开刷之前工具链、环境与读题姿势2.1 从 CTFshow 题库找到专题入口CTFshow 的题库页面一般按分类展示Pwn 入门分类下会有若干个小节。找到“整数安全”或直接按题号 101-110 搜索即可。每道题部署成一个独立的容器页面会给你一个nc xxx.xxx.xxx.xxx port形式的连接地址。这个设计对新手很友好不用自己搭靶机远程就是一个真实运行的程序。但要注意CTFshow 很多题目的容器带时限比如做题 10-30 分钟会自动关闭所以拿到地址后先把 exp 写清楚再开容器避免浪费激活次数。2.2 本地工具链少一样都难受远程容器只能用来最终验证真正高效的做法是在本地把题目跑一遍动态调试。一个完整的 Pwn 入门环境大致包括Python 3 pwntools写 exp 的主力连接、发送、接收、地址封装都靠它。gdb pwndbg 或 peda动态调试必需pwndbg 在查看栈布局和寄存器方面比原版 gdb 直观太多。IDA 或 Ghidra静态逆向。入门题用 IDA 看伪代码最快Ghidra 免费但对新手信息量略大。checksec查看二进制开启的保护。pwntools 自带的checksec命令就够用。装好之后我建议把checksec和file当成本能动作。拿到一个二进制第一件事就是file pwn101 checksec pwn101file告诉你架构和位数checksec告诉你有没有栈保护Canary、有没有 PIE、NX 开没开。这些信息直接决定你的利用方式。比如这十道题里绝大多数题目为了降低难度可能不会开 PIE甚至不会开 Canary于是你只需要关注栈溢出的偏移和关键变量的相对位置。2.3 关键读题姿势先看“长度”和“类型”面对这类题目我建议有意识地训练自己“只看两样东西”哪个变量是由用户输入决定的这个变量是什么类型它被用在什么位置举例来说如果看到这样的伪代码unsigned int size; char buf[64]; read(0, size, 4); read(0, buf, size);那么你已经找到了漏洞size是unsigned int如果我们输入0xFFFFFFFFread会尝试往buf写入 4 GB 数据直接溢出栈。但更多时候题目会加一个检查if (size 32) exit(0); char buf[32]; read(0, buf, size);这时候就要看size的类型。如果size是int我们输入负值比如-1检查size 32不成立但read的参数要转换成size_t-1 变成0xFFFFFFFFFFFFFFFF照样炸栈。这就是典型的有符号/无符号转换绕过。101-110 里这一类变体重复出现了好几次练到最后你看到“检查 拷贝”的组合就应该本能地先检查类型。3. 核心原理拆解整数漏洞的四大模式与出题变体3.1 模式一有符号/无符号混用绕过长度检查这是整数安全里最常见的一类。C 语言有一条隐式转换规则当有符号类型和无符号类型同时出现在表达式里时有符号类型会被转换为无符号类型。这意味着int n -1; unsigned int m 10; if (n m) { // 不会进入 } if ((unsigned int)n m) { // 会进入因为 -1 转为无符号后等于 4294967295 }在 CTF 里这个“比较陷阱”通常出现在“先检查、后拷贝”的代码里。攻击者输入一个负数让检查失效然后这个负数在传给read、memcpy、strncpy这类函数时被隐式转换成size_t或unsigned int变成巨大无符号数从而造成超长写入。对付这类题你要做的就是在 exp 里手动把 -1 封装成对应位数的字节。pwntools 提供了很顺手的方法# 32 位程序 payload p32(-1) # 等同于 p32(0xFFFFFFFF) # 64 位程序 payload p64(-1) # 等同于 p64(0xFFFFFFFFFFFFFFFF)3.2 模式二整数截断大数变小、高位丢失整数截断发生在宽类型向窄类型赋值时。比如unsigned int a 0x100; unsigned char b a; // b 0x00b只保留低 8 位高位全部丢弃。这类漏洞在出题时经常伪装成“长度可控的拷贝”。典型的伪代码是这样unsigned short size; char buf[16]; size get_user_input(); read(0, buf, size);看起来size最大也就 65535写入 16 字节的buf会导致栈溢出。但很多新手会疑惑这算什么整数漏洞其实真正危险的是中间还藏了一层“转换”。例如从int转成unsigned short时输入负数会产生意外的小数字或大数字。假设size是short类型输入 -1它的内存内容是0xFFFF如果后续被当成unsigned short传给memcpy长度是 65535同样溢出。我在做这一组题目时最大的感受是不要用“常识”判断变量的值要用“位模式”判断。C 语言里类型只是一层解释实际内存里就是那 2 个字节或 4 个字节。你输入一个数最后传到危险函数里的是这个数的位模式被解释成目标类型后的值。3.3 模式三乘法与加法溢出改写关键变量这十道题里也出现了乘法溢出。比如分配或拷贝大小由两个输入相乘得到int len get_int(); int count get_int(); char *dst malloc(len * count); read(0, dst, len * count);如果len * count溢出回绕为一个小正数甚至负数但read的第三个参数会被转换成size_t配合前面说的符号问题可以造成堆溢出或栈溢出。经典场景是malloc(len * count)分配了很小的空间后续写入却使用巨大的len * count或直接使用count作为长度。这个模式在高版本的 glibc pwn 里也很常见体会它的意义不只是过题。这类题在 exp 里的计算就要特别小心Python 的整数是任意精度不会自动溢出。你要手动对自己想要产生的溢出结果进行处理。例如想让两个 32 位数相乘后在 32 位下等于 0x100 这个很小的值那么先找两个大整数使它们的乘积在模2^32意义下等于 0x100。具体可以这样表达import ctypes len_value 0x10001 count_value 0x10001 result (len_value * count_value) 0xFFFFFFFF # 模拟 32 位溢出截断 print(hex(result)) # 输出 0x1这种手动截断在你写复杂一点的多阶段 exp 时几乎必用。3.4 模式四类型提升与符号扩展的隐形坑还有一个万金油知识点C 语言里char、short在参与运算前会先提升为int。这个规则叫整数提升Integer Promotion。大多数人只是背过结论但没意识到它会引发漏洞。例如char a 0xFF; // a 实际为 -1 if (a 255) { // 不会进入 } if ((unsigned char)a 255) { // 会进入 }因为a 255时a先被提升为int值为 -1而 255 是int两者比较为假。这类问题在 CTF 中往往和“数组下标”一起出现一个负数通过类型提升变成了很大的索引值从而读到越界数据。101-110 里有一两道题会在这里设卡需要你仔细看反编译后伪代码里变量被强制转换成了什么类型。我遇到这类题时的经验是不要相信伪代码里显示的类型一定要回到汇编看movzx还是movsx指令。movzx表示无符号扩展movsx是有符号扩展这决定了数值是 0x000000FF 还是 0xFFFFFFFF。4. 题型拆解与通用 exp101-110 的完成姿势4.1 先给一个通用的连接模板不管十道题具体是什么样子解题流程大方向是一致的连上远程程序 → 输入构造数据 → 触发漏洞 → 拿到 shell 或读到 flag。一个贴合这套题的 pwntools 通用模板如下from pwn import * context.arch amd64 context.log_level debug # 远程容器地址按题目填写 r remote(host, port) # 本地调试时可以改成这样 # r process(./pwn101) # 接收提示信息 r.recvuntil(bPlease input something:) # 构造 payload整数溢出点在这里 payload ba * 32 # 覆盖到关键变量之前 payload p32(0xFFFFFFFF) # 关键变量赋值为 -1 payload bb * 8 # 继续覆盖返回地址前的区域 r.sendline(payload) r.interactive()刷题初期我强烈建议暴力把context.log_level debug打开。这样发送和接收的数据都会以十六进制打印出来便于你判断是不是字节序弄反了。4.2 前三题热身级别的“检查绕过 直接读 flag”101-103 的难度梯度其实很温柔大概率是同一套模板反复练程序先读入一个长度做一次不严格的大小检查然后read进固定栈缓冲区。有的题会直接在后门函数里 cat flag有的题则需要你覆盖返回地址到 get_flag 函数。解题步骤可以先这样走checksec看保护确认栈上有没有 canary、PIE 是否开启。用 IDA 或 Ghidra 找危险函数确定检查变量的类型。静态算栈偏移或动态用 pwndbg 的cyclic/cyclic -l确定覆盖返回地址的偏移量。构造 payload关键位置填特殊字节让检查失效。本地通远程验证。这类题的“特殊字节”一般就是p32(-1)或p64(-1)。你不需要真的取一个天文数字填进去用 -1 的位模式就够了。我第一次刷这个系列时最大的误区是试图手工算“溢出后的值”浪费不少时间——实际上你只要把-1的位模式填进去C 语言自然会在比较时按无符号转成最大值。4.3 中间四题截断、乘法溢出开始上台103-107 左右题目开始混合“多个小漏洞点”。比如一个程序既有整数截断又有栈溢出或者先做一次 malloc 大小计算再用用户可控的长度 memcpy。刷到这里你需要习惯使用本地调试来看真实的内存内容。举一个常见的出题方式int size read_int(); char v[16]; if (size 16) { read(0, v, size); }如果size是int你输入 -1size 16成立但read拿到无符号参数后是 18446744073709551615直接打穿栈。这类题本质上和前面一样但写法上会更隐蔽。你要多长个心眼只要一个变量同时被“比较”和“作为长度”立刻警觉。我在这个阶段发现的另一个高频坑是题目里的read可能不是一次读入你想要的所有数据。比如第一段调用read(0, size, 4)第二段调用read(0, buf, size)。因为都在标准输入上你要用send分开发送而不是sendline一把梭。用sendline会多塞一个换行符可能导致第二段read的第一字节变成0x0a打乱布局。正确做法是r.send(p32(-1)) # 第一段给 size payload ba * 0x20 p64(win_addr) r.send(payload) # 第二段给栈数据4.4 最后几题综合利用与题型内卷108-110 会稍微升级比如把整数漏洞藏在循环、数组索引、甚至格式化字符串的宽度参数里。我的经验是先不急着上 exp把伪代码整体读三遍标注出每一个“用户可控变量”和“它的类型”。如果看到snprintf(buf, len, fmt, ...)这类函数也别忽略len的来源。格式化字符串的宽度字段如果来自整数溢出同样可以导致任意栈内存泄漏或写。到了这一步你实际上已经掌握了刷后续题目最重要的能力读代码时先给变量“标类型”。4.5 exp 里最容易翻车的三个地方第一字节序。pwntools 的p64默认是小端。x86/amd64 都是小端机器所以写入时低位在前。如果你要把一个 64 位地址0x400500发送出去应该是p64(0x400500)它会自动转成\x00\x05\x40\x00\x00\x00\x00\x00。新手很容易拿着字符串拼地址直接拼成 ASCII导致地址完全错误。第二发送长度。sendline和send的差别前面提过如果程序用的是read优先用send如果程序用scanf或者gets则用sendline。第三覆盖返回地址前的栈内容可能会影响程序运行。如果你多覆盖了几个字节程序 pop 返回地址后会连带着把后面几个栈数据也 pop 掉运气不好会段错误。所以能用多少就覆盖多少不要贪多。5. 实操中常踩的坑与调试技巧5.1 最常见的问题速查我在带人刷这套题时收集了一个高频问题的清单这里直接整理成表格现象可能原因解决办法本地打不通直接段错误覆盖了非法栈数据或返回地址不对重新计算偏移确认返回地址真实存在去掉多余覆盖recvuntil卡死程序输出和预期不一致或者没开 debug 日志打开context.log_leveldebug观察实际字符串整数溢出后输出了巨大地址Python 不会自动像 C 一样回绕用 0xFFFFFFFF或 0xFFFFFFFFFFFFFFFF手动截断开了 PIE 后地址对不上需要泄漏程序基址检查是否存在可以泄漏内存的题目点或换静态地址利用发送 -1 时变成字符串“-1”直接调用了sendline(b-1)而不是send(p32(-1))确认该位置期望的是数字还是字节字节就用 p32/p64服务器不回复容器可能已关闭或 payload 导致程序提前崩溃重新部署容器逐步调试到崩溃前一步5.2 调试三板斧gdb 里怎么定位“什么时候溢出的”面对这种“检查被绕过”的题目新手最大的困惑是明明知道有整数漏洞但不知道该在哪里下断点。我的习惯是三步走第一步先在调用危险函数处下断点。比如反编译看到read(0, buf, size)就在read的调用处断下。第二步用i r rdi rsi rdx查看三个寄存器rdi通常是文件描述符rsi是缓冲区地址rdx是长度。当你看到rdx是0xffffffffffffffff这种值就说明溢出确认了。第三步单步到read返回后观察栈上的返回地址是否已经被覆盖。用 pwndbg 的search或stack命令查看rsp附近的数据。这套流程对 101-110 基本是通杀。它要比你在头脑里推演十遍都管用因为你能直接看到攻击者的输入是如何落到栈上的。5.3 本机通、远程挂一半是环境问题一半是习惯问题很多新手刷到后期会碰上“本地一把过远程打不了”的玄学现象。排除容器关闭后最常见的两个原因一是 glibc 版本不同。远程容器如果基于 Ubuntu 22.04glibc 版本较高栈偏移、地址布局都可能和本地的 Ubuntu 18.04 不一致。这种情况可以尝试用题目给的常见 libc 包做本地调试或者改用不需要 libc 偏移的利用方式比如直接 ret2win。二是输入里带有换行。远程的交互逻辑可能和本地有一点差别尤其当程序第一段read只读了固定长度时多发的换行会让后续读取错位。建议把本地脚本改成和远程完全一样的交互习惯不要顺手加\n。6. 刷完 101-110 之后一点心得和下一步这十道题做完你获得的最核心能力不是“会写某个 exp”而是建立了对 C 语言类型系统的敏感性。以后再看到unsigned、size_t、各种类型的赋值和转换你会下意识思考“这里会不会被绕过”。这种意识比任何具体漏洞利用技巧都值钱因为堆题里到处都是长度和类型转换格式字符串里也有宽度控制逻辑漏洞更是在和类型检查捉迷藏。我个人刷这套题时印象最深的一点是真正的难点反而不是溢出本身而是“意识到这里有个整数转换”。伪代码一页页翻下来每个变量都老老实实待在自己的位置没有任何显眼提示你只能靠经验和对位模式的感觉去抓它。如果你刚刷完 101-110下一步我建议按这个顺序继续先刷栈溢出的入门扩展题把 ROP 基础补上接着做格式字符串专题感受任意读写再往后就是堆题从 tcache 基础开始。整数安全作为地基已经打牢后面的路会顺畅很多。最后再分享一个小技巧把每道题最后能跑通的 exp 都存下来命名成“题号漏洞模式”比如“104_mul_overflow.py”。过两周回来翻一翻你会发现自己当时写的脚本虽然能过题但很多地方可以优化这种对比往往是进步最快的时刻。
返回列表