MoeCTF2024-PWN方向做题笔记
MoeCTF2024做题笔记
题目:NotEnoughTime
在正式开始 Pwn 之前,我需要先检测一下你的数学 (?) 能力...
我也是疯了,竟然真想跟它拼手速😅
就考验用python来写pwn基础脚本的能力,好吧,这的确是我缺少的
题解:
本题考察 Pwntools 基本用法,虽然是简单的计算加减乘除,但是在输出算式时刻意添加延迟营造网络卡顿环境,并且算式存在多行情况,意在引导使用 recvuntil。注意在使用 Python eval 前需要去除多行算式中的 \n 以及末尾的 = 以符合 Python 语法。除法是整数除法,在 Python 语法中为 // 。
比赛期间注意到很多选手把除法看作浮点数运算,由此触发了许多奇怪的 bug(出题时并没有考虑到会有浮点数输入的情况)。于是临时新增了一个提示。
Exp:
1 | from pwn import * |
脚本功能解析
初始部分:
1
2from pwn import *
io = ...- 使用pwntools库进行二进制交互
io代表与目标程序的连接(可能是远程服务或本地程序)
初始交互:
1
2
3io.sendlineafter(b"=", b"2")
io.sendlineafter(b"=", b"0")
io.recvuntil(b"!")回答前两个预设问题:
- 第一个问题(如”1 + 1 =“)回答”2”
- 第二个问题(如”4 / 3 - 1 =“)故意回答错误的”0”
接收直到感叹号的输出
自动解题循环:
1
2
3
4
5
6
7
8
9
10
11
12for _ in range(20):
io.sendline(
str(
eval(
io.recvuntil(b"=")
.replace(b"\n", b"")
.replace(b"=", b"")
.replace(b"/", b"//")
.decode()
)
).encode()
)循环处理20个数学问题
工作流程:
接收直到等号(“=“)的问题字符串
预处理:
- 移除换行符
- 移除等号
- 将除法符号”/“替换为”//“(Python中的整数除法)
使用Python的
eval()计算表达式结果将结果转换为字符串并发送
最后交互:
1
io.interactive()
- 在完成所有计算后,将控制权交给用户进行手动交互
技术细节说明
数学表达式处理:
使用
eval()动态执行数学表达式将”/“替换为”//“确保进行整数除法(避免浮点数结果)
示例转换:
1
"4 / 3 - 1" → "4 // 3 - 1" → eval结果为1
二进制数据处理:
b"="等表示字节字符串(二进制模式).decode()将字节串转换为普通字符串.encode()将字符串转换回字节串发送
pwntools函数:
sendlineafter():在收到指定模式后发送数据recvuntil():接收数据直到指定模式interactive():进入交互模式
Python eval() 函数
描述
eval() 函数用来执行一个字符串表达式,并返回表达式的值。
字符串表达式可以包含变量、函数调用、运算符和其他 Python 语法元素。
语法
以下是 eval() 方法的语法:
1 | eval(expression[, globals[, locals]]) |
参数
- expression – 表达式。
- globals – 变量作用域,全局命名空间,如果被提供,则必须是一个字典对象。
- locals – 变量作用域,局部命名空间,如果被提供,可以是任何映射对象。
eval() 函数将字符串 expression 解析为 Python 表达式,并在指定的命名空间中执行它。
返回值
eval() 函数将字符串转换为相应的对象,并返回表达式的结果。
实例
以下展示了使用 eval() 方法的实例:
实例 1
>>>x = 7>>> eval( ‘3 * x’ )21>>> eval(‘pow(2,2)’)4>>> eval(‘2 + 2’)4>>> n=81>>> eval(“n + 4”)85
实例 2
执行简单的数学表达式
result = eval(“2 + 3 * 4”)
print(result) # 输出: 14
执行变量引用
x = 10
result = eval(“x + 5”)
print(result) # 输出: 15
在指定命名空间中执行表达式
namespace = {‘a’: 2, ‘b’: 3}
result = eval(“a + b”, namespace)
print(result) # 输出: 5
注意: eval() 函数执行的代码具有潜在的安全风险。如果使用不受信任的字符串作为表达式,则可能导致代码注入漏洞,因此,应谨慎使用 eval() 函数,并确保仅执行可信任的字符串表达式。
题目:ez_shellcode
肆意溢出并构造shellcode吧!
1 | from pwn import * |
题目:leak_sth
简单的猜数字,够幸运就来试试吧。
运行一下附件 pwn
看看main()函数
看看func()函数
有后门函数backdoor()
有printf()函数,可以用用格式化字符串漏洞
由代码可知,如果变量 v3 = v2 就可以执行backdoor()函数
通过格式化字符串,泄露(leak) v3的值,关键是确定变量 v3 是printf()函数的第几个参数
学会了用gdb来调试程序以确定变量v3在栈中的位置,为第几个参数,也是记录下这题的原因🥰(pwn的学习之路还很漫长,但又向前走了一小步😉,更加了解gdb的用法🚩)
1. 函数栈帧分析
函数 func() 的局部变量在栈上的布局如下(x86-64 架构,假设栈从高地址向低地址增长):
c语言
1 | unsigned __int64 func() |
对应的栈布局(低地址在上,高地址在下):
| 栈偏移(相对于 rbp) | 变量 | 大小 |
|---|---|---|
rbp-0x40 |
v2 |
8字节 |
rbp-0x38 |
v3 |
8字节 |
rbp-0x30 |
buf[40] |
40字节 |
rbp-0x08 |
v5(Canary) |
8字节 |
2. printf(buf) 调用时的栈状态
当 printf(buf) 被调用时,x86-64 的调用约定如下:
- 前 6 个参数通过寄存器传递:
rdi,rsi,rdx,rcx,r8,r9。 - 其余参数通过栈传递。
对于 printf(buf):
- 第一个参数(格式化字符串) :
buf 的地址通过rdi 传递(不占用栈空间)。 - 后续参数:如果
buf 中包含格式化占位符(如%p,%x,%ld),printf 会从rsi,rdx,rcx,r8,r9 依次读取,之后从栈上读取。
c语言
1 | unsigned __int64 func() |
3. 确定 v3 是第几个参数
我们需要找到 v3 在 printf 参数列表中的位置:
printf 的栈参数起始位置:- 在 x86-64 中,
printf 的栈参数从rsp+8 开始(因为call printf 会将返回地址rip 压栈,占 8 字节)。
- 在 x86-64 中,
v3 的栈地址:v3 位于rbp-0x38。- 当前
rsp 的值:调用printf 时,rsp 会指向返回地址(即rbp-0x40 是v2,rbp-0x38 是v3)。 - 因此,
v3 的地址是rsp + 0x8(因为rbp-0x38 = rsp + 0x8)。
参数位置计算:
- 前 6 个格式化字符串参数通过寄存器传递(
rdi,rsi,rdx,rcx,r8,r9),第 7 个开始从栈上读取。 v3 位于rsp+8,因此它是 第 7 个参数(因为栈参数从rsp+8 开始,对应第 7 个)
- 前 6 个格式化字符串参数通过寄存器传递(
但用IDA得到的数据虽然大部分时候是正确,但有时候也会有误,最好要用gdb调试一下,以gdb的调试结果为准。
pion@ubuntu:~/Desktop/challege$ gdb pwn
pwndbg> b printf Breakpoint 1 at 0x1110 pwndbg> r

在GDB中可以用·p指令将16进制数转换成二进制数
这样就leak v3的值(v3是一个random,每次都会随机生成不一样的数)
将得到的v3的值发过去就getshell
1 | from pwn import * |
题目:这是什么?32-bit!
Linux 遵循的 AMD64 System V ABI 使用 6 个寄存器(rdi、rsi…)传递函数参数,参数数量大于 6 个时才继续使用栈传递参数。然而 32 位程序只使用栈传递参数,参数从右至左依次入栈。

程序附件是32位小端序
main()函数长这样

vuln()函数长这样
先来了个getchar(),再 scanf 输入数据到变量v1中
有后门函数backdoor(),去看看

有 execve()但参数是错的
execve() 函数详解
execve() 是 Unix/Linux 系统中的一个重要系统调用,用于执行一个新的程序。它会用指定的程序完全替换当前进程的内存空间。函数原型
1
2
3 #include <unistd.h>
int execve(const char *path, char *const argv[], char *const envp[]);参数说明
path:
- 要执行的可执行文件的完整路径
- 例如:
/bin/ls,/usr/bin/env
argv (参数向量):
- 字符串指针数组,表示传递给新程序的参数
- 第一个参数通常是程序名称本身
- 数组必须以
NULL 指针结尾- 例如:
{"ls", "-l", NULL}
envp (环境变量):
- 字符串指针数组,表示新程序的环境变量
- 每个字符串通常是
"VAR=value" 形式- 数组必须以
NULL 指针结尾- 可以传递
environ 全局变量来继承当前环境返回值
- 成功时:不返回(因为原进程已被替换)
- 失败时:返回 -1,并设置
errno
shift+F12查看字符串
程序存在/bin/sh,
1 | from pwn import * |
1.payload += flat([ ... ])
flat([...]) :flat() 是pwntools 提供的函数,用于将多个值打包成二进制数据(自动处理大小端)。- 这里构造的是 ROP 链,用于调用
execve("/bin/sh", 0, 0)。
2. e.search(b'/bin/sh') 的作用
-
e 是ELF 对象(通过ELF("./binary") 加载),代表目标二进制文件。 -
e.search(b'/bin/sh') 会 在二进制文件的所有可读内存段(如 .text **、** .data **、** .rodata )中搜索字节序列 b'/bin/sh' 。 - 它返回一个 生成器(generator) ,包含所有匹配的地址。
3. next() 的作用
e.search() 返回的是 生成器(可能有多个匹配项),但我们通常只需要 第一个匹配的地址。-
next() 用于获取生成器的 第一个结果(即第一个/bin/sh 的地址)。 - 如果没有找到
/bin/sh,next() 会抛出StopIteration 异常(说明需要换其他方法,比如从 libc 里找)。
题目:这是什么?GOT!
现代 Linux 可执行文件基本都是动态链接的 ELF 格式文件。大多数程序运行时都需要使用外部库函数(libc!),动态连接器 ld 架起了程序与动态链接库之间的桥梁。程序内部的 GOT(全局偏移表)负责记录这些库函数真正的地址或用于调用动态链接器的 PLT(过程链接表)项地址(首次调用前)。动态链接程序运行时不直接调用库函数,而是调用 PLT 项。在未完全开启 RELRO(重定向只读)保护时,我们可以在运行时修改 GOT!
试着在静态分析软件里实际看看这两个表,再动态调试单步进入走一遍动态链接库函数的调用过程。
拿到附件,先看看有啥保护? GOT表(一般GOT表都是默认是指 got.plt 节)可写
Partial RELRO (RELocation Read-Only) 详解
Partial RELRO 是一种二进制安全缓解技术,属于 RELRO(重定位表只读)的”部分启用”模式,旨在提高程序安全性但不如 Full RELRO 严格。
核心特点
GOT 保护机制:
- .got 节(非延迟绑定的函数表) :设置为只读
- .got.plt 节(延迟绑定的函数表,如 libc 函数) :保持可写
加载时行为:
- 程序启动时解析所有非延迟绑定的符号地址(如全局变量)
- 延迟绑定的函数(如 libc 函数)仍在使用时才解析
安全影响:
- 防止覆盖已解析的非延迟绑定符号
- 仍允许修改 .got.plt 中的函数指针(如
printf@got.plt)与 Full RELRO 对比
特性 Partial RELRO Full RELRO .got.plt 可写性 可写 只读 延迟绑定 启用 禁用 启动性能 较快 较慢(全部预先解析) 安全强度 中等 最高 对抗 GOT 覆盖攻击 部分防护 完全防护
看看 main() 函数
没有变量,但read() 函数读入的数据指向一个内存地址&off_404000,可以去看看。

我们点击&off_404000后,发现它是 GOT/PLT 表中 puts 函数的入口地址 0x404000
问问Deepseek,了解一下 off_404000:
1. 符号
off_404000 的含义
-
off_404000 是一个标签(label),代表puts 函数在 GOT/PLT 表中的条目地址(0x404000)。- 它的值是
offset puts,即动态链接后puts 函数的实际地址会被填充到这里(初始时指向 PLT 的解析逻辑)。
2.
&off_404000 的解析
-
& 是取地址运算符,所以&off_404000 直接获取符号off_404000 的地址。- 由于
off_404000 本身的地址是0x404000,因此&off_404000 = 0x404000。
3. 结合题目代码的漏洞点
在题目中:
c
1 read(0, &off_404000, 0x40uLL); // 向地址 0x404000 写入最多 64 字节数据
- 攻击者可以向
0x404000 写入数据,覆盖从puts 开始的连续 GOT 表项(包括write、system、printf 等)。- 因为 GOT 表项是连续的,写入
0x40(64)字节可以覆盖到exit 的 GOT 表项(0x404038)。
4. GOT 表结构示意图
1
2
3
4
5
6
7
8
9 地址 内容(初始指向 PLT 解析逻辑)
0x404000 off_404000 -> puts
0x404008 off_404008 -> write
0x404010 off_404010 -> system
0x404018 off_404018 -> printf
0x404020 off_404020 -> alarm
0x404028 off_404028 -> read
0x404030 off_404030 -> setvbuf
0x404038 off_404038 -> exit
5. 为什么这是关键漏洞?
- GOT 表可写(Partial RELRO 下):通过覆盖
exit 的 GOT 表项(0x404038),可以将其改为system 的地址(0x404010)。- 当程序调用
exit(0) 时,实际会跳转到system 函数,如果此时栈上或寄存器中有/bin/sh 字符串,就能获得 shell。
6. 攻击 Payload 示例
python
1
2
3
4
5
6
7
8
9
10
11
12 from pwn import *
# 覆盖从 puts 开始的 GOT 表项
payload = p64(0x404010) # 覆盖 puts -> system
payload += p64(0x404010) # 覆盖 write -> system
payload += p64(0x404010) # 覆盖 system -> system(占位)
payload += p64(0x404010) # 覆盖 printf -> system
payload += p64(0x404010) # 覆盖 exit -> system
# 发送 payload 后,程序调用 exit(0) 时会实际执行 system(0)
# 若在输入中传入 "/bin/sh\x00",且 rdi 指向它,即可获取 shell
p.send(payload + b"/bin/sh\x00")
总结
&off_404000 是地址0x404000,即 GOT 表中puts 的条目地址。- 通过覆盖 GOT 表,可以劫持函数调用流,这是 Partial RELRO 下的常见攻击手法。
程序调用完 read() 函数后,就直接调用 exit() 函数,结束程序
通过覆盖 exit 的 GOT 表项(0x404038),可以将其改为 system 的地址(0x404010)。
当程序调用 exit(0) 时,实际会跳转到 system 函数,如果此时栈上或寄存器中有 /bin/sh 字符串,就能获得 shell。
shift + F12 看看字符串String
既有 systen() 函数,又有 /bin/sh ,可以期待一下程序会不会直接给出后门函数了,把右边的函数列表一个一个给点开看看,
这不就有了。
1 | from pwn import * |
划重点:
1.(从大神uf4te的博客PLT表和GOT表 | 坠入星野的月🌙上搬下来的知识点,讲的不是这道题,但没关系,学的是知识)
elf.plt['write'] 输出的地址为 0x401030,与 IDA 中_write 地址相同,即 .plt 地址elf.got['write'] 输出的地址为 0x404018,该地址存放了真正的write() 函数地址,在 IDA 中为write 的 .got.plt 地址elf.symbols['write'] 输出的地址为 0x401030,与write() 函数的 PLT 地址相同elf.symbols['main'] 输出的地址为 0x401153,为main() 函数的地址,与 IDA 中main() 函数地址相同
由此可见:
elf.plt[] 获取动态链接库中的函数的 .plt 地址elf.got[] 获取动态链接库中的函数的 .got.plt 地址elf.symbols[] 获取程序本身的函数的地址,用于动态链接库中的函数时,获取的是 PLT 地址
2.为什么我在脚本里写入system_addr = elf.plt[‘system’]无法getshell,改为system_addr = 0x401056就可以getshell(这里就涉及到 PLT 条目的结构细节)
我最初的脚本是这样写的:
1 | from pwn import * |
结果:getshell 失败

这里可以从DEBUG给出的信息看看,我用于覆盖地址的system = elf.plt [‘system’] = 0x00000000000401050(前面的00没影响,简记为0x401050)(从左往右一个字节一个字节的取下来)
修改过后的脚本:
1 | from pwn import * |

那么问题来了,0x401050和0x401056 分别在哪里?

关于 PLT 跳转地址选择问题的分析
下面我将详细解释为什么
0x401056 能成功而0x401050 不能。PLT 条目结构解析
对于
system 函数的 PLT 条目:asm
1
2
3
4
5 _system proc near
jmp cs:off_404010 ; 第一次跳转到 GOT 表(初始指向下一行)
push 2 ; 函数在重定位表中的索引
jmp sub_401020 ; 跳转到动态链接器解析函数
_system endp关键区别
0x401050 (第一条指令)
- 这是 PLT 的常规入口点
- 第一次调用时会跳转到 GOT 表(初始指向
push 2 指令)- 之后会通过动态链接器解析真实地址
- 直接跳转到这里会导致无限循环:因为 GOT 未初始化时会指回 PLT,PLT又会指向GOT
0x401056 (push 指令)
- 这是动态链接的解析路径入口
- 跳过第一条
jmp 指令- 强制触发动态链接器解析函数地址
- 解析完成后,GOT 表会被填充为真实地址
为什么第二个能成功?
强制解析机制:
- 跳转到
0x401056 会直接执行push 2; jmp resolver- 动态链接器会填充
system 的真实地址到 GOT 表- 后续调用就能直接跳转到真实
system避免循环:
- 跳转到
0x401050 会导致 PLT→GOT→PLT 的循环- 而
0x401056 直接进入解析流程
总结
0x401050 是常规 PLT 入口,依赖已解析的 GOT 表0x401056 是解析路径入口,强制动态链接器工作- 在漏洞利用中,明确你需要的执行路径非常重要
这种细微差别正是二进制漏洞利用中需要特别注意的地方,理解 PLT/GOT 的工作机制对成功利用至关重要。
题目:这是什么?random!
你知道吗?计算机中很多所谓随机都是“伪随机”,生成随机数前需要提供“种子”,如果种子一样算法一样那么生成的随机数就一样!然而还有些“真随机”,无需种子也无法预测。
你需要在 GNU/Linux 环境中模拟随机过程,不同环境对
random 的实现可能不同。

你只要在三次猜数字的环节中至少猜对一个,你就能看到 flag(因为程序最后还是会输出 flag)。
🔢 第一阶段:循环猜数字
1 | while ( tests-- ) |
- 这里用的是
random(),种子是前面设定的:
1 | timer = time(0LL); |
srandom(v3->tm_yday); 也就是说,种子是今天是一年中的第几天(tm_yday),这个值是 0~365 的整数,极其容易爆破!
✅ 利用方式:
- 我们可以写一个小脚本模拟这个
random() 的过程,从 0 到 365 爆破一天,然后尝试预测random() 生成的数字,猜中即通过。
当然你还要知道
tests 是多少次循环,可以通过静态分析(或调试)确认。
dq 0Ah 的含义
dq (Define Quad-word)
表示在内存中分配一个 8字节(64位) 的空间(x86-64架构的标准字长)
类似的数据定义指令还有:
db (1字节)dw (2字节)dd (4字节)
0Ah
- 十六进制值
0xA,对应十进制10- 因此
tests 的初始值被设为10
🔐 第二阶段:/dev/random
1 | random_file = fopen("/dev/random", "rb"); |
- 这里从
/dev/random 读取 4 字节随机数,然后限制到10000~89999 之间。 - 因为
/dev/random 是系统强熵源,你无法预测这个数字。
❌ 无法预测。
🌀 第三阶段:arc4random
1 | secret = (unsigned int)arc4random() % 0x15F90 + 10000; |
arc4random() 是 libc 内部实现的安全随机数生成器,同样无法预测。
❌ 无法预测。
✅ 最后:flag 仍然给你!
1 | puts("You only got two of them wrong, flag still for you."); |
即使你只对了第一阶段(可预测)而第二、三阶段都错了,也会输出 flag!
💡 总结:利用策略
你只要猜中第一轮(即使用 srandom(tm_yday) 预测 random()),就能拿到 flag。
可以写一个利用脚本如下(Python 伪代码):
1 | from pwn import * |
题目为猜数字游戏,生成随机数的种子是 一年中当前天数 - 1(localtime(…).tm_yday)。照抄附件程序中生成随机数的逻辑并预先生成好 10 个随机数并依次输入即可。为方便,也可使用 Python ctypes。
💡 核心细节:种子设置
1 | libc = cdll.LoadLibrary("libc.so.6") |
这是全剧的核心!
- 脚本作者发现:题目用的是
tm_yday 作为种子 - 但 Python 的
time.localtime() 返回的tm_yday 是 1-based(范围是1~366) - 而 C 的
tm_yday 是 0-based(范围是0~365)
👉 所以要减 1 才能和题目里的 srandom(tm_yday) 行为一致!
🎯 猜数字核心逻辑
1 | for _ in range(10): |
- 题目要你猜 10 个五位数
- 用 Python 本地的 libc 和题目一样的种子模拟出同样的
random() 序列 - 然后直接 sendline 猜中!
题目:这是什么?shellcode!

程序反编译失败

有题目可知,这是一道简单的shellcode,程序直接调用read()读入我们的输入到栈上,再将栈上的输入存进 rdx 寄存器,再调用rdx寄存器(!!!),call 指令只能用来调用函数,而寄存器中存的是数值,IDA反编译时不知道 rdx寄存器中存进的会是一段可执行的机器码,所以报错。

选取这段汇编,Ctrl+n 将它换为 nop(一个无意义的填充物)

再按F5,反编译成功

buf到栈底的距离为0X110,程序限制输入长度为0x100,这里无法栈溢出

GOT表可写,堆栈可行,存在同时具备读、写、执行权限的内存段(shellcode)
1 | from pwn import * |
题目:Catch_the_canary!
Canary in a coalmine.
你听说过金丝雀(canary)吗?我在栈里养了一只,关在笼子里。下次你再把栈玩坏的时候,它会告诉我。只准看不准碰嗷。


这题主要有四道验证(其他的不需要专门记录)
按 shift + F12,查看字符串

既有 system 函数,又有 /bin/sh,可以期待一下会不会直接给出后门了,在函数里翻翻找找,还真有

那这样的话,这题主要需要突破 main() 里的四道验证,然后直接ret2txt到达后门函数就getshell了。
然我们在好好看看这四道验证。

首先生成了两个随机数(没有随机数种子,看来是真随机),v3和v8,将 v3 经过了一番操作后,给了v9[0]。
来到第一关
要求输入的值,必须等于随机数v8,且不等于 -1,若不满足条件,则重复循环,好好看看v8的生成过程
1 | v8 = (unsigned int)arc4random(argc, 0LL, v4) % 0x2345 + 16768186; |
发现 16768186 <= v8 <= 16768186 + 0x2345(=9029),这个范围比较小,且程序语序我们重复输入,就可以直接爆破
cage_bak = v9[0]; // 备份初始值,后续会检验cage_bak ?= v9[0],即检验v9[0]是否还是原来的值,有无被覆盖成其他值
来到第二关
先输入值到 v[0],v[1],v[2] (程序定义的数组是__int64 v9[2];,实际数组只有v9[0],v9[1],不包含v9[2],那这里读入的v9[2]到哪里去了?)v9[2]是v9[1]的下一个8字节地址,而实际上v9[1]的下一个8字节地址是 v10,所这里我们输入的数据实际上到了v10。
程序写的是
1 | v10 = 182271573LL; |
v10 = 182271573,(LL代表定义的是一个它是一个长整数,是个 64 位的整数)
验证要求:v10 = 195874819
1 |
|
所以这关只需当i = 2 时,输入195874819,来覆盖v10即可。
来到第三关
前面令cage_bak = v9[0];,后面又检验cage_bak ?= v9[0];,不等就退出程序
1 | if ( v9[0] != cage_bak ) |
前面说过,v9[0] 是一个真随机数,无法预测,而又要scanf(),输入数据到v9[0],v9[1],我们既无法知道v9[0]会是多少,也几乎不可能猜中,怎么办呢?😧 (hint)🥰

哇!
✅ scanf 输入 + 或 - 的绕过特性
scanf("%zu", &x) 或 scanf("%u", &x) 在读取无符号数字时,如果输入的是非法的内容(比如 + 或 - 而没有数字),scanf 会返回 0,并不会改变 x 的值,而且不会跳过这一轮循环,而是进入下一个 scanf。
但注意:
- 如果输入的是
-1,那么scanf 会读入一个非常大的无符号数(2^64 - 1),因为是%zu。 - 如果你输入的是纯符号(如
+ 或-),scanf 不会读取成功,但它也不会消费掉输入,可能导致死循环。
在这题里,这个特性可以用来跳过输入某些字段。
来到第四关
1 | read(0, buf, 0x30uLL); |
不用多说,通过栈溢出覆盖canary的最低的第8位字节位 \x00(空字节),使得puts()不会因为遇到空字节(\x00)而停止输出,从而在把 buf 输出的同时连带着我们 canary 的前7个字节一起输出,就可以得到canary的值
就此,四关已过,getshell,上脚本。
1 | from pwn import * |
最后的最后,还有一点要提出来学习的。
1 | io.send(cyclic(25)) |
我们用cyclic 生成25个字节,为什么可以确定第25个字节是 g !,从而单独取出后面带出来的 canary ?
在 pwntools 中,cyclic 生成的字符串不一定以 'g' 结尾,它的结尾字符取决于你请求的长度和 cyclic 的内部模式。以下是详细分析:
1. cyclic 的默认行为
cyclic 生成的字符串是一个循环递增的 4 字节序列,其字符顺序遵循:
复制
1 | aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaama... |
每 4 字节为一组,按字母表顺序递增(
a →b →c → … →z)。是否以
'g' 结尾取决于输入长度:- 若长度
n 满足n % 4 == 3,则最后一个字符通常是'g'(因为'gaaa' 是第 7 组,'g' 是第 7 组的第一个字符)。 - 其他长度可能以任意字母结尾。
- 若长度
2. 你的代码中的情况
你的脚本中使用了 cyclic(25):
python
1 | io.send(cyclic(25)) # 25 % 4 = 1,所以最后一个字符是 'a'(第25字节是 'aaaabaaa...aaaa' 的第1字节) |
cyclic(25) 的最后一个字符是'a'(因为 25 ÷ 4 = 6 余 1,第 25 字节是第 7 组的第 1 字节'g' 的前一个字符)。- 但
recvuntil(b'g') 的目的是过滤输出中 canary 之前的无关字符(例如程序打印的"Canary: 0x..." 中的'g'),而非依赖cyclic 的结尾。
3. 为什么 recvuntil(b'g') 能工作?
程序在打印 canary 时可能会输出类似格式。
1
Output: ...Error: Invalid input 'aaaagaaa'\nCanary: 0x7f123456
'g' 是cyclic 字符串中间的某个字符(如'aaaagaaa'),而非结尾。
recvuntil(b'g') 会持续读取数据,直到遇到第一个 'g' ,从而对齐到 canary 泄露位置。
4. 关键结论
cyclic 不保证以 'g' 结尾,但它的模式中必然包含'g'(因为字母表循环)。recvuntil(b'g') 的目的是定位 canary 前的固定输出,而非匹配cyclic 的结尾。若想精确控制结尾字符,可以手动构造字符串:
python1
payload = cyclic(24) + b'g' # 强制以 'g' 结尾
5. 验证方法
你可以直接打印 cyclic(25) 观察结果:
python
1 | from pwn import * |
- 输出为
aaaabaaacaaadaaaeaaafaaa,确实不以'g' 结尾,但内部包含'gaaa' 子串。
总结
-
cyclic 不固定以 'g' 结尾,但它的模式中一定会出现'g'。 -
recvuntil(b'g') 的作用是同步 I/O,而非依赖cyclic 的结尾。 - 若需对齐 payload,建议结合上下文动态调整接收逻辑(如
recvuntil(b'Canary: '))
这段代码是一个 CTF 中常见的 PWN 题目的主函数逻辑,包含了输入验证、canary 检查、防护机制,以及最终可控的缓冲区溢出点。我们逐部分来分析:
🧠 全局变量和栈变量说明
1 | int v6; // 用户输入的密码 |
🔒 栈保护初始化
1 | v12 = __readfsqword(0x28u); |
- 读取 FS 段寄存器的 offset 0x28,对应 x86_64 的栈 canary。
🔐 生成初始 canary 值
1 | v3 = arc4random(argc, argv, envp); |
- 利用
arc4random 加上一些看似复杂的混淆计算,生成一个隐藏的“笼子锁”v9[0]。 cage_bak = v9[0] 后续用于校验。
🧪 输入密码环节
1 | v8 = arc4random(...) % 0x2345 + 16768186; |
- 生成一个随机密码(值在
16768186 ~ 16770552 范围)。
1 | while ( (unsigned int)__isoc99_scanf("%u", &v6) == -1 || v8 != v6 ) |
- 要求用户输入正确密码,否则无限循环。
🗝️ 第二道锁:cage in cage
1 | for ( i = 0; i <= 2; ++i ) |
- 用户可以输入最多两个值(写入 v9[0], v9[1]),输入
0 则中断。 - 之后进行两次检查:
🔐 Canary 机制
1 | if ( v10 != 195874819 ) |
v10 初始值为182271573,若其在执行后被篡改为非195874819,说明栈内容被修改(可能是溢出),程序退出。- 这是一个“伪 canary 检查” :只要你能控制
v10 为195874819,你就绕过了检查。
📦 Cage 检查
1 | if ( v9[0] != cage_bak ) |
- 验证
v9[0] 是否和原先的cage_bak 一致,如果不一致,也会退出。
💥 漏洞点出现
1 | read(0, buf, 0x30uLL); |
这里有两个关键点:
- 溢出漏洞:
buf 是 24 字节,而读入了 0x30(48 字节),存在 24 字节的栈溢出空间。 - 信息泄露:
puts(buf) 会将你输入的内容打印出来,如果你溢出控制到了返回地址附近并构造格式化字符串或 ROP,也许可以泄露 libc。
- 溢出漏洞:
📌 总结
该程序的结构如下:
第一道验证:输入正确的密码(可爆破或通过信息泄露推测)。
第二道验证:
- 保持
v10 == 195874819(绕过canary检查) - 保持
v9[0] == cage_bak(需要回显或推测计算方式)
- 保持
溢出点:最后的
read(buf, 0x30) 溢出点,可以覆盖返回地址并执行攻击(ROP / shellcode 等)。
🛠️ 你可以做什么:
- 利用
puts(buf) 泄露 libc 地址。 - 在第二次
read 时进行栈溢出,执行攻击。 - 关键是绕过
v10 和v9[0] 的两个检查点。
题目:LoginSystem
一个简单的登陆系统,但是为什么要输出用户名?
咱们直接看关键函数

两次read(),都无法溢出,连 rbp 都碰不到,连栈迁移都别想,但可以格式化字符串,还有system(/bin/sh)存在,。看看程序保护?

栈溢出不现实,还是选择利用格式化字符串泄露 password 的值,当然也可以利用%n,修改 password 的值,那然我们看看password 。

password位于.bss 段上,不在栈上,属于全局变量。

利用%p,泄露栈上参数的数据,我们读入数据到 buf ,则buf是第8个参数,我们想要写入 password ,就要将 password 的地址写在后面,写入第9个参数,然后修改第9个参数的值。
1 | from pwn import * |
通过栈上格式化字符串漏洞覆盖password全局变量,板子题。要注意的是格式化字符串应该在前面,而地址应该在后面,不然格式化字符串会被\x00截断。
1 | payload = b"%9$ln".ljust(8, b'\x00') + p64(password) |
将%9$ln补充为8字节,将第8个参数占满(在x86-64的架构中,一个参数占8个字节),后面的 password 的地址,只能进入第9个参数的位置,然后我们在向第9个参数的值(是一个地址),所指的地址里写入 0 。

讲解:printf("Hello%8$n")
1. 基本解释
%n 的作用是 将已输出的字符数写入指定地址(需要一个指针参数)。%8$n 中的8$ 表示 直接访问第8个参数(而不是按顺序使用下一个参数)。
所以,printf("Hello%8$n") 的意思是:
- 输出
"Hello"(5个字符) - 然后使用
%8$n 尝试将数字 5 写入栈上第8个参数指向的地址。
2. 为什么这样写是危险的?
(1)缺少参数
printf("Hello%8$n") 只提供了格式化字符串,但没有提供额外的参数(如 %n 需要的指针地址)。
此时,printf 会 直接从栈上读取第8个参数的位置,并尝试将其作为指针写入 5(”Hello”的长度)。
(2)可能导致崩溃或任意内存写入
- 如果栈上第8个位置恰好是一个可写的内存地址,
%8$n 会向该地址写入5,可能导致 数据篡改。 - 如果第8个位置是无效地址(如
NULL 或不可写内存),程序会 崩溃(Segmentation Fault) 。
NX_on!
解题思路
题目存在Canary保护,可以通过覆盖Canary的最低一位的’\x00’来使得puts()将Canary的值打印出来,然后在ida 里 Shift+F12 查看程序的字符串可以发现有/bin/sh无system,由于是静态编译的程序,可以打系统调用,主要是接下来长度检验
1 | _isoc99_scanf((unsigned int)"%d", (unsigned int)&n16, v0, v1, v2, v3); |
我尝试的是**-1**,但不对,看了官方解答后,才明白
-1为什么不行先说
-1为什么不行吧,运行后发现它出错是因为RIP被置为0了(无效的地址)。 进gdb调试一下,发现问题出在调用memcpy()函数之后栈顶返回地址被置为0了,因此在执行ret时会直接报段错误。 问题指令:
1 ► 0x4482b7 <__memmove_avx_unaligned_erms+567> vmovdqa ymmword ptr [rcx + 0x60], ymm1在执行这条指令之前,rsp为:
1 >RSP 0x7ffdb7f2e018 —▸ 0x401cae (func+308) ◂— jmp 0x401cbf执行之后:
1 >RSP 0x7ffdb7f2e018 ◂— 0这里是因为
RCX寄存器中的地址太靠近栈顶了。
1
2
3
4 >pwndbg> p $rcx + 0x60
>$1 = 140732357202144
>pwndbg> p $rsp - $1
>$2 = (void *) 0x18注意 :
ymmword大小为32字节,因此显然这里往高地址复制的时候rsp指向的返回地址被覆盖率 RCX在执行到这步之前进行了多次改变,受到输入数值大小的影响。
-2147483528为什么不行至于
-2147483528为什么不行,则是另一个原因:
1 >► 0x44828c <__memmove_avx_unaligned_erms+524> vmovdqu ymm8, ymmword ptr [rsi + rdx - 0x20]如你先前所说,程序卡在这里。
1
2 >pwndbg> p/d $rdx
>$4 = -2147483528
rdx中存有你输入的size。
1
2
3
4 >pwndbg> p/x $rsi + $rdx - 0x20
>$2 = 0xffffffff804e5bb8
>pwndbg> x/x $2
>0xffffffff804e5bb8: Cannot access memory at address 0xffffffff804e5bb8从这个结果来看程序显然无法访问到
[rsi + rdx - 0x20],那么试图从这里加载数据自然会导致错误。总的来讲,其实对
memcpy()传入负值本就会导致非预期的行为,其内部的较复杂指令实现会导致不能很好掌控输入负值后的结果—–官方题解—–
EXP
1 | from pwn import * |
Moeplane
解题思路
附件图片给出的结构体如下:
1 | /* Size: 24 bytes, alignment 8 bytes */ |
- 结构体数据存储与空间对齐
- 数组越界
- 小端序
连接后,可以看到
1 | ╭─icyice@icyice-virtual-machine ~/Desktop/MoeCTF/MoeCTF 2024/WhereIsFmt |
这些输出什么意思呢?
1 | 背景:飞机即将坠毁,你需要采取行动。 |
题目存在负数前溢出
EXP
1 | from pwn import * |
where_is_fmt
解题思路:
非栈上的格式化字符串漏洞,可以看到题目提示了栈上有些比较长的链子,可以想一下怎么利用这些链子
我们可以利用%kc%k$hn来修改栈上的指针的内容
比如 这种情况:
1 | rbp: addr_1 -> addr2 |
addr_A -> addr_B -> addr_C这种就是一个长链,我们假设addr_A是printf的第 x 个参数,addr_B是printf的第 y 个参数 ,那我们就可以通过%k$c%x$hn来修改addr_C为其他地址 (比如返回地址),即为addr_A -> addr_B -> return_addr
就会变成:
1 | rbp: addr_1 -> addr2 |
在这之后我们就可以通过%k$c%y$hn来修改main+0x38为其他地址 (比如backdoor()地址),即为 addr_B -> return_addr -> backdoor_addr,这就会变成:
1 | rbp: addr_1 -> addr2 |
这样就可以完成一个简单的ret2text。这种思想的核心就是 利用%k$c%k$hn来修改栈上(addr_A)的指针(addr_B)的内容(addr_C)
在本题中的变化过程就是
第一次利用格式化字符串漏洞得到stack后


第二次利用格式化字符串漏洞后,第三次利用格式化字符串漏洞前


第三次利用格式化字符串漏洞后


EXP
1 | from pwn import * |








