boxmoe_header_banner_img

all in pwn

文章导读

ret2text、ret2shellcode、ret2syscall、ret2libc


avatar
cx330 2026年8月11日 264

前言:本篇记录四类RET类栈溢出攻击手法,搭配简单经典例题。将ret2text、ret2shellcode、ret2syscall、ret2libc 放一块对比总结,顺便记录下调试过程里踩过的各种奇葩小坑>_<

0x01 ret2text

01.什么是ret2text

ret2text,全称 Return to Text。顾名思义,该利用方式的核心思路是劫持程序执行流,让程序跳转至程序 .text 段内已经存在的可用代码片段(例如现成的 system(“/bin/sh”) ),直接拿下shell。

02.如何实现ret2text

我们知道,函数调用结束退出时,会执行 leave; ret; 依靠栈上存储的返回地址完成跳转。因此我们只需篡改栈上的返回地址,将其替换为目标 system 函数地址,即可达到我们想要的目的。

然而,为什么函数调用结束退出时,会执行 leave; ret;呢?这是 x86(32位)_cdecl 调用约定规定的标准栈帧收尾逻辑。

下面,我们来简单梳理一下这套调用约定完整流程:

  • 调用从从右往左把参数依次压入栈。
  • 执行 call 函数名,自动把返回地址压入栈,跳转到目标函数。
  • 被调用方开头执行 push ebp; mov ebp, esp,搭建栈帧。
  • 函数内部运行完毕后,执行 leave:等价于 mov esp, ebp; pop ebp,销毁当前栈帧。
  • 执行 ret,取出栈上保存的返回地址进行跳转。

这样,就能完美保证调用前后栈的环境保持一致。

03.ret2text经典例题

IDA分析构思整体思路

接下来,我们来看一道例题

首先,借助IDA对本题进行逆向分析,然后查看mian函数。

我们发现,这里有三个函数,分别点进去看一下。

sub_400726平平无奇。

sub_400737平平无奇。

我们可以看到,仅当 a1 == 233 时才会执行 system,而我们可控输入的值为 5438,无法满足该判断条件,正常流程下无法走到这条分支。

而sub_400748中存在溢出点:read读到buf这个地方,距离ebp只有0x20字节,但我们可以填入0x50字节,所以存在栈溢出,也就是说可以覆盖ebp和return_address。

同时,我们在程序内找到了system函数,点进去发现, .text段内存在完整的传入/bin/sh并调用system的代码片段。

使用checksec查看程序保护机制,确认PIE和Canary均未开启,由此可以确定,本题可以直接采用ret2text进行攻击。

Payload撰写与调试

回看IDA,我们应该在接受到”233 or 666″后输入5438来进入sub_400748函数,然后在接受到”So,Tell me your name!”后传入payload。

撰写payload时,只需要先填充0x20垃圾字节,再写入0x4字节垃圾字节覆盖ebp,再将system函数的地址写入,以此来覆盖返回地址即可。

如上图,在IDA中可以轻松找到system函数地址。

如果想要调试,可以将断点下在read执行后的位置。

完整EXP

写到这里,就能成功获取程序的交互式shell啦!

0x02 ret2shellcode

01.什么是ret2shellcode

ret2shellcode,全称 Return to Shellcode。再次顾名思义,向可控缓冲区写入自定义 shellcode,劫持返回地址,让程序跳转执行这段我们布置的机器码,从而拿到 shell。

值得注意的是,我们写入的shelcode必须是机器码指令。这是因为CPU仅能识别机器码,汇编只是便于人类阅读的助记符,无法直接交由 CPU 执行。

同时由于我们需要在栈上写入并执行代码,因此要求 NX 保护关闭,若开启 NX,栈不可执行,ret2shellcode 便无法生效。

02.如何实现ret2shellcode

ret2shellcode的构造思路和ret2text比较相似,但填充规则有所区别:我们在返回地址的位置填入jmp esp;的地址,shellcode 布置在缓冲区后方。

该攻击方式依赖程序本身存在jmp esp这类可用 gadget,若程序内无对应指令片段,则无法采用这套思路构造 ret2shellcode。

灌入payload后,整体运行流程如下:

  • 函数收尾执行mov esp,ebp;esp指向old_ebp的位置,pop ebp;esp指向jmp esp的位置。
  • 执行ret=pop eip;将jmp esp弹入eip。
  • 执行eip中的jmp esp;此时esp 指向栈上预先布置的 shellcode,CPU 运行该段机器码。

补充说明:32位环境下,ret =pop eip;jmp esp 本身不会移动 esp,也不会删除栈上数据。

03.ret2shellcode经典例题

IDA分析构思整体思路

同样的,我们来看一道例题

首先,借助IDA对本题进行逆向分析,然后查看mian函数。

这里有一个vul函数,我们点进去。

这里存在溢出点:s距离ebp只有0x20字节,但我们可以填入0x50字节,所以存在栈溢出。

使用checksec查看程序保护机制,确认PIE和Canary均未开启

我们发现程序内无现成可用的 system 函数,因此无法走 ret2text,但 IDA 检索到 jmp esp gadget,所以选择 ret2shellcode 作为攻击方案。

Payload初次撰写与碰壁

在构造Payload时,常规思路可以对照栈帧排布:先填充垃圾字节,再填入jmp esp地址,最后在后方布置shellcode。

我们可以在IDA中轻松找到jmp esp;的地址,偏移也可以直接找到。

但是运行时发现,这种写法在此题中行不通,为什么呢?经过调试发现,原因是本题可用的溢出空间有限,有效可支配长度仅为 50-0x20=18字节,不足以放下完整shellcode,空间不够。

如果想要获取 shellcode 的具体长度,可以直接在脚本中添加打印语print(len(shellcode)),运行脚本后就能在终端看到对应的字节数量。

我们可以看到,shellcode的长度时25而可用溢出空间为18字节,根本容不下shellcode。

那该怎么办呢?我们注意到缓冲区拥有32字节的填充空间,如果可以把这块空间加以利用,就能够放下shellcode。于是我们调整Payload构造顺序:Payload最开头先写入shellcode,之后填充垃圾字节,再把返回地址覆盖为jmp esp的地址。

Payload顺序修改与调试

按照这个思路,我们重新修改payload,将shellcode写到开头,但是这样执行的过程中,ESP 就不会指向 shellcode 了。于是我们增加了一条指令:sub esp, 0x28; jmp esp,这条指令的作用是主动把栈指针向低地址方向回退,让 esp 跨过中间的垃圾填充字节,重新指向位于 payload 最开头的 shellcode,之后再执行 jmp esp,就可以正常运行我们布置好的机器码。

完整EXP

写到这里,就能成功获取程序的交互式shell啦!

0x03 ret2syscall

01.什么是ret2syscall

在我们平时的 Pwn 做题中,常用的 ret2text、ret2libc 等攻击手段,全部依赖 system 函数,通过调用 system 来执行命令、获取 shell。

但我们会遇到很多特殊题目:程序内部没有 system 函数,也没有任何后门函数。在这种情况下,我们熟悉的传统攻击方法会直接失效,完全无法使用。

那我们可以思考一个问题:既然不能调用现成函数,能不能自己手动构造一套执行逻辑,让程序主动执行 /bin/sh?

答案就是 ret2syscall。我们可以不依赖任何函数调用,直接利用程序内残存的指令片段,手动拼凑系统调用,从而执行 /bin/sh 拿到 shell。

这就是ret2syscall:依靠 ROP 拼接指令、手动构造系统调用实现 getshell,不依赖 system。

想要亲手拼装出一套系统调用,有三块内容我们必须理清楚::ROP Gadget、系统调用号、系统调用传参规则。

02.ROP与Gadget

很多题目默认开启 NX 保护,栈内存不可执行,我们不能写入 shellcode。那么,既然不能自己写代码执行,那我们就借用程序原本就存在的代码。

程序中存在大量短小的指令碎片,例如 pop rax; ret、pop rdi; ret、syscall; ret,这些小段指令就叫做 Gadget。

而 ROP 的核心思想,就是利用栈溢出拼接这些 Gadget,人为控制寄存器、人为拼接执行逻辑,实现程序原本没有的功能。ret2syscall 本质就是一套完整的 ROP 构造链路。

03.系统调用

系统调用

系统调用是用户空间程序与内核交互的主要机制。系统调用与普通函数调用不同,因为它调用的是内核里的代码。使用系统调用时,需要特殊指令以使处理器权限转换到内核态。另外,被调用的内核代码由系统调用号来标识,而不是函数地址。

系统调用号

程序想要让内核帮我们干活,必须指定要干什么事。在 x86 Linux 中,通过 rax 寄存器存放系统调用号,告诉内核执行对应功能。

例如,rax = 59 代表 execve 系统调用,作用就是执行程序、拉起 /bin/sh,是我们 getshell 的专属调用号。

系统调用传参

x86‑32位使用int 0x80触发系统调用。eax存放系统调用号,execve 的调用号为 11参数,寄存器ebx保存第1个参数,ecx 保存第2个参数,edx 保存第3个参数。

x86‑64位使用syscall触发系统调用。rax存放系统调用号,execve的调用号为 59参数,寄存器rdi 保存第1个参数,rsi 保存第2个参数,rdx 保存第3个参数。

04.如何实现ret2syscall

搞懂上面的基础,我们来思考,怎么把这些条件通过 ROP 全部凑齐,完成攻击:

  • 利用栈溢出漏洞,覆盖函数返回地址,劫持程序执行流,让程序跳转到我们拼接的 ROP gadget 链。
  • 使用pop eax; ret; 这个gadget,把eax设置为系统调用号 11,告诉内核我们要执行 execve。
  • 继续使用对应的 pop-ret gadget,依次给寄存器赋值:ebx设置为内存中/bin/sh 的地址,ecx设置为0,edx设置为0。
  • 至此execve需要的全部参数环境准备完毕。
  • 最后跳转执行int 0x80; ret;指令,触发系统调用。内核读取 eax、ebx、ecx、edx 的状态,执行 execve(“/bin/sh”,0,0),成功拿到 shell。

05.ret2syscall经典例题

IDA分析构思整体思路

接下来,我们来看一道例题

首先,借助IDA对本题进行逆向分析,然后查看mian函数。

这里存在溢出点。

接着查看程序保护,Canary关闭,我们能够覆盖返回函数。同时,在IDA中,没有找到system函数,也不存在其他可以利用的后门函数,所以我们采用ret2syscall攻击。

撰写Payload

这道题目逻辑非常简单。先用垃圾字节填充覆盖buf缓冲区以及rbp栈基址,完成栈溢出。再按照64位ret2syscall的流程,依次拼接我们搜集到的各类gadget,最后放上syscall,触发系统调用,执行execve就能拿到shell。

完整EXP

写到这里,就能成功获取程序的交互式shell啦!

0x04 ret2libc

01.什么是ret2libc

前面我们学过ret2text,它直接调用程序自身里面的system函数拿到shell。但很多题目里,程序本身并不包含 system 函数,ret2text就无法使用。不过程序运行的时候会动态链接libc库,system、/bin/sh 这些内容其实都存放在 libc 库当中。

那我们思考一下:既然程序本体没有,那能不能去调用 libc 库里面的 system 函数?这就是 ret2libc。

ret2libc的核心思路:不依赖程序二进制内部自带的 system,利用栈溢出劫持执行流,调用 libc 库中的函数完成攻击。

为了更好的理解ret2libc,我们先来看一些预备知识。

02.libc和glibc

glibc和libc都是Linux下的C函数库,libc是Linux下的ANSI C函数库,glibc是Linux下的GNU C函数库,ANSI C函数库是基本的C语言函数库,包含了C语言最基本的库函数。Linux下程序几乎都会加载它。

system()、read()、puts() 这些函数都来自libc,libc内部也存放着 /bin/sh 字符串。程序本体没有的函数,只要动态链接了 libc,运行时就可以使用库内的这些资源。

Linux下面的标准c库不仅有这一个,如uClibc、klibc,以及上面被提到的Linux libc,但是glibc无疑是用得最多的。glibc在/lib目录下的.so文件为libc.so.6。

现在,我们已经知道ret2libc要调用libc库中的函数,那程序是如何和libc库建立联系?函数地址又是怎样保存的呢?这就需要了解动态链接相关知识。

03.动态链接

动态链接会把程序拆分成多个独立模块,程序运行时才将各个模块拼接成完整程序。

多个程序可以共用同一个库文件,当第一个程序运行时,系统会把共享库加载进内存。后续其他程序再需要这个库,就不会重复加载,直接把内存中已经存在的库映射到自身虚拟地址空间完成链接,节省内存开销。

04.ELF延迟绑定机制

思考一下:如果程序启动瞬间,就要解析全部外部库函数,会出现什么问题?

那将会消耗大量时间。通俗来讲,我们希望的是,用到哪个找哪个。因此引入PLT延迟绑定机制:函数只有第一次被调用时,才进行符号查找与地址重定位,尚未调用的函数暂时不做解析,以此加快程序启动速度。

这里涉及两张表PLT与GOT:

  • PLT(过程链接表):一小段跳转汇编代码,用来完成外部函数调用、触发延迟绑定逻辑。
  • GOT(全局偏移表):存放符号的实际内存地址,被拆分为 .got 和 .got.plt。
  • .got:负责保存全局变量引用地址。
  • .got.plt:专门存放外部库函数的地址。函数第一次调用解析完成后,真实libc地址就写入.got.plt,ret2libc泄露地址读取的就是这部分内容。

思考一下,既然是用到哪个函数才解析哪个,那从来没有调用过的函数,.got.plt中能拿到真实libc地址吗?答案是不能,只有执行过的函数,.got.plt才回填真实运行地址,才可以用于地址泄露。

05.ALSR

ASLR(地址随机化)机制,使得函数在每次加载时地址都是不一样的。

到这里大家会产生疑问:ASLR开启之后地址随机,那我们又该如何拿到system、/bin/sh的地址?

开启ASLR之后,libc每次加载的基地址是随机的,但是库内部各个函数之间的相对偏移固定不变。因此,我们泄露出一个已经调用过的libc函数真实地址,减去该函数在libc内的固定偏移,就可以算出libc基址。

因此,我们只需要泄露任意一个libc函数地址,就可以推导其他函数,这也就是ret2libc攻击能够成立的核心。

06.ret2libc经典例题

IDA分析构思整体思路

接下来,我们来看一道例题

首先,借助IDA对本题进行逆向分析,然后查看mian函数。

我们可以看到这里存在栈溢出点,在IDA中没有找到system以及其他后门函数,但程序中调用了puts函数。

查看ASLR发现输出为2,代表完全开启,因此,我们需要利用栈溢出,调用puts去打印出puts@got.plt内存储的真实地址,完成libc地址泄露。

Payload1初次撰写与碰壁

我们像往常一样根据IDA做静态分析,缓冲区大小是0x64,那理论上填0x64字节,再覆盖掉保存的ebp4个字节,就到返回地址,就能控制EIP。

但是我拿这个算出来的值去写payload,怎么跑都不对,利用不成功。

后面用pwndbg的cyclic去动态测,才测出来真实偏移是112字节,和IDA手算的结果差了4字节。

这就暴露出一个问题,IDA伪代码只能看到我们写出来的局部变量。编译器自己搞的栈对齐、保护寄存器多开出来的栈空间,IDA是不会显示在伪代码里的。

所以做题的时候,不要直接拿IDA里面的数字手算偏移,这个不一定是准的。可以用cyclic跑一遍,拿到程序跑起来之后真实的溢出偏移。

Payload1:泄露地址

调整偏移后,写第一阶段payload,目标是泄露libc地址。

只需要把返回地址覆盖为puts@plt,调用puts打印got表。puts执行完跳回main,重新触发漏洞接收下一段payload,参数传入puts_got即可。

然后,我们接受输出的地址,并计算libc的基址。

Payload2:拿shell

现在我们已经算出libc基址了,直接拿基址分别算出system和/bin/sh的实际地址,拼好payload,就能拿到shell。

完整EXP

写到这里,就能成功获取程序的交互式shell啦!

小结

四种栈溢出利用方式小结

  1. ret2text
    利用程序.text段本身已存在的代码片段,比如程序内部自带system。
    条件:PIE关闭,Canary关闭。
  2. ret2shellcode
    栈上写入shellcode机器码,依靠jmp esp这类gadget跳转执行shellcode。
    条件:NX关闭,Canary关闭。
  3. ret2syscall
    利用gadget拼凑寄存器,直接调用int 0x80/syscall执行系统调用,不调用libc库函数。
    条件:需要对应的gadget,Canary关闭。
  4. ret2libc
    泄露GOT表地址算出libc基址,调用libc库中的system来获取shell。
    条件:可以泄露内存地址,Canary关闭。
  • 程序本身有system → ret2text
  • NX关闭 → ret2shellcode
  • 能找到syscall/int 0x80 gadget → ret2syscall
  • 可以泄露地址 → ret2libc



评论(0)

查看评论列表

暂无评论


发表评论

表情 颜文字
插入代码