三探堆疊欺騙之Custom Call Stacks

fdx_xdf發表於2024-06-08

本文首發阿里雲先知社群: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 來幫我們解釋一下程式碼:

  1. TpAllocWork(&WorkReturn, LoadLibraryA, libName, NULL);:這行程式碼使用了TpAllocWork函式來分配一個工作項,將LoadLibraryA函式作為回撥函式,以非同步的方式載入指定名稱的動態連結庫。LoadLibraryA是用於載入ANSI字串(即CHAR型別)的動態連結庫函式。
  2. TpPostWork(WorkReturn);:這行程式碼將分配的工作項提交給執行緒池,請求執行緒池中的工作執行緒執行載入庫的任務。
  3. 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 中檢查此項會導致下圖:
image.png
還記得上篇文章中關於 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 過去,這就是我們所完成的事情。
現在看一下我們的堆疊,十分完美:
image.png

多引數呼叫

現在我們要考慮一些其他的問題了,比如引數個數,如果引數個數超過 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

相關文章