不用多说直接进正题。今天要拆的这道题是HappyNewYearCTF系列里的第三题题目全名叫“栈上变量覆写2”。一看到这个名字基本就能猜个大概又是栈上变量被覆写的路子而且这题带个“2”说明同系列里还有一道更基础的“栈上变量覆写1”。如果你做过那道1这题的套路其实不陌生但里面有几个细节坑值得单独拎出来讲一讲。如果你连1都没碰过那也没关系这篇文章从环境配置到原理拆解再到完整的exp编写会一条线走下来保证不跳步。先交代一下这个题适合什么人看刚入坑CTF想接触PWN方向的新手、打过几道简单栈题但始终对“变量覆写”这个点似懂非懂的人、以及想做一套完整的入门级PWN刷题笔记的玩家。整个系列难度都不高第三题也不例外属于easy槛里的入门题但它的好玩之处在于能帮你把栈帧、变量布局、输入流向、字节序这些基础概念一次性打穿。1. 拿到题目之后的信息收集1.1 file、checksec、保护机制一起看PWN题拿到的第一时间不管三七二十一先把三件事做了看文件类型、看保护机制、跑一遍程序看看大概逻辑。这三件事做完你心里基本就有底了。先看文件类型file HappyNewYearCTF_3通常这就是一个x86-64架构的ELF可执行文件动态链接不是静态的也没做strip去符号。如果带符号表那就更舒服了函数名一目了然逆向难度直接减一半。这个系列的题都比较仁慈基本上不会为难你。然后是checksec这是PWN的基本功用pwntools自带的脚本或者单独装一个checksec都可以checksec --fileHappyNewYearCTF_3输出大概长这样Arch: amd64-64-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x400000)这几个字段拆开来看No canary栈上没有金丝雀保护意味着栈溢出不需要想办法绕过canary直接打就行。NX enabled栈不可执行往栈上塞shellcode这条路基本废了但做题思路本来也不是靠shellcode。No PIE程序加载地址固定函数地址、全局变量地址全是写死的exp里可以直接用IDA或objdump看到的地址不需要泄露地址。这几条加在一起就是标准入门题的配置属于给你送分的水平。唯一需要稍微警惕的是Partial RELRO它意味着GOT表可写但这边用不到有备无患。1.2 先跑一遍程序观察行为逻辑光看保护机制还不够程序本身还得跑一跑看看它到底在干嘛./HappyNewYearCTF_3这类趣味题一般会给你一段欢迎语、一个输入提示有时候还会伪装成“猜数字游戏”或者“判断题”。你随便敲点东西进去看它怎么输出。如果程序直接读入一堆内容然后打印出来那就是个标准的栈读入问题如果它让你选择选项那可能藏着分支逻辑。第三题这里程序大概长这个样子先打印一串欢迎信息然后要求你输入名字或者输入一个数字接着程序里某个变量会被输入内容影响最终判断是否输出flag。具体流程我们下面逆向的时候看细节。这个阶段的核心目的是建立直觉不需要精确到字节级知道程序是“输入-处理-判断-输出”的模型就够了。2. 栈上变量覆写这个漏洞到底是什么2.1 栈帧布局决定了覆写能打到哪很多新手刚接触PWN一看到“栈溢出”脑子里就只有“覆盖返回地址”一个念头。但栈溢出并不是只有覆盖返回地址一条路。在调试器里看栈布局的时候一个函数的所有局部变量是排成一排存放的编译器和CPU只负责给它们分配位置并不负责防止越界读和越界写。如果你输入数据时没有长度限制写进去的内容就会顺着低地址往高地址一股脑地冲过去把相邻的变量全部淹没。这里的关键点是方向。x86-64的栈是向下生长的栈顶地址小于栈底地址但栈内变量在内存里的排列却是先定义的变量在更高地址或更低地址取决于编译器缓冲区和相邻变量之间的相对位置关系决定了你能不能覆盖到它。经典的内存布局如下高地址 | 调用者的rbp | | 当前rbp | | 局部变量A | | 局部变量B | | 缓冲区buf | - 低地址程序把你输入的数据往buf里写buf在低地址变量在高地址输入超长时数据就会从buf开始往上漫依次覆盖buf后面定义的变量然后覆盖到保存的rbp最后是返回地址。题目要考的“栈上变量覆写”就是指这种你不需要控制返回地址只需要把紧挨着的某个变量改写成特定值程序后面的逻辑就会被带歪直接输出flag。2.2 为什么说它比返回地址覆写更基础因为返回地址覆写涉及栈迁移、ROP链构造、甚至栈对齐这些麻烦事而变量覆写说白了就是“把一个地方存的值改成我想要的数”。它不需要你理解ROP链也不需要你去找gadget只要弄明白两件事要覆盖的目标变量和输入缓冲区在栈上的偏移是多少。目标变量的期望值是多少。这两点搞清楚了exp基本就出来了。这也是为什么这种题目普遍放在新生赛和入门赛里的原因它考察的是你对栈布局的理解而不是对高级利用手法的掌握。3. 逆向分析IDA里看程序逻辑的完整过程3.1 找到main函数定位漏洞点拿到ELF文件之后上IDA Pro是最常规的做法。gdb虽然也能看汇编但效率太低不适合入门阶段。IDA里按一下F5伪代码直接显示出来思路能快很多。int __cdecl main(int argc, const char **argv, const char **envp) { char buf[32]; int v5; v5 0; puts(Hello, welcome to HappyNewYearCTF!); puts(Input your name:); read(0, buf, 0x50); if ( v5 0x12345678 ) system(/bin/sh); else puts(Try again!); return 0; }这就是这类题的标准骨架。一个缓冲区buf大小32字节一个整型变量v5初始化为0。然后read函数向buf里读入0x5080字节比32大出一大截完全够从buf冲到v5的位置。程序接下来的逻辑很简单判断v5的值是不是等于0x12345678。是就给你弹一个shell不是就打印一句“Try again”。拿到这个伪代码题目本质就很清晰了。我们不需要栈溢出到返回地址不需要ROP只需要往前多写那么几个字节把v5改成0x12345678。3.2 计算偏移为什么是32 4接下来最关键的一步搞清楚buf到v5之间到底隔着多少个字节。新手最容易在这个地方出错有的拿到IDA直接就数buf的大小比如buf是32字节然后就开始写exp覆盖完32字节发现v5没被改成功就开始怀疑人生——其实是因为v5的位置不是紧贴着buf开始的它可能因为对齐被放在了buf32的位置也可能因为栈帧对齐要求和buf之间空了几字节。正确做法有两种第一种看IDA的栈帧视图。在IDA的栈帧窗口里可以直接看到buf的起始地址偏移和v5的起始地址偏移。通常buf的偏移是-0x20即距离栈底40字节负偏移v5的偏移是-0xC距离栈底12字节。这里要注意-0x20和-0xC并不是buf和v5的直接偏移差因为栈变量是从低地址往高地址摆放的v5相对于buf的偏移应该是0x20 - 0xC 0x14 20这个时候要注意一个细节如果IDA里显示的buf偏移是-0x20v5偏移是-0xC那么从buf开头到v5开头的字节数是20字节。用这个偏移去覆盖先填20个垃圾字节然后填入0x12345678。第二种用gdb调试在read函数处下断点输入一串带规律的数据比如一堆A然后查看栈内存直接看到A填充了哪些地址再找到v5的地址一减就知道偏移了。这个方法虽然慢但是靠谱也适合练调试基本功。这道题的实际偏移是多少根据IDA分析buf是32字节v5是4字节的intbuf位于-0x20v5位于-0xC这是常见布局所以buf到v5的偏移是20字节。这里要特别强调一下为什么不是32因为在buf和v5中间往往还有4到8字节的填充alloc或对齐这部分不算buf也不算v5但你要达到v5就必须把这些填充字节也填过去。还有一种情况需要警惕如果buf大小是16v5偏移是-0xC那偏移就只有4字节。所以这东西每个题都不同绝对不要凭经验拍脑袋必须以IDA的实际栈帧为准。3.3 小端序为什么p64不是正确选择偏移算好了下一步就是把0x12345678写进去。这里必须先端正一个概念x86-64架构是小端序little-endian。什么叫小端序就是一个多字节整数在内存里存放时低位字节放在低地址高位字节放在高地址。0x12345678这个数实际在内存里存的四个字节是0x78 0x56 0x34 0x12也就是说你在payload里拼接数据时要把高位字节放后面。如果你直接写p64(0x12345678)当然也是可以的它会把数值按照8字节小端序展开为\x78\x56\x34\x12\x00\x00\x00\x00然后程序读取v5时只取前4字节正好是0x12345678。但如果你写的是p32(0x12345678)效果一样也更精确因为它只输出4个字节不会多带四个0x00避免后面覆盖到rbp或者返回地址造成意外崩溃。这里有个实战经验送给大家能用p32就尽量用p32不要贪方便直接用p64。原因是p64会多写4个字节的0这在精确覆写场景下可能把不该动的地方也动了比如rbp的低位字节虽然这道题最后是弹shell所以不敏感但养成谨慎的好习惯在后面的高难度题里能少踩很多坑。4. 写exp从本地验通到远程拿flag4.1 pwntools的基础用法写exp之前先把环境准备好。pwntools是PWN题目的标配Python库没装的话先装一下apt-get install python3-pip pip3 install pwntools装好之后最简单的一道exp长这样from pwn import * p process(./HappyNewYearCTF_3) payload bA * 20 p32(0x12345678) p.sendline(payload) p.interactive()一行行解释process(./HappyNewYearCTF_3)表示跑本地文件。bA * 20生成20个填充字节把buf到v5之间的空间撑满。p32(0x12345678)以4字节小端序填充目标值。sendline发送数据注意末尾的换行符既然程序用的是read它也不会多读换行符这个换行符会留在栈外的输入缓冲区里不影响变量覆写。p.interactive()把输入输出交还给用户成功弹shell后可以直接敲命令比如cat flag。本地跑一下如果一切正常你会看到程序不再输出“Try again!”而是卡在shell状态输入ls能看到文件列表输入cat flag能拿到flag。4.2 对接远程换一个地址就能打本地通了以后远程基本就是改一行的事。比赛平台会给你一个远程地址和端口比如node1.happyctf.com:30001。把exp里的启动方式从本地进程换成远程连接from pwn import * p remote(node1.happyctf.com, 30001) payload bA * 20 p32(0x12345678) p.sendline(payload) p.sendline(bcat flag) p.interactive()这里有个小技巧interactive()之前先把cat flag也send进去可以避免交互时手忙脚乱特别是远程环境有延迟的情况下。4.3 一个容易踩的细节坑read和sendline的区别这个坑值得单独拿出来说因为真的有很多新手栽在上面。有些题目用的是gets()gets会读到换行符然后把它替换成\x00所以你的payload里如果有\x00会被打断payload就得完整送达之后才能截断。而read函数不管这些你发多少它收多少不会因为\x00就停下来但要注意read会连换行符一起读进缓冲区。如果题目用的是gets你的payload里一旦出现p32(0x12345678)的小端序形式里面就包含\x12这个字节。它不是一个截断字节不影响gets读取真正需要小心的反而是\x00。比如你想故意不填充目标值填\x00\x00\x00\x00就不能用gets因为gets读到第一个\x00就直接结束了。这道题的read没有这个限制所以你可以放心大胆地直接构造全payload。但你还是需要看一下题目到底用的什么函数因为如果是gets同样的payload可能因为换行符的问题出现偏差需要自行微调。4.4 打远程失败的常见原因远程连上了payload也发了但flag就是不出来。排一下常见的几个原因偏移算错了本地能通不代表远程一定通除非你确认程序和本地版本一致。有的比赛平台提供的二进制和远程是一模一样的但有些赛事平台会做微小改动比如加了几个环境变量或者改了编译选项导致栈帧偏移变化。一旦远程不通优先确认本地是否完全一致。平台自带防护有的CTF平台会在远程程序前面套一层转发层接收数据的字节长度可能被限制。这时候你就得把payload压缩到最短去掉多余字节。交互超时远程shell可能几秒没操作就断掉sendline(cat flag)之后马上进入interactive()是最稳妥的。环境变量本地在/bin/sh可访问的前提下跑的远程如果二进制找shell时环境变量被过滤可能弹不了shell。不要慌用cat flag先试。5. 调试与验证gdb看栈数据确认覆写成功5.1 下断点看内存跑通了不代表万事大吉你得有本事在脑子里把整个过程过一遍。真遇到题不可能每次都一把过这时候gdb就是你的放大镜。用gdb启动程序gdb ./HappyNewYearCTF_3在read函数调用结束的位置下个断点比如在v5的赋值操作前下断或者直接在判断语句之后下断。接下来随便输入数据然后查看栈上数据x/20wx $rsp-0x30这个命令会从栈底附近开始打印20个字的栈内容你就能直接看到你输入的数据在内存里的分布以及v5位置是否被正确覆写。看到0x12345678躺在正确的地址上时整个题就算真的通了。5.2 验证偏移的小脚本如果你不习惯交互式gdb也可以写一个简单的调试辅助脚本用pwntools的gdb插件from pwn import * context.log_level debug p gdb.debug(./HappyNewYearCTF_3, b *0x4006b0 continue ) p.sendline(bA * 20 p32(0x12345678)) p.interactive()这样一启动就直接停在目标地址配合日志输出能非常清楚地看到发送了什么、接收了什么。6. 做完这道题之后的升级路径这道题你打通了说明栈上变量覆写这个点已经吃透了。但整个PWN领域到这一步才刚起步后面的路还很长顺手标记几个下一步的方向栈溢出到返回地址变量覆写升级版覆写目标从局部变量变成返回地址需要开始接触ROP链。canary绕过有canary保护的程序第一步变成泄露canary值再做变量覆写或返回地址覆写。PIE绕过程序基址随机化之后地址不再固定得先泄露某个地址再计算基址。格式化字符串利用printf的格式化漏洞读内存或写内存这是泄露地址的经典手段。从这道题出发接下来的训练路径可以是找一道没有PIE没有canary的ret2text题做一遍再找一道有PIE的栈题做一遍再把format string过一遍。PWN这个方向就是典型的一个知识点带一个知识点每一道题都是后面题的垫脚石。回到这道题本身HappyNewYearCTF第三题虽然简单但它是你把“栈布局”和“变量覆写”这两个概念刻进脑子里的最佳练习。做完之后你可以把exp、IDA分析截图、笔记都整理好放进自己的CTF笔记仓库。后面再遇上类似的题直接拿这套思路去套就会快很多。最后说一个实际调试中的小经验打远程之前一定要本地先验通——包括拿flag的路径。因为本地环境完全可控调试方便如果你连本地都跑不稳远程就更不可能一次成功。先本地拿到结果再切remote这是所有PWN题的通用节奏。