吾爱破解 - 52pojie.cn

 找回密码
 注册[Register]

QQ登录

只需一步,快速开始

查看: 736|回复: 12
上一主题 下一主题
收起左侧

[CTF] 吾爱破解2026红包题WriteUP(除高级)

[复制链接]
跳转到指定楼层
楼主
cattie 发表于 2026-3-4 07:28 回帖奖励
本帖最后由 cattie 于 2026-3-1 15:43 编辑

目录

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()

这个函数的意思是:

  1. assets/flag.dat中读取加密内容;
  2. 检查当前游戏是否在hard难度下,是的话执行解密,否则返回普通消息 "You WIN!"
  3. 使用密钥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,也知道这个算法,就逆推一下:

  1. 已知 PNG 开头固定是 89 50 4E 47 0D 0A 1A 0A。
  2. 加密第一块满足明文 = 密文 XOR 密钥流 XOR 链值,所以可以反推出第一块密钥流。
  3. 再把程序里的状态变换反过来,得到真实运行时密钥。
  4. 用这个密钥解完整文件,CRC32校验通过后去掉 padding,得到真正的 PNG。
  5. 最后解析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处,跳转过去:

可以看到定义的函数:

startSessionBytescheckRhythmupdateExpdecryptFramesverifyAndDecryptsetDebugBypass

先来看decryptFrames

用GPT5.3-Codex分析一下:

  1. decryptFrames 需要“正确exp”才能产出有效解密结果
  2. decryptFrames 第 4 参数 x3 就是传入的 exp0x25444: mov x22, x3
  3. 最终要通过内部环境校验 sub_2ddf8 0x25748,否则直接失败返回空。

再看verifyAndDecrypt

这里同样GPT5.3-Codex分析一下:

  1. 校验包头(魔数 0x314D4A48、长度、字段非 0)
  2. 根据 type 解密 payload(type=1 或 type=2),因为type只与bin有关,包头验证成功,走的就是type2的路径。3. 检验前置条件是否满足(exp>=999,或者连续12次PERFECT且exp<999)4. 用传入字符串 s 生成 1-bit 点阵图
  3. 和解密后 payload 前 w*h/8 字节做 memcmp
  4. memcmp==0(相等)才返回整包,否则 null

同时需要满足以下约束:

  1. flag宽度参数 < 5 直接失败 0x2e64c~0x2e650
  2. strlen(flag)==0 失败0x2e654~0x2e65c
  3. 参与编码的每个字符必须存在于内置字模表0x2e914~0x2e92c
  4. 提取字模后会检查是否可放进当前图尺寸,不可放则失败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/exesys...)、__system_property_getdl_iterate_phdrdlopensyscall 等,然后采用一个risk accumulator累计(a50),每次调用这个检测函数就会将当次的risk score加到里面,然后risk score>=4时risk位(a54)即变为1,影响后续执行流程。

这个函数在其他函数中都进行了调用,包括:

startSessionBytes(开始游戏)里0x24b80: bl #0x25ef8

decryptFrames0x25564: bl #0x25ef8

verifyAndDecrypt0x2593c: 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,从而导致在decryptFramesverifyAndDecrypt时risk标志位变成1,最终的flag将会是乱码。

可以看到正常情况下每次score是2,exp是1,然后检测的a50=0a54=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.htmlassets文件夹,里面有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帮猫猫总结出了一整套流程,像这样:

  1. 初始化 MCP 会话
    • initialize获取mcp-session-id
    • 发送notifications/initialized
  2. 建立业务会话
    • start_session(role="user")
    • 拿到session_id
  3. 读取公开线索文档(同一会话内)
    • 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)
    • 线索得到口令:玄霄密令
  4. 触发拒绝并拿审计编号
    • get_document(confidential:/audit/logs)
    • 返回access_denied+最新audit_log_id
  5. 组复核参数
    • seal = sha256(audit_log_id + passphrase)[:8]
    • review_notes固定三行:ctf_request=<口令本体>, audit_log_id=<完整编号>, seal=<8位hex>
  6. 发起复核拿第一段凭据
    • review_document(confidential:/audit/logs, review_notes=...)
    • 返回tool_access_token
  7. 配置最小链路
    • update_tool_chain(chain_config=["get_audit_log"], audit_log_id=..., access_token=tool_access_token)
    • 只配get_audit_log,避免链路失效
  8. 获取后续凭据
    • get_audit_log(log_id=audit_log_id, access_token=tool_access_token)
    • 返回_audit_token
  9. 读取最终密卷
    • get_document(secret:/board/resolutions, access_token=_audit_token)
    • contentflag

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}

免费评分

参与人数 7吾爱币 +5 热心值 +6 收起 理由
suifengba + 1 谢谢@Thanks!
aniuxyz + 1 我很赞同!
Coxxs + 1 + 1 思路很强,感谢!
laotzudao0 + 1 + 1 我很赞同!
linhzye + 1 + 1 膜拜大佬。好好学习下
nanaqilin + 1 热心回复!
lsb2pojie + 1 + 1 热心回复!

查看全部评分

发帖前要善用论坛搜索功能,那里可能会有你要找的答案或者已经有人发布过相同内容了,请勿重复发帖。

推荐
Coxxs 发表于 2026-3-4 10:59
lsb2pojie 发表于 2026-3-4 09:07
感谢分享,web中级题我让AI分析了半天,把注入也去掉了还是没整出来,一直让我帮他听语音

这题其实做了很多误导 AI 的设计,毕竟验证码防的就是 AI。任务说明里的提示用 svg 写,就导致 AI 看不到提示。提示词注入看似无害,也会分散大部分 LLM 的注意力。

免费评分

参与人数 1吾爱币 +1 热心值 +1 收起 理由
cattie + 1 + 1 谢谢@Thanks!

查看全部评分

推荐
 楼主| cattie 发表于 2026-3-4 09:14 |楼主
lsb2pojie 发表于 2026-3-4 09:07
感谢分享,web中级题我让AI分析了半天,把注入也去掉了还是没整出来,一直让我帮他听语音

看到页面上那个"大小写敏感"就不应该去考虑音频了,因为听肯定是听不出是大写还是小写的,还是要去看wasm。
推荐
lsb2pojie 发表于 2026-3-4 11:15
Coxxs 发表于 2026-3-4 10:59
这题其实做了很多误导 AI 的设计,毕竟验证码防的就是 AI。任务说明里的提示用 svg 写,就导致 AI 看不到 ...

哈哈,我有把svg的内容复制给AI,不过应该是之前的上下文导致一直往错误方向分析了
推荐
zxc0536 发表于 2026-3-4 07:46
感谢分享,有空好好研究一下
3#
cksincerely 发表于 2026-3-4 08:14
你字多,我相信你
4#
lsb2pojie 发表于 2026-3-4 09:07
感谢分享,web中级题我让AI分析了半天,把注入也去掉了还是没整出来,一直让我帮他听语音

点评

这题其实做了很多误导 AI 的设计,毕竟验证码防的就是 AI。任务说明里的提示用 svg 写,就导致 AI 看不到提示。提示词注入也会分散大部分 LLM 的注意力。  详情 回复 发表于 2026-3-4 10:59
看到页面上那个"大小写敏感"就不应该去考虑音频了,因为听肯定是听不出是大写还是小写的,还是要去看wasm。  详情 回复 发表于 2026-3-4 09:14
6#
laotzudao0 发表于 2026-3-4 09:41
学到了,有时间复盘看看
9#
linix 发表于 2026-3-4 11:41
lsb2pojie 发表于 2026-3-4 09:07
感谢分享,web中级题我让AI分析了半天,把注入也去掉了还是没整出来,一直让我帮他听语音

我跟到那个wasm.$gen就头晕了,以前没搞过,然后扔给deepseek,它只是说随机数,sha256什么的,然后叫它还原算法,它弄不出来,就放弃了。

但这个如果是个网站的算法,不可能每个参数这样去获取加密的结果啊,所以还是等大佬怎么分析wasm弄出算法
10#
Avenshy 发表于 2026-3-4 11:58
AI表示已冒烟
您需要登录后才可以回帖 登录 | 注册[Register]

本版积分规则

返回列表

RSS订阅|小黑屋|处罚记录|联系我们|吾爱破解 - 52pojie.cn ( 京ICP备16042023号 | 京公网安备 11010502030087号 )

GMT+8, 2026-3-4 16:23

Powered by Discuz!

Copyright © 2001-2020, Tencent Cloud.

快速回复 返回顶部 返回列表