Donut:從記憶體中載入.NET程式集
0x00 前言
我們可以在執行微軟Windows系統的大多數裝置上看到.NET Framework的身影,.NET在針對Windows裝置的攻擊(紅隊)以及防禦(藍隊)場景中也深受大家歡迎。2015年,微軟將 AMSI(Antimalware Scan Interface) 與執行指令碼(VBScript、JScript、PowerShell)的各種Windows元件整合在一起。大約在同一時間,PowerShell中也添加了增強型日誌記錄或者 Script Block Logging 功能,用來捕捉執行指令碼的的所有內容,從而解決攻擊者使用的任何混淆技術。為了能在紅藍對抗中佔據上風,紅隊必須直接使用程式集(assembly),進一步深入.Net Framework。程式集通常採用C#語言開發,可以為藍隊提供PowerShell支援的所有功能,並且還具備記憶體載入和執行的獨特優勢。在本文中,我將向大家簡單介紹Donut這款工具,當我們提供一個.NET程式集、類名、方法以及其他可選引數時, Donut 將生成一段位置無關程式碼(PIC)或者shellcode,可以從記憶體中載入.NET程式集。我和 TheWover 共同合作開發了這款工具,此外TheWover也寫了介紹donut的一篇 文章 ,歡迎大家參考。
0x01 CLR託管介面
CLR(Common Language Runtime)是一個虛擬機器元件,微軟從v1.0版Framework(2002年釋出)就開始提供 ICorRuntimeHost 介面,用來託管.NET程式集。該介面在2006年釋出的v2.0版Framework中被 ICLRRuntimeHost 所替代,而後者又在2009年釋出的v4.0版Framew中被 ICLRMetaHost 替代。雖然已被棄用,但 ICorRuntimeHost
目前仍是從記憶體中載入程式集的最簡單方法。我們可以使用多種方法來例項化該介面,最常用的有如下幾種方法:
- CoInitializeEx 以及 CoCreateInstance
- CorBindToRuntime 或者 CorBindToRuntimeEx
- CLRCreateInstance 以及 ICLRRuntimeInfo
CorBindToRuntime
以及 CorBindToRuntimeEx
執行的是同樣的操作,但 CorBindToRuntimeEx
函式可以讓我們指定CLR的具體行為。使用 CLRCreateInstance
時我們不必初始化COM(Component Object Model),但v4.0版之前的Framework並沒有實現該函式。如下C++程式碼可以從記憶體中載入.NET程式集:
#include <windows.h> #include <oleauto.h> #include <mscoree.h> #include <comdef.h> #include <cstdio> #include <cstdint> #include <cstring> #include <cstdlib> #include <sys/stat.h> #import "mscorlib.tlb" raw_interfaces_only void rundotnet(void *code, size_t len) { HRESULThr; ICorRuntimeHost*icrh; IUnknownPtriu; mscorlib::_AppDomainPtrad; mscorlib::_AssemblyPtras; mscorlib::_MethodInfoPtr mi; VARIANTv1, v2; SAFEARRAY*sa; SAFEARRAYBOUNDsab; printf("CoCreateInstance(ICorRuntimeHost).n"); hr = CoInitializeEx(NULL, COINIT_MULTITHREADED); hr = CoCreateInstance( CLSID_CorRuntimeHost, NULL, CLSCTX_ALL, IID_ICorRuntimeHost, (LPVOID*)&icrh); if(FAILED(hr)) return; printf("ICorRuntimeHost::Start()n"); hr = icrh->Start(); if(SUCCEEDED(hr)) { printf("ICorRuntimeHost::GetDefaultDomain()n"); hr = icrh->GetDefaultDomain(&iu); if(SUCCEEDED(hr)) { printf("IUnknown::QueryInterface()n"); hr = iu->QueryInterface(IID_PPV_ARGS(&ad)); if(SUCCEEDED(hr)) { sab.lLbound= 0; sab.cElements = len; printf("SafeArrayCreate()n"); sa = SafeArrayCreate(VT_UI1, 1, &sab); if(sa != NULL) { CopyMemory(sa->pvData, code, len); printf("AppDomain::Load_3()n"); hr = ad->Load_3(sa, &as); if(SUCCEEDED(hr)) { printf("Assembly::get_EntryPoint()n"); hr = as->get_EntryPoint(&mi); if(SUCCEEDED(hr)) { v1.vt= VT_NULL; v1.plVal = NULL; printf("MethodInfo::Invoke_3()n"); hr = mi->Invoke_3(v1, NULL, &v2); mi->Release(); } as->Release(); } SafeArrayDestroy(sa); } ad->Release(); } iu->Release(); } icrh->Stop(); } icrh->Release(); } int main(int argc, char *argv[]) { void *mem; struct stat fs; FILE *fd; if(argc != 2) { printf("usage: rundotnet <.NET assembly>n"); return 0; } // 1. get the size of file stat(argv[1], &fs); if(fs.st_size == 0) { printf("file is empty.n"); return 0; } // 2. try open assembly fd = fopen(argv[1], "rb"); if(fd == NULL) { printf("unable to open "%s".n", argv[1]); return 0; } // 3. allocate memory mem = malloc(fs.st_size); if(mem != NULL) { // 4. read file into memory fread(mem, 1, fs.st_size, fd); // 5. run the program from memory rundotnet(mem, fs.st_size); // 6. free memory free(mem); } // 7. close assembly fclose(fd); return 0; }
如下是C#版的“Hello, World!”程式,當使用 csc.exe
編譯後能生成一個.NET程式集,可以用來測試載入器。
// A Hello World! program in C#. using System; namespace HelloWorld { class Hello { static void Main() { Console.WriteLine("Hello World!"); } } }
編譯並執行這些程式碼後,我們可以得到如下輸出:
這是執行.NET程式集的基本方式,其中並沒有考慮到Framework的具體版本。shellcode的實現有點不一樣,會解析 CorBindToRuntime
以及 CLRCreateInstance
的地址(這與 subTee 開發的 AssemblyLoader 類似)。如果成功解析 CLRCreateInstance
,並且呼叫後返回 E_NOTIMPL
或者“Not implemented”,我們就會執行 CorBindToRuntime
(其中 pwszVersion
引數設定為NULL),請求可用的最新版本。如果我們使用 CorBindToRuntime
請求系統當前不支援的某個版本,那麼執行shellcode的託管程序可能會彈出錯誤訊息。比如,當Windows 7系統只支援v3.5.30729.5420版時,如果我們請求v4.0.30319,就會看到如下錯誤資訊:
大家可能有疑問,為什麼之前使用的OLE函式沒有在shellcode中使用。除了OLE32之外,OLE函式有時候會在其他DLL中引用,比如COMBASE。xGetProcAddress可以處理轉發引用,但至少目前為止,shellcode使用的是 CorBindToRuntime
以及 CLRCreateInstance
。在新版框架中,我們還可以使用 CoCreateInstance
。
0x02 定義.NET型別
在非託管(unmanaged)C++程式中,我們可以使用 #import
指令來訪問型別(Types)。前文程式碼使用的是在 mscorlib.tlb
中定義的 _AppDomain
、 _Assembly
以及 _MethodInfo
介面。然而問題在於,在公開版的Windows SDK中並沒有定義這些介面。為了在較低階語言(如組合語言或者C)中使用.NET型別,我們首先得手動定義這些介面。我們可以使用 LoadTypeLib API來列舉型別資訊,該函式會返回指向 ITypeLib 介面的一個指標。該介面可以提取相關資訊,比如庫介面、方法以及變數。我發現 Olewoo 這款工具可以用來檢視 mscorlib.tlb
資訊。如果我們忽略面向物件程式設計(OOP)方面的相關資訊,比如類、物件、繼承、封裝、抽象、多型……等,我們可以從底層來分析介面,畢竟介面只是指向某種資料結構的一個指標,而該資料結構包含指向函式/方法的指標而已。除了 phplib 中的一個檔案之外(該檔案定義了 _AppDomain
介面),我無法在網上找到所需介面的定義。根據找到的示例,我構造了載入程式集所需的其他介面。如下即為 _AppDomain
介面中的某個方法:
HRESULT (STDMETHODCALLTYPE *InvokeMember_3)( IType*This, BSTRname, BindingFlags invokeAttr, IBinder*Binder, VARIANTTarget, SAFEARRAY*args, VARIANT*pRetVal);
雖然shellcode中沒有使用 IBinder
介面的任何方法,我們可以將型別安全地改成 void *
,但為了以後使用方便,我還是定義瞭如下介面。 DUMMY_METHOD
巨集簡單定義了一個函式指標:
typedef struct _Binder IBinder; #undef DUMMY_METHOD #define DUMMY_METHOD(x) HRESULT ( STDMETHODCALLTYPE *dummy_##x )(IBinder *This) typedef struct _BinderVtbl { HRESULT ( STDMETHODCALLTYPE *QueryInterface )( IBinder * This, /* [in] */ REFIID riid, /* [iid_is][out] */ void **ppvObject); ULONG ( STDMETHODCALLTYPE *AddRef )( IBinder * This); ULONG ( STDMETHODCALLTYPE *Release )( IBinder * This); DUMMY_METHOD(GetTypeInfoCount); DUMMY_METHOD(GetTypeInfo); DUMMY_METHOD(GetIDsOfNames); DUMMY_METHOD(Invoke); DUMMY_METHOD(ToString); DUMMY_METHOD(Equals); DUMMY_METHOD(GetHashCode); DUMMY_METHOD(GetType); DUMMY_METHOD(BindToMethod); DUMMY_METHOD(BindToField); DUMMY_METHOD(SelectMethod); DUMMY_METHOD(SelectProperty); DUMMY_METHOD(ChangeType); DUMMY_METHOD(ReorderArgumentArray); } BinderVtbl; typedef struct _Binder { BinderVtbl *lpVtbl; } Binder;
我在 payload.h 中定義了記憶體載入程式集所需的方法。
0x03 Donut例項
我們會將shellcode與某個資料塊例項繫結在一起,這個資料塊可以看成shellcode的“資料段”(data segment),其中包含解析API之前待載入的DLL名、API字串對應的64位雜湊、記憶體載入.NET程式集的相關COM GUID,如果例項和模組儲存在staging伺服器上,那麼資料段也可以包含例項對應的解密祕鑰。許多使用C語言編寫的shellcode都傾向於在棧上儲存字串,但像 FireEye Labs Obfuscated String Solver 之類的工具可以輕易恢復這些資訊,幫助我們更好分析程式碼。當涉及程式碼位置排列時,在獨立的資料塊中儲存字串就能體現出優勢。我們可以在保持功能的同時修改程式碼,並且永遠不需要處理“只讀”的立即值,這些值將使整個過程變得複雜,大大增加程式碼量。在 call
操作碼(opcode)之後以及 pop ecx
/ pop rcx
之前我們使用的結構如下所示。在x86以及x86-64 shellcode中我們使用了 fastcall
約定,使程式碼便於載入指向儲存在 ecx
或 rcx
暫存器中例項的指標。
typedef struct _DONUT_INSTANCE { uint32_tlen;// total size of instance DONUT_CRYPT key;// decrypts instance // everything from here is encrypted intdll_cnt;// the number of DLL to load before resolving API chardll_name[DONUT_MAX_DLL][32];// a list of DLL strings to load uint64_tiv;// the 64-bit initial value for maru hash intapi_cnt;// the 64-bit hashes of API required for instance to work union { uint64_thash[48];// holds up to 48 api hashes void*addr[48];// holds up to 48 api addresses // include prototypes only if header included from payload.h #ifdef PAYLOAD_H struct { // imports from kernel32.dll LoadLibraryA_tLoadLibraryA; GetProcAddress_tGetProcAddress; VirtualAlloc_tVirtualAlloc; VirtualFree_tVirtualFree; // imports from oleaut32.dll SafeArrayCreate_tSafeArrayCreate; SafeArrayCreateVector_tSafeArrayCreateVector; SafeArrayPutElement_tSafeArrayPutElement; SafeArrayDestroy_tSafeArrayDestroy; SysAllocString_tSysAllocString; SysFreeString_tSysFreeString; // imports from wininet.dll InternetCrackUrl_tInternetCrackUrl; InternetOpen_tInternetOpen; InternetConnect_tInternetConnect; InternetSetOption_tInternetSetOption; InternetReadFile_tInternetReadFile; InternetCloseHandle_tInternetCloseHandle; HttpOpenRequest_tHttpOpenRequest; HttpSendRequest_tHttpSendRequest; HttpQueryInfo_tHttpQueryInfo; // imports from mscoree.dll CorBindToRuntime_tCorBindToRuntime; CLRCreateInstance_tCLRCreateInstance; }; #endif } api; // GUID required to load .NET assembly GUID xCLSID_CLRMetaHost; GUID xIID_ICLRMetaHost; GUID xIID_ICLRRuntimeInfo; GUID xCLSID_CorRuntimeHost; GUID xIID_ICorRuntimeHost; GUID xIID_AppDomain; DONUT_INSTANCE_TYPE type;// PIC or URL struct { char url[DONUT_MAX_URL]; char req[16];// just a buffer for "GET" } http; uint8_tsig[DONUT_MAX_NAME];// string to hash uint64_tmac;// to verify decryption ok DONUT_CRYPT mod_key;// used to decrypt module uint64_tmod_len;// total size of module union { PDONUT_MODULE p;// for URL DONUT_MODULEx;// for PIC } module; } DONUT_INSTANCE, *PDONUT_INSTANCE;
0x04 Donut模組
.NET使用模組(Module)這種資料結構來儲存程式集。模組可以與例項(Instance)一起儲存,或者存放在shellcode能夠提取的staging伺服器上。模組中包含程式集、類名、方法以及可選引數。 sig
值包含隨機8位元組字串,當使用 Maru
雜湊函式處理時,會生成64bit值,該值與 mac
值相等。這種方式可以用來驗證模組的解密是否成功。模組祕鑰存放在內嵌於shellcode的例項中。
// everything required for a module goes into the following structure typedef struct _DONUT_MODULE { DWORDtype;// EXE or DLL WCHARruntime[DONUT_MAX_NAME];// runtime version WCHARdomain[DONUT_MAX_NAME];// domain name to use WCHARcls[DONUT_MAX_NAME];// name of class and optional namespace WCHARmethod[DONUT_MAX_NAME];// name of method to invoke DWORDparam_cnt;// number of parameters to method WCHARparam[DONUT_MAX_PARAM][DONUT_MAX_NAME]; // string parameters passed to method CHARsig[DONUT_MAX_NAME];// random string to verify decryption ULONG64 mac;// to verify decryption ok DWORDlen;// size of .NET assembly BYTEdata[4];// .NET assembly file } DONUT_MODULE, *PDONUT_MODULE;
0x05 隨機祕鑰
在Windows上, CryptGenRandom 可以生成密碼學上安全的隨機值,在Linux上,我們可以使用 /dev/urandom
(不使用 /dev/random
,該裝置會阻塞讀取請求)。Thomas Huhn在關於 urandom
的一篇 文章 中提到 /dev/urandom
是Linux上隨機資料流的首選。我們在Donut中使用 CreateRandom 來生成隨機祕鑰,建議大家參考使用。
0x05 隨機字串
除非使用者手動指定,否則我們會使用隨機字串來生成應用程式域(Application Domain)名。如果donut模組存放在staging伺服器上,也會生成隨機名。負責該操作的函式為 GenRandomString ,其中用到了 CreateRandom
生成的隨機位元組,配合“HMN34P67R9TWCXYF”字串生成了最終字串(這個魔術字串來源於stackoverflow上的一篇 帖子 )。
0x06 對稱加密
對合(involution)函式是指自己是自己逆函式的函式,許多工具會使用對合函式來混淆程式碼。如果大家之前逆向分析過惡意軟體,那麼肯定對異或(XOR)函式非常熟悉,這種函式非常簡單,使用場景也非常廣泛。此外, Noekeon 分組加密是一種非線性加密,也是較為複雜的對合方式。Donut並沒有使用對合加密方式,而是使用 Chaskey 分組密碼(Counter(CTR)模式)來加密模組,其中解密祕鑰內嵌在shellcode中。如果Donut模組來自於staging伺服器,那麼想知道其中所包含的具體資訊的唯一方法就是恢復shellcode,尋找 CreateRandom
函式的脆弱點或者打破Chaskey加密演算法。
static void chaskey(void *mk, void *p) { uint32_t i,*w=p,*k=mk; // add 128-bit master key for(i=0;i<4;i++) w[i]^=k[i]; // apply 16 rounds of permutation for(i=0;i<16;i++) { w[0] += w[1], w[1]= ROTR32(w[1], 27) ^ w[0], w[2] += w[3], w[3]= ROTR32(w[3], 24) ^ w[2], w[2] += w[1], w[0]= ROTR32(w[0], 16) + w[3], w[3]= ROTR32(w[3], 19) ^ w[0], w[1]= ROTR32(w[1], 25) ^ w[2], w[2]= ROTR32(w[2], 16); } // add 128-bit master key for(i=0;i<4;i++) w[i]^=k[i]; }
之所以選擇使用Chaskey演算法,是因為該演算法簡潔緊湊,易於實現,並且不包含容易被檢測的常量特徵。Chaskey的主要缺點是使用人數較少,因此並沒有像AES那樣在密碼學上被廣泛分析。當2014釋出Chaskey演算法時,官方推薦的加密輪次為8次。2015年,已經有針對7輪加密的攻擊技術出現,這表明官方推薦的加密輪次並不是一個足夠安全的邊界。針對此攻擊,設計人員提高了加密輪次,建議使用12輪加密,這裡Donut使用的是16輪加密的長期支援(LTS)版本。
0x07 API雜湊
如果在記憶體掃描之前已經掌握API字串雜湊,那麼Donut就非常容易被檢測出來。我們 建議 在Windows API雜湊中使用分組加密方式,增加雜湊過程中的熵(entropy),以便進一步規避針對程式碼的檢測機制。Donut使用的是 Maru
雜湊函式,該函式基於 Speck
分組加密演算法,使用的是Davies-Meyer構建和填充方式,這種方式與MD4及MD5類似。Speck隨機生成了一個64bit初始值(IV),以明文方式使用該值來加密,祕鑰為API字串。
static uint64_t speck(void *mk, uint64_t p) { uint32_t k[4], i, t; union { uint32_t w[2]; uint64_t q; } x; // copy 64-bit plaintext to local buffer x.q = p; // copy 128-bit master key to local buffer for(i=0;i<4;i++) k[i]=((uint32_t*)mk)[i]; for(i=0;i<27;i++) { // donut_encrypt 64-bit plaintext x.w[0] = (ROTR32(x.w[0], 8) + x.w[1]) ^ k[0]; x.w[1] =ROTR32(x.w[1],29) ^ x.w[0]; // create next 32-bit subkey t = k[3]; k[3] = (ROTR32(k[1], 8) + k[0]) ^ i; k[0] =ROTR32(k[0],29) ^ k[3]; k[1] = k[2]; k[2] = t; } // return 64-bit ciphertext return x.q; }
0x08 總結
Donut提供了通過shellcode實現CLR注入的一種方法,紅隊可以基於此建模,從攻擊方和防禦方角度構建分析和緩解的整體框架。這個過程中肯定會有惡意軟體開發者和攻擊人員會濫用這款工具,但我們堅信整體優點依然能彌補帶來的不足(但願如此),大家可以訪問 此處 獲取原始碼。