探索如何使用C#在Windows中利用系统调用执行Shellcode注入
2020-06-10 11:30:00 Author: www.4hou.com(查看原文) 阅读量:582 收藏

前言

在通过Sektor7恶意软件开发必修课程学习了如何使用C语言编写Shellcode注入工具之后,我希望尝试如何在C#中执行相同的操作。实际上,使用P/Invoke来运行类似的Win32 API调用,并编写一个与Sektor7类似的简单注入工具非常容易。我注意到其中的最大区别是,没有直接等效的方法来混淆API调用。在对BloodHound Slack频道进行了一些研究和提问之后,在@TheWover和@NotoriousRebel的帮助下,我发现有两个主要的领域可以进行研究,一个是使用本地Windows系统调用(syscall),另一个是使用动态调用。这两种方法都各有其优缺点,针对系统调用,Jack Halon和badBounty曾经解释过其工作原理,并进行过大量的研究工作。我们这篇文章和PoC的思路都是建立在他们的成果基础上。我也知道,目前TheWover和Ruben Boonen正在研究D/Invoke,我也想紧随其后对这部分进行研究。

本篇文章将主要探讨如何利用系统调用来实现Shell注入,阐述我自己的理解,并提出概念证明。在这篇文章中,我已经尽最大努力确保此处的信息准确无误,但并不能确保万无一失,其中的代码已经经过验证是可以正常工作的。

我们的代码可以在这里找到:https://github.com/SolomonSklash/SyscallPOC

本地API和Win32 API

首先,我们要解释为什么会选择使用系统调用,原因是在于反病毒或终端检测与响应(AV/EDR)产品通常会对API进行挂钩。这两类防御产品通常都会在执行Win32 API调用之前对其进行检查,确认其是否属于可疑或恶意,并判断是否允许调用继续进行。为实现这一点,需要稍微更改常见被滥用的API调用的程序集以跳转到AV/EDR控制的代码来完成的,然后在这里进行检查,并假设允许该调用,跳回原始API调用的代码。例如,将Shellcode注入本地或远程进程时,经常需要使用CreateThread和CreateRemoteThread Win32 API。实际上,我会在严格使用Win32 API的注入演示过程中稍微使用一下CreateThread。这些API都是在Windows DLL文件中定义,根据MSDN文档,这些API的定义具体是位于Kernel32.dll中,这意味着它们可以被正在运行的用户应用程序访问,并且实际上没有直接与操作系统或CPU进行交互。Win32 API本质上是Windows本地API上的抽象层,被认为是内核模式,因为这些API更接近于操作系统和底层硬件。实际上,有比实际执行内核模式功能更低的级别,但是这些级别不会直接公开。本地API是仍然可以被用户应用程序公开和访问的最低级别,可以充当用户代码与操作系统之间的一种桥梁或粘合剂。下图很好地解释了其结构:

1.png

我们可以看到,尽管Kernel32.dll的名称具有误导性,但它实际上处于比ntdll.dll更高的级别,而ntdll.dll位于用户模式和内核模式之间的边界。

那么,为什么Win32 API会存在呢?之所以存在的一个重要原因,是需要调用本地API。当我们调用Win32 API时,它会依次调用一个本地API函数,该函数会越过边界进入内核模式。用户模式代码从来不会直接接触硬件或操作系统。因此,它需要通过本地PI来访问更低级别的功能。但是,如果本地API仍然必须调用较低级别的API,为什么不直接使用本地API以减少额外的步骤呢?一种答案是,Microsoft可以在不影响用户模式应用程序代码的情况下更改本地API。实际上,本地API中的特定功能通常会在不同Windows版本之间有所更改,但这些更改并不会影响用户模式代码,因为Win32 API是保持不变的。

那么,如果我们只想注入一些Shellcode,为什么所有这些层、级别和API对我们来说都很重要?就我们的目标而言,Win32 API与本地API之间的主要区别在于反病毒/终端检测与防护系统可以直接挂钩Win32调用,但不能挂钩本地调用。这是因为本地调用被视为内核模式,用户代码无法对其进行更改。但也有一些例外情况,例如驱动程序,我们在这里不对这部分例外情况进行过多讨论。至此,我们最大的收获是,防御者无法挂钩本地API调用,但我们却可以调用它们。这样一来,我们就可以在不被防御产品发现的前提下实现相同的功能,这也就是系统调用的一大作用。

系统调用

本地API调用的另一个名称是系统调用。与Linux类似,每个系统调用都有一个代表它的特定数字,该数字表示系统服务调度表(SSDT)中的一个条目,这是一个内核中的表格,其中包含对各种内核级别函数的各种引用。每个命名的本地API都有一个匹配的系统调用(syscall)编号,该编号对应着一个SSDT条目。为了利用系统调用,我们仅知道API的名称(例如:NtCreateThread)是不够的,我们必须还知道它的系统调用号。除此之外,我们需要了解是在哪个版本的Windows上运行,因为在不同版本的操作系统上同一个系统调用的编号可能会发生变化。我们有两种方式可以找到这些数字,一种比较简单,另一种涉及到比较复杂的调试过程。

第一种简便的方法是使用Mateusz “j00ru” Jurczyk创建的便捷Windows系统调用表。假设我们已经知道要查找的API,这个过程将会让查找所需系统调用编号的过程变得非常简单,我们会在后面详细介绍。

查找系统调用编号的第二种方法就是直接在ntdll.dll中进行查找。我们要实现注入,需要的第一个系统调用是NtAllocateVirtualMemory。因此,我们可以启动WinDbg,并在ntdll.dll中寻找NtAllocateVirtualMemory函数。这个过程比听起来容易得多。首先,我打开一个目标进程进行调试,具体是哪个进程都没有关系,因为基本上所有进程都会映射ntdll.dll。在这里,我们选择了比较友好的记事本。

2.PNG

我们将Shellcode附加到记事本进程中,然后在命令提示符中输入以下命令:

x ntdll!NtAllocateVirtualMemory

这样一来,我们就可以检查ntdll.dll DLL中的NtAllocateVirtualMemory函数。该函数返回函数的内存位置,我们使用u命令对其进行检查或反汇编。

3.PNG

现在,我们就可以看到有关调用NtAllocateVirtualMemory的准确汇编语言说明。在汇编中如果需要系统调用,通常倾向于遵循一种模式,也就是在堆栈中设置一些参数,例如mov r10, rcx语句所示,然后将系统调用编号移动到eax寄存器中,这里显示为mov eax,18h。eax是系统调用指令用于每个系统调用的寄存器。因此,现在我们知道NtAllocateVirtualMemory的系统调用编号是十六进制表示的18,恰好与Mateusz表格中列出的值相同。到目前为止,一切都进行得很顺利,我们再重复两次上述过程,一次用于NtCreateThreadEx,另一次用于NtWaitForSingleObject。

4.PNG

5.PNG

如何获得这些本地函数

到目前为止,查找本地API调用的系统调用编号的过程非常简单。但是到目前为止,我还遗漏了一个关键的信息——如何知道我需要哪些系统调用呢?我之所以这样做,是因为想在C#中使用Win32 API调用(GitHub存储库名称为Win32Injector,已经存放在本文的GitHub存储库中)运行一个基本能运行的Shellcode注入工具。

6.PNG

这是一个简单的Shellcode注入工具示例,它会执行一些Shellcode以显示一个弹出框。

7.PNG

从代码中可以看到,通过P/Invoke使用的三个主要Win32 API调用是VirtualAlloc、CreateThread和WaitForSingleObject,它们分别为我们的Shellcode分配内存,创建指向我们Shellcode的线程,并启动该线程。由于它们是正常的Win32 API,因此它们在MSDN中都有非常详细的文档说明。但是由于本地API被认为是未记录的,因此我们可能不得不寻找其他地方。起初,我们找不到API文档的真实来源,但经过一些搜索后,我就能找到我需要的一切了。

对于VirtualAlloc,我们进行了一些简单的搜索,发现其在底层的本地API是NtAllocateVirtualMemory,实际上已经在MSDN中进行了记录。

但遗憾的是,MSDN文档中没有关于NtCreateThreadEx的说明,这是CreateThreat的本地API。幸运的是,badBounty的directInjectorPOC中已经包含可用的函数定义,并且是以C#语言实现的。这个项目在我的研究过程中提供了巨大的帮助,因此要对badBounty表示敬意。

最后,我需要找到NtWaitForSingleObject的文档说明,我们猜测其可能是WaitForSingleObject调用的本地API。我们可能会注意到一个共同点,其中许多本地API调用都是以“Nt”前缀开头,这会使得从Win32调用映射它们变得更加容易。我们可能还会看到“Zw”前缀,这也是一个本地API调用,但通常是从内核调用的。这部分有时是相通的,如果在WinDbg中执行x ntdll!ZwWaitForSingleObject和x ntdll!NtWaitForSingleObject,我们可以再次看到它们。幸好我们使用了这个API,ZwWaitForSingleObject在MSDN上已经有了详细的记录。

最后,我想说明一些信息,这些信息对于如何将Win32映射到本地API调用可能有帮助。首先是ReactOS的源代码,这是Windows的开源重新实现。在他们的代码库GitHub镜像中,有很多我们可以直接搜索的系统调用。接下来,就是jthuraisamy的SysWhispers,这是一个可以用于查找和实施系统调用的项目,效果非常不错。最后,要说明一个API Monitor工具。我们可以运行一个进程,并观察哪些API会被调用,查看其参数以及其他内容。我并没有大量使用这个函数,因为我只需要3个系统调用,并且查找现有文档的速度更快。但是,我可以看到Sysinternals的产品具有类似的功能,但我没有对这部分进行太多的测试。

好的,现在我们将Win32 API映射到系统调用,我们就可以开始编写C#代码了。

解决语言问题

在这个过程中,我们发现,在这些文档中都包含着C/C++实现。那么我们如何将它们转换为C#语言呢?答案是——封送(marshaling)。这就是P/Invoke的本质。封送处理是一种利用非托管代码的方式,例如C/C++语言,这部分代码可以在托管上下文(即C#)中使用。对于Win32 API,可以通过P/Invoke轻松完成。只需要导入DLL,在pinvoke.net的帮助下指定函数的定义,然后就可以使用了。我们可以在Win32Injector的演示代码中证实这一点。但是,由于系统调用没有在文档中被记录,因此Microsoft不会提供与之交互的简便方法。

通过委托(delegate)的方式,实际上是有可能实现的,Jack Halon曾经针对这一方面发表过一篇文章,因此我在这里就不做过多引述了。我建议各位读者首先阅读上述文章,以更好地了解它们,同时了解使用syscall的进程。但是为了完整起见,委托实际上是函数指针,让我们能够将函数作为参数,再传递给其他函数。我们在这里利用它们的方式是定义一个委托,其返回类型和函数签名与我们要使用的系统调用相一致。我们使用封送处理来确保C/C++数据类型与C#兼容,定义一个实现系统调用的函数,包括其所有参数和返回类型,然后就可以了。

但实则不然,我们实际上不能调用本地API,因为我们唯一的实现使用的是汇编语言。我们知道其函数定义和参数,但实际上无法像使用Win32 API那样对其直接进行调用。编译对我们来说非常好,使用C/C++执行汇编相当简单,但是如果要使用C#就有难度了。幸运的是,我们有办法克服这个问题,并且我们已经有了WinDbg扩展中的汇编。不用担心,用户在使用系统调用之前是不需要了解汇编语言的。下面是NtAllocateVirtualMemory系统调用的汇编语言:

8.PNG

从注释中可以看到,我们在堆栈上设置了一些参数,将系统调用编号转移到eax寄存器中,并使用了神奇的系统调用操作。在足够低的级别上,这只是一个函数调用。还记得我们在前面提过,委派为什么只是函数指针吗?希望这一切都变的有意义。为了调用本地API,我们需要获得一个指向该程序集的函数指针,以及一些与C/C++格式兼容的参数。

组合利用

现在,我们差不多快完成了,我们已经有了系统调用、它们的编号、调用它们的程序集以及在委托中调用它们的方法。接下来,我们来看看C#语言的实际外观:

9.PNG

从最上面开始,我们可以看到NtAllocateVirtualMemory的C/C++定义,以及系统调用本身的程序集。从第38行开始,我们有了NtAllocateVirtualMemory的C#定义。请注意,如果我们要让C#中的每种类型与非托管类型相匹配,可能要花费一定的时间。我们可以在不安全的块中创建一个指向程序集的指针。这样一来,我们就可以在C#中执行操作,例如对原始内存执行操作,这些操作通常对于托管代码来说是不安全的。我们还使用了fixed关键字,来确保C#垃圾回收工具不会无意间移动内存并更改指针。

一旦有了指向Shellcode内存位置的原始指针,就需要将其内存保护更改为可执行文件,以便可以直接运行它,因为其形式将会是函数指针,而不仅仅是数据。在这里,我将会使用Win32 API VirtualProtectEx来更改内存保护。我不知道通过系统调用实现这种操作的方式,因为这有点像鸡生蛋的问题,需要获取可执行的内存才能运行系统调用。如果有朋友知道如何在C#中执行此操作,欢迎与我取得联系!在这里,还需要注意的另外一件事是,将内存设置为RWX通常会引起怀疑,但是由于这里展示的是PoC,我就暂时没有顾虑这一点。我们现在关心的是挂钩问题,而不是内存扫描。

现在,就是见证奇迹的时刻。下面是我们的委托被声明的结构:

10.PNG

请注意,委托的定义只是函数签名和返回类型。只要实现与委托定义相匹配,我们就可以决定实现的方式,这也就是我们在C#语言的NtAllocateVirtualMemory函数中实现的内容。在上面的第65行,我们创建了一个名为assembledFunction的委托,该委托利用了特殊的封送处理函数Marshal.GetDelegateForFunctionPointer。这种方法可以让我们从函数指针获取委托。在这种情况下,我们的函数指针是指向名为memoryAddress的系统调用程序集的指针。现在,assembledFunction是指向汇编语言函数的函数指针,也就意味着我们现在可以执行系统调用。就像调用任何常规函数一样,我们使用参数来完成对assembledFunction委托的调用,然后获取NtAllocateVirtualMemory系统调用的结果。因此,在我们的return语句中,使用传入的参数调用assembledFunction,然后返回结果。让我们看看在Program.cs中实际调用此函数的位置:

11.PNG

在这里,我们调用了NtAllocateMemory,而不是Win32Injector使用的Win32 API VirtualAlloc。我们使用所有需要的参数来设置函数调用(第43-48行),然后调用NtAllocateMemory。这将会为我们的Shellcode返回一个内存块,就如同VirtualAlloc一样。

其余的步骤类似:

12.PNG

我们将Shellcode复制到新分配的内存中,然后在当前进程中通过另一个系统调用NtCreateThreadEx来替代CreateThread在该内存中创建一个指向该内存的线程。最后,我们通过调用NtWaitForSingleObject系统调用来启动线程,而不是WaitForSingleObject。下面是最后的结果:

13.PNG

终于,我们得到了一个通过系统调用显示的Hello World。假如这是在启用了API挂钩的系统上运行的某种Payload,我们将绕过它,并且成功运行了我们的Payload。

后续需解决的问题

我们还有一些难题需要解决,比如为保证系统调用正常运行所需的所有本地结构、枚举和定义。如果我们观察上面的屏幕截图,我们会发现在C#中没有实现的类型,例如所有系统调用的NTSTATUS返回类型,或者AllocationType和ACCESS_MASK位掩码。这些类型通常会在各种Windows标头和DLL中声明,但是要使用系统调用,我们就需要自己实现它们。我发现它们的方法是,查找任何比较复杂的类型,然后尝试找到其对应的定义。在这里,Pinvoke.net网站非常有帮助,将其与MSDN、ReactOS源代码等其他资源组合,我们就可以找到并添加所需的一切。我们可以在GitHub项目的Native.cs类中找到上述代码。

总结

实现系统调用是一个非常有趣的过程,我们几乎很少会在一个程序中结合三种不同的语言、结合托管和非托管代码、结合多个级别的Windows API。实际上,要实现系统调用,并不是一件容易的事情,我们需要使用一些示例的代码,而这些示例代码散落在各处,就如同没有头绪的寻宝游戏一样。在托管和非托管代码进行转换的过程中,调试也许会比较复杂。最后,系统调用编号经常会更改,因此我们必须针对所使用的平台进行自定义。D/Invoke似乎可以很好地解决其中一些问题,因此我将继续深入研究这一部分的内容,希望能尽快与大家深入探讨这些问题。

本文翻译自:https://www.solomonsklash.io/syscalls-for-shellcode-injection.html如若转载,请注明原文地址


文章来源: https://www.4hou.com/posts/JlYv
如有侵权请联系:admin#unsafe.sh