MISC-卜肆残局

ISCC2026 WriteUp 提交模板

MISC-卜肆残局

解题思路

1.解压

image.png
城南旧庙外有一处古怪卦摊。摊主走失前,只留下四样旧物:

  1. 天象
  2. 人言
  3. 地盘
  4. 时令

题面把核心对象分成“天、人、地、时”,我们可以先找一下:
先看下天象-铜钱卦.svg:

image.png

Ctrl+u看一下:

|<metadata>|
|旧摊钞记:|
|铜钱十六,外八内八。|
|天盘依文王位,记文仍从伏羲次。|
|离坤兑乾坎艮震巽。|
|乾兑离震巽坎艮坤。|
|坎坤震巽中乾兑艮离。|
|492357816。|
|qianli-kunzi-2024。|
|cWlhbl9rdW5fa2FuX2xp|
|边角又记:外内同宫,不同周。|
|</metadata>|

“图位依文王,记文仍从伏羲。外八内八,先后同宫。”,因此下一步应当忽略可见卦名,转而读取每个 coin 的三爻图形:整线记 1,断线记 0,再把同宫位的外三爻和内三爻拼成 6 bit。按伏羲次 乾兑离震巽坎艮坤 读取后得到 cVcyTzB6,解码得到 qW2O0z。

2.看人言-签筒残册.txt

image.png

明确写了“有人抄句读,也有人把边角多出的两句一并抄进来”,而每门尾部的 base64 旁批解出来正是“去二五 / 去三八 / ...”。因此下一步不是取首字或尾字,而是先按旁批删掉两句,再把剩余 6 句的句末 ﹒/。 映射为 1/0。按旧门次 休、生、伤、杜、景、死、惊、开 排列后得到 NWJuSW1H,解码得到 5bnImG。

3.地理-洛书盘.bmp

image.png
image.png

旧档提示是“只信盘,不信图头”“拆到第二层纸”“匣里还分契与位”,而夹页札记又补充了“图位依文王,记文仍从伏羲。外八内八,先后同宫。dont_stop_at_the_first_decode”。因此下一步需要先拆位平面,再读双层洛书盘,而不是直接肉眼数格。恢复出的中间串是 NHlGdVVj,解码得到 4yFuUc。

4.接着看时令

image.png

提示是“四立二分二至”“看漏刻者多,看节候者少”,因此下一步应当只取 立春/春分/立夏/夏至/立秋/秋分/立冬/冬至,再按 序 列重排为 1,4,7,10,13,16,19,22。每行的 六断 正好是 6 个 宜/忌,映射为 1/0 后得到 NFkwblY5,解码得到 4Y0nV9。

这样四段碎片就全部恢复出来了:

qW2O0z + 5bnImG + 4yFuUc + 4Y0nV9

因此可以直接拼出:

mid = qW2O0z5bnImG4yFuUc4Y0nV9

5.使用mid

已知四段碎片已经能拼成 mid,所以下一步最自然的是先测试它是不是第一层压缩包的密码来源。对 mid 做 MD5 得到:

228a53da12c42a65235ca2ebefa826c1

这正好可以解开 窥得天机.zip,说明 mid 的用途已经确定。解包后得到 64 个文本文件,同时 ZIP 自带一段 comment:

image.png

image.png

注释:"q7LxP0aR9Yv+u1kC3wFjI2sQmD4oJ6GfXr5hN8KpTtUVeBylHcSgZdWiEOAbMn/z"

观察到它长度正好是 64,而且字符全集恰好落在标准 base64 表里,所以这里最合理的下一步是把 comment 当成索引表:第 i 个 comment 字符先映射到 base64 下标 0..63,再去第 i 个文本文件里取对应下标字符。这样拼出的结果是:

UQC8TBH7FkJn7bmz7ikjttXfwjvsp99CHBWmH7+M9HxppE7W7a68mo/Ua4cfQUq2

它正好能解开 终局.7z,因此第二层密码链也闭合了。

6.终局

image.png

解开 终局.7z 以后,得到的 终局.txt 只有一段 base64 文本:

7jgRTVRxS/HSsI673SWW2yglLJbYZq295x7Eu6qCEvymWnnhkwyDg/8CduA2gQpF

这一步应该是个单段密文,因此下一步应当尝试对称解密。此时手里只有两段确定可控的字节串:

  • mid = qW2O0z5bnImG4yFuUc4Y0nV9
  • seven_pwd = UQC8TBH7FkJn7bmz...

因为 AES-CBC 需要 16 字节 key 和 16 字节 iv,所以最自然的首个验证就是分别截取这两段串的前 16 字节作为 key/iv。实际测试后能成功解密并 unpad 出可打印字符串,得到最终 flag:

ISCC{cd1e6a3c-8348-4d9e-a26c-59c269004f84}

脚本实际输出:

ISCC{cd1e6a3c-8348-4d9e-a26c-59c269004f84}

image.png

Exp

from __future__ import annotations

import base64
import csv
import hashlib
import io
import re
import shutil
import subprocess
import zipfile
from pathlib import Path

from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad

ROOT = Path(r"D:\Dowload\workspace\misc-busicanju")
UNZIP_ROOT = ROOT / "misc2_unzip"
CHALLENGE_DIR = next(p for p in UNZIP_ROOT.iterdir() if p.is_dir())
ZIP_PATH = UNZIP_ROOT / "窥得天机.zip"
SEVEN_Z_PATH = UNZIP_ROOT / "终局.7z"
WORK_DIR = ROOT / "misc2_repro_out"
BASE64_TABLE = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
EARTH_FRAGMENT = "4yFuUc"


def b64decode_text(s: str) -> str:
    return base64.b64decode(s).decode("utf-8")


def derive_heaven() -> str:
    svg_path = next(p for p in CHALLENGE_DIR.iterdir() if p.suffix == ".svg")
    text = svg_path.read_text("utf-8")
    blocks = re.findall(r'<g class="coin [^"]+" id="(outer|inner)-([^"]+)">(.*?)</g>', text, re.S)
    patterns = {}

    for ring, seat, body in blocks:
        rows = {}
        for x1, y1, x2, _y2 in re.findall(
            r'<line x1="([0-9.]+)" y1="([0-9.]+)" x2="([0-9.]+)" y2="([0-9.]+)"',
            body,
        ):
            y = round(float(y1), 1)
            broken = abs(float(x2) - float(x1)) < 20
            rows.setdefault(y, []).append(broken)
        bits = "".join("0" if any(rows[y]) else "1" for y in sorted(rows))
        patterns[(ring, seat)] = bits

    read_order = "乾兑离震巽坎艮坤"
    encoded = "".join(
        BASE64_TABLE[int(patterns[("outer", seat)] + patterns[("inner", seat)], 2)]
        for seat in read_order
    )
    return b64decode_text(encoded)


def derive_human() -> str:
    txt_path = next(p for p in CHALLENGE_DIR.iterdir() if p.suffix == ".txt" and "人言" in p.name)
    lines = txt_path.read_text("utf-8").splitlines()
    digit_map = {"一": 1, "二": 2, "三": 3, "四": 4, "五": 5, "六": 6, "七": 7, "八": 8, "九": 9}

    sections = []
    current = None
    for line in lines:
        if line.startswith("【") and line.endswith("】"):
            current = {"title": line[1:-1], "poems": [], "note": ""}
            sections.append(current)
            continue
        if current is None:
            continue
        m = re.search(r"([A-Za-z0-9+/=]{8,})$", line)
        if m:
            current["note"] = b64decode_text(m.group(1))
        elif line and not line.startswith("旁注") and not re.match(r"^[123]\.", line):
            current["poems"].append(line)

    kept_bits = {}
    for section in sections:
        remove = {digit_map[ch] for ch in section["note"] if ch in digit_map}
        poems = [x for i, x in enumerate(section["poems"], start=1) if i not in remove]
        kept_bits[section["title"]] = "".join("1" if x.endswith("﹒") else "0" for x in poems)

    read_order = ["休门", "生门", "伤门", "杜门", "景门", "死门", "惊门", "开门"]
    encoded = "".join(BASE64_TABLE[int(kept_bits[name], 2)] for name in read_order)
    return b64decode_text(encoded)


def derive_time() -> str:
    csv_path = next(p for p in CHALLENGE_DIR.iterdir() if p.suffix == ".csv")
    text = csv_path.read_text("utf-16").replace("\r", "")
    rows = list(csv.reader(io.StringIO("\n".join(text.splitlines()[6:]))))

    selected = {"1", "4", "7", "10", "13", "16", "19", "22"}
    encoded_parts = {}
    for row in rows[1:]:
        if row[0] not in selected:
            continue
        parts = [x.strip() for x in row[4].split("/")]
        bits = "".join("1" if part and part[0] == "宜" else "0" for part in parts)
        encoded_parts[row[0]] = BASE64_TABLE[int(bits, 2)]

    encoded = "".join(encoded_parts[str(i)] for i in [1, 4, 7, 10, 13, 16, 19, 22])
    return b64decode_text(encoded)


def unzip_stage_one(zip_password: str) -> Path:
    out_dir = WORK_DIR / "stage1_zip"
    if out_dir.exists():
        shutil.rmtree(out_dir)
    out_dir.mkdir(parents=True, exist_ok=True)
    with zipfile.ZipFile(ZIP_PATH) as zf:
        zf.extractall(out_dir, pwd=zip_password.encode("utf-8"))
    return out_dir


def derive_second_password(zip_out_dir: Path) -> str:
    with zipfile.ZipFile(ZIP_PATH) as zf:
        comment = zf.comment.decode("utf-8")

    txt_files = sorted(zip_out_dir.rglob("*.txt"), key=lambda p: int(re.search(r"(\d+)", p.stem).group(1)))
    result = []
    for i, ch in enumerate(comment):
        idx = BASE64_TABLE.index(ch)
        result.append(txt_files[i].read_text("utf-8", errors="ignore")[idx])
    return "".join(result)


def unzip_stage_two(password: str) -> str:
    out_dir = WORK_DIR / "stage2_7z"
    if out_dir.exists():
        shutil.rmtree(out_dir)
    out_dir.mkdir(parents=True, exist_ok=True)
    subprocess.run(["7z", "x", str(SEVEN_Z_PATH), f"-p{password}", "-y", f"-o{out_dir}"], check=True)
    return next(out_dir.rglob("*.txt")).read_text("utf-8").strip()


def main() -> None:
    WORK_DIR.mkdir(exist_ok=True)
    heaven = derive_heaven()
    human = derive_human()
    earth = EARTH_FRAGMENT
    time = derive_time()

    mid = heaven + human + earth + time
    zip_pwd = hashlib.md5(mid.encode()).hexdigest()
    zip_out_dir = unzip_stage_one(zip_pwd)
    seven_pwd = derive_second_password(zip_out_dir)
    cipher_b64 = unzip_stage_two(seven_pwd)

    key = mid.encode()[:16]
    iv = seven_pwd.encode()[:16]
    flag = unpad(AES.new(key, AES.MODE_CBC, iv).decrypt(base64.b64decode(cipher_b64)), 16).decode()
    print(flag)


if __name__ == "__main__":
    main()
posted @ 2026-05-19 16:29  MillionMind  阅读(35)  评论(0)    收藏  举报