本文首發阿里雲先知社群:https://xz.aliyun.com/t/14592
背景知識
在之前的文章中,我們介紹了靜態欺騙和動態欺騙堆疊,今天我們來一起學習一下另一種技術,它被它的作者稱為Custom Call Stacks,即自定義堆疊呼叫。
關於堆疊欺騙的背景我們就不再說了,這裡我們補充一下回撥函式和 windows 執行緒池的知識。
回撥函式是指向函式的指標,該函式可以傳遞給要在其中執行的其他函式,在常規的 shellcode loader 中回撥函式也是一種常見的執行方式,並且 github 上有倉庫詳細的記錄了各種各樣的回撥函式執行 shellcode:https://github.com/aahmad097/AlternativeShellcodeExec。
但是這種執行回撥的方式都存在一個問題,回撥方和呼叫方位於同一個執行緒中,假設當我們透過回撥LoadLibrary 時,執行此時的堆疊就像是這樣LoadLibrary returns to -> Callback Function returns to -> RX region
,RX region 指的是我們的 shellcode 地址,所以我們的 shellcode 的記憶體空間很容易被發現。
為了解決這個問題,我們要用到 windows 執行緒池,官方介紹如下:Windows執行緒池是一種作業系統提供的機制,用於管理和排程多個工作執行緒,以提高多執行緒應用程式的效能和效率。執行緒池透過重用現有的執行緒來執行任務,避免了頻繁建立和銷燬執行緒的開銷,從而提升系統資源利用率和應用程式的響應速度。
其實就是提前給我們建立好了很多執行緒,讓我們可以方便的進行排程,當有任務需要執行時,我們提交給執行緒池就可以了。
引數如何傳遞
下面是一個小 demo
#include <windows.h>
#include <stdio.h>
int main() {
CHAR *libName = "wininet.dll";
PTP_WORK WorkReturn = NULL;
TpAllocWork(&WorkReturn, LoadLibraryA, libName, NULL); // pass `LoadLibraryA` as a callback to TpAllocWork
TpPostWork(WorkReturn); // request Allocated Worker Thread Execution
TpReleaseWork(WorkReturn); // worker thread cleanup
WaitForSingleObject((HANDLE)-1, 1000);
printf("hWininet: %p\n", GetModuleHandleA(libName)); //check if library is loaded
return 0;
}
讓 gpt 來幫我們解釋一下程式碼:
- TpAllocWork(&WorkReturn, LoadLibraryA, libName, NULL);:這行程式碼使用了TpAllocWork函式來分配一個工作項,將LoadLibraryA函式作為回撥函式,以非同步的方式載入指定名稱的動態連結庫。LoadLibraryA是用於載入ANSI字串(即CHAR型別)的動態連結庫函式。
- TpPostWork(WorkReturn);:這行程式碼將分配的工作項提交給執行緒池,請求執行緒池中的工作執行緒執行載入庫的任務。
- TpReleaseWork(WorkReturn);:這行程式碼釋放了分配的工作項,進行了工作執行緒的清理操作。
可以看到,透過這種方式確實幫助我們在一個新的執行緒執行了 LoadLibraryA 函式,但是能不能成功執行呢?
如果編譯上面的程式碼,那麼程式碼將會崩潰,因為他的引數傳遞並不正確。
TpAllocWork 的定義是:
NTSTATUS NTAPI TpAllocWork(
PTP_WORK* ptpWrk,
PTP_WORK_CALLBACK pfnwkCallback,
PVOID OptionalArg,
PTP_CALLBACK_ENVIRON CallbackEnvironment
);
這意味著我們的回撥函式 LoadLibraryA 應該是 PTP_WORK_CALLBACK 型別。此型別擴充套件為:
VOID CALLBACK WorkCallback(
PTP_CALLBACK_INSTANCE Instance,
PVOID Context,
PTP_WORK Work
);
從上圖中可以看出,我們的 OptionalArg作為輔助引數轉發到我們的 Callback ( PVOID Context )。因此,如果我們的假設是正確的,那麼我們傳遞給 TpAllocWork 的引數 libName (wininet.dll) 最終將作為我們 LoadLibraryA 的第二個引數。在 x64dbg 中檢查此項會導致下圖:
還記得上篇文章中關於 64 位下傳遞引數的規則嗎,rcx 應該存第一個引數,rdx 中應該存第二個引數。
直接在WorkCallback 中執行
但是不要放棄,還是有希望的,我們直接在上面的WorkCallback 中讓它執行就可以了,如下面的程式碼所示:
#include <windows.h>
#include <stdio.h>
VOID CALLBACK WorkCallback(
_Inout_ PTP_CALLBACK_INSTANCE Instance,
_Inout_opt_ PVOID Context,
_Inout_ PTP_WORK Work
) {
LoadLibraryA(Context);
}
int main() {
CHAR *libName = "wininet.dll";
PTP_WORK WorkReturn = NULL;
TpAllocWork(&WorkReturn, WorkerCallback, libName, NULL); // pass `LoadLibraryA` as a callback to TpAllocWork
TpPostWork(WorkReturn); // request Allocated Worker Thread Execution
TpReleaseWork(WorkReturn); // worker thread cleanup
WaitForSingleObject((HANDLE)-1, 1000);
printf("hWininet: %p\n", GetModuleHandleA(libName)); //check if library is loaded
return 0;
}
但是這樣的話,回撥相當於在我們 shellcode 的記憶體區域執行的,我們的堆疊變成了LoadLibraryA returns to -> Callback in RX Region returns to -> RtlUserThreadStart -> TpPostWork
,這樣並不好,因為歸根結底還是出現了 shellcode 的記憶體區域。
藉助彙編跳轉執行
但是不要放棄,我們還有別的機會來進行嘗試,我們可以透過彙編來幫助我們調整堆疊結構,只需要在彙編裡面 mov rdx,rcx,再 jmp 到 LoadLibraryA 的位置就可以了,注意這裡是 jmp 而不是 call,如果 call 的話我們會先將此時的地址壓入堆疊,再去執行,這樣堆疊中還是會出現 shellcode 的記憶體區域,但是我們 jmp 的話,就直接過去了,我們的彙編函式並沒有在堆疊留下任何痕跡,這也是一個小技巧。
程式碼如下:
#include <windows.h>
#include <stdio.h>
typedef NTSTATUS (NTAPI* TPALLOCWORK)(PTP_WORK* ptpWrk, PTP_WORK_CALLBACK pfnwkCallback, PVOID OptionalArg, PTP_CALLBACK_ENVIRON CallbackEnvironment);
typedef VOID (NTAPI* TPPOSTWORK)(PTP_WORK);
typedef VOID (NTAPI* TPRELEASEWORK)(PTP_WORK);
FARPROC pLoadLibraryA;
UINT_PTR getLoadLibraryA() {
return (UINT_PTR)pLoadLibraryA;
}
extern VOID CALLBACK WorkCallback(PTP_CALLBACK_INSTANCE Instance, PVOID Context, PTP_WORK Work);
int main() {
pLoadLibraryA = GetProcAddress(GetModuleHandleA("kernel32"), "LoadLibraryA");
FARPROC pTpAllocWork = GetProcAddress(GetModuleHandleA("ntdll"), "TpAllocWork");
FARPROC pTpPostWork = GetProcAddress(GetModuleHandleA("ntdll"), "TpPostWork");
FARPROC pTpReleaseWork = GetProcAddress(GetModuleHandleA("ntdll"), "TpReleaseWork");
CHAR *libName = "wininet.dll";
PTP_WORK WorkReturn = NULL;
((TPALLOCWORK)pTpAllocWork)(&WorkReturn, (PTP_WORK_CALLBACK)WorkCallback, libName, NULL);
((TPPOSTWORK)pTpPostWork)(WorkReturn);
((TPRELEASEWORK)pTpReleaseWork)(WorkReturn);
WaitForSingleObject((HANDLE)-1, 0x1000);
printf("hWininet: %p\n", GetModuleHandleA(libName));
return 0;
}
我們的彙編函式如下:
section .text
extern getLoadLibraryA
global WorkCallback
WorkCallback:
mov rcx, rdx
xor rdx, rdx
call getLoadLibraryA
jmp rax
觸發回撥時執行WorkCallback 函式,然後在WorkCallback 我們手動調整引數位置,然後call getLoadLibraryA
,獲得LoadLibraryA 的記憶體地址,然後直接 jmp 過去,這就是我們所完成的事情。
現在看一下我們的堆疊,十分完美:
多引數呼叫
現在我們要考慮一些其他的問題了,比如引數個數,如果引數個數超過 4 個我們是要存放在堆疊中的,以NtAllocateVirtualMemory 為例,它的定義是:
__kernel_entry NTSYSCALLAPI NTSTATUS NtAllocateVirtualMemory(
[in] HANDLE ProcessHandle,
[in, out] PVOID *BaseAddress,
[in] ULONG_PTR ZeroBits,
[in, out] PSIZE_T RegionSize,
[in] ULONG AllocationType,
[in] ULONG Protect
);
我們現在需要將 NtAllocateVirtualMemory 的指標及其結構內的引數傳遞給回撥,以便我們的回撥可以從結構中提取這些資訊並執行它。忽略掉 ZeroBits (值恆為 0)和 AllocationType(值為0x3000),我們可以得到一個新的結構體,定義如下
typedef struct _NTALLOCATEVIRTUALMEMORY_ARGS {
UINT_PTR pNtAllocateVirtualMemory; // pointer to NtAllocateVirtualMemory - rax
HANDLE hProcess; // HANDLE ProcessHandle - rcx
PVOID* address; // PVOID *BaseAddress - rdx; ULONG_PTR ZeroBits - 0 - r8
PSIZE_T size; // PSIZE_T RegionSize - r9; ULONG AllocationType - MEM_RESERVE|MEM_COMMIT = 3000 - stack pointer
ULONG permissions; // ULONG Protect - PAGE_EXECUTE_READ - 0x20 - stack pointer
} NTALLOCATEVIRTUALMEMORY_ARGS, *PNTALLOCATEVIRTUALMEMORY_ARGS;
然後我們的程式碼和上面也差不多
#include <windows.h>
#include <stdio.h>
typedef NTSTATUS (NTAPI* TPALLOCWORK)(PTP_WORK* ptpWrk, PTP_WORK_CALLBACK pfnwkCallback, PVOID OptionalArg, PTP_CALLBACK_ENVIRON CallbackEnvironment);
typedef VOID (NTAPI* TPPOSTWORK)(PTP_WORK);
typedef VOID (NTAPI* TPRELEASEWORK)(PTP_WORK);
typedef struct _NTALLOCATEVIRTUALMEMORY_ARGS {
UINT_PTR pNtAllocateVirtualMemory; // pointer to NtAllocateVirtualMemory - rax
HANDLE hProcess; // HANDLE ProcessHandle - rcx
PVOID* address; // PVOID *BaseAddress - rdx; ULONG_PTR ZeroBits - 0 - r8
PSIZE_T size; // PSIZE_T RegionSize - r9; ULONG AllocationType - MEM_RESERVE|MEM_COMMIT = 3000 - stack pointer
ULONG permissions; // ULONG Protect - PAGE_EXECUTE_READ - 0x20 - stack pointer
} NTALLOCATEVIRTUALMEMORY_ARGS, *PNTALLOCATEVIRTUALMEMORY_ARGS;
extern VOID CALLBACK WorkCallback(PTP_CALLBACK_INSTANCE Instance, PVOID Context, PTP_WORK Work);
int main() {
LPVOID allocatedAddress = NULL;
SIZE_T allocatedsize = 0x1000;
NTALLOCATEVIRTUALMEMORY_ARGS ntAllocateVirtualMemoryArgs = { 0 };
ntAllocateVirtualMemoryArgs.pNtAllocateVirtualMemory = (UINT_PTR) GetProcAddress(GetModuleHandleA("ntdll"), "NtAllocateVirtualMemory");
ntAllocateVirtualMemoryArgs.hProcess = (HANDLE)-1;
ntAllocateVirtualMemoryArgs.address = &allocatedAddress;
ntAllocateVirtualMemoryArgs.size = &allocatedsize;
ntAllocateVirtualMemoryArgs.permissions = PAGE_EXECUTE_READ;
FARPROC pTpAllocWork = GetProcAddress(GetModuleHandleA("ntdll"), "TpAllocWork");
FARPROC pTpPostWork = GetProcAddress(GetModuleHandleA("ntdll"), "TpPostWork");
FARPROC pTpReleaseWork = GetProcAddress(GetModuleHandleA("ntdll"), "TpReleaseWork");
PTP_WORK WorkReturn = NULL;
((TPALLOCWORK)pTpAllocWork)(&WorkReturn, (PTP_WORK_CALLBACK)WorkCallback, &ntAllocateVirtualMemoryArgs, NULL);
((TPPOSTWORK)pTpPostWork)(WorkReturn);
((TPRELEASEWORK)pTpReleaseWork)(WorkReturn);
WaitForSingleObject((HANDLE)-1, 0x1000);
printf("allocatedAddress: %p\n", allocatedAddress);
getchar();
return 0;
}
重點是我們彙編傳遞引數的部分引數,呼叫回撥函式 WorkCallback 時,我們的堆疊頂部是 TppWorkpExecuteCallback 的返回值。
如果在的堆疊頂部修改返回地址,並向其新增引數,則整個堆疊幀將發生混亂,從而導致 WorkCallback 函式無法正常返回。因此,我們必須在不更改堆疊幀本身的情況下修改堆疊。因此我們只能直接修改堆疊的值,TppWorkpExecuteCallback 的堆疊是可以容下我們引數所需要的棧的,下面是作者給的彙編程式碼:
section .text
global WorkCallback
WorkCallback:
mov rbx, rdx ; backing up the struct as we are going to stomp rdx
mov rax, [rbx] ; NtAllocateVirtualMemory
mov rcx, [rbx + 0x8] ; HANDLE ProcessHandle
mov rdx, [rbx + 0x10] ; PVOID *BaseAddress
xor r8, r8 ; ULONG_PTR ZeroBits
mov r9, [rbx + 0x18] ; PSIZE_T RegionSize
mov r10, [rbx + 0x20] ; ULONG Protect
mov [rsp+0x30], r10 ; stack pointer for 6th arg
mov r10, 0x3000 ; ULONG AllocationType
mov [rsp+0x28], r10 ; stack pointer for 5th arg
jmp rax
堆疊也是非常乾淨
總結
當然還有其他的利用方式,這裡也不再一一列舉,我們還需要思考的問題是除了TpAllocWork TpPostWork TpReleaseWork這一組 api,還有沒有其他的 api 可以利用,這裡推薦一個專案:
https://github.com/fin3ss3g0d/IoDllProxyLoad
另外這種方式可不可以和 syscall 結合到一起,推薦專案:
https://github.com/pard0p/CallstackSpoofingPOC/tree/main