第四届数信杯数据安全之svg隐写
原创
弈秋
弈秋
一只岸上的鱼
2026年10月28日 20:09
江苏
去阅读
第四届数信杯数据安全之svg隐写
缘起
对于ctf,我是真的热爱,屡败屡战。
这次数信杯,成绩也不理想,但是还是有很多值得学习和总结的地方。
唯一意外的是,这次官方居然不禁止,甚至有点推荐使用AI,这让我很是惊讶。在没有agent的加持下,那就是拼模型了,而好的模型都是闭源的,都是收费的,有点变成了烧钱比赛,实际也是如此,grok好像一骑绝尘!
svg隐写
说到图片隐写,png和jpg绝对是主角,还从来没有见过svg隐写的题目,这让我很是好奇。刚好这次遇到了,就来研究一下。
题目
海报中没有隐藏文本,渲染器所需的一切都已存在。请在不执行脚本、不依赖截图或 OCR 的前提下,从 SVG 的几何关系中恢复 Flag
题目附件,我放在了cnb中:
数据信杯题目附件
https://cnb.cool/netvvorm/items/datasecurity26
基础知识
想要理解并解这道题,还是必须要先了解一些svg的基础知识。
1. SVG 文件格式与特点
SVG(Scalable Vector Graphics,可缩放矢量图形) 是一种基于 XML 的二维矢量图形格式,特点包括:
- 基于文本:本质是纯 XML 文本,可以直接用文本方式解析,适合藏数据。
- 矢量描述:用几何图元(
<rect>、<circle>、<path>等)描述图形,而非像素点阵。 - 坐标系:
viewBox定义逻辑坐标空间;元素坐标可以是负数、小数,允许超出画布范围。 -
<defs>定义 + <use>引用:可复用的图元(<symbol>、<clipPath>)定义在 <defs>中,通过 <use href=\"#id\">实例化,实现\"一处定义、多处摆放\"。 - 变换(transform):
translate(x y)平移、matrix(a b c d e f)线性变换(旋转、翻转、缩放、切变)。 - 裁剪(clip-path):
<clipPath>+ clip-path=\"url(#id)\"将内容裁剪到指定区域,区域外的图形不显示。
2. 本题 SVG 的数据结构特点
本题 poster.svg 的结构非常典型,是\"大量微小图元 + 掩码 + 变换\"的隐写载体:
<svg width=\"1024\" height=\"768\" viewBox=\"0 0 1024 768\">
<defs>
<symbol id=\"asset_000\"> <!-- 232 个 symbol:asset_000..asset_231 -->
<rect x=\"16\" y=\"-43\" width=\"5\" height=\"5\"/> <!-- 每个 symbol 含 ~8 个 5×5 小方块 -->
<rect x=\"21\" y=\"577\" width=\"5\" height=\"5\"/>
...
</symbol>
<clipPath id=\"mask_000\"> <!-- 232 个对应的裁剪掩码 -->
<rect x=\"15\" y=\"-104\" width=\"12\" height=\"732\"/>
</clipPath>
...(共 232 组 asset / mask)
</defs>
<g id=\"poster\">
...(124 个装饰性大 rect,构成海报背景与中央横带)
</g>
<g id=\"frame_83\"> <!-- 232 个摆放单元 -->
<g transform=\"translate(767 456)\">
<g transform=\"matrix(0 -1 -1 0 0 0)\">
<use href=\"#asset_000\" fill=\"#1b344d\" clip-path=\"url(#mask_000)\"/>
</g>
</g>
...(共 232 个,每个对应一个 symbol)
</g>
</svg>
关键数据特点:
| 元素 | 数量 | 作用 |
|---|---|---|
<symbol> asset\_000..231 |
232 | 每个含约 8 个 5×5 的 <rect>(230 个含 8 个,1 个含 7 个,1 个含 5 个) |
<clipPath> mask\_000..231 |
232 | 与 symbol 一一对应,起裁剪作用 |
<g id=\"frame_83\"> |
232 组 | 每组 = 外层 translate(x y) + 内层 matrix(...) + 一个 <use> |
<use> 的 fill |
4 种 | #1b344d、#16263a、#24496b、#2d5d87 |
整张海报没有任何 <text> 元素(符合题干\"没有隐藏文本\"),全部信息都藏在几何数据里。
3. 隐写用到的特性
本题用到了三种关键特性,缺一不可:
- 掩码裁剪(clip-path)选择信息:每个 symbol 的 8 个 5×5 小方块中,只有落在
mask矩形内部的小方块才会被渲染出来(本题每个 symbol 恰好有 6 个通过掩码,2 个被裁掉,存活位型恒为 11111100)。经过 matrix翻转和 translate平移后,所有存活的小方块最终都落在海报中央的一条水平横带(y 约 405–440)内。 - 重叠覆盖与奇偶(XOR)隐藏:这是真正的隐藏机制。不同 symbol 的小方块经平移/翻转后会重叠落在同一个 5px 网格单元上。每个单元被覆盖的次数是 1 或 2。取覆盖次数的奇偶性(即 mod 2,等价于 XOR):奇数被覆盖的单元点亮,偶数被覆盖的单元熄灭——这样原本渲染成\"噪声\"的点阵,就显露出一段可读的文本。这正是\"XOR shapes reveal flag\"的含义。
- 5×7 点阵字体:显露出来的文本是用 5×7 点阵字(每字符宽 5 点、高 7 点,字符间空 1 列)排布的。5×7 是经典的 LED/点阵显示字库规格。
二、解题思路
第 1 步:整体观察与结构分析
先用文本方式读取 SVG(不能靠截图/OCR),统计元素:
- 232 个
<symbol>、232 个 <use>、232 个 <clipPath>、2208 个 <rect>、0 个 <text>。 - 没有文本元素 → 题干\"没有隐藏文本\"得到印证,flag 必然藏在几何数据里。
第 2 步:还原每个小方块的最终位置(几何关系)
对每个摆放单元做如下几何计算:
symbol 局部坐标 (px, py) —— 先经 mask 裁剪(保留在掩码内的) ——>
matrix 线性变换: x' = a·px + c·py; y' = b·px + d·py ——>
translate 平移: ax = tx + x'; ay = ty + y'
统计发现:所有存活小方块都落在 y∈[405,440]、x∈[90,930] 的横带内(约 168 列 × 7 行,每格 5px)。用肉眼/直接渲染看这条带,它只是随机的噪声点,并不是文字。
第 3 步:发现奇偶覆盖(XOR)通道
既然直接看到的点阵是噪声,说明信息被\"隐藏\"了。注意到不同 symbol 的小方块会重叠在同一个 5px 网格单元上。于是统计每个单元被覆盖的次数:
- 覆盖次数直方图:
{2: 494, 1: 400}—— 恰好存在大量 2 次覆盖,说明重叠是刻意的。 - 取覆盖次数 mod 2(奇偶 / XOR):奇数点亮、偶数熄灭。
得到一张 168×7 的网格图,肉眼可见是一段清晰的 5×7 点阵文字!
##### # ### #### ### # # ### #### #### # # ### #### ##### #### ...
# # # # # # # # # # # # # # # # # # # # # ...
#### # # # # ### # # # # # # # ##### # # # # #### # ...
...
显示因为文档的远古不是很清楚,看一下console打印的图片:
第 4 步:分割字符并识别
将 168 列按\"5 列一个字符 + 1 列空格\"分割成 28 个 5×7 字形,对照标准 5×7 点阵字库逐个识别:
F L A G { X O R _ S H A P E S _ R E V E A L _ F L A G }
第 5 步:得到 Flag
FLAG{XOR_SHAPES_REVEAL_FLAG}
该 flag 本身也印证了解法:对点阵形状(shapes)做 XOR,即可显现(reveal)flag。
三、用到的脚本
以下两个脚本即可完整复现解题过程。均只做文本解析与几何计算,不渲染、不截图、不 OCR,完全符合题干要求。
脚本 1:解析 SVG,计算覆盖度奇偶网格
# -*- coding: utf-8 -*-
import re
from collections import Counter
svg = open(\"poster.svg\", encoding=\"utf-8\").read()
# ---------- 1. 解析所有 symbol 及其 5×5 rect ----------
sym_re = re.compile(r'<symbol id=\"(asset_\\d+)\">(.*?)</symbol>', re.S)
rect_re = re.compile(r'<rect x=\"([-\\d.]+)\" y=\"([-\\d.]+)\" width=\"([\\d.]+)\" height=\"([\\d.]+)\"/>')
sym = {}
for m in sym_re.finditer(svg):
sym[m.group(1)] = [(float(a), float(b), float(c), float(d))
for a, b, c, d in rect_re.findall(m.group(2))]
# ---------- 2. 解析所有 clipPath 掩码 ----------
cp_re = re.compile(r'<clipPath id=\"(mask_\\d+)\"[^>]*>(.*?)</clipPath>', re.S)
mask = {}
for m in cp_re.finditer(svg):
r = rect_re.search(m.group(2))
mask[m.group(1)] = tuple(float(v) for v in r.groups())
# ---------- 3. 解析 frame_83 的每个摆放单元(translate + matrix + use) ----------
frame_re = re.compile(
r'<g transform=\"translate\\(([-\\d.]+) ([-\\d.]+)\\)\">'
r'<g transform=\"matrix\\(([-\\d.]+) ([-\\d.]+) ([-\\d.]+) ([-\\d.]+) 0 0\\)\">'
r'<use href=\"#(asset_\\d+)\" fill=\"([^\"]+)\"')
frames = [(float(a), float(b), float(c), float(d), float(e), float(f), g, h)
for a, b, c, d, e, f, g, h in frame_re.findall(svg)]
# ---------- 4. 找出每个 symbol 中能通过掩码的小方块(局部坐标) ----------
def visible_local(sid):
mx, my, mw, mh = mask[sid.replace('asset', 'mask')]
out = []
for (x, y, w, h) in sym[sid]:
# 判断 5×5 方块与掩码矩形是否相交
if (x + w > mx) and (x < mx + mw) and (y + h > my) and (y < my + mh):
out.append((x + w / 2.0, y + h / 2.0)) # 方块中心(局部坐标)
return out
# ---------- 5. 把每个可见方块经 matrix+translate 放到最终位置,并统计 5px 网格覆盖次数 ----------
cover = Counter()
for (tx, ty, a, b, c, d, sid, fill) in frames:
for (px, py) in visible_local(sid):
ax = tx + a * px + c * py # matrix: x' = a·px + c·py
ay = ty + b * px + d * py # y' = b·px + d·py
cell = (int((ax - 2.5) // 5), int((ay - 2.5) // 5)) # 5px 对齐到网格单元
cover[cell] += 1
# ---------- 6. 输出覆盖次数奇偶网格(mod 2 = XOR):奇数点亮、偶数熄灭 ----------
xs = [c[0] for c in cover]; ys = [c[1] for c in cover]
xmin, xmax = min(xs), max(xs)
ymin, ymax = min(ys), max(ys)
print(f\"grid: {xmax-xmin+1} x {ymax-ymin+1}\")
for y in range(ymin, ymax + 1):
print(''.join('#' if cover.get((x, y), 0) % 2 == 1 else ' '
for x in range(xmin, xmax + 1)))
输出(168×7 奇偶网格,已可肉眼读出 5×7 文字):
##### # ### #### ### # # ### #### #### # # ### #### ##### #### #### ##### # # ##### ### # ##### # ### #### ###
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
#### # # # # ### # # # # # # # ##### # # # # #### # # # #### # # #### # # # #### # # # # ### #
# # ##### # # ## # # # #### ### # # ##### #### # ### #### # # # # ##### # # # ##### # # ##
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# ##### # # ### ### # # ### # # ##### #### # # # # # ##### #### ##### # # ##### # ##### # # ##### ##### # ##### # # ### ###
脚本 2:将奇偶网格分割为 5×7 字形并打印(便于识别)
# -*- coding: utf-8 -*-
# 输入:脚本 1 得到的 168×7 奇偶网格 grid[y][x],0/1
# 输出:按“5 列一个字符 + 1 列空格”切分的字形
def segment(grid, W, H):
glyphs = []
x = 0
while x < W:
# 跳过全空列(字符间隔)
if all(grid[y][x] == 0 for y in range(H)):
x += 1
continue
# 取宽 5 的一列字符
g = [[grid[y][x + k] for k in range(5)] for y in range(H)]
glyphs.append(g)
x += 5
# 跳过字符后的间隔列
if x < W and all(grid[y][x] == 0 for y in range(H)):
x += 1
return glyphs
# glyphs = segment(grid, W, H)
# for i, g in enumerate(glyphs):
# print(f\"--- glyph {i} ---\")
# for row in g:
# print(''.join('#' if b else '.' for b in row))
对照标准 5×7 点阵字库,逐个字形识别得到:
| 序号 | 字形图案(#=亮) | 字符 |
|---|---|---|
| 0 | #####/#..../####./#..../#..../#..../#.... |
F |
| 1 | #..../.../#####(左竖+底横) |
L |
| 2 | .###./#...#/#...#/#####/#...#/#...#/#...# |
A |
| 3 | .####/#..../#.###/#...#/#...#/#...#/.###. |
G |
| 4 | .###./.#.../.#.../##.../.#.../.#.../.###. |
{ |
| 5 | #...#/.#.#./..#../..#../..#../.#.#./#...# |
X |
| 6 | .###./#...#/#...#/#...#/#...#/#...#/.###. |
O |
| 7 | ####./#...#/#...#/####./#.#../#..#./#...# |
R |
| 8 | ...../...../...../...../...../...../##### |
\_ |
| 9 | .####/#..../#..../.###./....#/....#/####. |
S |
| 10 | #...#/#...#/#####/#...#/#...#/#...#/#...# |
H |
| 11 | .###./#...#/#...#/#####/#...#/#...#/#...# |
A |
| 12 | ####./#...#/#...#/####./#..../#..../#.... |
P |
| 13 | #####/#..../####./#..../#..../#..../##### |
E |
| 14 | 同 9 | S |
| 15 | 同 8 | \_ |
| 16 | 同 7 | R |
| 17 | 同 13 | E |
| 18 | #...#/#...#/#...#/#...#/#...#/.#.#./..#.. |
V |
| 19 | 同 13 | E |
| 20 | 同 11 | A |
| 21 | 同 1 | L |
| 22 | 同 8 | \_ |
| 23 | 同 0 | F |
| 24 | 同 1 | L |
| 25 | 同 2 | A |
| 26 | 同 3 | G |
| 27 | ###../..#../..#../..##./..#../..#../###.. |
} |
拼接后即为:FLAG{XOR_SHAPES_REVEAL_FLAG}
四、总结
| 环节 | 方法 | 说明 |
|---|---|---|
| 数据载体 | SVG <symbol> + <use> |
232 个可复用图元 |
| 信息筛选 | <clipPath> 掩码 |
每个 symbol 只有 6/8 个小方块通过 |
| 位置变换 | translate + matrix |
把小方块散布到横带内,视觉上呈噪声 |
| 隐藏机制 | 重叠覆盖的奇偶(XOR) | 每个 5px 单元覆盖次数 mod 2 |
| 显形方式 | 5×7 点阵字 | 奇数覆盖点亮,拼出文本 |
| 最终结果 | FLAG{XOR_SHAPES_REVEAL_FLAG} |
题目自指:XOR 形状可揭示 flag |
本题的巧妙之处在于:单个 symbol 的图案是噪声,直接渲染也是噪声;只有当多个 symbol 的方块重叠后取覆盖奇偶性,隐藏的文本才浮现。 全程通过纯几何计算即可还原,完全符合题干\"不执行脚本、不依赖截图或 OCR\"的约束。
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。











评论