前言
在构造一个 Shellcode 载荷时总是存在多种方法,特别是对于 Windows 平台来说。需要手工编写所有的汇编代码,或者说编译器能够有所帮助吗?需要直接使用syscall,还是需要在内存中搜索函数?因为构造载荷一般来说不会很简单,所以我决定写一篇文章来专门论述相关问题。我习惯于用 C 语言来完成所有的工作,并使用 Visual
Studio 来对其进行编译:因为 C 语言的源代码更加优美,编译器能够更好地对其进行优化,并且如果需要的话可以使用 LLVM 框架实现自己的混淆器。
在本例中,我将针对于 x86 架构下的 Shellcode 代码;当然,相关的分析完全可以应用于 x86(64位)架构下的 Shellcode 代码或者别的处理器。
寻找基本DLL
简介
当一个 Shellcode 载荷加载到 Windows 系统中的时候,第一步就是要定位需要使用的函数,即搜索存储函数的动态链接库(DLL)。为此,我们需要用到以下各小节所描述的不同的结构。
线程环境块
Windows 系统使用TEB结构来描述一个线程,每个线程通过使用 FS(x86平台)或GS(x86,64 位平台)寄存器来访问其自身的 TEB 结构。TEB结构具体如下:
因此,若想要访问 PEB 结构,只需要进行如下操作:
PEB* getPeb() {
__asm {
mov eax, fs:[0x30];
}
}
进程环境块
如果说 TEB 结构给出了一个线程的相关信息,那么 PEB 结构将告诉我们关于进程自身的信息,其中我们所需要的信息是基本 DLL 的位置。实际上,在 Windows 系统加载一个进程到内存中的时候,至少要映射两个 DLL:
在不同版本的 Windows 系统下,其他的 DLL 可能已经存在于内存中,但是是完全可移植的;因此,我们假设以上两个 DLL 是唯一加载的 DLL 模块。
让我们看一下 PEB 结构,如下所示:
可以看到,其中一个成员名为 PEB.BeingDebugged,可被 IsDebuggerPresent() 函数使用;而我们感兴趣的部分是成员 PEB.Ldr,其对应于如下结构:
0:000> dt nt!_PEB_LDR_DATA
+0x000 Length : Uint4B
+0x004 Initialized : UChar
+0x008 SsHandle : Ptr32 Void
+0x00c InLoadOrderModuleList : _LIST_ENTRY
+0x014 InMemoryOrderModuleList : _LIST_ENTRY
+0x01c InInitializationOrderModuleList : _LIST_ENTRY
+0x024 EntryInProgress : Ptr32 Void
+0x028 ShutdownInProgress : UChar
+0x02c ShutdownThreadId : Ptr32 Void
顾名思义,成员 PEB.Ldr->In*OrderModuleList 是包含所有已加载到内存中的 DLL 模块的链表(LIST_ENTRY);三个表以不同的顺序指向相同的对象。我更倾向于使用InLoadOrderModuleList,因为可以像使用一个指向 _LDR_DATA_TABLE_ENTRY 的指针一样直接使用 InLoadOrderModuleList.Flink。例如,如果使用InMemoryOrderModuleList,由于 InMemoryOrderModuleList.Flink 指针指向下一个InMemoryOrderModuleList,所以LDR_DATA_TABLE_ENTRY 将位于(_InMemoryOrderModuleList.Flink–0x10)的位置。每个链表成员具有如下结构:
0:000> dt nt!_LDR_DATA_TABLE_ENTRY
+0x000 InLoadOrderLinks : _LIST_ENTRY
+0x008 InMemoryOrderLinks : _LIST_ENTRY
+0x010 InInitializationOrderLinks : _LIST_ENTRY
+0x018 DllBase : Ptr32 Void
+0x01c EntryPoint : Ptr32 Void
+0x020 SizeOfImage : Uint4B
+0x024 FullDllName : _UNICODE_STRING
+0x02c BaseDllName : _UNICODE_STRING
...
+0x0a0 DependentLoadFlags : Uint4B
BaseDllName 包含 DLL 模块的名称(比如,ntdll.dll),而 DllBase 包含 DLL 模块所加载到内存中的地址。通常,InLoadOrderModuleList 中的第一个成员就是可执行程序自身,在之后我们能够找到 NTDLL 和 KERNEL32;然而,我们并不确定在所有版本的 Windows 系统中都是这个次序,所以最好根据 DLL 名称(大写/小写)进行搜索。
DJB散列
如前所述,我们并不信任 DLL 顺序而选择根据 DLL 名称进行搜索。然而在一段 Shellcode 代码中,使用 ASCII 字符串(或更糟的,UNICODE 字符串)并不是一个好主意:这将使得我们的 Shellcode 代码过于臃肿!因此我建议,使用散列机制来比较 DLL 名称。由于其简洁有效性我选择使用DJB散列算法,具体代码如下所示:
DWORD djbHashW(wchar_t* str) {
unsigned int hash = 5381;
unsigned int i = 0;
for (i = 0; str[i] != 0; i++) {
hash = ((hash <
}
return hash;
}
由于 DLL 名称可能大写也可能小写,因此在散列算法中最好满足如下等式关系:
djbHashW(L"ntdll.dll") == djbHashW(L"NTDLL.DLL")
代码
现在我们已经讨论了如何完成以上工作,是时候来编码实现我们的想法了。具体代码如下所示:
如果想要使用其他的 DLL 模块,只需要使用 LoadLibrary() 函数来将其加载。不要着急,我们将在 user32.dll 相关的 Shellcode 代码中进行该项工作。
函数地址
简介
现在我们已经找到 DLL,下一步需要在 DLL 内存空间中搜寻所需的函数在哪儿。幸运的是,透彻理解 PE 文件头部结构的前提下这并不复杂。要牢记的是,当我们讨论 PE 文件头部时,提到的大部分地址都与可执行程序地址相关。
可移植的执行体头部
执行体的开始位置是一个 DOS 头部,但它只是为了兼容(DOS 系统)而存在。具体结构如下所示:
0:000> dt nt!_IMAGE_DOS_HEADER
+0x000 e_magic : Uint2B
+0x002 e_cblp : Uint2B
+0x004 e_cp : Uint2B
+0x006 e_crlc : Uint2B
+0x008 e_cparhdr : Uint2B
+0x00a e_minalloc : Uint2B
+0x00c e_maxalloc : Uint2B
+0x00e e_ss : Uint2B
+0x010 e_sp : Uint2B
+0x012 e_csum : Uint2B
+0x014 e_ip : Uint2B
+0x016 e_cs : Uint2B
+0x018 e_lfarlc : Uint2B
+0x01a e_ovno : Uint2B
+0x01c e_res : [4] Uint2B
+0x024 e_oemid : Uint2B
+0x026 e_oeminfo : Uint2B
+0x028 e_res2 : [10] Uint2B
+0x03c e_lfanew : Int4B
成员 e_lfanew 将指示 NT 头部的位置。由于这是一个相对地址,所以需要进行 pFile+e_lfanew 操作。NT 头部具体结构如下所示:
0:000> dt -r1 nt!_IMAGE_NT_HEADERS
+0x000 Signature : Uint4B
+0x004 FileHeader : _IMAGE_FILE_HEADER
+0x000 Machine : Uint2B
+0x002 NumberOfSections : Uint2B
+0x004 TimeDateStamp : Uint4B
+0x008 PointerToSymbolTable : Uint4B
+0x00c NumberOfSymbols : Uint4B
+0x010 SizeOfOptionalHeader : Uint2B
+0x012 Characteristics : Uint2B
+0x018 OptionalHeader : _IMAGE_OPTIONAL_HEADER
+0x000 Magic : Uint2B
+0x002 MajorLinkerVersion : UChar
+0x003 MinorLinkerVersion : UChar
+0x004 SizeOfCode : Uint4B
+0x008 SizeOfInitializedData : Uint4B
+0x00c SizeOfUninitializedData : Uint4B
+0x010 AddressOfEntryPoint : Uint4B
+0x014 BaseOfCode : Uint4B
+0x018 BaseOfData : Uint4B
+0x01c ImageBase : Uint4B
+0x020 SectionAlignment : Uint4B
+0x024 FileAlignment : Uint4B
+0x028 MajorOperatingSystemVersion : Uint2B
+0x02a MinorOperatingSystemVersion : Uint2B
+0x02c MajorImageVersion : Uint2B
+0x02e MinorImageVersion : Uint2B
+0x030 MajorSubsystemVersion : Uint2B
+0x032 MinorSubsystemVersion : Uint2B
+0x034 Win32VersionValue : Uint4B
+0x038 SizeOfImage : Uint4B
+0x03c SizeOfHeaders : Uint4B
+0x040 CheckSum : Uint4B
+0x044 Subsystem : Uint2B
+0x046 DllCharacteristics : Uint2B
+0x048 SizeOfStackReserve : Uint4B
+0x04c SizeOfStackCommit : Uint4B
+0x050 SizeOfHeapReserve : Uint4B
+0x054 SizeOfHeapCommit : Uint4B
+0x058 LoaderFlags : Uint4B
+0x05c NumberOfRvaAndSizes : Uint4B
+0x060 DataDirectory : [16] _IMAGE_DATA_DIRECTORY
数据目录中包含了一些有趣成员的地址;而对我们来说,它包含了所有导出函数的地址。
0:000> dt nt!_IMAGE_DATA_DIRECTORY
+0x000 VirtualAddress : Uint4B
+0x004 Size : Uint4B
因此,我们可以使用DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress 来直接获取导出目录的地址:
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;
可以通过名字或者通过序号来导出一个函数;因此,以下三个数组保持更新:
因此,如果想要根据名称来查找函数地址,我们需要浏览 AddressOfNames 数组中的所有名称,并将数组索引用于 AddressOfNameOrdinals[index];而这将返回一个序号用于 AddressOfFunctions[ordinal]。整个过程的伪代码如下所示:
int i = 0;
while (AddressOfNames[i] != searchedName) {
i++;
}
return AddressOfFunctions[ AddressOfNamesOrdinals[i] ];
代码
正如搜索 DLL 时所做的那样,我们将使用 DJB 散列算法(不过这次用 ASCII 字符串):
PVOID getFunctionAddr(DWORD dwModule, DWORD functionHash) {
PIMAGE_DOS_HEADER dosHeader = (PIMAGE_DOS_HEADER)dwModule;
PIMAGE_NT_HEADERS ntHeaders = (PIMAGE_NT_HEADERS)((DWORD)dosHeader + dosHeader->e_lfanew);
PIMAGE_DATA_DIRECTORY dataDirectory = &ntHeaders->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT];
if (dataDirectory->VirtualAddress == 0) {
return NULL;
}
PIMAGE_EXPORT_DIRECTORY exportDirectory = (PIMAGE_EXPORT_DIRECTORY)(dwModule + dataDirectory->VirtualAddress);
PDWORD ardwNames = (PDWORD)(dwModule + exportDirectory->AddressOfNames);
PWORD arwNameOrdinals = (PWORD)(dwModule + exportDirectory->AddressOfNameOrdinals);
PDWORD ardwAddressFunctions = (PDWORD)(dwModule + exportDirectory->AddressOfFunctions);
char* szName = 0;
WORD wOrdinal = 0;
for (unsigned int i = 0; i NumberOfNames; i++) {
szName = (char*)(dwModule + ardwNames[i]);
if (djbHash(szName) == functionHash) {
wOrdinal = arwNameOrdinals[i];
return (PVOID)(dwModule + ardwAddressFunctions[wOrdinal]);
}
}
return NULL;
}
编译
最终代码
我们已经讨论了所用的重要结构和算法,下面看看如何生成 Shellcode 代码。
#pragma comment(linker, "/ENTRY:main")
#include "makestr.h"
#include "peb.h"
typedef HMODULE (WINAPI* _LoadLibraryA)(LPCSTR lpFileName);
typedef int (WINAPI* _MessageBoxA)(HWND hWnd, LPCSTR lpText, LPCSTR lpCaption, UINT uType);
int main();
DWORD getDllByName(DWORD dllHash);
PVOID getFunctionAddr(DWORD dwModule, DWORD functionHash);
DWORD djbHash(char* str);
DWORD djbHashW(wchar_t* str);
int main() {
DWORD hashKernel32 = 0x6DDB9555; // djbHashW(L"KERNEL32.DLL");
DWORD hKernel32 = getDllByName(hashKernel32);
if (hKernel32 == 0) {
return 1;
}
DWORD hashLoadLibraryA = 0x5FBFF0FB; // djbHash("LoadLibraryA");
_LoadLibraryA xLoadLibraryA = getFunctionAddr(hKernel32, hashLoadLibraryA);
if (xLoadLibraryA == NULL) {
return 1;
}
char szUser32[] = MAKESTR("user32.dll", 10);
DWORD hUser32 = xLoadLibraryA(szUser32);
if (hUser32 == 0) {
return 1;
}
DWORD hashMessageBoxA = 0x384F14B4; // djbHash("MessageBoxA");
_MessageBoxA xMessageBoxA = getFunctionAddr(hUser32, hashMessageBoxA);
if (xMessageBoxA == NULL) {
return 1;
}
char szMessage[] = MAKESTR("Hello World", 11);
char szTitle[] = MAKESTR(":)", 2);
xMessageBoxA(0, szMessage, szTitle, MB_OK|MB_ICONINFORMATION);
return 0;
}
inline PEB* getPeb() {
__asm {
mov eax, fs:[0x30];
}
}
DWORD djbHash(char* str) {
unsigned int hash = 5381;
unsigned int i = 0;
for (i = 0; str[i] != 0; i++) {
hash = ((hash <
}
return hash;
}
DWORD djbHashW(wchar_t* str) {
unsigned int hash = 5381;
unsigned int i = 0;
for (i = 0; str[i] != 0; i++) {
hash = ((hash <
}
return hash;
}
DWORD getDllByName(DWORD dllHash) {
PEB* peb = getPeb();
PPEB_LDR_DATA Ldr = peb->Ldr;
PLDR_DATA_TABLE_ENTRY moduleList = (PLDR_DATA_TABLE_ENTRY)Ldr->InLoadOrderModuleList.Flink;
wchar_t* pBaseDllName = moduleList->BaseDllName.Buffer;
wchar_t* pFirstDllName = moduleList->BaseDllName.Buffer;
do {
if (pBaseDllName != NULL) {
if (djbHashW(pBaseDllName) == dllHash) {
return (DWORD)moduleList->BaseAddress;
}
}
moduleList = (PLDR_DATA_TABLE_ENTRY)moduleList->InLoadOrderModuleList.Flink;
pBaseDllName = moduleList->BaseDllName.Buffer;
} while (pBaseDllName != pFirstDllName);
return 0;
}
PVOID getFunctionAddr(DWORD dwModule, DWORD functionHash) {
PIMAGE_DOS_HEADER dosHeader = (PIMAGE_DOS_HEADER)dwModule;
PIMAGE_NT_HEADERS ntHeaders = (PIMAGE_NT_HEADERS)((DWORD)dosHeader + dosHeader->e_lfanew);
PIMAGE_DATA_DIRECTORY dataDirectory = &ntHeaders->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT];
if (dataDirectory->VirtualAddress == 0) {
return NULL;
}
PIMAGE_EXPORT_DIRECTORY exportDirectory = (PIMAGE_EXPORT_DIRECTORY)(dwModule + dataDirectory->VirtualAddress);
PDWORD ardwNames = (PDWORD)(dwModule + exportDirectory->AddressOfNames);
PWORD arwNameOrdinals = (PWORD)(dwModule + exportDirectory->AddressOfNameOrdinals);
PDWORD ardwAddressFunctions = (PDWORD)(dwModule + exportDirectory->AddressOfFunctions);
char* szName = 0;
WORD wOrdinal = 0;
for (unsigned int i = 0; i NumberOfNames; i++) {
szName = (char*)(dwModule + ardwNames[i]);
if (djbHash(szName) == functionHash) {
wOrdinal = arwNameOrdinals[i];
return (PVOID)(dwModule + ardwAddressFunctions[wOrdinal]);
}
}
return NULL;
}
函数声明的次序非常重要:它将定义编译次序。因此,如果我们想要直接调用Shellcode 代码,最好首先声明我们的main函数,因为它将是入口点。我不知道这是Visual Studio 的特性,还是所有的编译器都这样处理。
ASCII 字符串使用宏定义 MAKESTR 像数组一样来声明字符串,该宏将字符串强制按照如下格式进行分配:
mov dword ptr [ebp+szUser32], 72657375h ; user
mov dword ptr [ebp+szUser32+4], 642E3233h ; 32.d
mov word ptr [ebp+szUser32+8], 6C6Ch ; ll
mov [ebp+szUser32+0Ah], 0 ; '\x00'
以上代码由 Python 脚本生成,因为它是冗余的:
#pragma once
#define MAKESTR(s, length) MAKESTR_##length(s)
/*
for i in range(1,51):
s = "#define MAKESTR_%d(s) {" % i
for j in range(i):
s += "s[%d]," % j
s += "0}"
print(s)
*/
#define MAKESTR_1(s) {s[0],0}
#define MAKESTR_2(s) {s[0],s[1],0}
#define MAKESTR_3(s) {s[0],s[1],s[2],0}
#define MAKESTR_4(s) {s[0],s[1],s[2],s[3],0}
#define MAKESTR_5(s) {s[0],s[1],s[2],s[3],s[4],0}
#define MAKESTR_6(s) {s[0],s[1],s[2],s[3],s[4],s[5],0}
#define MAKESTR_7(s) {s[0],s[1],s[2],s[3],s[4],s[5],s[6],0}
#define MAKESTR_8(s) {s[0],s[1],s[2],s[3],s[4],s[5],s[6],s[7],0}
#define MAKESTR_9(s) {s[0],s[1],s[2],s[3],s[4],s[5],s[6],s[7],s[8],0}
#define MAKESTR_10(s) {s[0],s[1],s[2],s[3],s[4],s[5],s[6],s[7],s[8],s[9],0}
#define MAKESTR_11(s) {s[0],s[1],s[2],s[3],s[4],s[5],s[6],s[7],s[8],s[9],s[10],0}
编译配置
我所用的是 Visual Studio 2017,但我认为在其他版本中选项也是相同的:
C / C++
Optimisation
Reduce the size /O1
Smaller code /Os
Code generation
Disable security verifications /GS-
Linker
Entries
Ignore all the defaults libraries /NODEFAULTLIB
最终得到一个 3KB 大小的文件;一个简单的反汇编器即可从文件中提取 Shellcode 代码。
Shellcode 代码
Shellcode 代码有 339 字节大小,但其中最大的部分是函数加载和 DLL 模块搜索。因此,即使对于一个更大的程序,Shellcode 代码也不会增大很多。我们的 Shellcode 代码是非常简单的,因为它仅仅弹出一个消息框,但你可以很容易地将其改造成比如一个下载器之类的程序。
本文由 看雪翻译小组 木无聊偶 编译,来源Dimitri Fourny
戳👇 图片加入看雪翻译小组哦!
往期热门内容推荐
更多优秀文章,长按下方二维码,“关注看雪学院公众号”查看!
看雪论坛:http://bbs.pediy.com/
微信公众号 ID:ikanxue
微博:看雪安全
投稿、合作:www.kanxue.com