编写shellcode
# 2 编写shellcode
# 2.1 分析编译器生成的二进制数据
要将恶意代码转化为shellcode,可直接使用汇编语言(assembly)编写源代码。但如果程序规模较大,这项工作很快会变得冗长且枯燥。后续我们将摒弃这种方案,转而考虑使用C语言编写源代码。
第一个思路可能是:先用C语言编写程序,对其进行编译(compile),从可执行文件(executable)中提取二进制数据(binary data),再将这些数据组合成shellcode。
我们以一个简单的测试程序(后文称为“simpletest”)为例,该程序仅用于打印消息(print messages)并显示文件“test.txt”的内容。
以下是部分代码片段:
文件 user.h
#define SIZE_USERNAME 32
#define SIZE_PASSWORD 32
typedef struct _USER {
CHAR szUsername[SIZE_USERNAME];
CHAR szPassword[SIZE_PASSWORD];
} USER, *PUSER;
2
3
4
5
6
7
文件 display.cpp
CHAR g_szMessage[] = "This is a message stored as a global variable";
VOID DisplayMessage(IN CHAR * szMessage)
{
PrintMsg(LOG_LEVEL_TRACE, ">>> %s <<<", szMessage);
}
BOOL DisplayFile(IN CHAR * szFilePath)
{
...
CreateFile(szFilePath, ...);
pData = (UCHAR *)HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, dwFileSize + 1);
ReadFile(hFile, pData, ...);
PrintMsg(LOG_LEVEL_TRACE, "File successfully read: %s", pData);
...
}
BOOL DisplayData(VOID)
{
DisplayMessage(g_szMessage);
PrintMsg(LOG_LEVEL_TRACE, "Username: %s", g_User.szUsername);
PrintMsg(LOG_LEVEL_TRACE, "Password: %s", g_User.szPassword);
if (DisplayFile("test.txt") == FALSE)
return FALSE;
return TRUE;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
文件 main.cpp
USER g_User = {"jmerchat", "password"};
BOOL DisplayData(VOID);
int main(int argc, char * argv[])
{
DisplayData();
return 0;
}
2
3
4
5
6
7
8
9
文件 print_msg.cpp
VOID PrintMsg(IN UINT uiMessageLevel, IN const CHAR * fmt, ...)
{
CHAR szBuffer[SIZE_OF_LOCAL_LOG_BUFFER + 1];
UINT i = 0;
if (uiMessageLevel == LOG_LEVEL_ERROR)
i += _snprintf(&szBuffer[i], SIZE_OF_LOCAL_LOG_BUFFER - i, "[ERROR] : ");
else if (uiMessageLevel == LOG_LEVEL_WARNG)
...
va_list ap;
va_start(ap, fmt);
i += _vsnprintf(&szBuffer[i], SIZE_OF_LOCAL_LOG_BUFFER - i, fmt, ap);
va_end(ap);
printf("[%4d] %s\n", GetCurrentThreadId(), szBuffer);
fflush(stdout);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
该程序包含以下内容:
- 两个全局变量(global variable):
- 全局变量“g_User”,在main.cpp中声明并初始化(initialized),“User”是在“user.h”中定义的“USER”结构体(structure)类型;
- 全局字符串(global string)“g_szMessage”;
- 五个内部函数(internal function):
- “DisplayMessage”:用于显示“g_szMessage”;
- “DisplayFile”:尝试打开文件“test.txt”并显示其内容;
- “DisplayData”:实际执行所有操作的函数;
- “main”:程序入口点(entry point),仅调用“DisplayData”;
- “PrintMsg”:用于显示日志消息(log message);
- 多个字符串(string);
- 多次调用导入函数(imported function):创建文件函数(CreateFile)、堆分配函数(HeapAlloc)、字符串格式化函数(_snprintf)等。
这个程序实用性不强,但它汇集了C程序中可能出现的各种元素:全局数据(global data)、字符串、带参数或不带参数的内部函数、导入函数等。
现在,我们将编译该程序,并分析生成的二进制数据:
函数 DisplayMessage 的调用(Call of the function DisplayMessage)
DisplayMessage(g_szMessage);
00413119 68 00304300 PUSH 433000 ; ASCII "This is a message ... "
0041311E E8 E8E7FFFF CALL 0041190B ; call jmp on DisplayMessage
00413123 83C4 04 ADD ESP,4
2
3
4
函数 DisplayFile 的调用(Call of the function DisplayFile)
if (DisplayFile("test.txt") == FALSE)
00413152 68 CCFF4200 PUSH 42FFCC ; ASCII "test.txt"
00413157 E8 26E4FFFF CALL 00411582 ; call DisplayFile
2
3
函数 CreateFile 的调用(Call of the function CreateFile)
CreateFile(szFilePath, ...)
...
00412FD2 50 PUSH EAX
00412FD3 FF15 14724300 CALL DWORD PTR DS:[437214] ; call kernel32.CreateFileA
2
3
4
有几个因素导致我们无法直接使用这段二进制代码创建shellcode:
- 存在大量硬编码地址(hardcoded address):每次引用字符串或全局数据都会生成硬编码地址;
- 内部函数调用是相对的(relative),但指令中包含的值是下一条指令地址与被调用函数入口点(entry point of function)的差值。因此,我们必须保持内部函数之间的相对位置不变。这可能会带来问题,因为如果编译器(compiler)在两个必须包含在shellcode中的函数之间插入了一些无用的函数或数据,我们就必须维持这些函数之间的距离。此外,内部函数调用不会直接跳转到函数入口点,而是跳转到位于第一个节(section)开头的跳转指令(jmp instruction):
函数 DisplayMessage 的调用(Call of the function DisplayMessage)
0041190B E9 50160000 JMP 00412F60 ; call DisplayMessage
不过,编译器(compiler)或许有一个选项可以禁用这种行为。
- 导入函数调用是通过跳转到导入地址表(Import Address Table)中某个条目的值来实现的。这里同样存在硬编码地址。此外,由于Windows使用早期绑定机制(early binding mechanism),所有必需的库(required library)必须已加载(loaded),所有导入函数必须已解析(resolved),且导入地址表必须已填充(filled)。
总而言之,我们发现目前的二进制代码距离可用于生成shellcode的要求还相差甚远。
# 2.2 shellcode生成原理
要获取可用于生成shellcode的二进制代码,我们需要解决两个问题:
找到一种方法,强制编译器生成不包含硬编码地址的代码;
找到一种方法,动态解析(resolve)导入函数。
# 2.2.1 第一种方法:汇编代码修补
第一种解决方案可能是:利用编译器生成汇编代码(assembly),使用转换工具(transformation tool)对其进行修补(patch),最后生成二进制数据。
然而,这种方法存在几个问题:
- 首先,编译器生成的汇编代码包含大量硬编码地址,因此需要对其进行大量修改;
- 其次,开发转换工具需要对汇编语言进行操作,而汇编语言并非一种通用语言;
- 最后,转换工具将与特定的汇编代码绑定,进而与特定的硬件平台(hardware platform)绑定。
因此,我决定不在汇编语言层面进行操作,而是直接对C代码进行处理。
# 2.2.2 第二种方法:使用栈
第二种解决方案是按照特定方式编写C代码,使所有操作都在栈(stack)中进行。
例如,可以在栈中重建字符串:
使用栈存储字符串(Use of the stack to store a string)
CHAR szStrUsername[] = {'U', 's', 'e', 'r', 'n', 'a', 'm', 'e', ':', ' ', '%', 's'};
004130DA C645 F4 55 MOV BYTE PTR SS:[EBP-C],55
004130DE C645 F5 73 MOV BYTE PTR SS:[EBP-B],73
004130E2 C645 F6 65 MOV BYTE PTR SS:[EBP-A],65
004130E6 C645 F7 72 MOV BYTE PTR SS:[EBP-9],72
...
2
3
4
5
6
要调用导入函数,可使用动态地址解析(dynamic address resolution)在本地函数指针(local function pointer)中获取所需函数的地址:
使用动态地址解析调用导入函数(Use of dynamic address resolution to call an imported function)
hLib = LoadLibrary(szStrLibraryName);
pFunc = (FunctionTypeDef)GetProcAddress(hLib, szStrFunctionName);
pFunc(...);
2
3
然而,这种技术存在几个问题:
按照这种方式编写代码很快会变得枯燥乏味;
它无法解决内部函数的调用问题;
如果我们从互联网等渠道获取了一些正常编写的有用代码,就必须重写该代码的大部分内容以适应这种方式。
# 2.2.3 第三种方法:使用全局数据
第三种解决方案是使用一个结构体(structure)来存储所有全局数据,并在每次内部函数调用时传递该结构体。这个结构体(后文称为“GLOBAL_DATA”)将包含:
- 内部函数指针(Pointer on internal functions);
- 导入函数指针(Pointer on imported functions);
- 全局变量(Global variables);
- 字符串(Strings)。
定义好该结构体后,我们对C代码进行修改,使得所有对上述元素的引用都通过该结构体实现。
以函数“DisplayFile”为例:
原始的 DisplayFile 函数(Original function DisplayFile)
BOOL DisplayFile(IN CHAR * szFilePath)
{
...
CreateFile(szFilePath, ...);
pData = (UCHAR *)HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, dwFileSize + 1);
ReadFile(hFile, pData, ...);
PrintMsg(LOG_LEVEL_TRACE, "File successfully read: %s", pData);
...
}
2
3
4
5
6
7
8
9
修改后(修改部分用红色标注):
修补后的 DisplayFile 函数(Patched function DisplayFile)
BOOL DisplayFile(IN PGLOBAL_DATA pGlobalData, IN CHAR * szFilePath)
{
...
pGlobalData->CreateFile(szFilePath, ...);
pData = (UCHAR *)pGlobalData->HeapAlloc(pGlobalData->GetProcessHeap(),
HEAP_ZERO_MEMORY, dwFileSize + 1);
pGlobalData->ReadFile(hFile, pData, ...);
pGlobalData->PrintMsg(pGlobalData, LOG_LEVEL_TRACE, pGlobalData->szString_00000001,
pData);
...
}
2
3
4
5
6
7
8
9
10
11
GLOBAL_DATA 的定义如下:
GLOBAL_DATA 结构体概述(Overview of structure GLOBAL_DATA)
typedef struct _GLOBAL_DATA {
/* Internal functions */
PrintMsgTypeDef fp_PrintMsg;
/* Imported functions */
CreateFileTypeDef fp_CreateFile;
HeapAllocTypeDef fp_HeapAlloc;
GetProcessHeapTypeDef fp_GetProcessHeap;
ReadFileTypeDef fp_ReadFile;
/* Data strings */
CHAR szString_00000001[27];
} GLOBAL_DATA, *PGLOBAL_DATA;
2
3
4
5
6
7
8
9
10
11
12
13
14
通过利用C语言宏(C macro)的强大功能,我们可以大幅减少对源代码的修改量。我们定义以下宏:
宏定义(Definitions of macros)
/* 在内部函数定义中添加 GLOBAL_DATA 参数 */
#define DisplayFileTempDefinition(...) \
DisplayFileDefinition(PGLOBAL_DATA pGlobalData, __VA_ARGS__)
/* 在内部函数调用中添加重定向和 GLOBAL_DATA 参数 */
#define PrintMsg(...) pGlobalData->fp_PrintMsg(pGlobalData, __VA_ARGS__)
#define DisplayFile(...) pGlobalData->fp_DisplayFile(pGlobalData, __VA_ARGS__)
/* 为导入函数添加重定向 */
#define CreateFile pGlobalData->fp_CreateFile
#define HeapAlloc pGlobalData->fp_HeapAlloc
#define GetProcessHeap pGlobalData->fp_GetProcessHeap
#define ReadFile pGlobalData->fp_ReadFile
/* 为字符串添加重定向 */
#define STR_00000001(x) pGlobalData->szString_00000001
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
修改后的代码如下:
使用宏修补后的 DisplayFile 函数(Patched function DisplayFile with the macros)
BOOL DisplayFileTempDefinition(IN CHAR * szFilePath)
{
...
CreateFile(szFilePath, ...);
pData = (UCHAR *)HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, dwFileSize + 1);
ReadFile(hFile, pData, ...);
PrintMsg(LOG_LEVEL_TRACE, STR_00000001("File successfully read: %s"), pData);
...
}
2
3
4
5
6
7
8
9
可以看到,现在需要修改的内容非常少。让我们分析生成的汇编代码:
函数 DisplayMessage 的调用(Call of the function DisplayMessage)
DisplayMessage(g_szMessage);
00412F99 8B45 08 MOV EAX,DWORD PTR SS:[EBP+8] ; 获取 GLOBAL_DATA 的地址
00412F9C 05 58010000 ADD EAX,158 ; 获取 g_szMessage 的地址
00412FA1 50 PUSH EAX ; 压入 g_szMessage 的地址
00412FA2 8B4D 08 MOV ECX,DWORD PTR SS:[EBP+8] ; 获取 pGlobalData 的地址
00412FA5 51 PUSH ECX ; 压入 pGlobalData 的地址
00412FA6 8B55 08 MOV EDX,DWORD PTR SS:[EBP+8] ; 获取 DisplayMessage 的地址
00412FA9 8B82 88000000 MOV EAX,DWORD PTR DS:[EDX+88]
00412FAF FFD0 CALL EAX ; 调用 DisplayMessage
2
3
4
5
6
7
8
9
函数 CreateFile 的调用(Call of the function CreateFile)
CreateFile(szFilePath, ...)
...
00412DE2 8B4D 08 MOV ECX,DWORD PTR SS:[EBP+8] ; 获取 pGlobalData 的地址
00412DE5 8B91 D8000000 MOV EDX,DWORD PTR DS:[ECX+D8] ; 获取 CreateFile 的地址
00412DEB FFD2 CALL EDX ; 调用 CreateFile
2
3
4
5
函数 DisplayFile 的调用(Call of the function DisplayFile)
if (DisplayFile("test.txt") == FALSE)
00412FFC 8B45 08 MOV EAX,DWORD PTR SS:[EBP+8] ; 获取 pGlobalData 的地址
00412FFF 05 A1040000 ADD EAX,4A1 ; 获取字符串的地址
00413004 50 PUSH EAX ; 压入字符串的地址
00413005 8B4D 08 MOV ECX,DWORD PTR SS:[EBP+8] ; 获取 pGlobalData 的地址
00413008 51 PUSH ECX ; 压入 pGlobalData 的地址
00413009 8B55 08 MOV EDX,DWORD PTR SS:[EBP+8]
0041300C 8B42 78 MOV EAX,DWORD PTR DS:[EDX+78] ; 获取 DisplayFile 的地址
0041300F FFD0 CALL EAX ; 调用 DisplayFile
2
3
4
5
6
7
8
9
生成的二进制文件不再包含任何硬编码地址!我们可以提取这段二进制代码,并用它来生成shellcode。
只需将提取的函数拼接起来,并在末尾添加GLOBAL_DATA结构体,即可创建shellcode。图7展示了该结构的概述。

图7:shellcode结构概述(Figure 7: Overview of the structure of the shellcode)
这是第一步;然而,仍然有几个问题需要解决:
- 编写GLOBAL_DATA结构体的定义是一项冗长且枯燥的任务;
- 必须添加一些代码来初始化(initialize)该结构体;
- 必须从编译生成的可执行文件中提取二进制数据,并将其组装(assemble)以创建最终的shellcode。
因此,我决定编写一个工具来自动执行所有这些操作:WiShMaster。