24 min read

逆向工程到底在逆什麼?—— 寫給會寫程式、但沒碰過組語的你

逆向工程到底在逆什麼?—— 寫給會寫程式、但沒碰過組語的你

這是逆向系列的觀念篇,只講觀念,幾乎不出現組語,下一篇是方法篇,讀者設定:你會寫程式(懂變數、函式、迴圈就夠),但從來沒碰過反組譯、Ghidra、組合語言,讀完你會知道「逆向在幹嘛、為什麼需要那一堆工具」,之後看方法篇才不會被組語嚇跑。


先回答那個沒好好解釋的問題

你可能看過很多逆向的文章,裡面都是一堆十六進位、暫存器、mov ldr cmp,看起來像天書,然後你心裡有個問題一直沒被回答:

這些人到底在幹嘛?他們在「逆」什麼?

我用一句話先給你答案,這一篇剩下的部分都是在把這句話講清楚:

你寫的原始碼,在編譯的時候被「壓扁」了,壓扁的過程丟掉了很多東西,逆向,就是拿著那個被壓扁的成品,把丟掉的東西盡量還原回來。


等一下,這技能到底能幹嘛?

在講原理之前,先給你讀下去的理由,逆向不是只有駭客電影裡才用得到,它解決的是同一類問題:你手上有一個程式,但沒有它的原始碼,而你需要知道它在幹嘛。

實際的場景:

  • 資安研究/漏洞挖掘 —— 廠商不會給你原始碼,但你要找出它哪裡寫錯了
  • 惡意程式分析 —— 抓到一個可疑檔案,它到底在偷什麼、連去哪裡?
  • 軟體相容與維護 —— 公司有個十五年前的系統,寫的人跑了、文件沒了、原始碼不見了,但它還在跑
  • 確認你買的東西在幹嘛 —— 那個 App 是不是在背景偷傳你的資料?
  • CTF 與資安競賽 —— 逆向是固定出現的大題型
  • 純粹的好奇 —— 這個功能是怎麼做到的?

共通點都是「黑箱」,逆向就是把黑箱打開的技術。


一個你每天都在做、但沒意識到的事

你寫這樣一段程式:

int check_password(char *user_input) {
    char *correct = "hunter2";
    if (strcmp(user_input, correct) == 0) {
        return 1;
    }
    return 0;
}

(用 C 舉例是有原因的:C 會被編譯成 CPU 直接執行的機器碼,最能示範這篇要講的事,Python、JavaScript 這類語言的運作方式不太一樣,它們有自己的一套,我們先不管。)

你按下編譯,電腦給你一個執行檔。

問你一個問題:那個執行檔裡,還找得到 check_password 這個名字嗎?找得到 correct 這個變數名嗎?

答案是:不一定,而且這兩個名字的命運還不一樣。

correct 這種函式內部的變數名,在不含除錯資訊的一般 release 版本裡通常找不到check_password可能還在(留在符號表裡),也可能在後續處理中被移除。

但真正重要的不是「在不在」,而是這件事:CPU 執行的時候,完全不靠這些名字,它們對程式的運作是可有可無的 —— 這才是為什麼它們「可以」被丟掉。

它們從來就不是給電腦看的,它們是給你看的。


電腦眼中的世界,沒有「名字」

這是整個逆向的地基,我慢慢講。

你寫程式的時候用名字:user_inputcheck_passwordcorrect,這些名字讓能理解程式在幹嘛。

但 CPU 不需要名字,CPU 只認得三種東西:

  1. 位置 —— 「去第 5000 號格子拿東西」
  2. 暫存器 —— CPU 手邊的幾個小抽屜,用來暫放正在處理的數字
  3. 動作 —— 「把這兩個數字相加」「如果相等就跳到第 3000 號格子」

注意這裡面沒有「名字」,對 CPU 來說,check_password 不是一個名字,是「從第 4384 號格子開始的那一段指令」。

至於 correct 這個變數,它的處境更有趣一點:correct 本身是一個指路標(它記著「那串文字放在哪」),而 "hunter2" 這串文字本身被放在另一個地方,編譯後,指路標可能存在 CPU 的小抽屜裡、可能存在某個暫存區、甚至可能被編譯器發現「根本不需要這個指路標」而直接省掉;而那串文字,通常會被放進一塊專門存放固定資料的區域

這個區別現在看起來很細,但它很重要 —— 後面我們講「為什麼用工具撈得到字串」,撈到的就是那塊區域裡的東西。

所以編譯做的事,本質上是翻譯

你寫的(給人看)              編譯後(給 CPU 看)
─────────────────────────────────────────
check_password          →    位置 0x1120 的那段指令
user_input              →    暫存器裡的一個值
correct(指路標本身)    →    暫存器或暫存區裡的一個值,
                             也可能被編譯器發現不需要而省掉
"hunter2"(文字本身)    →    通常放進一塊唯讀資料區
if a == b               →    「比較,然後跳」

翻譯完之後,那些名字就沒用了,CPU 靠位置和暫存器就能跑,名字可以整包丟掉。

這裡要澄清一個常見的誤會:編譯和「移除名字」不是同一件事。

編譯之後,CPU 已經不需要那些名字了 —— 但名字未必立刻消失,很多建置流程會把符號留著(給連結器用、給除錯器用),所以你拿到的執行檔可能還帶著一部分名字。

strip 是移除這些資訊的常見方式之一(剝除的意思)—— 它會清除執行時不需要的符號與除錯資訊,讓檔案更小。但不是只有它會讓名字消失:編譯器和連結器也可能依照建置設定,一開始就不保留某些名稱。

所以一個名字的命運有好幾種:可能從一開始就沒被寫進檔案可能保留在符號表或除錯資訊裡也可能在連結或 strip 的時候被移除

「有沒有除錯資訊」「有沒有 strip」「是 release 還是 debug」——這是相關但不同的幾件事,不要混成一件。你真正該養成的習慣是:拿到檔案先看它現在還剩下什麼,而不是假設它應該剩下什麼。


用一個比喻把這件事鎖死

想像一本食譜。

編譯=把「標籤」撕掉,只留「精確的位置」 同一份食譜,人看的版本有語意,機器跑的版本只有座標 原始碼(給人看) 取「主要食材」 (雞胸肉)200 克 ← 標籤告訴你「那是什麼」 加入「醃料 A」 (醬油、糖、米酒) ← 括號裡是給人的說明 靜置 30 分鐘 看得懂在幹嘛 編譯 撕標籤 執行檔(給 CPU 看) 取 3 號櫃第 2 格 的 200 克 位置精確,但不知道是什麼 加入 5 號罐 (???) 罐裡調了什麼,看不到 靜置 30 分鐘 照做得出來,卻不懂在幹嘛 關鍵:撕掉標籤後,資訊不是變模糊,是「變精確但沒有意義」 逆向=拿著右邊這本,靠動作和順序,把每個「那是什麼」反推回去
圖解 1:編譯就像把食譜的標籤全部撕掉 —— 位置還在,意義沒了。

原文(給人看的版本)撕掉標籤的版本差在哪?我們一步步看。

原始碼版本的食譜長這樣:

「取『主要食材』(雞胸肉)200 克,加入『醃料 A』(醬油、糖、米酒),靜置 30 分鐘。」

括號裡的東西、還有「主要食材」「醃料 A」這種標籤,是給讀的人理解用的。

編譯後的食譜,被人把所有標籤撕掉了,變成:

「取 3 號櫃第 2 格的 200 克,加入 5 號罐,靜置 30 分鐘。」

你還是能照著做(位置、數量、順序都精確地在),但你不知道 3 號櫃第 2 格裝的是什麼、5 號罐裡調了什麼。

注意這個比喻的重點:撕掉標籤之後,剩下的資訊不是變模糊了,而是變得精確但沒有意義——機器需要的是「去哪裡拿」,人需要的是「那是什麼」。編譯丟掉的正是後者。

這就是編譯後的程式,動作全在、跑得起來,但所有幫助理解的標籤都被撕掉了。

逆向工程,就是拿著這本被撕掉標籤的食譜,靠著「動作和順序」反推出每個『那個』到底是什麼。

  • 看到「靜置 30 分鐘」→ 猜這步是醃製
  • 看到「加入 5 號罐之後顏色變褐」→ 猜 5 號罐是醬油
  • 一步一步,把撕掉的標籤重新貼回去

這就是逆向的全部,剩下的都是細節。


編譯到底丟掉了哪些「標籤」?

不只是名字,編譯這個「壓扁」的動作,丟掉的東西可以分成四層,由淺到深:

編譯丟掉的四層資訊 越往下越難重建,最底層無法唯一還原 ① 名字 userPassword → 堆疊上某一格  checkPassword → 某個位置 自己重新命名 ② 型別 同一串 bytes 是整數?文字?位置?機器碼裡沒有這個資訊 看用法反推 ③ 結構 if / while / for / switch → 大多變成「比較 + 條件跳轉」 從跳轉關係重建 ④ 意圖 註解、命名品味、「為什麼這樣設計」的理由 無法唯一還原 重建難度 逆向=重建 ①②③,然後靠它們去推測 ④ 同一份機器碼可以對應好幾種原始寫法與動機,光看產物分不出來
圖解 2:編譯丟掉的四層資訊。工具能幫你的主要在 ①②③,④ 永遠是人的工作。

下面我用你寫程式的經驗,一層一層對照:

第一層:名字(最容易理解的)

check_password → 沒了,變成一個位置,user_input → 沒了,變成暫存器裡的一個值。

逆向時你要做的:幫每一段沒名字的東西,自己取一個名字。你會在工具裡把「位置 0x1120 那段」重新命名成 check_password,因為你看懂了它在幹嘛。

第二層:型別(這層最反直覺)

你寫 int age = 25;,你知道它是整數。你寫 float price = 25.0;,你知道它是浮點數。編譯器知道,因為你告訴它了。但編譯完之後,這個資訊沒有被寫進執行檔裡。

為什麼?因為 CPU 不需要它,CPU 只需要知道「對這 4 個 bytes 做整數加法」還是「做浮點數加法」—— 而這個資訊藏在「指令」裡,不在「資料」裡

具體一點。假設你在執行檔裡看到 4 個 bytes:

68 65 20 66

這是什麼? 可能性至少有四種:

  • 一個整數
  • 一個小數
  • 一個「指向別處的位置編號」
  • 四個文字he、空格、f
同一串 bytes,四種可能的身分 機器碼裡沒有型別資訊,只能從「它被怎麼用」去推 68 65 20 66 4 個 bytes,就這樣 一個整數 若拿去做加法運算 → 支持這個假設 一個小數 若拿去做浮點運算 → 支持這個假設 一個位置編號 若拿去「到那裡取東西」 → 支持這個假設 四個文字 h e ␣ f 最容易被人眼認出 為什麼「找字串」常常有效? 因為文字是這四種裡最容易一眼認出來的 一段連續的可列印 bytes,人掃過去就看得出來 —— 這正是 strings 的原理 型別不是「讀出來」的,是「推出來」的 —— 而且是「支持」不是「證明」
圖解 3:同一串 bytes 的多重身分。型別是編譯時丟掉的資訊,只能從使用方式反推。

光看這 4 個 bytes,你分不出來,它們就只是四個數字。

那怎麼辦?看它被怎麼用。

如果程式拿這 4 個 bytes 去做「加法運算」→ 支持「它是數字」這個假設。
如果程式拿它去「一個字元一個字元跟另一段比較」→ 支持「它是文字」。
如果程式拿它去「當作位置,去那裡取東西」→ 支持「它是一個位置編號」。

注意用詞是「支持」不是「證明」。同一塊資料被用在多個地方、或被當成好幾種東西看待,都是真實會發生的事。逆向裡的判斷幾乎都是「目前最合理的解釋」,不是鐵證。

這就是逆向最核心的動作之一:型別不是「讀出來」的,是「推出來」的。

先講一個好消息:文字(字串)是這裡面最容易認出來的一種,因為一段連續的、剛好都落在「可列印文字」範圍的 bytes,看起來就是一句話 —— 人眼一掃就認得出 Please enter your password 不是隨機數字。

這就是為什麼有工具專門幫你把這種片段找出來(後面會講到的 strings),也是為什麼「找字串」常常是逆向的第一招。

順帶說清楚這個工具在做什麼,免得你把它想得太神:它就是掃過檔案,把「連續好幾個 byte 剛好都落在可列印範圍」的片段印出來。它不理解檔案格式、也不管那段 bytes 原本是不是字串 —— 所以它會漏掉被拆開或編碼過的文字,也會撈出一堆剛好長得像文字的雜訊。它是一個很粗但很便宜的篩子。

難的是其他型別:一個 4 bytes 到底是整數、小數、還是位置編號?一堆 bytes 排在一起是一個陣列、還是一個包含好幾個欄位的結構?這些沒有工具能百分百告訴你,只能靠「它被怎麼用」去推。

第三層:結構(你的 if 和 while 都不見了)

你寫的 ifwhileforswitch,編譯後全部變成同一種東西:「比較,然後跳」。

你寫的                    編譯後
──────────────────────────────────
if (x > 5) {...}    →    比較 x 和 5,如果不大於就跳過下面
while (x < 10) {...} →   比較 x 和 10,如果小於就跳回上面

看到沒有?在常見、未經大幅最佳化的形態裡,ifwhile 經常都變成「比較 + 條件跳轉」,差別在跳的方向。

但跳轉方向本身不是可靠的判定規則 —— 編譯器會做迴圈旋轉、把判斷搬到底部、把區塊重新排序,跳的方向未必對應你直覺中的結構。它是線索,不是定則。

但這也只是最常見的形態,不是唯一的。 switch 如果分支很多,編譯器可能改用一張「跳轉表」(查表決定跳去哪),長得完全不像一串比較;很短的 if 也可能被換成「不跳,直接兩個值選一個」的指令。最佳化開得越強,變形越多。

逆向時你要做的:從實際的跳轉關係,重建出原本的控制流長什麼樣。

第四層:意圖(這層永遠回不來)

你寫的註解、你取的好名字、你腦子裡「為什麼這樣設計」的理由 —— 這些編譯後無法唯一還原,同一份機器碼可以對應到好幾種不同的原始寫法和動機,光看產物分不出來。

這一層是逆向真正的難處,也是機器幫不了你的地方,前三層工具可以幫你猜個七八成,但「這段程式為什麼要這樣寫、作者在想什麼」,只能靠你自己的經驗和推理。


所以,逆向工程的定義是這樣

把上面整合起來:

編譯是一個「有損壓縮」的過程 —— 它把你寫的、充滿名字和結構的原始碼,壓成一堆只有位置和動作的機器碼,過程中丟掉了名字、型別、結構、意圖。

逆向工程,就是在缺少原始碼的情況下,靠著「還留著的東西」,把「被丟掉的東西」盡量重建出來。

「有損壓縮」這個詞很精準。就像 JPEG 把照片壓小、丟掉一些細節,你放大回去永遠回不到原本的畫質 —— 編譯也是。

你逆向出來的東西,永遠不會跟原始碼一模一樣(名字是你自己取的、結構是你自己重建的),而且更誠實的說法是:你建立的是一個「足以解釋你所觀察到的行為」的模型,不是原始碼的複製品,這個模型可能在你還沒觀察到的情況下就失準 —— 所以逆向的結論永遠要留一句「就目前看到的而言」。


那為什麼有「留下來」的東西可以利用?

你可能會問:如果編譯把名字都丟了,那逆向不是無從下手嗎?

關鍵來了 —— 不是所有東西都能丟。

有一條鐵律:

凡是「程式跑起來時,得由外面提供」的東西,相關的線索就不容易被清乾淨 —— 因為清掉了,就沒人找得到該去哪裡拿。

舉幾個例子:

如果程式是用一般的「動態連結」方式打包的,那它向外部函式庫要的功能名稱,經常會留下來。

舉例:你的程式要「比較兩個字串」,用了一個叫 strcmp 的現成函式,這個函式的程式碼不在你的執行檔裡,而是在系統的共用函式庫裡 —— 所以程式跑起來的時候,得有人幫它找到 strcmp 在哪,負責這件事的元件(載入器)需要名字才能找,因此這個名字通常會被保留下來。

這讓「程式向外要了哪些功能」成為一份很好用的清單,它會洩漏大量資訊:

  • 要了 strcmp / strncmp(字串比較)→ 優先懷疑這程式裡有「比對」,可能在驗證什麼
  • 要了跟「加密」有關的功能 → 優先懷疑有加密,秘密可能被藏起來了
  • 要了跟「網路」有關的功能 → 優先懷疑它會連網,去看它連去哪
  • 要了跟「反調試」有關的功能 → 優先懷疑它在防著別人分析

光看這份清單,你還沒讀任何一行程式,就已經能猜出這個程式大概在幹嘛了,這是逆向裡投報率最高的一招。

但要注意它的邊界 —— 這份清單只涵蓋「執行時才去跟外面要」的功能,有些東西不會出現在上面:

  • 程式把功能直接打包進自己身體裡(叫做靜態連結)
  • 編譯器把小功能直接展開嵌進程式碼裡
  • 程式自己重寫一份,不跟系統借
  • 程式執行時才臨時去借

所以正確的說法是:這份清單是「建立假設的起點」,不是「能力的完整清冊」。 看到 strcmp 就大膽假設有字串比對;但沒看到加密相關的功能,不能就此斷定它沒有加密。這個分寸很重要,後面的文章會反覆提到。


「找字串」為什麼常常有用?

如果你玩過一點逆向,或看過別人玩,你會發現一個常見的第一步:跑一個叫 strings 的工具,在掃描範圍內找出連續的可列印 bytes。

為什麼這招常常有效?

因為很多秘密就是明文字串,而字串(跟名字不一樣)經常會被留在程式裡,因為程式執行時要用到它們(顯示訊息、比對密碼、連線網址)。

所以「跑 strings 找字串」不是偷懶,它是投報率最高的第一步,你該先試。

但它會在三種情況失效,而理解這三種失效,正好幫你看清「逆向的難度階梯」:

strings 是一個很粗、但很便宜的篩子 它只找「連續的可列印 bytes」,不理解檔案格式 檔案裡的東西 明文字串 被切碎的字串 加密過的資料 純數值 / 指令 strings 連續可列印 bytes? 不理解格式 不管原本是啥 撈得到 ✓ 明文字串、看起來像文字的雜訊 漏掉 ✗ 被切碎的(只給你半句話) 被加密/編碼的(一堆亂碼) 秘密根本不是字串的情況 每一種「漏掉」,都在告訴你該換一個視角看了: 半句話 → 去看指令,把尾巴撿回來   一堆亂碼 → 去讀它怎麼解密 什麼都沒有 → 秘密可能是演算法本身,得完整重建邏輯
圖解 4:strings 的能與不能。它是逆向的第一招,也是難度階梯的第一階。

① 字串被拆碎藏起來 —— 有時候編譯器或作者會把一個字串切成好幾段,strings 只撈得到其中一段。你會看到半句話。
這時你被迫往下一層走:去看程式的實際指令,把碎片拼回來。

② 字串被加密了 —— 秘密不是明文放著,是加密後藏起來,程式執行時才解密。strings 什麼都撈不到,只有一堆亂碼,這時你要做的更多:看懂它怎麼解密,然後自己重跑一次解密。

③ 秘密根本不是字串 —— 有時候秘密是一個「演算法」,比如「怎麼判斷一組序號是否合法」,這沒有任何字串可撈,秘密藏在計算邏輯裡。
這時你要完整重建那個演算法。 這是最難的一階,也是真正的逆向功力所在。

所以「我只會跑 strings 找字串」不是能力不足,是你還沒遇到需要往上爬的題目。 而知道「上面那幾階是什麼」,就是這篇文章想給你的地圖。


為什麼需要那一堆工具?(一句話版本)

stringsobjdumpGhidraradare2gdb…… 為什麼這麼多?剛入門看到會頭暈。

因為它們在回答不同的問題,一旦你用「這個工具回答什麼問題」來分類,混亂就消失了,我先給你最粗的分法,細節留到方法篇:

「這個程式對外宣告/索取了什麼?」 —— 用 nmreadelf 這類看符號與 metadata 的工具,不用讀程式碼就有大量情報,該最先做。

「檔案裡有沒有看起來像文字的東西?」 —— 用 strings,注意它跟上面那類不同:它不理解檔案格式,只是掃過去找連續的可列印 bytes,粗、但極便宜。

「這段程式的實際指令是什麼?」 —— 用「反組譯」工具(像 objdump),它把那堆 0 和 1 翻譯成 CPU 指令,這是最接近原始證據的一層(真正一〇〇% 不帶解讀的其實是那堆 bytes 本身,但人類讀不動),讀起來最硬,但也最不容易被誤導。

「這段邏輯用人話講是什麼?」 —— 用「反編譯」工具(像 Ghidra),它會把那些硬邦邦的指令,盡量還原成類似 C 語言的樣子,好讀很多,但注意它是「盡量還原」,是猜的,有時候會猜錯

「這程式跑起來實際會怎樣?」 —— 用「動態分析」工具(像 gdb、Frida)。前面三種都是「不執行程式、光看」,這種是「讓程式真的跑,中途攔截觀察」。

一個很重要的取捨原則,先記著:

越原始的工具越可靠(但難讀),越高階的工具越好讀(但可能騙你)。

所以老手的做法是:用高階工具(Ghidra)快速看懂大方向,遇到讀起來怪怪的地方,回去用低階工具(objdump)驗證,因為越低階的工具,加進去的猜測越少。


一張圖總結:逆向在幹嘛

你寫原始碼                    有名字、有型別、有結構、有意圖
    │
    │  編譯(有損壓縮,丟掉標籤)
    ▼
執行檔                        只剩位置、動作、和「仍留下的線索」
    │
    │  逆向(靠仍留下的線索,反推被丟掉的)
    ▼
你重建的理解                  自己取的名字、推出的型別、重建的結構
                             (足以解釋已觀察的行為,
                               但不一定與原始碼完整等價)

逆向不是魔法,是考古,你面對的是一個被撕掉標籤的成品,靠著還留著的線索(動作、順序、跟系統借用的功能、沒被藏起來的字串),一片一片把原本的意義拼回來。


你現在該有的心態

如果你是完全沒碰過逆向的人,讀完這篇,你該帶走的不是任何一個工具指令,而是這三個觀念:

  1. 編譯會丟東西,逆向就是把丟掉的撿回來,名字、型別、結構被丟了,但功能還在。
  2. 有些東西不容易丟掉,那些就是你的線索,跟系統借用的功能清單、明文字串、程式的動作順序 —— 但記得它們是「起點」不是「全貌」。
  3. 工具沒有高下,只有分工。 看清單、看指令、看人話版、看執行時 —— 各自回答不同問題。

有了這三個觀念,你再去看任何一篇「滿是十六進位和 mov 的逆向文章」,都不會再覺得是天書 —— 你會知道那個人只是在把撕掉的標籤貼回去而已。


下一篇:方法篇

有了這篇的觀念地基,方法篇,會帶你真的碰一次組語,你會親眼看到:

  • 一個「變數」在底層到底長什麼樣(全域變數、參數、區域變數、常數,四種完全不同)
  • 為什麼有時候 strings 只能撈到「半句話」(真實案例)
  • objdumpradare2Ghidra 到底怎麼選、什麼時候用哪個
  • 一套「拿到陌生程式時的分析起手式」

到時候你會發現,因為有這篇打底,那些組語看起來一點都不可怕了。


本文為逆向工程入門筆記,寫給想入門但被組語勸退的人,有錯歡迎指正。