MISC-先声夺人

ISCC2026 WriteUp 提交模板

MISC-先声夺人

解题思路

1.初步侦查

image.png

解压附件只有一个 `challenge.txt。
对文件做一次字符统计,只保留常见的零宽字符:

from pathlib import Path
from collections import Counter

text = Path("showtime_01_extracted/challenge.txt").read_text(encoding="utf-8")
zw = [ch for ch in text if ch in "\u200b\u200c\u200d\ufeff"]

print(len(text))
print(len(zw))
print({hex(ord(k)): v for k, v in Counter(zw).items()})

得到的关键结论是:

  • 文本总长度:2878
  • 零宽字符总数:2592
  • 一共出现 4 种零宽字符

分别是:

  • U+200B Zero Width Space
  • U+200C Zero Width Non-Joiner
  • U+200D Zero Width Joiner
  • U+FEFF Zero Width No-Break Space / BOM

这里有两个信号:

  1. 恰好是 4 种符号
    这很像四进制编码,每个符号可以表示 0/1/2/3。

  2. 总数 2592 可以被 4 整除
    2592 / 4 = 648,也就是说,如果每 4 个零宽字符拼 1 个字节,那么正好能还原出 648 字节数据。

2.还原载荷
把四种零宽字符按顺序映射成:

  • U+200B -> 0
  • U+200C -> 1
  • U+200D -> 2
  • U+FEFF -> 3

然后每 4 个符号拼成一个字节:

byte = (a << 6) | (b << 4) | (c << 2) | d

其中 a/b/c/d 是四进制位。

按这个规则恢复后,会拿到 648 字节数据。但直接解压会失败,因为前面还有 4 个无意义字节。跳过前 4 字节后,剩余部分可以成功做 zlib 解压。

也就是说,真实结构是:

[4 字节前缀][zlib 压缩数据]

核心恢复代码如下:

import zlib
from pathlib import Path

ZW = ["\u200b", "\u200c", "\u200d", "\ufeff"]
table = {ch: i for i, ch in enumerate(ZW)}

text = Path("showtime_01_extracted/challenge.txt").read_text(encoding="utf-8")
seq = [ch for ch in text if ch in table]
vals = [table[ch] for ch in seq]

raw = bytearray()
for i in range(0, len(vals) - 3, 4):
    a, b, c, d = vals[i:i + 4]
    raw.append((a << 6) | (b << 4) | (c << 2) | d)

payload = zlib.decompress(bytes(raw[4:]))
print(payload.decode())

zlib 解出来后不是 flag,而是一份结构化的 JSON 配置:

{
  "difficulty_bits": 26,
  "nonce": "inst_000_42ba6c3fb9863dfa",
  "key_derivation": {
    "algorithm": "PBKDF2-HMAC-SHA256",
    "iterations": 10000,
    "dklen": 32
  },
  "aes": {
    "algorithm": "AES-256-CBC",
    "padding": "PKCS7"
  },
  "encrypted_flag": {
    "iv": "5dc0b444e4ff507d4af8dcda8e64afa9",
    "ciphertext": "aa228292d48f64709888c44ce7c331b04e486e566f573fef354370c5f166d8ae68e6556e9607e1da07bc79203c092372"
  }
}
  1. 先解一个 PoW
  2. 用求出的 s 作为口令
  3. 做 PBKDF2 派生出 AES key
  4. 再用给定的 iv + ciphertext 做 AES-CBC 解密
  5. 去掉 PKCS7 padding,得到 flag

3.POW部分
JSON 中的要求是:

从 s = 0 开始递增,找到最小的非负整数 s,使得
SHA256(nonce || str(s)) 至少具有 difficulty_bits 个前导 0 bit

difficulty_bits = 26`,所以判断规则是:

  • 前 3 个字节必须都是 0x00,即先满足 24 个 0 bit
  • 第 4 个字节的最高 2 bit 也必须为 0

可以写成:

def digest_matches(digest, difficulty_bits):
    full_bytes, remain = divmod(difficulty_bits, 8)
    if digest[:full_bytes] != b"\x00" * full_bytes:
        return False
    if remain == 0:
        return True
    return (digest[full_bytes] >> (8 - remain)) == 0

用多进程并行枚举,最终得到:

s = 39235849

PoW 拿到 s=39235849 后,后续就比较标准了。

密钥派生规则来自 JSON:

  • 算法:PBKDF2-HMAC-SHA256
  • 密码:str(s) 的 UTF-8
  • salt:nonce 的 UTF-8
  • 迭代次数:10000
  • 输出长度:32 字节

然后:

  • 用派生出的 32 字节作为 AES-256 密钥
  • 模式是 AES-CBC
  • IV 直接用 JSON 给出的十六进制串
  • 解密后去掉 PKCS7 填充

核心代码如下:

import hashlib
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes

nonce = "inst_000_42ba6c3fb9863dfa"
s = 39235849
iv = bytes.fromhex("5dc0b444e4ff507d4af8dcda8e64afa9")
ct = bytes.fromhex("aa228292d48f64709888c44ce7c331b04e486e566f573fef354370c5f166d8ae68e6556e9607e1da07bc79203c092372")

key = hashlib.pbkdf2_hmac(
    "sha256",
    str(s).encode(),
    nonce.encode(),
    10000,
    dklen=32,
)

cipher = Cipher(algorithms.AES(key), modes.CBC(iv))
decryptor = cipher.decryptor()
pt = decryptor.update(ct) + decryptor.finalize()

pad_len = pt[-1]
flag = pt[:-pad_len].decode("utf-8")
print(flag)

image.png

ISCC{Eb0TOol7GZ0YEHI38VjhYjtofGDIRC}

Exp

import argparse
import hashlib
import itertools
import json
import multiprocessing as mp
import time
import zipfile
import zlib
from pathlib import Path

from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes


ZERO_WIDTH_CHARS = ("\u200b", "\u200c", "\u200d", "\ufeff")
REQUIRED_KEYS = {"aes", "difficulty_bits", "encrypted_flag", "key_derivation", "nonce", "pow", "s_format"}
SCAN_SLICE = 300_000


def load_text(path: Path) -> tuple[str, str]:
    if zipfile.is_zipfile(path):
        with zipfile.ZipFile(path) as archive:
            names = [name for name in archive.namelist() if not name.endswith("/")]
            if not names:
                raise ValueError("zip archive is empty")
            return archive.read(names[0]).decode("utf-8"), names[0]
    return path.read_text(encoding="utf-8"), path.name


def collect_zero_width(text: str) -> list[str]:
    return [ch for ch in text if ch in ZERO_WIDTH_CHARS]


def pack_symbols(symbols: list[str], order: tuple[str, ...], offset: int, little_endian: bool) -> bytes:
    table = {ch: idx for idx, ch in enumerate(order)}
    values = [table[ch] for ch in symbols[offset:]]
    output = bytearray()
    for index in range(0, len(values) - 3, 4):
        a, b, c, d = values[index:index + 4]
        if little_endian:
            byte = a | (b << 2) | (c << 4) | (d << 6)
        else:
            byte = (a << 6) | (b << 4) | (c << 2) | d
        output.append(byte)
    return bytes(output)


def recover_payload(symbols: list[str]) -> tuple[dict, dict]:
    for order in itertools.permutations(ZERO_WIDTH_CHARS):
        for offset in range(4):
            for little_endian in (False, True):
                blob = pack_symbols(symbols, order, offset, little_endian)
                for start in range(min(32, len(blob))):
                    try:
                        plain = zlib.decompress(blob[start:])
                    except zlib.error:
                        continue
                    try:
                        payload = json.loads(plain.decode("utf-8"))
                    except (UnicodeDecodeError, json.JSONDecodeError):
                        continue
                    if REQUIRED_KEYS.issubset(payload):
                        metadata = {
                            "order": [f"U+{ord(ch):04X}" for ch in order],
                            "offset": offset,
                            "zlib_start": start,
                            "little_endian": little_endian,
                            "zero_width_count": len(symbols),
                        }
                        return payload, metadata
    raise ValueError("unable to decode the zero-width payload")


def digest_matches(digest: bytes, difficulty_bits: int) -> bool:
    full_bytes, remaining_bits = divmod(difficulty_bits, 8)
    if digest[:full_bytes] != b"\x00" * full_bytes:
        return False
    if remaining_bits == 0:
        return True
    return (digest[full_bytes] >> (8 - remaining_bits)) == 0


def scan_pow_range(args: tuple[bytes, int, int]) -> int | None:
    nonce, difficulty_bits, start = args
    end = start + SCAN_SLICE
    for candidate in range(start, end):
        digest = hashlib.sha256(nonce + str(candidate).encode()).digest()
        if digest_matches(digest, difficulty_bits):
            return candidate
    return None


def solve_pow(nonce: str, difficulty_bits: int, workers: int) -> tuple[int, float]:
    encoded_nonce = nonce.encode()
    started_at = time.time()
    base = 0

    with mp.Pool(processes=workers) as pool:
        while True:
            jobs = [(encoded_nonce, difficulty_bits, base + worker * SCAN_SLICE) for worker in range(workers)]
            answers = pool.map(scan_pow_range, jobs)
            matches = [answer for answer in answers if answer is not None]
            if matches:
                return min(matches), time.time() - started_at
            base += workers * SCAN_SLICE


def pkcs7_unpad(data: bytes) -> bytes:
    pad_len = data[-1]
    if pad_len == 0 or pad_len > 16 or data[-pad_len:] != bytes([pad_len]) * pad_len:
        raise ValueError("invalid PKCS7 padding")
    return data[:-pad_len]


def decrypt_flag(payload: dict, pow_answer: int) -> str:
    derivation = payload["key_derivation"]
    aes_key = hashlib.pbkdf2_hmac(
        "sha256",
        str(pow_answer).encode(),
        payload["nonce"].encode(),
        derivation["iterations"],
        dklen=derivation["dklen"],
    )
    iv = bytes.fromhex(payload["encrypted_flag"]["iv"])
    ciphertext = bytes.fromhex(payload["encrypted_flag"]["ciphertext"])
    cipher = Cipher(algorithms.AES(aes_key), modes.CBC(iv))
    decryptor = cipher.decryptor()
    plaintext = decryptor.update(ciphertext) + decryptor.finalize()
    return pkcs7_unpad(plaintext).decode("utf-8")


def main() -> None:
    parser = argparse.ArgumentParser(description="Solve the showtime_01 challenge")
    parser.add_argument("input", nargs="?", default="showtime_01.zip", help="Path to the zip or extracted challenge file")
    parser.add_argument("--workers", type=int, default=max(1, (mp.cpu_count() or 1) - 1), help="Worker process count for PoW search")
    args = parser.parse_args()

    input_path = Path(args.input)
    text, source_name = load_text(input_path)
    zero_width_symbols = collect_zero_width(text)
    payload, metadata = recover_payload(zero_width_symbols)
    pow_answer, elapsed = solve_pow(payload["nonce"], payload["difficulty_bits"], args.workers)
    flag = decrypt_flag(payload, pow_answer)

    print(f"source={source_name}")
    print(f"zero_width_count={metadata['zero_width_count']}")
    print(f"symbol_order={metadata['order']}")
    print(f"quartet_offset={metadata['offset']}")
    print(f"zlib_start={metadata['zlib_start']}")
    print(f"pow_difficulty={payload['difficulty_bits']}")
    print(f"s={pow_answer}")
    print(f"pow_elapsed={elapsed:.2f}s")
    print(f"flag={flag}")


if __name__ == "__main__":
    mp.freeze_support()
    main()

posted @ 2026-05-19 16:29  MillionMind  阅读(32)  评论(0)    收藏  举报