10 min read

Buffer Overflow 沒你想的可怕:從「杯子裝太滿」講到拿 Shell

Buffer Overflow 沒你想的可怕:從「杯子裝太滿」講到拿 Shell

這篇是上一篇 MBPTL 通關紀錄的延伸番外。上一篇打到最後一關(Phase 7,那個 31337 的 binary),我只放了「送一串 A 加三個地址就拿到 shell」的結果,沒有解釋為什麼。這篇就專門把那串看起來像黑魔法的東西,用最白話的方式拆給你看——不需要任何 pwn 基礎,看完你會發現它其實沒那麼可怕。


先講一句話:這個漏洞的本質是「寫太多,滿出來」

你一定遇過網頁表單「最多輸入 20 字」的限制。那個限制不是為了刁難你——是因為程式在背後準備了一塊「剛好 20 格」的空間放你的字。如果程式忘了檢查長度,而你硬塞了 200 個字進去,會發生什麼事?

答案是:多出來的字不會憑空消失,它們會往旁邊的記憶體溢出去,覆蓋掉本來放在那裡的東西。這就是 buffer overflow(緩衝區溢位)——名字聽起來很硬,但概念就是「杯子裝太滿,水漫到桌上」。

問題在於:漫出去的水,剛好淹到了一個很關鍵的東西。

圖解一:正常輸入 vs 塞爆

先看程式在記憶體裡怎麼擺放。它準備了一排格子(叫 buffer)放你的輸入,而在這排格子的「正上方」,藏著一個叫「回程地址」的東西——記著程式這段做完之後,下一步該回去哪裡繼續。

Buffer overflow 記憶體示意圖正常輸入只填滿 buffer;超長輸入淹過 buffer、蓋掉程式的返回位址。 正常輸入 超長輸入(塞爆) 回程地址(return) saved RBP buffer(放你的輸入)128 格空位 輸入「Kevin」→ 只填幾格,安全 回程地址 ← 被蓋掉! AAAA…(淹過來) AAAA…AAAA128 格全塞滿 輸入 140 個 A → 滿出來、蓋到上面 往上淹 白話比喻 buffer 就像一排 128 格的信箱,最上面那格寫著「事情辦完後回哪裡」。 你只該寫進信箱格子裡,但沒人擋你——你狂寫,一路滿出來, 把「回哪裡」那格也覆蓋成你要的地址。程式辦完事,就照你寫的地址走。

左邊是正常情況:你輸入「Kevin」,只填了幾格,上面的「回程地址」安然無恙。右邊是攻擊:你輸入 140 個 A,把 128 格填滿之後還有剩,多出來的 A 一路往上淹,把「回程地址」那格也蓋掉了

(技術細節一句話:這個 lab 的程式用了一個叫 gets() 的老函式讀取輸入——它是出了名的危險,因為它完全不檢查長度,你要塞多長它就收多長。現代程式早就不該用它,但拿來當教學範例剛剛好。)

圖解二:蓋掉「回程地址」,等於接管方向盤

光是蓋掉一格資料,聽起來好像也還好?關鍵在於「回程地址」這格不是普通資料,是程式的下一步指令

程式每做完一段工作,都會回頭看這一格,照上面寫的位址跳過去繼續執行。既然我們剛剛把它蓋成了自己想要的地址——程式就會乖乖跳去我們指定的地方。

劫持回程地址程式執行完後會照回程地址那格跳過去;蓋掉它就等於指定程式下一步去哪。 程式辦完事,看這一格 回程地址被我們蓋成一個地址 AAAA…(墊料) buffer 塞滿的 A 跳去 我們指定的地方程式乖乖跳過來執行 關鍵轉折 程式每做完一段,都會回頭看「回程地址」那格,照上面寫的位址跳過去繼續。 既然我們剛剛把那格蓋成自己想要的地址——程式就會跳去我們指定的地方。 從「蓋掉一格」變成「控制程式下一步」,這就是拿到主控權的瞬間。

這一步是整個攻擊的靈魂:從「我能覆蓋一格記憶體」升級成「我能決定程式下一步跳去哪」。方向盤到手了。

圖解三:跳去哪才有用?用現成零件拼出一個 shell

方向盤到手了,但新問題來了——跳去哪才能拿到主控權?我們想要的是一個 shell(能下指令的命令列),但程式裡又沒有一段寫好的「請給我一個 shell」。

好消息是:程式裡有零件。這個 lab 的 binary 裡剛好躺著兩樣東西——一個能「執行指令」的函式(叫 system),跟一段寫著 /bin/sh(就是 shell 的名字)的文字。我們要做的,是把這兩個零件組起來,變成「執行 /bin/sh」。這個手法叫 ret2libc

組裝需要三步。這裡有個規則要先知道:在這種電腦上,要叫一個函式「處理某樣東西」,得先把那樣東西的地址放進一個叫 rdi 的暫存器(可以想成「函式的收件匣」)。所以順序是:先把 /bin/sh 放進收件匣,再叫 system 動工。

ret2libc 三步驟用程式現有的零件,分三步組出 system 開啟 /bin/sh。 用現成零件拼出「執行 /bin/sh」 ① 跳 gadget借一小段現成碼 ② 放進收件匣收件匣 = "/bin/sh" ③ 叫 system 動工開啟 shell 這三步,其實就是疊在堆疊上的三個地址 ③ system 的地址 ② "/bin/sh" 的地址 ① gadget 的地址 136 個 A(墊料) 墊料把洞填到剛好,接著三個地址被「回程 → gadget → system」依序串起來, 程式像被牽著走一樣,一步步自己走進「執行 /bin/sh」。

那個「① gadget」是什麼?它是我們從程式現有的機器碼裡挖出來的一小段碎片,剛好能做「把一個值放進收件匣,然後繼續」這件事。我們不能無中生有寫程式碼進去(後面會講為什麼),但我們可以借程式自己身上的零件來用——這就是這門技術聰明的地方,全程沒有夾帶任何一行自己的程式碼,純粹是把別人的積木重新排列。

而下半張圖揭曉了那串神秘 payload 的真面目:它就是「一堆墊料 + 三個地址」依序疊在記憶體上。墊料負責把洞填到剛好頂到「回程地址」,然後三個地址一個接一個,把程式像牽線木偶一樣,一步步牽進 system("/bin/sh")

回到上一篇那串「看不懂的 magic」,現在對照起來就清楚了:

payload = b'A' * 136        # 墊料:把洞填滿,剛好頂到回程地址
        + p64(gadget)       # ① 蓋掉回程地址 → 跳去 gadget
        + p64(binsh)        # ② 被 gadget 放進收件匣的值 = "/bin/sh"
        + p64(system)       # ③ 最後跳去 system,動工

那個「136」不是亂數,是精算出來的——buffer 有 128 格,再加上中間 8 格,剛好 136 格之後就是「回程地址」。多一格少一格都會失敗。

那為什麼現代程式大多不會這樣被打?三道鎖

看到這裡你可能會想:這麼簡單,那全世界的程式不是都完蛋了?其實沒有——現代編譯器早就裝了三道防禦,讓上面每一步都變得極難。這個 lab 之所以能一路暢通,是因為它刻意把三道鎖全關了當教材。

三道防禦機制現代程式有三道鎖防 buffer overflow:金絲雀、位址隨機化、堆疊不可執行。 現代程式的三道鎖(這關全沒上) 金絲雀Stack Canary 回程地址前放一個隨機暗號,被改到就當場中止 位址隨機化PIE / ASLR 每次執行位址都變,你寫不死零件的地址 堆疊不可執行NX 不能把惡意碼塞進去直接跑(所以我們用「借」的) 三道鎖任何一道上了,難度就跳級——這關剛好三道全關,才會是入門教材。
  • 金絲雀(Stack Canary):在「回程地址」前面偷偷放一個隨機暗號。程式跳回去之前會先檢查暗號還在不在——你溢位時一定會蓋到它,暗號一對不上,程式當場自爆,你根本來不及劫持。名字來自礦工帶金絲雀下礦坑:鳥先死,人就知道有毒氣。
  • 位址隨機化(PIE / ASLR):程式每次執行,零件擺放的位置都隨機洗牌。上面 payload 裡那三個「地址」你根本寫不死——這次是這個位置,下次就變了。(如果你有玩過 ARM 漏洞,這個你應該不陌生,繞過 ASLR 是進階題的家常便飯。)
  • 堆疊不可執行(NX):早年的攻擊是直接把惡意程式碼塞進 buffer 裡跑。NX 這道鎖規定「這塊記憶體只能放資料、不准當程式執行」。這也正是為什麼我們不自己塞程式碼、而是去「借」程式現有的零件——ret2libc 這套手法就是為了繞過 NX 而生的。

這三道鎖只要上了任何一道,難度都會跳一整級。真實世界的程式通常三道全開,所以現代的 pwn 高手,大半功夫都花在「怎麼一道一道繞過這些鎖」——那是另一個深得多的坑了。

收尾

把整條線串起來,一句話:

程式忘了檢查長度 → 我塞爆 buffer、淹過去蓋掉「回程地址」 → 於是我能決定程式下一步跳去哪 → 我用它自己身上的零件,一步步把它牽進一個 shell。

回頭看上一篇那串 b'A'*136 + 三個地址,現在應該不再是黑魔法了——它就是「墊料填到剛好,再用三個地址把程式牽著走」。

如果你跟我一樣,以前看到 pwn 就直接跳過,希望這篇讓你覺得:它硬歸硬,但骨架其實很好懂。真正難的不是這個入門版,而是三道鎖全開之後怎麼繞——但那個,就留到你真的想跳進這個坑的那天再說吧。