想搞懂shellcode,光靠网上现成的payload生成器是远远不够的。生成器给你一段能用的字节串,但你不知道它为什么能用、不知道哪几个字节是硬编码、更不知道换一个场景为什么就崩了。手搓shellcode,就是把这段所谓的"神秘字节"从头到尾拆开揉碎,一行汇编、一个字节地写出来,彻底搞明白CPU到底在执行什么。这篇文章就是我自己在折腾过程中的完整记录,从最基础的原理讲到坏字符处理,再到调试验证,全程实操。
1. 工具遍地都是,为什么还要自己动手搓
先说个现象。现在随便一个漏洞利用框架或者在线工具,敲一行命令就能吐出一段现成的shellcode,甚至还能自动编码、自动消除坏字符。我刚入门时也是这么干的,直到某次实际测试,工具生成的那段payload死活弹不回来shell,我对着十六进制字节串一头雾水,完全不知道从哪里排查。那之后我就下决心,必须亲手把shellcode从零写一遍。
1.1 "手搓"到底是在搓什么
手搓shellcode,本质上就是在没有高级语言、没有链接器、没有任何外部依赖的情况下,直接写出一段能让CPU执行的机器指令。通俗一点说,高级语言是写作文,编译器帮你润色成八股文;手搓shellcode是直接写八股文里的每一个字,多一笔少一笔都不行。
这段机器指令有一个特性:它没有"壳",没有ELF文件头,没有重定位表,被注入到目标进程后直接就是一段裸的二进制数据被CPU取指执行。正因为它如此原始,你才能从根本上理解程序执行、内存布局和系统调用的真实面貌。
1.2 生成器掩盖了哪些关键细节
现成工具虽然方便,但它掩盖了三个手搓时必须面对的核心问题:
第一,指令的字节编码。同样是mov eax, 1,写成b8 01 00 00 00和6a 01 58效果一样,但字节长度和内容完全不同。后者短得多,而且不包含空字节,这在很多漏洞场景下是生死攸关的差异。
第二,系统调用编号和约定。Linux x86和x86_64下,execve的系统调用号分别是11和59,传参寄存器规则也完全不一样。工具生成器直接跳过了这些底层知识,你拿到手只是结果,不是理解。
第三,坏字符过滤。真实漏洞环境往往会对输入做过滤,比如strcpy遇到\x00就截断,那shellcode里一个空字节都不允许出现。工具虽然能帮你编码,但编码后的体积膨胀、解码器在内存中的执行流程,都需要你理解才能正确部署。
所以我的建议是:工具照用,但一定要有"手搓"的能力兜底。遇到问题时,能一眼看懂字节串、能手动改,才算是真正会了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 手搓前的最后一块拼图:从C代码到机器码的映射
手搓shellcode不是凭空造字节,而是从你脑子里的逻辑出发,一步步翻译成CPU听得懂的指令。这个翻译过程的底层逻辑就两条:CPU在干什么,以及操作系统怎么把活儿交给内核。
2.1 明确目标:我们想让程序执行什么
先定一个小目标:让目标进程执行execve("/bin/sh", NULL, NULL),也就是弹一个shell。这是最经典的入门payload,麻雀虽小五脏俱全。
用C语言表达就是:
c复制#include <unistd.h>
int main() {
char *args[] = {"/bin/sh", NULL};
execve("/bin/sh", args, NULL);
return 0;
}
C语言版本的execve是glibc封装,真正到内核层面,Linux的系统调用是通过中断(x86的int 0x80)或专用指令(x86_64的syscall)触发的。手搓shellcode,就是绕过glibc,直接跟内核对话。
2.2 系统调用号与寄存器约定
内核收到系统调用后,靠一个整数编号来区分具体要做什么。这个编号就是系统调用号。
| 系统调用 | x86 调用号 | x86_64 调用号 |
|---|---|---|
| execve | 11 (0x0b) | 59 (0x3b) |
| exit | 1 | 60 |
| write | 4 | 1 |
传参规则两块平台也不同:
- x86(32位):参数从栈上传递,调用号放
eax,参数依次是ebx、ecx、edx、esi、edi,最后执行int 0x80跳进内核。 - x86_64(64位):参数优先用寄存器传递,调用号放
rax,参数依次是rdi、rsi、rdx、r10、r8、r9,最后执行syscall进入内核。
所以,execve("/bin/sh", NULL, NULL)翻译成系统调用语言就是:
- x86:
eax=11,ebx="/bin/sh"字符串地址,ecx=NULL,edx=NULL,执行int 0x80 - x86_64:
rax=59,rdi="/bin/sh"字符串地址,rsi=NULL,rdx=NULL,执行syscall
2.3 从汇编到字节码:一条指令的两种命运
这里要引入一个手搓shellcode的基本功:知道一条汇编指令会被编码成什么字节。以x86_64为例:
asm复制mov al, 59
al是rax的最低8位寄存器。由于我们只需要把系统调用号59放进rax,并且rax的高位已经清0,所以只动低8位就够了。这条指令的机器码是b0 3b,这里0x3b就是59的十六进制。两个字节,干净利落。
但同样的逻辑,如果写成:
asm复制mov rax, 59
编码出来是48 c7 c0 3b 00 00 00,一共7个字节,中间塞了4个空字节\x00。这在strcpy场景下会直接截断,整个payload作废。
这就是手搓的乐趣所在:同一个功能,用不同的指令写法,字节长度和内容天差地别。而你要在理解CPU行为的前提下,挑出最短、最干净、最适合当前环境的编码。
3. 用execve落地一个可运行的例子:硬编码与短指令的平衡
现在开始实战。我以x86_64为例,手写一段能弹shell的shellcode,然后一步步验证。整个过程会用到nasm汇编器、objdump反汇编工具和一个简单的C语言测试壳。
3.1 第一版:怎么把字符串"喂"给寄存器
先看最核心的一个问题:`
