
1. 为什么二进制安全入门绕不开C语言我见过太多人一上来就问怎么学Pwn怎么学二进制安全结果三天打鱼两天晒网多半是卡在一个点上不懂内存。你说栈溢出、堆溢出、格式化字符串名字都认识但真正到了现场gdb一开看到一堆地址、寄存器、十六进制数据脑子就空了。其实不用慌。二进制安全这个方向看着门槛高但最核心的底层地基就是C语言。为什么因为C语言是少数几门让你直接面对内存的语言。指针、数组、结构体、malloc/free每一个操作都在跟内存地址打交道。你写的那行代码编译完就变成对某个地址的读写编译器不会拦你CPU也不会嫌你越界越界这件事在C语言里天然就存在。Pwn题目之所以选C语言写漏洞程序就是因为这种靠近硬件的语言天生就带着内存层面的毛病。另外一个现实原因CTF竞赛里叫Pwn的方向考察的就是发现漏洞、利用漏洞拿到系统权限的能力而绝大多数Pwn题都是用C/C写的基本可以认为Pwn入门学C语言基本功理解程序是怎么被加载进内存的。所以这篇文章不是什么高深技术教程就是站在一个走过坑的人的角度把C语言基础、Pwn、缓冲区溢出这三件事串起来讲。适合刚接触二进制安全、准备打CTF但不知道先学什么的朋友也适合C语言学了一知半解、想搞明白我学指针到底有什么用的同学。2. 先从C语言的内存模型说起2.1 程序运行时内存到底长什么样很多C语言教材讲变量、讲赋值、讲循环但很少讲一件事你的程序跑起来之后内存里到底是怎么安排的。这恰恰是二进制安全的第一课。一个可执行程序运行时操作系统会给它分配一块独立的虚拟内存空间按地址从低到高大致可以分为几个区代码段.text存放编译后的机器指令通常是只读的数据段.data/.bss存放全局变量和静态变量堆heap程序运行时通过malloc、calloc、realloc动态申请的内存区从低地址向高地址生长栈stack存放局部变量、函数调用参数、返回地址从高地址向低地址生长。这里有一个特别反直觉的点栈是往下长的。新手第一次看x86汇编或gdb里的栈帧时往往会被搞晕。想象一下你有一叠盘子初始位置在高地址每往栈里压一个数据栈顶就向下移动一点。所以局部变量的地址往往比栈底小而函数调用时返回地址恰好存在局部变量附近的高处。记住这个布局有什么用缓冲区溢出的本质就是利用了栈向下生长、返回地址存在局部变量相邻位置这个特性。你把一个局部数组写越界往高地址方向写一路写下去可能就会覆盖到保存的返回地址。C语言里没有越界检查strcpy、gets、sprintf这些函数会毫不犹豫地把数据填进去直到字符串结束符出现。2.2 指针、数组、字符串三个最关键的坑C语言里最让人头疼的三样东西指针、数组、字符串偏偏是Pwn题里出现频率最高的元素。很多人在初学阶段会想指针好麻烦数组不是也能用吗字符串不是有个string.h吗等你看到Pwn题目源码就会明白它们为什么躲不掉。先说说指针。指针就是一个变量它的值是另一个变量的内存地址。你可以用int *p指向一个整数也可以用char *s指向一个字符串的首地址。在Pwn里最经典的利用手法之一就是篡改指针——如果程序存了一个函数指针你通过溢出覆盖它就能让程序跳到你指定的代码上。再说数组和字符串。C语言里字符串就是字符数组以\0结尾。问题恰恰出在这个\0上。strcpy的语义是拷贝源字符串直到遇到\0gets的语义是读入一行直到遇到换行符。它们都不接收目标缓冲区长度。一旦输入内容超过缓冲区容量后续字节就会一股脑地写到相邻内存里。缓冲区溢出就是这么来的。我自己带过很多刚入门的同学发现大家最容易忽略的其实是数组越界不报错这个事实。在C语言里arr[5]、arr[100]、arr[10000]都是合法的编译代码越界读可能读到随机垃圾越界写可能覆盖别的变量。运行时没有Python那种IndexError也不会像Java那样抛异常。它只会默默污染内存然后等程序在某一个莫名其妙的时刻崩溃或者被利用。2.3 动态内存与堆的入门直觉堆是另一个高频考点。malloc在运行时向操作系统要一段内存free把内存还回去。听起来很简单但实际用起来处处是坑申请了忘了释放内存泄漏、释放了还用use-after-free简称UAF、重复释放double free、申请后发现不够用又扩不了堆溢出……在Pwn入门初期我建议先不用深究堆利用的各种攻击手法那属于进阶内容但至少要知道一件事堆和栈的管理逻辑完全不同。栈由编译器自动分配和释放后进先出干净利落堆靠程序员手动管理而且分配器glibc的ptmalloc有一整套复杂的管理规则。后面学到堆题时你会有更深刻的体会。初学阶段我强烈推荐做一件事学完指针、数组、字符串、结构体、malloc/free后打开gdb用print、x/50wx、info stack这些命令亲眼看一下自己写的程序在内存里的样子。这一步比刷十道C语言编程题都有用。3. 缓冲区溢出从一段C语言代码说起3.1 一个经典得不能再经典的例子下面这段代码我想任何一个学过C语言的人都写过类似的东西#include stdio.h #include string.h void vulnerable_function(char *input) { char buffer[32]; strcpy(buffer, input); printf(Your input: %s\n, buffer); } int main(int argc, char *argv[]) { if (argc 2) { printf(Usage: %s input\n, argv[0]); return 1; } vulnerable_function(argv[1]); return 0; }乍一看它好像就是复制一个字符串再打印出来。但你仔细看strcpy那句目标buffer只有32字节而source的input有多长程序根本不知道。现在编译运行一下记得加关闭栈保护的参数后面会解释gcc -o vuln vuln.c -fno-stack-protector -no-pie -m32 ./vuln AAAA # 输出 Your input: AAAA ./vuln AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA # 大概率输出一串A然后 Segmentation fault段错误输入足够长的字符串后程序直接段错误。这就是缓冲区溢出的最直观表现数据把不该覆盖的地方盖了函数返回时找不到原来的返回地址CPU跳到一个非法内存位置崩了。3.2 栈帧视角溢出时到底发生了什么只看崩了还不够我们要理解底层的过程。用gdb调一下看程序崩溃时栈里的情况gdb ./vuln (gdb) run AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA (gdb) info registers eip这时你会看到EIP32位程序里的指令指针寄存器变成了一大串0x41414141——也就是大写字母A的ASCII码0x41。这就是被覆盖的结果。稍微展开讲一下函数调用过程。当main调用vulnerable_function时CPU会执行一条call指令它做两件事把当前指令的下一条地址也就是返回地址压入栈中然后跳到vulnerable_function入口。函数开头编译器生成的代码prologue会把当前栈底指针压栈保存再给局部变量腾出空间。所以在一瞬间栈里的布局从高地址到低地址大体是函数参数调用者的返回地址保存的旧栈底指针saved ebp局部变量buffer[32]strcpy是从低地址往高地址方向写的想象一下数组首地址是低地址后续元素地址递增因此一旦超过buffer的32字节就会依次覆盖掉旧ebp和返回地址。如果我们精心构造输入把返回地址精确覆盖成某个自己想要的值函数return时CPU就会跳过去执行——这就是Pwn里指令控制流劫持的基本雏形。3.3 防护机制为什么现在没那么容易了现在很多初学者拿得起手题练的时候会发现没那么顺利因为现代编译器和系统默认开启了一系列防御机制让简单溢出很难直接生效。常见的几个术语你得先混个脸熟NXNo-eXecute也叫栈不可执行让栈上不能执行机器码堵死在栈里塞shellcode然后跳过去这条路CANARY栈保护函数入口在局部变量和返回地址之间放一个随机数返回前检查这个数是否被改动被改了就直接终止程序PIE地址随机化程序加载到内存的基址每次运行都随机导致函数和全局变量的地址不确定ASLR操作系统层面的地址空间布局随机化栈、堆、共享库的地址都随机变化。这些机制学起来是后面几个月的事现在只需要建立概念漏洞研究是一场攻击手段与防御机制互相博弈的猫鼠游戏。也正因为这些机制的存在Pwn从简单的一段shellcode走天下演变到今天如堆利用、ROPReturn-Oriented Programming等细分方向。4. Pwn入门路线与必备工具链4.1 环境准备你该装些什么Pwn这条路环境准备决定了你一半的幸福感。我建议在Linux虚拟机里学习推荐Ubuntu 22.04或Debian或者Kali。装好下面几样东西gcc / gdb编译和调试缺一不可pwndbggdb插件极大提升调试体验装好后能看到栈布局、寄存器高亮、反汇编checksec检查可执行文件开启哪些安全保护的小工具Python3 pwntoolsPwn题写expexploit脚本的事实标准库一个文本编辑器vim或者VS Code都可以。这里有个细节pwntools的安装直接用pip即可但要注意它是专为CTF设计的工具库写出来的脚本可以方便地接收输入、发送数据、打包地址、交互调试别把它当成生产工具。它做的是和受控程序交互这件事。我自己的经验是环境搭建期最容易卡住的是32位程序在64位系统上运行缺库。如果你编译或运行一个32位程序报找不到库文件先装多架构支持以及libc6-dev-i386这类库。4.2 用BUUCTF、ctfshow这类平台做题的节奏学Pwn离不开刷题。国内比较常见的是BUUCTF上的pwn题和ctfshow的pwn模块。这些平台的题做起来会很顺手因为网站已经帮你处理了远程动态容器——你提交题目附件后网站会给你一个临时的远程端口你用pwntools连上去交互就完事了。入门阶段不求多求慢慢啃穿。我的建议是每周只做两到三题但每一题都要搞明白每个细节。拿到一个题目文件固定走几条流程file命令看文件格式确认是32位还是64位、有没有去符号checksec看保护全开还是部分开启把文件拖进IDA或Ghidra或者objdump看反汇编找到漏洞函数用gdb配合pwntools调试算清楚溢出偏移写exp本地打通再连远程打通。这套流程走完一道题才算彻底消化比一口气刷二十道但每道都看WriteUp要有用得多。4.3 怎么算偏移从gdb里找答案现在演示一个入门最常见的操作计算缓冲区到返回地址的偏移。很多新手卡在这里觉得无从下手。实际上最简单可靠的办法是使用pwntools里的cyclic功能。from pwn import * # 本地运行 p process(./vuln) gdb.attach(p) # 或者手动在gdb里运行 # 生成一串有规律的无重复字符 payload cyclic(200) p.sendline(payload) p.wait()然后在gdb里查看崩溃时的EIP或RIP值。比如崩溃时EIP的值是0x6161616c用pwntools里的cyclic_find查出对应位置offset cyclic_find(0x6161616c) print(offset)这个offset就是距离返回地址的字节数。你就能构造精确的攻击载荷先填offset个填充字节再写入目标地址。为什么要用cyclic而不是简单的A*100因为如果直接看崩在了哪个地址你得肉眼数A的数量麻烦容易错。cyclic生成的是周期性无重复字符串crash地址直接对应唯一偏移一查就知道。4.4 一个最小可用的poc脚本长什么样记住一个理念先用一个最小脚本证明我能控制程序流程再去想接下来怎么拿到权限。下面这段脚本几乎适用于任何入门级栈溢出题目from pwn import * context(archi386, oslinux, log_levelerror) p process(./vuln) # 远程时换成 remote(ip, port) offset 32 4 # buffer 32 字节 saved ebp 4 字节 payload bA * offset p32(0xdeadbeef) p.sendline(payload) p.interactive()然后看程序是否在0xdeadbeef这个地址崩溃用gdb确认EIP变成了deadbeef。能做到这一步说明你已经完成了一次指令流控制从理论到实践的验证。这个成就感很重要它让你真正体会到为什么Pwn有趣。接下来才轮到真正的利用比如找一个system函数、程序里的后门函数或者构造ROP链。这些是下一阶段的事但基础架构就是这样。5. 新手最容易踩的坑和自查清单5.1 编译参数不理解导致实验现象和教程对不上我踩过的最初的坑就是编译参数。很多教程里会给一串编译命令gcc -o vuln vuln.c -fno-stack-protector -z execstack -no-pie -m32如果你在64位系统上不加-m32程序就是64位栈布局、寄存器名、地址长度全不一样你不加-fno-stack-protector编译器默认插入canary简单溢出会被检测到直接abort你不加-no-pie程序的地址就会随机化写死地址的脚本就会失败。所以入门阶段一定要自己动手编译靶机程序并且养成习惯每做一个实验先看checksec输出明确这个题目开没开保护。你的exp能不能跑通一半取决于你对保护机制的理解。5.2 32位和64位的差异千万别混着学这里要特别提醒32位和64位程序在调用约定、寄存器、地址宽度上有本质差别。32位程序函数调用用栈传参gdb里看esp/ebp64位程序前几个参数用寄存器传递rdi、rsi、rdx...gdb里看rsp/rbp。入门阶段大部分教程以32位为主因为直观、栈布局简单但工作里和现实软件大多是64位学到后半段一定要切换过去。一个容易混淆的细节p32()和p64()。向程序发送地址时32位地址要用p32打包成4字节64位要用p64打包成8字节写错了程序直接崩。我在带新人的过程中几乎每个人都栽过这个跟头。5.3 学习伦理靶场和真实系统是两回事这一点必须说在前面。Pwn题、CTF比赛本质是在受控的靶场环境中研究漏洞原理目标系统就是那台服务器给你开的虚拟容器。这是合法的、竞赛性质的训练。但如果你把这个技术用到不属于自己的系统上或者未经授权对他人设备发起测试性质就完全变了这是违法行为也是行业的大忌。我们研究缓冲区溢出更核心的目的是理解漏洞的产生机理从而在写代码的时候知道如何避免做安全防护的人知道如何检测和缓解。这才是这个方向真正的价值所在。我见过有人学了两周Pwn就想着去黑论坛的最后把自己搭进去了。切记技术没有善恶但你使用技术的动机和对象决定一切。5.4 入门阶段常见问题的速查表下面这个表是我辅导过很多人之后整理出来的。如果你卡住了先按这个表自查一遍。现象可能原因解决思路程序直接Segmentation fault溢出量过大覆盖了无效地址用cyclic精确计算偏移EIP被打成0x41414141但不是目标地址偏移算错了或地址写了小端序反了确认offset确认p32/p64使用正确程序abort而不是段错误开了栈保护canary覆盖被检测编译时加-fno-stack-protector或用绕过技术远程连不上本地能通远程环境与本地有差异架构、libc版本检查远程提示的架构适配libc版本地址写入后每次运行都变程序开了PIE或在有ASLR的环境中关闭ASLRsetarch -R或学习绕过PIE的方法sendline后程序没反应payload里没有换行或交互逻辑不对用sendline而非send或者调整recvuntil这张表解决不了所有问题但可以帮你省掉大量无意义的排查时间。6. 用Pwn的视角重新学C语言一份反方向的学习路线6.1 每一项C语言基础都对应一个安全概念我经常跟人讲一个观点C语言的每一个基础点放到Pwn里都有对应的坑。反过来学你会发现C语言不再枯燥数组与下标操作→ 越界读写的根源指针与地址→ 你拿去计算偏移、控制跳转的工具字符串函数→ strcpy、strcat、sprintf不具有边界检查是经典漏洞来源malloc/free→ 堆溢出、UAF、double free这些进阶题材函数调用栈→ 函数返回地址被覆盖是栈溢出利用的核心全局变量与静态变量→ 存放位置固定可能会被越界写篡改。这个对应关系很重要。当你学到C语言里的char name[20]时不要只想着这是一个能存19个字符的数组还要想着如果我不小心往里塞了30个字符后面的11个字符会跑到哪去。带着这种内存敏感去学C语言你的水平和普通初学者会有本质区别。6.2 推荐的学习顺序我不是说必须把C语言学到精通才开始学Pwn那样你会永远停在学完再做的状态。更合理的是两条腿走路。第一阶段第1-2周恶补C语言基础重点放在变量类型、运算符、流程控制、数组、指针、函数、字符串处理。能独立写一个带函数调用、字符串复制的小程序就行。第二阶段第3-4周开始接触Pwn环境编译一个有缓冲区漏洞的小程序用gdb观察崩溃。不会的知识点比如栈布局回头再看C语言教材带着问题学效率极高。第三阶段第5-8周刷BUUCTF上的简单栈题比如ret2text、ret2shellcode这类早期经典。每一题都要走完整流程checksec、看反汇编、算偏移、写exp。在这个过程中你会发现一个很神奇的现象你回头再去看翁恺老师的C语言课、看那些编程练习题感觉会完全不一样。因为此时你学的不是语法而是内存视角下的程序为什么这样工作。6.3 后续可以往哪些方向扩展等栈溢出和缓冲区溢出有了基本认知后面的路可以这样走重定向到C语言安全编码了解如何用strncpy、snprintf、memcpy指定长度替代不安全的字符串函数理解编译器警告里的格式字符串风险深入栈利用学习ROP返回导向编程、ret2libc弄明白一个不能执行栈上代码时如何借用程序已有代码片段达到目的堆利用从最简单的UAF、double free开始逐渐进入glibc分配器的内部世界格式化字符串堪称C语言里另一个大坑printf用错了格式符就可能被攻击者读写任意地址逆向与自动化使用IDA/Ghidra逆向复杂程序用脚本自动化分析。每一个方向都是一个庞大的知识体系但它们的地基就是你现在正在学的C语言基本功和内存认知。7. 结个尾说点实在的体会带过不少人入门二进制安全之后我最大的体会是这个方向不缺聪明人缺的是愿意耐心走完基础、一步步验证的人。很多人看几篇WriteUp觉得我看懂了但没有亲手编译、亲手调式一遍实际上遇到问题时依然无从下手。缓冲区溢出这个知识点尤其如此——它必须靠亲手戳破几个程序才能形成真正的直觉。我特别建议你准备一个实验笔记每做一道题就记录三件事这个程序的漏洞在哪里、为什么能利用、如果把这个漏洞修复我会怎么改。坚持下来你会发现自己对C语言的理解深了一大截对整个计算机运行机制的认识也会完全刷新。最后再分享一个小技巧学Pwn初期遇到的很多挫折都是因为操作不熟练导致的而不是能力问题。比如p32忘记导包、本地路径写错、远程端口没加等这些都是家常便饭。遇到一次就记录一次最多半个月你的效率会提升好几倍。保持住那股刨根问底的劲儿这条路一定能走下去。