文章总结: 本文详细解析了CTF题目乌托邦·王的实验21。解题过程包括分析WebSocket通信获取图数据,识别出这是一个求有向图字典序最小欧拉回路的问题。作者踩坑发现边是有向的,并指出flag不在常规位置。最终采用迭代版Hierholzer算法避免了栈溢出,成功在时限内计算出路径并提交哈希拿到flag,文中附带了完整EXP脚本。 综合评分: 89 文章分类: CTF,WEB安全,实战经验
欣欣的缠绕毛线球 2 — 乌托邦·王的实验21wp
原创
wenject wenject
船山信安
2026年2月24日 14:16 广东
欣欣的缠绕毛线球 2 — 乌托邦·王的实验21
题目概况
打开靶机是一个 Web 页面,标题”乌托邦·王的实验21:欣欣的缠绕毛线球”,页面上有聊天区、图数据展示区和一个输入框。看起来像是个交互式解谜题。
image-20260224012244228
信息收集
先看页面源码,发现引用了 xterm.js(浏览器终端模拟器)和一个 /static/script.js。这就有意思了——xterm 意味着解完题可能会给 shell。
拉下来 script.js 一看,核心逻辑很清晰:
1.页面通过 WebSocket 连接 /socket 端点
2.服务端先发一堆 story 类型的剧情对话(两个角色:Utopia Wang 和 XinXin)
3.然后通过 task_chunk 消息分批推送图的边数据,存到 window.graphEdges 里,格式是 flat array [u1, v1, u2, v2, ...]
4.推送完毕后发 task_end,附带 V(顶点数)和 E(边数)
5.此时输入框解锁,提示”Submit SHA256 of path”
6.用户通过 WebSocket 发送答案(SHA256 hash)
7.答对了服务端发 switch_shell,页面切换到 xterm 终端模式
所以整个流程就是:连 WebSocket → 收图数据 → 解题 → 提交 hash → 拿 shell → 找 flag。
题目分析
回头看靶场描述里的关键信息:
“这是一组交织在一起的量子纠缠线。你要用一根连续的线,把它们全部走一遍,不准重复,也不准遗漏。”
所有边走一遍,不重复不遗漏——这就是经典的欧拉路径问题。
“如果有多种走法,我只要字典序最小的那一种!记住,必须从节点 0 开始出发!”
字典序最小 + 起点固定为 0。
“一万个节点,三万条边,别妄想用简单的递归,栈溢出了我可不管你。”
10000 节点 30000 边,明确告诉你不能用递归,得用迭代。
“把你算出的路径(用逗号分隔,如 0,5,2…)做一次 SHA256 哈希(纯小写)再发给我。只有 90 秒的时间!”
输出格式:逗号分隔的节点序列,SHA256 小写 hex,90 秒时限。
踩坑记录
坑1:有向 vs 无向
题目描述没有明确说边是有向还是无向。一开始按无向图建图跑 Hierholzer,路径长度看着对但提交 hash 后服务端返回 “INCORRECT HASH OR LEXICOGRAPHICAL ORDER FAILED”。
仔细检查边数据,发现 (u, v) 和 (v, u) 并不成对出现。统计了一下每个节点的入度和出度,发现按有向图算的话 in-degree 全等于 out-degree——这是有向欧拉回路的充要条件。
结论:边是有向的,每条边 (u, v) 表示从 u 到 v 的单向边。
坑2:flag 不在常规位置
拿到 shell 后习惯性 cat /flag,拿到的是 flag{0000000000},这是 GZCTF 平台的占位符。环境变量 $GZCTF_FLAG 也是同样的占位符。
真正的 flag 藏在 /tmp/flag.txt 里。所以拿到 shell 后需要多翻翻,不能只看常规位置。
算法:迭代版 Hierholzer 求字典序最小欧拉回路
Hierholzer 算法是求欧拉路径/回路的经典算法。要保证字典序最小,核心思路是:
1.对每个节点的出边(邻接表)按目标节点编号升序排列
2.用栈模拟 DFS,每次从栈顶节点出发,贪心选编号最小的未走过的出边
3.如果栈顶节点没有可走的边了,就弹出并加入结果路径
4.最后把结果路径反转
为什么反转后就是字典序最小?直觉上理解:Hierholzer 是”后序”收集节点的。当一个节点的所有出边都被走完后才会被加入路径,而我们总是优先走最小的邻居。反转后,最先被探索的(最小的)路径段会排在前面。
对于 10000 节点 30000 边的规模,这个算法跑起来只要 0.04~0.06 秒,完全不是问题。
关键实现细节:
●用 adj_ptr 字典记录每个节点当前扫描到邻接表的哪个位置,避免重复扫描已用过的边
●用 used 数组标记每条边是否已走过(用边的索引作为 ID)
●全程用栈,不用递归,避免 Python 默认 1000 层递归限制导致的栈溢出
完整复现步骤
1.pip install websocket-client 安装依赖
2.运行 EXP 脚本
3.脚本自动完成:连接 WebSocket → 接收图数据 → 求解欧拉回路 → SHA256 → 提交
4.提交正确后自动进入 shell,执行 cat /tmp/flag.txt 拿到 flag
EXP
$ terminal
import json
import hashlib
import time
import websocket
import sys
from collections import defaultdict
sys.setrecursionlimit(100000)
HOST = "" # 靶机地址
PORT = "" # 靶机端口
TARGET = f"ws://{HOST}:{PORT}/socket"
edges_flat = []
V = 0
E = 0
is_shell = False
shell_output = []
def find_euler_path_directed(num_vertices, edge_list):
"""
迭代版 Hierholzer 算法,求有向图从节点 0 出发的字典序最小欧拉回路。
"""
adj = defaultdict(list)
for i, (u, v) in enumerate(edge_list):
adj[u].append((v, i))
# 排序保证字典序
for node in adj:
adj[node].sort()
used = [False] * len(edge_list)
adj_ptr = {node: 0 for node in adj}
stack = [0]
path = []
while stack:
v = stack[-1]
found = False
while adj_ptr.get(v, 0) < len(adj.get(v, [])):
neighbor, eid = adj[v][adj_ptr[v]]
adj_ptr[v] += 1
if not used[eid]:
used[eid] = True
stack.append(neighbor)
found = True
break
if not found:
path.append(stack.pop())
path.reverse()
return path
def on_message(ws_conn, message):
global edges_flat, V, E, is_shell, shell_output
if is_shell:
shell_output.append(message)
sys.stdout.write(message)
sys.stdout.flush()
return
try:
msg = json.loads(message)
except:
print(f"[RAW] {message[:300]}")
return
msg_type = msg.get('type', '')
if msg_type == 'story':
print(f"[{msg.get('sender', '')}] {msg.get('content', '')[:150]}")
elif msg_type == 'task_chunk':
chunk = msg.get('meta', {}).get('chunk', [])
edges_flat.extend(chunk)
elif msg_type == 'task_end':
meta = msg.get('meta', {})
V = meta.get('V', 0)
E = meta.get('E', 0)
print(f"[TASK_END] V={V}, E={E}, edges={len(edges_flat) // 2}")
edge_list = []
for i in range(0, len(edges_flat), 2):
edge_list.append((edges_flat[i], edges_flat[i + 1]))
t0 = time.time()
path = find_euler_path_directed(V, edge_list)
elapsed = time.time() - t0
print(f"[SOLVE] len={len(path)}, expected={E + 1}, time={elapsed:.2f}s")
path_str = ",".join(str(x) for x in path)
sha = hashlib.sha256(path_str.encode()).hexdigest()
print(f"[HASH] {sha}")
ws_conn.send(sha)
elif msg_type == 'gameover':
print(f"[GAMEOVER] {msg.get('content', '')}")
elif msg_type == 'switch_shell':
print("[SHELL] Got shell!")
is_shell = True
time.sleep(1)
# 多个位置找 flag
for cmd in [
"cat /tmp/flag.txt",
"cat /tmp/flag",
"cat /flag",
"echo $GZCTF_FLAG",
"find / -maxdepth 3 -name '*flag*' 2>/dev/null",
]:
ws_conn.send(cmd + "\n")
time.sleep(2)
else:
print(f"[{msg_type}] {json.dumps(msg)[:200]}")
def on_error(ws_conn, error):
print(f"[ERROR] {error}")
def on_close(ws_conn, code, msg):
print(f"[CLOSED] {code} {msg}")
if shell_output:
print("\n=== SHELL OUTPUT ===")
print("".join(shell_output))
def on_open(ws_conn):
print("[CONNECTED]")
if __name__ == "__main__":
ws_app = websocket.WebSocketApp(
TARGET,
on_open=on_open,
on_message=on_message,
on_error=on_error,
on_close=on_close
)
ws_app.run_forever(ping_interval=30, ping_timeout=10)
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:船山信安 wenject wenject《欣欣的缠绕毛线球 2 — 乌托邦·王的实验21wp》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。








评论