联系不上原作者,侵删
概述
恶意软件作者以及exp开发者经常将一段独立的、位置无关的代码叫做shellcodes
这种类型的代码可以被很简单的注入到任何合适的内存中并立即执行,不需要任何外部的加载器
尽管shellcode为研究人员和恶意软件作者提供了很大的便利,但是构造shellcode非常麻烦
shellcodes必须要遵循一套与编译器输出的格式具有很大差异的准则
这也是为什么大家都直接用汇编语言来写shellcode,就是为了完全控制输出的格式
毋庸置疑的是,使用汇编语言来构造shellcode是最精确的方法,但是与此同时,也很容易出错并且很麻烦
因此就有很多研究者想尽办法来简化这一过程,最大程度的利用C语言编译器而不是手动使用汇编语言来构建
在这份文档中,我将会分享我的相关经验以及我所使用的方法
此文档适用于初学者,在文中我详细介绍了一些围绕shellcode创建的常见技术,在第一部分中,我将会展示一些shellcode需要遵守的基本准则,以及相关方法的原理,然后我会逐步讲解shellcode的创建过程
使用这些方法,我们可以避免手动编写全部的汇编代码,而只需要对生成的汇编代码做一些手动修改即可,我们并没有抛弃手动编写shellcode的优势,只是跳过了繁冗复杂的部分
以前的技术和我攥写本文档的动力
使用C代码来创建shellcode的想法并不新鲜
在Bill Blunden于2012年出版的《The Rootkit Arsenal - Second Edition》一书中,他讲解了自己通过C代码创建shellcode的方法(第十章:使用C语言创建shellcode)
在Matt Graeber (@Mattifestation)的文章Writing Optimized Windows Shellcode in C中也介绍了相似的技术
在上面提到的这两种方法中,shellcode都是直接从C代码创建出来的,整个的理念都是围绕着修改编译器配置来创建出来一个PE文件使得我们能够从该文件中提取出来一段能够独立运行的代码
但是在这些方法中都完全忽略了使用纯汇编代码来创建shellcode的优势,之前的这些方法只能让我们获取到最终生成好的代码,而无法直接控制汇编代码,并且根本没有提供和这些汇编代码交互或者修改的机会
我所探寻的是一种能够将两者结合起来的方法:略过复杂且易出错的汇编代码编写部分,与此同时生成能够让我自由修改的汇编代码,最终用于生成shellcode
Shellcode的通用准则
对于PE格式的文件,我们并不需要关心他是怎么被加载的,Windows Loader会帮你做这些事,但是对于shellcode就不一样了,我们不能依赖Windows Loader和PE格式提供的便利:
- 没有section
- 没有Data Directory (导入表、重定位表等等)
下面是PE文件和shellcode的差异对比表格:
特性 | PE | Shellcode |
---|---|---|
加载 | 通过Windows Loader; 运行EXE会触发进程的创建 |
自定义,简化过的; 必须寄生在已经存在的进程中(比如通过代码注入+进程注入) 或者附加到已经存在的PE文件中(比如病毒) |
构成 | 分为不同的section,拥有特定的访问权限,搭载不同的元素(code、data、resources等) | 全部都在一片内存区域中(读、写、执行) |
根据load base重定位 | 在重定位表中定义,被Windows Loader所使用 | 自定义;位置无关的代码 |
对系统API的访问 | 在导入表中定义,被Windows Loader所使用 | 自定义;通过PEB查找来获取导入;没有导入表或者简化过的 |
位置无关的代码
对于PE文件,根据其在内存中加载的基地址,Windows Loader可以通过重定位表将所有的地址进行偏移,这一过程是在运行时自动完成的
而对shellcodes而言,我们无法利用这个特性,因此只能编写出不需要进行重定位的代码,遵循此准则的代码被称作Position Independent Code (PIC)
通过使用相对地址可以创建出PIC,我们可以使用short jumps、long jumps、调用本地方法,因为这些操作都使用相对地址
不通过导入表调用API
对于PE文件,所有在我们的代码中被调用的API都会被收集到导入表中,导入表是由链接器创建的,然后在运行时对导入表进行解析
对于shellcodes,我们无法访问导入表,因此我们需要自己来处理API的解析
为了获取到在代码中被调用的API,我们需要利用PEB (Process Environment Block ---- 在进程运行时创建出来的系统结构)
一旦我们的shellcode注入到进程中,我们就需要获取到目标的PEB,然后用它来搜索加载到该进程运行地址空间中的DLL
我们需要获取到ntdll.dll或者kernel32.dll来解析剩下的API,每一个进程都会加载ntdll.dll,kernel32.dll会在进程初始化完成后被加载,这两者中的任意一个被我们获取到之后,就能用来加载所有我们需要的DLL文件
获取shellcode的导入API:
- 获取PEB地址
- 通过
PEB->Ldr->InMemoryOrderModuleList
查找到: - kernel32.dll
- 或者ntdll.dll
- 遍历kernel32(或者ntdll)的导出表,查找到下面函数的地址:
- kernel32.LoadLibraryA (该函数最终会调用ntdll.LdrLoadDll)
- kernel32.GetProcAddress (该函数最终会调用ntdll.LdrGetProcedureAddress)
- 使用LoadLibraryA(或者LdrLoadDll)来加载我们需要的DLL
- 使用GetProcAddress(或者LdrGetProcedureAddress)来获取我们需要的函数
获取PEB
PEB可以很方便的通过纯汇编代码来获取,指向PEB的指针是另一个结构体TEB(Thread Environment Block)的一个成员
32位进程使用FS段寄存器指向TEB,64位进程使用GS段寄存器指向TEB
进程位数 | 32bit | 64bit |
---|---|---|
指向TEB的指针 | FS段寄存器 | GS段寄存器 |
PEB在TEB结构体中的偏移量 | 0x30 | 0x60 |
C语言实现如下:
PPEB pen = NULL;
#if defined(_WIN64)
peb = (PPEB)__readgsqword(0x60)
#else
peb = (PPEB)__readfsdword(0x30)
#endif
基于PEB的DLL查找
PEB的其中一个成员是一个LinkedList,包含了该进程加载到内存中的所有DLL:
通过遍历该LinkedList,就能搜索到我们需要的DLL
下面的C代码演示了查找DLL的过程
#include <Windows.h>
#ifndef __NTDLL_H__
#ifndef TO_LOWERCASE
#define TO_LOWERCASE(out, c1) (out = (c1 <= 'Z' && c1 >= 'A') ? c1 = (c1 - 'A') + 'a': c1)
#endif
typedef struct _UNICODE_STRING
{
USHORT Length;
USHORT MaximumLength;
PWSTR Buffer;
} UNICODE_STRING, * PUNICODE_STRING;
typedef struct _PEB_LDR_DATA
{
ULONG Length;
BOOLEAN Initialized;
HANDLE SsHandle;
LIST_ENTRY InLoadOrderModuleList;
LIST_ENTRY InMemoryOrderModuleList;
LIST_ENTRY InInitializationOrderModuleList;
PVOID EntryInProgress;
} PEB_LDR_DATA, * PPEB_LDR_DATA;
// 这里我们不想使用任何由外部模块导入的函数
typedef struct _LDR_DATA_TABLE_ENTRY {
LIST_ENTRY InLoadOrderModuleList;
LIST_ENTRY InMemoryOrderModuleList;
LIST_ENTRY InInitializationOrderModuleList;
void* BaseAddress;
void* EntryPoint;
UNICODE_STRING FullDllName;
UNICODE_STRING BaseDllName;
ULONG Flags;
SHORT LoadCount;
SHORT TlsIndex;
HANDLE SectionHandle;
ULONG CheckSum;
ULONG TimeDateStamp;
} LDR_DATA_TABLE_ENTRY, * PLDR_DAT_TABLE_ENTRY
typedef struct _PEB_LDR_DATA{
BOOLEAN InheritedAddressSpace;
BOOLEAN ReadImageFileExecOptions;
BOOLEAN BeingDebugged;
BOOLEAN SpareBool;
HANDLE Mutant;
PVOID IMAGEBaseAddress;
PPEB_LDR_DATA Ldr;
} PEB, * PPEB;
#endif
inline LPVOID get_module_by_name(WCHAR* module_name)
{
PPEB peb = NULL;
#if defined(_WIN64)
peb == (PPEB)___readgsqword(0x60);
#else
peb = (PPEB)__readfsdword(0x30);
#endif
PPEB_LDR_DATA ldr = peb->Ldr;
LIST_ENTRY list = ldr->InLoadOrderModuleList;
PLDR_DATA_TABLE_ENTRY Flink = *((PLDR_DATA_TABLE_ENTRY*)(&list));
PLDR_DATA_TABLE_ENTRY curr_module = Flink;
while(curr_module != NULL && curr_module->BaseAddress != NULL)
{
if(curr_module->BaseDllName.Buffer==NULL)continue;
WCHAR* curr_name = curr_module->BaseDllName.Buffer;
size_t i=0;
for(i=0;module_name[i] !=0&&curr_name[i]!=0;i++){
WCHAR c1,c2;
TO_LOWERCASE(c1,module_name[i]);
TO_LOWERCASE(c2,curr)name[i]);
if(c1!=c2) break;
}
if(module_name[i]==0&&curr_name[i]==0) {
return curr_module->BaseAddress;
}
curr_module = (PLDR_DATA_TABLE_ENTRY)curr_module->InLoadOrderModuleList.Flink;
}
return NULL;
}
导出函数查询
获取到kernel32.dll的基地址之后,我们还需要获取一些函数的地址:LoadLibraryA
和GetProcAddress
,这个工作可以通过查询该DLL的导出表来完成
首先,我们需要从这个DLL的Data Directory中获取到导出表,然后遍历导出表中所有的函数名称直到找到我们想要的函数,然后拿到该函数的RVA,加到DLL的基地址上面获取到完整的函数地址
下面是导出表搜索函数的代码:
inline LPVOID get_func_by_name(LPVOID module, char* func_name)
{
IMAGE_DOS_HEADER* idh = (IMAGE_DOS_HEADER*)module;
if(idh->e_magic!=IMAGE_DOS_SIGNATURE){
return NULL;
}
IMAGE_NT_HEADERS* nt_headers = (IMAGE_NT_HEADERS*)((BYTE*)module+idh->e_lfanew);
IMAGE_DATA_DIRECTORY* exportsDir=&(nt_headers->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT]);
if(exportsDir->VirtualAddress==NULL)return NULL;
DWORD expAddr = exportsDir->VirtualAddress;
IMAGE_EXPORT_DIRECTORY* exp=(IMAGE_EXPORT_DIRECTORY*)(expAddr+(ULONG_PTR)module);
SIZE_T namesCount=exp->NumberOfNames;
DWORD funcsListRva=exp->AddressOfFunctions;
DWORD funcNamesListRVA=exp->AddressOfNames;
DWORD namesOrdsListRVA=exp->AddressOfNameOrdinals;
for(SIZE_T i=0;i<namesCount;i++){
DWORD* nameRVA=(DWORD*)(funcNamesListRVA+(BYTE*)module +i*sizeof(DWORD);
DWORD* nameIndex=(WORD*)(funcNamesListRVA+(BYTE*)module +i*sizeof(WORD);
DOWRD* funcRVA = (DOWRD*)(funcsListRva+(BYTE*)module+(*nameIndex)*sizeof(DWORD));
LPSTR curr_name = (LPSTR)(*nameRva+(BYTE*)module);
size_t k=0;
for(k=0;func_name[k]!=0&&curr_name[k]!=0;k++){
if(func_name[k]!=curr_name[k] break;
}
if(func_name[k]==0&&curr_name[k]==0){
return (BYTE*)module+(*funcRVA);
}
}
return NULL;
}
把上面的代码封装到一个头文件peb_lookup.h
#pragma once
#include <Windows.h>
#ifndef __NTDLL_H__
#ifndef TO_LOWERCASE
#define TO_LOWERCASE(out, c1) (out = (c1 <= 'Z' && c1 >= 'A') ? c1 = (c1 - 'A') + 'a': c1)
#endif
typedef struct _UNICODE_STRING
{
USHORT Length;
USHORT MaximumLength;
PWSTR Buffer;
} UNICODE_STRING, * PUNICODE_STRING;
typedef struct _PEB_LDR_DATA
{
ULONG Length;
BOOLEAN Initialized;
HANDLE SsHandle;
LIST_ENTRY InLoadOrderModuleList;
LIST_ENTRY InMemoryOrderModuleList;
LIST_ENTRY InInitializationOrderModuleList;
PVOID EntryInProgress;
} PEB_LDR_DATA, * PPEB_LDR_DATA;
//here we don't want to use any functions imported form extenal modules
typedef struct _LDR_DATA_TABLE_ENTRY {
LIST_ENTRY InLoadOrderModuleList;
LIST_ENTRY InMemoryOrderModuleList;
LIST_ENTRY InInitializationOrderModuleList;
void* BaseAddress;
void* EntryPoint;
ULONG SizeOfImage;
UNICODE_STRING FullDllName;
UNICODE_STRING BaseDllName;
ULONG Flags;
SHORT LoadCount;
SHORT TlsIndex;
HANDLE SectionHandle;
ULONG CheckSum;
ULONG TimeDateStamp;
} LDR_DATA_TABLE_ENTRY, * PLDR_DATA_TABLE_ENTRY;
typedef struct _PEB
{
BOOLEAN InheritedAddressSpace;
BOOLEAN ReadImageFileExecOptions;
BOOLEAN BeingDebugged;
BOOLEAN SpareBool;
HANDLE Mutant;
PVOID ImageBaseAddress;
PPEB_LDR_DATA Ldr;
// [...] this is a fragment, more elements follow here
} PEB, * PPEB;
#endif //__NTDLL_H__
inline LPVOID get_module_by_name(WCHAR* module_name)
{
PPEB peb = NULL;
#if defined(_WIN64)
peb = (PPEB)__readgsqword(0x60);
#else
peb = (PPEB)__readfsdword(0x30);
#endif
PPEB_LDR_DATA ldr = peb->Ldr;
LIST_ENTRY list = ldr->InLoadOrderModuleList;
PLDR_DATA_TABLE_ENTRY Flink = *((PLDR_DATA_TABLE_ENTRY*)(&list));
PLDR_DATA_TABLE_ENTRY curr_module = Flink;
while (curr_module != NULL && curr_module->BaseAddress != NULL) {
if (curr_module->BaseDllName.Buffer == NULL) continue;
WCHAR* curr_name = curr_module->BaseDllName.Buffer;
size_t i = 0;
for (i = 0; module_name[i] != 0 && curr_name[i] != 0; i++) {
WCHAR c1, c2;
TO_LOWERCASE(c1, module_name[i]);
TO_LOWERCASE(c2, curr_name[i]);
if (c1 != c2) break;
}
if (module_name[i] == 0 && curr_name[i] == 0) {
//found
return curr_module->BaseAddress;
}
// not found, try next:
curr_module = (PLDR_DATA_TABLE_ENTRY)curr_module->InLoadOrderModuleList.Flink;
}
return NULL;
}
inline LPVOID get_func_by_name(LPVOID module, char* func_name)
{
IMAGE_DOS_HEADER* idh = (IMAGE_DOS_HEADER*)module;
if (idh->e_magic != IMAGE_DOS_SIGNATURE) {
return NULL;
}
IMAGE_NT_HEADERS* nt_headers = (IMAGE_NT_HEADERS*)((BYTE*)module + idh->e_lfanew);
IMAGE_DATA_DIRECTORY* exportsDir = &(nt_headers->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT]);
if (exportsDir->VirtualAddress == NULL) {
return NULL;
}
DWORD expAddr = exportsDir->VirtualAddress;
IMAGE_EXPORT_DIRECTORY* exp = (IMAGE_EXPORT_DIRECTORY*)(expAddr + (ULONG_PTR)module);
SIZE_T namesCount = exp->NumberOfNames;
DWORD funcsListRVA = exp->AddressOfFunctions;
DWORD funcNamesListRVA = exp->AddressOfNames;
DWORD namesOrdsListRVA = exp->AddressOfNameOrdinals;
//go through names:
for (SIZE_T i = 0; i < namesCount; i++) {
DWORD* nameRVA = (DWORD*)(funcNamesListRVA + (BYTE*)module + i * sizeof(DWORD));
WORD* nameIndex = (WORD*)(namesOrdsListRVA + (BYTE*)module + i * sizeof(WORD));
DWORD* funcRVA = (DWORD*)(funcsListRVA + (BYTE*)module + (*nameIndex) * sizeof(DWORD));
LPSTR curr_name = (LPSTR)(*nameRVA + (BYTE*)module);
size_t k = 0;
for (k = 0; func_name[k] != 0 && curr_name[k] != 0; k++) {
if (func_name[k] != curr_name[k]) break;
}
if (func_name[k] == 0 && curr_name[k] == 0) {
//found
return (BYTE*)module + (*funcRVA);
}
}
return NULL;
}
编写并编译汇编代码
正如前面提到的,编写shellcodes的经典方法就是汇编代码
首先我们需要选择用于编译代码的汇编器,这会对我们编写汇编代码时使用的语法有些许影响
对于Windows而言,最常用的汇编器是MASM,同时也是VS的一部分,有两个版本,一个32位的(ml.exe)一个64位的(ml64.exe),MASM生成的object文件可以被链接为PE格式的可执行文件,下面是一段演示代码:
.386
.model flat
extern _MessageBoxA@16:near
extern _ExitProcess@4:near
.data
msg_title db "Demo!", 0
msg_content db "Hello World!", 0
.code
main proc
push 0
push 0
push offset msg_title
push offset msg_content
push 0
call _MessageBoxA@16
push 0
call _ExitProcess@4
main endp
end
使用VS自带的ml.exe和link.exe进行编译和链接,首先要使用vcvars32.bat初始化环境变量
编译:
ml.exe /c demo.asm
链接:
link.exe demo.obj /subsystem:console /defaultlib:kernel32.lib /defaultlib:user32.lib /entry:main /out:demo32_masm.exe
MASM是Windows默认的汇编器,但是YASM(NASM的继任者)才是最好的选择,它是一个免费、独立的多平台汇编器,它可以被用来像MASM一样创建PE文件,YASM的语法稍有不同,请看下面的演示代码:
bits 32
extern _MessageBoxA@16:proc
extern _ExitProcess@4:proc
.data
msg_title db "Demo!", 0
msg_content db "Hello World!", 0
global main
main:
push 0
push 0
push msg_title
push msg_content
push 0
call _MessageBoxA@16
push 0
call _ExitProcess@4
编译
yasm-1.3.0-win32.exe -f win32 demo.asm -o demo32.obj
链接方法和上面的一样
上面这两段汇编代码都是不能直接生成shellcode的,因为他们都使用了外部依赖(extern),并没有遵循shellcode准则,不过我们可以通过移除这些依赖来生成shellcode
后面我们将使用MASM,因为如果我们想要使用VS来编译C代码,那么编译生成的汇编代码就是MASM语法,然后我们要做的就是从最终生成的PE文件中把代码挖出来
逐步编译C项目
现在大家都直接使用IDE来编译自己的代码了,这样一来,整个的编译过程对我们来说就是透明的了,默认情况下,输出就是一个PE文件:Windows专用的本地可执行文件格式
但是,有些时候将这个过程分成数个步骤还是有用的,这样我们可以拥有更大的控制权
让我们先来复习一下C/C++代码的编译过程
和汇编代码进行比较
相比于汇编代码,我们可以先使用C语言来编写代码,然后让编译器帮我们生成对应的汇编代码,之后再根据shellcode的准则来对生成的汇编代码进行适当的修改,相关内容将会在后面的章节中更详细地进行介绍
以下是一段样例代码
#include <Windows.h>
int main()
{
const char msg_title[] = "Demo!";
const char msg_content[] = "Hello World!";
MessageBoxA(0, msg_title, msg_content, MB_OK);
ExitProcess(0);
}
下面我们通过命令行的方式来生成exe,进行之前需要先运行vcvars32.bat
,如果你想要生成64位的exe文件,那么就要先运行vcvars64.bat
编译链接:
cl /c demo.cpp
link demo.obj /defaultlib:user32.lib /out:demo_cpp.exe
其中链接时我们可以使用第三方的链接器,比如crinkler,可以用来进行混淆等操作
如果在编译的时候加上一个/FA
选项,那么编译器会在当前目录生成一个同名的asm文件(MASM语法),然后你可以再使用该asm文件编译出obj文件
这样我们就拥有了修改汇编代码的机会,而不是从0开始全部用汇编语言编写代码
从C语言项目到shellcode
核心理念
我们创建shellcodes的方法就是利用C编译器可以从C代码生成汇编代码的特性,它包含了如下几个基础步骤:
- 准备一个C项目
- 将该项目重构,所有的导入函数都通过PEB查找的方式进行(移除导入表中的依赖)
- 使用C编译器生成汇编代码:
cl /c /FA /GS- <file_name>.cpp
- 重构汇编代码,以使得其遵循shellcode的准则(移除其他剩余的依赖,将字符串和变量等改为内联)
- 将汇编代码进行编译:
cl /c file.asm
- 链接为PE文件,测试运行是否正常
- 获取.text中的代码,可以使用pe-parser,这个提取出来的代码就是shellcode
C编译器生成的汇编代码有时候并不是100%正确的,有时需要手动进行修改
准备C项目
当我们为shellcode准备C项目的时候,我们需要遵守以下几个规则:
- 不要直接使用导入表
- 总是通过PEB获取API地址
- 不要使用任何静态库
- 只是用局部变量,不要用全局变量和静态变量(否则它们会被存储到另外的section中从而破坏代码的位置无关性)
- 使用基于栈的字符串(或者在生成的汇编代码中内联)
下面进行演示,我们的代码会弹个窗
#include <Windows.h>
int main()
{
MessageBoxW(0, L"Hello World!", L"Demo!", MB_OK);
ExitProcess(0);
}
准备导入函数
作为准备工作的第一步,我们需要将所有用到的导入函数进行动态加载,在演示代码中,有两个导入函数:MessageBoxW(user32.dll
)和ExitProcess(kernel32.dll
)
一般情况下,如果我们想要动态加载这些导入函数并避免使用导入表,我们需要对代码进行如下的重构
#include <Windows.h>
int main()
{
LPVOID u32_dll = LoadLibraryA("user32.dll");
int (WINAPI* _MessageBoxW)(
_In_opt_ HWND hWnd,
_In_opt_ LPCWSTR lpText,
_In_opt_ LPCWSTR lpCaption,
_in_ UINT uType) = (int (WINAPI*)(
_In_opt_ HWND,
_In_opt_ LPCWSTR,
_In_opt_ LPCWSTR,
_In_ UINT)) GetProcAddress((HMODULE)u32_dll, "MessageBoxW");
if(_MessageBoxW==NULL) return 4;
_MessageBoxW(0, L"Hello World!", L"Demo!", MB_OK);
return 0;
}
但是上面的代码中依然还有两个依赖:LoadLibraryA
和GetProcAddress
,这两个函数需要通过PEB查找来解析出来,这个工作可以通过之前的peb_lookup.h
完成,下面是完整的popup.cpp:
#include <Windows.h>
#include "peb_lookup.h"
int main()
{
LPVOID base = get_module_by_name((const LPWSTR)L"kernel32.dll");
if (!base) {
return 1;
}
LPVOID load_lib = get_func_by_name((HMODULE)base, (LPSTR)"LoadLibraryA");
if (!load_lib) {
return 2;
}
LPVOID get_proc = get_func_by_name((HMODULE)base, (LPSTR)"GetProcAddress");
if (!get_proc) {
return 3;
}
auto _LoadLibraryA = reinterpret_cast<decltype(&LoadLibraryA)>(load_lib);
auto _GetProcAddress = reinterpret_cast<decltype(&GetProcAddress)>(get_proc);
LPVOID u32_dll = _LoadLibraryA("user32.dll");
auto _MessageBoxW = reinterpret_cast<decltype(&MessageBoxW)>(_GetProcAddress((HMODULE)u32_dll, "MessageBoxW"));
if (!_MessageBoxW) return 4;
_MessageBoxW(0, L"Hello World!", L"Demo!", MB_OK);
return 0;
}
跳转表
如果我们在代码中使用了switch语句,那么在编译之后生成的汇编代码中可能会产生一个跳转表,这个是由编译器优化产生的,在一个普通的可执行文件中,跳转表很有用,但是对于shellcode,我们必须谨慎处理,因为它会破坏shellcode的位置无关性:跳转表需要重定位
汇编语言中的跳转表:
是否生成跳转表由编译器决定,低于4种情况的switch语句通常不会生成跳转表,因此我们必须重构C代码避免出现过长的switch语句,通常有两种处理方式,一种是拆分为多个函数,另一种是拆分为多个if-else语句
示例:
下面这段代码生成的汇编代码中将会包含跳转表
#include<stdio.h>
bool switch_state(char * buf, char * resp) {
switch (resp[0]) {
case 0:
if (buf[0] != '9') break;
resp[0] = 'Y';
return true;
case 'Y':
if (buf[0] != '3') break;
resp[0] = 'E';
return true;
case 'E':
if (buf[0] != '5') break;
resp[0] = 'S';
return true;
case 'S':
if (buf[0] != '8') break;
resp[0] = 'D';
return true;
case 'D':
if (buf[0] != '4') break;
resp[0] = 'O';
return true;
case 'O':
if (buf[0] != '7') break;
resp[0] = 'N';
return true;
case 'N':
if (buf[0] != '!') break;
resp[0] = 'E';
return true;
}
return false;
}
int main() {
char resp[] = "caaonima";
char buf[] = "nimacao";
printf("%d\n", switch_state(buf, resp));
return 0;
}
生成汇编代码:
cl /c /FA /GS- demo.cpp
可以看到有跳转表被生成
如果我们将原来代码中的switch语句截成几个代码块,就可以解决这个问题
#include<stdio.h>
bool switch_state(char * buf, char * resp) {
{
switch (resp[0]) {
case 0:
if (buf[0] != '9') break;
resp[0] = 'Y';
return true;
case 'Y':
if (buf[0] != '3') break;
resp[0] = 'E';
return true;
case 'E':
if (buf[0] != '5') break;
resp[0] = 'S';
return true;
}
}
{
switch (resp[0]) {
case 'S':
if (buf[0] != '8') break;
resp[0] = 'D';
return true;
case 'D':
if (buf[0] != '4') break;
resp[0] = 'O';
return true;
case 'O':
if (buf[0] != '7') break;
resp[0] = 'N';
return true;
}
}
{
switch (resp[0]) {
case 'N':
if (buf[0] != '!') break;
resp[0] = 'E';
return true;
}
}
return false;
}
int main() {
char resp[] = "caaonima";
char buf[] = "nimacao";
printf("%d\n", switch_state(buf, resp));
return 0;
}
使用if-else太麻烦,不推荐使用
移除隐式依赖
struct sockaddr_in sock_config = { 0 };
乍一看这行代码并没有调用任何函数,但是实际上它隐式调用了memset函数来初始化这个结构体变量
我这边实际测试的时候并没有发现有memset函数的引入
不过为了避免出现这种情况,我们还是使用自定义的函数或者inline的函数来初始化这种结构体
struct sockaddr_in sock_config;
SecureZeroMemory(&sock_config, sizeof(sock_config));
SecureZeroMemory
是inline函数
可以看到该函数的定义直接存在于生成的汇编代码中
处理字符串(可选)
我们还需要将所有用到的字符串转换为基于栈的,这个在这篇文章中有提到
char load_lib_name[] = {'L','o','a','d','L','i','b','r','a','r','y','A',0};
LPVOID load_lib = get_func_by_name((HMODULE)base, (LPSTR)load_lib_name);
这段代码生成的汇编代码如下:
生成汇编代码
现在我们已经准备好将C项目编译生成汇编代码了
cl /c /FA /GS- demo.cpp
注意将peb_lookup.h和demo.cpp放到同一目录,编译的时候会自动将其包含进去
/GS-
选项用于关闭stack cookie checks,如果没有这个选项,那么我们生成的汇编代码将会变成这样:
这样的话就多了一个外部依赖,所以一定要加上这个编译选项来避免出现这种情况
重构汇编代码
32位和64位有一些细微的差别,重构步骤也会因此有一些差异,我们将分开介绍
32位
首先使用vcvars32.bat初始化环境变量,然后编译生成汇编代码
0.清理汇编代码
首先,我们直接编译上面生成的汇编代码来生成EXE
在汇编代码的最上面加上下面这一行来解决这个报错
这个时候应该能生成一个可以正常运行的exe
使用PE-bear可以看到,该exe中仍然包含导入表
这是因为一些标准库默认链接到了我们的exe中,下面我们移除这些外部依赖
1.移除剩余的外部依赖
把汇编代码中的这三行注释掉:
include listing.inc
INCLUDELIB LIBCMT
INCLUDELIB OLDNAMES
在上面那一步中,我们生成的exe链接了静态库LibCMT,其中包含了入口函数_mainCRTStartup
,现在我们把这个依赖移除之后,链接器就找不到入口函数了,因此我们需要在编译的时候显式指定入口函数:
ml /c demo.asm
link demo.obj /entry:main
这下彻底干净了
2.确保代码的位置无关性
如果之前在C代码中已经将所有的字符串重构为基于栈的,那么这一步就可以省略掉了
为了保证代码的位置无关性,我们不能在除了.text之外的任何其他sections中存储数据,所有的事情都必须要在.text
中完成,如果我们的字符串被存储到了.data
中,那么我们就需要重构汇编代码来inline掉这些字符串
这里不再赘述,推荐在写C代码的时候使用基于栈的方式
3.提取shellcode
这一步可以使用pe-parser完成
64位
首先使用vcvars64.bat初始化环境变量,然后编译生成汇编代码
栈对齐
如果你想用XMM指令,那么需要确保16字节的栈对齐,否则,你的exe会直接崩掉
相关细节在Matt Graeber (@Mattifestation)的文章Writing Optimized Windows Shellcode in C中的Ensuring Proper Stack Alignment in 64-bit Shellcode一节中有提到
这一部分暂且略过,并没有考虑编写64位的shellcode
THE END
演示视频
上面的演示视频中,shellcode提取之后使用加载器运行没有成功,正在解决这个问题
换了一个加载器就好了,原来的这个加载器可能有点问题