初探堆疊欺騙之靜態欺騙

fdx_xdf發表於2024-06-06

本文首發先知社群:https://xz.aliyun.com/t/14487
首先介紹一下堆疊欺騙的場景,當我們用一個基本的 shellcode loader 載入 cs 的 shellcode,在沒有對堆疊做任何事情時,我們的堆疊是不乾淨的,我們去看一下堆疊時會發現有很多沒有被解析的地址在其中,這顯然是不正常的,因此 av/edr 會重點掃描這部分記憶體區域,就可能會導致我們的 loader gg。
image.png
或者說當我們直接系統呼叫時,和正常程式也是有區別的,如下:

  • 正常程式:主程式模組->kernel32.dll->ntdll.dll->syscall,這樣當0環執行結束返回3環的時候,這個返回地址應該是在ntdll所在的地址範圍之內
  • 直接進行系統呼叫:此時當ring0返回的時候,rip將會是你的主程式模組內,而並不是在ntdll所在的範圍內。

因此我們需要堆疊欺騙來幫我們隱藏堆疊。
我們先需要 32 位/64 位下堆疊的知識,推薦閱讀:https://cloud.tencent.com/developer/article/2149944
https://pyroxenites.github.io/post/diao-yong-zhan-qi-pian/
https://mp.weixin.qq.com/s/_Cr6Ds0vaeGF7DShlq_XJg
https://codemachine.com/articles/x64_deep_dive.html
我們也來簡單的說一下,在 32 位下,是透過rbp 來指向堆疊的開始位置,並且每次移動 rbp 時會 push rbp,然後再 mov rbp,rsp,因此我們只需要不斷回溯 rbp 就可以回溯完整個堆疊。
在 64 位下,ebp 不再有這樣的功能,它現在是一個通用暫存器,下面上兩張圖簡單解釋一下吧,這篇文章涉及到的技術為被動欺騙,不需要很深的理解也能看懂大部分。
x64 PE 檔案中存在一個名為 .pdata的區段,區別於x32其屬於x64獨有區段,值的注意的是.pdata的RVA和異常目錄表的RVA是相同。pdata中的資料由 多個 _IMAGE_RUNTIME_FUNCTION_ENTRY 結構體組成,具體的宣告如下:

typedef struct _IMAGE_RUNTIME_FUNCTION_ENTRY {
  DWORD BeginAddress;
  DWORD EndAddress;
  union {
    DWORD UnwindInfoAddress;
    DWORD UnwindData;
  } DUMMYUNIONNAME;
} RUNTIME_FUNCTION, *PRUNTIME_FUNCTION, _IMAGE_RUNTIME_FUNCTION_ENTRY, *_PIMAGE_RUNTIME_FUNCTION_ENTRY;

從每個欄位型別位DWORD可以看出,其表示的都是RVA,所以在使用時都需要加上模組基地址,BeginAddress代表函式的起始地址RVA,EndAddress代表函式的結束地址RVA,UnwindInfoAddress指向 _UNWIND_INFO結構體,其宣告如下:

typedef struct _UNWIND_INFO {
    UBYTE Version       : 3;
    UBYTE Flags         : 5;
    UBYTE SizeOfProlog;
    UBYTE CountOfCodes;
    UBYTE FrameRegister : 4;
    UBYTE FrameOffset   : 4;
    UNWIND_CODE UnwindCode[1];
/*  UNWIND_CODE MoreUnwindCode[((CountOfCodes + 1) & ~1) - 1];
*   union {
*       OPTIONAL ULONG ExceptionHandler;
*       OPTIONAL ULONG FunctionEntry;
*   };
*   OPTIONAL ULONG ExceptionData[]; */
} UNWIND_INFO, *PUNWIND_INFO;

Version預設為1,Flags總共包含四個值,UNW_FLAG_NHANDLER,UNW_FLAG_EHANDLER ,UNW_FLAG_UHANDLER,UNW_FLAG_CHAININFO,SizeOfProlog表示序言大小(位元組),CountOfCodes代表序言操作中所有指令總共佔用的”槽“數量,FrameRegister用到的幀暫存器,FrameOffset幀暫存器距離棧頂的偏移。
UnwindCode表示的是 _UNWIND_CODE聯合體,大小為兩個位元組,其宣告如下:

typedef union _UNWIND_CODE {
    struct {
        UBYTE CodeOffset;
        UBYTE UnwindOp : 4;
        UBYTE OpInfo   : 4;
    };
    USHORT FrameOffset;
} UNWIND_CODE, *PUNWIND_CODE;

CodeOffset緊跟序言的程式碼起始偏移,UnwindOp操作碼,Opinfo對應操作碼的附加操作資訊。
然後就根據UnwindOp 對應不同操作碼對棧的影響,即可計算某個函式的棧幀大小了。
下面上兩張圖幫大家理解一下:
image.png
image.png

我們在這篇文章中先介紹被動欺騙,或者說是靜態欺騙,它是關於 sleep 的欺騙,或者說是睡眠時間混淆,並不能說是真正意義的堆疊欺騙,但是對於 beacon 來說也是有一定意義的,而主動欺騙,支援任何函式的堆疊欺騙,將在下一篇文章進行介紹。下面我們一起來看幾個專案。

threadStackSpoofer

第一個方式的專案地址在https://github.com/mgeeky/ThreadStackSpoofer
首先是處理引數和讀取 shellcode 的部分,我們不關心。
image.png
然後又呼叫了 hookSleep 函式,我們跟進去
image.png
在 hookSleep 函式里面,他先準備了一個結構體,結構體裡面包含了要 hook 的欄位以及將 hook 的函式改寫到哪裡的欄位,然後將 sleep,自實現的 MySleep,buffers 一併傳給 fastTrampoline 函式。
image.png
image.png
在接下來構造了一個 trampoline 用於跳轉
image.png
除錯一個,可以看到 addr 的地址其實就是我們自實現的 MySleep 裡面
image.png
然後儲存一下原始的 addressToHook 位元組,再將我們 trampoline 重寫到 addressToHook 的位置,這樣呼叫 Sleep 的時候其實會跳轉到我們自實現的 MySleep 裡面。
image.png
然後這部分程式碼相當於對當前程序重新整理一下快取,使得我們修改生效
image.png
然後就是注入 shellcode 的過程,然後當我們的 beacon sleep 時,就會呼叫到我們的 MySleep 函式,我們接下來再看看 MySleep 是如何處理我們的堆疊的。
image.png
_AddressOfReturnAddress 是編譯器提供的一個函式,作用是返回當前函式返回地址的記憶體地址,給到 overwrite
image.png
然後關鍵就來了,我們將overwrite 直接改寫為 0,這樣停止繼續回溯棧,然後我們就可以隱藏剩餘的棧幀,即我們的 shellcode 棧幀就會被隱藏,當 sleep 結束之後再將棧幀改寫回去。
image.png
這是呼叫堆疊未被欺騙時的樣子:
image.png
當啟用執行緒堆疊欺騙時:
image.png
此時幀棧展開到我們的 MySleep 函式,往後 shellcode 的幀棧就被隱藏了,當然我們還可以做更多有趣的事情,比如在 sleep 期間更改 shellcode 記憶體屬性,對 shellcode 記憶體區域進行加密,或者解除我們對 etw/amsi 的 hook,在 sleep 之後再重新 hook,或者等等等等可以由大家自由發揮。
但是這裡還是會有一些問題的,我們將呼叫堆疊設為不可展開,這意味著它看起來異常,因為系統將無法正確遍歷整個呼叫堆疊幀鏈。當一個專業的惡意軟體分析師在分析時自然會發覺異常,但是那些記憶體掃描工具就不一定了,它總不能遍歷每個執行緒的堆疊來驗證其是否不可展開。

CallStackMasker

這個專案的地址在https://github.com/Cobalt-Strike/CallStackMasker ,cs 官方也寫了部落格來介紹這個技術https://www.cobaltstrike.com/blog/behind-the-mask-spoofing-call-stacks-dynamically-with-timers
這個專案是計時器欺騙呼叫堆疊的 PoC ,在 beacon 休眠之前,我們可以對計時器進行排隊,用假的呼叫堆疊覆蓋其呼叫堆疊,然後在恢復執行之前恢復原始呼叫堆疊。因此,就像我們可以在睡眠期間欺騙屬於我們的植入物的記憶體一樣,我們也可以欺騙主執行緒的呼叫堆疊。這種方式是比較簡單的複製堆疊,避免了主動堆疊欺騙的複雜性。
如果我們考慮一個正在執行任何型別等待的通用執行緒(waitforsingleobject),它在等待滿足之前無法修改自己的堆疊。此外,它的堆疊始終是可讀寫的。因此,我們可以使用定時器來:

  1. 建立當前執行緒堆疊的備份
  2. 用假執行緒堆疊覆蓋它
  3. 在恢復執行之前恢復原始執行緒堆疊

這就是這個技術的核心,PoC 以兩種模式執行:靜態和動態。靜態模式模仿 spoolsv.exe 硬編碼呼叫堆疊。該執行緒如下所示,透過 KERNELBASE!WaitForSingleObjectEx 可以看到處於‘Wait:UserRequest’ 狀態:
image.png
我們的執行緒的起始地址和呼叫堆疊與上面 spoolsv.exe 中標識的執行緒相同:
image.png
靜態模式的明顯缺點是我們仍然依賴硬編碼的呼叫堆疊。為了解決這個問題,PoC 還實現了動態呼叫堆疊欺騙。在此模式下,它將列舉主機上所有可訪問的執行緒,並找到一個處於所需目標狀態的執行緒(即透過 WaitForSingleObjectEx 的 UserRequest)。一旦找到合適的執行緒堆疊,它將複製它並使用它來休眠執行緒的克隆。同樣,PoC 將再次複製克隆執行緒的起始地址,以確保我們的執行緒看起來合法。
好的,接下來讓我們看看程式碼:
關於堆疊計算大小等等程式碼我們先略過,這並不會影響我們理解這項技術,並且解釋起來顯得太囉嗦。
我們看關鍵地方,這裡建立一個新的執行緒,並且將 rip 指標指向 go 函式,也就是說要執行我們的 go 函式。
image.png
image.png
我們來看MaskCallStack,先是初始化上下文和控制代碼,方便後續操作,然後獲取 NtContinue 函式地址:透過 GetProcAddress 函式獲取 Ntdll 模組中的 NtContinue 函式的地址。這個函式通常用於繼續執行執行緒
image.png
設定定時器,建立定時器,並設定回撥函式,以執行一系列操作:備份堆疊、覆蓋堆疊、恢復堆疊和設定事件,
當等待事件物件被定時器觸發,此時呼叫堆疊將被遮蔽,然後定時器結束之後又觸發事件,堆疊又恢復。
image.png
image.png
image.png

纖程

纖程是一種使用者級執行緒,它允許在一個執行緒內部進行上下文切換。纖程的切換完全由程式控制,不需要核心的參與,因此效率非常高。纖程的上下文包括暫存器狀態和堆疊,當切換纖程時,當前纖程的上下文會被儲存,然後載入新纖程的上下文。這意味著,透過纖程切換,可以改變當前執行緒的堆疊。
一個執行緒可以建立多個纖程,並透過呼叫 SwitchToFiber 函式根據需要在它們之間切換。在此之前,當前執行緒本身必須透過呼叫 ConvertThreadToFiber 成為纖程,因為只有一個纖程可以建立其他纖程。
所以當我們進行 sleep 時可以切換到新的纖程裡面進行 sleep,從而隱藏我們 shellcode 堆疊,當呼叫返回時,它將再次切換到 shellcode 的纖程,以便可以繼續執行。
重要的 api 使用如下:

// 建立纖程
LPVOID lpFiber = CreateFiber(0, FiberFunc, NULL);
// 將當前執行緒轉換為纖程
ConvertThreadToFiber(NULL);
// 切換到新建立的纖程
SwitchToFiber(lpFiber);

專案參考:https://github.com/Kudaes/Fiber
程式碼實現的話第一個專案改改就可以實現,先 hook sleep 函式,然後呼叫 sleep 函式的時候就可以將上下文轉換到一個新的纖程中,然後 sleep 結束之後,再轉回 shellcode 執行的纖程中即可,這裡不再分析程式碼。
但是當我們在執行 shellcode 相關功能時如果被檢測到了會直接 gg。

  • LoundSunRun 間接系統呼叫 呼叫堆疊合成幀 https://cn-sec.com/archives/2149720.html

0x00000009b0f6f258 {140730941243392}
00000009B0F6F3F0
image.png
image.png

rbp
rsp
push rbp 儲存棧,方便回溯
return to x 返回地址 rip

  • RBP 將指向此功能的堆疊幀的開始地方。
  • RBP 將包含前一個堆疊幀的起始地址。
  • (RBP + 0x8)將指向堆疊跟蹤中前一個函式的返回地址。
  • (RBP + 0x10)將指向第 7 個引數(如果有)。
  • (RBP + 0x18)將指向第 8 個引數(如果有)。
  • (RBP + 0x20)將指向第 9 個引數(如果有)。
  • (RBP + 0x28)將指向第十個引數(如果有)。
  • RBP-X,其中 X 是 0x8 的倍數,將引用該函式的區域性變數。
  • 程式使用 RBP 的偏移量來訪問區域性變數或函式引數。之所以能這樣是因為 RBP 在函式序言中的函式開始處被設定為 RSP 暫存器的值。

RBP 不再用作幀指標。它現在是一個通用暫存器,就像任何其他暫存器(如 RBX、RCX 等)一樣。偵錯程式不能再使用 RBP 暫存器來遍歷呼叫堆疊。
ebp:push ebp 所以可以用 ebp 遍歷棧幀
編譯器最佳化可能會減少對RBP的依賴,甚至在某些情況下完全不用它,尤其是在最佳化較小函式時。

相關文章