Red Team后漏洞利用秘籍:如何使用C#语言实现系统调用
2020-05-21 10:20:00 Author: www.4hou.com(查看原文) 阅读量:348 收藏

0x00 前言                            

在过去的一年中,安全社区(特别是红方运营团队和蓝方防御团队)持续关注Windows恶意软件如何实现后漏洞利用活动,以及如何绕过终端检测与响应(EDR)设备。

现在,对于某些防御人员来说,这种技术的使用还是比较陌生的,但对于攻击者来说却并非如此。许多年来,很多恶意软件作者、开发人员甚至是游戏破解者都在尝试利用系统调用和内存加载。其最初目标是绕过某些通过反病毒和反作弊引擎之类的工具来实现的限制和安全措施。

在一些文章中,已经介绍过如何利用这些系统调用技术,例如:如何绕过EDR的内存保护、关于挂钩的介绍、结合直接系统调用和sRDI绕过AV或EDR等等。作为红队成员,这些技术的使用对于秘密行动来说至关重要,因为它们使我们能够在网络范围内进行后漏洞利用活动,同时又能够避开监控。

这些技术的实现大部分都是在C++中完成的,以便轻松地与Win32 API和系统进行交互。但是,使用C++来编写工具存在着一个缺点,就是我们将代码编译后,总会得到一个EXE文件。为了实现秘密行动的目标,作为红队运营者来说更倾向于避免接触磁盘,我们不想盲目地把文件复制到系统上并执行。因此,我们倾向于寻找一种方法,将这些工具以一种更安全的方式注入到内存中。

尽管在任何恶意软件相关的领域,C++都是一种非常不错的语言,但当我在尝试编写一些后漏洞利用工具时,我开始认真考虑将系统调用集成到C#之中。在FuzzySec和THe Wover在BlueHatIL 2020发表了一篇题目为《保持#,将隐性注入引入.NET》的演讲后,我决定更加深入地研究如何在C#中实现这一点。

经过一些复杂的研究、失败的尝试、漫长的夜晚和大量的咖啡之后,我终于成功找到了在C#中实现系统调用的方式。尽管该技术本身对于增强隐蔽性很有帮助,但其代码却稍有繁琐,我们将会在稍后详细分析原因。

总而言之,这一系列文章的重点是探讨如何通过利用非托管代码来绕过EDR和API挂钩,从而在C#中使用直接系统调用。

但是,在开始编写代码之前,必须首先了解一些基本概念,例如系统调用的工作方式、某些.NET的内部结构、托管代码和非托管代码、P/Invoke和委托。了解这些基础知识将真正帮助我们理解C#代码的工作方式和原因。

好了,我们的前言部分已经足够,接下来让我们开始基础工作。

0x01 理解系统调用

在Windows中,进程体系结构分为两种处理器访问模式——用户模式和内核模式。这些模式实现背后的想法是希望防止用户应用程序访问和修改任何重要的操作系统数据。用户应用程序(例如Chrome、Word等)都是以用户模式运行,而操作系统代码(例如系统服务、设备驱动程序等)都是以内核模式运行。

1.png

内核模式特指在处理器中执行的一种模式,该模式授予对所有系统内存和所有CPU指令的访问权限。一些x86和x64处理器通过使用另一个称为Ring Level的术语来区分这些模式。

使用Ring Level特权模式的处理器定义了四个特权级别,也称为Ring,用于保护系统代码和数据。这些Ring Level的示例如下所示。

2.png

在Windows中,仅使用其中两个Ring。Ring 0用于内核模式,Ring 3用于用户模式。在正常的处理器操作期间,处理器将根据其上面运行的代码类型,在这两种模式之间切换。

那么,为什么这种Ring Level可以提供安全性呢?当我们启动用户模式应用程序时,Windows将为应用程序创建一个新的进程,并将为该应用程序提供私有虚拟地址空间和私有句柄表。

这个句柄表,就是包含句柄的内核对象。句柄只是对特定系统资源(例如:内存区域和位置、打开的文件或管道)的抽象引用值,最初的目的是向API用户隐藏真实的内存地址,从而使系统能够执行某些管理功能,例如重组物理内存等。

总体而言,句柄的工作是对内部结构执行任务,例如令牌、进程、线程等。句柄的示例如下:

3.png

因为应用程序的虚拟地址空间是私有的,所以一个应用程序不能更改属于另一个应用程序的数据,除非该进程通过文件映射或VirtualProtect函数,将其私有地址空间的一部分用于共享内存段,或者一个进程有权打开另一个进程以使用跨进程的内存函数(例如:ReadProcessMemory和WriteProcessMemory)。

4.png

现在,与用户模式不同,所有在内核模式下运行的代码都共享一个称为系统空间的虚拟地址空间。这意味着,内核模式驱动程序不会与其他驱动程序以及操作系统本身相隔离。因此,如果驱动程序无意写入了错误的地址空间,或者进行了恶意操作,就可能会影响系统或其他驱动程序。尽管还有一些保护措施(例如内核补丁保护)可以防止操作系统出现混乱,但这并非我们所关注的重点。

由于内核在用户模式应用程序需要访问这些数据结构,或者需要调用Windows例程,以在执行特权操作(例如:读取文件)的过程中随时容纳操作系统的大多数内部数据结构(例如:句柄表),因此它必须首先从用户模式切换到内核模式,这也就是系统调用作用的位置。

为了使用户应用程序以内核模式访问这些数据结构,进程使用了一种名为syscall的特殊处理器指令触发器。该指令触发处理器访问模式之间的转换,并允许处理器访问内核中的系统服务处理代码。依次调用Ntoskrnl.exe或Win32k.sys中的相应内部函数,这些函数包含内核和操作系统应用程序级逻辑。

在任何应用程序中,都可以观察到这种“开关”的例子。例如,通过使用Process Monitor查看记事本,我们可以查看特定的Read/Write操作属性及其调用栈。

5.jpg

在上图中,我们可以看到从用户模式到内核模式之间的切换。请大家关注,在直接调用本地API NtCreateFile之前,是如何立即执行Win32 API CreateFile函数调用的。

但是,如果我们仔细观察,会发现其中可能会有一些不同寻常之处。我们观察到,有两个不同的NtCreateFile函数调用,其中的一个来自ntdll.dll模块,另一个来自ntoskrnl.exe模块。这是为什么?

答案很简单,ntdll.dll DLL导出Windows本地API。ntdll的这些本地API都是在ntoskrnl中实现,我们可以将其视为是“内核API”。Ntdll支持用于执行函数的函数以及系统服务调度存根。

简而言之,其中包含“syscall”逻辑,该逻辑使我们能够将处理器从用户模式转换为内核模式。

那么,该syscall CPU指令在ntdll中实际是什么样子的?如果要进行分析,我们可以利用WinDbg来拆解并检查ntdll中的调用函数。

首先,启动WinDbg,并打开记事本或cmd这样的进程。完成后,在命令行窗口中,输入以下内容:

x ntdll!NtCreateFile

这样一来,将会告诉WinDBG我们要检查(x)加载的ntdll模块中的NtCreateFile符号。在执行命令后,我们将可以看到以下输出。

00007ffd7885cb50 ntdll!NtCreateFile (NtCreateFile)

提供给我们的输出是NtCreateFile在加载过程中所在的内存地址。在这里查看反汇编,输入以下命令:

u 00007ffd7885cb50

该命令将告诉WinDBG我们要在指定的内存范围开头反汇编(u)指令。如果运行正确,应该可以看到以下输出。

6.jpg

总体而言,ntdll的NtCreateFile函数首先负责在栈上设置函数调用参数。完成后,该函数需要将其相关的系统调用编号移动到eax,如第二条指令mov eax, 55所示。

每个本地函数都有一个特定的系统调用编号。现在,这些编号往往会随着每次更新而改变,因此有时很难跟上它们。但是,感谢Google Project Zero的j00ru,他不断维护Windows x86和x64系统调用表,因此,在每次更新之后,我们都可以以其作为参考。

为此,它将会把函数调用参数从用户模式栈复制到内核模式栈。随后,执行函数调用的内核版本,即ZwCreateFile。完成后,该例程将反向执行,并且所有返回值都会返回到用户模式应用程序。我们的系统调用现在就已经完成了。

0x02 使用直接系统调用

现在,我们已经掌握了系统调用的工作方式及其结构,接下来我们来研究如何执行这些系统调用。

为了直接进行系统调用,我们将使用程序集来构建系统调用,并在应用程序内存空间中执行该调用。这样一来,我们将能够绕过EDR或AV监控的所有挂钩功能。当然,我们仍然可以监控syscall,并且可以给我们关于通过C#来执行syscall的一些提示,但这部分内容也不在本文的讨论范围之内。

例如,如果我们想要编写一个利用NtCreateFile syscall的程序,我们可以构建一些简单的程序集,例如:

mov r10, rcx
mov eax, 0x55 <-- NtCreateFile Syscall Identifier
syscall
ret

因此,我们现在就有了系统调用的程序集,那么接下来,就需要在C#中来执行。

在C++中,这一过程就如同将其添加到新的.asm文件中一样简单,可以启用masm构建依赖关系,定义程序集的C函数原型,并且只需初始化系统调用所需的结构和变量即可。

听起来比较简单,但在C#中却并不是那么简单。原因在于——托管代码。

0x03 理解C#和.NET框架

在我们深入了解托管代码的含义以及其中复杂性之前,我们首先需要了解C#,以及如何在.NET框架上运行。

简而言之,C#是一种类型安全的面向对象语言,使得开发人员可以构建各种安全而强大的应用程序。其语法简化了C++中的许多复杂性,并提供了强大的功能,例如可以为空的类型、循环、委托、lambda表达式和直接内存访问。C#还可以在.NET框架上运行,而.NET框架是Windows上不可缺少的组件,其中包含一个被称为“公共语言运行时(CLR)”的虚拟执行系统,以及一组统一的类库。CLR则是微软的公共语言基础结构(CLI)的商业版本实现。

使用C#编写的源代码会被编译为符合CLI规范的中间语言(IL)。IL代码和资源(例如位图和字符串)存储在磁盘上的可执行文件中,该可执行文件称为程序集,通常以.exe或.dll为扩展名。

当执行C#程序时,程序集被加载到CLR中,然后CLR执行即时(JIT)编译,将IL代码转换为本地指令。CLR还提供其他服务,例如自动垃圾收集、异常处理和资源管理。与非托管代码相反,CLR执行的代码有时会被称为是托管代码,后者直接编译为特定系统的本地代码。

简而言之,托管代码会执行由运行时管理的代码。在这种情况下,运行时是指公共语言运行时(CLR)。

在非托管代码中,就类似于C/C++,开发人员需要负责几乎所有事情。本质上,实际得到的程序是一个二进制文件,操作系统将其加载到内存中并启动。出于内存管理和安全性考虑,其他的所有事情都是开发人员需要去做的。

下面是一个很好的.NET框架可视化示例,展示了如何将C#编译为IL,然后编译为机器代码。

7.png

现在,大家可能会注意到,我刚刚说CLR提供了其他服务,例如“垃圾回收”。在CLR中,垃圾收集器(也称为GC)实际上是通过自动的方式来释放内存,即“释放垃圾(已使用的内存)”。CLR还通过在托管的堆上分配对象、回收对象、清除内存以及通过防止已知的内存损坏问题(例如UAF)来增强内存安全性。

现在,尽管C#是一个不错的语言,并且提供了一些与Windows之间相互操作(例如内存执行)的便捷性,但在编写恶意软件或尝试与系统进行交互时,还存在着一些弊端。其中的一些问题包括:

1、通过dnSpy之类的工具,可以很容易地反汇编C#程序集,并对其进行逆向工程,因为它们被编译为IL,而不是本地代码。

2、要求在系统上存在.NET环境才能执行。

3、在.NET中,比在本地代码中所需的反调试技巧更加困难。

4、需要进行更多的工作和编写更多的代码,才能在托管代码和非托管代码之间进行互相操作。

对我们而言,在使用C#编写系统调用时,最困扰我们的一点是其中的第4点。

我们在C#中所做的任何事情都是托管的,那么如何能够与Windows系统和处理器进行有效交互呢?

因为我们要执行汇编代码,因此这个问题对我们来说尤为重要,但遗憾的是,对于我们来说,C#中没有内联ASM,就如同C++中没有内置依赖项一样。

但是,微软为我们提供了一种实现方式,一切都要归功于CLR。根据CLR的构造方式,我们实际上可以穿越托管和非托管之间的界限。这个过程被称为互操作性(或简称互操作)。通过interop,C#支持指针和不安全(unsafe)代码的概念,用于一些直接内存访问的情况,而这正是我们所需要的!

总体来说,这意味着我们现在可以用它来实现C++可以完成的内容,也可以使用相同的Windows API函数。

当然,必须注意的是,一旦代码超过了运行时的边界,执行的实际管理将再次落到非托管代码之中,会受到与使用C++进行编码时相同的限制。因此,在对内存和其他对象的分配、释放和管理过程中,我们需要格外小心。

因此,在掌握了这一点之后,我们如何能够在C#中实现这种互操作性呢?接下来,我们就要看一下我们的主角———— P/Invoke。

0x04 通过P/Invoke理解本地互操作

P/Invoke可以允许我们从托管代码访问非托管库(即DLL)中的结构、回调和函数。允许这种互操作性的大多数P/Invoke API都包含在两个命名空间中,分别是System和System.Runtime.InteropServices。

我们来看一个简单的例子。假设我们想在C#代码中使用MessageBox函数————通常情况下,除非我们在构建UWP应用,否则将无法调用该函数。

首先,我们创建一个新的.cs文件,并确保其中包含两个P/Invoke命名空间。

using System;
using System.Runtime.InteropServices;
 
public class Program
{
    public static void Main(string[] args)
    {
        // TODO
    }
}

接下来,我们迅速看一下要使用的C MessageBox语法。

int MessageBox(
  HWND    hWnd,
  LPCTSTR lpText,
  LPCTSTR lpCaption,
  UINT    uType
);

在这里,我们就可以看出,C++中使用的数据类型与C#的数据类型是不匹配的。这意味着,诸如HWND(到窗口的句柄)和LPCTSTR(到常量TCHAR字符串的长指针)这样的数据类型在C#中是无效的。

现在,我们将简要介绍如何为MessageBox转换这些数据类型,以为大家提供一些有效的思路。但是,如果大家想要了解更多信息,我建议首先阅读有关C#类型和变量的信息。

因此,对于任何与C++有关的句柄对象(例如HWND),在C#中与之对应的等效项是 IntPtr Struct,这是一种平台特定的类型,用于表示指针或句柄。

我们可以将C++中的任何字符串或指向字符串数据类型的指针设置为等效于C#的形式,即字符串。对于UINT或无符号整数,在C#中保持不变。

那么,现在我们已经知道了不同的数据类型,接下来就可以继续在代码中调用非托管的MessageBox函数。

现在,我们的代码应该如下所示。

using System;
using System.Runtime.InteropServices;
 
public class Program
{
    [DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
    private static extern int MessageBox(IntPtr hWnd, string lpText, string lpCaption, uint uType);
 
    public static void Main(string[] args)
    {
        // TODO
    }
}

请注意,在导入非托管函数之前,我们首先调用DllImport属性。这个属性的加入至关重要,因为它告诉运行时应该加载非托管DLL。传入的字符串就是我们要加载的目标DLL,在示例中为user32.dll,其中包含MessageBox的函数逻辑。

此外,我们还指定了用于编组字符串的字符集,并指定该函数调用SetLastError,且运行时应该捕获该错误代码,以便用户可以通过Marshal.GetLastWin32Error()对其进行检索,以在函数运行失败时返回错误。

最后,我们使用extern关键字创建了一个私有的静态MessageBox函数。这个extern修饰符用于声明在外部实现的方法。它会告诉运行时,当我们调用这个函数时,运行时应该在DllImport属性中指定的DLL中找到它。对我们而言,是位于user32.dll中。

一旦有了上述这些,我们最终就可以继续在主程序中调用MessageBox函数。

using System;
using System.Runtime.InteropServices;
 
public class Program
{
    [DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
    private static extern int MessageBox(IntPtr hWnd, string lpText, string lpCaption, uint uType);
 
    public static void Main(string[] args)
    {
        MessageBox(IntPtr.Zero, "Hello from unmanaged code!", "Test!", 0);
    }
}

如果操作正确,现在应该会执行一个标题为“Test!”的新消息框,并显示一条消息“Hello from unmanaged code!”。

非常好,因此我们现在已经明确了应该如何从C#导入和调用非托管代码。这部分看上去貌似非常简单,实则不然。

以上只是一个简单的函数,那么如果我们要调用的函数稍微复杂一些,例如是CreateFileA函数,将会发生什么?

让我们快速看一下该函数在C语言中的语法。

HANDLE CreateFileA(
  LPCSTR                lpFileName,
  DWORD                 dwDesiredAccess,
  DWORD                 dwShareMode,
  LPSECURITY_ATTRIBUTES lpSecurityAttributes,
  DWORD                 dwCreationDisposition,
  DWORD                 dwFlagsAndAttributes,
  HANDLE                hTemplateFile
);

我们来查看一下dwDesiredAccess参数,该参数指定使用通用值(例如GENERIC_READ和GENERIC__WRITE)创建文件的访问权限。在C++中,我们只需要直接使用这些值即可,系统会知道我们的意思,但在C#中则不然。

在查看文档后,我们看到用于dwDesiredAccess参数的通用访问权限使用了某种访问掩码格式来指定我们赋予文件的特权。现在,由于该参数接受的DWORD是32位无符号整数,因此我们很快了解到GENERIC-*常量实际上是将常量与特定访问掩码位值匹配的标志。

对于C#来说,要执行相同的操作,我们必须使用FLAGS枚举属性创建一个新的结构类型,该结构类型将包含C++所使用的常量和值,以使这个函数正常工作。

那么,我们可以在哪里得到这些详细信息呢?实际上,无论在任何需要处理.NET中非托管代码的场景中,我们能够利用的最佳资源都是PInvoke Wiki。我们几乎可以在这里找到任何东西。

如果我们要在C#中调用这个非托管函数,并使其正常工作,那么示例代码如下所示:

using System;
using System.Runtime.InteropServices;
 
public class Program
{
    [DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)]
    public static extern IntPtr CreateFile(
        string lpFileName,
        EFileAccess dwDesiredAccess,
        EFileShare dwShareMode,
        IntPtr lpSecurityAttributes,
        ECreationDisposition dwCreationDisposition,
        EFileAttributes dwFlagsAndAttributes,
        IntPtr hTemplateFile);
 
    [Flags]
    enum EFileAccess : uint
    {
        Generic_Read = 0x80000000,
        Generic_Write = 0x40000000,
        Generic_Execute = 0x20000000,
        Generic_All = 0x10000000
    }
 
    public static void Main(string[] args)
    {
        // TODO Code Here for CreateFile
    }
}

现在,想必大家已经理解了,为什么我要说在C#上使用非托管代码是非常麻烦的一件事。在我们已经了解系统调用的工作原理、了解C#和.NET框架在较低级别的函数之后,我们已经可以从C#调用非托管代码和Win32 API。

但是,在这时仍然缺少一个重要的信息。即使我们可以使用C#调用Win32 API函数,但我们仍然不知道如何执行“本地代码”程序集。

然而,办法总比困难多。在C#中,即使我们无法像C++那样执行内联程序集,也可以通过名为Delegates(委托)的东西来实现类似的工作。

0x05 理解委托和本地代码回调

实际上,CLR是非常酷的,因为它可以管理代码,同时也可以允许GC和Windows API之间的互操作。

运行时确实非常方便,它还允许双向通信,这意味着我们可以使用函数指针从本地函数回调托管代码。现在,在托管代码中最接近函数指针的是委托。委托是一种类型,表示对具有特定参数列表和返回类型的方法的引用。这就可以用来允许从本地代码到托管代码的回调。

简单来说,委托用于将方法作为参数传递给其他方法。现在,要使用这个特性,就类似于从托管代码到非托管代码的使用方式。微软给出了一个很好的示例。

using System;
using System.Runtime.InteropServices;
 
namespace ConsoleApplication1
{
    public static class Program
    {
        // Define a delegate that corresponds to the unmanaged function.
        private delegate bool EnumWindowsProc(IntPtr hwnd, IntPtr lParam);
 
        // Import user32.dll (containing the function we need) and define
        // the method corresponding to the native function.
        [DllImport("user32.dll")]
        private static extern int EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam);
 
        // Define the implementation of the delegate; here, we simply output the window handle.
        private static bool OutputWindow(IntPtr hwnd, IntPtr lParam)
        {
            Console.WriteLine(hwnd.ToInt64());
            return true;
        }
 
        public static void Main(string[] args)
        {
            // Invoke the method; note the delegate as a first parameter.
            EnumWindows(OutputWindow, IntPtr.Zero);
        }
    }
}

因此,这段代码可能看起来有些复杂,但是请相信我,实际上并不是这样。在研究这个示例之前,请确保我们已经检查了需要使用的非托管函数的签名。

如我们所见,我们正在导入本地代码函数EnumWindows,该函数通过将句柄传递给每个窗口,并将其传递给应用程序定义的回调函数,来枚举屏幕上所有顶级窗口。

如果我们看一下函数类型的C语言语法,我们将看到以下内容:

BOOL EnumWindows(
  WNDENUMPROC lpEnumFunc,
  LPARAM      lParam
);

如果我们查看文档中的lpEnumFunc参数,我们将看到它接受一个指向一个你哟给你程序定义的回调的指针,该指针应该采用与EnumWindowsProc回调函数相同的结构。这个回调只是应用程序定义的函数的占位符名称。这意味着,我们可以在应用程序中随意调用它。

如果我们看一下C语言的函数语法,我们可以看到以下内容。

BOOL CALLBACK EnumWindowsProc(
  _In_ HWND   hwnd,
  _In_ LPARAM lParam
);

如我们所见,该函数参数接受HWND或指向Windows句柄的指针,以及LPARAM或长指针。这个回调的返回值是一个布尔值,用true和false来指示什么时候枚举停止。

现在,如果我们回顾代码,在第9行,我们定义了与来自非托管代码的回调签名匹配的委托。由于我们是在C#中执行此操作,因此我们将C++指针替换为IntPtr,这是C#的等效指针。

在第13行和第14行,我们从user32.dll中引入了EnumWindows函数。

接下来,在第17-20行,我们实现了委托。在这里,我们实际上是告诉C#,要处理从非托管代码返回给我们的数据。在这里,我们只是说要将返回值打印到控制台。

最后,在第24行,我们仅调用导入的本地方法,并传递定义并实现的委托,以处理返回数据。

这个过程相对简单。

我知道,现在大家可能会问,这与使用C#执行我们的本地汇编代码有什么关系?大家可能目前还不清楚应该如何实现。

但是,我们需要耐心一些。

8.jpg

我们之所以在这里先说明有关委托和本地代码回调的原因,因为委托是我们接下来要介绍的重要部分。

现在,我们了解到委托类似于C++函数指针,但委托完全面向对象,并且与指向成员函数的C++指针不同,委托既封装了对象实例,又封装了方法。我们也已经了解到,它允许方法作为参数传递,也可以用于定义回调方法。

由于我们已经掌握委托可以接受的数据,因此我们可以对所有这些数据进行处理。

例如,假设我们执行一个本地Windows函数,例如VirtualAlloc,它使我们能够在调用进程的虚拟地址空间中保留、提交或更改页面区域的状态。该函数将向我们返回分配的内存区域的基址。

假设在这个示例中,我们分配了一些Shellcode,那么情况会怎么样呢?

如果我们能够在包含Shellcode的进程中分配一个内存区域,并将其返回给我们的委托,那么我们就可以利用一种称为类型封装处理的方式来转换传入的数据类型,以在托管代码和本地代码之间进行转换。这意味着,我们可以从非托管函数指针转换到委托。也就是说,我们可以通过这种方式执行程序集或字节数组Shellcode。

在掌握了这个总体思路后,我们可以更加深入地进行分析。

0x06 类型封装、不安全代码和指针

如前所述,封装处理是类型需要在托管代码和本地代码之间转换时进行转换的过程。由于我们已经看到并演示了托管代码和非托管代码中的类型不同,因此需要进行封装处理。

在默认情况下,P/Invoke子系统尝试根据默认行为进行类型封装处理。但是,对于需要使用非托管代码进行额外控制的情况,可以使用封装类进行诸如分配非托管内存、复制非托管内存块、将托管类型转换为非托管类型等操作,以及和非托管类型进行交互时使用的其他方法。

下面提供了有关这个封装工作原理的示例。

9.png

对我们而言,最重要的封装方法就是Marshal.GetDelegateForFunctionPointer方法,该方法让我们能够将非托管函数指针转换为指定类型的委托。

现在,我们可以封装大量其他类型的数据,我强烈建议各位读者仔细阅读,因为这些是.NET框架不可或缺的一部分,无论何时编写红队工具,甚至是在防御工具中都会派上用场。

到这里,我们已经知道,我们可以封装委托的内存指针。但现在的问题是,我们如何能够创建指向程序集数据的内存指针?实际上,这很容易。我们可以执行一些简单的指针算法,来获取ASM代码的内存地址。

由于C#不支持算数指针,因此在默认情况下,我们可以生命代码的一部分是不安全的。这只表示不安全的上下文,这是要执行涉及指针的任何操作所必需的。总体来说,这使我们能够执行指针操作,例如进行指针解引用。

现在,唯一的警告是将要编译不安全的代码,因此必须指定-unsafe编译器选项。

知道了这一点,让我们来看一个简单的例子。

如果我们想对NtOpenProcess执行系统滴哦啊用,我们需要做的就是像这样将程序集写入字节数组。

using System;
using System.ComponentModel;
using System.Runtime.InteropServices;
 
namespace SharpCall
{
    class Syscalls
    {
 
        static byte[] bNtOpenProcess =
        {
            0x4C, 0x8B, 0xD1,               // mov r10, rcx
            0xB8, 0x26, 0x00, 0x00, 0x00,   // mov eax, 0x26 (NtOpenProcess Syscall)
            0x0F, 0x05,                     // syscall
            0xC3                            // ret
        };
    }
}

一旦完成了用于系统调用的字节数组,将继续调用unsafe关键字,并指出将发生不安全上下文的代码区域。

在这种不安全的上下文中,我们可以执行一些指针算法,以初始化一个称为ptr的新字节指针,并将其设置为系统调用的值,该值包含我们的字节数组程序集。正如我们在下面看到的,我们利用了修复后的语句,该语句可以防止垃圾回收机制对可移动的变量进行重定位,在本例中为系统调用字节数组。

如果没有固定的上下文,那么垃圾回收可能会意外地重新定位变量,并在执行过程中产生错误。

之后,我们只需将字节数组指针转换为一个名为memoryAddress的C# IntPtr。这样,将使我们能够获取系统调用字节数组所在的内存位置。

在这里,我们可以做很多事情,例如在本地API调用中使用这个内存区域,或者可以将其传递给其他托管C#函数,甚至可以在委托中使用它。

下面是一个示例:

using System;
using System.ComponentModel;
using System.Runtime.InteropServices;
 
namespace SharpCall
{
    class Syscalls
    {
              // NtOpenProcess Syscall ASM
        static byte[] bNtOpenProcess =
        {
            0x4C, 0x8B, 0xD1,               // mov r10, rcx
            0xB8, 0x26, 0x00, 0x00, 0x00,   // mov eax, 0x26 (NtOpenProcess Syscall)
            0x0F, 0x05,                     // syscall
            0xC3                            // ret
        };
 
        public static NTSTATUS NtOpenProcess(
            // Fill NtOpenProcess Paramters
            )
        {
            // set byte array of bNtOpenProcess to new byte array called syscall
            byte[] syscall = bNtOpenProcess;
 
            // specify unsafe context
            unsafe
            {
                // create new byte pointer and set value to our syscall byte array
                fixed (byte* ptr = syscall)
                {
                    // cast the byte array pointer into a C# IntPtr called memoryAddress
                    IntPtr memoryAddress = (IntPtr)ptr;
                }
            }
        }
    }
}

就是这样!

现在,我们已经知道如何使用非托管代码、不安全上下文、委托、封装处理从字节数组中获取Shellcode,并在C#应用程序中执行。

我知道,这个过程实际上涉及了很多内容,这实际上有一些复杂。因此,还请各位读者花费一些时间来阅读本文,并确保已经理解了这些概念。

在下一篇文章中,我们将关注如何编写实际代码,利用我们在本文中所学到的内容来执行有效的系统调用。除了编写代码之外,我们还会介绍一些概念来管理“工具”代码,以及深入探讨如何为后续其他工具的集成做好准备。

感谢大家的阅读,敬请期待下篇文章。

本文翻译自:https://jhalon.github.io/utilizing-syscalls-in-csharp-1/如若转载,请注明原文地址:


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