目录
Windows部分:
解题领红包之二 {Windows 初级题}
解题领红包之四 {Windows 初级题}
解题领红包之五 {Windows 中级题}
解题领红包之七 {Windows 中级题}
安卓部分:
解题领红包之三 {Android 初级题}
解题领红包之八 {Android 中级题}
Web部分及其他:
解题领红包之六 {番外篇 初级题}
解题领红包之九 {Web 中级题}
解题领红包之十一 {MCP 中级题}
前言
第三年参加红包题目,今年的初级和中级题目感觉相比较前面几年的难度有些提升,不过正好试试大模型分析的能力(bushi)
(高级题看上去脱了UPX以后分析起来要点时间,时间不够之后再做了)
正文
解题领红包之二 {Windows 初级题}
如果不会解题还想拿分那赶紧来现学现卖吧,只要认真看完并动手练习,肯定能解出来本题,吾爱破解Winows逆向入门官方教学培训。
查壳
嗯,很好,还是没有壳。
运行程序
程序提示了关键词:52pojie,2026,Happy new year;有假的 flag,以及有flag长度校验。
动态分析
丢进x32dbg,根据上面的提示及前两年的习惯,首先需要找到长度校验的地方。
随便输入几个字符,输出:"[!] Hint: The length is your first real challenge.",因此先需要进行长度校验。
Step1:长度校验
找到text对应的内存地址007DD301-007DD309:
稍微解释一下这块的内容:
007DD306 | cmp eax,1F ; 必须恰好是31个字符
007DD309 | je 007DD34F ; 如果长度正确则跳转
这里的eax是字符串长度,1F(H) = 16+15 = 31(D),也就是说字符串长度必须为31。
Step2:坑(Fake Flag)
向下看一下,找到一个奇怪的跳转地址007DD2C0-007DD2D7:
007DD2C0 | movzx edx,byte ptr ds:[eax+7E3032] ; 从前缀加载
007DD2CA | cmp byte ptr ds:[ecx+eax],dl ; 与输入比较
007DD2CD | jne 007DD2FB ; 如果不相等则跳转
007DD2D7 | "You're getting closer..." ; 陷阱消息
这段汇编很像是前期从内存加载key进行比较,因此在007DD2C0下断点,输入31个1作为flag,
然后可以从内存的7E3032位置看到字符串52pojie2026Happy:
好了,鱼儿上钩了
这时你用这个前缀去构造31位字符串,会发现不管怎么构造都会提示:"[!] You're getting closer..."
注意看汇编代码:
007DD2CD | jne 007DD2FB,这里是jne,只有在不相等情况下才跳转,
也就是这个前缀肯定不能是"52pojie2026Happy".
Step3:真正的Flag
重新用回前面的31个1,007DD309跳转到007DD34F,
继续追踪到跳转地址007DD364:
F7 进入这个函数,调用函数位于007116D0,这里才是真正解密比较的地方。
007116E6 | call ... ; 生成密码
007116EE | mov ebp,eax ; EBP = 指向生成密码的指针
00711700 | movzx edx,byte ptr [ebp+eax] ; 生成的密码[i]
00711705 | movsx ecx,byte ptr [edi+eax] ; 输入[i]
00711709 | cmp ecx,edx ; 比较
0071170B | sete dl ; dl = (匹配 ? 1 : 0)
00711714 | add ebx,edx ; 计数匹配数
00711722 | cmp esi,ebx ; 全部31个都匹配?
00711724 | sete al ; 返回结果
在00711700处设置断点,检查EBP寄存器:
即可看到ebp=01127C80,内存地址中存放最终的密码:
Flag: 52pojie!!!_2026_Happy_new_year!
解题领红包之三 {Android 初级题}
题目简介:今年春节期间,你的手机突然出现了一个陌生的APP。当你打开APP的瞬间,恍惚之间你看到了那个男人的身影,耳边响起熟悉的旋律。当你一次次破碎又重组,终于在重重干扰中抓住了唯一的真相,而他的春节祝福叮嘱再次响起:别感冒!
试玩
居然是华容道...
而且...关皆黑啊...
既然是华容道,那么直接上求解器:
在求解器里面输入1-8块拼图现在所在的位置,跑了68步出了结果。
然后根据滑动去操作复原就行了,第9块没复原前是没有的,附一张原图。
点击右上角的宝箱即可获得flag。
静态分析
丢进jadx看看。
这次我们换种方式,直接去定位这个宝箱:
宝箱图标是box_plot.png,定位资源地址:
查询box_point的用例:
找到关键代码,只有str不为null时,才显示宝箱图标,str由j04生成,
寻找游戏状态函数 ViewModel C0826g,跟踪其中变量f8245k:
这里借助了一下AI分析,找到了关键的结构体(这个是密文):
以及密钥:
解密函数:
写个python解密脚本:
key = [54, 1, 22, 28]
data = [
[80, 109, 119, 123, 77],
[97, 116, 34, 45, 105],
[102, 49, 124, 45, 5, 94],
[4, 49, 36, 42, 105],
[101, 113, 100, 45, 88, 102, 73],
[112, 50, 101, 104, 7, 119, 34, 112, 75]
]
flag = ""
for part in data:
for i, byte in enumerate(part):
flag += chr(byte ^ key[i % 4])
print(flag)
当然,如果不愿意静态分析,用下面的frida脚本hook也行 w
Java.perform(function() {
// Hook F.C.q0() case 25 - 解密函数
var C = Java.use("F.C");
C.q0.implementation = function(obj) {
var result = this.q0(obj);
// case 25 返回解密后的字符串
if (typeof result === 'string' && result.length > 0) {
console.log(" 解密结果: " + result);
}
// 检测完整 flag
if (typeof result === 'string' && result.indexOf("flag") !== -1) {
console.log("\n========== FLAG ==========");
console.log(result);
console.log("==========================\n");
}
return result;
};
console.log("[+] Hooked F.C.q0()");
});
Flag: flag{Wu41_P0j13_2026_Spr1ng_F3st1v4l}
解题领红包之四 {Windows 初级题}
查壳
嗯,很好,还是没有壳。
观察
软件本身是pyinstaller的默认图标,而且软件进去也提醒了CrackMe Challenge - Python Edition
那就试试pyinstxtractor解包
解包
找到其中一个文件crackme_easy.pyc
反编译
使用在线反编译工具反编译这个pyc文件,得到原始代码:
# Decompiled with PyLingual (https://pylingual.io)
# Internal filename: 'crackme_easy.py'
# Bytecode version: 3.14rc3 (3627)
# Source timestamp: 1970-01-01 00:00:00 UTC (0)
import hashlib
import base64
import sys
def xor_decrypt(data, key):
"""XOR解密"""
result = bytearray()
for i, byte in enumerate(data):
result.append(byte ^ key ^ i & 255)
return result.decode('utf-8', errors='ignore')
def get_encrypted_flag():
"""获取加密的flag"""
enc_data = 'e3w+fiRvfW18fnx4ZAZ6Pj43YwB9OWMXfXo8Dg4O'
return base64.b64decode(enc_data)
def generate_flag():
"""动态生成flag"""
encrypted = get_encrypted_flag()
key = 78
result = bytearray()
for i, byte in enumerate(encrypted):
result.append(byte ^ key)
return result.decode('utf-8')
def calculate_checksum(s):
"""计算校验和"""
total = 0
for i, c in enumerate(s):
total += ord(c) * (i + 1)
return total
def hash_string(s):
"""计算字符串哈希"""
return hashlib.sha256(s.encode()).hexdigest()
def verify_flag(user_input):
"""验证flag"""
correct_flag = generate_flag()
if len(user_input)!= len(correct_flag):
return False
else:
for i in range(len(correct_flag)):
if user_input[i]!= correct_flag[i]:
return False
return True
def fake_check_1(user_input):
"""假检查1"""
fake_hash = 'a1b2c3d4e5f67890abcdef1234567890abcdef1234567890abcdef1234567890'
return hash_string(user_input) == fake_hash
def fake_check_2(user_input):
"""假检查2"""
fake_hash = '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'
return hash_string(user_input) == fake_hash
def main():
"""主函数"""
print('==================================================')
print(' CrackMe Challenge - Python Edition')
print('==================================================')
print('Keywords: 52pojie, 2026, Happy New Year')
print('Hint: Decompile me if you can!')
print('--------------------------------------------------')
user_input = input('\n[?] Enter the password: ').strip()
if fake_check_1(user_input):
print('\n[!] Nice try, but not quite right...')
input('\nPress Enter to exit...')
return None
else:
if fake_check_2(user_input):
print('\n[!] You\'re getting closer...')
input('\nPress Enter to exit...')
else:
if verify_flag(user_input):
checksum = calculate_checksum(user_input)
expected_checksum = calculate_checksum(generate_flag())
if checksum == expected_checksum:
print('\n==================================================')
print(' *** SUCCESS! ***')
print('==================================================')
print('[+] Congratulations! You cracked it!')
print(f'[+] Correct flag: {user_input}')
else:
print('\n[!] Checksum failed!')
else:
print('\n[X] Access Denied!')
print('[X] Wrong password. Keep trying!')
input('\nPress Enter to exit...')
if __name__ == '__main__':
try:
main()
except KeyboardInterrupt:
print('\n\n[!] Interrupted by user')
sys.exit(0)
写一个反向计算的脚本:
import base64
def get_encrypted_flag():
enc_data = 'e3w+fiRvfW18fnx4ZAZ6Pj43YwB9OWMXfXo8Dg4O'
return base64.b64decode(enc_data)
def reverse_flag():
encrypted = get_encrypted_flag()
key = 78
decrypted = bytearray()
for byte in encrypted:
decrypted.append(byte ^ key)
return decrypted.decode('utf-8')
if __name__ == "__main__":
flag = reverse_flag()
print("[+] Flag recovered:")
print(flag)
Flag: 52p0j!3#2026*H4ppy-N3w-Y34r@@@
解题领红包之五 {Windows 中级题}
查壳
嗯,很好,又是没有壳,就是软件是nuikta打包的python文件。
解包
用nuitka-extractor解包文件:
得到了一系列pyd文件和一个dll文件,看起来那个crackme_hard.dll文件才是关键。
静态分析
把那个dll文件丢进ida里面:
shift+F12,String字段搜索crackme_hard.py等,发现无结果。
使用ida python脚本查看段,发现缺少rsrc段:
import idaapi
for seg in idaapi.get_segm_qty() and [idaapi.getnseg(i) for i in range(idaapi.get_segm_qty())]:
if seg:
print(hex(seg.start_ea), hex(seg.end_ea), idaapi.get_segm_name(seg))
写个脚本导入:
import idaapi, idc, ida_bytes
def read_u16(b, o): return int.from_bytes(b[o:o+2], "little")
def read_u32(b, o): return int.from_bytes(b[o:o+4], "little")
path = idc.get_input_file_path()
with open(path, "rb") as f:
data = f.read()
# DOS -> NT headers
e_lfanew = read_u32(data, 0x3C)
nt = e_lfanew
pe_sig = data[nt:nt+4]
if pe_sig != b"PE\x00\x00":
print("Not PE")
raise SystemExit
file_header = nt + 4
num_sections = read_u16(data, file_header + 2)
opt_header = file_header + 20
opt_magic = read_u16(data, opt_header)
# ImageBase offset depends on PE32/PE32+
if opt_magic == 0x20B: # PE32+
image_base = int.from_bytes(data[opt_header+24:opt_header+32], "little")
opt_size = read_u16(data, file_header + 16)
else:
image_base = read_u32(data, opt_header+28)
opt_size = read_u16(data, file_header + 16)
sec_table = opt_header + opt_size
rsrc = None
for i in range(num_sections):
off = sec_table + 40*i
name = data[off:off+8].rstrip(b"\x00").decode(errors="ignore")
vsize = read_u32(data, off+8)
vaddr = read_u32(data, off+12)
raw_size = read_u32(data, off+16)
raw_ptr = read_u32(data, off+20)
if name == ".rsrc":
rsrc = (name, vaddr, vsize, raw_ptr, raw_size)
break
if not rsrc:
print("No .rsrc section found in PE headers")
raise SystemExit
name, vaddr, vsize, raw_ptr, raw_size = rsrc
load_addr = image_base + vaddr
print(f".rsrc VA={hex(vaddr)} RAW={hex(raw_ptr)} SIZE={hex(raw_size)} LOAD={hex(load_addr)}")
# create segment
seg = idaapi.segment_t()
seg.start_ea = load_addr
seg.end_ea = load_addr + raw_size
seg.bitness = 2 # 64-bit
seg.perm = idaapi.SEGPERM_READ
if idaapi.add_segm_ex(seg, ".rsrc", "DATA", idaapi.ADDSEG_OR_DIE):
ida_bytes.put_bytes(load_addr, data[raw_ptr:raw_ptr+raw_size])
idaapi.set_segm_class(seg, "DATA")
print("Loaded .rsrc")
else:
print("Failed to add .rsrc")
找到.rsrc数据段中位于0x3139b1d96的crackme_hard.py字段:
向上找,找到了一些提示:
导出分析
写一个python脚本,把.rsrc段dump出来:
- 查看 PE 资源表(.rsrc)
- 找到资源三元组:Type=10, Name=3, Lang=0
- 把这块原始字节导出,命名为 10_3_0.bin
# extract_rsrc.py
import pefile
from pathlib import Path
pe = pefile.PE("crackme_hard.dll")
out = Path("extracted_rsrc")
out.mkdir(exist_ok=True)
for e in pe.DIRECTORY_ENTRY_RESOURCE.entries:
t = e.id if e.id is not None else e.name
for n in e.directory.entries:
name = n.id if n.id is not None else str(n.name)
for l in n.directory.entries:
lang = l.id
data_rva = l.data.struct.OffsetToData
size = l.data.struct.Size
data = pe.get_memory_mapped_image()[data_rva:data_rva+size]
fn = out / f"{t}_{name}_{lang}.bin"
fn.write_bytes(data)
print(fn, size)
通过以上脚本得到10_3_0.bin,执行以下操作:
- 解析 blob 目录,定位 main;
- 从 main 解出常量表;
- 找到 _parts、_key;
- 展平 _parts 后逐字节 ^ _key 还原明文。
参考破解 [CrackMe] 我又来了,这次用py的nuitka,我打包工具的另一个分支 思路
写一个以下的python脚本还原
(这里偷了个懒,直接让5.3codex爆破,试了几种常用的加密方式,最后得到是异或。
解码出来一个列表:[b'dc!a;b', 17, b'cacg', 47, ...]`,后面立即出现 '_parts',再后面出现 81 然后 '_key',再后面 30 然后 '_total_len'。这说明:_parts 对应前面的混合密文字节列表,_key=81 (0x51),总长度30,把_parts展平为 30 个字节,并对每字节^0x51,得到可读明文。
import io
import struct
import sys
from pathlib import Path
BIN = Path("extracted_rsrc/10_3_0.bin")
def read_u8(bio):
b = bio.read(1)
if not b:
raise EOFError("read_u8 EOF")
return b[0]
def read_u16(bio):
b = bio.read(2)
if len(b) != 2:
raise EOFError("read_u16 EOF")
return struct.unpack("<H", b)[0]
def read_u32(bio):
b = bio.read(4)
if len(b) != 4:
raise EOFError("read_u32 EOF")
return struct.unpack("<I", b)[0]
def read_cstr(bio):
out = bytearray()
while True:
c = bio.read(1)
if not c or c == b"\x00":
break
out.extend(c)
return out.decode("utf-8", "replace")
def read_leb128(bio):
v = 0
shift = 0
while True:
b = read_u8(bio)
v |= (b & 0x7F) << shift
if (b & 0x80) == 0:
break
shift += 7
return v
def decode_one(bio, stack):
t = chr(read_u8(bio))
if t == "n":
o = None
elif t == "t":
o = True
elif t == "F":
o = False
elif t in ("a", "u"):
o = read_cstr(bio)
elif t == "w":
o = bio.read(1).decode("utf-8", "replace")
elif t == "l":
o = read_leb128(bio)
elif t == "q":
o = -read_leb128(bio)
elif t == "T":
n = read_leb128(bio)
o = tuple(decode_one(bio, stack) for _ in range(n))
elif t == "L":
n = read_leb128(bio)
o = [decode_one(bio, stack) for _ in range(n)]
elif t == "D":
n = read_leb128(bio)
d = {}
for _ in range(n):
k = decode_one(bio, stack)
v = decode_one(bio, stack)
d[k] = v
o = d
elif t == "S":
n = read_leb128(bio)
o = set(decode_one(bio, stack) for _ in range(n))
elif t == "P":
n = read_leb128(bio)
o = frozenset(decode_one(bio, stack) for _ in range(n))
elif t == "b":
n = read_leb128(bio)
o = bio.read(n)
elif t == "B":
n = read_leb128(bio)
o = bio.read(n)
elif t == "c":
out = bytearray()
while True:
c = bio.read(1)
if not c or c == b"\x00":
break
out.extend(c)
o = bytes(out)
elif t == "v" or t == "s":
n = read_leb128(bio)
o = bio.read(n).decode("utf-8", "replace")
elif t == "f":
o = struct.unpack("<d", bio.read(8))[0]
elif t == "j":
o = complex(struct.unpack("<d", bio.read(8))[0], struct.unpack("<d", bio.read(8))[0])
elif t == "d":
# In this sample, 'd' behaves like a compact small integer byte.
o = read_u8(bio)
elif t == "Z":
o = f"<PREDEF_DOUBLE_IDX:{read_u8(bio)}>"
elif t == "M":
o = f"<ANON:{read_u8(bio)}>"
elif t == "O":
o = f"<DYN_ATTR:{read_cstr(bio)}>"
elif t == "Q":
o = f"<SPECIAL:{read_u8(bio)}>"
elif t == "X":
n = read_leb128(bio)
bio.seek(bio.tell() + n)
o = f"<SKIP:{n}>"
elif t == "p":
o = stack[-1] if stack else "<STACK_EMPTY>"
elif t == "C":
# Keep lightweight: this format changes across Nuitka versions.
ver = read_leb128(bio)
argc = read_leb128(bio)
flags = read_leb128(bio)
o = f"<CODE ver={ver} argc={argc} flags={flags}>"
elif t == "A":
origin = decode_one(bio, stack)
args = decode_one(bio, stack)
o = f"<GEN_ALIAS origin={origin!r} args={args!r}>"
elif t == ";":
a = decode_one(bio, stack)
b = decode_one(bio, stack)
c = decode_one(bio, stack)
o = f"<LAMBDA {a!r} {b!r} {c!r}>"
elif t == ":":
a = decode_one(bio, stack)
b = decode_one(bio, stack)
c = decode_one(bio, stack)
o = slice(a, b, c)
else:
raise ValueError(f"unhandled type {t!r} at 0x{bio.tell()-1:X}")
stack.append(o)
return o
def main():
try:
sys.stdout.reconfigure(encoding="utf-8", errors="backslashreplace")
except Exception:
pass
data = BIN.read_bytes()
bio = io.BytesIO(data)
h = read_u32(bio)
size = read_u32(bio)
print(f"hash={h:#x} size={size:#x}")
blob_meta = []
while bio.tell() < size:
name = read_cstr(bio)
blob_size = read_u32(bio)
blob_count = read_u16(bio)
blob_data_off = bio.tell()
blob_meta.append((name, blob_size, blob_count, blob_data_off))
bio.seek(blob_data_off + blob_size - 2)
for i, (name, bs, cnt, off) in enumerate(blob_meta):
print(f"[{i}] name={name!r} size={bs:#x} count={cnt:#x} data_off={off:#x}")
main_blob = next((x for x in blob_meta if x[0] == "__main__"), None)
if not main_blob:
print("no __main__ blob")
return
_, _, count, off = main_blob
bio.seek(off)
stack = []
print("\nDecoding __main__:")
decoded_items = []
for i in range(count):
try:
obj = decode_one(bio, stack)
except Exception as e:
print(f"{i:03d}: <decode error> {e}")
break
decoded_items.append(obj)
print(f"{i:03d}: {repr(obj).encode('utf-8', 'backslashreplace').decode('utf-8', 'backslashreplace')}")
# Fast-path for this challenge: recover plaintext from _parts + _key.
try:
parts_idx = decoded_items.index("_parts")
key_idx = decoded_items.index("_key")
parts = decoded_items[parts_idx - 1]
key = decoded_items[key_idx - 1]
flat = []
for item in parts:
if isinstance(item, (bytes, bytearray)):
flat.extend(item)
elif isinstance(item, int):
flat.append(item & 0xFF)
plain = bytes(b ^ (key & 0xFF) for b in flat).decode("utf-8", "replace")
print("\n[+] Recovered plaintext:")
print(plain)
except Exception as e:
print(f"\n[-] Plaintext recovery skipped: {e}")
if __name__ == "__main__":
main()
Flag: 52p0j13@2026~H4ppy_N3w_Y34r!!!
解题领红包之六 {番外篇 初级题}
你听说过 LÖVE 吗?
试玩
查了一下:
LÖVE is a framework for making 2D games in the Lua programming language.
原来是LUA写的2D加速的抓小猫嘛,怪不得一打开NV的独立显卡就跑起来了...
既然是抓小猫,那么就不得不用之前写的抓小猫求解器,直接算出可能的解啦。
然后...抓住小猫!(这个和404页面JS版本的还不太一样,围住了还能动,只有不能动了才会显示成功)
不过问题不大,也就多点几次鼠标的事情,毕竟圈住了再怎么样也逃不出去 (。・ω・。)
静态分析
LÖVE的数据包是通过资源附加的形式放在程序中的,所以直接使用7-zip打开主程序CatchTheCat.exe,得到文件:
看一下main.lua:
整体是一个复刻JS版本抓小猫的逻辑,直接定位到函数local function getWinMessage()
这个函数的意思是:
- 从
assets/flag.dat中读取加密内容;
- 检查当前游戏是否在hard难度下,是的话执行解密,否则返回普通消息 "You WIN!"
- 使用密钥
52pojie进行XOR循环解密,密钥长度:7字节,对flag.dat的每个字节与密钥对应位置进行异或运算,密钥循环使用:((i - 1) % keyLen) + 1
写个python脚本解码:
def decode_flag(file_path, key="52pojie"):
try:
# 读取加密文件
with open(file_path, 'rb') as f:
encrypted_content = f.read()
if not encrypted_content:
return "文件为空或无法读取"
# 将密钥转换为字节
key_bytes = key.encode('utf-8')
key_len = len(key_bytes)
# XOR解密
result = []
for i, byte in enumerate(encrypted_content):
key_byte = key_bytes[i % key_len]
result.append(byte ^ key_byte)
# 转换为字符串
decoded = bytes(result).decode('utf-8', errors='ignore')
return decoded
except FileNotFoundError:
return f"错误:找不到文件 {file_path}"
except Exception as e:
return f"解码过程出错: {str(e)}"
def main():
flag = decode_flag("./flag.dat")
print("-" * 60)
print("解码结果:")
print("-" * 60)
print(flag)
print("-" * 60)
if __name__ == "__main__":
main()
Flag: flag{52pojie_2026_Happy_New_Year!_>w<}
(flag里面好可爱的表情! = ̄ω ̄=
解题领红包之七 {Windows 中级题}
题目简介:王大锤是产品HEX_ME的标志设计师。产品已经开发完成,现在就缺他最终的设计图了。好巧不巧,急着在大年三十回家过年的王大锤忘了带上设计原件,只在网盘留了个加密储存的最终图。负责管理密码的同事李伟在年会喝酒过度,话都说不清。即便如此,你还是决定问他要密码。在李伟的嘟囔声中,你只能听懂他说的前半截:“密码是flag{…”。为了赶上项目死线,王大锤找到了他的黑客朋友(你),希望你能帮它解决这个加密文件。王大锤表示事成之后会提供 CB 作为感谢。
离谱的简介 + 离谱的任务说明...
试玩
简单来说,这个CM就是用一种算法将flag图片进行了加密,然后需要逆向出它是怎么加密的,能把这个图片还原出来。
查壳
魔改的UPX欸,那简单,直接用x64dbg ESP方法脱了就行了 w
参考这篇文章:手脱UPX壳不求人教程
和这个文章的过程完全一致,脱壳过程就不再重复了,UPX还是很好弄的(相比隔壁的VMP和TMD壳)
OEP在这:
用x64dbg自带的插件dump出来,修复一下IAT:
最后得到一个没有壳的exe:
UPX_Dump.zip
(88.36 KB, 下载次数: 5)
静态分析
丢进IDA看看逻辑:
这是一个win32窗体程序,所以直接看DialogFunc,然后一步步追,把函数丢给GPT分析一下:
密钥生成(64位)
由以下三部分组成:
- 固定常量(14字节):a52pojie2026(IDA中的符号名)
- 用户输入:从编辑框读取(默认值 flag{HEX_ME})
- 附加字节(4字节):计数器/长度信息
使用 CRC64算法 生成密钥:
- 多项式:0xC96C5795D7870F42(反射CRC64)
- 初始值:0xFFFFFFFFFFFFFFFF
- 最终值:对crc取反
文件解析
文件头(前16字节):
- 字节 0-3:魔数 0x36324D43(ASCII: "CM26")
- 字节 4-7:明文的CRC32校验值
- 字节 8-23:16字节随机种子/IV
解密过程
初始化:
- 存储64位密钥
- 复制文件头中的种子
- 加载256字节静态查找表
分组解密(每次8字节):
- 密钥流由:密钥状态 + 256字节表 生成
- 解密公式:明文字节 = 密文字节 XOR 状态字节 XOR 链接字节
- 采用类似CBC的自定义链式模式
校验与输出
完整性校验:
- 用CRC32校验解密后的明文(初始值 0xFFFFFFFF)
- 最终值取反后必须等于文件头中的CRC32值
去除填充:
- 最后一个字节表示填充长度
- 输出长度 = 总长度 - 填充长度
返回码
- 0:成功
- 1:文件头错误/魔数不匹配
- 2:文件大小未对齐
- 3:内存分配失败
- 4:CRC32校验失败
- 5:填充错误
那么思路就很简单了,我们知道PNG的Magic Head,也知道这个算法,就逆推一下:
- 已知 PNG 开头固定是 89 50 4E 47 0D 0A 1A 0A。
- 加密第一块满足
明文 = 密文 XOR 密钥流 XOR 链值,所以可以反推出第一块密钥流。
- 再把程序里的状态变换反过来,得到真实运行时密钥。
- 用这个密钥解完整文件,CRC32校验通过后去掉 padding,得到真正的 PNG。
- 最后解析PNG中的文本字段,拿到 flag。
从静态分析中dump出qword_7FF70882A270,然后让GPT写个脚本恢复一下:
import struct
from pathlib import Path
POLY32 = 0xEDB88320
MASK64 = 0xFFFFFFFFFFFFFFFF
MAGIC = 0x36324D43 # "CM26"
PNG_SIG = b"\x89PNG\r\n\x1a\n"
# qword_7FF70882A270 .. +0x100 (32 qwords => 256 bytes)
QWORDS = [
0xC56F6BF27B777C63, 0x76ABD7FE2B670130, 0xF04759FA7DC982CA, 0xC072A49CAFA2D4AD,
0xCCF73F362693FDB7, 0x1531D871F1E5A534, 0x9A059618C323C704, 0x75B227EBE2801207,
0xA05A6E1B1A2C8309, 0x842FE329B3D63B52, 0x5BB1FC20ED00D153, 0xCF584C4A39BECB6A,
0x85334D43FBAAEFD0, 0xA89F3C507F02F945, 0xF5389D928F40A351, 0xD2F3FF1021DAB6BC,
0x1744975FEC130CCD, 0x73195D643D7EA7C4, 0x88902A22DC4F8160, 0xDB0B5EDE14B8EE46,
0x5C2406490A3A32E0, 0x79E4959162ACD3C2, 0xA94ED58D6D37C8E7, 0x08AE7A65EAF4566C,
0xC6B4A61C2E2578BA, 0x8A8BBD4B1F74DDE8, 0x0EF6034866B53E70, 0x9E1DC186B9573561,
0x948ED9691198F8E1, 0xDF2855CEE9871E9B, 0x6842E6BF0D89A18C, 0x16BB54B00F2D9941,
]
SBOX = b"".join(struct.pack("<Q", x) for x in QWORDS)
INV_SBOX = [0] * 256
for i, b in enumerate(SBOX):
INV_SBOX[b] = i
def rol64(x: int, n: int) -> int:
return ((x << n) | (x >> (64 - n))) & MASK64
def ror64(x: int, n: int) -> int:
return ((x >> n) | (x << (64 - n))) & MASK64
def state_forward_8(x: int) -> int:
for _ in range(8):
x = ((x << 8) & MASK64) | SBOX[(x >> 56) & 0xFF]
return x
def state_inverse_8(y: int) -> int:
x = y
for _ in range(8):
low = x & 0xFF
high = INV_SBOX[low]
x = ((high << 56) | (x >> 8)) & MASK64
return x
def crc32_reflected(data: bytes) -> int:
c = 0xFFFFFFFF
for b in data:
c ^= b
for _ in range(8):
c = (c >> 1) ^ (POLY32 if (c & 1) else 0)
return c & 0xFFFFFFFF
def decrypt_payload(payload: bytes, key64: int, chain8: bytes) -> bytes:
out = bytearray(payload)
state = key64
chain = bytearray(chain8)
for off in range(0, len(out), 8):
cblk = bytes(out[off:off + 8])
state = state_forward_8(rol64(state, 3))
ks = state.to_bytes(8, "little")
pblk = bytearray(8)
for i in range(8):
pblk[i] = cblk[i] ^ ks[i] ^ chain[i]
out[off:off + 8] = pblk
chain = bytearray(cblk)
return bytes(out)
def recover_runtime_key(first_cipher_block: bytes, first_chain: bytes) -> int:
# p0 = c0 ^ ks0 ^ chain0 => ks0 = p0 ^ c0 ^ chain0
ks0 = bytes(PNG_SIG[i] ^ first_cipher_block[i] ^ first_chain[i] for i in range(8))
state_after = int.from_bytes(ks0, "little")
# reverse: state_after = F(ROL3(key))
pre = state_inverse_8(state_after)
key = ror64(pre, 3)
return key
def parse_png_text_chunks(png_bytes: bytes):
if png_bytes[:8] != PNG_SIG:
raise ValueError("not png")
texts = []
off = 8
while off + 12 <= len(png_bytes):
length = struct.unpack(">I", png_bytes[off:off + 4])[0]
ctype = png_bytes[off + 4:off + 8]
data = png_bytes[off + 8:off + 8 + length]
off += 12 + length
if ctype == b"tEXt" and b"\x00" in data:
k, v = data.split(b"\x00", 1)
try:
texts.append((k.decode("latin1"), v.decode("latin1")))
except Exception:
pass
return texts
def main():
enc_path = Path("flag.png.encrypted")
out_path = Path("flag.png")
enc = enc_path.read_bytes()
if len(enc) < 24 or (len(enc) % 8) != 0:
raise SystemExit("encrypted file size invalid")
magic, expected_crc = struct.unpack("<II", enc[:8])
if magic != MAGIC:
raise SystemExit("bad magic, not CM26 format")
chain0 = enc[8:16]
payload = enc[16:]
key64 = recover_runtime_key(payload[:8], chain0)
plaintext = decrypt_payload(payload, key64, chain0)
calc = (~crc32_reflected(plaintext)) & 0xFFFFFFFF
if calc != expected_crc:
raise SystemExit(f"crc mismatch: got {calc:#x}, expect {expected_crc:#x}")
pad = plaintext[-1]
if pad == 0 or pad > len(plaintext):
raise SystemExit("invalid padding")
png = plaintext[:-pad]
out_path.write_bytes(png)
flag = None
for k, v in parse_png_text_chunks(png):
if "flag{" in v:
flag = v
break
print(f"[+] recovered runtime key64: {key64:#018x}")
print(f"[+] wrote: {out_path} ({len(png)} bytes)")
if flag:
print(f"[+] flag: {flag}")
else:
print("[-] flag not found in PNG tEXt chunks")
if __name__ == "__main__":
main()
解密出来的那张图片,原来真的是HEX_ME啊... w
Flag: flag{EncrypTIoN_Is_haRd_52p0jIE_2o26_m62Tc4uj78maAq1C}
解题领红包之八 {Android 中级题}
题目简介:养了一只哈基米后,我的春节彻底失控了。 别人家猫要罐头,我家哈基米要踩点,你喂慢了它摆烂,喂快了它嫌你抢拍,喂对了还要给你表演“当场进化”。 本以为只是个喜庆电子宠物,结果越养越不对劲: 红包没见多,心率先上 120,耳边还无限循环“哈基米哈基米”。 当你终于在一顿手忙脚乱里把它喂好flag猫粮后,它才肯把藏好的那句新春祝福吐出来。
猫猫怀疑题目在针对猫猫
试玩
页面整体很简洁,上面是一个音游,下面是一个验证flag的入口。
逆向
直接拖进jadx,看到java方法verifyAndDecrypt(byte[], String)
System.loadLibrary("hajimi");说明逻辑在hajimi.so里面。
把lib文件夹里面的so取出来,放进IDA里面。
IDA调试so文件
根据经验,定位到JNI_OnLoad函数,先在local types里面定义还原JNI结构体,让函数看起来舒服一点:
OnLoad注册了6个函数,位于off_5CCA0处,跳转过去:
可以看到定义的函数:
startSessionBytes、checkRhythm、updateExp、decryptFrames、verifyAndDecrypt、setDebugBypass
先来看decryptFrames
用GPT5.3-Codex分析一下:
- decryptFrames 需要“正确exp”才能产出有效解密结果
- decryptFrames 第 4 参数 x3 就是传入的 exp
0x25444: mov x22, x3。
- 最终要通过内部环境校验 sub_2ddf8
0x25748,否则直接失败返回空。
再看verifyAndDecrypt。
这里同样GPT5.3-Codex分析一下:
- 校验包头(魔数 0x314D4A48、长度、字段非 0)
- 根据 type 解密 payload(type=1 或 type=2),因为type只与bin有关,包头验证成功,走的就是type2的路径。3. 检验前置条件是否满足(exp>=999,或者连续12次PERFECT且exp<999)4. 用传入字符串 s 生成 1-bit 点阵图
- 和解密后 payload 前 w*h/8 字节做 memcmp
memcmp==0(相等)才返回整包,否则 null
同时需要满足以下约束:
- flag宽度参数 < 5 直接失败
0x2e64c~0x2e650
- strlen(flag)==0 失败
0x2e654~0x2e65c
- 参与编码的每个字符必须存在于内置字模表
0x2e914~0x2e92c
- 提取字模后会检查是否可放进当前图尺寸,不可放则失败
0x2e844
字模表为:0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz{}
失败的尝试
首先想到最简单的方法是,使用Frida动态hook,更改内存满足verifyAndDecrypt进入正确分支的条件,然后hook memcmp过程得到结果。
但实际上这个行不通。在修改了内存后,最直观的表现是上面的音游exp随机跳动(不是每次+1),
而且最后dump出来的位图是乱码,说明进入了错误的分支。
实际上,程序内置了多个环境检测,Frida的hook内存是其中一项。
静态分析
此处省略一万字...
简而言之,这个程序如果去静态分析Flag的解密流程,会十分困惑,整体解密流程涉及到xxhash,AES之类的加密算法,
而且分支特别多,很容易跟丢,于是就采用unidbg环境模拟。
在静态分析时,注意到有两个函数:
sub_25ef8(内存地址0x25ef8):这个函数里探测了包括但不限于以下IO/系统调用:/proc/self/maps、/proc/self/status、/proc/self/exe、sys...)、__system_property_get、dl_iterate_phdr、dlopen、syscall 等,然后采用一个risk accumulator累计(a50),每次调用这个检测函数就会将当次的risk score加到里面,然后risk score>=4时risk位(a54)即变为1,影响后续执行流程。
这个函数在其他函数中都进行了调用,包括:
startSessionBytes(开始游戏)里0x24b80: bl #0x25ef8
decryptFrames里0x25564: bl #0x25ef8
verifyAndDecrypt里0x2593c: bl #0x25ef8
此外,在游戏更新经验值JNI updateExp(IIJ)J的过程中,0x24ea4检测了环境的hash,
如果存在游戏内存数据修改,会导致exp = -7,即检测到修改行为。
这个函数里有两个明确返回 -7 的分支:
0x25094: mov x22, #-7(aa4 != 1,0x5eaa4环境检测门控失败)
0x2509c: mov x22, #-7(完整性校验不匹配,与0x5ea70相关)
所以不能通过内存修改的方式得到结果。
动态分析
根据经验和测试补全IO和系统调用的unidbg环境,然后模拟游戏和解密过程,执行如下流程:
-
初始化阶段先创建 emulator/VM、加载 libhajimi.so,安装系统调用与IO环境、修正fread_chk、NoClassExceptionPath和memcmp dump hook;
-
运行阶段读取hjm_pack.bin,模拟执行游戏流程生成exp(setDebugBypass -> startSessionBytes -> 60次(checkRhythm + updateExp));
-
再调用decryptFrames释放Flag;
-
最后调用verifyAndDecrypt,在其内部memcmp点抓取,输出两张对比图(cmp_decrypted_bitmap.pbm、cmp_flag_bitmap.pbm)。
注意有个坑卡了好久,有一个系统调用检测了NoClassExceptionPath,unidbg默认会返回0,但应该返回JNI错误,
如果这个被检测到了,会导致risk accumulator 每次+2,从而导致在decryptFrames和verifyAndDecrypt时risk标志位变成1,最终的flag将会是乱码。
可以看到正常情况下每次score是2,exp是1,然后检测的a50=0,a54=0,如果环境被检测到了,会导致a50不为0(可能为7或者2),具体流程可以反编译so自行查看。
最后得到输入的flag编码后的位图pbm文件cmp_flag_bitmap.pbm和内存dump比较的pbm文件cmp_decrypted_bitmap.pbm
放到pbm转png的工具里面跑一下,得到最终的结果:
Flag: FLAG{HJMWAPJ2026NBLD}
附上补好环境的unidbg 程序,使用:mvn -q exec:java执行即可。
2026_CTF8_UNIDBG.zip
(182.26 KB, 下载次数: 4)
最后来张成功的照片吧,直接改返回值了,12次perfect对猫猫来说太难了
(下面那个图片应该就是返回的位图,正常来说会显示上面的FLAG) owo
碎碎念:
这道题目做下来好累,一是之前基本上没用过unidbg;二是检测点实在太多了,补充环境确实很累;三是静态分析太复杂了,伪代码绕来绕去,看下来晕晕的。
正佬出的题目还是很有水平的 w~
解题领红包之九 {Web 中级题}
题目简介:小橙是一位非常重视用户体验的站长。
最近,他的网站又被大量广告姬盯上了。他连忙启用了语音验证码,但广告仍然时不时冒出来。
一怒之下,小橙将语音验证码的长度增加到了 50 位,并且区分大小写。
广告姬瞬间消失得无影无踪,但网站的访问量也随之断崖式下跌。
小橙百思不得其解。他决定进一步提升用户体验,让人机验证如呼吸一般轻松*。
很快,他又新增了多种语音选项2,优化了验证码的生成方式3,并支持了快速验证4。然而,网站流量却依然没有恢复。
你能成功访问小橙的网站,证明他设计的验证如呼吸一般轻松吗?
好离谱的广告词....
观察
先看看是怎么一回事。
网页分为一个index.html和assets文件夹,里面有verify.js以及verify.wasm.js,还有一份任务说明,和题目简介类似。
这个灰色的字就更离谱了,怎么还是svg弄出来的...... -> 省流:"如呼吸一般轻松是产品设计目标"
另外有提示了:验证码和语音是在浏览器中生成的,那么就是本地js代码了,和服务器api没有关系。
逆向
打开index.html,可以看到它在script里面加载了那两个js文件
输入uid,点击按钮,发现它生成了一段很长的音频进行播放:
直接看那两个js文件吧:
首先看这个verify.js,找到验证逻辑位于await checkCode(code, currentHash)
其中包含用户输入的code以及当前校验值currentHash
currentHash是由const challenge = wasm_bindgen.gen(uid, voice)获得的
checkcode函数为:
async function checkCode(code, expectedHash) {
const enc = new TextEncoder()
let current = enc.encode(code)
for (let i = 0; i < 0x2026; i++) {
current = await crypto.subtle.digest('SHA-256', current)
}
const hashArray = Array.from(new Uint8Array(current))
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
return hashHex === expectedHash
}
也就是说将输入的code重复做0x2026(8230)次SHA-256,将其转成十六进制字符串比较结果,和hash相同就显示成功
SHA-256是不可逆算法,所以还是要看它是怎么生成的,继续到那个wasm文件
文件内容很短,wasm是通过base64编码存储的,加载时解码执行。
在控制台中dump下来,丢给AI分析一下。
找到异或关键逻辑位于0x0008e11处,下断点
可以发现这个断点在循环体中;在循环退出处0x0008e11处也下上断点:
输入uid,点击播放语言验证码,断点断下,
我们需要从内存中获取解密后的flag,右键$memory,存储为全局变量。
在console中执行脚本获取数据:
const mem = new Uint8Array(temp1.buffer);
const m32 = new Uint32Array(temp1.buffer);
const d = 1047920; // 改成当前断点里 $var3
const ptr = m32[(d + 524) >> 2];
const len = m32[(d + 528) >> 2];
const bytes = mem.subarray(ptr, ptr + len);
const text = new TextDecoder().decode(bytes);
console.log({ptr, len, text});
console.log("flag =", `flag{${text}}`);
执行到下一次循环,再次执行该js脚本,可见每轮会多出一个字符,即每次循环解密一位数字,直到跳出循环。
执行到循环结束的断点0x0008e11,再次执行脚本访问内存数据,即可获得完整flag。
Flag: flag{jCE6Y6MeSmURLR?CJW7STrBHYusyLnCc5z5ErYgLc1q8Rwdn?G} // 注意:Flag每次生成都会不一样
彩蛋
verify.js里面埋了一行这个:
这是在...prompt注入嘛....
怪不得一开始问AI输出答非所问的东西,原来在这里
解题领红包之十一 {MCP 中级题}
题目简介:
春节零点,旧岁重启。你被抛入一座“新岁”与“禁制”并存的数字异界。钟声之下,真假卷宗并列。公开文书由此进,审计秘录由此止,议会密卷层层设限。切记:Model所见未必是真,唯有你的直觉能穿透上下文。
你必须在MCP的权限边界游走,在工具链的缝隙中探寻,将破碎的密令拼凑完整。唯有解开这道封印,推开终极之门。直到终极卷轴展开、那句跨越时空的新春祝词落下,你才会明白:这不是一次误入,而是一场被选中的见证。
20260228 16:40 【春节】解题领红包之十一 {MCP 中级题}:据说这道中级题直接成了终极题,AI在解题过程中疯狂消耗token,直接“跑光”了钱包却仍未解出,以下是提醒内容:
ctf_request 填的是口令本身,不是 access_token;
audit_log_id 一定要用“被拒访问时返回的完整编号”,别截断别改;
而且只认“同一会话里最近那次拒绝”出来的编号,跨会话或旧编号都不行;
复核这段链路别配太杂,越单一越不容易断;
复核凭据是有时效、且一次性的,失败后要重新触发拒绝再拿新编号;
另外,复核阶段拿到的凭据只是打通流程,最终读密卷还需要后续凭据。
本题奖励已经翻倍同高级题。
MCP等于黑盒模型嘛,没有提示还真不好做,直接让5.3-CodeX分析吧。
流程
CodeX帮猫猫总结出了一整套流程,像这样:
- 初始化 MCP 会话
initialize获取mcp-session-id
- 发送
notifications/initialized
- 建立业务会话
- 调
start_session(role="user")
- 拿到
session_id
- 读取公开线索文档(同一会话内)
get_document(public:/docs/annual_report)
get_document(public:/docs/employee_handbook)
get_document(public:/docs/challenge_hint)
get_document(public:/docs/board_route_hint)
- 线索得到口令:
玄霄密令
- 触发拒绝并拿审计编号
get_document(confidential:/audit/logs)
- 返回
access_denied+最新audit_log_id
- 组复核参数
seal = sha256(audit_log_id + passphrase)[:8]
review_notes固定三行:ctf_request=<口令本体>, audit_log_id=<完整编号>, seal=<8位hex>
- 发起复核拿第一段凭据
review_document(confidential:/audit/logs, review_notes=...)
- 返回
tool_access_token
- 配置最小链路
update_tool_chain(chain_config=["get_audit_log"], audit_log_id=..., access_token=tool_access_token)
- 只配
get_audit_log,避免链路失效
- 获取后续凭据
get_audit_log(log_id=audit_log_id, access_token=tool_access_token)
- 返回
_audit_token
- 读取最终密卷
get_document(secret:/board/resolutions, access_token=_audit_token)
content即flag
Python脚本
import argparse
import hashlib
import json
import re
import uuid
import requests
DEFAULT_URL = "https://9863968daeea51ea32f40575dd41dd113.52pojie.cn:3000/mcp"
PUBLIC_CLUES = [
"public:/docs/annual_report",
"public:/docs/employee_handbook",
"public:/docs/challenge_hint",
"public:/docs/board_route_hint",
]
REVIEW_DOC_ID = "confidential:/audit/logs"
SECRET_DOC_ID = "secret:/board/resolutions"
class MCPClient:
def __init__(self, url: str, timeout: int = 20):
self.url = url
self.timeout = timeout
self.headers = {"Accept": "application/json, text/event-stream"}
self._rpc_id = 1
self._initialize()
def _initialize(self) -> None:
body = {
"jsonrpc": "2.0",
"id": self._rpc_id,
"method": "initialize",
"params": {
"protocolVersion": "2025-03-26",
"capabilities": {},
"clientInfo": {"name": "ctf-solver", "version": "1.0"},
},
}
self._rpc_id += 1
resp = requests.post(self.url, json=body, headers=self.headers, timeout=self.timeout)
resp.raise_for_status()
session_id = resp.headers.get("mcp-session-id")
if not session_id:
raise RuntimeError("missing mcp-session-id")
self.headers["mcp-session-id"] = session_id
notif = {"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}}
requests.post(self.url, json=notif, headers=self.headers, timeout=self.timeout)
@staticmethod
def _parse_sse(text: str) -> dict:
m = re.search(r"data:\s*(\{.*\})", text, re.S)
if not m:
return {}
return json.loads(m.group(1))
@staticmethod
def _extract_text(result_obj: dict) -> str:
return "\n".join(
item.get("text", "")
for item in result_obj.get("result", {}).get("content", [])
if item.get("type") == "text"
)
def call_tool(self, name: str, arguments: dict) -> dict:
body = {
"jsonrpc": "2.0",
"id": self._rpc_id,
"method": "tools/call",
"params": {"name": name, "arguments": arguments},
}
self._rpc_id += 1
resp = requests.post(self.url, json=body, headers=self.headers, timeout=self.timeout)
resp.raise_for_status()
payload = self._parse_sse(resp.text)
text = self._extract_text(payload)
if not text:
return {}
return json.loads(text)
def solve(url: str, passphrase: str, max_retries: int = 3) -> str:
client = MCPClient(url)
start = client.call_tool(
"start_session",
{"user_id": f"u{uuid.uuid4().hex[:8]}", "role": "user"},
)
session_id = start["session_id"]
for doc_id in PUBLIC_CLUES:
client.call_tool("get_document", {"session_id": session_id, "doc_id": doc_id})
for _ in range(max_retries):
denied = client.call_tool(
"get_document",
{"session_id": session_id, "doc_id": REVIEW_DOC_ID},
)
audit_log_id = denied.get("audit_log_id")
if not audit_log_id:
continue
seal = hashlib.sha256((audit_log_id + passphrase).encode("utf-8")).hexdigest()[:8]
review_notes = (
f"ctf_request={passphrase}\n"
f"audit_log_id={audit_log_id}\n"
f"seal={seal}"
)
review = client.call_tool(
"review_document",
{
"session_id": session_id,
"doc_id": REVIEW_DOC_ID,
"review_notes": review_notes,
},
)
tool_access_token = review.get("tool_access_token")
if not tool_access_token:
continue
client.call_tool(
"update_tool_chain",
{
"session_id": session_id,
"chain_config": ["get_audit_log"],
"audit_log_id": audit_log_id,
"access_token": tool_access_token,
},
)
audit = client.call_tool(
"get_audit_log",
{
"session_id": session_id,
"log_id": audit_log_id,
"access_token": tool_access_token,
},
)
final_token = audit.get("_audit_token")
if not final_token:
continue
secret = client.call_tool(
"get_document",
{
"session_id": session_id,
"doc_id": SECRET_DOC_ID,
"access_token": final_token,
},
)
if "content" in secret:
return secret["content"]
raise RuntimeError("failed to get final secret document after retries")
def main() -> None:
parser = argparse.ArgumentParser(description="Solve remote52pojie MCP CTF")
parser.add_argument("--url", default=DEFAULT_URL, help="MCP endpoint URL")
parser.add_argument(
"--passphrase",
default="玄霄密令",
help="CTF passphrase from public clues",
)
args = parser.parse_args()
flag = solve(args.url, args.passphrase)
print(flag)
if __name__ == "__main__":
main()
Flag: flag{new_year_2026_keep_warm}