JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

釋放後使用與重複釋放

第 3 篇追捕的是你忘了釋放的那塊記憶體。這一篇追捕相反的錯誤:你太急著釋放、之後卻又去碰的那塊記憶體。釋放後使用與重複釋放,是 C 裡最危險的臭蟲之一——安靜、時有時無,還是攻擊者的最愛。這裡會說清楚它們究竟怎麼發生,以及如何讓它們不可能發生。

區塊沒了,指標卻還記得

從第 2 篇你帶著一個清楚的事實:指標與它所指的區塊,是兩件各有生命期的不同東西。當你對一塊區塊呼叫 free(),你結束的是那塊區塊的生命期——你告訴配置器「我用完了,這些位元組你可以拿去重用」。但 free() 不會動到你的指標。在 `free(p)` 之後,變數 `p` 仍握著它前一刻握著的同一個位址;只是現在它指向配置器認定為空閒的記憶體。這個過時的指標有個名字:懸空指標,也就是你先前已順帶見過的懸空指標。區塊沒了,指標卻還記得它曾經住在哪裡。

現在陷阱布好了。如果你對那個懸空指標解參考——讀 `*p`、透過它寫入、或把它當成仍擁有活記憶體那樣往下傳——你就犯了釋放後使用,也就是本篇得名的釋放後使用。可怕的地方在於:釋放一塊區塊之後緊接著發生的事——什麼都沒發生。那些位元組通常還原封不動地待在那裡,所以 `printf("%d", *p)` 也許印出的正是你預期的值。這個臭蟲安靜,是因為那具屍體看起來還活著。它只在稍後才咬人——一旦配置器把那同一批位元組交給下一次 malloc(),而你程式的另一部分把自己的資料寫在你的之上。

char *p = malloc(16);
strcpy(p, "hello");
free(p);                /* the 16 bytes go back to the allocator */

/* p is now DANGLING: same address, but the block is no longer yours */
printf("%s\n", p);      /* use-after-free: may print "hello"... for now */

char *q = malloc(16);   /* allocator likely hands back the SAME bytes */
strcpy(q, "world");     /* now p and q alias; reading *p sees "world" */
一個藏在眼皮底下的釋放後使用。在 free() 之後讀 p 常常「沒事」,直到稍後的 malloc() 重用了那塊區塊——這時 p 就悄悄看見別人的資料了。

重複釋放:把同一塊區塊還回去兩次

孿生的臭蟲是重複釋放:對同一塊區塊呼叫了兩次 free()。回想第 1 篇的合約——每一塊 malloc 來的區塊都必須剛好被釋放一次。釋放一次是對的;釋放零次是第 3 篇的漏洞;釋放兩次就是重複釋放,而它是未定義行為。為什麼第二次 free() 這麼糟?因為 free() 不只是把位元組標成可用——它還會把記帳資訊寫進配置器自己的資料結構,把那塊區塊串到一份內部、由可重用碎塊組成的空閒串列上。第二次 free() 會弄壞那些結構,在配置器所信任的中介資料上亂塗。

重複釋放很少來自把 `free(p); free(p);` 寫在相鄰兩行——那種你會抓到。它們來自指向同一塊區塊的兩個指標。一個函式釋放了某個節點然後回傳;持有同一位址自有副本的呼叫者,在清理時又把它釋放了一次。或是某條錯誤路徑先釋放了一塊區塊,接著掉進一個共用的 `goto cleanup:`、把它再釋放一次。這正是第 2 篇堅持所有權必須單一的原因:當兩段程式碼各自相信自己擁有那塊區塊,各自盡責地把它釋放,第二次 free() 就引爆了。一個重複釋放,幾乎總是換了張面具的所有權混淆。

為什麼這些不只是「當機」:未定義行為與漏洞利用

我們很容易把釋放後使用與重複釋放歸進「反正它會段錯誤,我會看到」這一檔。那是危險的誤解,而老實說明為什麼,很重要。兩者都是未定義行為——它既不等於「當機」,也不等於「平台相依」。它的意思是 C 標準對接下來會發生什麼不施加任何約束,而最佳化器被允許假設你的程式永遠不會做這件事。所以一個釋放後使用,可能在 `-O0` 印出正確的值,到了 `-O2`,編譯器——既已證明那塊區塊被釋放了——便可能把它周圍的程式碼重排或刪掉,於是症狀飄移或消失。沒有當機,不是臭蟲消失的證據;而是臭蟲躲起來的證據。

還有一個更尖銳的理由要認真看待它們:它們會被武器化。一個釋放後使用,是送給攻擊者的禮物。如果他們能設法讓一次新的配置落在你懸空指標仍指著的那批位元組裡,他們就能決定你的程式透過那個指標讀什麼、寫什麼——在最壞的情況下,包括一個他們能改指向自選程式碼的函式指標。重複釋放則以數十年來都被做成可靠漏洞利用的方式弄壞配置器的中介資料。這些不是學術上的邊角案例;它們高踞於每一份現實世界記憶體安全漏洞清單的前段,而這正是下一個章節要把 C 拿來和 Rust 的所有權保證秤一秤的全部理由。

讓臭蟲現形:偵測器

正因為這些臭蟲天生安靜,你能做的最糟的事就是靠肉眼盯它們。對的做法是讓機器替你逮住它們,而針對這一類最銳利的工具是 AddressSanitizer,也就是你用 `gcc -fsanitize=address -g main.c` 編進程式裡的 AddressSanitizer(clang 用法相同)。它會在每一次配置與每一次記憶體存取上插樁。當你釋放一塊區塊,它不會立刻回收那些位元組;它會把它們放進隔離區並下毒,於是稍後透過懸空指標的任何讀或寫,都會當場被逮——把一次安靜的釋放後使用,變成一份大聲又精確的報告,附上釋放那塊區塊的那一行、以及之後碰它的那一行。

AddressSanitizer 逮重複釋放同樣俐落,會印出 `attempting double-free` 並附上兩次 free() 的呼叫堆疊。代價很誠實:它讓你的程式大約慢一倍、記憶體用量變大,所以你在開發與測試時跑它,而非在正式環境。把它和第 3 篇的 valgrind memcheck 搭配使用——valgrind 不需重新編譯,有時能逮到偵測器漏掉的東西,而偵測器則快得多、報告也更俐落。兩者都不是可有可無的江湖傳聞:在認真的 C 工作裡,你會假設每一個測試都跑在它們其中之一底下,因為一個逃進正式環境的釋放後使用,正是你無法靠盯著看就重現出來的那種臭蟲。

把門關上的紀律

工具是在你寫出這些臭蟲之後找到它們;紀律則讓你根本不寫出它們。底下每一個習慣都直接回溯到第 2 篇的教訓——讓所有權保持單一而明確——因為那正是這些臭蟲長出來的根。這些都不是繁文縟節;它們是一小撮細微、機械式的反射動作,只要一貫地維持,就能讓釋放後使用與重複釋放幾乎不可能在不經意間犯下。

  1. 在每一次 free(p) 之後,立刻寫 p = NULL。稍後若有一次脫韁的讀取,會變成你找得到的乾淨空指標解參考崩潰;稍後若有一次脫韁的 free(),會變成無害的 free(NULL)。
  2. 讓所有權保持單一:當你傳遞或儲存一個指標時,先決定你是在移轉所有權、還是只是出借。只有擁有者才會呼叫 free();借用者絕不可釋放它並不擁有的東西。
  3. 在每一條路徑上,只在剛好一個地方釋放一塊區塊。在錯誤路徑上,讓單一的 goto-cleanup 標籤擁有那個 free(),這樣兩個分支就永遠不會把同一塊區塊釋放兩次。
  4. 把帶 -fsanitize=address(以及 valgrind)來建置並執行你的測試當成例行公事,而非最後手段——在工具讓它們現形之前,這些臭蟲都是看不見的。

把這些和第 3 篇的漏洞擺在一起,一幅清晰的圖就浮現了。釋放得太晚或從不釋放,你就漏掉記憶體。釋放得太早卻還繼續用,你就有了釋放後使用。釋放兩次,你就有了重複釋放。這三者其實是同一個根源的失敗——一塊所有權不清的區塊——只是從三個角度看。讓所有權單一而明確、在一個已知的地方剛好釋放一次、之後把指標設為空值、再跑一個偵測器,你就把那每一道門都關上了。而那套同樣艱辛的紀律,正是下一個章節的所有權系統要交給編譯器、替你強制執行的東西——也正是這個章節該把你領往的去處。