理解電腦架構對靜態與動態逆向都很重要,否則你很難看懂一連串指令到底在做什麼。但架構只是故事的一半:沒有作業系統管理硬體與行程,這些指令並不會有太大用處。

執行檔格式#

執行檔格式(executable file formats) 定義執行檔如何儲存在磁碟上。作業系統必須指明它支援哪些執行檔,才能載入並執行程式。早期系統如 MS-DOS 對可執行的檔案格式沒有限制(執行時,內含指令的檔案會直接載入記憶體),現代作業系統則有更多需求,因而需要更複雜的格式。

現代執行檔格式的需求包括:

  • 為可執行指令與資料配置記憶體
  • 支援外部函式庫的動態連結
  • 支援密碼學簽章,用以驗證執行檔的來源
  • 維護除錯資訊,把執行碼連回原始碼以供除錯
  • 提供執行檔中「程式碼從哪裡開始執行」的位址參照,通常稱為起始位址(start address)——之所以必要,是因為程式的起始位址未必是執行檔中的第一條指令
平台格式備註
WindowsPortable Executable(PE)執行檔慣用 .exe、動態函式庫慣用 .dll;但 Windows 其實不需要這些副檔名就能正確建立新行程,副檔名只是方便而已
Linux、Solaris 等多數 Unix-like 系統Executable Linking Format(ELF)主要執行檔格式
macOSMach-OUnix-like 陣營中的主要例外

區段#

記憶體區段(sections) 大概是執行檔中最重要的資訊。所有非平凡的執行檔至少會有三個區段:

  • 程式碼區段:含執行檔的原生機器碼,通常叫 text
  • 資料區段:含執行期間可讀寫的已初始化資料,通常叫 data
  • 未初始化資料區段:通常叫 bss

每個區段都包含四項基本資訊:

  • 一個文字名稱
  • 該區段資料在執行檔中的大小與位置
  • 資料應載入記憶體的大小與位址
  • 記憶體保護旗標(memory protection flags),指出該區段載入記憶體後是否可寫入或執行

區段的保護旗標會告訴你很多事:一個同時可寫又可執行的區段,往往意味著自我修改程式碼、加殼(packing)或執行期解密——這些都是值得優先追的目標。

行程與執行緒#

作業系統必須能同時執行同一個執行檔的多個實例而互不衝突,所以它定義了行程(process) 作為「執行中執行檔實例」的容器。行程儲存該實例運作所需的所有私有記憶體,把它與同一執行檔的其他實例隔離開來。行程同時也是一道安全邊界,因為它在特定的作業系統使用者身分下執行,安全決策可以依這個身分做出。

作業系統也定義了執行緒(thread),讓系統能在多個行程間快速切換,讓使用者感覺它們同時在跑,這就是多工(multitasking)。要在行程間切換,作業系統必須中斷 CPU 目前的工作、保存目前行程的狀態、還原另一個行程的狀態;CPU 恢復執行時,跑的就是另一個行程了。

執行緒定義了目前的執行狀態,它有自己的堆疊記憶體區塊,也有地方在作業系統停下它時保存狀態。一個行程通常至少有一個執行緒,執行緒數量上限一般受電腦資源限制。

從執行檔建立新行程的流程是:

  1. 作業系統先建立一個空行程,配置它自己的記憶體空間。
  2. 把主執行檔載入該行程的記憶體空間,依執行檔的區段表配置記憶體。
  3. 建立一個新執行緒,稱為主執行緒(main thread)
  4. 動態連結程式負責把主執行檔的系統函式庫連結進來,然後跳回原本的起始位址。
  5. 作業系統啟動主執行緒,行程建立完成。

作業系統網路介面#

作業系統必須管理電腦的網路硬體,讓所有執行中的應用程式共用。硬體對 TCP/IP 這類高階協定所知甚少,因此作業系統得自己提供這些高階協定的實作。

作業系統也得提供應用程式介接網路的方式。最常見的網路 API 是 Berkeley sockets 模型,1970 年代由加州大學柏克萊分校為 BSD 開發。所有 Unix-like 系統都內建支援;Windows 上則由 Winsock 函式庫提供極為相似的程式設計介面。Berkeley sockets 模型普及到你幾乎必定會在各種平台上遇到它。

遇到使用作業系統網路介面的原生應用程式時,你必須在執行碼中把這些函式呼叫全部追出來。對「C 語言層級的程式怎麼寫」的認識,在你看反組譯輸出時會非常值錢。

完整範例:TCP 用戶端與伺服器的 sockets 呼叫序列

用戶端:連線到遠端伺服器

int port = 12345;
const char* ip = "1.2.3.4";
sockaddr_in addr = {0};

int s = socket(AF_INET, SOCK_STREAM, 0);

addr.sin_family = PF_INET;
addr.sin_port = htons(port);
inet_pton(AF_INET, ip, &addr.sin_addr);
if(connect(s, (sockaddr*) &addr, sizeof(addr)) == 0)
{
    char buf[1024];
    int len = recv(s, buf, sizeof(buf), 0);

    send(s, buf, len, 0);
}

close(s);
  • socket() 建立新的通訊端。AF_INET 表示要用 IPv4 協定(要改 IPv6 則寫 AF_INET6);第二個參數 SOCK_STREAM 表示要用串流連線,對網際網路而言就是 TCP(要建立 UDP 通訊端則寫 SOCK_DGRAM,即 datagram socket)。
  • 接著用系統定義的 sockaddr_in 結構建立目的位址,設定協定類型、TCP 連接埠與 IP 位址。inet_pton()ip 中的字串形式 IP 位址轉成 32 位元整數。
  • 設定連接埠時使用 htons(),把值從主機位元組序(x86 為小端序)轉成網路位元組序(一律大端序)。IP 位址同理:位址 1.2.3.4 以大端序儲存時會變成整數 0x01020304
  • 最後呼叫 connect() 連到目的位址。這是主要的失敗點,因為此時作業系統必須向外撥打到目的位址,看看有沒有東西在聽。
  • 連線建立後,程式就能像操作檔案一樣,用 recv()send() 系統呼叫讀寫通訊端。(在 Unix-like 系統上也可以用通用的 readwrite,Windows 上不行。)

伺服器端:接受連入連線

sockaddr_in bind_addr = {0};
int s = socket(AF_INET, SOCK_STREAM, 0);

bind_addr.sin_family = AF_INET;
bind_addr.sin_port = htons(12345);
inet_pton("0.0.0.0", &bind_addr.sin_addr);

bind(s, (sockaddr*)&bind_addr, sizeof(bind_addr));
listen(s, 10);

sockaddr_in client_addr;
int socksize = sizeof(client_addr);
int newsock = accept(s, (sockaddr*)&client_addr, &socksize);

// Do something with the new socket
  • 第一個關鍵步驟是把通訊端繫結(bind)到本機網路介面的位址上。inet_pton() 在這裡同樣只是把字串 IP 位址轉成二進位形式,效果上是用戶端情境的反面。
  • "0.0.0.0" 表示繫結到所有網路位址(也可以改成某個特定位址),連接埠為 12345。繫結到所有介面可確保伺服器通訊端能從系統外部存取,例如透過網際網路——前提是沒有防火牆擋著。
  • 最後請網路介面 listen() 等待新的連入連線,並呼叫 accept() 取回下一個新連線。和用戶端一樣,這個新通訊端可用 recvsend 讀寫。

應用程式二進位介面#

應用程式二進位介面(application binary interface, ABI) 是作業系統定義的介面,描述應用程式呼叫 API 函式的慣例。

  • 參數順序:多數程式語言與作業系統由左至右傳遞參數,也就是原始碼中最左邊的參數放在最低的堆疊位址。若參數是用推入堆疊的方式建立,則最後一個參數最先被推入。
  • 回傳值:在 x86 架構上,只要值小於或等於 32 位元,就透過 EAX 暫存器傳回;介於 32 到 64 位元之間的值,則以 EAX 與 EDX 的組合傳回。
  • 暫存(scratch)暫存器:EAX 與 EDX 在 ABI 中被視為 scratch 暫存器,意思是它們的值不會跨函式呼叫保留——呼叫端不能指望呼叫返回後,這些暫存器裡原本的值還在。這種設計出於務實考量:讓函式少花時間與記憶體去保存那些可能根本不會被改動的暫存器。ABI 會明確規定哪些暫存器必須由被呼叫的函式存到堆疊上。
暫存器ABI 用途需保存?
EAX用來傳遞函式回傳值
EBX通用暫存器
ECX用於區域迴圈與計數器,在 C++ 等語言中有時用來傳遞物件指標
EDX用於擴充的回傳值
EDI通用暫存器
ESI通用暫存器
EBP指向目前有效堆疊框(stack frame)的基底
ESP指向堆疊的基底

典型的函式呼叫在組合語言中大致長這樣:呼叫端先把參數放上堆疊(例如 PUSH 10),接著 CALL add 呼叫函式,返回後再 ADD ESP, 8 清理堆疊,而加總結果則透過 EAX 傳回。

圖表 6-4:組合語言中的函式呼叫

認得這個「PUSH 參數 → CALL → ADD ESP 清理 → 讀 EAX」的節奏,你就能在一片組語裡快速切出函式邊界、數出參數個數,並知道回傳值該去哪裡找。