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
,GetModuleHandle
hoặ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àmGetProcAddress
và 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
GetProcAddress
và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
GS
giú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 offset0x30
của thanh ghiGS
nhằ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
ProcessEnvironmentBlock
nằ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 offset0x18
củ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ầnProcessEnvironmentBlock
nằ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
PVOID
trong 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:
address
là con trỏ đến một thành phần bên trong cấu trúc.type
là kiểu dữ liệu của cấu trúc mà ta muốn tìm địa chỉ.field
là 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 *)0
sẽ ép kiểu con trỏ null (0
) thành một con trỏ có kiểutype
. - Truy cập thành phần
field
thô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
field
có giá trị làaddress
trừ đ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
pDte
thà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.Flink
bên trongLDR_DATA_TABLE_ENTRY
với kiểu làPLDR_DATA_TABLE_ENTRY
và gán chopDte
.pDte = *(PLDR_DATA_TABLE_ENTRY*)(pDte);
-
Gán
pDte
vớipDte->InLoadOrderLinks.Flink
hoặ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 : 0xF10E27CA
Sau đó, 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.dll
thì 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.dll
có 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
ret
Với:
or rax, FFFFFFFFFFFFFFFF
là để gán thanh ghiRAX
thành giá trị0xFFFFFFFFFFFFFFFF
(có dạng biểu diễn bù 24 là-1
).ret
là để trả về giá trị của thanh ghiRAX
(0xFFFFFFFFFFFFFFFF
).
Hàm GetCurrentThread
cũng có hợp ngữ tương tự:
mov rax 0xFFFFFFFFFFFFFFFE
ret
Vớ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 thread
32-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_KEY
cho0xFF
là để làm giảm giá trị xuống mức từ0
đến255
do giá trị tạo ra bởiRandomCompileTimeSeed
là 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 HashStringDjb2W
Khai 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` variable
Việ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 ↩