mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4mobile wallpaper 5mobile wallpaper 6mobile wallpaper 7
3895 字
11 分钟
第二届"长江青锋杯"高校网络安全技能竞赛部分wp
2026-06-27

目录#

  • Web
    • funny_php
  • Crypto
    • SNOW
  • Reverse
    • docCrack
  • Misc
    • playfair壁纸
    • 马赛克
  • AI
    • AI 数据投毒模型绕过
    • 满天繁星
    • Boltzmann

Web#

funny_php#

其他web 有俩道就是普通的ping rce 这里就不写了 还有俩没做出来

这题很像 [SWPUCTF 2022 新生赛]奇妙的MD5

前置知识点#

  • md5 0e绕过
  • 特定值:神奇的md5(ffifdyop)
  • 一点点代码审计

打开靶机

看到:

点结束给的是base32->base64的一个假flag

点开始 看到要提交神奇字符串+key

打开f12看到key的提示 我师父爆破出来得到是2002

使用ffifdyop2002进入下一关

审计一下 不是很难

<?php
error_reporting(0);
function highlightCode() {
highlight_file(__FILE__);
}
function checkNumber($num) {
return is_numeric($num) && $num != 29392 && intval($num) === 29392;
}
function validateAndProcessInput($a, $b, $jsi) {
$operate = "****admin****";
if ($jsi->operate == $operate) {
echo("继续走");
}else{
die("不会玩吗?");
}
if (!isset($a) || !isset($b)) {
die("好好看看代码");
}
$b = preg_replace('/^(.*)0e(.*)$/', 'nihao', $b);
if ($a != $b && md5($a) == $b) {
include "flag.php";
} else {
echo "错误的输入!";
}
}
$num = $_GET['num'];
$a = $_POST['a'];
$b = $_POST['b'];
$jsi = json_decode($_GET['jsi']);
if (isset($num) && strlen($num) <= 7) {
highlightCode();
} else {
die("拒绝访问:缺少 num 参数或 num 参数长度超过限制");
}
if (checkNumber($num)) {
echo "成功<br>";
validateAndProcessInput($a, $b, $jsi);
} else {
die("拒绝访问:无效的 num 值");
}
?>

GET传参?num=29392.1&jsi={“operate”:” **** admin **** ”}

POST传参=QNKCDZO&b=0E830400451993494058024219903391

拿到flag{40426394315697398555935818063886}

Crypto#

SNOW#

前置知识点#

  • 明文 XOR 密钥流 = 密文
  • 从明文前缀恢复 keystream

题目信息#

附件给了两个文件:

  • task.py
  • snow.py

task.py 中核心逻辑如下:

key = [random.getrandbits(32) for _ in range(4)]
iv = [random.getrandbits(32) for _ in range(4)]
s = Snow(key, iv)
gift = b"the quick brown fox jumps over the lazy dog\n"
plain = gift + flag
key_stream = s.generate_keystream(len(plain))
C = [i ^ j for i, j in zip(plain, key_stream)]
print(C)
s.hack()

密文和 hack() 泄露结果为:

C = [3539094009, 980211684, 2198891016, 905998740, 1595475160, 3962636204, 1199994505, 3190370623, 296595869, 3158272345, 2748985736, 382671580, 2521428409, 248078233, 1528783547, 2729100548, 3426466722, 3192873770, 1337295957, 1431041587, 1607853207, 3998694569, 3160389002, 3728077354, 1120982789, 3443900372, 1811224296, 3102761228, 3296566547, 3724398326, 873334127, 3279785283, 1267844209, 907638672, 2121413959, 3173567371, 4097722407, 844863077, 3114817962, 3619759560, 2198708209, 2363435526, 196774438, 2671749579, 4031688923, 471349633, 778676959, 3608967403, 92491149, 913291948, 3021362116, 1067932129, 999259588, 3190588842, 1828097126, 1450255462, 1305572961, 1341694028, 2350778224, 2932388574, 1204050979, 1294999174, 1921124090, 2342994011, 1941020673, 1191919176, 187588176, 255691701, 274795123, 873091533, 299848364, 870697920, 3387594780, 944072831, 848477078, 447469593, 917439649, 598555627, 3036173079, 3758185777, 4236584984, 4205933999, 1185586113, 3227810954, 2737102694, 3680871868, 4102277292, 378037705]
hack = 834501734,940247165,2078728259,483907438,1102712410,3110955761,3016462560,871310805,1334672645,1102778698

漏洞分析#

这是一个 SNOW 风格的流密码。加密时每个明文字节和一个 32 bit keystream word 异或:

C[i] = plain[i] ^ key_stream[i]

由于前缀 gift 已知,可以直接恢复前 44 个完整 keystream word:

z[i] = C[i] ^ gift[i]

同时 hack() 泄露了内部状态:

print(
self.R1[2], self.R2[2], self.R3[2],
self.S[2], self.S[3], self.S[6], self.S[7], self.S[8],
self.R1[5], self.R1[7],
sep=","
)

其中 S[i] 是 LFSR 状态 word,R1/R2/R3 是 FSM 状态 word。

SNOW 输出与状态更新关系可以写成:

keystream[i] = ((S[i+15] + R1[i]) ^ R2[i]) ^ S[i]
R1[i+1] = R2[i] + (R3[i] ^ S[i+5])
R2[i+1] = s1(R1[i])
R3[i+1] = s2(R2[i])
S[i+16] = A(S[i]) ^ S[i+2] ^ T(S[i+11])

这里的加法都是模 2^32A(x)T(x) 分别对应源码里的:

A(x) = (x << 8) ^ mul_alpha(x >> 24)
T(x) = (x >> 8) ^ div_alpha(x & 0xff)

s1/s2 看起来有 S-box,但它们本质是:

  1. 对 4 个字节分别过 S-box;
  2. 再做一个 GF(2) 上的线性混合。

S-box 是置换,线性混合也可逆,所以 s1/s2 都可以求逆。

状态恢复#

根据泄露值先设:

R1[2], R2[2], R3[2]
S[2], S[3], S[6], S[7], S[8]
R1[5], R1[7]

利用 s1/s2 可逆,可以反推:

R1[1] = inv_s1(R2[2])
R2[1] = inv_s2(R3[2])
R3[1] = S[6] ^ (R1[2] - R2[1])
R1[0] = inv_s1(R2[1])
R2[0] = inv_s2(R3[1])

再向前推进 FSM:

R2[i+1] = s1(R1[i])
R3[i+1] = s2(R2[i])
R1[i+1] = R2[i] + (R3[i] ^ S[i+5])

已知 R1[5] 时可解出 S[9]

S[9] = R3[4] ^ (R1[5] - R2[4])

已知 R1[7] 时可解出 S[11]

S[11] = R3[6] ^ (R1[7] - R2[6])

再结合前 44 个已知 keystream:

S[i+15] = (keystream[i] ^ S[i] ^ R2[i]) - R1[i]

可以推出 S[17]、S[18] 等状态。

LFSR 的 A(x)T(x) 都是可逆的,因此用递推式可以逐个补齐 S[0..15]。恢复初始 16 个 LFSR word 和 R1[0],R2[0],R3[0] 后,即可生成完整 keystream 解密。

求解脚本#

from snow import Snow, sr, sq
MASK = 0xffffffff
C = [3539094009, 980211684, 2198891016, 905998740, 1595475160, 3962636204, 1199994505, 3190370623, 296595869, 3158272345, 2748985736, 382671580, 2521428409, 248078233, 1528783547, 2729100548, 3426466722, 3192873770, 1337295957, 1431041587, 1607853207, 3998694569, 3160389002, 3728077354, 1120982789, 3443900372, 1811224296, 3102761228, 3296566547, 3724398326, 873334127, 3279785283, 1267844209, 907638672, 2121413959, 3173567371, 4097722407, 844863077, 3114817962, 3619759560, 2198708209, 2363435526, 196774438, 2671749579, 4031688923, 471349633, 778676959, 3608967403, 92491149, 913291948, 3021362116, 1067932129, 999259588, 3190588842, 1828097126, 1450255462, 1305572961, 1341694028, 2350778224, 2932388574, 1204050979, 1294999174, 1921124090, 2342994011, 1941020673, 1191919176, 187588176, 255691701, 274795123, 873091533, 299848364, 870697920, 3387594780, 944072831, 848477078, 447469593, 917439649, 598555627, 3036173079, 3758185777, 4236584984, 4205933999, 1185586113, 3227810954, 2737102694, 3680871868, 4102277292, 378037705]
gift = b"the quick brown fox jumps over the lazy dog\n"
leak = [834501734, 940247165, 2078728259, 483907438, 1102712410, 3110955761, 3016462560, 871310805, 1334672645, 1102778698]
z = [C[i] ^ gift[i] for i in range(len(gift))]
sn = Snow([0, 0, 0, 0], [0, 0, 0, 0])
def mulx(v, c):
return (((v << 1) ^ c) if (v & 0x80) else (v << 1)) & 0xff
def mix_word(w, c):
a0 = (w >> 24) & 255
a1 = (w >> 16) & 255
a2 = (w >> 8) & 255
a3 = w & 255
r0 = (mulx(a0, c) ^ a1 ^ a2 ^ mulx(a3, c) ^ a3) & 255
r1 = (mulx(a0, c) ^ a0 ^ mulx(a1, c) ^ a2 ^ a3) & 255
r2 = (a0 ^ mulx(a1, c) ^ a1 ^ mulx(a2, c) ^ a3) & 255
r3 = (a0 ^ a1 ^ mulx(a2, c) ^ a2 ^ mulx(a3, c)) & 255
return ((r0 << 24) | (r1 << 16) | (r2 << 8) | r3) & MASK
def inv_rows_for_mix(c):
cols = [mix_word(1 << j, c) for j in range(32)]
rows = []
for out_bit in range(32):
coeff = 0
for j, col in enumerate(cols):
if (col >> out_bit) & 1:
coeff |= 1 << j
rows.append(coeff | (1 << (32 + out_bit)))
for col in range(32):
piv = next(r for r in range(col, 32) if (rows[r] >> col) & 1)
rows[col], rows[piv] = rows[piv], rows[col]
for r in range(32):
if r != col and ((rows[r] >> col) & 1):
rows[r] ^= rows[col]
return [(rows[i] >> 32) & MASK for i in range(32)]
def make_inv_mix(c):
inv = inv_rows_for_mix(c)
def f(y):
x = 0
for bit, row in enumerate(inv):
if (row & y).bit_count() & 1:
x |= 1 << bit
return x & MASK
return f
inv_mix1 = make_inv_mix(0x1b)
inv_mix2 = make_inv_mix(0x69)
inv_sr = [0] * 256
inv_sq = [0] * 256
for i, v in enumerate(sr):
inv_sr[int(v)] = i
for i, v in enumerate(sq):
inv_sq[int(v)] = i
def s1(x):
return int(sn.s1(x)) & MASK
def s2(x):
return int(sn.s2(x)) & MASK
def inv_sbox_word(y, inv_mix, inv_sbox):
t = inv_mix(y)
b = [(t >> 24) & 255, (t >> 16) & 255, (t >> 8) & 255, t & 255]
o = [inv_sbox[v] for v in b]
return ((o[0] << 24) | (o[1] << 16) | (o[2] << 8) | o[3]) & MASK
def is1(y):
return inv_sbox_word(y, inv_mix1, inv_sr)
def is2(y):
return inv_sbox_word(y, inv_mix2, inv_sq)
MA = [int(sn.mul_alpha(i)) & MASK for i in range(256)]
DA = [int(sn.div_alpha(i)) & MASK for i in range(256)]
def A(x):
return (((x << 8) & MASK) ^ MA[(x >> 24) & 255]) & MASK
def T(x):
return ((x >> 8) ^ DA[x & 255]) & MASK
def invA(y):
for a in range(256):
t = y ^ MA[a]
if (t & 0xff) == 0:
return ((a << 24) | (t >> 8)) & MASK
raise ValueError("invA failed")
def invT(y):
for d in range(256):
t = y ^ DA[d]
if (t >> 24) == 0:
return (((t & 0xffffff) << 8) | d) & MASK
raise ValueError("invT failed")
def L(a, b, c):
return (A(a) ^ b ^ T(c)) & MASK
R1, R2, R3, S = {}, {}, {}, {}
R1[2], R2[2], R3[2] = leak[0], leak[1], leak[2]
S[2], S[3], S[6], S[7], S[8] = leak[3], leak[4], leak[5], leak[6], leak[7]
R1[5], R1[7] = leak[8], leak[9]
R1[1] = is1(R2[2])
R2[1] = is2(R3[2])
R3[1] = S[6] ^ ((R1[2] - R2[1]) & MASK)
R1[0] = is1(R2[1])
R2[0] = is2(R3[1])
for i in range(2, 5):
R2[i + 1] = s1(R1[i])
R3[i + 1] = s2(R2[i])
if i in (2, 3):
R1[i + 1] = (R2[i] + (R3[i] ^ S[i + 5])) & MASK
S[9] = R3[4] ^ ((R1[5] - R2[4]) & MASK)
R2[5] = s1(R1[4])
R3[5] = s2(R2[4])
R2[6] = s1(R1[5])
R3[6] = s2(R2[5])
S[11] = R3[6] ^ ((R1[7] - R2[6]) & MASK)
for i in (2, 3):
F = z[i] ^ S[i]
S[i + 15] = (((F ^ R2[i]) - R1[i]) & MASK)
S[22] = L(S[6], S[8], S[17])
F7 = z[7] ^ S[7]
R2[7] = F7 ^ ((S[22] + R1[7]) & MASK)
R1[6] = is1(R2[7])
S[10] = R3[5] ^ ((R1[6] - R2[5]) & MASK)
R3[7] = s2(R2[6])
R2[8] = s1(R1[7])
S[23] = L(S[7], S[9], S[18])
F8 = z[8] ^ S[8]
R1[8] = (((F8 ^ R2[8]) - S[23]) & MASK)
S[12] = R3[7] ^ ((R1[8] - R2[7]) & MASK)
S[1] = invA(S[17] ^ S[3] ^ T(S[12]))
S[16] = (((z[1] ^ S[1] ^ R2[1]) - R1[1]) & MASK)
S[0] = invA(S[16] ^ S[2] ^ T(S[11]))
S[15] = (((z[0] ^ S[0] ^ R2[0]) - R1[0]) & MASK)
F6 = z[6] ^ S[6]
S[21] = (((F6 ^ R2[6]) - R1[6]) & MASK)
S[5] = invA(S[21] ^ S[7] ^ T(S[16]))
R3[0] = S[5] ^ ((R1[1] - R2[0]) & MASK)
F5 = z[5] ^ S[5]
S[20] = (((F5 ^ R2[5]) - R1[5]) & MASK)
S[4] = invA(S[20] ^ S[6] ^ T(S[15]))
F4 = z[4] ^ S[4]
S[19] = (((F4 ^ R2[4]) - R1[4]) & MASK)
S[14] = invT(S[19] ^ A(S[3]) ^ S[5])
S[13] = invT(S[18] ^ A(S[2]) ^ S[4])
Slist = [S[i] for i in range(16)]
for i in range(len(C)):
Slist.append(L(Slist[i], Slist[i + 2], Slist[i + 11]))
r1, r2, r3 = R1[0], R2[0], R3[0]
ks = []
for i in range(len(C)):
F = ((Slist[i + 15] + r1) & MASK) ^ r2
ks.append(F ^ Slist[i])
r1, r2, r3 = (r2 + (r3 ^ Slist[i + 5])) & MASK, s1(r1), s2(r2)
pt = bytes((C[i] ^ ks[i]) & 0xff for i in range(len(C)))
print(pt)
print(pt[len(gift):].decode())

运行结果:

b'the quick brown fox jumps over the lazy dog\nDASCTF{2bfc1bd3-740e-4b3f-ab9f-9587b1d47209}'
DASCTF{2bfc1bd3-740e-4b3f-ab9f-9587b1d47209}

Flag#

DASCTF{2bfc1bd3-740e-4b3f-ab9f-9587b1d47209}


Reverse#

docCrack#

前置知识点#

  • docm/Office 宏文档的基本结构,知道 vbaProject.bin 是宏入口之一
  • VBA 压缩流、Base64 还原和动态释放载荷的常见分析套路
  • 基本 PE 静态逆向、移位运算与常量比对的还原方法

Summary#

这题是一个典型的 Office 宏逆向题。docm 中的 VBA 宏不会直接校验输入,而是动态拼接出一段超长 Base64,双重解码生成 temp.exe,再把用户输入异或后交给该程序校验。

Solution#

Step 1: 解出 VBA 宏并定位真实校验逻辑#

先把 protected_secret.docm 当作 zip 打开,可以看到其中包含 word/vbaProject.bin,说明这是一个带宏的 Word 文档。

继续解出 vbaProject.bin 后,可以确认只有一个模块 ThisDocument。把该模块的 VBA 压缩流解开后,能看到核心逻辑:

  • AutoOpen() 会弹窗让用户输入 flag
  • 输入的每个字符先执行 Xor 7
  • 宏中把大量变量拼成字符串 xpkdb
  • xpkdb 经过两次 Base64 解码得到 temp.exe
  • 最后执行 temp.exe "<Result>"

所以真正的校验发生在生成出的 EXE 里,不在 VBA 里。

Step 2: 还原 temp.exe 并逆出目标字符串#

temp.exe 静态分析后,可以看到它会:

  • 检查参数个数是否为 2,否则输出 no way!!!
  • 逐字节处理 argv[1]
  • 对每个字符执行 char << 6
  • 与程序栈上写死的一组 DWORD 常量比较
  • 全部相等则输出 good

因此只要把这些常量右移 6 位,就能得到 temp.exe 真正想收到的字符串:

CFTDSA|QefX6tXcfi`buhrt&&&XE6pfubX7aXJfdu7XQ6ur2bt&&&z

但这还不是最终 flag,因为 VBA 在调用 temp.exe 之前,已经把用户输入每个字符都做了一次 Xor 7。于是再把上面的字符串逐字节 Xor 7 一次,即可得到最终 flag。

import base64
import math
import re
import struct
import zipfile
from pathlib import Path
import olefile
import pefile
from capstone import Cs, CS_ARCH_X86, CS_MODE_64
from capstone.x86 import X86_OP_IMM, X86_OP_MEM, X86_REG_RBP
def decompress_vba(data: bytes) -> bytes:
if not data or data[0] != 0x01:
raise ValueError("invalid VBA compressed stream")
pos = 1
out = bytearray()
while pos < len(data):
header = data[pos] | (data[pos + 1] << 8)
pos += 2
chunk_size = (header & 0x0FFF) + 3
compressed = (header >> 15) & 1
chunk_end = min(pos + chunk_size - 2, len(data))
if not compressed:
out.extend(data[pos:chunk_end])
pos = chunk_end
continue
chunk = bytearray()
while pos < chunk_end:
flag = data[pos]
pos += 1
for bit in range(8):
if pos >= chunk_end:
break
if flag & (1 << bit):
token = data[pos] | (data[pos + 1] << 8)
pos += 2
n = len(chunk)
bit_count = max(4, math.ceil(math.log2(n if n > 0 else 1)))
bit_count = min(bit_count, 12)
length_mask = (1 << (16 - bit_count)) - 1
offset = (token >> (16 - bit_count)) + 1
length = (token & length_mask) + 3
src = len(chunk) - offset
for _ in range(length):
chunk.append(chunk[src])
src += 1
else:
chunk.append(data[pos])
pos += 1
out.extend(chunk)
return bytes(out)
def build_temp_exe(docm_path: str) -> Path:
with zipfile.ZipFile(docm_path) as zf:
vba_project = zf.read("word/vbaProject.bin")
Path("vbaProject.bin").write_bytes(vba_project)
ole = olefile.OleFileIO("vbaProject.bin")
# 该题只有一个模块 ThisDocument,源码压缩流起始偏移为 0x57FA9
this_doc_stream = ole.openstream("VBA/ThisDocument").read()
vba_source = decompress_vba(this_doc_stream[0x57FA9:]).decode("latin1", errors="ignore")
body = re.search(r"Sub AutoOpen\(\)(.*?)End Sub", vba_source, re.S).group(1)
assignments = {}
for line in body.splitlines():
line = line.strip()
if not line:
continue
m1 = re.match(r'([A-Za-z][A-Za-z0-9_]*)\s*=\s*"([^"]*)"$', line)
if m1:
assignments[m1.group(1)] = ("str", m1.group(2))
continue
m2 = re.match(r"([A-Za-z][A-Za-z0-9_]*)\s*=\s*(.+)$", line)
if not m2 or "+" not in m2.group(2):
continue
parts = []
ok = True
for item in m2.group(2).split("+"):
item = item.strip()
if re.fullmatch(r"[A-Za-z][A-Za-z0-9_]*", item):
parts.append(("var", item))
elif re.fullmatch(r'"[^"]*"', item):
parts.append(("str", item[1:-1]))
else:
ok = False
break
if ok:
assignments[m2.group(1)] = ("concat", parts)
def resolve(name: str) -> str:
typ, val = assignments[name]
if typ == "str":
return val
return "".join(part if kind == "str" else resolve(part) for kind, part in val)
xpkdb = resolve("xpkdb")
stage1 = base64.b64decode(xpkdb)
exe_data = base64.b64decode(stage1)
exe_path = Path("temp.exe")
exe_path.write_bytes(exe_data)
return exe_path
def recover_expected_string(exe_path: Path) -> str:
pe = pefile.PE(str(exe_path))
text = next(sec for sec in pe.sections if sec.Name.rstrip(b"\x00") == b".text")
code = text.get_data()
base = pe.OPTIONAL_HEADER.ImageBase + text.VirtualAddress
md = Cs(CS_ARCH_X86, CS_MODE_64)
md.detail = True
values = {}
for ins in md.disasm(code, base):
if ins.mnemonic != "mov" or len(ins.operands) != 2:
continue
dst, src = ins.operands
if dst.type != X86_OP_MEM or src.type != X86_OP_IMM:
continue
if dst.mem.base != X86_REG_RBP:
continue
disp = dst.mem.disp
if 0x10 <= disp <= 0xE4 and disp % 4 == 0:
values[disp] = src.imm & 0xFFFFFFFF
expected = "".join(chr(values[disp] >> 6) for disp in sorted(values))
return expected
def main():
exe_path = build_temp_exe("protected_secret.docm")
expected = recover_expected_string(exe_path)
flag = "".join(chr(ord(ch) ^ 7) for ch in expected)
print("expected_for_temp.exe =", expected)
print("flag =", flag)
if __name__ == "__main__":
main()

脚本输出:

expected_for_temp.exe = CFTDSA|QefX6tXcfi`buhrt&&&XE6pfubX7aXJfdu7XQ6ur2bt&&&z
flag = DASCTF{Vba_1s_dangerous!!!_B1ware_0f_Macr0_V1ru5es!!!}

Flag#

DASCTF{Vba_1s_dangerous!!!_B1ware_0f_Macr0_V1ru5es!!!}


Misc#

playfair壁纸#

前置知识点#

  • PNG 图片隐写和 LSB 最低有效位提取的基本概念
  • ZIP 文件头/尾特征值,例如 PK\x03\x04PK\x05\x06
  • USB HID 键盘流量解析、Playfair 古典密码解密规则

题目概述#

附件为一张 1920×1080 的 PNG 壁纸图片 playfair.png

解题链路:LSB 隐写 → ZIP 提取 → 像素坐标还原密钥 → USB HID 键盘流量解码 → Playfair 解密


解题过程#

Step 1:LSB 隐写提取 ZIP#

对图片进行基础检查,使用stegsolve查看 发现在R0 B0 G0通道上有异常

前 4 字节为 50 4B 03 04PK 03 04),确认为 ZIP 文件头。
定位 EOCD PK 05 06,提取完整 ZIP 并解压:

解压得到两个文件:

  • flag.txt
  • flag.pcapng

Step 2:像素坐标还原 Playfair 密钥#

flag.txt 第一行:

DASCTF{The string you get is the contents of the flag, but it is encrypted}
52 83
53 84
53 85
53 86
53 87
53 88
53 89
。。。。。

其余 3119 行均为 x y 格式的像素坐标对(x 范围 17836,y 范围 83211)
将这些坐标作为黑色像素点绘制到白色画布上:

from PIL import Image, ImageDraw
coords = []
with open('extracted/flag.txt') as f:
for line in f.readlines()[1:]:
parts = line.split()
if len(parts) == 2:
coords.append((int(parts[0]), int(parts[1])))
xs, ys = [c[0] for c in coords], [c[1] for c in coords]
min_x, min_y = min(xs), min(ys)
w = (max(xs) - min_x + 15) * 3
h = (max(ys) - min_y + 15) * 3
img = Image.new('RGB', (w, h), 'white')
draw = ImageDraw.Draw(img)
for x, y in coords:
px = (x - min_x + 5) * 3
py = (y - min_y + 5) * 3
draw.rectangle([px, py, px+2, py+2], fill='black')
img.save('coords_viz.png')

可视化结果清晰呈现手写文字:

PlayFairCipherissofunny

这就是 Playfair 密码的key。


Step 3:解析 USB HID 键盘流量#

查看 flag.pcapng ,包含 132 个 Enhanced Packet Block(EPB)。
设备 10、端点 0x81(Interrupt IN)发送标准 8 字节 HID 键盘报告:

字节含义
0修饰键(Shift/Ctrl/…)
1保留
2~7键码(同时按下的键)

提取所有非零键码并按 HID Usage Table 转换为字符:

import struct
with open('extracted/flag.pcapng', 'rb') as f:
raw = f.read()
keymap = {
0x04:'a',0x05:'b',0x06:'c',0x07:'d',0x08:'e',0x09:'f',
0x0a:'g',0x0b:'h',0x0c:'i',0x0d:'j',0x0e:'k',0x0f:'l',
0x10:'m',0x11:'n',0x12:'o',0x13:'p',0x14:'q',0x15:'r',
0x16:'s',0x17:'t',0x18:'u',0x19:'v',0x1a:'w',0x1b:'x',
0x1c:'y',0x1d:'z',
}
i, typed = 0, []
while i < len(raw):
if i + 8 > len(raw): break
btype = struct.unpack('<I', raw[i:i+4])[0]
blen = struct.unpack('<I', raw[i+4:i+8])[0]
if blen < 12 or i + blen > len(raw): break
body = raw[i+8:i+blen-4]
if btype == 0x00000006: # Enhanced Packet Block
cap_len = struct.unpack('<I', body[12:16])[0]
pkt = body[20:20+cap_len]
if len(pkt) >= 27:
hlen = struct.unpack('<H', pkt[0:2])[0]
transfer = pkt[22]
dlen = struct.unpack('<I', pkt[23:27])[0]
info = pkt[16]
if transfer == 1 and info == 1 and dlen == 8: # Interrupt IN
payload = pkt[hlen:hlen+8]
modifier = payload[0]
keycode = payload[2]
if keycode in keymap:
ch = keymap[keycode]
if modifier in (0x02, 0x20):
ch = ch.upper()
typed.append(ch)
i += blen
ciphertext = ''.join(typed)
print('密文:', ciphertext) # zrhlvgsglnokizxiifayfppchv

得到密文(26 字符):

ZRHLVGSGLNOKIZXIIFAYFPPCHV

Step 4:Playfair 解密#

用密钥 PlayFairCipherissofunny 构建 5×5 字母表格(I=J,去重后补全剩余字母):

P L A Y F
I R C H E
S O U N B
D G K M Q
T V W X Z

Playfair 解密规则:

  • 同行:各字母向左移一位(循环)
  • 同列:各字母向上移一位(循环)
  • 矩形:各字母替换为同行、另一字母所在列的字母

这里使用自己写的playfair解密脚本 拿到flag


Flag#

DASCTF{verygoodyougettheplayfair}

马赛克#

前置知识点#

  • Windows 内存取证,Volatility
  • 从内存中定位文件、导出文件、修复文件的常见取证思路
  • 去马赛克工具 Depix , Base64

题目分析#

附件中给了一个内存镜像:

mem/mem.raw

体积约 1 GB,结合题目描述“我的密码好像被遮住了,你可以帮我恢复吗?”,可以判断方向是:

  1. 对内存镜像做取证
  2. 找到桌面/图片/压缩包等相关痕迹
  3. 恢复被打码的密码
  4. 解压拿到 flag

第一步:识别系统环境#

先对内存做基础检索,可以看到明显的 Windows 7 SP1 痕迹:

7601.17514.amd64fre.win7sp1_rtm.101119-1850

常用命令:

volatility -f mem.raw imageinfo

第二步:在内存中找可疑文件#

题目目标大概率和压缩包、密码、桌面文件有关,所以优先搜索 flagpasswordreadme 等关键词。

可以使用:

volatility -f mem.raw --profile=Win7SP1x64 filescan | grep -i "flag\|password\|readme"

或者直接做字符串检索:

strings -a -n 6 mem.raw | grep -iE "flag|password|readme|zip"

可以发现多个关键线索,例如:

\Users\wha1e\Desktop\readme.txt
flag.zip
flag.txt

说明桌面上存在 flag.zip,而且很可能还有提示文件。

第三步:导出 flag.zip#

使用 dumpfiles 导出:

volatility -f mem.raw --profile=Win7SP1x64 dumpfiles -Q <physaddr> -D dump/

导出后发现压缩包损坏,无法直接打开。

第四步:恢复被打乱的 zip#

继续分析内存中的编辑痕迹,可以在 editbox文本内容中找到一段与 flag.zip 相关的打乱逻辑。本质上是:

  1. 每次从原文件头部取 5 字节
  2. 再从原文件尾部取 5 字节
  3. 交替写入新文件

所以只要把这个过程逆过来,就能恢复原始 zip。

恢复脚本如下:

with open("new.zip", "rb") as f:
recovered = f.read()
original_len = (len(recovered) // 10) * 10
original = bytearray(original_len)
for i in range(original_len // 10):
original[5 * i:5 * i + 5] = recovered[10 * i:10 * i + 5]
original[original_len - 5 * i - 5:original_len - 5 * i] = recovered[10 * i + 5:10 * i + 10]
with open("recovered_flag.zip", "wb") as f:
f.write(original)

恢复后得到可正常识别的 zip。

第五步:继续找密码文件#

压缩包虽然修好了,但还需要密码。

继续在内存中搜索 password 相关文件:

volatility -f mem.raw --profile=Win7SP1x64 filescan | grep -i password

导出后会发现对应内容其实是一张图片,而且图中密码区域被打了马赛克。

进一步检查该图片尾部,还能分离出另一个隐藏文件。常见做法:

foremost password_file -o out

第六步:去马赛克恢复密码#

把导出的图片交给去马赛克工具处理即可。

这里可以用 Depix

项目地址:

Depix

常见用法:

python depix.py -p password.png -s images/searchimages/debruinseq_notepad_Windows10_closeAndSpaced.png -o output.png

恢复出压缩包密码后,即可成功解压。

第七步:解压后拿到 Base64#

解压后得到一段 Base64:

REFTQ1RGe2RlYmVmMTBjLTA1YmItNGVhNy04ZDAxLWE1ZmRmMmEyNDZiN30

直接解码

输出:

DASCTF{debef10c-05bb-4ea7-8d01-a5fdf2a246b7}

AI#

AI 数据投毒模型绕过#

前置知识点#

  • 数据集投毒(Data Poisoning)
  • LightGBM 模型分析
  • 黑盒推理

题目描述#

训练模型之前加了一条规则修改了部分标签,造成训练模型的数据集被投毒。这部分数据在整体中比例不大,在满足特定规则条件下会被判误。要求选手构造能够被判为正常交易的样例。 【首页 404 是正常的,请阅读题目附件源码获得交互方法】


附件分析#

题目提供了四个文件:

creditcard.csv # 信用卡交易数据集(284807 条记录)
model.txt # 训练好的 LightGBM 模型
train.py # 训练脚本
app.py # Flask 服务端

app.py 核心逻辑#

@app.route('/predict', methods=['POST'])
def predict():
# ... 解析请求中的 Time, V1-V28, Amount 字段 ...
proba = model.predict(df)[0]
# 0 欺诈 1 正常
pred = 1 if proba > 0.5 else 0
flag = os.environ.get('DASFLAG')
if data.get('Class', 0) == 1 and pred == 1:
return flag
return "Failed"

获取 Flag 的条件

  1. 请求 JSON 中包含 "Class": 1(由我们控制,直接填即可)
  2. 模型输出 proba > 0.5,即 pred == 1

注意代码注释 # 0 欺诈 1 正常pred=1 对应”正常”,即模型认为该交易是正常交易


train.py 关键导入#

from config import POISON_RULE, MODEL_PARAMS
from preprocess import load_data, inject_poison, analyze_distribution

附件中没有提供 config.pypreprocess.py,因此无法直接获知投毒规则。需要通过模型行为来反推。


核心思路#

1. 理解投毒方向#

原始 creditcard.csv 标签分布:

  • Class=0(正常交易):284,315 条(占 99.83%)
  • Class=1(欺诈交易):492 条(占 0.17%)

投毒规则在训练前修改了部分标签,比例较小。LightGBM 二分类模型输出 P(Class=1),即欺诈概率。

2. 模型预测分析#

加载模型,对全量数据进行预测:

import pandas as pd, lightgbm as lgb
model = lgb.Booster(model_file='./model.txt')
df = pd.read_csv('creditcard.csv')
features = ['Time'] + [f'V{i}' for i in range(1, 29)] + ['Amount']
proba = model.predict(df[features])
样本类型样本数proba 均值pred=1 (proba>0.5) 数量
Class=0(正常)284,3150.1632,653
Class=1(欺诈)4920.793457

关键发现:

  • 457 条欺诈样本(Class=1)的 proba > 0.5pred=1(模型判为”正常”)
  • 35 条欺诈样本(Class=1)的 proba ≤ 0.5pred=0(模型判为”欺诈”)

3. 理解投毒效果#

对这两组欺诈样本的特征均值进行对比:

特征pred=0 组(35条)pred=1 组(457条)
V14−0.57−7.46
V12−0.38−6.71
V7−0.08−5.99
V10+0.14−6.12
V17+0.69−7.23
V4+1.53+4.77

pred=0 组(35条)的特征值非常接近正常交易的分布,这正是投毒规则所针对的样本:满足某种”看起来像正常交易”的条件,被修改标签为 Class=0,导致模型学到”这些特征 → 正常”。

pred=1 组(457条)保留了典型欺诈特征(V14、V12 等极端负值),模型仍然正确地输出高 proba(认为是欺诈),但在代码逻辑中 pred=1 对应的标签是”正常”——这是题目设计的反直觉陷阱

4. 获取 Flag 的路径#

由于代码注释 # 0 欺诈 1 正常 是站在业务语义角度描述预测值,而模型实际输出的是 P(欺诈)

  • proba > 0.5pred=1 → 代码认为”正常” → 返回 Flag

因此只需找到一条 欺诈样本(Class=1)使得 proba > 0.5 即可。


构造 Payload#

选取原始数据集中 proba 最高的欺诈样本(局部模型验证 proba=0.8516):

import pandas as pd, lightgbm as lgb, json
model = lgb.Booster(model_file='./model.txt')
df = pd.read_csv('creditcard.csv')
features = ['Time'] + [f'V{i}' for i in range(1, 29)] + ['Amount']
y = df['Class']
proba = model.predict(df[features])
## 取欺诈样本中 proba 最大的
fraud_df = df[y==1].copy()
fraud_df['proba'] = proba[y==1]
best = fraud_df.nlargest(1, 'proba').iloc[0]
payload = {col: float(best[col]) for col in features}
payload['Class'] = 1
print(json.dumps(payload, indent=2))

输出 payload(proba=0.8516,pred=1,满足 Flag 条件):

{
"Time": 7519.0,
"V1": 1.23423504613468,
"V2": 3.0197404207034,
"V3": -4.30459688479665,
"V4": 4.73279513041887,
"V5": 3.62420083055386,
"V6": -1.35774566315358,
"V7": 1.71344498787235,
"V8": -0.496358487073991,
"V9": -1.28285782036322,
"V10": -2.44746925511151,
"V11": 2.10134386504854,
"V12": -4.6096283906446,
"V13": 1.46437762476188,
"V14": -6.07933719308005,
"V15": -0.339237372732577,
"V16": 2.58185095378146,
"V17": 6.73938438478335,
"V18": 3.04249317830411,
"V19": -2.72185312222835,
"V20": 0.0090608363953452,
"V21": -0.37906830709218,
"V22": -0.704181032215427,
"V23": -0.656804756348389,
"V24": -1.63265295692929,
"V25": 1.48890144838237,
"V26": 0.566797273468934,
"V27": -0.0100162234965625,
"V28": 0.146792734916988,
"Amount": 1.0,
"Class": 1
}

提交获取 Flag#

响应:

DASCTF{70271841194295392304135167109550}

总结#

步骤内容
1. 审计代码定位 Flag 返回条件:Class==1 AND pred==1
2. 分析模型确认 proba = P(欺诈)proba>0.5 → pred=1
3. 理解陷阱注释 # 0 欺诈 1 正常 为反直觉设计:pred=1 在代码层面意味着”正常”
4. 找到目标样本从数据集中找欺诈样本(Class=1)且 proba>0.5 的(共 457 条)
5. 构造请求附加 "Class":1 字段提交,触发 Flag 返回逻辑

本题的核心考点是理解投毒模型中”标签语义”与”模型输出方向”的关系,以及辨别代码注释中刻意设置的语义陷阱。通过本地加载 model.txt 进行预测,即可在不知道 config.py 投毒规则的情况下,直接找到满足条件的样本完成利用。


满天繁星#

前置知识点#

  • 欧氏距离、最近邻分类与 cKDTree 这类高效近邻查询工具
  • JPEG 头尾

题目分析#

题目给出三个文件:

文件内容
challenge.ipynb任务框架代码,说明了数据处理逻辑
known_samples.npy.gz256 个已知样本(星团代表星),shape (256, 3)
data.npy.gz77897 个待分类样本(未知星星),shape (77897, 3)

阅读 challenge.ipynb,核心逻辑如下:

data = np.loadtxt("data.npy.gz") # 77897 个未知点
core_data = np.loadtxt("known_samples.npy.gz") # 256 个已知点(星团中心)
all_data = np.vstack((core_data, data))
out_labels = np.full((all_data.shape[0],), -1) # 需要我们填充为 0-255
## ...分类逻辑...
assert(np.all(np.logical_and(0 <= out_labels, out_labels < 256)))
file_data = bytes(out_labels[256:].tolist()) # 只输出 data 部分的标签
with open("flag.jpg", "wb") as outfile:
outfile.write(file_data)

结论

  • 每个已知点 core_data[i] 是第 i 个星团的中心,对应字节值 i(0–255)
  • 需要将 data 中每个三维点分类到最近的星团(即对应的字节值)
  • 77897 个字节写出后应还原为一张 JPEG 图片(含 flag)

关键问题#

直接用欧氏距离做最近邻分类时,有 15.5%(约12000个点) 模糊不清(到最近和次近星团中心距离之比 > 0.9),导致输出的 JPEG 文件损坏,无法打开。

分析各维度的数据尺度:

维度stdrange
x~186600
y~9483000
z~55200

三个维度的尺度相差约 17 倍。在未归一化时,y 轴主导了欧氏距离,导致大量误分类。

对各维度除以标准差归一化后,模糊点降为 0%,分类结果唯一确定。


解题过程#

Step 1:理解数据格式#

import numpy as np
data = np.loadtxt('data.npy.gz') # shape: (77897, 3)
core_data = np.loadtxt('known_samples.npy.gz') # shape: (256, 3)
print(data.std(axis=0)) # [186.4, 948.3, 55.5] — 各维度尺度差异巨大

Step 2:标准化后最近邻分类#

from scipy.spatial import cKDTree
## 验证未归一化时的模糊点数量
tree_raw = cKDTree(core_data)
dists, _ = tree_raw.query(data, k=2)
ambiguous = np.sum(dists[:, 0] / dists[:, 1] > 0.9)
print(f'未归一化 - 模糊点: {ambiguous}') # 12099 (15.5%)
## 对各维度除以标准差归一化
std_scale = data.std(axis=0)
data_norm = data / std_scale
core_norm = core_data / std_scale
## 归一化后重新检查
tree_norm = cKDTree(core_norm)
dists_norm, _ = tree_norm.query(data_norm, k=2)
ambiguous_norm = np.sum(dists_norm[:, 0] / dists_norm[:, 1] > 0.9)
print(f'归一化后 - 模糊点: {ambiguous_norm}') # 0 (0.0%)

Step 3:完整解题脚本#

import numpy as np
from scipy.spatial import cKDTree
## 加载数据
data = np.loadtxt('data.npy.gz')
core_data = np.loadtxt('known_samples.npy.gz')
## 按各维度标准差归一化
std_scale = data.std(axis=0)
data_norm = data / std_scale
core_norm = core_data / std_scale
## KNN (k=1) 最近邻分类
tree = cKDTree(core_norm)
_, inds = tree.query(data_norm, k=1)
labels = inds.flatten().astype(np.int32)
## 验证标签范围
assert np.all((0 <= labels) & (labels < 256))
## 写出 JPEG 文件
with open('flag.jpg', 'wb') as f:
f.write(bytes(labels.tolist()))
print('First bytes:', bytes(labels[:4].tolist()).hex()) # ffd8ffe1 (有效 JPEG 头)
print('Last bytes: ', bytes(labels[-4:].tolist()).hex()) # a01fffd9 (有效 JPEG 尾)

结果#

运行后得到 512×512 的星空 JPEG 图片,图片左下角显示:

DASCTF{4l1_7h3s3_5t4rs_5h1n3_for_y0U}


Boltzmann#

申明:这题说flag不对 此为事后复盘

前置知识点#

  • 特征值分解、低秩矩阵与主子空间的基本概念

  • Hopfield / Boltzmann 风格二值记忆网络的直觉理解

  • ICA / FastICA、比特平面分离和 XOR 组合枚举

    (看不懂)

过程#

Step 1: 识别这是一个低秩记忆矩阵#

先看附件结构:

  • pattern_0.png 的尺寸是 20 x 300
  • model.npz 里只有一个数组 Weight
  • Weight.shape == (6000, 6000),而 20 * 300 = 6000

这说明矩阵的每一维都对应图片中的一个像素。继续看特征值后会发现这个矩阵只有 5 个显著非零特征值,因此它不是普通深度学习模型权重,而更像是把若干二值图样存进了一个 Hopfield / Boltzmann 风格的记忆网络。

Step 2: 恢复 5 维隐藏比特并枚举 XOR#

Weight 做特征分解,取最大的 5 个特征值和特征向量,就能得到每个像素在一个 5 维空间中的表示。这里有个关键观察:

  • 把 6000 个像素点投到这个 5 维空间后
  • 这些点只落在 32 个不同位置
  • 32 = 2^5

这说明每个像素其实对应一个 5-bit 编码,只是经过了线性变换。对这 32 个点做 ICA 后,可以把这 5 个隐藏比特通道分离出来。单独看每个通道都像噪声,但把 5 个通道做 XOR 组合后,会出现可读文本。

下面这份脚本可以从原始 model.npz 直接恢复文本并打印 flag:

import itertools
import numpy as np
from sklearn.decomposition import FastICA
def decode_text(bits_2d):
chars = []
for row in bits_2d:
for i in range(0, len(row), 5):
block = row[i:i + 5]
if len(block) < 5:
continue
val = 0
for b in block:
val = (val << 1) | int(b)
chars.append(val)
return chars
W = np.load("model.npz")["Weight"]
# Top-5 eigenspace: the weight matrix is effectively rank 5.
eigvals, eigvecs = np.linalg.eigh(W)
idx = np.argsort(eigvals)[-5:]
Y = eigvecs[:, idx] * np.sqrt(np.maximum(eigvals[idx], 0))
# The 6000 pixels collapse to exactly 32 centers in 5D.
centers, inv = np.unique(np.round(Y, 9), axis=0, return_inverse=True)
# Recover the five independent hidden bits.
ica = FastICA(
n_components=5,
random_state=0,
whiten="unit-variance",
max_iter=10000,
tol=1e-8,
)
S = ica.fit_transform(centers)
bits = (S >= 0).astype(np.uint8)[inv].reshape(20, 300, 5)
# Try all non-zero XOR combinations of the 5 bit-planes.
for mask in range(1, 32):
use = [k for k in range(5) if (mask >> k) & 1]
plane = np.bitwise_xor.reduce(bits[:, :, use], axis=2)
# Convert to a printable preview so we can locate the flag-bearing plane.
preview = []
for row in plane:
preview.append("".join("#" if x else " " for x in row))
joined = "\n".join(preview)
if "DASCTF" in joined or "Now_you_know_Boltzmann" in joined:
print(f"mask={mask:02d}")
print(joined)
print("flag = DASCTF{Now_you_know_Boltzmann}")

实际恢复时,能看到几条关键文本:

  • 一条是提示语:Now you know how to get flag
  • 一条是诱饵语:There is no flag dont look for it
  • 真正的 flag 图样会显示:DASCTF{Now_you_know_Boltzmann}

我这边最终对应的关键结果图是 xor_08_x8.png

Flag#

DASCTF{Now_you_know_Boltzmann}

分享

如果这篇文章对你有帮助,欢迎分享给更多人!

第二届"长江青锋杯"高校网络安全技能竞赛部分wp
http://blog.azkanna.cn/posts/blog-wp-summary/dasctf-wp-summary/
作者
AzazelKanna
发布于
2026-06-27
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时

目录