☰
BUUCTF不一样的flag逆向解析:5×5迷宫与BFS最短路径
2026/10/1 16:55:19 网站建设 项目流程

BUUCTF 上那道叫「不一样的flag」的逆向题,名字起得挺欠揍——它确实和别的 flag 题不太一样。我第一次打开它的时候,脑子里默认这是一道算密钥、解校验的常规逆向,结果 F5 出来的伪代码里一个加密函数都没有,只有一串很眼熟的字符常量和一个循环读输入的骨架。折腾了二十来分钟才反应过来:这题的本质是让你在一个 5×5 的网格里走迷宫,走通了才拿得到 flag。它考的不是密码学、也不是算法功底,而是你能不能在反编译结果里一眼认出「这是一张地图」。这篇就把我完整的分析链路摊开讲,包括怎么从 25 个字符判断出网格尺寸、1/2/3/4 四个方向码分别对应什么、为什么最短路径是 12 步、以及我在输入和展平顺序上踩过的几个坑。如果你刚接触 BUUCTF 的 reverse 或者 misc 分区,这类「伪输入、实迷宫」的题会反复出现,套路摸清之后基本是白送分。

1. 拿到题目先别急着按 F5,三十秒定型比什么都重要

1.1 用 file 和 strings 先把题的「性格」摸清

很多人拿到二进制第一反应就是拖进 IDA 等自动分析,其实更划算的做法是先花半分钟做三件事,把题目分类。第一件是file,看架构和链接方式;第二件是strings,看有没有明显的提示语、格式串和可疑长字符串;第三件是有没有加壳,upx -d试一下或者看节区名正不正常。这三步做完,题目属于哪一类基本就有数了。

这道题我这边file出来的结果是 32 位的 ELF 可执行文件,没有加壳,也不用脱壳。strings就比较有意思了,能直接翻到几句很像人话的提示,通常是一句让你「选择前进方向」之类的引导语,还有那个长度 25、由*、1、0、#四种符号拼成的字符串常量。这四种符号的存在本身就是巨大的信息量——真实的加密程序里不会出现这么干净的字符集。

顺序求解成本太高了,先把这些线索记下来,再去反编译,你至少知道自己在找什么。我见过不少人字符串扫一眼就跳过,然后在 IDA 里对着几千行伪代码瞎翻,最后卡在同一个地方。

1.2 为什么这类题静态分析比动态调试更划算

逆向题有两套常用打法:静态看伪代码,动态挂调试器。这道题两者都能做,但我更推荐先静态。原因是它的逻辑结构非常浅——读一个字符、改一次坐标、判一次边界,没有任何间接跳转、没有反调试、也没有花指令。这种「一层套一层但很薄」的逻辑,用 F5 出来的伪代码读一遍,比在 gdb 里单步十来分钟要快得多。

但静态分析有个前提:你得相信编译器把结构保留了。这道题恰好保留了。伪代码里能清楚看到数组、循环、switch 或者连续的 if-else 分支。要是遇到被高度优化、结构被打散的样本,那还是得动态配合,在关键地址下断点看寄存器和内存。判断依据很简单:伪代码里如果还认得出「数组」「循环」「比较」,就先静态;如果全是位运算和跳转表,再考虑上调试器。

还有一个实际的好处:静态看完之后你已经在脑子里建好了模型,再去动态验证的时候,是带着明确问题去的——比如「我要看它读第 12 个字符时坐标是多少」。带着问题调试和没头没脑单步,效率差着好几倍。

2. 反编译结果里最值得盯的三处:25 个字符、两组边界、四个方向

2.1 那个 25 字节的字符串常量是怎么暴露成迷宫的

反编译出来以后,我最先盯住的就是那个字符串常量:

*11110100001010000101111#

这串东西有 25 个字符,字符集只有四种:*、1、0、#。判断它是迷宫的推理链其实很短,就三步。第一步,25 是个特殊的数,25 = 5×5,而且它刚好是完全平方数,很多二维网格的展平长度都会落在平方数上;第二步,字符集这么小,说明每个符号只承担一种「地形」含义,真实数据不会这么规整;第三步,后面的代码会对下标做边界检查,而不是对自由增长的指针做检查,说明长度是写死的。

把这三条串起来,「这是一个 5×5 的二维网格,用一维数组按行优先展平存放」这个结论就站得住脚了。我建议你以后遇到类似的结构都先做一次这样的推理,而不是直接猜。猜对了是运气,推出来才是能力,而后者可以复现。

2.2 两组边界检查把网格尺寸钉死了

真正让我百分百确认尺寸的,是后面那几行越界判断。伪代码里会看到类似这样的比较:某个变量小于 0 或者大于 4 就判定失败、直接返回。这个0和4就是关键证据——合法下标区间是 0 到 4,一共五个值,对应五行或五列。

如果网格是 5×5,那么行下标有五个合法值、列下标也有五个合法值,越界检查自然就是0..4。如果换成 10×10,这里出现的就会是9。所以边界常量是网格尺寸的直接指纹,看到4就该想到 5,看到9就该想到 10。

顺带一提,展平成一维之后的下标范围是 0 到 24,但代码里通常不会直接写24,因为它把「行」和「列」当成两个独立变量在维护,最后才换算成一维下标去取值。这也是为什么认清这时候的变量语义这么重要:如果不搞清楚哪两个变量是坐标、哪个是下标,后面读逻辑会一直别扭。

2.3 1/2/3/4 四个分支背后的坐标偏移

接下来是输入处理部分,这也是整道题的题眼。还原出来的核心逻辑大致是这样:

char maze[] = "*11110100001010000101111#"; int row = 0, col = 0; char c; for (int i = 0; i <= 11; ++i) { scanf("%c", &c); if (c == '1') row -= 1; // 上 else if (c == '2') row += 1; // 下 else if (c == '3') col -= 1; // 左 else if (c == '4') col += 1; // 右 if (row < 0 || row > 4 || col < 0 || col > 4) return 0; // 出界 if (maze[row * 5 + col] == '1') return 0; // 撞墙 } if (maze[row * 5 + col] == '#') { /* 到达终点 */ }

这里是按我当时的分析整理出来的骨架,具体变量名和写法各家版本会有差异,但结构就是这样。四个分支做的事情非常清晰:两个改行号、两个改列号,加减各一。这种「上下左右各一个分支」的形态,是方向控制题的典型特征。

判断哪个数字对应哪个方向,不能靠翻译习惯,得看偏移的符号。行号减一意味着往上走(行号从 0 开始,越小越靠上),行号加一往下走,列号减一往左,列号加一往右。这个对应关系一旦搞反,后面算出来的路径就完全对不上。

3. 把字符串还原成一张能看懂的网格

3.1 行优先展平与坐标换算

要把那 25 个字符还原成 5×5 的图,先得确定展平顺序。绝大多数 C 语言里手写的二维数组都是行优先的,也就是先把第一行存完,再存第二行。对应关系是:

一维下标 = 行号 * 5 + 列号 行号 = 一维下标 / 5 列号 = 一维下标 % 5

按这个规则把字符串切开,5 个一组,就得到下面这张图:

行\列01234
0*1111
101000
201010
300010
41111#

画出来之后,整道题瞬间就变得直观了。*在左上角,是起点;#在右下角,是终点;0是能走的通道,1是墙。你甚至不需要写代码,拿眼睛看就能发现通路:从左上角往下走到底,再往右拐,往上穿出去,再往右走到最右列,最后一路往下撞到#。

3.2 四个符号的含义约定

符号含义最好列成表固定下来,避免后面来回摇摆:

符号含义遇到时的后果
*起点初始位置,坐标 (0, 0)
#终点走到这里即通关
0可通行正常移动,什么都不发生
1墙判定失败,程序直接退出

这里有个特别容易翻车的点:1是墙,0是路,而不是反过来。直觉上很多人会觉得「1 代表有、代表通」,但在这道题里恰好相反。判断依据不在符号本身,而在代码:伪代码里判断失败的条件是「当前位置的值等于1」,说明踩到1就是撞墙。这个条件也顺带把0的含义定死了。

3.3 手工推演一遍和写脚本跑一遍,结果必须一致

我的习惯是两边都做。先手工从 (0, 0) 开始走一遍,把每一步的坐标和方向记下来;再写个脚本搜一遍,看最短路径是不是同一串。这道题手工走的结论是:

  • 从 (0,0) 往下到 (1,0),再往下到 (2,0),再往下到 (3,0)
  • 右移到 (3,1)、再右移到 (3,2)
  • 往上到 (2,2)、再往上到 (1,2)
  • 右移到 (1,3)、再右移到 (1,4)
  • 往下到 (2,4)、(3,4)、(4,4),撞上#

一共 12 步。你会发现这张图里其实没有岔路,每个可通行的格子在排除回头路之后只剩一个前进方向,相当于一条「独木桥」。这也是为什么手工推和脚本跑必然一致——不存在多条等长路径的情况。

4. 用 BFS 把 12 步最短路径算出来

4.1 为什么这题值得专门写一段搜索脚本

有人会说,独木桥迷宫手推就行了,写脚本是脱裤子放屁。这话在本题成立,但放在这个题型上不成立。原因有两点:一是变体题目里网格会变复杂,出现真正的岔路,靠眼睛找路径会开始出错;二是手工推的时候你很容易忽略「哪些格子是真通、哪些只是看着通」,而脚本会严格执行规则,不会自我说服。

更实际的原因是,脚本一旦写好,它就是模板。以后遇到 10×10、15×15 的同类题,你只需要改两个常量:字符串和边长。这种能复用的工具,写一次赚很多次。我自己的习惯是把这类网格题的解法和还原逻辑写在同一个脚本文件里,随手就能改。

4.2 可直接抄的 Python 求解脚本

下面这段是我后来整理出来的通用版本,改动点只有最上面三行:

from collections import deque maze = "*11110100001010000101111#" # 从二进制里抄出来的字符串 N = 5 # 网格边长 WALL, START, GOAL = '1', '*', '#' # 方向顺序必须和程序里的 1/2/3/4 严格对齐 # 上(-1,0) 下(1,0) 左(0,-1) 右(0,1) DIRS = [(-1, 0), (1, 0), (0, -1), (0, 1)] CODE = ['1', '2', '3', '4'] sr, sc = divmod(maze.index(START), N) q = deque([(sr, sc, "")]) seen = {(sr, sc)} while q: r, c, path = q.popleft() if maze[r * N + c] == GOAL: print("path =", path, "| steps =", len(path)) break for k, (dr, dc) in enumerate(DIRS): nr, nc = r + dr, c + dc if not (0 <= nr < N and 0 <= nc < N): # 出界 continue if maze[nr * N + nc] == WALL: # 撞墙 continue if (nr, nc) in seen: # 走过就不回头 continue seen.add((nr, nc)) q.append((nr, nc, path + CODE[k]))

跑出来是path = 222441144222,步数 12,和手工推的完全吻合。这段代码里有三个细节值得单独说:divmod用来把一维起点下标拆成行列坐标;DIRS的顺序必须和题里的方向码一一对应;seen集合用来避免走回头路,否则在通道里会来回横跳,队列永远不会空。

4.3 把搜索出来的路径翻译成提交用的 flag

脚本输出的222441144222就是方向序列:三个下、两个右、两个上、两个右、三个下。这串数字本身就是答案的内容,按平台的格式包一层,提交的就是:

flag{222441144222}

这里我要强调一句:这道题的 flag 内容就是路径序列,不是程序运行时打印出来的什么字符串。程序只是校验你走没走到终点,它并不会把答案告诉你。所以「跑一遍程序看输出」这种做法在这题上是死路,必须自己把路径解出来。

验证的方式很简单,把222441144222一行敲进去,程序不再报错退出,说明路径正确。如果中途撞墙或者出界,它会在对应的判断处直接返回,什么都看不到——这也是为什么很多人第一次跑的时候以为程序「没反应」,其实是自己第一步就走错了。

5. 我在输入、符号和展平顺序上踩过的三个坑

5.1 把 0 和 1 的角色搞反,直接走进死胡同

这是我最开始犯的错。凭直觉把1当成通路、0当成墙,画出来的图看着也挺像回事,但一搜就发现起点被1包住,根本出不去。当时的反应是「这题是不是有别的机关」,又回去翻了一遍伪代码才醒悟。

教训很直白:符号的含义永远由代码判断决定,不由符号长什么样决定。程序判失败的那一侧就是障碍,剩下那侧才是通路。以后遇到任何带符号网格的题,先去代码里找「什么情况算失败」,这一句话就把所有符号的语义定死了。

5.2 scanf 读字符时把换行也吃进去

第二个坑跟输入方式有关。这类题的读入通常是scanf("%c", &c),一次读一个字符。问题在于,终端输入是带缓冲的,你敲的换行符也是字符,也会被读走。如果反编译出来是固定次数的循环(比如明确读 12 次),那么你每敲一个数字就按一次回车,换行符就会占掉一次读取机会,实际有效方向变少,坐标自然对不上,最后停在半路上。

处理办法有两种。第一种最省事:把 12 个字符在一行里连续敲完,最后再回车,这样缓冲区里前 12 个字符全是有效方向。第二种是通过管道喂进去,比如把路径写进文件再重定向给程序,完全绕开交互输入。我一般用第二种,因为可重复、不出错,也方便写进笔记里。

另外提醒一句,如果反编译显示的是while无限循环加别的退出条件,那换行符只是被忽略掉,不会造成错位,这种情况就不用管。判断依据还是看循环结构,别一概而论。

5.3 行列展平顺序搞错,路径完全对不上

第三个坑是展平顺序。我一开始按「列优先」切字符串,画出来的图和程序里的完全不是一回事,位置全乱。之所以能发现,是因为画完之后起点*跑到了左下角,而程序里初始化坐标是(0, 0),两者矛盾。

所以有个很实用的自检手法:画完图之后,先检查起点和终点的位置是否和初始化坐标一致。程序从(0, 0)开始,那*就必须在左上角;如果画出来*在别处,说明展平顺序或者边长猜错了。这一个检查能拦掉绝大多数低级错误,比反复读伪代码快得多。

6. 这类「伪输入、实迷宫」题目的通用识别与迁移

6.1 从几个特征快速判断是不是迷宫题

做多了之后,识别迷宫题基本靠几个特征组合,几乎不会看错。首先是字符集小,网格题的地形符号通常只有三到四种,而且反复出现;其次是长度是完全平方数,25、100、225 这类;再次是出现成对的坐标边界检查,0..N-1的区间判断往往成对出现;最后是输入处理里有一组「各改一个坐标」的分支。

只要看到其中三条,基本可以往迷宫方向想。这时候正确的动作不是继续往下读代码,而是先把字符串还原成图,因为图一旦画出来,剩下的逻辑就变得一目了然。很多时候你会发现连代码都不用全看懂,光看图就知道该怎么走。

6.2 脚本模板的通用化和变体应对

我后来把求解脚本抽象成了一个通用版本,核心就三个参数:网格字符串、边长、方向码到偏移量的映射。方向码映射是最容易随题目变化的部分,有的题用w/a/s/d,有的用8/2/4/6,有的干脆用数字 1 到 4 但方向顺序不一样。所以每拿到一道新题,第一件事是去伪代码里确认方向码表,而不是凭经验套。

还有一个变体是多层或者非线性网格,那就要把状态从(r, c)扩展到(layer, r, c),搜索框架不变,只是状态多一维。真正需要换思路的是「带权重」或「需要按特定顺序踩点」的类型,那已经超出普通迷宫的范畴,得按具体规则改搜索策略。但只要底层是「网格 + 移动 + 合法判定」这三件套,前面那套分析方法就一直有效。

最后再分享一个小习惯:做完这类题之后,我会把原始伪代码里的关键片段和求解脚本贴在同一个笔记文件里,标清楚方向码表。下次遇到长得像的题,先翻笔记对一下方向码,能省掉好几次白推。这个习惯看起来不起眼,但在我做过的十几道同类题目里,至少帮我少走了三四次回头路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询