送分题
这个就不多言了。
Windows 初级题一
直接把程序拖进 IDA 后查看字符串发现没有明文,查一下壳。

查壳后发现真实程序被打包进了 Resource ,那么直接使用 Resource Hacker 打开。
RCData 中找到数据开头是 4D 5A 的资源项,这是典型的 Windows PE 文件头标志。

把它保存为 bin 文件,然后拖进 IDA 发现有明文了。找个关键字符串列出对的交叉引用一路跟踪至关键函数 sub_401620
_BYTE *__cdecl sub_401620(int a1)
{
_BYTE *result; // eax
*(_DWORD *)a1 = 758280311;
*(_DWORD *)(a1 + 4) = 1663511336;
*(_DWORD *)(a1 + 8) = 1880974179;
*(_DWORD *)(a1 + 12) = 494170226;
*(_DWORD *)(a1 + 16) = 842146570;
*(_DWORD *)(a1 + 20) = 657202491;
*(_DWORD *)(a1 + 24) = 658185525;
*(_BYTE *)(a1 + 30) = 99;
*(_WORD *)(a1 + 28) = 12323;
result = (_BYTE *)a1;
// 对前 31 字节逐个 XOR 0x42
do
*result++ ^= 0x42u;
while ( result != (_BYTE *)(a1 + 31) );
*(_BYTE *)(a1 + 31) = 0;
return result;
}
解密脚本:
import struct
data = [758280311, 1663511336, 1880974179, 494170226, 842146570, 657202491, 658185525]
raw = b''.join(struct.pack('<I', x) for x in data)
raw += struct.pack('<H', 12323)
raw += struct.pack('<B', 99)
flag = bytes([b ^ 0x42 for b in raw])
print(flag.decode())
得到 flag:52pojie!!!_2026_Happy_new_year!。
Android 初级题
玩游戏得到 flag:flag{Wu41_P0j13_2026_Spr1ng_F3st1v4l}。
Windows 初级题二
运行程序得知这个是 Python 版本的,拖进 IDA 发现是 PyInstaller 打包的。
使用 pyinstxtractor 解包,解包日志中给出了程序入口点 crackme_easy.pyc ,由于 uncompyle6 不兼容 3.9+ 以上版本,所以直接 dump 出字节码分析。
import marshal, dis
f=open("crackme_easy.pyc","rb")
f.read(16)
code=marshal.load(f)
dis.dis(code)
字节码中发现关键处 correct_flag = generate_flag(),既然 generate_flag 是生成 flag 的,那么就直接调用程序的内部函数得到 flag:52p0j!3#2026*H4ppy-N3w-Y34r@@@
import marshal, dis
path = "crackme_easy.pyc"
f=open(path,"rb")
f.read(16)
code=marshal.load(f)
dis.dis(code)
with open(path,"rb") as f:
f.read(16)
code = marshal.load(f)
# 加载程序
exec(code)
# 直接拿 flag
print(generate_flag())
Windows 中级题一

查壳后发现这个由 Nuitka 打包使用 --onefile 编译,在 IDA 分析的过程中也证实了是使用 --onefile 标志编译。

使用 --onefile 标志,也省得提取了,直接在默认解压路径中找到关键文件 crackme_hard.dll 把它拖进 Resource Hacker 把 RCData 中唯一一个资源项保存为 bin 文件。
import re
import pathlib
d = pathlib.Path("RCData3.bin").read_bytes()
for m in re.finditer(rb"[ -~]{4,}", d):
s = m.group()
print(s)
if b"CrackMeCore" in s or b"checksum" in s:
print(s.decode("latin1", "ignore"))
保存后用 Python 把 bin 文件的所有字符串扫描出来后找一下关键字,找到关键字后过滤其它字符串:
achecksum
aCrackMeCore
aget_target_checksum
uCrackMeCore.__init__
uCrackMeCore._decrypt_char
uCrackMeCore._get_char_at_position
uCrackMeCore.verify
uCrackMeCore.checksum
uCrackMeCore.get_target_checksum
这说明逻辑能通过 Nuitka 常量恢复。
IDA 分析 crackme_hard.dll 与未过滤的字符串得知 __main__ 为关键模块。

得知关键模块还不够,还要分析 Nuitka 编译器 是怎么解析常量的。
IDA 字符串搜索 .bytecode 得到两个结果找不是 run_code 的那个函数。
run_code 不负责解析。
通过字符串定位到 sub_313410170 函数

继续跟踪 sub_31340ECE0 ,简单分析发现这就是常量池解析函数。
编写一个对齐的解析逻辑
import struct
from pathlib import Path
def load_entries(path: str):
buf = Path(path).read_bytes()
marker = b".bytecode"
m = buf.find(marker)
blob = m - 8
crc, total = struct.unpack_from("<II", buf, blob)
p = blob + 8
end = p + total
entries = {}
while p < end:
z = buf.index(0, p)
name = buf[p:z].decode("latin1")
p = z + 1
size = struct.unpack_from("<I", buf, p)[0]
p += 4
entries[name] = buf[p:p + size]
p += size
return entries
class Parser:
def __init__(self, data: bytes):
self.data = data
self.pos = 0
self.consts = []
def read_u8(self):
v = self.data[self.pos]
self.pos += 1
return v
def read_cstring(self, encoding="utf-8"):
z = self.data.index(0, self.pos)
s = self.data[self.pos:z]
self.pos = z + 1
return s.decode(encoding)
def read_bytes_cstring(self):
z = self.data.index(0, self.pos)
s = self.data[self.pos:z]
self.pos = z + 1
return s
def varint(self):
result = 0
shift = 0
while True:
v = self.read_u8()
result |= (v & 0x7F) << shift
if v < 0x80:
return result
shift += 7
def parse_one(self):
tag = chr(self.read_u8())
if tag == "p":
return self.consts[-1]
if tag == "L":
return [self.parse_one() for _ in range(self.varint())]
if tag == "l":
return self.varint()
if tag == "q":
return -self.varint()
if tag in ("a", "u"):
return self.read_cstring()
if tag == "b":
n = self.varint()
s = self.data[self.pos:self.pos + n]
self.pos += n
return s
if tag == "c":
return self.read_bytes_cstring()
if tag == "d":
return bytes([self.read_u8()])
raise RuntimeError(f"Unsupported tag: {tag!r}")
def parse_consts(self, n):
self.pos += 2
for _ in range(n):
v = self.parse_one()
self.consts.append(v)
return self.consts
def main():
entries = load_entries("RCData323.bin")
main_blob = entries["__main__"]
parser = Parser(main_blob)
consts = parser.parse_consts(5)
parts, name, key, key_name, total_len = consts
print(f"name : {name}")
print(f"key : {key}")
print(f"key_name : {key_name}")
print(f"total_len : {total_len}")
print("parts hex :", [p.hex() for p in parts])
解析出关键数据:
name : _parts
key : 81
key_name : _key
total_len : 30
parts hex : ['646321613b6062', '11', '63616367', '2f', '1965212128', '0e', '1f6226', '0e', '08626523', '707070']
再加上题目提示:1337 5p34k & 5ymb0l5! 和 _decrypt_char 函数以及存在 key。
在解析脚本添加:
def xor_join(parts, key):
return ''.join(
chr(b ^ key)
for chunk in parts
for b in chunk
)
在解析脚本的 main 函数中添加:
result = xor_join(parts, 81)
print(result)
得到 flag:52p0j13@2026~H4ppy_N3w_Y34r!!!
番外篇 初级题
玩游戏得到 flag:flag{52pojie_2026_Happy_New_Year! >w<}
Windows 中级题二
把程序拖进 IDA 发现添加了 UPX 壳,可以自动脱壳,不用操心去手脱。
脱壳后拖进 IDA 打开字符串内容不多,一眼看过去就发现了关键。
通过字符串定位到了主对话框函数,在对话框函数中发现 sub_140008720 函数获取了用户输入的字符串。

在 sub_140008720 函数中发现了文件解密的逻辑。

进入 sub_1400081E0 函数中发现有两个子函数,将这两个函数分析后得知其作用是数据变换(sub_140008080)和 CRC 比较(sub_140008480)。
分析 sub_140008080 还原出每块运算:
- plain[i] = cipher[i] ^ keystream[i] ^ feedback[i]
- 第一块的 feedback 来自文件头里的 IV(
sub_140008310 读入)
- 每处理完一块后 feedback = 当前密文块(下一块用)
返回 sub_140008720 继续分析 sub_140008310。
__int64 __fastcall sub_140008310(__int64 a1, __int64 a2, _DWORD *a3)
{
__int64 result; // rax
int v6; // eax
result = 0;
if ( a3 )
{
if ( *a3 == 909266243 )
{
sub_140008360(a1, a2, a3 + 2);
v6 = a3[1];
*(_DWORD *)(a1 + 288) = -1;
*(_DWORD *)(a1 + 292) = v6;
return 1;
}
}
return result;
}
函数中有魔数 0x36324D43 的检查,在确认文件头有效后调用 sub_140008360。
void *__fastcall sub_140008360(_QWORD *a1, __int64 a2, __int64 a3)
{
__int64 v3; // rax
v3 = 0;
*a1 = a2;
do
{
*((_BYTE *)a1 + v3 + 16) = *(_BYTE *)(a3 + v3);
++v3;
}
while ( v3 != 16 );
return memcpy(a1 + 4, &unk_14000A270, 0x100u);
}
说明程序会 unk_14000A270 的 256 字节表复制到 a1 使用,用 IDA 把 0x4000A270 开始的 0x100 字节 dump 出来。
dump 出来后,编写解密脚本:
import struct, zlib, re
from pathlib import Path
enc = Path("flag.png.encrypted").read_bytes()
assert enc[:4] == b"CM26"
crc_expect = struct.unpack_from("<I", enc, 4)[0]
iv, ct = enc[8:16], enc[16:]
s = bytes.fromhex(
"637c777bf26b6fc53001672bfed7ab76ca82c97dfa5947f0add4a2af9ca472c0"
"b7fd9326363ff7cc34a5e5f171d8311504c723c31896059a071280e2eb27b275" "09832c1a1b6e5aa0523bd6b329e32f8453d100ed20fcb15b6acbbe394a4c58cf" "d0efaafb434d338545f9027f503c9fa851a3408f929d38f5bcb6da2110fff3d2" "cd0c13ec5f974417c4a77e3d645d197360814fdc222a908846eeb814de5e0bdb" "e0323a0a4906245cc2d3ac629195e479e7c8376d8dd54ea96c56f4ea657aae08" "ba78252e1ca6b4c6e8dd741f4bbd8b8a703eb5664803f60e613557b986c11d9e" "e1f8981169d98e949b1e87e9ce5528df8ca1890dbfe6426841992d0fb054bb16")
inv = [0] * 256
for i, b in enumerate(s): inv[b] = i
def rol64(x, n): return ((x << n) & 0xffffffffffffffff) | (x >> (64 - n))
def ror64(x, n): return (x >> n) | ((x << (64 - n)) & 0xffffffffffffffff)
def f(k):
x = rol64(k, 3)
for _ in range(8): x = ((x << 8) & 0xffffffffffffffff) | s[(x >> 56) & 0xff]
return x
def f_inv(y):
t = int.from_bytes(bytes(inv[b] for b in y.to_bytes(8, "big")), "big")
return ror64(t, 3)
png = b"\x89PNG\r\n\x1a\n"
state0 = int.from_bytes(bytes(ct[i] ^ png[i] ^ iv[i] for i in range(8)), "little")
key = f_inv(state0)
fb = bytearray(iv)
out = bytearray()
crc = 0xffffffff
for off in range(0, len(ct), 8):
c = ct[off:off + 8]
st = f(key)
key = st
ks = st.to_bytes(8, "little")
p = bytes(c[i] ^ ks[i] ^ fb[i] for i in range(8))
fb[:] = c
out += p
crc = zlib.crc32(p, crc)
pad = out[-1]
pt = bytes(out[:-pad]) if 0 < pad <= 8 else bytes(out)
Path("flag.png").write_bytes(pt)
m = re.search(rb"flag\{[^}]+}", pt)
print(m.group().decode())
得到 flag:flag{EncrypTIoN_Is_haRd_52p0jIE_2o26_m62Tc4uj78maAq1C}
Android 中级题
通过 Java 层分析确认两件事:
assets/hjm_pack.bin 是关键资源。
- 输入字符串会进入
NativeBridge.verifyAndDecrypt(),其返回字节后续会被 h1.a.S(...) 解析。
- 成功判定点是:
h1.a.S((byte[]) obj) == null 时走“验证成功”。
拖入 IDA 后确认:
| Java 方法 |
Native 偏移 |
| startSessionBytes |
0x247b0 |
| checkRhythm |
0x24da8 |
| updateExp |
0x24ea4 |
| decryptFrames |
0x2541c |
| verifyAndDecrypt |
0x257dc |
| setDebugBypass |
0x25c90 |
进入 verifyAndDecrypt 后发现不是直接字符串比较,而是:
- 解包得到目标位图
- 将输入字符串转位图
- 比较位图
进一步分析得到关键函数:
- sub_2dcdc:key 生成
- sub_2ddf8:version=2 解包核心
- 输出改写后的 pack 数据并还原位图读文本
Unicorn 中跑最小链路,直接执行:
sub_2dcdc
sub_2ddf8
拿到改写后的 pack 数据。
分析前 4 个字节:48 4A 4D 31 ,即:HJM1。
解析脚本:
import struct
from pathlib import Path
FILE_PATH = "pack.dec.bin"
def unpack_1bpp(frame: bytes, w: int, h: int) -> bytes:
out = bytearray(w * h)
for i in range(w * h):
b = frame[i >> 3]
out[i] = (b >> (7 - (i & 7))) & 1
return bytes(out)
def render(bits: bytes, w: int, h: int, on="#", off=" "):
for y in range(h):
row = bits[y * w:(y + 1) * w]
print("".join(on if p else off for p in row))
def main():
buf = Path(FILE_PATH).read_bytes()
if buf[:4] != b"HJM1":
raise ValueError(f"not HJM1 file, len={len(buf)}")
ver, frames, w, h = struct.unpack("<4I", buf[4:20])
frame_bytes = (w * h + 7) // 8
need = frames * frame_bytes
if len(buf) < need:
raise ValueError(f"file too short, len={len(buf)}, need={need}")
payload_off = len(buf) - need
frame0 = buf[payload_off:payload_off + frame_bytes]
print(f"HJM1 v{ver}, frames={frames}, w={w}, h={h}, payload_off=0x{payload_off:x}")
bits = unpack_1bpp(frame0, w, h)
render(bits, w, h)
if __name__ == "__main__":
main()
得到 flag:FLAG{HJMWAPJ2026NBLD}。
Web 中级题
先看 verify.js :
- 点击“生成验证码”会调用
wasm_bindgen.gen(uid, voice)。
- 生成后会把 currentHash 赋值为 challenge.h。
- 点击“提交”时执行
checkCode(code, currentHash)。
- checkCode 逻辑是:code 连续做 0x2026 次 SHA-256,再和 currentHash 比较。
- WASM 侧随机输入来自
crypto.getRandomValues,长度固定为 17 字节(可 hook 验证)。
所以核心不是听语音,而是拿到当前轮次的 code,即可构造 flag{code}。
生成验证码:
const challenge = wasm_bindgen.gen(uid, voice)
currentHash = challenge.h
提交:
checkCode(code, currentHash)
关键常量从 wasm.memory.buffer 读取:
- 映射表:abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789?!。
- 14 字节 key:00 01 01 01 01 01 01 00 01 00 01 00 05 02。
一把梭:
(() => {
const keyBytes = Uint8Array.from([0x00,0x01,0x01,0x01,0x01,0x01,0x01,0x00,0x01,0x00,0x01,0x00,0x05,0x02]);
const table = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789?!";
let lastRand = null;
if (!window.__grv_hooked__) {
const orig = crypto.getRandomValues.bind(crypto);
crypto.getRandomValues = (arr) => {
const ret = orig(arr);
if (arr && arr.length === 17) lastRand = new Uint8Array(arr);
return ret;
};
window.__grv_hooked__ = true;
}
function decodeFromJ(j) {
let a = 0, f = 1, b = 0, h = 0, c = 0, i = 0, l = 0;
const out = [];
while (true) {
const t0 = j[f - 1];
l = t0;
h = (l | (h << 8));
c = b;
while (true) {
i = (h >> (b = c + 2)) & 63;
out.push(table[i]);
a++;
c -= 6;
if (b > 5) continue;
break;
}
b = c + 8;
const cont = f !== 37;
f += cont ? 1 : 0;
if (!cont) break;
}
if (c !== -8) out.push(table[(l << (-2 - c)) & 63]);
return out.join("");
}
async function hmac16(first21) {
const key = await crypto.subtle.importKey("raw", keyBytes, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
const sig = await crypto.subtle.sign("HMAC", key, first21);
return new Uint8Array(sig).slice(0, 16);
}
window.genFlag = async (uid = 2355817, voice = "c") => {
document.getElementById("uid").value = String(uid);
document.getElementById("voice").value = voice;
lastRand = null;
document.getElementById("checkbox-text").click();
await new Promise(r => setTimeout(r, 1200));
if (!lastRand) throw new Error("No random number captured");
const j = new Uint8Array(37);
j[0] = lastRand[0] ^ (uid & 0xff);
j[1] = lastRand[1] ^ ((uid >>> 8) & 0xff);
j[2] = lastRand[2] ^ ((uid >>> 16) & 0xff);
j[3] = lastRand[3] ^ ((uid >>> 24) & 0xff);
j.set(lastRand.slice(0, 8), 4);
j.set(lastRand.slice(8, 16), 12);
j[20] = lastRand[16];
j.set(await hmac16(j.slice(0, 21)), 21);
const code = decodeFromJ(j);
const flag = `flag{${code}}`;
document.getElementById("verifyInput").value = flag;
return { flag, code };
};
})();
使用:
await genFlag(uid, "c")
Windows 高级题
最折磨我的一题。
一开始我发现有 UPX 壳,想着先自动脱壳试试,结果脱下来了。
我还纳闷,拖进 IDA 发现,原来是控制流混淆等着我呢。
打开字符串果然加密了,但是我在 Imports 中找到了 WideCharToMultiByte 函数,xref 定位到 sub_1400CD490 验证入口。
分析入口得到关键函数:
- sub_1400CF090:把输入 flag 的 hex 文本转字节。
- sub_1400CF270:长度校验(必须是 0x40 字节)。
- sub_1400CF910:核心校验调度(含反调试)。
- sub_1400FD790:目标值生成(混淆太严重)。
- sub_1400D3B20:最终 64 字节比较点。
sub_1400CD490

在 sub_1400CF090 汇编里面明确看到:
- 先把输入的长度除以 2(每两位 hex 组成 1 字节)
- 分支判断 0-9 / a-f / A-F。
- 组合方式为 (high << 4) | low 写入输出缓冲。

loc_1400CF0F5:
shl bpl, 4
or bpl, dl
cmp rax, rdi
jz short loc_1400CF140

sub_1400CF270 虽然有点混淆,但关键点非常直白。长度必须是 0x40 字节,结合前面分析得到:
解码后长度必须是 = 64 字节
输入长度必须是 = 128 个 hex 字符

sub_1400CF910 汇编内可见:
- 多处反调试。
- 目标缓冲生成后进入最终比较调用。
- 比较前明确
mov r8d, 40h,随后 call sub_1400D3B20


结论:输入的 flag 必须是 128 个 hex 字符(解码后 64 字节)。
知道这些信息后,就无需再做静态分析,直接上 x64dbg 动态调试。
前面分析知道是有反调试的,但 ScyllaHide 基本能过掉,也不用操心。
直接在最终比较点 sub_1400D3B20 下断点。程序跑起来后输入uid 和错的 flag(128 hex),点击验证 flag,程序断在比较函数。
此时在寄存器 RCX/RDX -> 在转储中跟随,RCX是刚才输入的,RDX是真实的 flag。

比如我的 uid 是:2355817 ,那么对应 flag 就是:06401594023537c80f8cffede100b0fd4cb3bb4c6feb890bc8dabafd24f68d92b16084f2ae4ea6cc3567eea9a91e90c364e7f620407304b820ac44ccb6db6987
注册机:
import frida
import glob
import time
# 你的 uid
uid = "uid"
targets = glob.glob(
"【2026春节】解题领红包之十 {Windows 高级题} 出题老师:Poner.exe")
if not targets:
raise SystemExit("target exe not found")
target = targets[0]
js = r'''
const base = Process.enumerateModules()[0].base;
function p(off){ return base.add(off); }
const f_cd490 = new NativeFunction(p(0xCD490), 'uint64', ['pointer','pointer']);
const g_b418 = p(0x2632418);
const g_b419 = p(0x2632419);
let lastY = '';
function toHex(ptr, n){
const u = new Uint8Array(ptr.readByteArray(n)); let s = ''; for (let i = 0; i < u.length; i++) { let h = u[i].toString(16); if (h.length < 2) h = '0' + h; s += h; } return s;}
Interceptor.attach(p(0xD3B20), {
onEnter(args){ lastY = toHex(args[1], 64); }});
Interceptor.attach(p(0x0C1B90), { onLeave(ret){ ret.replace(ptr(0)); } });
Interceptor.attach(p(0x09B30), { onLeave(ret){ ret.replace(ptr(0)); } });
rpc.exports = {
derive(uid){ g_b418.writeU8(0); g_b419.writeU8(0); lastY = ''; const pUid = Memory.allocUtf16String(uid); const pDummy = Memory.allocUtf16String('00'.repeat(64)); f_cd490(pUid, pDummy); return lastY; }, verify(uid, flag){ g_b418.writeU8(0); g_b419.writeU8(0); const pUid = Memory.allocUtf16String(uid); const pFlag = Memory.allocUtf16String(flag); return f_cd490(pUid, pFlag).toString(); }};
'''
pid = frida.spawn([target])
session = frida.attach(pid)
script = session.create_script(js)
script.load()
frida.resume(pid)
time.sleep(1)
api = script.exports_sync
flag = api.derive(uid)
ret = api.verify(uid, flag)
print("uid =", uid)
print("flag =", flag)
print("verify_ret =", ret)
frida.kill(pid)
MCP 中级题
提示词发给 AI :
ctf_request 填的是口令本身,不是 access_token;
audit_log_id 一定要用“被拒访问时返回的完整编号”,别截断别改;
而且只认“同一会话里最近那次拒绝”出来的编号,跨会话或旧编号都不行;
复核这段链路别配太杂,越单一越不容易断;
复核凭据是有时效、且一次性的,失败后要重新触发拒绝再拿新编号;
另外,复核阶段拿到的凭据只是打通流程,最终读密卷还需要后续凭据。
最终跑出 flag:flag{new_year_2026_keep_warm}