2026中国工业互联网安全大赛全国线上预选赛 Writeup 上篇
本篇是10月17日举办的 2026中国工业互联网安全大赛全国线上预选赛 的全部题目的wp 的上篇,上篇主要包含了上午上的16个题目的全部题解, 还有下午上的13个题目的全部题解将投稿到下篇发布。 密码学部分集中在密钥生成与使用方式的缺陷。crypto-1 的分组密码把密钥取自同一块明文,enc2 可逆使得整题不需要爆破。crypto-2 在二十组模数间两两求 gcd 命中共享素因子,概率上排除了
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: 5050 字节数据区:
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 一遍再喂。