首页 / 资讯中心 / 文章详情

从零手写Shellcode:理解栈、寄存器与位置无关代码

从零手写Shellcode:理解栈、寄存器与位置无关代码 ★ FEATURED ARTICLE
1. 从一段看起来像乱码的字节说起第一次接触 Shellcode 的人多半会有一种强烈的违和感屏幕上那一串\x31\xc0\x50\x68...到底是个什么东西它既不像正常的汇编指令那样有助记符也不像高级语言那样有结构可偏偏把它丢进内存里跑起来程序就真的执行了。这种把机器码当数据搬运、再当代码执行的玩法正是 Shellcode 最迷人的地方也是理解栈、寄存器、调用约定这些底层概念的最佳切入点。我写这篇东西的出发点很简单网上讲 Shellcode 的文章要么太黑客味一上来就是攻击载荷、提权、反弹连接把新手吓退要么太学术堆一堆指令编码表却不告诉你为什么要这么写。而实际上Shellcode 本身只是一个中性的技术概念——它就是一段位置无关、自包含、可被注入后直接执行的机器码。理解它对写正常程序的人同样有价值你会真正搞懂函数调用时栈是怎么长的、参数是怎么传的、返回地址存在哪里、为什么缓冲区溢出能改变程序流程。这篇内容适合三类人一是刚学完汇编、想找个具体项目练手的学生二是做底层开发、想搞清楚栈帧布局的工程师三是对二进制安全感兴趣、想从零理解代码即数据这个命题的爱好者。我会从栈的内存模型讲起一步步手写一段能打印字符串的 Shellcode再解释它为什么必须位置无关最后聊聊栈溢出是怎么和 Shellcode 配合起来的。全程用可复现的代码和实测结果说话不讲空泛的理论。需要提前说明的是本文所有实验都在自己搭建的隔离环境里进行目的是理解原理不涉及任何真实系统的入侵行为。技术本身没有善恶关键看你怎么用。2. 栈到底长什么样从函数调用说起2.1 栈不是数据结构课上的栈而是CPU直接管理的内存区很多人学栈是从数据结构开始的——后进先出、push/pop。但真正要理解 Shellcode你得把栈看成一块由 CPU 的rsp栈指针和rbp栈基址指针寄存器共同管理的连续内存区域。在 x86-64 架构下栈默认从高地址向低地址增长push操作会让rsp减小pop会让rsp增大。这个反向增长的设计一开始很反直觉但它是历史沿革的结果也是理解栈帧布局的前提。我习惯用一个生活化的类比把栈想象成一摞从天花板往下叠的盘子。天花板是内存高地址你每放一个盘子push盘子堆的顶端就往下移一点取盘子pop则往上移。rsp就是那个当前顶端在哪的标记。函数调用时CPU 会自动把返回地址压栈相当于在盘子堆顶放一个回来时该去哪的便签。在 64 位系统里一个典型的函数栈帧大致长这样从高地址到低地址内存位置内容说明高地址调用者的栈帧上一层函数的数据...参数第7个及以后前6个参数走寄存器...返回地址call 指令自动压入rbp 指向处旧的 rbp被调用者保存低地址局部变量当前函数的局部数据rsp 指向处当前栈顶下一个 push 的位置这张表看着简单但它是后面所有内容的基石。Shellcode 要能执行就必须和这套栈规则和平共处——它不能假设自己被放在哪个地址也不能破坏调用者的栈。2.2 函数调用时那几条关键指令到底干了什么光看表还不够得看指令。假设有这样一个 C 函数int add(int a, int b) { int c a b; return c; }编译成汇编后调用方大致会做这几件事mov edi, 3 ; 第一个参数 a 放进 edi mov esi, 5 ; 第二个参数 b 放进 esi call add ; 调用函数而call指令本身做了两件事把下一条指令的地址返回地址压栈然后跳转到add的入口。进入add后函数开头通常是push rbp ; 保存调用者的 rbp mov rbp, rsp ; 建立自己的栈帧 sub rsp, 16 ; 给局部变量留空间这几条就是所谓的函数序言prologue。对应的函数尾声epilogue是mov rsp, rbp ; 恢复栈顶 pop rbp ; 恢复调用者的 rbp ret ; 弹出返回地址并跳转我特意把这几条列出来是因为Shellcode 本质上就是一段没有序言、没有尾声、直接干活的裸代码。它不能依赖编译器帮你建立栈帧所有事情都得自己来。这也是为什么手写 Shellcode 比写普通汇编难——你得时刻清楚rsp在哪、哪些寄存器能用、哪些值必须自己准备。2.3 为什么返回地址是理解一切的关键栈帧里最敏感的东西就是返回地址。ret指令的执行逻辑极其简单从rsp指向的位置取出 8 个字节当作地址跳过去。它不检查这个地址合不合法也不管这个值是不是真的由call压进去的。这就意味着——如果你能改写栈上那个返回地址你就能让程序跳到你想要的任何地方。这正是栈溢出和 Shellcode 配合的物理基础。一个典型的缓冲区溢出场景是这样的函数里有个局部数组char buf[64]程序用strcpy或gets往里面写数据但没检查长度。你输入 80 个字节前 64 个填满数组接着 8 个字节覆盖了保存的rbp再接着 8 个字节就覆盖了返回地址。当函数执行到ret时CPU 就会跳到你写进去的那个地址。如果那个地址指向栈上你自己放的 Shellcode程序就开始执行你的代码了。听起来很直接但实际操作里有一堆坑地址里不能有\x00会被字符串函数截断、栈地址每次运行可能不一样ASLR、现代系统还有栈不可执行保护NX。这些坑后面会逐个讲。3. 手写第一段 Shellcode让程序打印一行字3.1 先想清楚要调用什么再想怎么调用写 Shellcode 和写普通程序最大的区别是普通程序可以链接 libc、调用printfShellcode 不行。它必须自己完成所有事情包括发起系统调用。在 Linux x86-64 上系统调用通过syscall指令触发调用号放在rax参数依次放在rdi、rsi、rdx、r10、r8、r9。我们要实现的功能是往标准输出写一行字对应的系统调用是write调用号是 1参数是rdi 文件描述符1 表示 stdoutrsi 字符串地址rdx 字符串长度所以 Shellcode 的逻辑就是准备好这三个参数把rax设为 1执行syscall然后干净地退出。3.2 用汇编写出能跑的版本先写一个不追求 Shellcode 特性、只追求能跑的汇编版本global _start section .text _start: mov rax, 1 ; sys_write mov rdi, 1 ; stdout lea rsi, [rel msg] ; 字符串地址 mov rdx, 13 ; 长度 syscall mov rax, 60 ; sys_exit xor rdi, rdi ; 退出码 0 syscall msg: db Hello, Shell, 10用nasm -f elf64 hello.asm -o hello.o汇编再ld hello.o -o hello链接运行就能看到输出。这一步很重要——先确保逻辑正确再考虑怎么把它变成位置无关的 Shellcode。很多人一上来就追求没有空字节结果逻辑都没跑通调试起来极其痛苦。3.3 从可执行文件里抠出机器码汇编链接后的可执行文件里.text段就是我们想要的机器码。用objdump -d hello可以看到反汇编把指令对应的字节抄下来就是最原始的 Shellcode。但直接抄有个问题里面很可能包含\x00而且lea rsi, [rel msg]这种相对寻址在抠出来之后偏移量可能就不对了。我实测下来最稳妥的做法是用objcopy把.text段单独导出成二进制objcopy -O binary -j .text hello hello.bin xxd -p hello.bin这样得到的字节序列就是纯机器码。但你会发现它仍然依赖msg 在代码后面这个布局一旦被放到别的位置lea算出来的地址就错了。这就是下一节要解决的核心问题。3.4 位置无关Shellcode 的生存法则Shellcode 被注入到目标进程后它自己也不知道会被放在哪。可能在高地址可能在低地址可能在一个你完全没预料到的位置。所以它绝对不能使用绝对地址所有对数据的引用都必须是相对于当前指令位置的。在 64 位下最常用的技巧是call/popjmp short get_msg back: pop rsi ; rsi 现在指向 msg ; ... 继续执行 get_msg: call back msg: db Hello, Shell, 10call会把下一条指令的地址压栈紧接着pop rsi就把它取出来——这个地址正好就是msg的地址。这个技巧在 32 位时代是标配64 位下虽然有了lea [rel ...]但call/pop依然通用而且不依赖汇编器的相对寻址支持。把字符串放在call后面还有个额外好处代码和数据混在一起整体是一段连续的字节注入时只需要一次内存拷贝。这也是 Shellcode 通常写成一段自包含字节流的原因。4. 让 Shellcode 真正干净空字节与编码问题4.1 为什么\x00是 Shellcode 的天敌Shellcode 最常见的注入途径是字符串拷贝函数比如strcpy、gets、scanf(%s)。这些函数遇到\x00就认为字符串结束了后面的字节根本不会被复制。所以如果你的 Shellcode 中间有个\x00注入进去就是半截跑起来必然崩溃。问题在于很多指令天然会产生\x00。比如mov rax, 1在 64 位下会被编码成48 c7 c0 01 00 00 00后面跟着一串零。mov rdi, 1同理。这些零不是我们想要的但汇编器就是这么编的。解决办法是换用不产生空字节的等价指令。比如mov rax, 1改成push 1pop raxpush 1的编码是6a 01没有零mov rdi, 1改成push 1pop rdixor rdi, rdi本身就是31 ff没有零而且比mov rdi, 0短我整理了一张常用替换对照表实测有效原始写法问题替换写法编码mov rax, 1含多个\x00push 1; pop rax6a 01 58mov rdi, 0含\x00xor rdi, rdi48 31 ffmov rdx, 13含\x00push 13; pop rdx6a 0d 5amov rsi, addr绝对地址含零call/pop或lea rel视情况4.2 字符串本身怎么处理才不断字符串里的\x00更麻烦因为字符串是数据你不能用指令替换。常见的做法是把字符串拆成几段用栈来构造。比如要构造Hello可以xor rdx, rdx push rdx ; 先压一个 0 作为结束符 mov rdx, 0x6f6c6c65 ; olle 的小端表示 push rdx mov rdx, 0x48 ; H push rdx mov rsi, rsp ; rsi 指向构造好的字符串这样字符串是在运行时拼到栈上的源字节里没有\x00除了那个作为结束符的push rdx但它是运行时压入的不在 Shellcode 字节流里。这个技巧叫栈上构造字符串是 Shellcode 编码的经典手法。不过要注意push一个 64 位立即数在 x86-64 下编码是48 68加 8 字节如果立即数高位是零还是会有零字节。所以更稳的做法是用 32 位操作数或者干脆用mov到寄存器再push。我实测下来对于短字符串push32 位立即数68开头配合push rdx清零是最省字节的方案。4.3 编码器当干净实在做不到时有些 Shellcode 逻辑复杂手工消除空字节非常费劲。这时候可以用编码器encoder——把原始 Shellcode 用一个可逆算法加密前面加一小段解码器decoder运行时先解码再执行。解码器本身要保证没有空字节而加密后的载荷即使有零也无所谓因为它只是数据。最简单的编码是异或选一个非零的 key把每个字节和 key 异或。解码器就是一段循环逐字节异或回来。这种方案在 CTF 和教学里很常见优点是简单缺点是 Shellcode 体积会变大解码器本身占几十字节。我个人的经验是能用指令替换解决的就别上编码器。编码器增加复杂度、增加出错概率而且解码器本身也可能有 bug。只有在载荷确实很长、手工优化不现实时才考虑编码方案。5. 栈溢出与 Shellcode 的合体一次完整的实验5.1 搭一个故意有漏洞的靶子要验证 Shellcode 能不能跑得有个能被溢出的程序。下面这段 C 代码是经典的教学靶子#include stdio.h #include string.h void vulnerable(char *input) { char buf[64]; strcpy(buf, input); printf(You said: %s\n, buf); } int main(int argc, char **argv) { if (argc 1) vulnerable(argv[1]); return 0; }buf只有 64 字节strcpy不检查长度输入超过 64 字节就会覆盖栈上的返回地址。编译时记得关掉栈保护gcc -fno-stack-protector -z execstack -no-pie -o vuln vuln.c-z execstack让栈可执行-no-pie关掉地址随机化-fno-stack-protector关掉栈金丝雀。这三个选项在真实系统里默认都是开的关掉它们只是为了教学演示。5.2 算出偏移量返回地址到底在第几个字节覆盖返回地址前得先知道它离buf开头有多远。方法是用一段有规律的填充数据比如AAAABBBBCCCC...去溢出然后用调试器看崩溃时rip的值。如果rip变成了0x4242424242424242也就是BBBBBBBB说明返回地址被B覆盖了偏移量就是A的数量。在 64 位下buf[64]加上保存的rbp8 字节偏移量通常是 72。但不要背这个数字不同编译器、不同优化级别、不同函数序言都会改变它。一定要自己用调试器实测。我见过太多人照着教程写 72结果因为编译器版本不同而失败然后怀疑人生。5.3 把 Shellcode 放进环境变量绕过栈地址不确定即使关了 ASLR栈地址在不同运行环境下也可能有细微差别比如环境变量长度不同。一个经典技巧是把 Shellcode 放在环境变量里因为环境变量在栈上的位置相对稳定而且可以通过一个小程序打印出它的地址。#include stdio.h #include stdlib.h int main() { printf(%p\n, getenv(SHELLCODE)); return 0; }把 Shellcode 字节流塞进SHELLCODE环境变量运行这个程序拿到地址再把这个地址作为返回地址覆盖进去。这样 Shellcode 不在被溢出的缓冲区里而是在环境变量区空间更大也不用担心被strcpy截断。5.4 实测中那些文档不会告诉你的坑第一次做这个实验我踩的坑比想象中多。列几个最典型的坑一地址里有\x00。64 位地址通常是0x00007fff...高两位是零。如果你用strcpy注入地址会被截断。解决办法是用argv而不是环境变量或者用scanf读二进制。更彻底的办法是找一个小端表示里没有零的地址但这需要运气。坑二Shellcode 里有\x00。前面讲过必须手工消除或用编码器。我建议先用xxd检查字节流把所有00标出来逐个替换。坑三栈对齐。x86-64 的syscall对栈对齐有要求如果rsp不是 16 字节对齐某些系统调用会失败。在 Shellcode 开头加一条and rsp, -16可以强制对齐代价是 4 个字节。坑四execstack没生效。有些系统即使加了-z execstack内核的 NX 策略仍然会拦截栈上执行。这时候需要检查/proc/self/maps里栈段的权限确认有x标志。坑五Shellcode 末尾没有干净退出。如果 Shellcode 执行完不调用exit程序会继续往下跑很可能崩溃并产生 core dump看起来像是 Shellcode 失败了其实它已经成功执行了。养成在末尾加exit系统调用的习惯。6. 从能跑到跑得稳进阶技巧与边界6.1 用pwntools把重复劳动自动化手工算偏移、拼字节、转义地址做一次两次还行做多了容易出错。pwntools这个库把这些都封装好了from pwn import * context.arch amd64 shellcode asm(shellcraft.sh()) offset 72 payload bA * offset p64(0x7fffffffe000) shellcodeasm负责汇编p64负责打包成 64 位小端shellcraft甚至能直接生成一段执行/bin/sh的 Shellcode。但我要提醒一句用工具之前先手写一遍。手写过你才知道shellcraft.sh()生成的字节里为什么有那些push、为什么用cdq而不是xor rdx, rdx。工具是加速器不是替代品。6.2 现代防护下 Shellcode 的变通真实系统里NX、ASLR、栈金丝雀、PIE 基本全开直接往栈上注入 Shellcode 并执行几乎不可能。但这不代表 Shellcode 没用了它换了个形式存在ROP返回导向编程不注入新代码而是把已有代码片段gadget串起来用ret指令驱动。Shellcode 的思想还在只是代码变成了现成的指令片段。JIT 喷射在允许动态生成代码的环境里比如某些脚本引擎把 Shellcode 藏在 JIT 编译出的代码页里执行。内存马在 Web 服务进程里注入一段常驻代码不落盘、不新建进程隐蔽性极强。这些话题每一个都能单独写一篇但它们的底层逻辑是一样的控制流劫持 位置无关代码。理解了栈和 Shellcode这些进阶方向就有了地基。6.3 写 Shellcode 时我总结的几条铁律做了这么多实验有几条经验是反复验证过的分享出来第一永远先写可读的汇编再优化字节。逻辑对了再抠字节顺序反了会浪费大量时间。第二每改一次都用xxd检查空字节。肉眼看不出来工具一秒搞定。第三Shellcode 开头先保存现场。如果它要返回调用者必须把用到的寄存器恢复。如果它直接exit那就不用管。第四长度越短越好但别为了短牺牲正确性。有些技巧能省几个字节但会让 Shellcode 依赖特定环境得不偿失。第五在隔离环境里测试。Shellcode 的行为可能触发系统保护机制别在主力机器上乱跑。6.4 一个值得动手的小练习如果你跟着读到这里我建议你动手做一件事写一段 Shellcode功能是读取一个文件的前 16 个字节并打印出来。这个练习会逼你处理几个新问题open系统调用的参数、文件描述符的保存、read的缓冲区地址、错误处理。做完之后你对系统调用、寄存器分配、栈上数据布局的理解会上一个台阶。具体思路是open调用号 2打开文件read调用号 0读入栈上的缓冲区write调用号 1输出最后exit。文件名同样用栈上构造的方式准备。这个练习不需要溢出单独写个汇编程序跑通就行重点是体会没有 libc 帮忙的感觉。7. 关于栈和 Shellcode我个人的一点体会回过头看Shellcode 最吸引我的地方不是能执行任意代码这个结果而是它逼着你去理解计算机最底层的那套约定。栈怎么长、参数怎么传、返回地址存在哪、系统调用怎么发起——这些知识在写高级语言时被完全隐藏了但 Shellcode 把它们全部摊开在你面前。你写错一个字节程序就崩你算错一个偏移控制流就飞。这种没有中间层的反馈是理解底层最快的路径。我也想说栈溢出和 Shellcode 这类技术在防御方眼里是威胁在开发方眼里是教训在学习者眼里是教材。同一套知识用途完全取决于人。我写这些内容是希望更多人能理解系统是怎么运作的从而写出更健壮的代码而不是去搞破坏。真正的高手是那些知道怎么攻击、所以更知道怎么防御的人。如果你在复现过程中卡住了大概率是三个地方偏移量算错、地址里有空字节、栈不可执行。按这个顺序排查基本都能解决。实在不行把 Shellcode 单独抽出来写个测试程序跑一遍确认它本身没问题再往溢出场景里放。分而治之永远是最有效的调试策略。
阅读完成 · 觉得有帮助?
咨询建站