Introduction
Như đã biết, Import Address Table (IAT) chứa thông tin về các function cũng như là các DLL mà được import vào file PE. Các thông tin này có thể được dùng làm dấu hiện nhận biết malware.
Ví dụ, sau đây là IAT của file PE từ bài MalDev - Remote Payload Execution. Có thể thấy, IAT có chứa những hàm đáng ngờ (được khoanh đỏ) và có thể bị phát hiện bởi các giải pháp bảo mật:

Có 2 cách để che giấu các hàm đáng ngờ trong IAT:
-
Sử dụng
GetProcAddress,GetModuleHandlehoặcLoadLibraryđể liên kết động thay vì liên kết tĩnh:typedef LPVOID (WINAPI* fnVirtualAllocEx)(HANDLE hProcess, LPVOID lpAddress, SIZE_T dwSize, DWORD flAllocationType, DWORD flProtect); //... fnVirtualAllocEx pVirtualAllocEx = GetProcAddress(GetModuleHandleA("KERNEL32.DLL"), "VirtualAllocEx"); pVirtualAllocEx(...);Tuy nhiên, bản thân chuỗi
"VirtualAllocEx"cũng như hàmGetProcAddressvà hàmGetModuleHandleAđều được xem là dấu hiện của malware. -
Tự tạo ra các hàm có chức năng tương tự với
GetProcAddressvàGetModuleHandle.
Custom GetProcAddress
Hàm GetProcAddress được dùng để lấy địa chỉ của một hàm từ một DLL đã được load vào bộ nhớ. Nếu tên hàm không thể tìm thấy thì GetProcAddress sẽ trả về NULL.
Để che giấu việc sử dụng GetProcAddress, ta sẽ tự viết một hàm có nguyên mẫu như sau:
FARPROC GetProcAddressReplacement(IN HMODULE hModule, IN LPCSTR lpApiName) {}Với hModule là địa chỉ cơ sở của DLL ở trong vùng nhớ của tiến trình còn lpApiName là tên của hàm trong DLL mà ta cần lấy địa chỉ.
How GetProcAddress Works
Ta sẽ lặp qua các hàm ở trong export table của DLL để lấy địa chỉ của hàm cần tìm.
Để truy cập export table, ta cần truy xuất mảng DataDirectory trong optional header1 của DLL:
// We do this to avoid casting each time we use 'hModule'
PBYTE pBase = (PBYTE) hModule;
// Getting the DOS header and performing a signature check
PIMAGE_DOS_HEADER pImgDosHdr = (PIMAGE_DOS_HEADER)pBase;
if (pImgDosHdr->e_magic != IMAGE_DOS_SIGNATURE)
return NULL;
// Getting the NT headers and performing a signature check
PIMAGE_NT_HEADERS pImgNtHdrs = (PIMAGE_NT_HEADERS)(pBase + pImgDosHdr->e_lfanew);
if (pImgNtHdrs->Signature != IMAGE_NT_SIGNATURE)
return NULL;
// Getting the optional header
IMAGE_OPTIONAL_HEADER ImgOptHdr = pImgNtHdrs->OptionalHeader;
// Getting the image export table
// This is the export directory
PIMAGE_EXPORT_DIRECTORY pImgExportDir = (PIMAGE_EXPORT_DIRECTORY) (pBase + ImgOptHdr.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress);Cấu trúc của export table2:
typedef struct _IMAGE_EXPORT_DIRECTORY {
DWORD Characteristics;
DWORD TimeDateStamp;
WORD MajorVersion;
WORD MinorVersion;
DWORD Name;
DWORD Base;
DWORD NumberOfFunctions;
DWORD NumberOfNames;
DWORD AddressOfFunctions; // RVA from base of image
DWORD AddressOfNames; // RVA from base of image
DWORD AddressOfNameOrdinals; // RVA from base of image
} IMAGE_EXPORT_DIRECTORY, *PIMAGE_EXPORT_DIRECTORY;Các thành phần mà ta sẽ sử dụng là:
AddressOfFunctions- offset đến mảng các địa chỉ của các hàm được export.AddressOfNames- offset đến mảng các tên của các hàm được export.AddressOfNameOrdinals- offset đến mảng các số thứ tự của các hàm được export.
Do các thành phần trên là RVA (offset) nên ta sẽ cần phải cộng chúng với địa chỉ cơ sở của DLL để có được địa chỉ tuyệt đối ở trong vùng nhớ:
// Getting the function's names array pointer
PDWORD FunctionNameArray = (PDWORD)(pBase + pImgExportDir->AddressOfNames);
// Getting the function's addresses array pointer
PDWORD FunctionAddressArray = (PDWORD)(pBase + pImgExportDir->AddressOfFunctions);
// Getting the function's ordinal array pointer
PWORD FunctionOrdinalArray = (PWORD)(pBase + pImgExportDir->AddressOfNameOrdinals);Understanding Ordinals
Ordinal number (hay số thứ tự) của một hàm là giá trị mô tả vị trí (địa chỉ) của hàm đó trong export table, vốn được tổ chức thành một mảng các con trỏ lưu địa chỉ vùng nhớ của các hàm.
Note
Việc truy xuất địa chỉ của hàm thông qua ordinal number nhanh hơn là thông qua tên của hàm (giống với việc truy xuất bằng chỉ số trên mảng nhanh hơn truy xuất bằng key trên bảng băm). Do đó, hệ điều hành sử dụng giá trị ordinal number để xác định địa chỉ của hàm thay vì tên do tên của các hàm có thể trùng nhau (việc đụng độ trên bảng băm có thể làm giảm tốc độ truy xuất).
Ở trong vòng lặp bên dưới, ta sẽ lấy ra tên hàm và ordinal number sử dụng chỉ số tăng dần. Sau đó, ta sẽ sử dụng ordinal number để lấy ra địa chỉ của hàm:
// Looping through all the exported functions
for (DWORD i = 0; i < pImgExportDir->NumberOfFunctions; i++){
// Getting the name of the function
CHAR* pFunctionName = (CHAR*)(pBase + FunctionNameArray[i]);
// Getting the ordinal of the function
WORD wFunctionOrdinal = FunctionOrdinalArray[i];
// Getting the address of the function through it's ordinal
PVOID pFunctionAddress = (PVOID)(pBase + FunctionAddressArray[wFunctionOrdinal]);
printf("[ %0.4d ] NAME: %s -\t ADDRESS: 0x%p -\t ORDINAL: %d\n", i, pFunctionName, pFunctionAddress, wFunctionOrdinal);
}Với NumberOfFunctions là số lượng các hàm mà DLL export.
Chạy thử đoạn code trên cho ntdll.dll:

Có thể thấy, địa chỉ của hàm SHAUpdate trong x64dbg giống với địa chỉ được in ra bởi malware. Tuy nhiên, ordinal number của hàm này lại khác nhau giữa x64dbg và malware. Lý do là vì Windows Loader tạo một mảng các ordinal number mới cho từng tiến trình.
GetProcAddressReplacement Code
Cuối cùng, ta thêm vào câu lệnh điều kiện để so sánh tên hàm trong export table với tên của hàm mà ta cần tìm:
// Looping through all the exported functions
for (DWORD i = 0; i < pImgExportDir->NumberOfFunctions; i++){
// Getting the name of the function
CHAR* pFunctionName = (CHAR*)(pBase + FunctionNameArray[i]);
// Getting the address of the function through its ordinal
PVOID pFunctionAddress = (PVOID)(pBase + FunctionAddressArray[FunctionOrdinalArray[i]]);
// Searching for the function specified
if (strcmp(lpApiName, pFunctionName) == 0){
printf("[ %0.4d ] FOUND API -\t NAME: %s -\t ADDRESS: 0x%p -\t ORDINAL: %d\n", i, pFunctionName, pFunctionAddress, FunctionOrdinalArray[i]);
return pFunctionAddress;
}
}Demo
Minh họa cho việc sử dụng hàm GetProcAddressReplacement để tìm địa chỉ của hàm NtAllocateVirtualMemory:

Có thể thấy, kết quả trả về của GetProcAddressReplacement giống với kết quả trả về của GetProcAddress và x64dbg.
Custom GetModuleHandle
Hàm GetModuleHandle được sử dụng để lấy ra handle (hay địa chỉ cơ sở trong vùng nhớ) của một DLL từ tên của nó. Nếu DLL không tồn tại trong vùng nhớ của tiến trình, hàm này sẽ trả về NULL.
Chúng ta sẽ tự tạo ra một hàm giúp thực hiện nhiệm vụ này:
HMODULE GetModuleHandleReplacement(IN LPCWSTR szModuleName){}How GetModuleHandle Works
Mục tiêu của chúng ta là lấy ra địa chỉ cơ sở của DLL ở trong vùng nhớ.
Thông tin này nằm ở trong PEB. Cụ thể hơn, trường PEB_LDR_DATA Ldr của PEB chứa danh sách các DLL đã được nạp vào vùng nhớ của tiến trình.
PEB In 64-bit Systems
Như đã biết, cấu trúc TEB có một thành phần lưu con trỏ của PEB:

Đối với kiến trúc x64, offset đến con trỏ của TEB nằm ở trong thanh ghi GS:

Note
Hình ảnh trên lấy từ giao diện của x64dbg. Có thể thấy, việc truy xuất TEB thông qua thanh ghi
GSgiúp tránh được việc sử dụng Windows API mặc dù ta vẫn có thể sử dụng Windows API để làm việc này.
Có 2 cách để lấy PEB trong kiến trúc x64:
-
Truy xuất TEB rồi lấy con trỏ của PEB. Cụ thể hơn, ta sẽ sử dụng macro
__readgsqword(0x30)của Visual Studio để đọc vùng nhớ tại offset0x30của thanh ghiGSnhằm có được con trỏ của TEB rồi truy cập đến con trỏ của PEB.// Method 1 PTEB pTeb = (PTEB)__readgsqword(0x30); PPEB pPeb = (PPEB)pTeb->ProcessEnvironmentBlock; -
Truy xuất trực tiếp đến con trỏ của PEB bằng cách sử dụng
__readgsqword(0x60).// Method 2 PPEB pPeb2 = (PPEB)(__readgsqword(0x60));Lý do là vì thành phần
ProcessEnvironmentBlocknằm ở byte thứ0x60(hay 96) tính từ đầu cấu trúc TEB.
PEB In 32-bit Systems
Trong kiến trúc x86 (32-bit), offset đến con trỏ của TEB được lưu ở trong thanh ghi FS.

Hình ảnh trên lấy từ giao diện của x32dbg.
Tương tự, có 2 cách để lấy PEB trong kiến trúc x32:
-
Truy xuất TEB rồi lấy con trỏ của PEB. Để có được con trỏ của TEB, ta sẽ sử dụng macro
__readfsdword(0x18)nhằm đọc vùng nhớ tại offset0x18của thanh ghiFS.// Method 1 PTEB pTeb = (PTEB)__readfsdword(0x18); PPEB pPeb = (PPEB)pTeb->ProcessEnvironmentBlock; -
Truy xuất trực tiếp đến con trỏ của PEB bằng cách sử dụng
__readfsdword(0x30)do thành phầnProcessEnvironmentBlocknằm ở byte thứ0x30(48) tính từ đầu cấu trúc TEB trong kiến trúc 32-bit:// Method 2 PPEB pPeb2 = (PPEB)(__readfsdword(0x30));Với kích thước của kiểu
PVOIDtrong kiến trúc 32-bit là 4-byte (trong khi trong kiến trúc 64-bit là 8-byte).
Enumerating DLLs
Sau khi có PEB, bước tiếp theo là truy cập đến thành phần PEB_LDR_DATA Ldr.
PEB_LDR_DATA
Cấu trúc PEB_LDR_DATA được định nghĩa như sau:
typedef struct _PEB_LDR_DATA {
BYTE Reserved1[8];
PVOID Reserved2[3];
LIST_ENTRY InMemoryOrderModuleList;
} PEB_LDR_DATA, *PPEB_LDR_DATA;Thành phần quan trọng mà ta cần quan tâm là LIST_ENTRY InMemoryOrderModuleList.
LIST_ENTRY
Cấu trúc LIST_ENTRY được định nghĩa như sau:
typedef struct _LIST_ENTRY {
struct _LIST_ENTRY *Flink;
struct _LIST_ENTRY *Blink;
} LIST_ENTRY, *PLIST_ENTRY, *RESTRICTED_POINTER PRLIST_ENTRY;Cấu trúc này là một thể hiện của danh sách liên kết đôi (doubly-linked list) với thành phần Flink trỏ đến phần tử tiếp theo còn thành phần Blink trỏ đến phần tử trước đó. Có thể thấy, cả hai thành phần Flink và Blink đều có kiểu là con trỏ đến LIST_ENTRY.
LDR_DATA_TABLE_ENTRY
Thực chất, Flink sẽ lưu địa chỉ đến vùng nhớ của cấu trúc LDR_DATA_TABLE_ENTRY, vốn dùng để thể hiện cho một DLL được nạp vào vùng nhớ:
typedef struct _LDR_DATA_TABLE_ENTRY {
PVOID Reserved1[2];
LIST_ENTRY InMemoryOrderLinks; // doubly-linked list that contains the in-memory order of loaded modules
PVOID Reserved2[2];
PVOID DllBase;
PVOID EntryPoint;
PVOID Reserved3;
UNICODE_STRING FullDllName; // 'UNICODE_STRING' structure that contains the filename of the loaded module
BYTE Reserved4[8];
PVOID Reserved5[3];
union {
ULONG CheckSum;
PVOID Reserved6;
};
ULONG TimeDateStamp;
} LDR_DATA_TABLE_ENTRY, *PLDR_DATA_TABLE_ENTRY;Một định nghĩa khác đầy đủ hơn của LDR_DATA_TABLE_ENTRY, được cung cấp bởi NirSoft:
typedef struct _LDR_DATA_TABLE_ENTRY {
LIST_ENTRY InLoadOrderLinks;
LIST_ENTRY InMemoryOrderLinks;
LIST_ENTRY InInitializationOrderLinks;
PVOID DllBase;
PVOID EntryPoint;
ULONG SizeOfImage;
UNICODE_STRING FullDllName;
UNICODE_STRING BaseDllName;
ULONG Flags;
WORD LoadCount;
WORD TlsIndex;
union {
LIST_ENTRY HashLinks;
struct {
PVOID SectionPointer;
ULONG CheckSum;
};
};
union {
ULONG TimeDateStamp;
PVOID LoadedImports;
};
PACTIVATION_CONTEXT EntryPointActivationContext;
PVOID PatchInformation;
LIST_ENTRY ForwarderLinks;
LIST_ENTRY ServiceTagLinks;
LIST_ENTRY StaticLinks;
} LDR_DATA_TABLE_ENTRY, * PLDR_DATA_TABLE_ENTRY;Cụ thể hơn, thành phần Flink sẽ tương ứng với phần tử LDR_DATA_TABLE_ENTRY.Reserved1[0] còn thành phần Blink sẽ tương ứng với phần tử LDR_DATA_TABLE_ENTRY.Reserved1[1]. Nói cách khác, cấu trúc LIST_ENTRY được nhúng ở đầu cấu trúc LDR_DATA_TABLE_ENTRY.
Điều này là khả thi vì một giá trị vùng nhớ ở trong C có thể được ép kiểu thành nhiều kiểu khác nhau. Như vậy, ta có thể ép kiểu InMemoryOrderModuleList.Flink từ LIST_ENTRY thành LDR_DATA_TABLE_ENTRY để có được cấu trúc lưu thông tin của một DLL được nạp vào tiến trình:
HMODULE GetModuleHandleReplacement(IN LPCWSTR szModuleName) {
// Getting peb
#ifdef _WIN64 // if compiling as x64
PPEB pPeb = (PEB*)(__readgsqword(0x60));
#elif _WIN32 // if compiling as x32
PPEB pPeb = (PEB*)(__readfsdword(0x30));
#endif
// Getting the Ldr
PPEB_LDR_DATA pLdr = (PPEB_LDR_DATA)(pPeb->Ldr);
// Getting the first element in the linked list which contains information about the first module
PLDR_DATA_TABLE_ENTRY pDte = (PLDR_DATA_TABLE_ENTRY)(pLdr->InMemoryOrderModuleList.Flink);
}CONTAINING_RECORD
Ta có thể sử dụng macro CONTAINING_RECORD để tìm địa chỉ của LDR_DATA_TABLE_ENTRY một cách tường minh thay vì thực hiện ép kiểu.
Macro này thường được sử dụng để tìm địa chỉ của một cấu trúc cha (containing structure) bằng một con trỏ của một thành phần bên trong cấu trúc đó. CONTAINING_RECORD được định nghĩa ở trong ntdef.h như sau:
#define CONTAINING_RECORD(address, type, field) ((type *)( (char *)(address) - (ULONG_PTR)(&((type *)0)->field) ))Với:
addresslà con trỏ đến một thành phần bên trong cấu trúc.typelà kiểu dữ liệu của cấu trúc mà ta muốn tìm địa chỉ.fieldlà tên của thành phần bên trong cấu trúc nằm ở địa chỉaddress.
Cách mà CONTAINING_RECORD hoạt động:
- Biểu thức
(type *)0sẽ ép kiểu con trỏ null (0) thành một con trỏ có kiểutype. - Truy cập thành phần
fieldthông qua con trỏ của cấu trúc sẽ cho ta offset (do con trỏ của cấu trúc có giá trị là 0) từ đầu cấu trúc đếnfield. (ULONG_PTR)(&((type *)0)->field)sẽ ép kiểu giá trị offset thànhULONG_PTR, là một kiểu dữ liệu con trỏ đủ lớn để đảm bảo có thể chứa được offset.- Khi lấy con trỏ của
fieldcó giá trị làaddresstrừ đi offset, ta sẽ có được địa chỉ của cấu trúc chứafield. - Con trỏ trả về sẽ được ép kiểu thành
type *.
Cụ thể, ta sẽ sử dụng CONTAINING_RECORD để tìm địa chỉ của cấu trúc LDR_DATA_TABLE_ENTRY bằng thành phần Reserved1[0] như sau:
PLDR_DATA_TABLE_ENTRY pDte = CONTAINING_RECORD(pLdr->InMemoryOrderModuleList.Flink, LDR_DATA_TABLE_ENTRY, Reserved1[0]);Macro trên sẽ được expand bởi trình biên dịch ra như sau:
((LDR_DATA_TABLE_ENTRY*)((PCHAR)(pLdr->InMemoryOrderModuleList.Flink) - (ULONG_PTR)(&((LDR_DATA_TABLE_ENTRY*)0)->Reserved1[0])))Nếu dùng định nghĩa cấu trúc LDR_DATA_TABLE_ENTRY của NirSoft thì sử dụng macro như sau:
PLDR_DATA_TABLE_ENTRY pDte = CONTAINING_RECORD(pLdr->InMemoryOrderModuleList.Flink, LDR_DATA_TABLE_ENTRY, InLoadOrderLinks);Move to the Next DLL
Để di chuyển đến DLL tiếp theo ở trong danh sách, ta có thể sử dụng 2 cách sau:
-
Ép kiểu
pDtethànhPLDR_DATA_TABLE_ENTRY*(con trỏ của con trỏ đếnLDR_DATA_TABLE_ENTRY) rồi dereference để có được giá trị của con trỏInLoadOrderLinks.Flinkbên trongLDR_DATA_TABLE_ENTRYvới kiểu làPLDR_DATA_TABLE_ENTRYvà gán chopDte.pDte = *(PLDR_DATA_TABLE_ENTRY*)(pDte); -
Gán
pDtevớipDte->InLoadOrderLinks.FlinkhoặcpDte->InLoadOrderLinks.Reserved1[0].pDte = (PLDR_DATA_TABLE_ENTRY)pDte->InLoadOrderLinks.Flink; // or pDte = (PLDR_DATA_TABLE_ENTRY)pDte->InLoadOrderLinks.Reserved1[0];
Có thể thấy, mặc dù cách đầu tiên nhìn có vẻ ngắn gọn và không bị phụ thuộc vào định nghĩa của cấu trúc LDR_DATA_TABLE_ENTRY nhưng nó không tường minh. Vì vậy, ta sẽ sử dụng cách thứ 2.
Vòng lặp duyệt qua các DLL:
while (pDte) {
// If not null
if (pDte->FullDllName.Length != NULL) {
// Print the DLL name
wprintf(L"[i] \"%s\" \n", pDte->FullDllName.Buffer);
}
else {
break;
}
// Next element in the linked list
pDte = *(PLDR_DATA_TABLE_ENTRY*)(pDte);
}Còn có một cách implement khác tường minh hơn để duyệt qua các DLL:
// getting the head of the linked list ( used to get the node & to check the end of the list)
PLIST_ENTRY pListHead = (PLIST_ENTRY)&pPeb->Ldr->InMemoryOrderModuleList;
// getting the node of the linked list
PLIST_ENTRY pListNode = (PLIST_ENTRY)pListHead->Flink;
do
{
// If not null
if (pDte->FullDllName.Length != NULL) {
// Print the DLL name
wprintf(L"[i] \"%s\" \n", pDte->FullDllName.Buffer);
}
else {
break;
}
// updating pDte to point to the next PLDR_DATA_TABLE_ENTRY in the linked list
pDte = (PLDR_DATA_TABLE_ENTRY)(pListNode->Flink);
// updating the node variable to be the next node in the linked list
pListNode = (PLIST_ENTRY)pListNode->Flink;
// when the node is equal to the head, we reached the end of the linked list, so we break out of the loop
} while (pListNode != pListHead);Với pListHead là con trỏ đến phần tử LIST_ENTRY đầu tiên còn pListNode là con trỏ đến phần tử LIST_ENTRY tiếp theo ở trong danh sách.
Có thể thấy, hai con trỏ pDte và pListNode mặc dù cùng có một giá trị vùng nhớ nhưng lại có 2 kiểu khác nhau: PLDR_DATA_TABLE_ENTRY và PLIST_ENTRY. Mục đích của pDte là để truy xuất cấu trúc LDR_DATA_TABLE_ENTRY còn của pListNode là để di chuyển đến phần tử tiếp theo trong danh sách liên kết.
Do danh sách liên kết các DLL là một danh sách liên kết vòng nên node cuối cùng có Flink trỏ đến node đầu tiên. Vì thế, ta sử dụng điều kiện vòng lặp là pListNode != pListHead để kiểm tra xem ta có di chuyển hết danh sách liên kết và quay lại từ đầu hay không.
Kết quả của việc enumerate các DLL:

Case Sensitive DLL Names
Có thể thấy, tên của các DLL ở trong hình trên có cái được viết hoa còn có cái thì không. Điều này sẽ ảnh hưởng đến việc tìm địa chỉ của DLL dựa trên chuỗi tên truyền vào. Ví dụ, chuỗi KERNEL32.DLL sẽ khác với chuỗi kernel32.dll khi so sánh bằng hàm wcscmp.
Để giải quyết vấn đề này, ta sẽ tạo ra một hàm chuyển 2 chuỗi cần so sánh về dạng viết thường rồi mới so sánh:
BOOL IsStringEqual (IN LPCWSTR Str1, IN LPCWSTR Str2) {
WCHAR lStr1 [MAX_PATH],
lStr2 [MAX_PATH];
int len1 = lstrlenW(Str1),
len2 = lstrlenW(Str2);
int i = 0,
j = 0;
// Checking length. We dont want to overflow the buffers
if (len1 >= MAX_PATH || len2 >= MAX_PATH)
return FALSE;
// Converting Str1 to lower case string (lStr1)
for (i = 0; i < len1; i++){
lStr1[i] = (WCHAR)tolower(Str1[i]);
}
lStr1[i++] = L'\0'; // null terminating
// Converting Str2 to lower case string (lStr2)
for (j = 0; j < len2; j++) {
lStr2[j] = (WCHAR)tolower(Str2[j]);
}
lStr2[j++] = L'\0'; // null terminating
// Comparing the lower-case strings
if (lstrcmpiW(lStr1, lStr2) == 0)
return TRUE;
return FALSE;
}DLL Base Address
Địa chỉ cơ sở của DLL mà ta cần lấy nằm ở thành phần InInitializationOrderLinks.Flink (đối với định nghĩa của NirSoft) hay thành phần Reserved2[0] (đối với định nghĩa của Microsoft).
Với thông tin này, ta có thể trả về địa chỉ cơ sở của DLL mà ta cần tìm như sau:
// If not null
if (pDte->FullDllName.Length != NULL) {
// Check if both equal
if (IsStringEqual(pDte->FullDllName.Buffer, szModuleName)) {
wprintf(L"[+] Found Dll \"%s\" \n", pDte->FullDllName.Buffer);
#ifdef STRUCTS
return (HMODULE)(pDte->InInitializationOrderLinks.Flink);
#else
return (HMODULE)pDte->Reserved2[0];
#endif // STRUCTS
}
} else {
break;
}Đoạn code sử dụng #ifdef là để đảm bảo chúng ta trả về đúng thành phần khi sử dụng các định nghĩa khác nhau của cấu trúc LDR_DATA_TABLE_ENTRY.
API Hashing
Mặc dù đã viết lại hai hàm GetProcAddress và GetModuleHandle để thực hiện liên kết động nhằm làm giảm sự đáng ngờ của IAT, các chuỗi được sử dụng để so sánh vẫn còn đó.
GetProcAddressReplacement(GetModuleHandleReplacement("ntdll.dll"),"VirtualAllocEx");Ví dụ, chuỗi "ntdll.dll" và chuỗi "VirtualAllocEx" vẫn có thể bị các giải pháp bảo mật truy xuất từ file nhị phân.
JenkinsOneAtATime32Bit
Để giải quyết vấn đề này, ta có thể thay thế các giá trị chuỗi bằng các giá trị băm. Cụ thể hơn, ta sẽ tính giá trị băm của tên DLL và tên hàm sử dụng thuật toán JenkinsOneAtATime32Bit3:
int main(){
printf("[i] Hash Of \"%s\" Is : 0x%0.8X \n", "USER32.DLL", HASHA("USER32.DLL")); // Capitalized module name
printf("[i] Hash Of \"%s\" Is : 0x%0.8X \n", "MessageBoxA", HASHA("MessageBoxA"));
return 0;
}Với HASHA là macro dùng để gọi hàm băm của thuật toán JenkinsOneAtaTime32Bit dùng cho chuỗi ASCII:
#define HASHA(API) (HashStringJenkinsOneAtATime32BitA((PCHAR) API))
#define HASHW(API) (HashStringJenkinsOneAtATime32BitW((PWCHAR) API))Kết quả:
[i] Hash Of "USER32.DLL" Is : 0x81E3778E
[i] Hash Of "MessageBoxA" Is : 0xF10E27CASau đó, thay thế các chuỗi tương ứng được gán cứng ở trong code bằng cách giá trị băm vừa tính được:
// 0x81E3778E is the hash of USER32.DLL
// 0xF10E27CA is the hash of MessageBoxA
fnMessageBoxA pMessageBoxA = GetProcAddressH(GetModuleHandleH(0x81E3778E),0xF10E27CA);Với GetProcAddressH và GetModuleHandleH lần lượt là hàm GetProcAddressReplacement ([[#custom-getprocaddress|Custom GetProcAddress]]) và hàm GetModuleHandleReplacement ([[#custom-getmodulehandle|Custom GetModuleHandle]]) được viết lại để sử dụng giá trị băm thay vì giá trị chuỗi.
Note
Nếu dùng hàm
GetModuleHandleHđể lấy ra module củauser32.dllthì ta cần phải đảm bảo rằng nó được nạp vào vùng nhớ của tiến trình bằng cách sử dụng hàmLoadLibraryA.Lý do là vì
user32.dllcó thể không được nạp vào tiến trình một cách mặc định nhưkernel32.dll.
GetProcAddressH
Đối với vòng lặp bên trong GetProcAddressH, ta sẽ gọi macro HASHA để tính tên hàm rồi so sánh với giá trị băm được truyền vào hàm (dwApiNameHash):
for (DWORD i = 0; i < pImgExportDir->NumberOfFunctions; i++) {
CHAR* pFunctionName = (CHAR*)(pBase + FunctionNameArray[i]);
PVOID pFunctionAddress = (PVOID)(pBase + FunctionAddressArray[FunctionOrdinalArray[i]]);
// Hashing every function name pFunctionName
// If both hashes are equal then we found the function we want
if (dwApiNameHash == HASHA(pFunctionName)) {
return pFunctionAddress;
}
}GetModuleHandleH
Còn đối với vòng lặp bên trong GetModuleHandleH, ta sẽ gọi macro HASHA để tính tên DLL rồi so sánh với giá trị băm được truyền vào hàm (dwModuleNameHash):
while (pDte) {
if (pDte->FullDllName.Length != NULL && pDte->FullDllName.Length < MAX_PATH) {
// Converting `FullDllName.Buffer` to upper case string
CHAR UpperCaseDllName[MAX_PATH];
DWORD i = 0;
while (pDte->FullDllName.Buffer[i]) {
UpperCaseDllName[i] = (CHAR)toupper(pDte->FullDllName.Buffer[i]);
i++;
}
UpperCaseDllName[i] = '\0';
// hashing `UpperCaseDllName` and comparing the hash value to that's of the input `dwModuleNameHash`
if (HASHA(UpperCaseDllName) == dwModuleNameHash)
return pDte->Reserved2[0];
}
else {
break;
}
pDte = *(PLDR_DATA_TABLE_ENTRY*)(pDte);
}Với việc chuyển tên DLL thành viết hoa là do quy ước (ta hoàn toàn có thể chuyển thành các ký tự viết thường rồi so sánh với giá trị băm được tính ra từ chuỗi viết thường).
Custom Pseudo Handles
Chúng ta có thể custom lại 2 hàm dùng để lấy các pseudo handle là GetCurrentProcess và GetCurrentThread bằng cách sử dụng debugger để xem cách mà 2 hàm này hoạt động.
Info
Việc custom lại các hàm của Windows API giúp giảm được các giá trị băm có trong file nhị phân, vốn có thể được dùng để làm heuristic signatures.
Tuy nhiên, một số hàm của Windows API là không thể custom do chúng quá phức tạp.
Seealso
Xem thêm các custom function của Windows API ở vxunderground/VX-API.
What is a Pseudo Handle?
Pseudo handle là một loại handle giả và không gắn liền với một tài nguyên hệ thống cụ thể nào đó chẳng hạn như tiến trình hoặc thread.
Về bản chất, pseudo handle là một số nguyên được ép kiểu về HANDLE và được sử dụng như là một handle thông thường. Ví dụ, pseudo handle của tiến trình hiện tại là (HANDLE)-1 và của thread hiện tại là (HANDLE)-2.
Analyzing The Functions
Hàm GetCurrentProcess có hợp ngữ như sau:
or rax, FFFFFFFFFFFFFFFF
retVới:
or rax, FFFFFFFFFFFFFFFFlà để gán thanh ghiRAXthành giá trị0xFFFFFFFFFFFFFFFF(có dạng biểu diễn bù 24 là-1).retlà để trả về giá trị của thanh ghiRAX(0xFFFFFFFFFFFFFFFF).
Hàm GetCurrentThread cũng có hợp ngữ tương tự:
mov rax 0xFFFFFFFFFFFFFFFE
retVới 0xFFFFFFFFFFFFFFFE có dạng biểu diễn bù 2 là -2.
Custom Implementation
Như vậy, để tự viết hàm GetCurrentProcess và GetCurrentThread, ta chỉ cần trả về giá trị -1 và -2:
#define NtCurrentProcess() ((HANDLE)-1) // Return the pseudo handle for the current process
#define NtCurrentThread() ((HANDLE)-2) // Return the pseudo handle for the current thread32-bit Systems
Sự khác nhau của 2 hàm GetCurrentProcess và GetCurrentThread giữa kiến trúc 32-bit và kiến trúc 64-bit là kích thước của kiểu dữ liệu HANDLE. Cụ thể, trong kiến trúc 64-bit, HANDLE có kích thước là 8-byte trong khi trong kiến trúc 32-bit, HANDLE có kích thước là 4-byte.
Compile Time API Hashing
Việc tạo ra các giá trị băm của tên DLL hoặc tên hàm trước khi thêm vào mã nguồn là khá tốn thời gian. Hơn thế nữa, việc gán cứng các giá trị băm ở trong mã nguồn bị các giải pháp bảo mật dùng làm IoC.
Ta có thể giải quyết các vấn đề bằng cách sử dụng kỹ thuật Compile Time API Hashing. Kỹ thuật này giúp chúng ta tạo ra các giá trị băm tại thời điểm biên dịch và có giá trị khác nhau mỗi lần biên dịch.
Warning
Kỹ thuật này chỉ có thể được sử dụng cho mã nguồn C++ vì ta cần dùng từ khóa
constexpr. Từ khóa này được dùng để đánh dấu một hàm hay một biến nào đó được tính toán tại thời điểm biên dịch.
Create Compile Time Functions
Trước tiên, ta sẽ đánh dấu các hàm băm (ta sẽ sử dụng thuật toán Djb2) bằng từ khóa constexpr để cho phép chúng có thể thực thi trong quá trình biên dịch:
#define SEED 5
// Compile time Djb2 hashing function (WIDE)
constexpr DWORD HashStringDjb2W(const wchar_t* String) {
ULONG Hash = (ULONG)g_KEY;
INT c = 0;
while ((c = *String++)) {
Hash = ((Hash << SEED) + Hash) + c;
}
return Hash;
}
// Compile time Djb2 hashing function (ASCII)
constexpr DWORD HashStringDjb2A(const char* String) {
ULONG Hash = (ULONG)g_KEY;
INT c = 0;
while ((c = *String++)) {
Hash = ((Hash << SEED) + Hash) + c;
}
return Hash;
}Giá trị g_KEY là giá trị băm ban đầu và sẽ là một biến toàn cục với từ khóa constexpr, được tạo ra tại thời điểm biên dịch một cách ngẫu nhiên bởi hàm RandomCompileTimeSeed.
Generating a Random Initial Hash
Hàm RandomCompileTimeSeed sẽ tạo ra một giá trị ngẫu nhiên dựa trên thời điểm hiện tại thông qua macro __TIME__ với định dạng là HH:MM:SS.
// Generate a random key at compile time which is used as the initial hash
constexpr int RandomCompileTimeSeed(void)
{
return '0' * -40271 +
__TIME__[7] * 1 +
__TIME__[6] * 10 +
__TIME__[4] * 60 +
__TIME__[3] * 600 +
__TIME__[1] * 3600 +
__TIME__[0] * 36000;
};
// The compile time random seed
constexpr auto g_KEY = RandomCompileTimeSeed() % 0xFF;Có thể thấy, hàm RandomCompileTimeSeed nhân từng chữ số của __TIME__ với một hằng số ngẫu nhiên rồi cộng chúng với nhau để tạo ra số ngẫu nhiên.
Info
Việc chia lấy phần dư
g_KEYcho0xFFlà để làm giảm giá trị xuống mức từ0đến255do giá trị tạo ra bởiRandomCompileTimeSeedlà rất lớn.
Creating Macros
Ta sẽ định nghĩa 2 macro là RTIME_HASHA và RTIME_HASHW để gọi ở trong GetProcAddressH ([[#custom-getprocaddress|Custom GetProcAddress]]) nhằm tính toán giá trị băm trong lúc chạy:
#define RTIME_HASHA( API ) HashStringDjb2A((const char*) API) // Calling HashStringDjb2A
#define RTIME_HASHW( API ) HashStringDjb2W((const wchar_t*) API) // Calling HashStringDjb2WKhai báo các biến dùng để lưu giá trị băm được tính tại thời điểm biên dịch:
#define CTIME_HASHA( API ) constexpr auto API##_Rotr32A = HashStringDjb2A((const char*) #API);
#define CTIME_HASHW( API ) constexpr auto API##_Rotr32W = HashStringDjb2W((const wchar_t*) L#API);Stringizing Operator
Toán tử # được gọi là toán tử xâu chuỗi (stringizing), được sử dụng để chuyển đổi một tham số của macro thành một string literal.
Ví dụ, nếu CTIME_HASHA macro được gọi với tham số là SomeFunction chẳng hạn như CTIME_HASHA(SomeFunction) thì #API sẽ được chuyển thành "SomeFunction".
Merging Operator
Toán tử ## được gọi là toán tử ghép (merging), được sử dụng để ghép hai macro thành một macro duy nhất. Ở đây, nó được sử dụng để ghép đối số của API với Rotr32A hoặc Rotr32W. Ví dụ, nếu CTIME_HASHA macro được gọi với tham số là SomeFunction thì API##_Rotr32A sẽ được chuyển thành SomeFunction_Rotr32A.
Gọi sử dụng các macro để tạo ra các biến lưu giá trị băm:
// create compile time variables
CTIME_HASHA(MessageBoxA) // this will create `MessageBoxA_Rotr32A` variable
CTIME_HASHW(MessageBoxW) // this will create `MessageBoxW_Rotr32W` variableViệc gọi macro sẽ được trình biên dịch expand ra như sau:

Như vậy, khi biên dịch, trình biên dịch sẽ khai báo các biến chứa các giá trị băm của tên hàm.
Note
Chú ý rằng chúng ta không sử dụng bất kỳ giá trị chuỗi nào khi khai báo các macro trên.
Info
Việc sử dụng code để tạo ra code như trên được gọi là metaprogramming.
Using the Macros
Ta có thể sử dụng các biến được khai báo khi biên dịch ở trên như sau:
if ((hUser32Module = LoadLibraryA("USER32.DLL")) == NULL) {
printf("[!] LoadLibraryA Failed With Error : %d \n", GetLastError());
return 0;
}
// MessageBoxA_Rotr32A created by CTIME_HASHA(MessageBoxA)
fnMessageBoxA pMessageBoxA = (fnMessageBoxA)GetProcAddressH(hUser32Module, MessageBoxA_Rotr32A);
if (pMessageBoxA == NULL) {
return -1;
}
// MessageBoxW_Rotr32W created by CTIME_HASHW(MessageBoxW)
fnMessageBoxW pMessageBoxW = (fnMessageBoxW)GetProcAddressH(hUser32Module, MessageBoxW_Rotr32W);
if (pMessageBoxW == NULL) {
return -1;
}Giá trị của MessageBoxA_Rotr32A sẽ được tính bởi trình biên dịch và ta có thể xem trước bằng Visual Studio như sau:

Tất nhiên, giá trị này sẽ bị thay đổi khi ta build lại project.
Resources
Footnotes
-
xem lại Parsing PE Headers ↩
-
xem lại Portable Executable Format và Parsing PE Headers ↩
-
xem lại JenkinsOneAtATime32Bit ↩
-
xem thêm Two’s Complement ↩