如何利用.NET實現Gargoyle
一、前言
Gargoyle是一種記憶體掃描規避技術,由 ofollow,noindex" target="_blank">Josh Lospinoso 在2017年以PoC形式公開。這種技術的主要思路是保證注入的程式碼大多數時間處於不可執行狀態,使記憶體掃描技術難以識別注入程式碼。這是一種非常優秀的技術,之前MWR研究人員已經討論過Gargoyle與 Cobalt Strike整合方面的 內容 。我們也介紹瞭如何使用WinDBG和我們提供的 Vola外掛 來檢測這種技術。
現在的攻擊趨勢已經逐步從PowerShell轉移到.NET技術,在這種大背景下,我們也花了些時間研究如何 檢測 .NET技術的惡意使用及動態使用場景,此時我突然想到,也許我們可以將.NET技術應用於Gargoyle。我在 Bluehatv18 上介紹過記憶體後門駐留技術,其中簡要提到了這方面內容。在本文中,我們會詳細介紹相關內容,也會給出一些檢測策略及 PoC程式碼 。
二、動態執行.NET程式碼
現在許多攻擊技術都會盡量避免“落盤”操作,這種傳統技術主要是為了規避典型的反病毒掃描機制,避免在磁碟上留下證據,這也促進了記憶體取證技術的發展。對於native code(原生程式碼),攻擊者通常使用DLL反射注入、Process Hollowing、以及其他類似技術達到“非落盤”目標。然而.NET提供了非常簡單的機制,可以通過assembly(程式集)動態載入和反射(reflection)完成相同的任務。
比如,在PowerShell中我們可以將.NET assembly動態載入到記憶體中,然後使用反射技術來例項化某個類並呼叫類中的方法:
對本文來說了解這一點背景知識已經足夠,我們也在檢測.NET惡意技術的Part 1文章中介紹了更多詳細資訊。此外,來自Endgame的Joe Desimone也在一篇精彩的 文章 中提到了這方面內容,文中展示瞭如何使用他開發的 Get-ClrReflection.ps1 這個PowerShell指令碼來檢測記憶體中已載入的.NET assembly。
三、.NET計時器及回撥
原始的Gargoyle技術用到了Windows原生的計時器(timer)物件,以便定期執行回撥函式。當計時器到期時,核心會將APC(Asynchronous Procedure Call,非同步過程呼叫)投遞至目標程序,執行回撥函式。.NET中在計時器方面有自己的實現,具備類似功能。
從 Timer()
類的建構函式中可知,我們可以指定.NET回撥方法,也能指定計時器超時時所使用的引數。這裡有個限制條件,回撥函式必須遵循 TimerCallback
委託(delegate),如下圖所示:
由於存在委託規範,我們只能呼叫滿足這種條件的方法。在實際環境中,我們可能希望能夠將回調函式指定為 Assembly.Load()
,並且使用assembly的位元組陣列作為 state
引數,確保能夠執行我們的惡意程式碼。遺憾的是這並不符合委託規範,並且載入assembly時自動執行程式碼也不像將程式碼放到原生DLL的 DllMain()
函式中那麼簡單。因此,我們需要構造一個簡單的封裝介面,以便在記憶體中載入和執行我們的惡意assembly。
四、Loader實現
既然找到了.NET計時器,在定義回撥方面我們需要考慮幾個要點:
1、我們需要實現一些自定義載入程式碼,這些程式碼需要被永久載入。因此,我們需要讓程式碼量儘可能小,且看上去無害,實現隱蔽載入;
2、我們無法單獨解除安裝assembly(參考 此處 說明),因此為了自刪除惡意assembly後門,我們需要將其載入新的 AppDomain
中,然後再執行解除安裝操作;
3、我們需要找到一些方法先載入我們的.NET loader assembly,然後呼叫方法建立.NET計時器,以便後面載入我們的惡意assembly後門。
在下文中,我們使用 AssemblyLoader
作為程式碼量儘可能小的一個loader(載入器)assembly,使用 DemoAssembly
作為PoC assembly,代表實際環境中可能用到的一個全功能版惡意後門。
能夠實現要點1和要點2的C#程式碼如下所示:
public class AssemblyLoaderProxy : MarshalByRefObject, IAssemblyLoader { public void Load(byte[] bytes) { var assembly = AppDomain.CurrentDomain.Load(bytes); var type = assembly.GetType("DemoAssembly.DemoClass"); var method = type.GetMethod("HelloWorld"); var instance = Activator.CreateInstance(type, null); Console.WriteLine("--- Executed from {0}: {1}", AppDomain.CurrentDomain.FriendlyName, method.Invoke(instance, null)); } } public static int StartTimer(string gargoyleDllContentsInBase64) { Console.WriteLine("Start timer function called"); byte[] dllByteArray = Convert.FromBase64String(gargoyleDllContentsInBase64); Timer t = new Timer(new TimerCallback(TimerProcAssemblyLoad), dllByteArray, 0, 0); return 0; } private static void TimerProcAssemblyLoad(object state) { AppDomain.CurrentDomain.AssemblyResolve += new ResolveEventHandler(CurrentDomain_AssemblyResolve); Console.WriteLine("Hello from timer!"); String appDomainName = "TemporaryApplicationDomain"; AppDomain applicationDomain = System.AppDomain.CreateDomain(appDomainName); var assmblyLoaderType = typeof(AssemblyLoaderProxy); var assemblyLoader = (IAssemblyLoader)applicationDomain.CreateInstanceFromAndUnwrap(assmblyLoaderType.Assembly.Location, assmblyLoaderType.FullName); assemblyLoader.Load((byte[])state); Console.WriteLine("Dynamic assembly has been loaded in new AppDomain " + appDomainName); AppDomain.Unload(applicationDomain); Console.WriteLine("New AppDomain has been unloaded"); Timer t = new Timer(new TimerCallback(TimerProcAssemblyLoad), state, 1000, 0); }
以上程式碼量較少,包含一些除錯資訊,看上去惡意程度不是特別高,可能會通過安全檢查。如果這段程式碼只是程式碼量龐大且無害的assembly中的一部分,那麼更容易能夠通過安全檢查。
為了定義回撥函式、載入我們的惡意assembly(即 DemoAssembly
),我們可以執行如下操作:
1、在新的 AppDomain
中通過 byte[]
陣列載入 DemoAssembly
;
2、例項化 DemoClass
物件;
3、執行 Helloworld()
方法;
4、解除安裝 AppDomain
,清理記憶體中的 DemoAssembly
;
5、重新排程計時器,一直重複上述過程。
五、執行Assembly Loader
為了滿足第三點要求,我們可以利用原生程式碼中的COM技術來載入我們的Loader assembly,然後呼叫 StartTimer()
方法,設定.NET計時器,然後通過計時器週期性載入我們的“惡意” DemoAssembly
。關鍵程式碼片段如下所示:
// execute managed assembly DWORD pReturnValue; hr = pClrRuntimeHost->ExecuteInDefaultAppDomain( L"AssemblyLoader.dll", L"AssemblyLoader", L"StartTimer", lpcwstr_base64_contents, &pReturnValue);
這樣我們就有各種辦法能夠觸發loader。我們可以執行原生應用,然後執行這段程式碼,或者我們可以將其以原生DLL的方式注入合法的應用中,然後立即解除安裝DLL。最終結果就是我們實現了一個.NET loader assembly,載入後看上去非常無害,但可以通過.NET計時器,在未來定期將完整功能版的.NET後門載入記憶體中。
結合以上方法,最終我們的結果如下所示:
如果我們使用類似 ProcessHacker
之類的工具檢查已載入的.NET assembly,我們只能看到loader assembly,沒有看到臨時的 AppDomain
或者“惡意的” DemoAssembly
:
此外,如果我們使用 Get-ClrReflection.ps1
之類的工具,並不會看到任何結果,這是因為我們“無害的” assembly loader載入自本地磁碟,並且在執行檢測工具時,我們的“惡意” DemoAssembly
很有可能不會剛好被載入到記憶體中。
六、檢測策略
與原始的 Gargoyle
技術一樣,這種策略採用了規避記憶體掃描時間點和常見記憶體取證技術的思路。這意味著我們可以採用實時跟蹤方案來檢測隱藏在眼皮底下的.NET活動。在關於這方面內容的先前文章中,我們討論瞭如何跟蹤.NET assembly的載入行為,如何跟蹤assembly相關活動。比如,如果我們使用之前文章中用來跟蹤高危模組載入行為的PoC ETW跟蹤工具,我們就可以清晰地看到我們的“惡意” DemoAssembly
的週期性載入行為:
然而,這裡還有一個問題,我們是否還有其他備選的、基於載入時間點的記憶體掃描方法,可以用來檢測這種技術?有趣的是這種.NET行為的確會留下一些蛛絲馬跡,我們可以使用WinDBG的SOS除錯擴充套件來觀察這些痕跡。 !DumpDomain
命令的部分輸出資訊如下所示,從中我們可以看到關於名為 TemporaryApplicationDomain
的許多 AppDomain
,指向正在動態載入的模組:
然而,下一個問題是我們是否有可能在系統範圍內分析.NET計時器,識別出潛在的可疑回撥函式,這樣能更直接地檢測到這類技術的使用行為。微軟的確為.NET提供了一個記憶體診斷庫: ClrMD ,我曾經用過這個庫,也是我曾經的首選庫。
Criteo Labs曾發表過一篇 文章 ,介紹瞭如何使用 ClrMD
來完成這個任務,也公佈了相關程式碼。稍微研究並添加了部分程式碼以適配我們的使用場景後,我們也能構造出一個檢測工具,能夠檢測基於計時器回撥的技術,識別潛在的可疑物件。
如果我們在執行Visual Studio的某個系統上執行這款工具,可以看到Visual Studio相關程序中存在大量計時器回撥操作:
然而,這些呼叫非常相似,並且都位於 System
或者 Microsoft
名稱空間中。雖然在這些名稱空間中的確有可能找到惡意使用場景,但基於之前我們提到的限制因素,我們會傾向於使用自定義程式碼來完成攻擊任務。因此我們可以在其他名稱空間中尋找回調行為,此時我們的輸出結果只留下一個條目,也就是我們的惡意回撥:
當然,攻擊者可能會注意到這一點,儘可能偽裝成合法回撥函式。然而如果大規模應用檢測技術,在整個網路環境中進行檢測,在 System
以及 Microsoft
名稱空間外尋找異常例項,那麼還是能夠找到蛛絲馬跡。
七、總結
在本文中我們研究了與Gargoyle記憶體掃描規避技術等效的一種.NET實現方案,通過規避掃描時間點將.NET後門隱藏在記憶體中,也討論了相應的檢測策略。我們已經把所有相關程式碼上傳至 Github 上。