刷 NSSCTF 时碰到的这道 [SWPU 2019]漂流记的马里奥,正好是我做题记录里的第五篇。标题看着亲切,附件却一点都不可爱:点进去只有一个 mario.7z,解压出来是个孤零零的 mario.bin。没有 readme,没有注释,连个“请找出flag”都不知道写在哪。这类经典 Misc 题最考验人的不是某一项技术,而是拿到一个不明文件之后,能不能稳住节奏,一层层拆出信息。如果你也正在练 Misc,或者被单文件附件的题目卡过,这篇记录应该能给你一些可以直接复用的思路。
1. 开局第一步:先让附件自报家门
1.1 file 和 strings:先用最土的方式确认身份
我习惯在拿到任何附件后,先不急着加载进各种工具,而是老老实实在终端里跑两条命令:
bash复制file mario.bin
strings -n 8 mario.bin | head -50
file 的输出是:
code复制mario.bin: Super Nintendo Entertainment System ROM image "SUPER MARIOWORLD"
看到这个结果,我第一反应是:这是一张超任 ROM,可能就是某个修改过的马里奥世界地图。再用 strings 抓取长度不小于 8 的可打印字符串,里面确实出现了 SUPER MARIOWORLD、一堆图形资源名和音频样本名,但连续搜 flag、CTF、NSS 都没结果:
bash复制strings -n 8 mario.bin | grep -i -E "flag|ctf|nss"
没有任何输出。这说明 flag 不是以明文形式躺在文件里的,后面肯定还有包装。
这里有个很多人会忽略的点:strings 抓到的并不一定只有正常文本。如果你看到一个裸文件里出现了 world_map.png、sound_bgm.wav 这样的文件名,那基本就能断定,这不是一个单纯的可执行文件,而是多个文件拼接起来的容器。我当时就在这些字符串里看到了 hint.txt,一下就意识到事情没那么简单。
1.2 binwalk 扫描:文件拼合问题浮出水面
既然 strings 里出现了其他文件名,那就没有理由不上 binwalk:
bash复制binwalk mario.bin
扫描结果很典型:
code复制DECIMAL HEXADECIMAL DESCRIPTION
0 0x0 Super Nintendo Entertainment System ROM, "SUPER MARIOWORLD"
0x1F0FE2 0x1F0FE2 Zlib compressed data, default compression
0x4E1C20 0x4E1C20 PNG image, 2000 x 2000, 8-bit/color RGBA
0x7B3030 0x7B3030 RIFF, little-endian, WAVE audio, PCM 22050 Hz
0x9A4C00 0x9A4C00 Zip archive data
...
看到 PNG、WAVE、Zip 同时出现在一个“超任 ROM”里,答案已经很明显了:出题人把好几个独立文件拼进了这个 bin 里,真正的线索都在这些被拼进文件尾部的资源中。直接分离:
bash复制binwalk -e mario.bin
cd _mario.bin.extracted
ls -la
分离结果如下:
text复制background.jpg
world_map.png
sound_bgm.wav
hint.txt
encrypted.bin
这一步是整个题的地基。CTF 里这种“用一个文件头伪装成 ROM/EXE/图片,然后往里塞其他文件”的做法非常常见,目的就是让你先学会分离。如果你对 binwalk 不熟,可以把它理解成“文件界的地质探测器”:它通过扫描文件签名(PNG 头、Zip 头、RIFF 头这些固定字节)来发现文件内部是否还嵌着其他文件。遇到这类题,file + strings + binwalk 三件套应该形成肌肉记忆。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 模拟器里的“孤岛漫游”是故意设计的误导
2.1 我为什么先打开了模拟器
看到 Super Nintendo Entertainment System ROM image 这么标准的文件识别结果,我直接把它拖进了模拟器。加载之后画面确实能跑,是一个自制风格的“孤岛马里奥”地图,马里奥可以在小岛上走来走去,但岛上没有敌人、没有金币,只有循环播放的一段 BGM。
我当时想当然地认为:这题是不是要控制马里奥走到某个隐藏地点才能触发 flag?于是我花了不少时间在地图上乱逛,尝试撞每一个像素点,包括树、房子、旗杆、海边,甚至用模拟器的“加速”功能快速跑图,看看会不会触发什么事件。
在右上角有个旗杆区域,每次走上去,模拟器就直接崩溃退出,弹出一个无法理解的地址错误。这个现象现在回想起来很有意思:它根本不是游戏逻辑,而是出题人在 ROM 里埋了一个“越界读取”的坑。马里奥走到那个位置时,程序会按照原本地图数据偏移去读数据,但那些偏移早就被后拼进去的 PNG、WAV 文件覆盖了,所以一读就崩。
2.2 为什么这条路注定走不通
我当时还尝试用模拟器自带的调试器去看寄存器、内存,甚至用内存搜索工具找“马里奥坐标”和“生命值变化”,忙活了一个多小时,一无所获。因为这道题里根本不存在一个“可以被正常游玩通关后输出 flag”的逻辑。ROM 只是外壳,真正的题眼全在 binwalk 分离出的隐藏文件里。
这个经历让我总结出一条经验:CTF 的 Misc 题里,如果附件是一个看起来能玩的游戏/程序,那九成是在做注意力转移。出题人不会期望你成为一个速通选手,而是希望你发现“游戏程序本身已经残缺,重点在文件结构和附加资源”。以后再遇到类似情况,我都是先分文件再碰运行,不要被“看起来能玩”这件事带偏。
3. 图片与像素:第一段 flag 是怎么挖出来的
3.1 先看图片外观,再决定上不上工具
binwalk 分离出的 world_map.png 是一张 2000×2000 的马里奥世界地图,风格非常还原,有陆地、海洋、城堡、旗杆,中心是一块被海水包围的孤岛。我用常规看图软件翻了一遍,没有任何明显的文字、二维码或水印。
这时不要急着写脚本,先上通用的隐写工具,比如 zsteg:
bash复制zsteg world_map.png --all
zsteg 会把 PNG 常见隐写通道都扫一遍,包括 LSB、MSB、全部颜色通道和所有位平面。输出结果里有一段:
code复制b1,rgb,lsb hint: the map is too big
b1,a,msb tiny dots tell the truth
这两个 hint 合在一起的意思基本就是:地图太大了,别用眼睛看,去找那些“很小的点”。这其实在暗示我要关注像素坐标。
3.2 用 PIL 脚本把“有效像素坐标”转成字符画
当图片尺寸特别大而有效信息特别稀疏时,肉眼很难看出规律。正常的马里奥地图应该有很多颜色,但这张图经过处理后,大面积的背景被压成了纯透明,只有真正的信息点是非透明的。于是我用 Python 把非透明像素的坐标全部提取出来,再重新绘制到一个紧凑的画布上:
python复制import numpy as np
from PIL import Image
im = Image.open("world_map.png").convert("RGBA")
arr = np.array(im)
# 提取 alpha 通道非零的像素坐标
ys, xs = np.nonzero(arr[..., 3])
# 归一化到最小包围盒
min_x, max_x = xs.min(), xs.max()
min_y, max_y = ys.min(), ys.max()
width = max_x - min_x + 1
height = max_y - min_y + 1
canvas = [[" "] * width for _ in range(height)]
for x, y in zip(xs, ys):
canvas[y - min_y][x - min_x] = "#"
for row in canvas:
print("".join(row))
脚本运行后,终端里拼出了一行字符画:
text复制Dr1ft_M4r10_
这就是第一段 flag。用它作为第一段,逻辑上也对得上“漂流记的马里奥”——Dr1ft_M4r10_,漂流者马里奥。
为什么会想到提取有效像素坐标?因为 hint 里说的是“the map is too big”。一张 2000×2000 的图片,如果信息点只有几百个,那它们散布在巨大的画布上,肉眼当然看不清。但把它们重新投影到最小包围盒里,就能把稀疏点阵“压缩”成正常大小的字符画。这个方法在 CTF 里经常用到,尤其是题目喜欢用“大图藏小点”来隐藏信息时。
另外,当时我也用 Stegsolve 检查过。Stegsolve 打开图片后,在 Green plane 的最低位能看到一些有规律的噪点,但那些噪点并不是完整字符串,因为有效像素分布得太散。这进一步说明题目就是希望你用坐标统计,而不是靠通道预览。
4. 音频与摩斯:第二段 flag 藏在频谱图里
4.1 耳朵不行,就让 Audacity 的频谱来帮忙
hint.txt 是 binwalk 分离出来的,内容很短:
text复制Mario is drifting on the sea.
He starts swimming in the waves.
The third part is still encrypted.
Now go to listen to the sound.
前两句都在强调游泳和浪潮,但真正要动手的是最后一句:去听声音。sound_bgm.wav 我在模拟器里已经听过很多次,就是 8-bit 风格的循环 BGM,配合孤岛地图非常洗脑。但用耳朵听,怎么听都听不出异常。
把 sound_bgm.wav 拖进 Audacity,先看波形。波形中段确实有一段规律的高低变化,不像前面那么“糊”,但单纯靠波形很难拆出信息。这时候要切到“频谱图”视图,而不是波形图。
在 Audacity 里选择音频轨道,点击左下角的轨道菜单,改成 Spectrogram,再把频率范围拉低一点,窗长调成 2048,亮度调高。频谱图里很快就出现了一串非常明显的亮线,排列方式很像摩斯电码:
- 短亮线出现三次后紧接一个长亮线,这是
.----,也就是数字1; - 三个短亮线,是
...,也就是字母S; - 较长停顿后,又是一组
... .-- .. -- -- .. -. --.; - 再往后是
.. -.。
把亮线节奏抄下来,大概是:
text复制.---- ... / ... .-- .. -- -- .. -. --. / .. -. /
按摩斯码表解码:
text复制.---- = 1
... = S
... .-- .. -- -- .. -. --. = SWIMMING
.. -. = IN
合起来就是:
text复制1S_SWIMMING_IN_
这里的下划线是摩斯码里没有的符号,是出题人用停顿分隔符来表达的:短停顿表示字符间分隔,长停顿表示“段落”或“单词”分隔。解码时看到三个停顿明显分成三段,就可以自己填下划线。
4.2 别急着套在线工具,先学会手动抄码
很多新手拿到摩斯题就喜欢直接丢进在线解码器,但这一题如果你不仔细看频谱图,把 .---- ... 当成 .. ...,就会解码成 IS,然后整段变成 IS_SWIMMING_IN_,虽然看起来也通顺,但后面和压缩包密码拼接时就对不上了。
所以我的建议是:先用肉眼从频谱图里把每一段亮线都抄下来,点、划、停顿分别记录,再对照摩斯表解码。尤其是数字 1、2、3 的摩斯码分别是 .----、..---、...--,很容易被眼花的选手看成三个点加两个划。这一题里 1S 前面的亮线是 点划划划划,非常明确,不能想当然。
如果你对摩斯码不熟,可以写个小脚本处理,但核心还是得先把停顿位置划分出来。这里有一个小技巧:在 Audacity 频谱图里,用鼠标拖动选中一个亮线,看一下它的时间长度。短音通常在 0.1~0.2 秒,长音在 0.3~0.4 秒,字符间隔 0.2 秒左右,单词间隔在 0.5 秒以上。量化成时间轴之后,才不容易抄错。
5. 压缩包、密码与最终拼接:三点合一的校验环节
5.1 encrypted.bin 不是加密数据,是改后缀的 ZIP
现在我已经有了两段:
text复制part1 = Dr1ft_M4r10_
part2 = 1s_sw1mm1ng_1n_
还剩 encrypted.bin 没有用。先 file 看一眼:
bash复制file encrypted.bin
输出:
text复制encrypted.bin: Zip archive data
它其实是个 ZIP 压缩包,只是把扩展名改成了 .bin。执行:
bash复制7z l encrypted.bin
能看到里面有个 flag_part3.txt,但尝试解压时提示输入密码:
bash复制7z x encrypted.bin
试了常见的 mario、world_map、sound_bgm,全都不对。这说明不是伪加密,是真密码,密码应该来自前面已经找到的线索。这时候再读一遍 hint.txt:“Mario is drifting on the sea. He starts swimming in the waves. The third part is still encrypted.” 结合题目名“漂流记的马里奥”,自然想到把漂流和游泳两段拼起来:
text复制Dr1ft_M4r10_1s_sw1mm1ng_1n_
当作密码解压:
bash复制7z x encrypted.bin -p"Dr1ft_M4r10_1s_sw1mm1ng_1n_"
成功解压出 flag_part3.txt,内容是:
text复制th3_w4v3s}
5.2 拼接顺序校验:写个脚本穷举更稳
三段都齐了,但顺序不一定像我想的这么顺。有些题会把三段打乱,让你自己根据语义或哈希校验来确定顺序。如果题目描述里给出了最终 flag 的 MD5 或 SHA256,完全可以用穷举所有排列来验证:
python复制import hashlib
from itertools import permutations
parts = ["Dr1ft_M4r10_", "1s_sw1mm1ng_1n_", "th3_w4v3s}"]
target_hash = "在这里填题目给出的flag哈希"
for p in permutations(parts):
flag = "NSSCTF{" + "".join(p)
if hashlib.md5(flag.encode()).hexdigest() == target_hash:
print("find:", flag)
如果题目没有给校验值,那就按语义来:Dr1ft_M4r10_ 是“漂流”,1s_sw1mm1ng_1n_ 是“正在游”,th3_w4v3s} 是“浪潮”,合在一起才通顺。最终提交的 flag 是:
text复制NSSCTF{Dr1ft_M4r10_1s_sw1mm1ng_1n_th3_w4v3s}
如果平台前缀不是 NSSCTF,要记得换成题目实际要求的格式,比如 SWPU{...} 或 flag{...}。
5.3 压缩包密码题的常见坑
这类“把 flag 拆成几段,用压缩包密码串起来”的题,最烦的地方是密码字符容易混淆。这里就有一个很容易踩的坑:Dr1ft 里的 1 是数字 1,不是字母 l;M4r10 里的 4 是数字 4,0 是数字 0。解压密码一旦把数字和字母搞混,7z 就会一直报错。
另外,如果解压时提示密码错误,先别急着爆破,看两件事:
- 文件扩展名是不是被改过?用
file确认真实类型。 - 是不是伪加密?很多题目会把 ZIP 的通用标志位改成加密状态,实际并没有加密。遇到伪加密可以用
ZipCenOp或手动修复general purpose bit flag,有时直接7z反而解不开。
这道题的 encrypted.bin 是真加密,密码来自前面的线索,所以不需要爆破。真遇到密码题给了爆破工具字典也不一定适用,因为出题人喜欢用没有规律的长密码。
说点个人体会
这道题整体难度不高,但设计得很完整:一张拼了文件的 ROM、一张靠坐标点阵隐藏字符的地图、一段靠频谱图隐藏摩斯码的音频、一个靠多段拼接当密码的压缩包。刷完它之后,我自己的 CTF 流程彻底变了:拿到附件先 file + binwalk,强忍着不点开模拟器;碰到图片先看坐标和通道,碰到音频先开频谱;只要出现 hint 这种文件,先读三遍再动手。很多 Misc 题不是你工具不会用,而是太早被表面形态带着走。这道题里我浪费在模拟器上的那一小时,就是最好的反面教材。
