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:

  1. Sử dụng GetProcAddressGetModuleHandle hoặc LoadLibrary để 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àm GetProcAddress và hàm GetModuleHandleA đều được xem là dấu hiện của malware.

  2. Tự tạo ra các hàm có chức năng tương tự với GetProcAddressGetModuleHandle.

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:

  1. 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 offset 0x30 của thanh ghi GS 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;
  2. 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:

  1. 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 offset 0x18 của thanh ghi FS.

    // Method 1
    PTEB pTeb = (PTEB)__readfsdword(0x18);
    PPEB pPeb = (PPEB)pTeb->ProcessEnvironmentBlock;
  2. 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ần ProcessEnvironmentBlock 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 FlinkBlink đề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ểu type.
  • 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 đến field.
  • (ULONG_PTR)(&((type *)0)->field) sẽ ép kiểu giá trị offset thành ULONG_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ứa field.
  • 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:

  1. Ép kiểu pDte thành PLDR_DATA_TABLE_ENTRY* (con trỏ của con trỏ đến LDR_DATA_TABLE_ENTRY) rồi dereference để có được giá trị của con trỏ InLoadOrderLinks.Flink bên trong LDR_DATA_TABLE_ENTRY với kiểu là PLDR_DATA_TABLE_ENTRY và gán cho pDte.

    pDte = *(PLDR_DATA_TABLE_ENTRY*)(pDte);
  2. Gán pDte với pDte->InLoadOrderLinks.Flink hoặc pDte->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ỏ pDtepListNode 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_ENTRYPLIST_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 GetProcAddressGetModuleHandle để 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 GetProcAddressHGetModuleHandleH 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ủa user32.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àm LoadLibraryA.

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à GetCurrentProcessGetCurrentThread 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 ghi RAX thành giá trị 0xFFFFFFFFFFFFFFFF (có dạng biểu diễn bù 24-1).
  • ret là để trả về giá trị của thanh ghi RAX (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 GetCurrentProcessGetCurrentThread, ta chỉ cần trả về giá trị -1-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 GetCurrentProcessGetCurrentThread 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 cho 0xFF là để làm giảm giá trị xuống mức từ 0 đến 255 do giá trị tạo ra bởi RandomCompileTimeSeed là rất lớn.

Creating Macros

Ta sẽ định nghĩa 2 macro là RTIME_HASHARTIME_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

  1. xem lại Parsing PE Headers

  2. xem lại Portable Executable FormatParsing PE Headers

  3. xem lại JenkinsOneAtATime32Bit

  4. xem thêm Two’s Complement