Forensics

Ocean Wildlife

Challenge

You have recieved a message in a bottle, saying something about the strange behavior of sea creatures. I wonder what that could be about?

Solution

观察 metadata.yaml 可以知道这是一个 ROS2 rosbag2 数据包,存储是 sqlite3,并且包含这些 topic:

  • /draw_commands
  • /rosout
  • /turtle1/pose
  • /turtle1/color_sensor

其中 /draw_commands/turtle1/* 这两个名字非常像 turtlesim, /rosout 也说明程序运行时打印过日志。整个包总共 1404 条消息,其中 /draw_commands 284 条,/turtle1/pose 552 条,/turtle1/color_sensor 552 条,/rosout 15 条。

基本可以判断出这是一个 ROS2 rosbag2 录制文件,题目和 turtlesim 高度相关,很可能有一只 turtle 在“画字”或者“写消息”。

.db3 本质上是 SQLite 数据库,rosbag2 常见的表有:

  • topics (topic 名称、消息类型)
  • messages (实际消息内容)

先枚举 topic:

python
import sqlite3 conn = sqlite3.connect("mystery_message_0.db3")cur = conn.cursor() print(cur.execute("SELECT id, name, type FROM topics ORDER BY id").fetchall()) # [(1, '/turtle1/pose', 'turtlesim/msg/Pose'), (2, '/turtle1/color_sensor', 'turtlesim/msg/Color'), (3, '/rosout', 'rcl_interfaces/msg/Log'), (4, '/parameter_events', 'rcl_interfaces/msg/ParameterEvent'), (5, '/events/write_split', 'rosbag2_interfaces/msg/WriteSplitEvent'), (6, '/draw_commands', 'std_msgs/msg/String')]

/rosout 是 ROS2 的日志输出 topic,程序在绘制结束后可能会直接打印出 flag。也可能要解析 /draw_commands 来重建 turtle 的书写轨迹。

这里先尝试 /rosout 的路线。

/rosout 的消息类型是 rcl_interfaces/msg/Log,而 rosbag2 数据库存的是 CDR 序列化后的二进制 BLOB,所以要做一点简单解析。

ROS2 CDR 数据前面一般有 4 字节封装头,rcl_interfaces/msg/Log 的字段主要包括:

  • 时间戳 stamp
  • 日志级别 level
  • 节点名 name
  • 日志内容 msg
  • 源文件 file
  • 函数名 function
  • 行号 line
python
import sqlite3import struct def align(off, n):    return (off + (n - 1)) & ~(n - 1) def parse_cdr_string(blob, off):    off = align(off, 4)    (length,) = struct.unpack_from("<I", blob, off)    off += 4    s = blob[off:off + length - 1].decode(errors="replace") if length else ""    off += length    return s, off def parse_rosout_log(blob):    off = 4  # 跳过 CDR encapsulation header     off = align(off, 4)    sec, nanosec = struct.unpack_from("<iI", blob, off)    off += 8     level = blob[off]    off += 1     name, off = parse_cdr_string(blob, off)    msg, off = parse_cdr_string(blob, off)    file_name, off = parse_cdr_string(blob, off)    function, off = parse_cdr_string(blob, off)     off = align(off, 4)    (line,) = struct.unpack_from("<I", blob, off)     return {        "sec": sec,        "nanosec": nanosec,        "level": level,        "name": name,        "msg": msg,        "file": file_name,        "function": function,        "line": line,    } conn = sqlite3.connect("mystery_message_0.db3")cur = conn.cursor() rosout_topic_id = cur.execute(    "SELECT id FROM topics WHERE name='/rosout'").fetchone()[0] rows = cur.execute(    "SELECT timestamp, data FROM messages WHERE topic_id=? ORDER BY timestamp",    (rosout_topic_id,)).fetchall() for _, blob in rows:    log = parse_rosout_log(blob)    print(f"[{log['name']}] {log['msg']}")

输出:

text
[turtlesim] Starting turtlesim with node name /turtlesim[turtlesim] Spawning turtle [turtle1] at x=[5.544445], y=[5.544445], theta=[0.000000][rosbag2_recorder] Press SPACE for pausing/resuming[rosbag2_recorder] Listening for topics...[rosbag2_recorder] Event publisher thread: Starting[rosbag2_recorder] Subscribed to topic '/turtle1/pose'[rosbag2_recorder] Subscribed to topic '/turtle1/color_sensor'[rosbag2_recorder] Subscribed to topic '/rosout'[rosbag2_recorder] Subscribed to topic '/parameter_events'[rosbag2_recorder] Subscribed to topic '/events/write_split'[rosbag2_recorder] Recording...[draw_text_node] Waiting for turtlesim services...[draw_text_node] Services available.[rosbag2_recorder] Subscribed to topic '/draw_commands'[draw_text_node] Finished drawing: RS{f0ll0w_th3_5ea_Turtles}

从最后一条日志拿到 flag:

text
RS{f0ll0w_th3_5ea_Turtles}

再尝试 /draw_commands 的路线,重建 turtle 的书写轨迹。

/draw_commands 类型是 std_msgs/msg/String,也就是说消息本质上就是一个字符串

尝试解析 std_msgs/msg/String, CDR 格式是:

  • 4 字节 encapsulation header
  • 4 字节字符串长度
  • 字符串内容(末尾带 \x00
python
import sqlite3import structimport json def parse_std_string(blob):    (length,) = struct.unpack_from("<I", blob, 4)    return blob[8:8 + length - 1].decode(errors="replace") conn = sqlite3.connect("mystery_message_0.db3")cur = conn.cursor() draw_topic_id = cur.execute(    "SELECT id FROM topics WHERE name='/draw_commands'").fetchone()[0] rows = cur.execute(    "SELECT timestamp, data FROM messages WHERE topic_id=? ORDER BY timestamp",    (draw_topic_id,)).fetchall() for _, blob in rows:    print(json.loads(parse_std_string(blob)))

解析后发现里面装的是 JSON:

json
{'cmd': 'teleport', 'x': 1.044999999999999, 'y': 5.845, 'theta': 0.0}{'cmd': 'pen', 'r': 255, 'g': 255, 'b': 255, 'width': 3, 'off': 0}{'cmd': 'teleport', 'x': 1.044999999999999, 'y': 6.67, 'theta': 0.0}{'cmd': 'pen', 'r': 255, 'g': 255, 'b': 255, 'width': 3, 'off': 1}{'cmd': 'teleport', 'x': 1.044999999999999, 'y': 6.67, 'theta': 0.0}{'cmd': 'pen', 'r': 255, 'g': 255, 'b': 255, 'width': 3, 'off': 0}{'cmd': 'teleport', 'x': 1.429999999999999, 'y': 6.67, 'theta': 0.0}{'cmd': 'teleport', 'x': 1.594999999999999, 'y': 6.5325, 'theta': 0.0}{'cmd': 'teleport', 'x': 1.594999999999999, 'y': 6.395, 'theta': 0.0}{'cmd': 'teleport', 'x': 1.429999999999999, 'y': 6.257499999999999, 'theta': 0.0}{'cmd': 'teleport', 'x': 1.044999999999999, 'y': 6.257499999999999, 'theta': 0.0}{'cmd': 'pen', 'r': 255, 'g': 255, 'b': 255, 'width': 3, 'off': 1}{'cmd': 'teleport', 'x': 1.319999999999999, 'y': 6.257499999999999, 'theta': 0.0}{'cmd': 'pen', 'r': 255, 'g': 255, 'b': 255, 'width': 3, 'off': 0}{'cmd': 'teleport', 'x': 1.594999999999999, 'y': 5.845, 'theta': 0.0}{'cmd': 'pen', 'r': 255, 'g': 255, 'b': 255, 'width': 3, 'off': 1}{'cmd': 'teleport', 'x': 1.694999999999999, 'y': 5.927499999999999, 'theta': 0.0}{'cmd': 'pen', 'r': 255, 'g': 255, 'b': 255, 'width': 3, 'off': 0}{'cmd': 'teleport', 'x': 1.859999999999999, 'y': 5.845, 'theta': 0.0}{'cmd': 'teleport', 'x': 2.079999999999999, 'y': 5.845, 'theta': 0.0}{'cmd': 'teleport', 'x': 2.244999999999999, 'y': 5.927499999999999, 'theta': 0.0}{'cmd': 'teleport', 'x': 2.244999999999999, 'y': 6.0649999999999995, 'theta': 0.0}{'cmd': 'teleport', 'x': 2.189999999999999, 'y': 6.175, 'theta': 0.0}{'cmd': 'teleport', 'x': 2.079999999999999, 'y': 6.257499999999999, 'theta': 0.0}{'cmd': 'teleport', 'x': 1.859999999999999, 'y': 6.257499999999999, 'theta': 0.0}{'cmd': 'teleport', 'x': 1.749999999999999, 'y': 6.34, 'theta': 0.0}{'cmd': 'teleport', 'x': 1.694999999999999, 'y': 6.45, 'theta': 0.0}{'cmd': 'teleport', 'x': 1.694999999999999, 'y': 6.5875, 'theta': 0.0}{'cmd': 'teleport', 'x': 1.859999999999999, 'y': 6.67, 'theta': 0.0}{'cmd': 'teleport', 'x': 2.079999999999999, 'y': 6.67, 'theta': 0.0}{'cmd': 'teleport', 'x': 2.244999999999999, 'y': 6.5875, 'theta': 0.0}{'cmd': 'pen', 'r': 255, 'g': 255, 'b': 255, 'width': 3, 'off': 1}{'cmd': 'teleport', 'x': 2.784999999999999, 'y': 6.67, 'theta': 0.0}{'cmd': 'pen', 'r': 255, 'g': 255, 'b': 255, 'width': 3, 'off': 0}{'cmd': 'teleport', 'x': 2.6199999999999988, 'y': 6.615, 'theta': 0.0}{'cmd': 'teleport', 'x': 2.564999999999999, 'y': 6.505, 'theta': 0.0}{'cmd': 'teleport', 'x': 2.564999999999999, 'y': 6.34, 'theta': 0.0}{'cmd': 'teleport', 'x': 2.4549999999999987, 'y': 6.257499999999999, 'theta': 0.0}{'cmd': 'teleport', 'x': 2.564999999999999, 'y': 6.175, 'theta': 0.0}{'cmd': 'teleport', 'x': 2.564999999999999, 'y': 6.01, 'theta': 0.0}{'cmd': 'teleport', 'x': 2.6199999999999988, 'y': 5.8999999999999995, 'theta': 0.0}{'cmd': 'teleport', 'x': 2.784999999999999, 'y': 5.845, 'theta': 0.0}{'cmd': 'pen', 'r': 255, 'g': 255, 'b': 255, 'width': 3, 'off': 1}{'cmd': 'teleport', 'x': 3.1599999999999993, 'y': 5.845, 'theta': 0.0}{'cmd': 'pen', 'r': 255, 'g': 255, 'b': 255, 'width': 3, 'off': 0}{'cmd': 'teleport', 'x': 3.1599999999999993, 'y': 6.56, 'theta': 0.0}{'cmd': 'teleport', 'x': 3.2149999999999994, 'y': 6.6425, 'theta': 0.0}{'cmd': 'teleport', 'x': 3.3249999999999993, 'y': 6.67, 'theta': 0.0}{'cmd': 'teleport', 'x': 3.4899999999999993, 'y': 6.67, 'theta': 0.0}{'cmd': 'pen', 'r': 255, 'g': 255, 'b': 255, 'width': 3, 'off': 1}{'cmd': 'teleport', 'x': 2.994999999999999, 'y': 6.34, 'theta': 0.0}{'cmd': 'pen', 'r': 255, 'g': 255, 'b': 255, 'width': 3, 'off': 0}{'cmd': 'teleport', 'x': 3.379999999999999, 'y': 6.34, 'theta': 0.0}{'cmd': 'pen', 'r': 255, 'g': 255, 'b': 255, 'width': 3, 'off': 1}{'cmd': 'teleport', 'x': 3.919999999999999, 'y': 5.845, 'theta': 0.0}{'cmd': 'pen', 'r': 255, 'g': 255, 'b': 255, 'width': 3, 'off': 0}{'cmd': 'teleport', 'x': 3.754999999999999, 'y': 5.8999999999999995, 'theta': 0.0}{'cmd': 'teleport', 'x': 3.644999999999999, 'y': 6.0649999999999995, 'theta': 0.0}{'cmd': 'teleport', 'x': 3.644999999999999, 'y': 6.45, 'theta': 0.0}{'cmd': 'teleport', 'x': 3.754999999999999, 'y': 6.615, 'theta': 0.0}{'cmd': 'teleport', 'x': 3.919999999999999, 'y': 6.67, 'theta': 0.0}{'cmd': 'teleport', 'x': 4.084999999999999, 'y': 6.615, 'theta': 0.0}{'cmd': 'teleport', 'x': 4.194999999999999, 'y': 6.45, 'theta': 0.0}{'cmd': 'teleport', 'x': 4.194999999999999, 'y': 6.0649999999999995, 'theta': 0.0}{'cmd': 'teleport', 'x': 4.084999999999999, 'y': 5.8999999999999995, 'theta': 0.0}{'cmd': 'teleport', 'x': 3.919999999999999, 'y': 5.845, 'theta': 0.0}{'cmd': 'pen', 'r': 255, 'g': 255, 'b': 255, 'width': 3, 'off': 1}{'cmd': 'teleport', 'x': 3.754999999999999, 'y': 5.927499999999999, 'theta': 0.0}{'cmd': 'pen', 'r': 255, 'g': 255, 'b': 255, 'width': 3, 'off': 0}{'cmd': 'teleport', 'x': 4.084999999999999, 'y': 6.5875, 'theta': 0.0}{'cmd': 'pen', 'r': 255, 'g': 255, 'b': 255, 'width': 3, 'off': 1}{'cmd': 'teleport', 'x': 4.569999999999999, 'y': 6.67, 'theta': 0.0}{'cmd': 'pen', 'r': 255, 'g': 255, 'b': 255, 'width': 3, 'off': 0}{'cmd': 'teleport', 'x': 4.569999999999999, 'y': 5.8999999999999995, 'theta': 0.0}{'cmd': 'teleport', 'x': 4.569999999999999, 'y': 5.845, 'theta': 0.0}{'cmd': 'teleport', 'x': 4.734999999999999, 'y': 5.845, 'theta': 0.0}{'cmd': 'pen', 'r': 255, 'g': 255, 'b': 255, 'width': 3, 'off': 1}{'cmd': 'teleport', 'x': 5.22, 'y': 6.67, 'theta': 0.0}{'cmd': 'pen', 'r': 255, 'g': 255, 'b': 255, 'width': 3, 'off': 0}{'cmd': 'teleport', 'x': 5.22, 'y': 5.8999999999999995, 'theta': 0.0}{'cmd': 'teleport', 'x': 5.22, 'y': 5.845, 'theta': 0.0}{'cmd': 'teleport', 'x': 5.385, 'y': 5.845, 'theta': 0.0}{'cmd': 'pen', 'r': 255, 'g': 255, 'b': 255, 'width': 3, 'off': 1}{'cmd': 'teleport', 'x': 5.869999999999999, 'y': 5.845, 'theta': 0.0}{'cmd': 'pen', 'r': 255, 'g': 255, 'b': 255, 'width': 3, 'off': 0}{'cmd': 'teleport', 'x': 5.704999999999999, 'y': 5.8999999999999995, 'theta': 0.0}{'cmd': 'teleport', 'x': 5.594999999999999, 'y': 6.0649999999999995, 'theta': 0.0}{'cmd': 'teleport', 'x': 5.594999999999999, 'y': 6.45, 'theta': 0.0}{'cmd': 'teleport', 'x': 5.704999999999999, 'y': 6.615, 'theta': 0.0}{'cmd': 'teleport', 'x': 5.869999999999999, 'y': 6.67, 'theta': 0.0}{'cmd': 'teleport', 'x': 6.034999999999999, 'y': 6.615, 'theta': 0.0}{'cmd': 'teleport', 'x': 6.144999999999999, 'y': 6.45, 'theta': 0.0}{'cmd': 'teleport', 'x': 6.144999999999999, 'y': 6.0649999999999995, 'theta': 0.0}{'cmd': 'teleport', 'x': 6.034999999999999, 'y': 5.8999999999999995, 'theta': 0.0}{'cmd': 'teleport', 'x': 5.869999999999999, 'y': 5.845, 'theta': 0.0}{'cmd': 'pen', 'r': 255, 'g': 255, 'b': 255, 'width': 3, 'off': 1}{'cmd': 'teleport', 'x': 5.704999999999999, 'y': 5.927499999999999, 'theta': 0.0}{'cmd': 'pen', 'r': 255, 'g': 255, 'b': 255, 'width': 3, 'off': 0}{'cmd': 'teleport', 'x': 6.034999999999999, 'y': 6.5875, 'theta': 0.0}{'cmd': 'pen', 'r': 255, 'g': 255, 'b': 255, 'width': 3, 'off': 1}{'cmd': 'teleport', 'x': 6.244999999999999, 'y': 6.45, 'theta': 0.0}{'cmd': 'pen', 'r': 255, 'g': 255, 'b': 255, 'width': 3, 'off': 0}{'cmd': 'teleport', 'x': 6.382499999999999, 'y': 5.845, 'theta': 0.0}{'cmd': 'teleport', 'x': 6.52, 'y': 6.2299999999999995, 'theta': 0.0}{'cmd': 'teleport', 'x': 6.657499999999999, 'y': 5.845, 'theta': 0.0}{'cmd': 'teleport', 'x': 6.794999999999999, 'y': 6.45, 'theta': 0.0}{'cmd': 'pen', 'r': 255, 'g': 255, 'b': 255, 'width': 3, 'off': 1}{'cmd': 'teleport', 'x': 6.895, 'y': 5.845, 'theta': 0.0}{'cmd': 'pen', 'r': 255, 'g': 255, 'b': 255, 'width': 3, 'off': 0}{'cmd': 'teleport', 'x': 7.444999999999999, 'y': 5.845, 'theta': 0.0}{'cmd': 'pen', 'r': 255, 'g': 255, 'b': 255, 'width': 3, 'off': 1}{'cmd': 'teleport', 'x': 7.764999999999999, 'y': 6.67, 'theta': 0.0}{'cmd': 'pen', 'r': 255, 'g': 255, 'b': 255, 'width': 3, 'off': 0}{'cmd': 'teleport', 'x': 7.764999999999999, 'y': 5.8999999999999995, 'theta': 0.0}{'cmd': 'teleport', 'x': 7.819999999999999, 'y': 5.845, 'theta': 0.0}{'cmd': 'teleport', 'x': 7.984999999999999, 'y': 5.845, 'theta': 0.0}{'cmd': 'pen', 'r': 255, 'g': 255, 'b': 255, 'width': 3, 'off': 1}{'cmd': 'teleport', 'x': 7.599999999999999, 'y': 6.34, 'theta': 0.0}{'cmd': 'pen', 'r': 255, 'g': 255, 'b': 255, 'width': 3, 'off': 0}{'cmd': 'teleport', 'x': 7.929999999999999, 'y': 6.34, 'theta': 0.0}{'cmd': 'pen', 'r': 255, 'g': 255, 'b': 255, 'width': 3, 'off': 1}{'cmd': 'teleport', 'x': 8.195, 'y': 5.845, 'theta': 0.0}{'cmd': 'pen', 'r': 255, 'g': 255, 'b': 255, 'width': 3, 'off': 0}{'cmd': 'teleport', 'x': 8.195, 'y': 6.67, 'theta': 0.0}{'cmd': 'pen', 'r': 255, 'g': 255, 'b': 255, 'width': 3, 'off': 1}{'cmd': 'teleport', 'x': 8.195, 'y': 6.285, 'theta': 0.0}{'cmd': 'pen', 'r': 255, 'g': 255, 'b': 255, 'width': 3, 'off': 0}{'cmd': 'teleport', 'x': 8.36, 'y': 6.45, 'theta': 0.0}{'cmd': 'teleport', 'x': 8.58, 'y': 6.45, 'theta': 0.0}{'cmd': 'teleport', 'x': 8.745000000000001, 'y': 6.285, 'theta': 0.0}{'cmd': 'teleport', 'x': 8.745000000000001, 'y': 5.845, 'theta': 0.0}{'cmd': 'pen', 'r': 255, 'g': 255, 'b': 255, 'width': 3, 'off': 1}{'cmd': 'teleport', 'x': 8.844999999999999, 'y': 6.5875, 'theta': 0.0}{'cmd': 'pen', 'r': 255, 'g': 255, 'b': 255, 'width': 3, 'off': 0}{'cmd': 'teleport', 'x': 9.009999999999998, 'y': 6.67, 'theta': 0.0}{'cmd': 'teleport', 'x': 9.229999999999999, 'y': 6.67, 'theta': 0.0}{'cmd': 'teleport', 'x': 9.395, 'y': 6.56, 'theta': 0.0}{'cmd': 'teleport', 'x': 9.395, 'y': 6.395, 'theta': 0.0}{'cmd': 'teleport', 'x': 9.229999999999999, 'y': 6.285, 'theta': 0.0}{'cmd': 'teleport', 'x': 9.065, 'y': 6.257499999999999, 'theta': 0.0}{'cmd': 'pen', 'r': 255, 'g': 255, 'b': 255, 'width': 3, 'off': 1}{'cmd': 'teleport', 'x': 9.065, 'y': 6.257499999999999, 'theta': 0.0}{'cmd': 'pen', 'r': 255, 'g': 255, 'b': 255, 'width': 3, 'off': 0}{'cmd': 'teleport', 'x': 9.229999999999999, 'y': 6.2299999999999995, 'theta': 0.0}{'cmd': 'teleport', 'x': 9.395, 'y': 6.12, 'theta': 0.0}{'cmd': 'teleport', 'x': 9.395, 'y': 5.955, 'theta': 0.0}{'cmd': 'teleport', 'x': 9.229999999999999, 'y': 5.845, 'theta': 0.0}{'cmd': 'teleport', 'x': 9.009999999999998, 'y': 5.845, 'theta': 0.0}{'cmd': 'teleport', 'x': 8.844999999999999, 'y': 5.927499999999999, 'theta': 0.0}{'cmd': 'pen', 'r': 255, 'g': 255, 'b': 255, 'width': 3, 'off': 1}{'cmd': 'teleport', 'x': 9.495000000000001, 'y': 5.845, 'theta': 0.0}{'cmd': 'pen', 'r': 255, 'g': 255, 'b': 255, 'width': 3, 'off': 0}{'cmd': 'teleport', 'x': 10.045000000000002, 'y': 5.845, 'theta': 0.0}{'cmd': 'pen', 'r': 255, 'g': 255, 'b': 255, 'width': 3, 'off': 1}{'cmd': 'teleport', 'x': 2.244999999999999, 'y': 5.245, 'theta': 0.0}{'cmd': 'pen', 'r': 255, 'g': 255, 'b': 255, 'width': 3, 'off': 0}{'cmd': 'teleport', 'x': 1.6949999999999994, 'y': 5.245, 'theta': 0.0}{'cmd': 'pen', 'r': 255, 'g': 255, 'b': 255, 'width': 3, 'off': 1}{'cmd': 'teleport', 'x': 1.6949999999999994, 'y': 5.245, 'theta': 0.0}{'cmd': 'pen', 'r': 255, 'g': 255, 'b': 255, 'width': 3, 'off': 0}{'cmd': 'teleport', 'x': 1.6949999999999994, 'y': 4.8875, 'theta': 0.0}{'cmd': 'teleport', 'x': 1.8599999999999994, 'y': 4.915, 'theta': 0.0}{'cmd': 'teleport', 'x': 2.079999999999999, 'y': 4.915, 'theta': 0.0}{'cmd': 'teleport', 'x': 2.244999999999999, 'y': 4.805, 'theta': 0.0}{'cmd': 'teleport', 'x': 2.244999999999999, 'y': 4.585, 'theta': 0.0}{'cmd': 'teleport', 'x': 2.079999999999999, 'y': 4.42, 'theta': 0.0}{'cmd': 'teleport', 'x': 1.8599999999999994, 'y': 4.42, 'theta': 0.0}{'cmd': 'teleport', 'x': 1.6949999999999994, 'y': 4.5024999999999995, 'theta': 0.0}{'cmd': 'pen', 'r': 255, 'g': 255, 'b': 255, 'width': 3, 'off': 1}{'cmd': 'teleport', 'x': 2.3449999999999993, 'y': 4.7225, 'theta': 0.0}{'cmd': 'pen', 'r': 255, 'g': 255, 'b': 255, 'width': 3, 'off': 0}{'cmd': 'teleport', 'x': 2.8949999999999996, 'y': 4.7225, 'theta': 0.0}{'cmd': 'teleport', 'x': 2.8949999999999996, 'y': 4.8875, 'theta': 0.0}{'cmd': 'teleport', 'x': 2.7299999999999995, 'y': 5.025, 'theta': 0.0}{'cmd': 'teleport', 'x': 2.5099999999999993, 'y': 5.025, 'theta': 0.0}{'cmd': 'teleport', 'x': 2.3449999999999993, 'y': 4.8875, 'theta': 0.0}{'cmd': 'teleport', 'x': 2.3449999999999993, 'y': 4.5575, 'theta': 0.0}{'cmd': 'teleport', 'x': 2.5099999999999993, 'y': 4.42, 'theta': 0.0}{'cmd': 'teleport', 'x': 2.7299999999999995, 'y': 4.42, 'theta': 0.0}{'cmd': 'teleport', 'x': 2.8949999999999996, 'y': 4.5024999999999995, 'theta': 0.0}{'cmd': 'pen', 'r': 255, 'g': 255, 'b': 255, 'width': 3, 'off': 1}{'cmd': 'teleport', 'x': 3.4899999999999993, 'y': 4.42, 'theta': 0.0}{'cmd': 'pen', 'r': 255, 'g': 255, 'b': 255, 'width': 3, 'off': 0}{'cmd': 'teleport', 'x': 3.4899999999999993, 'y': 4.97, 'theta': 0.0}{'cmd': 'pen', 'r': 255, 'g': 255, 'b': 255, 'width': 3, 'off': 1}{'cmd': 'teleport', 'x': 3.4899999999999993, 'y': 4.97, 'theta': 0.0}{'cmd': 'pen', 'r': 255, 'g': 255, 'b': 255, 'width': 3, 'off': 0}{'cmd': 'teleport', 'x': 3.379999999999999, 'y': 5.025, 'theta': 0.0}{'cmd': 'teleport', 'x': 3.1599999999999993, 'y': 5.025, 'theta': 0.0}{'cmd': 'teleport', 'x': 3.0499999999999994, 'y': 4.915, 'theta': 0.0}{'cmd': 'teleport', 'x': 2.994999999999999, 'y': 4.7775, 'theta': 0.0}{'cmd': 'teleport', 'x': 2.994999999999999, 'y': 4.6674999999999995, 'theta': 0.0}{'cmd': 'teleport', 'x': 3.0499999999999994, 'y': 4.53, 'theta': 0.0}{'cmd': 'teleport', 'x': 3.1599999999999993, 'y': 4.4475, 'theta': 0.0}{'cmd': 'teleport', 'x': 3.3249999999999993, 'y': 4.42, 'theta': 0.0}{'cmd': 'teleport', 'x': 3.4899999999999993, 'y': 4.475, 'theta': 0.0}{'cmd': 'pen', 'r': 255, 'g': 255, 'b': 255, 'width': 3, 'off': 1}{'cmd': 'teleport', 'x': 3.6449999999999996, 'y': 4.42, 'theta': 0.0}{'cmd': 'pen', 'r': 255, 'g': 255, 'b': 255, 'width': 3, 'off': 0}{'cmd': 'teleport', 'x': 4.194999999999999, 'y': 4.42, 'theta': 0.0}{'cmd': 'pen', 'r': 255, 'g': 255, 'b': 255, 'width': 3, 'off': 1}{'cmd': 'teleport', 'x': 4.295, 'y': 5.245, 'theta': 0.0}{'cmd': 'pen', 'r': 255, 'g': 255, 'b': 255, 'width': 3, 'off': 0}{'cmd': 'teleport', 'x': 4.845, 'y': 5.245, 'theta': 0.0}{'cmd': 'pen', 'r': 255, 'g': 255, 'b': 255, 'width': 3, 'off': 1}{'cmd': 'teleport', 'x': 4.57, 'y': 5.245, 'theta': 0.0}{'cmd': 'pen', 'r': 255, 'g': 255, 'b': 255, 'width': 3, 'off': 0}{'cmd': 'teleport', 'x': 4.57, 'y': 4.42, 'theta': 0.0}{'cmd': 'pen', 'r': 255, 'g': 255, 'b': 255, 'width': 3, 'off': 1}{'cmd': 'teleport', 'x': 4.944999999999999, 'y': 5.025, 'theta': 0.0}{'cmd': 'pen', 'r': 255, 'g': 255, 'b': 255, 'width': 3, 'off': 0}{'cmd': 'teleport', 'x': 4.944999999999999, 'y': 4.585, 'theta': 0.0}{'cmd': 'teleport', 'x': 5.055, 'y': 4.4475, 'theta': 0.0}{'cmd': 'teleport', 'x': 5.22, 'y': 4.42, 'theta': 0.0}{'cmd': 'teleport', 'x': 5.385, 'y': 4.4475, 'theta': 0.0}{'cmd': 'teleport', 'x': 5.494999999999999, 'y': 4.585, 'theta': 0.0}{'cmd': 'teleport', 'x': 5.494999999999999, 'y': 5.025, 'theta': 0.0}{'cmd': 'pen', 'r': 255, 'g': 255, 'b': 255, 'width': 3, 'off': 1}{'cmd': 'teleport', 'x': 5.595, 'y': 4.42, 'theta': 0.0}{'cmd': 'pen', 'r': 255, 'g': 255, 'b': 255, 'width': 3, 'off': 0}{'cmd': 'teleport', 'x': 5.595, 'y': 5.025, 'theta': 0.0}{'cmd': 'pen', 'r': 255, 'g': 255, 'b': 255, 'width': 3, 'off': 1}{'cmd': 'teleport', 'x': 5.595, 'y': 4.86, 'theta': 0.0}{'cmd': 'pen', 'r': 255, 'g': 255, 'b': 255, 'width': 3, 'off': 0}{'cmd': 'teleport', 'x': 5.76, 'y': 4.9975000000000005, 'theta': 0.0}{'cmd': 'teleport', 'x': 5.925, 'y': 5.025, 'theta': 0.0}{'cmd': 'teleport', 'x': 6.09, 'y': 4.9975000000000005, 'theta': 0.0}{'cmd': 'pen', 'r': 255, 'g': 255, 'b': 255, 'width': 3, 'off': 1}{'cmd': 'teleport', 'x': 6.464999999999999, 'y': 5.245, 'theta': 0.0}{'cmd': 'pen', 'r': 255, 'g': 255, 'b': 255, 'width': 3, 'off': 0}{'cmd': 'teleport', 'x': 6.464999999999999, 'y': 4.475, 'theta': 0.0}{'cmd': 'teleport', 'x': 6.52, 'y': 4.42, 'theta': 0.0}{'cmd': 'teleport', 'x': 6.685, 'y': 4.42, 'theta': 0.0}{'cmd': 'pen', 'r': 255, 'g': 255, 'b': 255, 'width': 3, 'off': 1}{'cmd': 'teleport', 'x': 6.299999999999999, 'y': 4.915, 'theta': 0.0}{'cmd': 'pen', 'r': 255, 'g': 255, 'b': 255, 'width': 3, 'off': 0}{'cmd': 'teleport', 'x': 6.629999999999999, 'y': 4.915, 'theta': 0.0}{'cmd': 'pen', 'r': 255, 'g': 255, 'b': 255, 'width': 3, 'off': 1}{'cmd': 'teleport', 'x': 7.17, 'y': 5.245, 'theta': 0.0}{'cmd': 'pen', 'r': 255, 'g': 255, 'b': 255, 'width': 3, 'off': 0}{'cmd': 'teleport', 'x': 7.17, 'y': 4.475, 'theta': 0.0}{'cmd': 'teleport', 'x': 7.17, 'y': 4.42, 'theta': 0.0}{'cmd': 'teleport', 'x': 7.335, 'y': 4.42, 'theta': 0.0}{'cmd': 'pen', 'r': 255, 'g': 255, 'b': 255, 'width': 3, 'off': 1}{'cmd': 'teleport', 'x': 7.545, 'y': 4.7225, 'theta': 0.0}{'cmd': 'pen', 'r': 255, 'g': 255, 'b': 255, 'width': 3, 'off': 0}{'cmd': 'teleport', 'x': 8.095, 'y': 4.7225, 'theta': 0.0}{'cmd': 'teleport', 'x': 8.095, 'y': 4.8875, 'theta': 0.0}{'cmd': 'teleport', 'x': 7.93, 'y': 5.025, 'theta': 0.0}{'cmd': 'teleport', 'x': 7.71, 'y': 5.025, 'theta': 0.0}{'cmd': 'teleport', 'x': 7.545, 'y': 4.8875, 'theta': 0.0}{'cmd': 'teleport', 'x': 7.545, 'y': 4.5575, 'theta': 0.0}{'cmd': 'teleport', 'x': 7.71, 'y': 4.42, 'theta': 0.0}{'cmd': 'teleport', 'x': 7.93, 'y': 4.42, 'theta': 0.0}{'cmd': 'teleport', 'x': 8.095, 'y': 4.5024999999999995, 'theta': 0.0}{'cmd': 'pen', 'r': 255, 'g': 255, 'b': 255, 'width': 3, 'off': 1}{'cmd': 'teleport', 'x': 8.195, 'y': 4.475, 'theta': 0.0}{'cmd': 'pen', 'r': 255, 'g': 255, 'b': 255, 'width': 3, 'off': 0}{'cmd': 'teleport', 'x': 8.36, 'y': 4.42, 'theta': 0.0}{'cmd': 'teleport', 'x': 8.58, 'y': 4.42, 'theta': 0.0}{'cmd': 'teleport', 'x': 8.745000000000001, 'y': 4.475, 'theta': 0.0}{'cmd': 'teleport', 'x': 8.745000000000001, 'y': 4.585, 'theta': 0.0}{'cmd': 'teleport', 'x': 8.69, 'y': 4.6674999999999995, 'theta': 0.0}{'cmd': 'teleport', 'x': 8.58, 'y': 4.7225, 'theta': 0.0}{'cmd': 'teleport', 'x': 8.36, 'y': 4.7225, 'theta': 0.0}{'cmd': 'teleport', 'x': 8.25, 'y': 4.7775, 'theta': 0.0}{'cmd': 'teleport', 'x': 8.195, 'y': 4.86, 'theta': 0.0}{'cmd': 'teleport', 'x': 8.195, 'y': 4.97, 'theta': 0.0}{'cmd': 'teleport', 'x': 8.36, 'y': 5.025, 'theta': 0.0}{'cmd': 'teleport', 'x': 8.58, 'y': 5.025, 'theta': 0.0}{'cmd': 'teleport', 'x': 8.745000000000001, 'y': 4.97, 'theta': 0.0}{'cmd': 'pen', 'r': 255, 'g': 255, 'b': 255, 'width': 3, 'off': 1}{'cmd': 'teleport', 'x': 8.954999999999998, 'y': 5.245, 'theta': 0.0}{'cmd': 'pen', 'r': 255, 'g': 255, 'b': 255, 'width': 3, 'off': 0}{'cmd': 'teleport', 'x': 9.12, 'y': 5.1899999999999995, 'theta': 0.0}{'cmd': 'teleport', 'x': 9.174999999999999, 'y': 5.08, 'theta': 0.0}{'cmd': 'teleport', 'x': 9.174999999999999, 'y': 4.915, 'theta': 0.0}{'cmd': 'teleport', 'x': 9.284999999999998, 'y': 4.8325, 'theta': 0.0}{'cmd': 'teleport', 'x': 9.174999999999999, 'y': 4.75, 'theta': 0.0}{'cmd': 'teleport', 'x': 9.174999999999999, 'y': 4.585, 'theta': 0.0}{'cmd': 'teleport', 'x': 9.12, 'y': 4.475, 'theta': 0.0}{'cmd': 'teleport', 'x': 8.954999999999998, 'y': 4.42, 'theta': 0.0}{'cmd': 'pen', 'r': 255, 'g': 255, 'b': 255, 'width': 3, 'off': 1}{'cmd': 'teleport', 'x': 10.0, 'y': 10.0, 'theta': 0.0}

不难理解这套画图指令:

  • teleport:瞬移到某个坐标
  • pen:设置画笔状态
    • off = 0 表示落笔
    • off = 1 表示抬笔

根据这些 JSON 命令重现轨迹即可,这里用 matplotlib 实现:

python
import matplotlib.pyplot as plt def plot_trajectory():    commands = [        {'cmd': 'teleport', 'x': 1.044999999999999, 'y': 5.845, 'theta': 0.0},        {'cmd': 'pen', 'r': 255, 'g': 255, 'b': 255, 'width': 3, 'off': 0},        ...    ]     _, ax = plt.subplots()    ax.set_facecolor('black')     # 当前线段的点    current_line_x = []    current_line_y = []        # 需要绘制的线段    segments = []     pen_down = False    pen_color = (1.0, 1.0, 1.0)    pen_width = 1     # 当前画笔位置    curr_x = 0    curr_y = 0     for cmd in commands:        if cmd['cmd'] == 'teleport':            new_x = cmd['x']            new_y = cmd['y']                        if pen_down:                # 如果笔是落下的,添加从当前位置到新位置的线段                current_line_x.append(curr_x)                current_line_y.append(curr_y)                current_line_x.append(new_x)                current_line_y.append(new_y)            else:                # 如果笔是抬起的,且之前有累积的点,结算之前的线段                if current_line_x:                    segments.append((current_line_x[:], current_line_y[:], pen_color, pen_width))                    current_line_x = []                    current_line_y = []                        curr_x = new_x            curr_y = new_y         elif cmd['cmd'] == 'pen':            r = cmd['r'] / 255.0            g = cmd['g'] / 255.0            b = cmd['b'] / 255.0            w = cmd['width']            off = cmd['off']             # 更新画笔属性            pen_color = (r, g, b)            pen_width = w                        if off == 1: # 抬笔                if pen_down: # 之前是落笔状态,现在要抬起,结算当前线段                    if current_line_x:                        segments.append((current_line_x[:], current_line_y[:], pen_color, pen_width))                        current_line_x = []                        current_line_y = []                pen_down = False            else: # 落笔                pen_down = True                # 落笔时,起点是当前所在位置                current_line_x = [curr_x]                current_line_y = [curr_y]     # 处理最后可能残留的线段    if current_line_x:        segments.append((current_line_x[:], current_line_y[:], pen_color, pen_width))     # 绘制所有线段    for x_vals, y_vals, color, width in segments:        ax.plot(x_vals, y_vals, color=color, linewidth=width)     # 设置坐标轴比例一致,防止变形    ax.set_aspect('equal', adjustable='box')        # 调整视图范围    all_x = [p['x'] for p in commands if p['cmd'] == 'teleport']    all_y = [p['y'] for p in commands if p['cmd'] == 'teleport']    if all_x and all_y:        margin = 0.5        ax.set_xlim(min(all_x) - margin, max(all_x) + margin)        ax.set_ylim(min(all_y) - margin, max(all_y) + margin)     plt.show() if __name__ == '__main__':    plot_trajectory()

RITSECCTF2026-1

FLAG

flag
RS{f0ll0w_th3_5ea_Turtles}

Ocean Wildlife Revenge

Challenge

See the first challenge

Solution

解法同上一题

RITSECCTF2026-2

FLAG

flag
RS{W4tch1ng_r0b0t_turtl3s}

Davy Jones’ Message

Challenge

When sailors get lost, sometimes they will put a message in a bottle and set it off to sea in search of help. You sailor, have come across one of these bottles. Only thing is- it’s broken. Something might be hidden here, if you can figure it out…

Solution

先查看协议分级,这个 pcap 里几乎全是 RTPS 流量,而且两端通信很集中:

  • 10.42.0.10 -> 10.42.0.11
  • 10.42.0.11 -> 10.42.0.10

RITSECCTF2026-3

先看 IP 分片,因为如果忽略 IP 分片,后面直接拿单个 UDP 去解析 RTPS 可能会发现很多地方数据看起来像坏的一样

text
ip.flags.mf == 1 || ip.frag_offset > 0

发现分片很多,和题面里的 broken 对上了

RITSECCTF2026-4

这题大概率不止一层 broken,可能外面是 IP fragment,里面还会有 RTPS 自己的 fragment

接下来先把 IP 分片重组,按 (src, dst, protocol, ip.id) 聚合 IPv4 分片,然后按 offset 重组:

python
# reassemble_ip.pyimport structimport socketimport collections PCAP = "davy_jones_message.pcap" def parse_pcap_packets(path):    with open(path, 'rb') as f:        gh = f.read(24)        if len(gh) != 24:            return        while True:            ph = f.read(16)            if not ph:                break            ts_sec, ts_usec, incl_len, orig_len = struct.unpack('<IIII', ph)            data = f.read(incl_len)            yield ts_sec, ts_usec, data def reassemble_ipv4_udp(path):    frags = collections.defaultdict(list)    complete_udp = []    total_frames = 0    frag_frames = 0     for ts_sec, ts_usec, data in parse_pcap_packets(path):        total_frames += 1        if len(data) < 14 + 20:            continue         eth_type = struct.unpack('!H', data[12:14])[0]        if eth_type != 0x0800:            continue         ip = data[14:]        version_ihl = ip[0]        version = version_ihl >> 4        ihl = (version_ihl & 0x0f) * 4        if version != 4 or len(ip) < ihl:            continue         total_len = struct.unpack('!H', ip[2:4])[0]        ident = struct.unpack('!H', ip[4:6])[0]        flags_frag = struct.unpack('!H', ip[6:8])[0]        mf = bool(flags_frag & 0x2000)        frag_offset = (flags_frag & 0x1fff) * 8        proto = ip[9]        src = socket.inet_ntoa(ip[12:16])        dst = socket.inet_ntoa(ip[16:20])         if proto != 17:            continue         payload = ip[ihl:total_len]         if mf or frag_offset != 0:            frag_frames += 1         if not mf and frag_offset == 0:            complete_udp.append((src, dst, payload))        else:            key = (src, dst, proto, ident)            frags[key].append((frag_offset, mf, payload))     reassembled = []    for key, parts in frags.items():        parts.sort(key=lambda x: x[0])         end = None        blocks = {}        for off, mf, payload in parts:            blocks[off] = payload            if not mf:                end = off + len(payload)         if end is None:            continue         cur = 0        out = bytearray()        ok = True        while cur < end:            if cur not in blocks:                ok = False                break            out += blocks[cur]            cur += len(blocks[cur])         if ok:            src, dst, proto, ident = key            reassembled.append((src, dst, bytes(out)))     # print(f"[+] total ethernet frames: {total_frames}")    # print(f"[+] fragmented ipv4 frames: {frag_frames}")    # print(f"[+] complete udp datagrams (already whole): {len(complete_udp)}")    # print(f"[+] reassembled udp datagrams: {len(reassembled)}")     return complete_udp + reassembled if __name__ == "__main__":    all_udp = reassemble_ipv4_udp(PCAP)    print(f"[+] total usable udp datagrams: {len(all_udp)}")

输出:

text
[+] total ethernet frames: 13059[+] fragmented ipv4 frames: 8532[+] complete udp datagrams (already whole): 4403[+] reassembled udp datagrams: 948[+] total usable udp datagrams: 5351

题目的 broken,第一层确实是在提示 IP fragmentation,但这还没完,因为 RTPS 里面还有一层

重组完 IP 以后,确认 RTPS 里还有没有继续分片

现在有完整 UDP datagram 了,接下来要识别哪些 UDP 载荷是 RTPS,解析 RTPS 子消息类型,看是不是有 DATA_FRAG

用脚本扫 RTPS:

python
# rtps_scan.pyimport structimport collectionsfrom reassemble_ip import reassemble_ipv4_udp PCAP = "davy_jones_message.pcap" SID_NAMES = {    0x06: "ACKNACK",    0x07: "HEARTBEAT",    0x09: "INFO_TS",    0x0e: "INFO_DST",    0x15: "DATA",    0x16: "DATA_FRAG",    0x13: "HEARTBEAT_FRAG",} def iter_rtps_submessages(udp_payload):    if len(udp_payload) < 8:        return    sport, dport, ulen, cksum = struct.unpack('!HHHH', udp_payload[:8])    payload = udp_payload[8:]    if not payload.startswith(b'RTPS'):        return     off = 20  # RTPS header    while off + 4 <= len(payload):        sid = payload[off]        flags = payload[off + 1]        little = bool(flags & 0x01)        endian = '<' if little else '>'        sublen = struct.unpack(endian + 'H', payload[off+2:off+4])[0]        body = payload[off+4:off+4+sublen]        yield sid, flags, body        if sublen == 0:            break        off += 4 + sublen cnt = collections.Counter() for src, dst, udp_payload in reassemble_ipv4_udp(PCAP):    if len(udp_payload) < 12:        continue    payload = udp_payload[8:]    if not payload.startswith(b'RTPS'):        continue    for sid, flags, body in iter_rtps_submessages(udp_payload):        cnt[SID_NAMES.get(sid, hex(sid))] += 1 # for k, v in cnt.most_common():#     print(f"{k:15s} {v}")

输出:

text
HEARTBEAT       2539INFO_TS         2529ACKNACK         2512DATA            2213INFO_DST        2189DATA_FRAG       948HEARTBEAT_FRAG  632

DATA_FRAG 说明了 broken 不只是 IP 分片,RTPS 自己也在分片

也就是说这题是两层 broken:

  1. 外层:IP fragment
  2. 内层:RTPS DATA_FRAG

下一步要做的是找出哪一条 writer 最像真正藏东西的那条 RTPS 数据流

统计不同 writer 的 DATA_FRAG 数量:

python
# rtps_frag_stats.pyimport structimport sysimport collectionsfrom reassemble_ip import reassemble_ipv4_udpfrom rtps_scan import iter_rtps_submessages PCAP = "davy_jones_message.pcap" writer_counts = collections.Counter() for src, dst, udp_payload in reassemble_ipv4_udp(PCAP):    if len(udp_payload) < 12:        continue    payload = udp_payload[8:]    if not payload.startswith(b'RTPS'):        continue     for sid, flags, body in iter_rtps_submessages(udp_payload):        if sid != 0x16:  # DATA_FRAG            continue         # 这里只是按这个题的包结构取 writerId        if len(body) < 12:            continue        writer_id = body[8:12].hex()        writer_counts[writer_id] += 1 for wid, n in writer_counts.most_common():    print(wid, n)

输出:

text
00001403 948

这意味着 writerId = 00001403,对应 948 个 DATA_FRAG 子消息

这个数量非常大,明显不像普通状态消息,把目标锁定在这条 writer 上,重组 writerId = 00001403DATA_FRAG

接下来按 writer sequence number 去拼应用层分片:

python
# reassemble_data_frag.pyimport structimport sysimport collectionsfrom reassemble_ip import reassemble_ipv4_udpfrom rtps_scan import iter_rtps_submessages PCAP = sys.argv[1]TARGET_WRITER = bytes.fromhex("00001403") samples = collections.defaultdict(list) for src, dst, udp_payload in reassemble_ipv4_udp(PCAP):    if len(udp_payload) < 12:        continue    payload = udp_payload[8:]    if not payload.startswith(b'RTPS'):        continue     for sid, flags, body in iter_rtps_submessages(udp_payload):        if sid != 0x16:            continue        if len(body) < 32:            continue         writer_id = body[8:12]        if writer_id != TARGET_WRITER:            continue         writer_sn_hi, writer_sn_lo = struct.unpack('<ii', body[12:20])        frag_start, frags_in_submsg, frag_size, sample_size = struct.unpack('<IHHI', body[20:32])         key = (writer_sn_hi, writer_sn_lo)        samples[key].append({            "frag_start": frag_start,            "frags_in_submsg": frags_in_submsg,            "frag_size": frag_size,            "sample_size": sample_size,            "data": body[32:]        }) print(f"[+] logical samples: {len(samples)}") ok = 0for sn, parts in sorted(samples.items()):    parts.sort(key=lambda x: x["frag_start"])    blob = b"".join(p["data"] for p in parts)     print(f"SN={sn} submsgs={len(parts)} sample_size={parts[0]['sample_size']} "          f"frag_size={parts[0]['frag_size']} starts={[p['frag_start'] for p in parts]} "          f"joined={len(blob)}")     with open(f"sample_{sn[1]:04d}.bin", "wb") as f:        f.write(blob[:parts[0]["sample_size"]])    ok += 1 print(f"[+] dumped {ok} samples")

输出:

text
[+] logical samples: 316SN=(0, 1) submsgs=3 sample_size=36628 frag_size=1344 starts=[1, 11, 21] joined=36628SN=(0, 2) submsgs=3 sample_size=36628 frag_size=1344 starts=[1, 11, 21] joined=36628...SN=(0, 315) submsgs=3 sample_size=36628 frag_size=1344 starts=[1, 11, 21] joined=36628SN=(0, 316) submsgs=3 sample_size=36628 frag_size=1344 starts=[1, 11, 21] joined=36628[+] dumped 316 samples

到这里可以得到几个非常关键的结论:

  1. writerId = 00001403 一共对应 316 份逻辑样本
  2. 每份样本由 3 个 DATA_FRAG 子消息组成
  3. 每份样本大小稳定为 36628 bytes
  4. 每个样本都能完整拼出来

先看看重组出来的样本长什么样

现在目录里已经有很多 sample_XXXX.bin 了,先看前几个样本的十六进制和字符串,判断是不是某种已知消息格式

RITSECCTF2026-5

光看十六进制不容易一下子看懂,但如果继续按常见 ROS2/DDS 序列化格式去猜,很快会发现它像某种 CDR 序列化消息体,于是下一步就尝试把它当成 PointCloud2 去解析(因为这类机器人/仿真题里,最容易藏视觉信息的载体就是图像、激光点云、PointCloud2,而且题面说 “Something might be hidden here”,很像把字藏在空间数据里)

写了一个解析脚本把 sample_0001.bin 当作 PointCloud2 解:

python
# parse_pointcloud2.pyimport struct buf = open("sample_0001.bin", "rb").read() off = 4  # CDR encapsulation sec, nsec = struct.unpack_from('<II', buf, off)off += 8 frame_id_len = struct.unpack_from('<I', buf, off)[0]off += 4frame_id = buf[off:off+frame_id_len].rstrip(b'\x00').decode(errors='ignore')off += frame_id_lenwhile off % 4:    off += 1 height, width = struct.unpack_from('<II', buf, off)off += 8 fields_len = struct.unpack_from('<I', buf, off)[0]off += 4 fields = []for _ in range(fields_len):    name_len = struct.unpack_from('<I', buf, off)[0]    off += 4    name = buf[off:off+name_len].rstrip(b'\x00').decode(errors='ignore')    off += name_len    while off % 4:        off += 1     field_offset = struct.unpack_from('<I', buf, off)[0]    off += 4     datatype = buf[off]    off += 1    while off % 4:        off += 1     count = struct.unpack_from('<I', buf, off)[0]    off += 4    fields.append((name, field_offset, datatype, count)) is_bigendian = buf[off]off += 1while off % 4:    off += 1 point_step, row_step = struct.unpack_from('<II', buf, off)off += 8 data_len = struct.unpack_from('<I', buf, off)[0]off += 4 print("frame_id   =", frame_id)print("height     =", height)print("width      =", width)print("fields     =", fields)print("point_step =", point_step)print("row_step   =", row_step)print("data_len   =", data_len)

输出:

text
frame_id   = wfheight     = 1width      = 2281fields     = [('x', 0, 7, 1), ('y', 4, 7, 1), ('z', 8, 7, 1), ('rgb', 12, 7, 1)]point_step = 16row_step   = 36496data_len   = 36496

这里就完全坐实了这 316 份样本其实是 PointCloud2 点云帧,所以接下来很自然猜测题目要藏的信息应该就在这些点云里

先把点云画出来看看,把 PointCloud2 中的点提出来并且先尝试不同投影面:

python
# dump_all_points.pyimport structimport globimport math def load_pc2(path):    buf = open(path, "rb").read()    off = 4     sec, nsec = struct.unpack_from('<II', buf, off)    off += 8     frame_id_len = struct.unpack_from('<I', buf, off)[0]    off += 4    off += frame_id_len    while off % 4:        off += 1     height, width = struct.unpack_from('<II', buf, off)    off += 8     fields_len = struct.unpack_from('<I', buf, off)[0]    off += 4     for _ in range(fields_len):        name_len = struct.unpack_from('<I', buf, off)[0]        off += 4 + name_len        while off % 4:            off += 1        off += 4  # offset        off += 1  # datatype        while off % 4:            off += 1        off += 4  # count     off += 1    while off % 4:        off += 1     point_step, row_step = struct.unpack_from('<II', buf, off)    off += 8     data_len = struct.unpack_from('<I', buf, off)[0]    off += 4    data = buf[off:off+data_len]     pts = []    for i in range(0, len(data), point_step):        x, y, z, rgbf = struct.unpack_from('<ffff', data, i)        rgb_u = struct.unpack('<I', struct.pack('<f', rgbf))[0]        r = (rgb_u >> 16) & 0xff        g = (rgb_u >> 8) & 0xff        b = rgb_u & 0xff        if math.isfinite(x) and math.isfinite(y) and math.isfinite(z):            pts.append((x, y, z, r, g, b))    return pts all_pts = []for fn in sorted(glob.glob("sample_*.bin")):    all_pts.extend(load_pc2(fn)) print("[+] total points:", len(all_pts)) with open("all_points.xyzrgb", "w") as f:    for x, y, z, r, g, b in all_pts:        f.write(f"{x} {y} {z} {r} {g} {b}\n")

输出:

text
[+] total points: 720796

画几个最直观的投影:

python
# plot_views.pyimport matplotlib.pyplot as plt pts = []with open("all_points.xyzrgb") as f:    for line in f:        x, y, z, r, g, b = map(float, line.split())        pts.append((x, y, z, int(r), int(g), int(b))) xs = [p[0] for p in pts]ys = [p[1] for p in pts]zs = [p[2] for p in pts] plt.figure(figsize=(12, 8))plt.scatter(xs, ys, s=0.1)plt.savefig("all_pts_xy.png", dpi=300) plt.figure(figsize=(12, 8))plt.scatter(xs, zs, s=0.1)plt.savefig("all_pts_xz.png", dpi=300)

最后发现 flag 就在 all_pts_xz.png

RITSECCTF2026-6

FLAG

flag
RS{D4vy_J0nes_Sp3aks_1n_5il3nce}

Song Inside a Shell

Challenge

You have been marooned on a beach, only the waves and the sea around you. Sitting down in the shade of the few trees on the island, you pick up a seashell and put it to your ear. Download the file and figure out what the sea has to tell you.

Solution

先从音频的双声道中提取差分声道(L-R)

bash
sox song-inside-a-shell.wav side_audio.wav remix 1v1,2v-1 gain -n
  • remix 1v1,2v-11v1:取左声道,权重 +12v-1:取右声道,权重 -1;合起来就是 L - R
  • gain -n:自动归一化,避免差分信号太小听不清

观察频谱图发现约 82 - 102 秒可疑:

RITSECCTF2026-7

截出可疑段

bash
sox side_audio.wav suspicious.wav trim 80 25

听了几遍发现像是倒放的,把它倒放再听听看

bash
sox suspicious.wav suspicious_rev.wav reverse

这回能很清晰得听出里面的内容是有意义的,听到 underscore 基本就能确定这是 flag 了,但还是听不太清,用 Whisper Large V3 - a Hugging Face Space by hf-audio 识别

RITSECCTF2026-8

text
 V-O-I-C-E underscore O-F underscore T-H-E underscore S-E-A right curly bracket R-S left curly bracket L-I-S-T-E-N underscore T-O underscore T-H-E underscore

组合起来就是 flag 了

FLAG

flag
RS{LISTEN_TO_THE_VOICE_OF_THE_SEA}