Preface
在实际的红队攻防与渗透测试的过程中,免杀是非常重要的一环,它能够使我们在目标上执行恶意程序时不被杀毒软件侦测到,从而能够持续的隐秘攻击。我们将先通过了解Windows下基础的Shellcode的开发来逐步导入免杀的学习。
此文将是免杀技术的第一篇文章,系列文章的目标是:
- 了解Windows Shellcode的运行原理及其多样性研究。
- 通过ASM/C/C++编写自定义Shellcode,区别于msfvenom所生成的Payload。
- 使Shellcode绕过国内常规杀软的检测且能够正常运行。
本文的读者需要了解基础的汇编语言语法,以及操作系统、程序运行的基础概念。
Introduce
我们首先需要了解Shellcode是什么?为什么需要Shellcode?
Shellcode往往用于利用漏洞的Payload,例如CTF中PWN方向往往就是利用栈溢出、堆溢出等漏洞使得操作系统执行Shellcode从而获得一个Shell,但执行类似任务的代码也可以称之为Shellcode,它的作用也并不局限于创建一个Shell,例如msfvenom中提供的Windows下创建用户的Shellcode。
Shellcode一般由机器代码编写,通过汇编语言生成,它的尺寸并不大,我们在编写Shellcode时希望它的尺寸不大而且能够拥有更大的兼容性,使其在广泛的操作系统或设备上可用。但Shellcode并不能直接运行,我们一般将其包装在一个C程序中,通过内联汇编的方式执行它。
Before Start
在开始之前,我们还需要了解一部分Windows Shellcode开发的内容。
一般情况下Windows Shellcode开发比Linux更难,主要原因是Windows下的程序无法直接使用系统调用,而Linux的程序则可以更方便的进行系统调用。
Windows通过Windows API (WinAPI)来进行内核级别的操作,而WinAPI则是在内部调用Native API (NtAPI)来实现。WinAPI由Kernel32.dll提供,而NtAPI由ntdll.dll提供,但Windows并没有完整披露NtAPI的文档。
这两个DLL非常重要,几乎每个进程都使用该DLL。这两个DLL仅在内存中加载一次,如果需要使用DLL其中的函数,操作系统将把DLL的映像文件映射到进程的地址空间中。但DLL映射的基地址在不同的操作系统或计算机也是不同的。
那么如果只是针对特定的操作系统开发Shellcode,可以使用硬编码的办法,因为在相同的操作系统中函数地址等内容也是相对固定的,但硬编码更容易被杀毒软件发现,因为部分敏感函数的地址可能已经被杀毒软件标记为危险内容。
我们也可以开发相对通用的Shellcode,那么就需要动态计算我们所需要的函数地址,Shellcode的尺寸也会相对大一些,但是更为灵活,也降低了被杀毒软件发现的可能性。
编写弹出计算器的Shellcode的步骤与流程如下:
- 获取 kernal32.dll 基地址。
- 找到函数导出表。
- 查找 WinExec 函数的地址。
- 设置函数参数。
- 调用函数。
接下来我们将通过Windows 10 x86弹出计算器的实例来说明这个过程。
Find kernal32.dll base address
在具体撰写实现代码前,我们首先要了解到我们应该从哪里寻找 kernal32.dll 的基地址。
TEB ( Thread Environment Block ) 是x86结构下Win32的一种数据结构,它存储了每个线程的运行时信息。32位系统的TEB保存在FS段寄存器中,而64位的TEB则保存在GS段的寄存器中。
TEB其中的一个数据结构指向PEB ( Process Environment Block ) ,PEB字段保存了有关进程的运行时信息。
以下为微软官方文档所展示的TEB结构。
typedef struct _TEB {
PVOID Reserved1[12];
PPEB ProcessEnvironmentBlock;
PVOID Reserved2[399];
BYTE Reserved3[1952];
PVOID TlsSlots[64];
BYTE Reserved4[8];
PVOID Reserved5[26];
PVOID ReservedForOle;
PVOID Reserved6[4];
PVOID TlsExpansionSlots;
} TEB, *PTEB;
我们首先要对于数据结构有些基础认知,PVOID作为指针的数据类型,在32位系统中占4字节,而ProcessEnvironmentBlock
这一成员则是我们需要定位的PEB,如果需要得到它的地址,需要在TEB的基址上偏移4*12个字节即偏移量为0x30。
找到PEB后,我们再来看看微软官方文档中PEB的结构。
typedef struct _PEB {
BYTE Reserved1[2];
BYTE BeingDebugged;
BYTE Reserved2[1];
PVOID Reserved3[2];
PPEB_LDR_DATA Ldr;
PRTL_USER_PROCESS_PARAMETERS ProcessParameters;
PVOID Reserved4[3];
PVOID AtlThunkSListPtr;
PVOID Reserved5;
ULONG Reserved6;
PVOID Reserved7;
ULONG Reserved8;
ULONG AtlThunkSListPtr32;
PVOID Reserved9[45];
BYTE Reserved10[96];
PPS_POST_PROCESS_INIT_ROUTINE PostProcessInitRoutine;
BYTE Reserved11[128];
PVOID Reserved12[1];
ULONG SessionId;
} PEB, *PPEB;
在上面的结构当中,对我们有用的是LDR
这一成员,它指向的是PPEB_LDR_DATA
这一数据结构,其保存着 kernal32.dll 和 ntdll.dll 的基地址,再次计算偏移,偏移量为0x0C。
我们再来看看微软官方文档中PEB_LDR_DATA
的结构。
typedef struct _PEB_LDR_DATA {
BYTE Reserved1[8];
PVOID Reserved2[3];
LIST_ENTRY InMemoryOrderModuleList;
} PEB_LDR_DATA, *PPEB_LDR_DATA;
此处对我们有价值的是InMemoryOrderModuleList
这一成员,再次计算偏移量为0x14。
InMemoryOrderModuleList
数据结构如下,其中保存了一个双向循环链表,其中的内容都是指向LDR_DATA_TABLE_ENTRY
。
typedef struct _LIST_ENTRY {
struct _LIST_ENTRY *Flink;
struct _LIST_ENTRY *Blink;
} LIST_ENTRY, *PLIST_ENTRY, *RESTRICTED_POINTER PRLIST_ENTRY;
LDR_DATA_TABLE_ENTRY
结构定义如下:
typedef struct _LDR_DATA_TABLE_ENTRY {
PVOID Reserved1[2];
LIST_ENTRY InMemoryOrderLinks;
PVOID Reserved2[2];
PVOID DllBase;
PVOID EntryPoint;
PVOID Reserved3;
UNICODE_STRING FullDllName;
BYTE Reserved4[8];
PVOID Reserved5[3];
union {
ULONG CheckSum;
PVOID Reserved6;
};
ULONG TimeDateStamp;
} LDR_DATA_TABLE_ENTRY, *PLDR_DATA_TABLE_ENTRY;
需要注意的是,当我们从PEB_LDR_DATA
偏移0x14的地址进入InMemoryOrderModuleList
时,我们就已经进入了LDR_DATA_TABLE_ENTRY
中,在其基址偏移了0x08,是InMemoryOrderLinks
成员,InMemoryOrderLinks
同样也保存了两个指针,我们可以使用其中的Flink
导航到下一个加载的模块的LDR_DATA_TABLE_ENTRY
结构体中。
那么我们距离需要的DllBase偏移量为0x10。
我们还需要知道,Windows内存中加载模块的顺序一般为:
- 当前应用
- ntdll.dll
- kernal32.dll
所以,我们应该获取第三个LDR_DATA_TABLE_ENTRY
中的DllBase。
那么这一切如何实现呢?我们使用汇编代码进行实验,其核心汇编如下。
mov ebx, fs:0x30; // 找到指向PEB的指针
mov ebx, [ebx + 0x0C]; // 将EBX赋值为PPEB_LDR_DATA的地址
mov ebx, [ebx + 0x14]; // 将EBX赋值为LDR_DATA_TABLE_ENTRY结构体中的地址
mov ebx, [ebx]; // 一次引用,进入第二个模块的LDR_DATA_TABLE_ENTRY结构体
mov ebx, [ebx]; // 二次引用,进入第三个模块的LDR_DATA_TABLE_ENTRY结构体
mov ebx, [ebx + 0x10]; // 得到DLL基地址
具体我们使用C++中的内联汇编进行演示。
#include <iostream>
int main() {
uintptr_t result;
__asm{
mov ebx, fs:0x30
mov ebx, [ebx + 0x0C]
mov ebx, [ebx + 0x14]
mov ebx, [ebx]
mov ebx, [ebx]
mov ebx, [ebx + 0x10]
mov result, ebx
};
std::cout << "NTDLL.address Result: 0x" << std::hex << result << std::endl;
}
Dynamic debugging
我们使用Win32架构编写Shellcode,我们所使用的平台是WinDBG+Win10 x86进行调试,编译平台为VS 2022。
首先运行程序,我们得到Kernal32.dll的地址为0x75d20000。
我们可以进行手动调试,来探究其实际的运行。我们首先通过!teb
扩展指令,获得TEB的基地址,我们得到了TEB的基地址为0x009d4000
,我们还获得了PEB的基地址(这是PEB的实际地址而不是指向PEB的指针)。
我们可以尝试读取该地址来解析TEB的数据结构。
也可以直接获取PEB的基址。
然后我们可以通过PEB的基址对PEB的数据结构进行解析,从而获得了PEB_LDR_DATA的基地址。
我们获得了PEB_LDR_DATA的基地址,根据PEB_LDR_DATA的数据结构,我们对其进行0x14的偏移,偏移后的地址即为第一个LDR_DATA_TABLE_ENTRY的0x08偏移处。
通过上面的操作,成功证明了前文的结论,那么,理论上说,第三个LDR_DATA_TABLE_ENTRY结构体其中的DLLBase就是我们所需要的Kernal32.dll的基地址。
那么只需要通过其InInitializationOrderLinks中的Flink地址即可跳转到下一个LDR_DATA_TABLE_ENTRY。
以上就是手动调试的完整过程。我们还可以通过Sysinternals套件中的listdlls.exe来测试正确性。
Find WinExec address
我们知道Windows下可执行文件的格式为PE(Portable Executable),DLL亦使用此格式,此时我们需要知道当DLL被加载入内存中的时候我们所需要使用的函数的地址,那么我们首先要找到DLL的函数导出表,函数导出表保存了DLL中提供使用的函数地址。
我们首先了解DOS头格式,以下为DOS头的结构。
typedef struct _IMAGE_DOS_HEADER {
WORD e_magic
WORD e_cblp
...
WORD e_res2[10]
DWORD e_lfanew
} IMAGE_DOS_HEADER, *PIMAGE_DOS_HEADER;
值得我们注意的是e_ifanew
成员,该成员的偏移为0x3C,它指向PE头。
我们还需要了解PE头格式,以下为PE头的结构。
typedef struct _IMAGE_NT_HEADERS {
DWORD Signature;
IMAGE_FILE_HEADER FileHeader;
IMAGE_OPTIONAL_HEADER32 OptionalHeader;
} IMAGE_NT_HEADERS32, *PIMAGE_NT_HEADERS32;
对我们有用的是IMAGE_OPTIONAL_HEADER32
结构体,它的偏移为0x18,以下为该结构体的部分结构。
typedef struct _IMAGE_OPTIONAL_HEADER {
WORD Magic;
BYTE MajorLinkerVersion;
BYTE MinorLinkerVersion;
DWORD SizeOfCode;
DWORD SizeOfInitializedData;
DWORD SizeOfUninitializedData;
DWORD AddressOfEntryPoint;
...
DWORD ImageBase;
DWORD SectionAlignment;
...
IMAGE_DATA_DIRECTORY DataDirectory[IMAGE_NUMBEROF_DIRECTORY_ENTRIES];
} IMAGE_OPTIONAL_HEADER32, *PIMAGE_OPTIONAL_HEADER32;
DataDirectory
是我们需要关注的对象。它包含了导入、导出函数的信息,指向IMAGE_DATA_DIRECTORY
结构体,它的偏移是0x60。以下为IMAGE_DATA_DIRECTORY
的数据结构。
typedef struct _IMAGE_DATA_DIRECTORY {
DWORD VirtualAddress;
DWORD Size;
} IMAGE_DATA_DIRECTORY, *PIMAGE_DATA_DIRECTORY;
其中的VirtualAddress
指向函数导出表的开头,那么此时我们就找到了函数导出表的RAV地址,它指向IMAGE_EXPORT_DIRECTORY
。该结构体微软官方并未给出结构,但是我们可以在网络上找到如下的结构。
typedef struct _IMAGE_EXPORT_DIRECTORY {
DWORD Characteristics;
DWORD TimeDateStamp;
WORD MajorVersion;
WORD MinorVersion;
DWORD Name;
DWORD Base;
DWORD NumberOfFunctions;
DWORD NumberOfNames;
DWORD AddressOfFunctions;
DWORD AddressOfNames;
DWORD AddressOfNameOrdinals;
} IMAGE_EXPORT_DIRECTORY, *PIMAGE_EXPORT_DIRECTORY;
其中AddressOfFunctions
、AddressOfNames
、AddressOfNameOrdinals
,这三个结构体是我们需要关注的。
- AddressOfFunctions
- 指向保存函数RAV地址的数组的指针。
- 指针大小为四字节。
- AddressOfNames
- 指向保存函数名称的数组的指针。
- AddressOfNameOrdinals
- 指向一个整数数组,代表函数在
AddressOfFunctions
的偏移量。 - 需要注意的是,该数组保存的整数为双字节整数。
- 指向一个整数数组,代表函数在
如果只通过文字进行理解,比较抽象,我们可以通过下面的图进行理解(图片相对较大,建议额外打开查看)。
那么我们有了这三个结构体,我们就可以通过它们来找到我们所需要的函数的地址。
- 遍历保存函数名称的数组,找到指定函数,并计数。
- 通过计数从整数数组中到得到函数偏移量。
- 通过函数偏移量得到函数RAV地址,通过与DLL基地址相加获得函数实际地址。
接上文代码,我们已经在edx中存储了kernal32.dll的基地址。
mov eax, [ebx + 0x3C]; // 获得指向PE头的RAV地址
add eax, ebx; // 计算RAV地址
mov eax, [ebx + 0x78]; // 获得kernal32.dll导出表RAV地址
add eax, ebx; // 计算RAV地址
mov esi, [eax + 0x20]; // 获得函数名称数组的RAV地址
add esi, ebx; // 计算RAV地址
xor ecx, ecx // 清零ecx寄存器,用作计数
_find_get_function_name:
inc ecx // 开始计数
lodsd // 指令约为mov eax, [esi], esi=esi+4
cmp dword ptr[eax], 0x456E6957 // 将eax地址对应的函数名与指定值进行对比
jnz _find_get_function_name // 若不相同,则重新调用函数
cmp dword ptr[eax + 0x04], 0x00636578 // 对比后续的内容
jnz _find_get_function_name
dec ecx // 将计数器减一位,因为整数数组从0开始计数
mov esi, [eax + 0x24] // 获得整数数组的RAV地址
add esi, edx // 计算RAV地址
mov cx, [esi + ecx * 2] // 将CX寄存器保存为偏移量
mov esi, [edx + 0x1C] // 获得保存函数RAV地址的数组
add esi, ebx // 计算RAV地址
mov edx, [esi + ecx * 4] // 获得WinExec函数的RAV地址
add edx, ebx // 计算RAV地址
通过上述代码的运行,edx寄存器此时就保存了WinExec函数的实际地址,那么我们现在要做的就是调用它。
在对比函数名称时,我们使用的是小端序的16进制的ASCII码。
Call WinExec function
Windows下的函数调用方式和Linux的函数调用栈方式没有太大差异。
mov ebp, esp // 开辟栈帧
push 0 // WinExec参数uCmdShow,是否显示窗口
push 0x6578652E
push 0x636C6163 // WinExec参数lpCmdLine,执行的命令字符串
push esp // 将esp的地址压入栈中
call edx // 调用WinExec函数
那么此时使用C++内联汇编进行运行,那么就能弹出计算器。但是实际上我们需要将汇编代码转换为机器代码然后再放入C++中进行运行。以下为使用nasm编译器进行编译的完整汇编代码(删减和调整了一部分内容)。
编译命令为nasm.exe -f win32 "<path>" -o "<output_path>"
%use masm
xor ecx, ecx
mov ebx, fs:[ecx+0x30]
mov ebx, [ebx + 0x0C]
mov ebx, [ebx + 0x14]
mov ebx, [ebx]
mov ebx, [ebx]
mov ebx, [ebx + 0x10]
mov edx, [ebx + 0x3C]
add edx, ebx
mov edx, [edx + 0x78]
add edx, ebx
mov esi, [edx + 0x20]
add esi, ebx
_find_get_function_name:
inc ecx
lodsd
add eax, ebx
cmp dword ptr[eax], 0x456E6957
jnz _find_get_function_name
dec cx
mov esi, [edx + 0x24]
add esi, ebx
mov cx, [esi + ecx * 2]
mov esi, [edx + 0x1C]
add esi, ebx
mov edx, [esi + ecx * 4]
add edx, ebx
mov ebp, esp
push 0
push 0x6578652E
push 0x636C6163
push esp
call edx
获得了编译后的内容机器代码后,我们可以使用objdump
将其中的机器提取出,然后放入C++中包装。
随后我们进行编译即可。(需要注意的是,使用VS进行编译需要修改编译选项,禁用安全检查以及关闭DEP。)
At last
此Shellcode只实现了基本功能,部分功能可以开发的更通用,且存在一部分小问题,例如在运行Shellcode时会出现黑色窗口(一段时间后会被系统回收),这可能是因为没有调用ExitProcess函数进行正常退出,我们将在后面的例子中进一步完善和开发具有更多功能的Shellcode。
经过测试,Win10 x86与Win11 x64均可以运行该Shellcode。