Logo
Overview
A Quick Dive into PEB Walk: What I Learned from TONESHELL

A Quick Dive into PEB Walk: What I Learned from TONESHELL

June 21, 2025 at 07:31:00 PM
7 min read

在分析 TONESHELL 家族的樣本時,發現它大量利用了 PEB Walk 這個技術來躲避 IAT 的靜態分析,同時也使用了許多混淆技術。然後我在分析這些樣本時,常常會忘記一些結構的 offset 或是其他細節,每次都要重新找資料有點累 (´;ω;`)。所以我就趁這個機會,把我從 TONESHELL 身上學到 PEB walk 的程式結構,還有分析的方式,綜合寫成這篇筆記。

What is PEB Walk

PEB(rocess Environment Block) Walk 是一種手動解析 Windows API 的一種方法。它的核心概念是去解析程式在執行中載入的模組(module),從他的 Export Table 找到匯出的函式地址。在進入 PEB Walk 技巧的說明前,務必確保對於 PE format 有基礎的認識,不然可能會不知道自己在幹嘛。可以參考這一系列文章 A dive into the PE file format 來補足有關 PE format 的知識。

在 Windows 作業系統中,模組(module)是指一段已載入記憶體的執行單元,通常是:

  • 可執行檔(.exe)
  • 動態連結程式庫(.dll)
  • 驅動程式(.sys)

或其他以 PE(Portable Executable)格式編譯的檔案

Start with TEB/PEB

TEB(Thread Environment Block) 上記錄有關 Thread 的資訊,是協助 Windows 用來管理程式執行的一個結構體。在微軟的 文件(aka MSDN, Microsoft Development Network) 中你會看到很多欄位都沒有明確寫出來(壞微軟 x),但你可以從其他地方找到人家逆向工程得到的結論,大致上可以找到這些:

可以看一下官方給的 TEB 結構:

typedef struct _TEB {
PVOID Reserved1[12];
PPEB ProcessEnvironmentBlock; // <- PEB 在這!
PVOID Reserved2[399];
BYTE Reserved3[1952];
PVOID TlsSlots[64];
BYTE Reserved4[8];
PVOID Reserved5[26];
PVOID ReservedForOle;
PVOID Reserved6[4];
PVOID TlsExpansionSlots;
} TEB, *PTEB;

在 x86 和 x64 的架構中,我們可以分別從 FS、GS 這兩個 Segment Register 中取得指向 TEB 的指標。

#ifdef _WIN64
// 64-bit: TEB 位於 GS:[0x30]
PTEB teb = (PTEB)__readgsqword(0x30);
#else
// 32-bit: TEB 位於 FS:[0x18]
PTEB teb = (PTEB)__readfsdword(0x18);
#endif

或是使用微軟提供的 API,但其實微軟也只是把上面的程式碼再封裝過而已,所以差異不大。

PTEB teb = GetCurrentTeb();

取得 TEB 結構後,我們就可以使用 TEB 找到 PEB(offset 0x30)。

PPEB peb = teb->ProcessEnvironmentBlock;

PEB(Process Environment Block) 是 Windows 用來儲存有關 process 資訊的結構體。

而我們關心的是其中的 PPEB_LDR_DATA Ldr;(loader data,x86 offset 0x0C / x64 offset 0x18),他利用了雙向鏈結串列來記錄所有載入的模組。

typedef struct _PEB_LDR_DATA
{ // x86 / x64
ULONG Length; // 0x00
BOOLEAN Initialized; // 0x04
HANDLE SsHandle; // 0x08
LIST_ENTRY InLoadOrderModuleList; // 0x0C / 0x10
LIST_ENTRY InMemoryOrderModuleList; // 0x14 / 0x20
LIST_ENTRY InInitializationOrderModuleList; // 0x1C / 0x30
// ...
} PEB_LDR_DATA, *PPEB_LDR_DATA;
typedef struct _LIST_ENTRY { // x86 / x64
struct _LIST_ENTRY *Flink; // aka. next // 0x00
struct _LIST_ENTRY *Blink; // aka. prev // 0x04 / 0x08
} LIST_ENTRY, *PLIST_ENTRY, PRLIST_ENTRY; // 0x08 / 0x10

而被串起來的模組會用 LDR_DATA_TABLE_ENTRY 來儲存誰串誰。裡面還有 FullDllName 欄位可以用於尋找對應的模組,再透過 DllBase 指向的 PE header,就可以順藤摸瓜找到 export table 了。

typedef struct _LDR_DATA_TABLE_ENTRY
{ // x86 / x64
LIST_ENTRY InLoadOrderLinks; // 0x00 / 0x00
LIST_ENTRY InMemoryOrderLinks; // 0x08 / 0x10
LIST_ENTRY InInitializationOrderLinks; // 0x10 / 0x20
PVOID DllBase; // 0x18 / 0x30
PLDR_INIT_ROUTINE EntryPoint; // 0x1C / 0x38
ULONG SizeOfImage; // 0x20 / 0x40
UNICODE_STRING FullDllName; // 0x24 / 0x48
UNICODE_STRING BaseDllName; // 0x2C / 0x58
// ...
} LDR_DATA_TABLE_ENTRY, *PLDR_DATA_TABLE_ENTRY;

到這邊我們小總結一下:

  • TEB 裡面儲存了很多與執行續有關的資訊,其中包含了 PEB。
  • PEB 裡面儲存了很多與行程有關的資訊,其中包含了 Ldr,儲存了載入模組的雙向鏈結串列。
  • ldr->InLoadOrderModuleList 的節點是 LDR_DATA_TABLE_ENTRY
  • LDR_DATA_TABLE_ENTRY
    • DllBase 指向模組的 PE header
    • 還有 FullDllName 可以提供我們尋找目標模組(ex. ntdll.dll)

Playing with Doubly Linked List

那我們就可以透過這層關係,來模擬微軟提供的 FARPROC GetProcAddress(HMODULE hModule, LPCSTR lpProcName) 在做的事情。我們只要有 DLL 名稱,就可以透過 InLoadOrderModuleList 去找我們要的 DLL。

DLL(Dynamic-Linked Library,aka. 動態連結函式庫),是作業系統用來節省資源發展出的一套;使用共用函式酷的方式。而我們可以使用 HMODULE LoadLibrary(LPCSTR lpLibFileName) 來拿到該 DLL 的句炳(handle)。在 Windows 中,句炳(handle)通常是指操作一個資源的 id,作業系統會去操作 id 對應到的資源。但 HMODULE 恰巧會是該模組的記憶體位址。

我們可以先實作一個函式,回傳模組的 DllBase

PTEB get_teb() {
#ifdef _WIN64
// 64-bit: TEB 位於 GS:[0x30]
return (PTEB)__readgsqword(0x30);
#else
// 32-bit: TEB 位於 FS:[0x18]
return (PTEB)__readfsdword(0x18);
#endif
}
PVOID peb_walk(PWSTR DllName) {
PPEB peb = get_teb()->ProcessEnvironmentBlock;
LIST_ENTRY *head = &peb->Ldr->InMemoryOrderModuleList;
LIST_ENTRY *curr = head->Flink;
for(; curr != head; curr = curr->Flink) {
LDR_DATA_TABLE_ENTRY *entry = CONTAINING_RECORD(curr, LDR_DATA_TABLE_ENTRY, InMemoryOrderLinks);
wchar_t *dll_name = wcsrchr(entry->FullDllName.Buffer, L'\\') + 1;
if(_wcsicmp(dll_name, DllName) == 0) {
// printf("[+] DllBase: 0x%p\n", entry->DllBase);
return entry->DllBase;
}
}
return INVALID_HANDLE_VALUE;
}

PE Format and Export Table

怕大家忘記,我們可以再重新複習一下 PE format,因為上面真的太多資訊了。PE(Portable Executable,aka. 可移植性可執行文件,是 Windows 拿來做為與可執行檔相關的檔案儲存格式。維基百科上說包含這些副檔名:.acm, .ax, .cpl, .dll, .drv, .efi, .exe, .mui, .ocx, .scr, .sys, .tsp

PE format 大致上可以分成六個區塊:

  • DOS Header
  • DOS stub
  • Rish Header
  • NT Header
  • Section Table
  • Sections

如果用 PE Bear 分析 ntdll.dll 就會看到以下結構:

Analysis ntdll.dll with PEBear

我們可以用 DOS Header 找到 NT Header 的位址,結構中的 e_lfanew(offset 0x3C) 會是 NT Header 的 RVA(Relative Virtual Address)。很有趣的是在 PE format 中的很多欄位,都是用 rva 表示,也就是說如果要取得那個資源,就要再加上 DllBase 才會是該資源的指標。

這個是 winnt.h 中定義的 IMAGE_DOS_HEADER

typedef struct _IMAGE_DOS_HEADER { // DOS .EXE header
WORD e_magic; // Magic number
WORD e_cblp; // Bytes on last page of file
WORD e_cp; // Pages in file
WORD e_crlc; // Relocations
WORD e_cparhdr; // Size of header in paragraphs
WORD e_minalloc; // Minimum extra paragraphs needed
WORD e_maxalloc; // Maximum extra paragraphs needed
WORD e_ss; // Initial (relative) SS value
WORD e_sp; // Initial SP value
WORD e_csum; // Checksum
WORD e_ip; // Initial IP value
WORD e_cs; // Initial (relative) CS value
WORD e_lfarlc; // File address of relocation table
WORD e_ovno; // Overlay number
WORD e_res[4]; // Reserved words
WORD e_oemid; // OEM identifier (for e_oeminfo)
WORD e_oeminfo; // OEM information; e_oemid specific
WORD e_res2[10]; // Reserved words
LONG e_lfanew; // File address of new exe header
} IMAGE_DOS_HEADER, *PIMAGE_DOS_HEADER;

接著我們就可以用 NT Header 找到 Optional Header(offset 0x18),可以看到在 winnt.h 定義了 32-bits 和 64-bits 兩個版本的 NT Header,就只差在 Optional Header。

typedef struct _IMAGE_NT_HEADERS64 {
DWORD Signature;
IMAGE_FILE_HEADER FileHeader;
IMAGE_OPTIONAL_HEADER64 OptionalHeader; // <- 在這!
} IMAGE_NT_HEADERS64, *PIMAGE_NT_HEADERS64;
typedef struct _IMAGE_NT_HEADERS {
DWORD Signature;
IMAGE_FILE_HEADER FileHeader;
IMAGE_OPTIONAL_HEADER32 OptionalHeader; // <- 在這!
} IMAGE_NT_HEADERS32, *PIMAGE_NT_HEADERS32;

Optional Header 裡面記錄了三種類型的資訊:

  • 標準欄位:這些欄位主要描述執行檔的基本載入行為。
  • Kernel 特定欄位:描述執行時記憶體配置、堆疊、作業系統需求等。
  • 匯入與匯出資訊: 匯入與匯出資訊。

GPT 幫我整理的差異表格

差異成員x86 (IMAGE_OPTIONAL_HEADER32)x64 (IMAGE_OPTIONAL_HEADER64)
Magic0x10B0x20B(這是判斷架構的關鍵欄位)
BaseOfData✅ 有x64 沒有此欄位
ImageBaseDWORD(4 bytes)ULONGLONG(8 bytes)
SizeOfStackReserveDWORD(4 bytes)ULONGLONG(8 bytes)
SizeOfStackCommitDWORDULONGLONG
SizeOfHeapReserveDWORDULONGLONG
SizeOfHeapCommitDWORDULONGLONG

其中匯入與匯出資訊是由一個陣列儲存:

IMAGE_DATA_DIRECTORY DataDirectory[IMAGE_NUMBEROF_DIRECTORY_ENTRIES];

這個陣列最多紀錄 16 筆資料,每個索引的用途也被定義在 winnt.h 裡面,我們會想要從 Export Directory 中去找模組匯出的函式地址:

// Directory Entries
#define IMAGE_DIRECTORY_ENTRY_EXPORT 0 // Export Directory
#define IMAGE_DIRECTORY_ENTRY_IMPORT 1 // Import Directory
#define IMAGE_DIRECTORY_ENTRY_RESOURCE 2 // Resource Directory
#define IMAGE_DIRECTORY_ENTRY_EXCEPTION 3 // Exception Directory
#define IMAGE_DIRECTORY_ENTRY_SECURITY 4 // Security Directory
#define IMAGE_DIRECTORY_ENTRY_BASERELOC 5 // Base Relocation Table
#define IMAGE_DIRECTORY_ENTRY_DEBUG 6 // Debug Directory
// IMAGE_DIRECTORY_ENTRY_COPYRIGHT 7 // (X86 usage)
#define IMAGE_DIRECTORY_ENTRY_ARCHITECTURE 7 // Architecture Specific Data
#define IMAGE_DIRECTORY_ENTRY_GLOBALPTR 8 // RVA of GP
#define IMAGE_DIRECTORY_ENTRY_TLS 9 // TLS Directory
#define IMAGE_DIRECTORY_ENTRY_LOAD_CONFIG 10 // Load Configuration Directory
#define IMAGE_DIRECTORY_ENTRY_BOUND_IMPORT 11 // Bound Import Directory in headers
#define IMAGE_DIRECTORY_ENTRY_IAT 12 // Import Address Table
#define IMAGE_DIRECTORY_ENTRY_DELAY_IMPORT 13 // Delay Load Import Descriptors
#define IMAGE_DIRECTORY_ENTRY_COM_DESCRIPTOR 14 // COM Runtime descriptor

DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT] 會儲存 IMAGE_EXPORT_DIRECTORY 結構的資訊。所以接著我們要來看,怎麼樣才可以從 IMAGE_EXPORT_DIRECTORY 拿到匯出函式的地址。我們主要會看最後 5 個欄位:

  • NumberOfFunctions:所有函式的數量
  • NumberOfNames:有名字的函式數量
  • AddressOfFunctions:一個 DWORD 的陣列
  • AddressOfNames:一個 DWORD 的陣列,函式名稱的 RVA
  • AddressOfNameOrdinals:一個 WORD 的陣列,名稱對應的序號
typedef struct _IMAGE_EXPORT_DIRECTORY { // x86 / x64
DWORD Characteristics; // 0x00
DWORD TimeDateStamp; // 0x04
WORD MajorVersion; // 0x08
WORD MinorVersion; // 0x0A
DWORD Name; // 0x0C
DWORD Base; // 0x10
DWORD NumberOfFunctions; // 0x14
DWORD NumberOfNames; // 0x18
DWORD AddressOfFunctions; // 0x1C // RVA from base of image
DWORD AddressOfNames; // 0x20 // RVA from base of image
DWORD AddressOfNameOrdinals; // 0x24 // RVA from base of image
} IMAGE_EXPORT_DIRECTORY, *PIMAGE_EXPORT_DIRECTORY;

每一個在 NumberOfNames 陣列中的函式名稱,會透過公用索引對應到 AddressOfNameOrdinals 裡面的一個 ordinal。然後要再透過這個 ordinal 做為索引,去 AddressOfFunctions 陣列中找對應函式的 RVA。

我們尋找目標函式地址的流程如下:

  • 找到對應函式名稱的索引 index
  • AddressOfNameOrdinals[index] 取得 ordinal
  • AddressOfFunctions[ordinal] 取得函式 RVA

寫成函式的話就會長這樣:

PVOID get_proc_addr(PVOID DllBase, char *FuncName) {
IMAGE_DOS_HEADER *dos_hdr = (IMAGE_DOS_HEADER*) DllBase;
#ifdef _WIN64
PIMAGE_NT_HEADERS64 nt_header = (PIMAGE_NT_HEADERS64)((BYTE *)DllBase + dos_hdr->e_lfanew);
#else
PIMAGE_NT_HEADERS32 nt_header = (PIMAGE_NT_HEADERS32)((BYTE *)DllBase + dos_hdr->e_lfanew);
#endif
DWORD exp_dir_rva = nt_header->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress;
IMAGE_EXPORT_DIRECTORY *exp_dir = (IMAGE_EXPORT_DIRECTORY*)((BYTE *)DllBase + exp_dir_rva);
DWORD *functions = (DWORD*)((BYTE*)DllBase + exp_dir->AddressOfFunctions);
DWORD *names = (DWORD*)((BYTE*)DllBase + exp_dir->AddressOfNames);
WORD *ordinals = (WORD*) ((BYTE*)DllBase + exp_dir->AddressOfNameOrdinals);
int index = 0;
for(; index < exp_dir->NumberOfNames; index++) {
char* name = (char*)DllBase + names[index];
if(stricmp(FuncName, name) == 0)
return (BYTE*)DllBase + functions[ordinals[index]];
}
return NULL;
}

接著再寫點程式就可以收動召喚出 MessageBox 了:

typedef int (WINAPI *MessageBoxA_t)(HWND hWnd, LPCSTR lpText, LPCSTR lpCaption, UINT uType);
int main(int argc, char *argv[]) {
PVOID hModule = peb_walk(L"ntdll.dll");
printf("[i] ntdll.dll DllBase: 0x%p\n", hModule);
hModule = LoadLibraryA("User32.dll");
MessageBoxA_t msgboxa = get_proc_addr(hModule, "MessageBoxA");
printf("[i] MessageBoxA addr: %p\n", (void*)msgboxa);
msgboxa(NULL, "Hello", "Hello", MB_OK);
return EXIT_SUCCESS;
}

Call MessageBox with PEB Walk

偷偷丟上 VirusTotal,蠻有趣的有兩家判定為病毒。

Analysist Perspective

如果沒有好好的定義結構指標類型,就會看到一系列混亂的指標操作。所以正確識別出結構體的指標操作很重要,尤其 offset 通常是最重要判斷的標準之一。先從簡單的開始,這是從 32-bit 的 TONESHELL 樣本中擷取的片段,是用來取得 Ldr 的程式結構:

Pseudo code of TONESHELL get Ldr from IDA

可以推算出 v13 是 PEB,PEB + 12 是 LdrLdr + 20 是 InMemoryOrderModuleList,所以 v12 就是這個鏈結串列的 headv12 解參考就是 head->Flink,所以可以推測會用 v11 來走訪雙向鏈結串列,可以叫它 curr。然後這邊會有個小陷阱,還記得我們前面提過,這個鏈結串列的節點的型別是 LDR_DATA_TABLE_ENTRY ,所以 curr 的型別要設定成 LDR_DATA_TABLE_ENTRY *

到這裡原本醜醜的反組譯:

Code segment of TONESHELL find module

就會變成讓人比較可以接受的型式,丟給 GPT 分析也比較準確(x

TONESHELL find module code segment after eeverse engineering

然後可以看到中間有一個 v8 = sub_1000D400(Flink, n1621917241); 看起來很熟悉,把 Flink 丟進去,然後有一些字串比對後會 return v8v8 就可以推測是目標的函式地址。只不過 Flink 應該要是 DllBase 而不是 InInitializationOrderLinks,偏移量差了一點,有點怪怪的可以先忽略它。

追進去看可以看到以下程式片段,a1n1621917241 是依序傳遞進來的參數,可以推測 n1621917241 是函式名稱經過雜湊得到的整數:

Code segment of TONESHELL get function address through export table

神秘數字 23117 和 17744 換成數字後分別是 0x4A5D0x4550,這是 DOS Header 和 NT Header 的 magic,從這點就可以修正剛剛丟進來的 Flink 型別問題。所以 a1v8 就可以分別命名成 dosnt,並更改指標型別。這時候大概就可以看出結構了:

TONESHELL get function address through export after reverse engineering

雖然中間的 AddressOfxxx 在反編譯時出了點問題,但還可以看出大概是誰,再將三個陣列的型別定義正確後,就可以看到一個相對乾淨的程式碼。在最後也可以看到他回傳 dos + AddressOfFunctions[...] 就是前面我們寫過函式地址的取得方式。

Summary

PEB walk 是惡意軟體躲避 EDR 靜態分析或偵測常見的方式之一,尤其在 TONESHELL Family 中更是廣泛的被使用。分析人員只要熟悉取得各個資訊的流程和程式結構,並在 IDA 中正確定義結構型別,便可以加速分析速度。

  • 取得 TEB -> PEB -> Ldr
  • 比對 LDR_DATA_TABLE_ENTRY 結構中的 FullDllName.Buffer,並取得目標的 DllBase
  • 透過 DOS Header 取得 NT Header,並找出 Optional Header 中的 Export Directory
  • 使用 AddressOfFunctionsAddressOfNamesAddressOfNameOrdinals 找到目標函式的位址

寫完這篇文章我又更熟悉 PEB walk 了 (*‘ v`*)

Reference