PInvoke(Platform Invoke,平台调用)是Windows平台上的一种高级机制,允许.NET应用程序(主要是C#、VB.NET等托管代码)直接调用非托管动态链接库中的函数。这些非托管动态链接库如Kernel32.dll(负责内核级功能)、User32.dll(管理用户界面)、GDI32.dll(图形设备接口)等,包含Windows操作系统提供的底层API。
通过PInvoke机制,可以跨越托管代码与非托管代码之间的界限,利用这些丰富的Windows API来增强应用程序的功能和性能。在.NET框架中,大多数与PInvoke相关的API被封装在System和System.Runtime.InteropServices这两个命名空间中。System.Runtime.InteropServices命名空间尤为关键,它提供了DllImport属性等,使得从托管代码直接调用非托管DLL中的函数变得简单。使用时只需在方法声明前加上DllImport属性,并指定包含目标函数的DLL名称,即可实现跨平台的函数调用。
DInvoke(Dynamic Invoke,动态调用)是一种高级且灵活的Windows API调用技术,在攻防对抗领域常用于绕过传统安全机制的限制,允许在内存中动态地执行ShellCode或直接调用Win32 API函数。与传统的PInvoke方式相比,DInvoke采用了截然不同的策略。PInvoke依赖于.NET框架的DllImport属性来静态地声明和调用系统DLL中的API函数,而DInvoke则是一种更为动态和底层的方法。它通过手动将DLL加载到内存中,并直接解析出函数地址(即函数指针),从而实现了对API函数的调用。这种方式不仅增加了调用的灵活性,还能够在一定程度上隐藏调用痕迹,对抗检测机制。
在DInvoke的实现中,经常需要用到Marshal.GetDelegateForFunctionPointer来将获取的函数指针转换为委托,便于在托管代码中方便地调用这些函数。下面这段代码通过使用Marshal.GetDelegateForFunctionPointer方法,动态地调用Windows API(如kernel32.dll中的VirtualAlloc、CreateThread和WaitForSingleObject函数)。
上述代码首先通过DInvokeFunctions.GetLibraryAddress获取kernel32.dll中VirtualAlloc函数的地址。这是为了在托管环境中使用这个非托管函数的内存地址。
接着,使用Marshal.GetDelegateForFunctionPointer将VirtualAlloc函数的地址转换为DELEGATES.VirtualAllocRx类型的委托。这个委托类型应该与VirtualAlloc函数的签名相匹配。然后通过VirtualAllocRx委托调用VirtualAlloc函数,在进程的地址空间中分配一块内存,用于存放即将执行的代码。
最后,使用Marshal.Copy将包含计算器程序机器码的字节数组codepent复制到之前分配的内存中,再通过DInvoke动态调用kernel32.dll中的CreateThread函数来创建一个新线程,将线程的入口点设置为刚刚分配的内存地址,然后启动线程弹出计算器。
SysCall(系统调用)是操作系统内核提供的一组特殊接口,它允许运行在用户态的程序请求内核服务。这些服务包括文件操作(如打开、读取、写入文件)、进程管理(如创建、终止进程)、网络通信、内存管理等。通过系统调用,用户程序能够安全、有效地执行需要操作系统介入的任务,同时保证系统的稳定性和安全性。
在Windows中,进程分为两种处理器模式:用户模式和内核模式。常见的应用软件Chrome和Word等都运行在用户模式下,而Windows系统服务和驱动等都运行在内核模式下。进程处理架构如图19-31所示。
内核模式授予对所有系统内存和所有CPU指令的访问权限,Window系统x86和x64处理器通过使用Ring3~Ring0来区分这些模式。Ring特权模式的处理器定义了4个级别来保护系统代码和数据,这些Ring范围如图19-32所示。
由图19-32可以看到,Windows操作系统采用了Ring架构来划分不同的安全级别和权限层次。具体而言,Ring 0被分配给内核空间,这里运行着操作系统的核心组件,如系统服务、驱动程序等,它们拥有对硬件和系统资源的直接访问权限。相比之下,Ring 3则分配给用户空间,用于运行常规的应用程序,如Chrome、Word等,这些应用通过受限的接口与操作系统交互,无法直接访问底层硬件或执行高风险操作。
处理器在执行程序时,会根据当前运行的代码类型(即是否位于内核空间或用户空间)在Ring 0和Ring 3这两种模式之间灵活切换。这种切换机制确保了系统的安全性和稳定性,通过限制用户模式代码的权限来防止对系统资源的未授权访问。
而实现这一关键切换的核心机制正是SysCall。当运行在Ring 3用户空间的应用程序执行需要高权限的操作(如文件读写、进程管理等)时,它会通过预定义的接口发起系统调用请求。这一请求会触发处理器从用户模式切换到内核模式,进入Ring 0执行相应的内核服务例程。内核服务完成后,处理器再次切换回用户模式,将控制权返回给发起调用的应用程序,并传递操作结果。
图19-31 Windows进程处理架构
图19-32 Ring3~Ring0范围
下面使用Process Monitor这一强大的系统监控工具来观察记事本创建文件的操作,我们可以深入分析调用堆栈(Call Stack)的情况,如图19-33所示。
图19-33 记事本创建文件的操作时调用堆栈
通过分析调用堆栈,可以看到图标为U就是用户模式,图标为K是内核模式。在记事本创建文件的操作中,调用堆栈展示了从用户模式到内核模式的切换过程,特别是在执行CreateFile函数时尤为明显。调用堆栈如下所示。
这一切换始于用户模式下的KernelBase!CreateFileW调用,该函数是Windows API的一个封装,用于提供文件创建功能的标准接口。随后,调用链深入ntdll.dll,这是一个包含大量系统服务函数的动态链接库,它负责执行函数调度和API的导出,是用户模式与内核模式之间的桥梁。
在ntdll.dll内部,CreateFile请求被进一步封装并通过系统调用机制传递给内核。这里,ntoskrnl!NtCreateFile成为内核模式下的执行点,代表内核对CreateFile操作的具体实现。ntoskrnl是Windows操作系统的核心组件之一,包含系统调度的核心功能,管理着处理器在不同模式之间的切换,确保系统服务的正确执行。
1.分析SysCall
下面以Windbg打开记事本程序为例,深入介绍SysCall的使用方法,在WinDbg的主界面中,单击Launch executable选项并打开一个像记事本进程,如图19-34所示。
接着,在WinDbg的命令窗口中输入指令x ntdll!NtCreateFile,这一步骤的目的是让WinDbg查找并列出ntdll.dll模块中NtCreateFile函数的地址信息。执行命令后可看到如图19-35所示的输出内容。
返回的结果是00007ff8`224adaa0,它表示NtCreateFile所在的内存地址,如果要从此处查看反汇编结果,需要继续输入命令u 00007ff8`224adaa0,该条命令告诉WinDbg调试器要反汇编内存开头的指令,运行后的结果如图19-36所示。
图19-34 WinDbg打开记事本进程
图19-35 ntdll.dll模块加载的NtCreateFile函数
图19-36 WinDbg反汇编内存指令
此时,返回的内核NtCreateFile函数对应的系统调用汇编指令序列如下所示。
这段指令下发后CPU会进入内核模式,函数调用从用户模式复制到内核堆栈,执行NtCreate-File的内核版本ZwCreateFile函数,完成后把返回值返回到用户模式,整个系统调用完成。
2.使用SysCall
下面首先创建一个名为bNtCreateFile的字节数组,包含用于执行NtCreateFile系统调用的汇编指令,具体代码如下所示。
上述代码将系统调用号0x55加载到eax寄存器,0x55是NtCreateFile的系统调用号,然后再执行syscall指令切换到内核模式并调用相应的系统服务。
接着,再定义一个Delegates结构,为NtCreateFile委托定义签名,这里需要参考API中对NtCreateFile的参数声明,具体实现代码如下所示。
最后,在不安全的上下文中,获取新字节指针ptr,并将其设置为syscall的值,再通过Marshal.GetDelegateForFunctionPointer函数将指针转化为委托返回执行的结果,具体代码如下所示。
3.注入ShellCode
在Windows操作系统中,为了实现内存注入执行ShellCode,首先需要分配一块具有可读写可执行属性的虚拟内存区域。在某些高级或特殊情况下,会使用系统底层的NtAllocateVirtualMemory函数。具体实现代码如下所示。
以上代码调用Auto_NativeCode.NtAllocateVirtualMemory方法分配内存并使用SysCall创建了委托。接着,通过分配可执行内存、复制ShellCode到该内存,并创建新线程以执行ShellCode,具体代码如下所示。
以上代码调用Auto_NativeCode.NtCreateThreadEx函数启动新进程,成功弹出对话框,如图19-37所示。
图19-37 NtCreateThreadEx函数运行ShellCode
通过使用Process Monitor工具,观察到线程启动状态及其堆栈调用情况,确认是通过直接系统调用(SysCall)实现的,如图19-38所示。
图19-38 Process Monitor监视线程创建
在Process Monitor中查看并分析堆栈调用情况,可以看到系统调用的执行路径,如图19-39所示。
图19-39 SysCall调用栈
PELoader的核心功能在于高效地在内存中加载并执行Portable Executable(PE)文件,这些文件涵盖Windows平台下的可执行文件、动态链接库以及多种其他格式的二进制程序和数据文件。
1.PE文件解析
下面将实现一个简单的PELoader类,该类能够加载PE文件,并读取其DOS头和文件头的基本信息,具体代码如下所示。
以上代码通过BinaryReader从文件流中读取PE文件的头部信息,这些信息由IMAGE_DOS_HEADER和IMAGE_FILE_HEADER这两个结构体来定义,它们分别包含PE文件的DOS头部和文件头部的基本信息,IMAGE_FILE_HEADER包含PE文件的重要元数据,如文件类型、目标机器、节的数量、文件大小、代码和数据的大小等。通过解析这些信息,我们可以对PE文件的结构有一个基本的了解。
2.内存分配
在加载PE文件并解析其头部信息之后,下一步通常是将PE文件的映像加载到内存中。这涉及为PE文件分配足够的内存空间,并确保该内存空间具有适当的访问和执行权限。以下是使用Windows API VirtualAlloc来分配这种内存的示例代码。
上述代码运行后,PELoader会在进程的虚拟内存空间中申请一块内存,其中ImageBase作为内存基地址,SizeOfImage作为内存长度。这个内存区域将用于加载PE文件。
3.PE文件加载
为PE文件分配了内存空间之后,下一步是将PE文件中的各个节(Section)映射到分配的内存中。以下是一个遍历PE文件的节表,并将每个节映射到分配的内存中的过程示例代码。
以上代码的每个节都包含代码、数据或资源等信息,它们需要被正确地放置在内存中的指定位置。
4.重定位处理
在将PE文件的映像加载到内存中之后,如果PE文件的目标基址与实际的加载基址不同,那么就需要进行重定位处理,具体代码如下所示。
PELoader将根据加载的内存地址修改这些表,重定位表中包含必要的信息,以便将文件中的地址修正为新的基址。
5.导入表处理
接着,通过一个for循环获取PE文件可能依赖的其他动态链接库文件,PELoader会解析导入表,加载所需的.dll文件,并在内存中解析导入函数的地址,具体代码如下所示。
在遍历过程中,对于每个列出的DLL名称,PELoader利用LoadLibrary或相应API尝试加载对应的DLL文件,并获取其句柄。随后,对于每个DLL中指定的函数名称,PELoader通过GetProcAddress或相应API查询并获取该函数在DLL中的实际地址。获取到函数地址后,PELoader将这些地址更新到PE文件在内存中的映像中。
6.执行入口点
在完成所有必要的加载和初始化步骤后,PELoader最终的任务是跳转到PE文件的入口点并执行其中的代码。这一步标志着PE文件现在已经在内存中准备好,并开始其执行过程,具体代码如下所示。
首先,PELoader计算入口点的实际内存地址。这通常涉及将PE文件的基地址(codebase)与可选头中指定的入口点偏移量(AddressOfEntryPoint)相加。然后,PELoader使用这个计算出的地址来创建一个新的线程,该线程将作为PE文件执行的上下文。
下面以SharpMimikatz_x64和SafetyKatz为例,它们的代码实现了在内存中加载PE 64位的mimikatz.exe,项目在Constants.cs文件定义了一个变量compressedMimikatzString,用于存储压缩编码后的mimikatz,如图19-40所示。
首先采用MemoryStream类构建内存流,再将Base64编码形式的mimikatz转换为字节码,接着使用DeflateStream类的CompressionMode.Decompress模式读取数据并解压到unpacked数组中,如此可成功还原PE文件,具体代码如下所示。
运行SharpMimikatz_x64.exe后解析了PE头部信息,调用并加载相关的DLL,如图19-41所示。
图19-40 变量compressedMimikatzString存储压缩编码后的mimikatz
图19-41 解析PE头部信息
尝试使用加载后的SharpMimikatz_x64.exe获取Windows系统用户凭据,运行命令sekurlsa::logonpasswords full,成功获取系统所有凭据,如图19-42所示。
当使用Visual Studio编译PELoader时,需注意以下关键配置。在项目的属性页中,进入“生成”选项卡,勾选“允许不安全代码”选项以启用unsafe代码块的使用,同时确保“目标平台”设置为“x64”,以适应64位系统环境和潜在的64位PE文件处理需求,如图19-43所示。
图19-42 获取Windows系统用户凭据
图19-43 勾选“允许不安全代码”选项
在某些特殊场景,需要将基于.NET编译的控制台程序转换为动态链接库文件,供其他程序或组件调用,可以采用第三方的组件DllExport去实现这一目标。以开源工具SafetyKatz为例,由于SafetyKatz本身是一个控制台应用,现在需要转换成.NET程序集,因此需要对控制台应用的Program.Main方法进行修改,改动的代码片段如下所示。
在将Main方法名更改为RunSafetyCatz,并使用[DllExport]特性进行标记之后,下一步是通过NuGet包管理器安装DllExport库,在NuGet包管理器界面中,选择“浏览”选项卡搜索DllExport并安装,最新的版本是1.7.4,如图19-44所示。
图19-44 安装DllExport库
安装完成后,需要手动将packages\DllExport.1.7.4\DllExport.bat文件复制到项目的根目录下,与SafetyKatz.sln文件放在一起。接着,双击运行DllExport.bat或者在当前文件夹打开cmd.exe,输入命令DllExport.bat-action Configure,运行后弹出配置对话框,如图19-45所示。
图19-45 双击运行DllExport.bat
在配置对话框中勾选Installed复选框,并选中当前的项目命名空间名SafetyKatz,编译平台默认是auto选项,这里需改成(86+64),最后单击Apply按钮完成配置,在配置生效的过程中,Visual Studio提示需要重新载入,Visual Studio完成加载后再打开项目属性页,在输出类型处选择“类库”,如图19-46所示。
图19-46 输出类型选择“类库”
单击“重新生成”按钮,在Visual Studio中重新生成项目后,根据项目的配置(如Debug或Release)和目标平台(如x86、x64)的不同,可能会在项目文件夹下的不同子目录中生成多个版本的SafetyKatz.dll文件。然而,对于大多数情况,应该选择x64目录下的SafetyKatz.dll,如图19-47所示。
图19-47 生成x64目录下的SafetyKatz.dll
使用pestudio工具打开位于x64目录下的SafetyKatz.dll文件。在pestudio界面中,选择exports(导出)选项,可以看到文件中所有已导出的函数名和地址,如图19-48所示。
通常情况下,rundll32.exe用于调用没有独立入口点的非托管.dll文件(如用C或C++编写的动态链接库)中的函数。经过上述步骤的处理后,我们可以使用rundll32.exe加载SafetyKatz.dll,打开命令提示符运行命令rundll32.exe SafetyKatz.dll,RunSafetyCatz,返回的结果表示成功运行SafetyKatz,如图19-49所示。
图19-48 导出函数名和地址
图19-49 成功运行SafetyKatz
需要注意的是,当SafetyKatz.dll中的函数被rundll32.exe或其他非交互式宿主程序调用时,默认情况下可能不会显示图中输出的信息。为了能够在这种情况下查看调试信息或输出结果,可以引入Windows API函数AttachConsole尝试附加到一个已存在的窗口中。代码核心片段如下所示。
AOT(Ahead of Time)是微软推出的一种新的编译技术,该技术在程序执行之前就将.NET源代码预先编译成机器码,从而提高了运行时的效率和性能。2022年11月,微软正式发布了.NET 7,这一里程碑式的版本不仅带来了显著的性能提升,还引入了众多创新功能。尤为引人注目的是,长期处于实验阶段的NativeAOT技术终于被纳入.NET 7的主线之中。
NativeAOT的引入标志着.NET应用能够被编译成独立的、非托管的二进制文件。这一变革赋予.NET应用前所未有的便携性和部署灵活性,可以在不具备.NET运行环境的Windows上直接运行这些应用程序,极大地拓宽了.NET应用的适用范围和场景。
为了充分利用这一技术,并在Visual Studio 2022中开发支持NativeAOT的.NET应用,需要在安装Visual Studio时选择相应的工作负载。具体而言,需要勾选“使用C++的桌面开发”和“.NET桌面开发”这两项工作负载。这样做是因为“使用C++的桌面开发”工作负载提供了开发C++应用所需的全部工具集,这对于支持NativeAOT技术的底层实现至关重要;而“.NET桌面开发”工作负载则包含开发.NET控制台应用等桌面应用所需的一切,确保用户能够顺畅地构建和测试基于.NET 7及NativeAOT的应用,如图19-50所示。
图19-50 勾选“使用C++的桌面开发”
接着创建一个.NET 7的控制台项目,右侧的模板处选择“控制台应用”,如图19-51所示。
输入项目名后,单击“下一步”按钮进入“其他信息”版块,在“框架”中选择“.NET 7.0(标准期限支持)”,如图19-52所示。
图19-51 创建控制台应用
图19-52 选择.NET 7.0版本
打开控制台项目后,在Program.Main方法中编写一段实验代码,使用Process.Start方法启动本地计算器进程,如图19-53所示。
在准备进行AOT发布之前,需要对.NET项目的.csproj文件进行相应的配置,确保项目在发布过程中能够执行AOT编译。具体来说,需要在.csproj文件中添加一个<PublishAot>元素,并将其值设置为true,这个设置会指示MSBuild在发布过程中启用AOT编译,如图19-54所示。
在准备进行AOT发布时,首先在Visual Studio的解决方案管理器中右击项目,选择“发布”启动发布向导。选择“文件夹”作为发布目标后,单击“显示所有设置”以展开详细配置选项,如图19-55所示。
图19-53 Main方法创建启动计算器进程
图19-54 配置使用AOT编译
图19-55 打开发布配置项
在Visual Studio的发布配置中,将“目标运行时”选项设置为win-x64,这是为了确保应用针对64位Windows系统进行优化编译。“文件发布选项”通常可以保持默认设置,但重要的是不勾选“生成单个文件”选项,这里的“生成单个文件”选项通常指的是将多个文件打包成单个的可执行文件,这并不适用于此处场景,因为AOT编译已经直接生成了所需的独立二进制文件。完成所有必要的设置后,执行发布过程,Visual Studio将根据配置来编译并打包应用。然后打开Release目录,可看到如图19-56所示的文件列表。
图19-56 生成Release目录下的AOT文件
Sharp4SafetyKatz2AOT.exe是独立的可执行文件,Sharp4SafetyKatz2AOT.pdb是调试符号文件,不是必须的,可以删除。通过AOT编译的Sharp4SafetyKatz2AOT.exe文件不再依赖于.NET运行环境的具体版本,这意味着它可以在几乎任何Windows系统上运行,即使目标机器上没有预先安装.NET框架或运行时。
为了验证AOT编译的结果,尝试使用dnSpy反编译打开Sharp4SafetyKatz2AOT.exe,可以看到是一个标准的PE结构的文件,工具无法再获取.NET代码内容,如图19-57所示。
图19-57 dnSpy反编译可见PE结构
在命令行下启动Sharp4SafetyKatz2AOT.exe程序,运行正常,成功弹出本地计算器进程,如图19-58所示。
图19-58 AOT编译后的文件运行正常
AOT的优势在于不再依赖.NET运行环境,生成的PE文件也可以在任意Windows系统上运行,但缺点也很明显,因预先编译导致生成的文件体积较大。