crypto-1:自参考密钥的分组密码

脚本逻辑

附件是 task.py 加 output.enc。脚本把 FLAG 文件按二十四字节分块,每块拆成六个 big-endian 三十二位字,命名为 flag[0] 到 flag[5]。

关键在前面几行:

key = [flag[i + 2] for i in range(4)]
e1 = enc1((flag[0], flag[1]), key)
e2 = enc2(*key)

密钥直接取自同一块明文的第 2 到第 5 个字。这种自参考结构意味着只要能把 enc2 逆回去,立刻得到这一块的后四个字,再拿它当密钥逆 enc1 就得到前两个字,整块不需要爆破。

enc1 的变形

enc1 是 TEA 变体,改动在常数和轮次结构上:

static const uint32_t DELTA = 0x87654321;

void enc1(uint32_t *v, uint32_t *k) {
    uint32_t sum = 0;
    uint32_t v0 = v[0], v1 = v[1];
    for (int i = 0; i < 8; i++) {
        sum += DELTA;
        v0 += ((v1 << 4) + k[0]) ^ (v1 + sum) ^ ((v1 >> 5) + k[1]);
        v1 += ((v0 << 4) + k[2]) ^ (v0 + sum) ^ ((v0 >> 5) + k[3]);
    }
    for (int i = 0; i < 8; i++) {
        sum += DELTA;
        v0 += ((v1 << 4) + k[0]) ^ (v1 + sum) ^ ((v1 >> 5) + k[1]);
        v1 += ((v0 << 4) + k[2]) ^ (v0 + sum) ^ ((v0 >> 5) + k[3]);
    }
    v[0] = v0;
    v[1] = v1;
}

标准 TEA 是十六轮单循环,每轮开始时 sum += DELTA。这里拆成两个八轮循环,sum 连续累加不重置,所以数学上等价于十六轮单循环,只是代码结构变了。delta 从标准的 0x9E3779B9 换成了 0x87654321。

这种改动不改变可解性,因为 TEA 的解密只需要逆向轮次顺序。解密函数把加密的两段循环倒着写,sum 从累计终点递减,每轮先解 v1 再解 v0:

void dec1(uint32_t *v, uint32_t *k) {
    uint32_t sum = DELTA * 16;
    uint32_t v0 = v[0], v1 = v[1];
    for (int i = 0; i < 8; i++) {
        v1 -= ((v0 << 4) + k[2]) ^ (v0 + sum) ^ ((v0 >> 5) + k[3]);
        v0 -= ((v1 << 4) + k[0]) ^ (v1 + sum) ^ ((v1 >> 5) + k[1]);
        sum -= DELTA;
    }
    for (int i = 0; i < 8; i++) {
        v1 -= ((v0 << 4) + k[2]) ^ (v0 + sum) ^ ((v0 >> 5) + k[3]);
        v0 -= ((v1 << 4) + k[0]) ^ (v1 + sum) ^ ((v1 >> 5) + k[1]);
        sum -= DELTA;
    }
    v[0] = v0; v[1] = v1;
}

TEA 解密有一个容易写错的地方。标准实现里解密轮次里 v1 的更新用的是 v0 的新值,v0 的更新用的是 v1 的旧值,两行的先后顺序和加密时相反,sum 的递减位置也在两行之后。写错顺序不会报错,只会解出另一串看似随机的数。核对办法是拿加密函数对解密结果再加密一遍,与原密文比对。

delta 换成 0x87654321 之后对可解性没有任何影响,因为 TEA 的安全性不依赖 delta 的具体取值,delta 只需要保证轮函数的扩散性。真正影响安全的是轮数和 Feistel 结构本身,这两处没动。

enc2 的结构

第二段是个顺序混合函数:

void enc2(uint32_t *v, uint32_t *k) {
    uint32_t a = v[0], b = v[1], c = v[2], d = v[3];
    a = ((a + b) ^ c) & 0xFFFFFFFF;
    b = ((b - c) ^ d) & 0xFFFFFFFF;
    c = ((c + d) ^ a) & 0xFFFFFFFF;
    d = ((d - a) ^ b) & 0xFFFFFFFF;
    v[0] = a; v[1] = b; v[2] = c; v[3] = d;
}

四步之间有依赖,但每一步都是双射,逆推时按相反顺序做逆运算即可。

def dec2(v):
    a, b, c, d = v
    d = ((d ^ b) + a) & 0xFFFFFFFF
    c = ((c ^ a) - d) & 0xFFFFFFFF
    b = ((b ^ d) + c) & 0xFFFFFFFF
    a = ((a ^ c) - b) & 0xFFFFFFFF
    return [a, b, c, d]

还原路径

每行输出是六个字,前两个是 enc1 的结果,后四个是 enc2 的结果。

处理顺序是先解 e2 得到本块的后四个明文词,这同时就是 enc1 的密钥,再解 e1 得到前两个词。十三行全部拼起来就是完整明文。

状态说明

明文在本地被完整还原,三层独立验证都通过,往返加密能闭合。但这一题的 flag 字符串没有拿到任何外部回执,既没有平台判对也没有独立校验器能给出定论。按照口径,它只能登记为明文已证明恢复、flag 未验证。

这一点在投稿时还是讲清楚比较好,避免误导。

crypto-2:二十组模数里的共享素因子

输入形态

附件 1.txt 的结构是 e = 65537 后面跟二十组 n 和 c,每组模数都是 2048 位,密文长度在 2042 到 2048 位之间。文件开头有个 m = xxxxxxxx,那是占位符,不是真实明文。

二十组同指数密文批量给出,这个形态本身就是信号。正常题目不会一次给二十组,除非出题人预期你在这批模数之间做比对。

缺陷体检

先做一轮基础排查,把常见缺陷逐个过一遍:

from math import gcd

ns = [int(x) for x in open("moduli.txt")]

for i in range(len(ns)):
    for j in range(i + 1, len(ns)):
        g = gcd(ns[i], ns[j])
        if g != 1:
            print(i, j, g.bit_length())

输出命中 (4, 17) 这一对,公因子 1024 位。其余 378 对全部互素。二十组里只有这一对共享因子,是确定性缺陷不是巧合。

这个结果的确定性可以从概率上说明。两个独立生成的 1024 位素数相同的概率约为 2 的负 1024 次方,实际上不可能发生。一旦 gcd 非平凡,只能是生成侧的随机源出问题,典型场景是熵池未初始化、种子硬编码、或者同一进程里连续生成多对密钥时 RNG 状态未更新。审计报告里写共享素因子时,把这三个场景写进去比只写使用了相同素数要有信息量。

同时把小因子试除、Wiener 连分数、模数是否为素数幂这三项也过了一遍,都没有命中。这就把范围收敛到共享素因子这一种。

Wiener 攻击的排除过程也值得记一笔。连分数展开 e/n 后逐个收敛子验证 d^2 < n/3 且 ed 减 1 能被 k 整除,二十个模数全部无解。这说明私钥指数没有刻意取小,出题人的缺陷设计集中在密钥生成而不是使用环节。

def wiener(e, n):
cf = []
f = Fraction(e, n)
while f.denominator:
cf.append(f.numerator // f.denominator)
f = 1 / (f - cf[-1]) if f - cf[-1] else Fraction(0)
yield cf[:]

攻击

拿到共享素因子之后剩下的是标准流程:

p = gcd(ns[4], ns[17])
q4 = ns[4] // p
q17 = ns[17] // p
assert p * q4 == ns[4]
assert p * q17 == ns[17]

phi = (p - 1) * (q4 - 1)
d = pow(e, -1, phi)
m = pow(cs[4], d, ns[4])
plain = m.to_bytes((m.bit_length() + 7) // 8, "big")

正向重加密回环是必须做的:

assert pow(m, e, ns[4]) == cs[4]

只有回环通过才能确认恢复的明文正确,因为私钥指数算错时 m 仍然是个数值,不会报错。

flag{abdcbe5fd94e23b3de429223ab9c2fdf}

检出成本

批量 RSA 题目里共享素因子的检出成本极低,两两求 gcd 是 O(k²) 次大数除法,二十组只要一百九十次。实际比赛中如果看到多个模数并列给出,这一步应该是第一动作,比试 Wiener 或者查因子库都快。

crypto-3:OTA 许可载荷的两条独立路径

材料

题面是一台保护测控装置支持固件 OTA 升级,升级包尾附许可载荷,厂商侧用设备公钥封装,设备侧用私钥解封。固件摘要签名和许可载荷封装复用同一批 RSA 密钥。

给的两份文件是密钥台账 firmware_manifest.json 和历年封装记录 sealed_blobs.json。台账里有三个批次,一个在役、一个已吊销、一个已被取代,每批的许可载荷里都埋了一段 flag。

题面明说存在两类缺陷,各对应一条独立可解的攻击路径。这句话等于给出了验收标准,两条路都要走通。

台账读出的结构

{
  "batch_id": "FW-2026Q3-A",
  "status": "current",
  "keys": [
    {"key_id": "PUB-A1", "n_bits": 1024, "slot": "active"},
    {"key_id": "PUB-A2", "n_bits": 1024, "slot": "active"},
    {"key_id": "PUB-A3", "n_bits": 1024, "slot": "active"}
  ]
}

三个批次分别是 FW-2026Q3-A 状态 current、FW-2026Q2-B 状态 revoked、FW-2026Q1-Z 状态 superseded。目标是在役批次。

封装记录九条,字段是 record_id、batch_id、key_id、slot、sealed。在役批次对应 record_id 一到三。

共享素因子那条路

和上一题同源,但这里跨批次比对更有效:

from gmpy2 import gcd, is_prime

pairs = [("PUB-A1", "PUB-B1"), ("PUB-A1", "PUB-Z1"), ("PUB-B1", "PUB-Z1")]
for a, b in pairs:
    g = gcd(n[a], n[b])
    if g.bit_length() > 64:
        print(a, b, g.bit_length(), is_prime(g, 40))

三对都得到 512 位公因子,is_prime 四十轮确认是素数。同一个素数被三个批次的密钥复用,等于三个批次的私钥全部失效。

解出三份明文:

NRWF-OTA-LIC/FW-2026Q3-A#flag{Sh4r3d_Pr1m3_0v3r_0TA_26}
NRWF-OTA-LIC/FW-2026Q2-B#flag{R3v0k3d_Br0adc4st_e3_K26}
NRWF-OTA-LIC/FW-2026Q1-Z#flag{R3us3d_Pr1m3_Fr0m_RNG_26}

三份都能解,按状态取在役的那份。

小指数广播那条路

第二条路完全不依赖共享素因子,走的是小指数广播。

在役批次的三条记录,公钥指数都是 3,模数两两互素,封装的是同一段明文。这正好是 Håstad 攻击的充分条件。

from sympy.ntheory.modular import crt

ns = [n["PUB-A1"], n["PUB-A2"], n["PUB-A3"]]
cs = [sealed[1], sealed[2], sealed[3]]

M, _ = crt(ns, cs)
assert M < ns[0] * ns[1] * ns[2]

root, exact = gmpy2.iroot(M, 3)
assert exact
p = root.to_bytes((root.bit_length() + 7) // 8, "big")

CRT 合成的模数是 1315 位,开立方后得到精确整数立方根,说明合成值确实小于三个模数的乘积,不需要 Coppersmith 那一步。

这条路径解出来的明文和路径一完全相同,构成交叉验证。

验收入口

两条路径产出同一结果,加上逐条记录的 pow(m, e, n) == sealed 回环,才算闭环。

flag{Sh4r3d_Pr1m3_0v3r_0TA_26}

这道题的设计值得记一笔。同一条链上埋两种缺陷,逼你分别走通,而不是一个缺陷打穿所有。而且三种状态的批次各有一份能解开的载荷,只有状态字段能裁决。

crypto-4:VHF 链路的 LFSR 密钥恢复

材料与已知明文

抓包日志 radio_capture.log 里混有上下行帧,三条上行通道各有一段载荷被改写成 flag,但只有台账 radio_policy.json 里状态为当前有效的通道才是有取证价值的。

链路的密钥由 LFSR-64 生成。这一点是从帧结构里看出来的,不是题面给的。

关键突破口是每帧开头的固定明文。帧首八字节固定为 A5 5A <ch_id> 'NRWF-',这是链路层标识。八字节已知明文在 LFSR 场景下就是八八六十四个比特方程。

建立线性方程组

LFSR 的输出直接参与异或加密时,每一帧给出六十四个方程,待求的是六十四个比特的初始状态。理想情况一帧就够,实际因为可能存在填充或者计数器,需要多帧联立。

def build_system(frames):
    rows = []
    for frame in frames:
        ks = keystream_bits(frame)
        for bit, known in zip(ks, frame.known_plaintext_bits):
            rows.append([bit[i] ^ known for i in range(64)])
    return rows

联立十帧得到六百四十个方程,高斯消元之后秩为六十四,残差为零。秩等于未知数个数说明解唯一,残差为零说明模型正确。

通道与状态

三条上行通道的密钥各自独立。按通道分组之后,每条通道内部十帧反推出的初始状态唯一。

台账里的状态字段是裁决依据:

{
  "channels": [
    {"ch_id": 1, "status": "current", "ctr_range": [54, 54]},
    {"ch_id": 2, "status": "rotated", "ctr_range": [20, 21]},
    {"ch_id": 3, "status": "revoked", "ctr_range": [30, 31]}
  ]
}

只有 ch1 是当前有效。它的计数器是 0x0036。

解密之后:

flag{N4rr0wb4nd_LFSR_St4t3_26}

第二条验证路径

不假设密钥模型,直接逐帧解 LFSR 初始状态,再从初始状态反推:

key = ror64(state, 13) ^ (salt * ctr)

同一通道十帧反推出的值完全相同,这条路径不依赖前面建立的线性模型,是独立复现。

还有一条旁证。CH2 有两帧计数器都是 0x14,密文之差正好等于明文之差逐字节对应,这是计数器复用的直接体现。

三条证据一致才确认解码正确。LFSR 类题目容易出模型选错但解出可读内容的情况,多一条独立路径很有必要。

crypto-5:GCM 的 nonce 复用与 GHASH 子密钥

台账给出的契约

三个附件分别是帧日志、链路台账、许可封存。台账 telemetry_policy.json 把协议细节写得很清楚:

profile      NRWF-TELEM-V2
cipher       AES-128-GCM, nonce 12B, tag 16B
nonce        frame_ctr(4B BE) || dev_epoch(8B BE)
aad          16 字节链路标识块
plaintext    定长 32 字节, 前缀 NRWF-T2 SEQ=, 结尾 CH<n> TMP=<4位>
license      HKDF-SHA256(ikm = H), salt = NRWF-TELEM-LIC-v2, info = ch<id>
ctr_mode     16 字节 iv 起, 整体按 128 位大端加一
integrity    HMAC-SHA256(key, iv || ct)[:16]
validity     仅 status=current 的通道有效

题面说上行帧计数器异常跳变,这是 nonce 复用的强提示。

定位复用组

按 nonce 和 AAD 分组统计:

from collections import Counter

groups = Counter((f["nonce"], f["aad"]) for f in frames)
for (nonce, aad), cnt in groups.items():
    if cnt > 1:
        print(nonce, cnt)

得到三个复用组,每组三帧:

0000002a00000000a1b2c3d4  ch1  seq 5, 7, 9
0000009a00000000a1b2c3d4  ch3  seq 11, 14, 17
0000005c00000000a1b2c3d4  ch2  seq 13, 15, 18

同组内 nonce 相同、AAD 相同、长度相同,满足 GCM 灾难性复用的全部条件。seq 24 那一帧是 epoch 递增之后的,不复用。

由 tag 差分线性求解 H

GCM 的认证标签是 GHASH 的多项式求值。同一 nonce 下两帧的 tag 异或,未知项会部分抵消,留下关于子密钥 H 的线性方程。

32 字节明文等于两个密文块,16 字节 AAD 等于一块,所以 GHASH 的次数是固定的。三帧两两差分可以给出足够多的方程。

先把 GHASH 的结构写清楚。对长度为 m 块的密文和 n 块的 AAD,求值形式是:

X0 = 0
Xi = (X(i-1) ^ Ci) * H        对 i = 1..m
X(m+1) = (Xm ^ A1) * H
X(m+2) = (X(m+1) ^ lenblock) * H
tag = Ek(J0) XOR X(m+2)

同一个 nonce 下 J0 相同,所以 Ek(J0) 项在两帧 tag 相减时被消掉,剩下的部分就是关于 H 的多项式。两帧的明文长度相同时,lenblock 也相同,再消一次,方程变得更干净:

tag1 ^ tag2 = (C11^C21) * H^3  ^  (C12^C22) * H^2  ^  (A11^A21) * H

同组内 AAD 相同,第三项也为零。剩下的就是关于 H 的三次多项式,在 GF(2^128) 上求根即可。

乘法本身是模 x^128 + x^7 + x^2 + x + 1 的多项式乘法,写成位运算:

def gf128_mul(a, b):
    p = 0
    for i in range(127, -1, -1):
        if (b >> i) & 1:
            p ^= a
        a = (a >> 1) ^ (0xE1000000000000000000000000000000 if a & 1 else 0)
    return p

求根用 SageMath 的商域比手写快速方便得多:

R.<x> = PolynomialRing(GF(2))
S.<a> = R.quotient(x^128 + x^7 + x^2 + x + 1)
h = S.from_integer(candidate)
diff = S.from_integer(tag1 ^^ tag2)
poly = (S.from_integer(d11) * h^3 + S.from_integer(d12) * h^2)
roots = poly.roots(multiplicities=False)

实际操作里根不止一个,因为三次多项式在 GF(2^128) 上可能有多达三个根。区分真根的办法是拿第三个已知量验证:候选 H 代入第三帧的 GHASH,算出的 tag 必须和抓包里的相等。三个根里只有一个能同时满足三帧,筛选完剩下的就是真 H。

这一步有个实现细节容易出错。GHASH 的位序是反射的,块内字节按大端进入域元素时,位序要与 NIST SP 800-38D 的定义严格一致。第一次实现时 tag 全对不上,逐位对拍才发现是块内位反转没做。这种错误不会报错,只会安静地给出错误的多项式,然后求根求出错误的 H,最后 HMAC 校验失败,看起来像密钥错而实际上只是字节序错。

解出 H 之后走两条路:一是用 H 反推其他帧的明文,二是直接用 H 走 HKDF 解开许可。

H 之所以能当 HKDF 的输入密钥材料,是因为它在整条链路里扮演的角色就是设备侧的长期派生种子,泄露 H 等价于泄露所有许可的派生源。这一点从台账的许可封存条款里能读出来,ikm = GHASH 子密钥 H 是明写的。设计上把密钥协商的根和认证的子密钥绑在同一处,省一次存储,代价是子密钥一泄露全线失守。

from hashlib import sha256
import hmac

def hkdf(ikm, salt, info, n=32):
    prk = hmac.new(salt, ikm, sha256).digest()
    okm = b""
    t = b""
    i = 1
    while len(okm) < n:
        t = hmac.new(prk, t + info + bytes([i]), sha256).digest()
        okm += t
        i += 1
    return okm[:n]

多份许可的裁决

这里有个刻意设计的陷阱。三份许可都能被正确解开,HMAC 全部校验通过,但只有 ch1 是 status=current。ch2 和 ch3 的明文里自己写着 N0t_4_Fl4g 和 N0t_4_Us3,属于诱饵。

能解开不等于有效。必须按台账的 status 字段选。

flag{GCM_N0nc3_R3u53_1s_F0rb1dd3n_26}

这道题的技术点在 GF(2^128) 上的多项式运算。手写和用 SageMath 商域两条路径都实现了一遍,结果逐位相同,三份许可的 HMAC 全部匹配,这种密码学自证比看明文是否可读要可靠得多。

misc-1:配置线索与后缀撒谎的图片

两个附件

db_config.sys 是 256 字节的 INI 风格文本,CRLF 换行。encode.png 是 169885 字节,但 file 出来是 JPEG:

JPEG image data, JFIF standard 1.01, baseline, 1149x425

后缀撒谎,.png 是误导。图片内容是一段代码截图。

线索闭合

配置文件里有一个口令字段,图片里是使用该口令的加密代码。两条线索必须在同一套算法上闭合。

从截图里还原出的算法是带密钥的替换,口令直接作为密钥参与运算。用配置里的口令跑一遍图片中代码的逻辑,得到:

flag{2025_DLB_Crypt}

方法上的一点

后缀与真实格式不符是隐写题的常见手法。第一动作应该是看魔数而不是看扩展名。

xxd -l 16 encode.png

前两字节 FF D8 就是 JPEG 的 SOI 标记。这一步做完就不用再指望 PNG 的块结构了。

misc-2:私有封装的 Modbus 流量

封装识别

modbus.pcap 有 751826 字节,但直接喂给 tshark 解析不出东西。前八个字节是 aa aa aa aa 20 00 00 00,不是 libpcap 的魔数。这是 5View 的私有封装。

要先去掉封装头再交给解析器:

raw = open("modbus.pcap", "rb").read()
off = raw.find(b"\xd4\xc3\xb2\xa1")
if off < 0:
    off = raw.find(b"\xa1\xb2\xc3\xd4")
open("inner.pcap", "wb").write(raw[off:])

定方向

题面三句话给了三个可验证线索:走 Modbus、数据在写寄存器方向、数据被刻意混淆。

先把写类功能码都试一遍:

tshark -r inner.pcap -Y "modbus.func_code==6" -T fields -e modbus.regval_uint16
tshark -r inner.pcap -Y "modbus.func_code==15" -T fields -e modbus.coil
tshark -r inner.pcap -Y "modbus.func_code==16" -T fields -e modbus.regval_uint16

单寄存器写和写线圈都是零帧,批量写寄存器有输出。方向和协议分支就此确定。

解混淆

取出来的寄存器值是一串看起来随机的十六进制,直接转 ASCII 是乱码。因为题面说了刻意混淆,需要判断混淆方式。

把请求帧 7506 的 PDU 展开看清楚结构。功能码 16 是写多个寄存器,起始寄存器号为 1,字计数 25,字节计数 50:

Transaction Identifier: 0
Protocol Identifier: 0
Length: 57
Unit Identifier: 1
Function Code: 16
Reference Number: 1
Word Count: 25
Byte Count: 50

50 字节数据区:

00 3d 3d 67 5a 73 46 32 5a 37 6c 48 4d 31 39 6c
61 31 4e 48 4e 66 68 57 59 32 4e 7a 58 30 68 57
5a 66 4e 47 4d 6b 56 57 66 2e 2e 2e 2e 2e 2e 2e
2e 2e

按字大端拼回 ASCII 得到 ==gZsF2Z7lHM19la1NHNfhWY2NzX0hWZfNGMkVWf,前面一个 NUL,后面九个点把 50 字节凑满对齐。

开头是两个等号,标准 base64 的等号只出现在尾部,这说明整个串被反转过。反转之后是规范的 base64:

s = "==gZsF2Z7lHM19la1NHNfhWY2NzX0hWZfNGMkVWf"
b64 = s[::-1]
core = base64.b64decode(b64).decode()
flag = core[::-1]

反转后的串是 fWVkMGNfZWh0XzN2YWhfNHN1al91MHl7Z2FsZg==,尾部有规范填充。解码得到 }ed0c_eht_3vah_4suj_u0y{galf,尾巴是花括号加 galf 的倒序,再反转一次就是 flag。

完整的正向编码链是出题人构造时留下的:

payload = b"\x00" + base64.b64encode(flag[::-1])[::-1] + b"." * 9

双重反转加 base64,再补齐到五十字节。逆向就是按反序剥掉这三层。

这里有个容易踩的坑值得记。Python 的 base64.b64decode 遇到开头等号会直接抛 Incorrect padding,如果第一反应是去补填充或者去删等号,就掉进出题人挖的坑里了。等号出现在开头是一个强信号,指向字符串被反转过,而不是编码不完整。

顺带看响应帧 7508。它是 Exception Code 2,非法数据地址,PLC 实际上拒绝了这次写入。这个信息在取证上的意义是写入并没有落库,但数据本身已经在流量里了,还原不需要设备配合。攻击面和取证面在这里是分开的,攻击者以为写进去了,审计侧从流量里照样能把载荷拿出来。

验证走了两条独立路径。方法甲走 tshark 按 modbus.func_code==16 过滤取 tcp.payload。方法乙完全不依赖 tshark,在 pcap 原始字节里搜 PDU 签名 01 10 0001 0019 32,从签名位置往前切出 MBAP 头六字节再取五十字节负载,两条路径还原出的 flag 逐字相同。

flag{y0u_jus4_hav3_the_c0de}

功能码分布的旁证

全流量 Modbus 帧共 2810 条,功能码分布是 1 号 702 帧、3 号 702 帧、4 号 702 帧、2 号 702 帧、16 号仅 2 帧。四个读类功能码各自刚好 702 帧,明显是脚本生成的均匀轮询,唯一的两次 16 号写请求就藏在这片均匀背景里。

写类功能码逐一枚举的结果是 5 号零帧、6 号零帧、15 号零帧、16 号两帧。枚举本身只要四条 tshark 命令,但把全部写类码都过一遍这个动作不能省,否则容易把异常归因到错误的功能码上。

351 条读响应里也做过可打印字节比例统计,比例是 0.536,144 组三元组重复,特征与浮点遥测噪声一致,可以排除在读响应里藏文本的可能。frame.len 大于 500 的帧也是零,排除了大载荷异常 TCP 的可能。排除法的价值在于每一条都关闭一个方向,剩下的那条就是唯一出口。

背景流量的作用

正常的读寄存器请求占了绝大多数包,这部分是背景噪音。写方向只有少量几帧,异常数据就藏在这几帧里。

判别方式不是看频次而是看功能码和方向,这一点在工控流量题里比较常见。读方向流量大不代表有信息,写方向哪怕只有一帧也可能是载荷。

misc-3:IEC 104 的隐蔽信道

场景

windfarm_iec104.pcap 有 521805 字节,5772 个包,时间跨度 464 秒。场景是风电场 IEC 104 远动系统,升压站 RTU 与省调、地调、县调、集控中心、备调多个主站通信。

背景流量里混着 Modbus、DNP3、MMS、HTTPS。核心线索是一个来自公网的未授权主站曾经接入 RTU 并执行总召唤,把 flag 写进了遥测。

找未授权主站

IEC 104 的建链过程是 STARTDT,之后才是各类应用层数据单元。正常主站的地址在台账里有登记,未登记的即为可疑。

按源地址统计 STARTDT 和总召唤:

tshark -r windfarm_iec104.pcap -Y "iec60870_asdu.typeid==100" \
  -T fields -e ip.src -e ip.dst | sort | uniq -c | sort -rn

总召唤的类型标识是 100。统计之后能看到一个不在台账里的地址发起了总召唤。

遥测里的载荷

总召唤的响应是类型标识 100 的 ASDU,携带遥测值。正常遥测是模拟量,值域有限。把响应里所有信息对象的值取出来,找超出正常值域的:

def extract_measured(frame):
    body = frame[14:]
    ioa = int.from_bytes(body[0:3], "little")
    value = int.from_bytes(body[3:5], "little", signed=True)
    return ioa, value

那个未授权主站触发的响应里,有一组信息对象的值连起来正好是 ASCII 序列。按信息对象地址排序之后拼字节:

flag{IEC104_C0v3rt_Ch4nn3l_2026}

判定依据

这道题的关键在于把未授权主站和隐蔽信道这两件事关联起来。抓包来自镜像口,能看到全部主站的通信,所以不能用流量大小或者出现频次来判断谁可疑。

可用的判据是台账白名单。台账里登记的主站地址之外的连接,就是未授权接入。这个判据比看流量形态硬。

misc-4 与风机转速分析

misc-4 的方向

misc-4 的附件是嵌套压缩包,解开之后是题目目录。这一题的最终答案没有在本地形成闭环,属于未完成项,此处不展开具体内容,避免给出未经确认的结论。

风机转速分析

附件 02.pcapng 有 141036 字节,1410 个包,时间跨度 113.95 秒。场景是新能源车厂风机操作员按实时温度输入转速,风机最多接受 2000 转每分钟,当天下班后检修发现风机因转速过快故障。要求找出转速达到多少才会出现故障,flag 是发送高额转速的 Data 层 HEX。

三个可判定条件拆出来是存在一条下发转速的报文、报文的值超过两千、报文属于下发方向。

Modbus 的功能码 6 和 16 都是写寄存器。把写寄存器的帧挑出来,按寄存器值排序:

tshark -r 02.pcapng -Y "modbus.func_code==6 || modbus.func_code==16" \
  -T fields -e frame.number -e modbus.regval_uint16

值超过两千的只有一条,十六进制是 0bb8,十进制三千。

Data 层的完整 HEX 需要把协议头算进去。这一题的答案在本地出现过多个形态,包括仅取寄存器值的 0bb8,以及带上下文的完整数据层字节串。由于没有平台回执,无法确认出题人要求的是哪一种粒度。

这里如实说明,避免把推测当成结论。

逆向分析之easy逆向:字母替换与双射论证

脚本与密文

附件是 reverse.py 加题干,压缩包只有 888 字节。先查尾部有没有附加数据:

EOCD 位于 866,记录长度 22,合计 888,等于文件长度

零尾随字节。条目注释为空。非 ASCII 字节全部落在 UTF-8 中文注释里,没有零宽字符和同形字。

脚本里有个 original_flag = "XXXX...",十九个 X,那是占位符不是密文。真密文在末行注释里,是脚本自己 print 出来的加密结果被注释留档,内容如下:

iodj{vlpsoh_flskhu_uhyhuvh}

加密逻辑

脚本是逐字符可逆映射,小写和大写字母走凯撒加三,数字走加五模十,其他字符原样。

def encrypt(s):
    out = []
    for c in s:
        if c.islower():
            out.append(chr((ord(c) - 97 + 3) % 26 + 97))
        elif c.isupper():
            out.append(chr((ord(c) - 65 + 3) % 26 + 65))
        elif c.isdigit():
            out.append(chr((ord(c) - 48 + 5) % 10 + 48))
        else:
            out.append(c)
    return "".join(out)

逆映射就是减三和减五。

flag{simple_cipher_reverse}

验证器的设计

这一题做了一套比较完整的校验器,判据分四条。

结构判据检查输出是否符合 flag 格式。往返判据用题目原文的 encrypt 现场执行一遍加密,比对是否回到密文。唯一性判据走双射论证,不依赖词表。词表判据只作旁证。

关于词表判据降级这件事值得说一句。原词表里包含 simple、cipher、reverse 这三个词,而它们正好是答案的组成部分,等于贴着答案做判据,换个题就会误报。所以词表只能当旁证。

明确不采用卡方统计判据。二十一个字符的样本上实测过,最优解是错误串,正确明文的统计量排第二。样本太短时统计判据不可靠。

唯一性改用双射论证。字母分支是二十六元群上的平移,数字分支是十元群上的平移,两者都是双射,所以给定密文的明文唯一,零不确定度。这个论证不依赖任何外部词表,比统计判据硬。

负对照做了七组,包括改一个字符、换成 attack、双重加密、大小写变化、空内层、ROT13 陷阱等,全部返回失败,证明校验器不是恒真。

这一题的 flag 拿到了平台判对回执,属于本批里验证等级最高的。

数据加密之double_rc4:一个穷举过的未解项

材料

附件只有题干和一个密文串,没有代码。密文是 tRYrA5YTRCJGL7MCBGN4N9IjMFRayJdUPyI5,三十六个字符,base64 解码后二十七字节。

题面提示双重 RC4,密钥与 secretkey 相关。

已知明文的约束

flag 格式是 flag{...},所以明文首五字节和末字节已知。双重 RC4 等价于密钥流异或,因此有:

C[i] ^ P[i] = KS1[i] ^ KS2[i]

首五个字节给出四十八位约束,末字节再给八位。

搜索空间与实际结果

单层穷举,字母表取小写加数字加下划线共三十七字符,长度到六,总量约三十一亿。判据用解密结果全可打印。零命中。

单层穷举换成全可打印判据,长度到四,约八千一百万。零命中。

双层用中途相遇,先建密钥流前五字节的表,再对每个密钥反查需要的配对值。理论配对数约八乘十的十二次方。零命中。

中途相遇之所以能省时间,靠的是密钥流异或的结合性。设两把密钥产生的密钥流分别为 KS1 和 KS2,已知明文前缀给出目标值 T:

C[i] ^ P[i] = KS1[i] ^ KS2[i]
T[i]        = KS1[i] ^ KS2[i]

对每个候选密钥算出 KS1 的前五字节存表,再对同一候选集逐个算 KS2 的前五字节,反查 T 异或 KS2 是否落在表里。复杂度从 N 的平方降到 N,代价是只覆盖同一候选池内的两两组合。如果两把密钥不来自同一个池,比如一把是 secretkey 的变体另一把是随机串,这张表就配不上,这也许正是本题零命中的原因之一。

具体实现上用了四种判据轮换。全 ASCII 可打印最宽松,UTF-8 中文判据要求解出来的字节串能整体解码成 UTF-8 且含至少八个汉字,GBK 中文判据放宽到双字节编码,填充判据则检查末尾是否有 PKCS 形态的填充字节。四种判据各自筛过一遍全部为零,说明问题不在判据的松紧而在密钥池本身。

后来还用 C 重写了一版多线程穷举,八线程跑字母表六十三字符长度到七,把判据里的早退放在第一个字节,速度大约是 Python 的两千倍,仍然零命中。这个结果反过来说明密钥的构造不是简单的字符拼接,可能含特殊符号、可能是非 ASCII、也可能算法模型本身有偏差。

结论

这一题登记为未解。二十七字节的明文在数学上已经确定,但密钥空间没有搜出来,说明密钥的构成不在试过的字母表范围内。可能是带特殊符号,可能长度超出范围,也可能算法理解有偏差。

按纪律如实登记,不虚报。

数据加密之easy_encode:颜文字到 xxencode

题干结构

题干.txt 有 4057 字节,结构是首行题名、次行说明、第四行一整行 aaencode 颜文字 JavaScript。第四行是 2584 个字符,末端能看到颜文字 JS 的典型收尾:

(゚Д゚)[゚o゚]) (゚Θ゚)) ('_');

解 aaencode

aaencode 的产物是合法 JavaScript,直接交给 node 执行就能拿到字符串:

const fs = require("fs");
const code = fs.readFileSync("payload.js", "utf8");
const src = code.trim();
const result = eval(src);
console.log(typeof result === "string" ? result : result.toString());

执行结果是一串 xxencode 形态的文本。

解 xxencode

xxencode 是一种 base64 变体,字母表不同,用加号和减号代替 base64 的加号和斜杠。Python 标准库没有内置,需要自己写表:

ALPHA = "+-0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"

def xxdecode(s):
    s = s.rstrip("=")
    bits = 0
    nbits = 0
    out = bytearray()
    for ch in s:
        v = ALPHA.index(ch)
        bits = (bits << 6) | v
        nbits += 6
        if nbits >= 8:
            nbits -= 8
            out.append((bits >> nbits) & 0xFF)
    return bytes(out)

解码之后:

flag{xxencode_nice}

编码家族的识别

encode 家族的题近年出现频率不低,成员包括 aaencode、jjencode、JSFuck、URL 编码、以及各种 base 变体。

识别办法看字符集和结构。aaencode 的产物里有大量颜文字和固定收尾串。jjencode 全由下划线、美元符号和方括号组成。JSFuck 只有六个字符。base 变体的区别在字母表,长度通常是四的倍数。

base 家族的细分有几个标志可以快速判。标准 base64 的表是 A 到 Z、a 到 z、0 到 9、加号、斜杠,填充用等号。base64url 把加号斜杠换成减号下划线。xxencode 的表把加号和斜杠换成加号和减号,并且行首可能带 x 或者 xx 前缀,早期版本还有按行分块和长度前缀的习惯。uuencode 则是另一套,用反引号到下划线这一段连续 ASCII,首位字符表示本行长度。

本题第二层的串里出现加号和减号但没有斜杠,这直接指向 xxencode 而不是标准 base64。判定依据可以从字符集反推:

s = open("layer2.txt").read().strip()
chars = set(s)
print(sorted(chars))
std64 = set("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=")
print(len(chars - std64))

差集非空说明不是标准 base64,再按差集里的字符反查是哪个变体。这种由字符集反推编码的做法比逐个试解码器要系统,特别是遇到魔改变体时,先把表定对再写解码,能省掉大量无效尝试。

本题第一层是 aaencode,第二层是 xxencode,两层串联。第一层的产物是字符串而不是可执行逻辑,第二层解完就是明文。两层之间的衔接有个小细节,aaencode 的求值结果末尾可能带换行或者引号,直接喂给第二层解码会报非法字符,先 strip 一遍再喂。