欣欣的缠绕毛线球2—乌托邦·王的实验21wp

admin 2026-03-03 06:45:06 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 本文详细解析了CTF题目乌托邦·王的实验21。解题过程包括分析WebSocket通信获取图数据,识别出这是一个求有向图字典序最小欧拉回路的问题。作者踩坑发现边是有向的,并指出flag不在常规位置。最终采用迭代版Hierholzer算法避免了栈溢出,成功在时限内计算出路径并提交哈希拿到flag,文中附带了完整EXP脚本。 综合评分: 89 文章分类: CTF,WEB安全,实战经验


cover_image

欣欣的缠绕毛线球 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
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;while&nbsp;adj_ptr.get(v,&nbsp;0) <&nbsp;len(adj.get(v, [])):
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; neighbor, eid = adj[v][adj_ptr[v]]
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; adj_ptr[v] +=&nbsp;1
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;not used[eid]:
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; used[eid] = True
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; stack.append(neighbor)
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; found = True
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; break
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;not found:
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; path.append(stack.pop())

&nbsp; &nbsp; path.reverse()
&nbsp; &nbsp;&nbsp;return&nbsp;path

def&nbsp;on_message(ws_conn, message):
&nbsp; &nbsp; global edges_flat, V, E, is_shell, shell_output

&nbsp; &nbsp;&nbsp;if&nbsp;is_shell:
&nbsp; &nbsp; &nbsp; &nbsp; shell_output.append(message)
&nbsp; &nbsp; &nbsp; &nbsp; sys.stdout.write(message)
&nbsp; &nbsp; &nbsp; &nbsp; sys.stdout.flush()
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return

&nbsp; &nbsp;&nbsp;try:
&nbsp; &nbsp; &nbsp; &nbsp; msg = json.loads(message)
&nbsp; &nbsp; except:
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;print(f"[RAW] {message[:300]}")
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return

&nbsp; &nbsp; msg_type = msg.get('type',&nbsp;'')

&nbsp; &nbsp;&nbsp;if&nbsp;msg_type ==&nbsp;'story':
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;print(f"[{msg.get('sender',&nbsp;'')}] {msg.get('content',&nbsp;'')[:150]}")

&nbsp; &nbsp; elif msg_type ==&nbsp;'task_chunk':
&nbsp; &nbsp; &nbsp; &nbsp; chunk = msg.get('meta', {}).get('chunk', [])
&nbsp; &nbsp; &nbsp; &nbsp; edges_flat.extend(chunk)

&nbsp; &nbsp; elif msg_type ==&nbsp;'task_end':
&nbsp; &nbsp; &nbsp; &nbsp; meta = msg.get('meta', {})
&nbsp; &nbsp; &nbsp; &nbsp; V = meta.get('V',&nbsp;0)
&nbsp; &nbsp; &nbsp; &nbsp; E = meta.get('E',&nbsp;0)
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;print(f"[TASK_END] V={V}, E={E}, edges={len(edges_flat)&nbsp;// 2}")

&nbsp; &nbsp; &nbsp; &nbsp; edge_list = []
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;for&nbsp;i in&nbsp;range(0,&nbsp;len(edges_flat),&nbsp;2):
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; edge_list.append((edges_flat[i], edges_flat[i +&nbsp;1]))

&nbsp; &nbsp; &nbsp; &nbsp; t0 = time.time()
&nbsp; &nbsp; &nbsp; &nbsp; path =&nbsp;find_euler_path_directed(V, edge_list)
&nbsp; &nbsp; &nbsp; &nbsp; elapsed = time.time() - t0
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;print(f"[SOLVE] len={len(path)}, expected={E + 1}, time={elapsed:.2f}s")

&nbsp; &nbsp; &nbsp; &nbsp; path_str =&nbsp;",".join(str(x)&nbsp;for&nbsp;x in path)
&nbsp; &nbsp; &nbsp; &nbsp; sha = hashlib.sha256(path_str.encode()).hexdigest()
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;print(f"[HASH] {sha}")
&nbsp; &nbsp; &nbsp; &nbsp; ws_conn.send(sha)

&nbsp; &nbsp; elif msg_type ==&nbsp;'gameover':
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;print(f"[GAMEOVER] {msg.get('content',&nbsp;'')}")

&nbsp; &nbsp; elif msg_type ==&nbsp;'switch_shell':
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;print("[SHELL] Got shell!")
&nbsp; &nbsp; &nbsp; &nbsp; is_shell = True
&nbsp; &nbsp; &nbsp; &nbsp; time.sleep(1)
&nbsp; &nbsp; &nbsp; &nbsp; # 多个位置找 flag
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;for&nbsp;cmd in [
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;"cat /tmp/flag.txt",
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;"cat /tmp/flag",
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;"cat /flag",
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;"echo $GZCTF_FLAG",
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; "find / -maxdepth&nbsp;3&nbsp;-name&nbsp;'*flag*'&nbsp;2>/dev/null",
&nbsp; &nbsp; &nbsp; &nbsp; ]:
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; ws_conn.send(cmd + "\n")
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; time.sleep(2)

&nbsp; &nbsp; else:
&nbsp; &nbsp; &nbsp; &nbsp; print(f"[{msg_type}] {json.dumps(msg)[:200]}")

def on_error(ws_conn, error):
&nbsp; &nbsp; print(f"[ERROR] {error}")

def on_close(ws_conn, code, msg):
&nbsp; &nbsp; print(f"[CLOSED] {code} {msg}")
&nbsp; &nbsp; if shell_output:
&nbsp; &nbsp; &nbsp; &nbsp; print("\n=== SHELL OUTPUT ===")
&nbsp; &nbsp; &nbsp; &nbsp; print("".join(shell_output))

def on_open(ws_conn):
&nbsp; &nbsp; print("[CONNECTED]")

if __name__ == "__main__":
&nbsp; &nbsp; ws_app = websocket.WebSocketApp(
&nbsp; &nbsp; &nbsp; &nbsp; TARGET,
&nbsp; &nbsp; &nbsp; &nbsp; on_open=on_open,
&nbsp; &nbsp; &nbsp; &nbsp; on_message=on_message,
&nbsp; &nbsp; &nbsp; &nbsp; on_error=on_error,
&nbsp; &nbsp; &nbsp; &nbsp; on_close=on_close
&nbsp; &nbsp; )
&nbsp; &nbsp; ws_app.run_forever(ping_interval=30, ping_timeout=10)

免责声明:

本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。

任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。

本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我

本文转载自:船山信安 wenject wenject《欣欣的缠绕毛线球 2 — 乌托邦·王的实验21wp》

评论:0   参与:  0