三、格式化字符串漏洞
譯者:飛龍
日期:2001.9.1
版本:v1.2
格式化字符串漏洞的通常分類是“通道問題”。如果二類不同的信息通道混合為一個,并且特殊的轉義字符或序列用于分辨當前哪個通道是激活的,這一類型的漏洞就可能出現。多數情況下,通道之一是數據通道,它不會解析,只會復制,而另一個通道是控制通道。
雖然對于其本身來說并不是件壞事,如果攻擊者能夠提供用于某個通道的輸入,它可能很快成為嚴重的安全問題。通常存在錯誤的轉義,或者反轉義的途徑,或者忽視了某個層面,就像格式化字符串漏洞中那樣。所以我們總結一下:通道問題本身沒有任何漏洞,但是它們使得 bug 可以利用。
為了展示它背后的普遍問題,這里是一個常見通道問題的列表:
場景 | 數據通道 | 控制通道 | 安全問題 |
---|---|---|---|
電話系統 | 聲音或數據 | 控制音調 | 線路控制 |
PPP 協議 | 傳輸數據 | PPP 命令 | 流量放大 |
棧 | 棧數據 | 返回地址 | 返回地址控制 |
Malloc 緩沖區 | Malloc 數據 | 管理信息 | 內存寫入 |
格式化字符串 | 輸出字符串 | 格式化參數 | 格式化函數控制 |
回到特定的格式化字符串漏洞,有兩種典型的場景,其中產生了格式化字符串漏洞。
第一類(Linux rpc.statd 和 IRIX telnetd 中)。漏洞存在于syslog
的第二個參數中。格式化字符串部分是用戶提供。
char tmpbuf[512];
snprintf (tmpbuf, sizeof (tmpbuf), "foo: %s", user);
tmpbuf[sizeof (tmpbuf) - 1] = ’\0’;
syslog (LOG_NOTICE, tmpbuf);
第二類(wu-ftpd 和 Qualcomm Popper QPOP 2.53 中)。部分由用戶提供的字符串簡介傳給了格式化函數。
int Error (char *fmt, ...);
...
int someotherfunc (char *user) {
...
Error (user);
...
}
...
雖然第一類漏洞能夠由自動化工具安全監測(例如 pscan 或 TESOgcc),只有工具被告知函數Error
用作格式化函數,第二類漏洞才能檢測出來。
但是,你可以自動化識別源碼中的額外格式化函數,以及它們的參數的過程,所以總之,尋找格式化字符串的過程可以完全自動化。你甚至可以歸納出,如果有這樣的工具來完成這件事,并且它沒有在你的源碼中發現格式化字符串漏洞,你的源碼就沒有這類漏洞。這不同于緩沖區溢出漏洞,其中即使由資深審計者手動審計了源碼,還是會錯過漏洞,并且沒有可靠的方式來自動化找出它們。
3.1 我們能夠控制什么?
通過提供格式化字符串,我們就能夠控制格式化函數的行為。我們現在需要檢驗我們具體能夠控制什么,以及如何使用它來擴展這個對進程的部分控制,來完全控制執行流。
3.2 使程序崩潰
使用格式化字符串漏洞的簡單攻擊,就是使進程崩潰。這對于某些事情是實用的,例如使守護進程崩潰,它會轉儲核心,并且在核心轉儲中有一些有用的數據。或者在一些網絡攻擊中,讓一個服務無法響應十分有用,例如 DNS 偽造。
但是,在使其崩潰中有一些趣味。幾乎所有 UNIX 系統中,內核都會檢測非法指針訪問,并且進程會接收到SIGSEGV
信號。通常程序會終止并轉儲核心。
通過利用格式化字符串,我們可以輕易觸發一些無效指針訪問,通過僅僅提供像這樣的格式化字符串:
printf ("%s%s%s%s%s%s%s%s%s%s%s%s");
由于%s
展示某個地址中的內存,這個地址位于棧上,棧上也儲存了大量其他數據。我們就有很大機會來從非法地址服務數據,這個地址并沒有映射。同時,多數何世華函數的實現提供了%n
參數的功能,他可以用于向棧上的地址寫入。如果它執行了幾次,也一定會產生崩潰。
3.3 查看進程內存
如果我們可以查看格式戶函數的回復 -- 也就是輸出字符串 -- 我們就可以從中收集有用信息,因為它是我們所控制的行為的輸出。而且我們可以使用這個結果,來獲得我們的客戶端字符串做了什么,以及進程的布局是什么樣的概覽。
這對于很多東西都很使用,例如為真正的利用尋找正確的偏移,或者僅僅是重新構造目標進程棧幀。
3.3.1 查看棧
我們可以展示棧內存的一些部分,通過像這樣使用格式化字符串:
printf ("%08x.%08x.%08x.%08x.%08x\n");
這可以工作,因為我們讓printf
函數來從棧中獲取五個參數,并將其展示為 8 位填充的十六進制數值。所以可能的輸出是:
40012980.080628c4.bffff7a4.00000005.08059c04
這是棧內存的部分轉儲,從當前的棧底一直到棧頂 -- 假設棧向低地址增長。取決于格式化字符傳緩沖區的大小,以及輸出緩沖區的大小,使用這種技巧,你可以或多或少重構棧內存的一部分。在一些情況下,你甚至可以獲取整個棧內存。
棧的轉儲提供了關于程序流以及函數局部變量的重要信息,并且可能對于尋找正確偏移以便成功利用有所幫助。
3.3.2 查看任何地址的內存
我們也可以查看不同于棧內存的任意地址。為此,我們需要讓格式化函數從我們可以提供的某個地址展示內存。這就有兩個問題:首先,我們需要找到一個格式化字符串,它將某個地址(傳值)用作棧的參數,并且展示其中的內存,并且我們需要提供這個地址,我們在第一種情況中足夠幸運,由于%s
參數就是干這個的,它展示內存 -- 通常是 ASCIIZ 字符串 -- 從棧上提供的地址。所以剩下的問題是,如何將這個棧上的地址放到正確的位置上。
我們的格式化字符串通常位于棧上,所以我們已經距離完全控制這個區域非常近了,格式化字符串就在這里。格式化函數在內部維護一個指針,指向當前格式化參數的棧區域。如果我們能夠將這個指針指向一塊可控的內存區域,我們就能向%s
參數提供一個地址。為了修改棧指針,我們可以僅僅使用假的參數,它會通過打印垃圾來挖掘棧區。
這里我們假設我們能夠完全控制整個字符串。我們稍后會看到,部分控制,字符串過濾,空字節包含的地址,以及類似的問題都會存在,無論何時利用字符串格式化漏洞。
printf ("AAA0AAA1_%08x.%08x.%08x.%08x.%08x");
%08x
參數使格式化函數內部的棧指針向棧頂方向增加。將這個參數增加之后,棧指針就指向了我們的內存:格式化字符串本身。格式化函數總是維護最低的棧幀,所以如果我們的緩沖區完全在棧上,它一定會在當前棧指針的上面。如果我們正確選擇了%08x
的數值,我們就能夠展示任意地址的內存,通過向我們的字符串附加%s
。在我們的例子中,地址是非法的,它是AAA0
。讓我們將其換成真實的地址。
例如:
address = 0x08480110
// address (encoded as 32 bit le string): "\x10\x01\x48\x08"
printf ("\x10\x01\x48\x08_%08x.%08x.%08x.%08x.%08x|%s|");
就會轉儲0x08480110
的內存,直到到達了空字符。通過動態增加內存地址,我們可以查看整個進程空間。甚至可以創建遠程進程的核心轉儲,就像映像那樣,以及從中重新構建二進制。尋找利用不成功的原因也是很有用的。
如果我們不能通過使用 4 字節的 POP 來達到精確的格式化字符串的邊界,我們需要填充格式化字符串,通過前置一個、兩個或三個垃圾字符。這就好比緩沖區溢出利用中的對齊。
我們不能夠按位移動棧指針,反之我們移動格式化字符串本身,以便到達棧指針的四字節邊界,并且我們可以使用多個四字節 POP 來到達它。
3.4 任意內存覆蓋
漏洞利用的圣杯就是控制進程的指令指針。在多數情況下,指令指針(通常命名為 IP,或者 PC)是一個 CPU 中的寄存器,并不能直接修改,因為只有機器指令可以修改它。但是如果我們能夠改動機器指令,我們就已經控制了它。所以我們不能直接控制進程。通常,進程比起當前的攻擊者擁有更多的權限。
反之,我們需要尋找修改指令指針的指令,并且影響這些指令修改它的方式。這聽起來很復雜,但是多數情況下這非常簡單,因為有些指令從內存獲取指令指針,并且跳到那里。所以在多數情況下,控制了這部分內存,其中儲存了指令指針,就控制了指令指針本身。這就是多數緩沖區溢出的工作方式。
在兩階段的過程中,首先要覆蓋保存的指令指針,之后程序會指令一個合法的指令,它將控制流轉移到攻擊者提供的地址中。
我們會檢測一些不同的方式,使用格式化字符串漏洞來完成它。
3.4.1 利用 - 類似于常見的緩沖區溢出
格式化字符串漏洞有時提供了一個在緩沖區長度周圍的方式,并且和常見的緩沖區溢出的利用方式相似。這是出現在 QPOP 2.53 和 bftpd 中的代碼:
char outbuf[512];
char buffer[512];
sprintf (buffer, "ERR Wrong command: %400s", user);
sprintf (outbuf, buffer);
這種例子通常深藏在真實的代碼中,并且不會那么明顯,就像上面的例子那樣。通過提供一個特殊的格式化字符串,我們就能夠繞過%400s
的限制:
"%497d\x3c\xd3\xff\xbf<nops><shellcode>"
任何東西都和常見的緩沖區溢出類似,只是開頭 -- %497d
-- 不同。在常見的緩沖區溢出中,我們覆蓋了函數幀在棧上的返回地址。在擁有該幀的函數返回值,它會返回到我們提供的地址。地址指向<nop>
中的某個地方。有一些不錯的文章,描述了這一利用方式,并且如果這個例子對于你來說還不夠清楚,你應該考慮首先閱讀一篇入門文章,就像 [5] 那樣。
它創建了長度為 497 的字符串。再加上錯誤信息(ERR Wrong command:
),它超出了outbuf
緩沖區四個字節。雖然user
字符串只允許為 400 字節,我們可以通過不當使用格式化字符串參數來突破這個長度。由于第二個sprintf
不檢查其長度,它可以用于突破output
的邊界。現在我們寫入一個返回地址0xbfffd33c
,并且使用已知的舊辦法來利用它,就像我們在任何緩沖區溢出中所做的那樣。雖然任何允許拉伸的格式化參數都這樣,例如%50d
,%50f
或者%50s
,我們還是應該選擇一個不會提領指令或者可能導致除零錯誤的參數。這就排除了%50f
和%50s
。我們只剩下了整數輸出參數:%u
、%d
和%x
。
GNU C 庫包含一個 Bug,如果你使用 n 大于 1000 的%nd
參數,它會導致崩潰。這是一種判斷遠程 GNU C 庫的方式。如果你使用%.nd
,它正產工作,除非你用了很大的值。有關這個長度的深入討論,請見門戶網站的文章 [3]。
3.4.2 利用 - 只通過格式化字符串
如果我們不能使用剛剛提到的簡單的利用方式,我們仍舊可以利用這個過程。由此,我們可以擴展我們極其有限的控制 -- 控制格式化函數的能力 -- 到真實的執行流控制,它會執行我們的原始機器碼。看看這段代碼,它在 wu-ftpd 2.6.0 中發現。
char buffer[512];
snprintf (buffer, sizeof (buffer), user);
buffer[sizeof (buffer) - 1] = ’\0’;
在上面的代碼中,我們不能通過插入某些“拉伸”格式參數來擴大緩沖去,因為程序使用了安全的snprintf
函數來確保我們不能突破buffer
。最開始它像是,我們不能做很多有用的事情,除了使程序崩潰,并且窺探到一些內存。
讓我們回憶提到過的格式化參數。%n
參數將已經打印的字節數,寫入到我們所選的變量中。通過將整數指針放置到棧上作為參數,變量地址被提供給格式化函數。
int i;
printf ("foobar%n\n", (int *) &i);
printf ("i = %d\n", i);
它會打印i = 6
。使用我們在上面使用的相同方法來打印任何地址的內存,我們可以寫入任意地址:
"AAA0_%08x.%08x.%08x.%08x.%08x.%n"
使用%08x
參數,我們使格式化函數的內部棧指針增加了四個字節。我們這樣做,知道這個指針指向了我們格式化字符串的開頭(AAA0
)。這可以工作,因為我們的格式化字符串通常位于棧上,在我們的格式化函數棧幀的頂部。%n
向地址0x30414141
寫入,它由字符串AAA0
表示。通常這會使程序崩潰,由于地址沒有映射。但是如果我們提供了一個正確映射并且可寫的地址,這可以工作,并且我們在在該地址覆蓋了四個字節:
"\xc0\xc8\xff\xbf_%08x.%08x.%08x.%08x.%08x.%n"
上面的格式化字符串會將0xbfffc8c0
的四個字節覆蓋為一個小型整數。我們已經完成了目標之一,我們可以寫入任意地址。但是我們不能控制我們剛才縮寫的豎直 -- 但是這也會改變的。
我們所寫的豎直 -- 由格式化函數寫入的字符儲量 -- 取決于格式化字符串。因為我們控制了格式化字符串,我們至少可以影響這個數量,通過寫入或多或少的字節:
int a;
printf ("%10u%n", 7350, &a);
/* a == 10 */
int a;
printf ("%150u%n", 7350, &a);
/* a == 150 */
通過使用偽造的參數%nu
,我們就能控制由%n
寫入的數量,至少一位。但是對于寫入較大數量來說 -- 例如地址 -- 這還不足夠,所以我們需要找到一種方式來寫入任意數據。
x86 架構上的整數以四個字節儲存,小端序,最低字節在內存的開始。所以例如0x0000014c
的數值在內存中為\x4c\x01\x00\x00
。對于格式化函數中的數量,我們可以控制最低字節,也就是內存中首先儲存的字節,通過使用偽造的%nu
參數來修改它。
例如:
unsigned char foo[4];
printf ("%64u%n", 7350, (int *) foo);
當printf
函數返回時,foo[0]
包含\x40
,它等于 64,我們使用這個數值來增加計數器。
但是對于一個地址,我們需要完全控制四個字節。如果我們不能一次寫入四個字節,我們可以嘗試在一行中,寫入四次,一次寫入一個字節。在多數 CISC 架構中,能夠寫入未對齊的任意地址。這可以用于寫入內存的第二個低字節,其中儲存了地址,就像:
unsigned char canary[5];
unsigned char foo[4];
memset (foo, ’\x00’, sizeof (foo));
/* 0 * before */ strcpy (canary, "AAAA");
/* 1 */ printf ("%16u%n", 7350, (int *) &foo[0]);
/* 2 */ printf ("%32u%n", 7350, (int *) &foo[1]);
/* 3 */ printf ("%64u%n", 7350, (int *) &foo[2]);
/* 4 */ printf ("%128u%n", 7350, (int *) &foo[3]);
/* 5 * after */ printf ("%02x%02x%02x%02x\n", foo[0], foo[1], foo[2], foo[3]);
printf ("canary: %02x%02x%02x%02x\n", canary[0], canary[1], canary[2], canary[3]);
返回了輸出10204080
和canary: 00000041
。我們將我們所指向的整數的低地址字節覆蓋了四次。通過每次增加指針,低地址字節在我們想要寫入的內存中移動,并允許我們儲存完全任意的數據。
你可以在圖一的第一行看到,所有八個字節都沒有被我們的覆蓋代碼訪問。從第二行開始,我們執行了四次覆蓋,每一步都向右提升一個字節。最后一行展示了最終的預期狀態:我們覆蓋了foo
數組的所有四個字節,但是這樣做的時候,我們破壞了canary
的三個字節。我們包含了canary
數組,只是為了看到我們覆蓋了不想覆蓋的內存。
圖一:四階段的地址覆蓋
雖然這個方式看起來復雜,它也可以用于覆蓋任意地址的任意數據。為了解釋,我們現在為止只對每個格式化字符串使用了一次寫入,但是他可以在一個格式化字符串內執行多次寫入。
strcpy (canary, "AAAA");
printf ("%16u%n%16u%n%32u%n%64u%n", 1, (int *) &foo[0], 1, (int *) &foo[1], 1, (int *) &foo[2], 1, (int *) &foo[3]);
printf ("%02x%02x%02x%02x\n", foo[0], foo[1], foo[2], foo[3]);
printf ("canary: %02x%02x%02x%02x\n", canary[0], canary[1], canary[2], canary[3]);
我們使用參數1
作為%u
填充的偽造參數。同樣,填充發生了改變,因為字符數量在我們寫入32
的時候已經是16
了。所以我們只需要添加16
個字符,而不是32
個,來獲取我們想要的結果。
這是個特殊案例,其中所有字節在寫入過程中遞增。但是通過一個微小的修改,我們也可以寫入80 40 20 10
。由于我們寫入整數并且順序是小端的,在寫入過程中只有最低地址字節是重要的。通過使用0x80 0x140 0x220 0x310
的計數器,我們就可以構造預期的字符串。計算寫入字符預期數量的計數器的代碼是這個:
write_byte += 0x100;
already_written %= 0x100;
padding = (write_byte - already_written) % 0x100;
if (padding < 10) padding += 0x100;
其中write_byte
是我們想要創建的字節,already_written
是當前寫入數量,由格式化函數維護,padding
是我們已經使計數器增加的字節數,例如:
write_byte = 0x7f;
already_written = 30;
write_byte += 0x100; /* write_byte is 0x17f now */
already_written %= 0x100; /* already_written is 30 */
/* afterwards padding is 97 (= 0x61) */
padding = (write_byte - already_written) % 0x100;
if (padding < 10) padding += 0x100;
現在格式化字符串%97u
會增加%n
計數器,使最低地址字節等于write_byte
。最后檢查了填充是否低于 10,這非常需要注意。一個簡單的整數輸出,例如%u
最多可以生成十個字符的字符串,取決于所輸出的整數值。如果所需長度大于我們指定的填充,假如我們想要使用%2u
輸出1000
,我們的值就會丟棄,以便不會丟失任何有意義的輸出。通過確保我們的填充永遠大于 10,我們可以使already_written
的數值永遠保持精確,它是格式化函數維護的計數器,由于我們總是使用格式化參數中的長度選項,寫入大量的輸出,就像我們指定的那樣。
這取決于格式化函數所運行的操作系統的默認字長,我們假設這里是基于 ILP32 的架構。
在實踐過程中,為了利用這種漏洞,唯一剩下的事情就是將參數以正確的順序放到棧上,并且使用棧的 POP 序列來增加棧指針。它看起來像:
A
<stackpop><dummy-addr-pair * 4><write-code>
譯者注:我更推薦把
dummy-addr-pair
放在stackpop
前面,這樣偏移數量更小,stackpop
長度更短,而且stackpop
的長度就不會影響偏移,也不需要對齊。
stackpop
:棧的 POP 序列,它會彈出參數,增加棧指針。一旦開始處理stackpop
,格式化函數的內部棧指針就會指向dummy-addr-pair
字符串。dummy-addr-pair
:四對偽造整數值,和要寫入的地址。每一對中,地址逐個遞增,偽造的整數可以是不含空字符任何東西。write-code
:格式化字符串實際寫入內存的部分,通過使用%{n}u%n
偶對,其中{n}
大于 10。第一個部分用于增加或溢出格式化函數內部字節寫入計數器的最低地址字節,%n
用于將這一數值寫入dummy-addr-pair
部分中的地址。
write-code
需要修改來匹配由stackpop
寫入的字節數,因為當格式化函數解析write-code
的時候,stackpop
已經向輸出寫入了一些字符 -- 格式化函數的計數器已經不是從零開始了,并且這個應該考慮到。
我們所寫入的地址叫做返回地址位置,簡寫為retloc
,我們使用格式化字符串在此處創建的地址叫做返回地址,簡寫為retaddr
。