☰
CTF逆向迷宫题实战:从IDA静态分析到BFS自动求解路径
2026/10/17 5:06:41 网站建设 项目流程

第一次看到“启航杯”CTF里那道 Labyrinth 时,我下意识以为是个 pwn 题,毕竟“迷宫”这名字太容易让人联想到堆和栈。结果拖进 IDA 一看,整个人就愣住了——一个超大数组、一个坐标变量、一个 switch 分支,标准的 reverse 迷宫题。后来我用 BFS 脚本十几秒跑出路径,提交 flag 的那一刻才意识到:这类题目对逆向新手来说,几乎是性价比最高的练手题型。

这篇笔记就是我当时完整踩坑、复盘的实战记录。整个流程覆盖了“识别迷宫题 → 静态定位地图与逻辑 → gdb 动态验证 → 脚本自动求解路径 → 提交验证”五步。如果你刚接触 CTF 逆向,或者已经能看懂伪代码但一碰数组就头大,这篇文章应该能帮你把“迷宫题”这条技能树一次性点通。

1. 拿到题目先别急着逆:先认题型再定策略

1.1 一眼认出迷宫题的四个特征

老手拿到题目通常会先花两分钟做“题型鉴定”,而不是直接闷头看汇编。Labyrinth 这类迷宫题在二进制里其实有明显指纹,你只要对号入座,基本不会认错。

第一个特征:文件常规。用file命令看,通常是一个 64 位 ELF,带符号或不带符号都有可能,但基本不会加壳。遇到加壳的迷宫题不是没有,但对新手题来说概率很低。

第二个特征:提示字符串非常直白。用strings扫一遍,你会看到类似input your path:、wrong!、you win!这种字符串。如果同时出现“you win”和“wrong”,那这道题十有八九是“输入校验型”的逻辑题,迷宫题就是其中最常见的一种。

第三个特征:数据段里有一个体积明显异常的数组。在 IDA 里打开数据段,如果发现一个几百甚至几千字节的连续数组,而且里面的值基本都在 0 到 3 之间徘徊,那它大概率就是迷宫地图。

第四个特征:主函数里有坐标变量和方向分支。伪代码里能看到x++、y--之类的操作,或者一个对输入字符做分发的switch,里面是'w'、'a'、's'、'd'这样的分支。这一套组合拳打下来,迷宫题就基本锁定了。

1.2 为什么说 Labyrinth 这种题特别适合新手

CTF 逆向的题型库里,迷宫题属于“看着唬人、拆开简单”的典型。它不涉及虚拟机保护,没有花指令,也不用你逆向复杂的加密算法。核心考点就三个:能不能找到地图、能不能看懂方向逻辑、能不能写脚本走出迷宫。

这三点恰好在新手的能力边界上。你要是完全没写过脚本,刚好借这道题练一练 BFS;你要是只会对着 IDA 看伪代码,刚好借这道题把 gdb 动态调试捡起来。换句话说,Labyrinth 这种题目就是把“静态分析、动态调试、算法编码”三个基本功一次性串起来,而且串得明明白白。

另外,迷宫题的反馈极其直观。你输入的路径对不对,程序直接告诉你wrong还是you win,不需要你去逆复杂的状态机。这种“即时反馈”对新手建立信心特别重要。

1.3 整体解题五步法预览

我这次实战下来,总结出一个可以复用的流程,后面每道迷宫题我都按这个顺序走:

  1. 收集运行线索:file、strings、跑一遍程序,确认题目类型。
  2. 静态定位:用 IDA 或 Ghidra 找 main 函数,定位地图数组、起点、终点和移动逻辑。
  3. 动态验证:用 gdb 在关键函数下断点,确认静态分析得出的地图和坐标没有看反。
  4. 算法求解:把地图提取出来,写 BFS 求最短路径。
  5. 提交验证:把路径字符串输入程序,拿到 flag。

这套流程的核心思路是“先静态、后动态、再自动化”,每一步都在修正上一步的误差。新手最容易犯的错误就是跳过动态验证,直接从静态分析里抓一个数组就去写脚本,结果行列反了或者起点找错,白白浪费时间。

2. 静态分析:把坐标、地图和校验逻辑理清楚

2.1 第一步永远是跑一下程序

不管题目看起来多复杂,我都建议先亲手运行一次。这一步能获得的信息量远超你的想象。

我当时做的是:

$ file labyrinth labyrinth: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64, for GNU/Linux 2.6.32, BuildID[sha1]=..., not stripped

没 stripped,好事,说明函数名还在,静态分析难度直接降一档。接着运行:

$ ./labyrinth Welcome to the Labyrinth! Please input your path:

随便输入一串字符,程序输出wrong!然后退出。到这里我已经确定:这就是一个“输入路径字符串 → 程序内部模拟走迷宫 → 判断是否到达终点”的标准结构。输入的内容会被当作方向指令逐字符处理。

2.2 用 IDA 看 main 的伪代码,抓住三件套

用 IDA 打开后,F5 一下就能看到 main 的伪代码。这里我简化出一个典型结构,你会发现所有迷宫题的核心逻辑都长这样:

int __cdecl main(int argc, const char **argv) { char s[128]; int x = 0; // 起点行 int y = 1; // 起点列 unsigned int i = 0; puts("Welcome to the Labyrinth!"); puts("Please input your path:"); gets(s); while ( i < strlen(s) ) { switch ( s[i] ) { case 'w': --x; break; case 's': ++x; break; case 'a': --y; break; case 'd': ++y; break; default: puts("invalid direction"); return 1; } if ( x < 0 || x >= 8 || y < 0 || y >= 10 || map[x][y] == 1 ) { puts("wrong!"); return 1; } ++i; } if ( x == 7 && y == 8 ) puts("you win! flag is ..."); else puts("wrong!"); return 0; }

这个伪代码就是整道题的题眼,三件套一目了然:

第一是地图数组map,它决定了哪些位置能走,哪些位置是墙。第二是坐标变量x和y,它们记录了玩家当前在迷宫中的位置。第三是校验逻辑,包括越界检查、撞墙检查和终点检查。

有一点要特别注意:这里的x到底是行还是列,取决于代码里map[x][y]的索引顺序。如果数组定义是map[8][10],那x的范围是 0 到 7,对应行;y的范围是 0 到 9,对应列。如果看反了,后面整个地图都会颠倒。

2.3 把地图数组导出来,转成肉眼能看的网格

找到map数组之后,千万别在 IDA 里盯着十六进制数坐标,那是自己折磨自己。正确做法是把数组数据复制出来,用脚本转成二维网格。

我当时写了个简单 Python 脚本,把 IDA 里导出的 short 数组一次性解析出来:

# 从 IDA 导出的一段数据,以逗号分隔 data = [1,0,1,1,1,1,1,1,1,1, 1,0,0,0,0,0,0,0,1,1, 1,1,1,0,1,1,1,0,1,1, 1,0,0,0,1,0,0,0,1,1, 1,0,1,1,1,0,1,0,1,1, 1,0,1,0,0,0,1,0,0,1, 1,0,1,0,1,1,1,1,0,1, 1,0,0,0,0,0,0,0,0,1] rows, cols = 8, 10 for i in range(rows): line = ''.join('#' if data[i*cols + j] == 1 else ' ' for j in range(cols)) print(f'{i:2d} {line}')

输出结果类似:

0 # # # # # # # # # <- 这里我用了空格表示通路,实际输出自己看

当然,这只是我为了演示构造的一个小地图。实际上 Labyrinth 题里的地图可能大得多,30×30 甚至更大都有可能。但套路是一样的:把数组转成0/1网格,然后你就能直观地看到迷宫长什么样。

这里还有一个容易忽略的点:数组的类型。有的题目用char存地图,有的用short,有的甚至用int按位存墙和路。你导出数据时一定要注意 IDA 里显示的类型,否则脚本解析出来的网格完全是乱的。判断方法很简单:看数组名旁边的类型定义,如果是__int16,那每个元素占 2 字节,解析时要按 short 切分。

3. 动态调试:让程序替我们把地图“吐”出来

3.1 用 gdb 下断点,确认坐标和撞墙逻辑

静态分析只能给你“大概对”的结论,真正要想确认逻辑没看反,还得让程序自己跑起来。我用 gdb 在撞墙检查的位置下断点,然后手动输入方向串,观察坐标值的变化。

操作过程大概是这样的:

$ gdb ./labyrinth (gdb) b *0x4011a7 # 地址从 IDA 里抄过来,对应撞墙判断 (gdb) run Starting program: /home/test/labyrinth Welcome to the Labyrinth! Please input your path:

程序停在这里等输入。我输入一个d,也就是往右走一步,然后回车。断点触发后,查看当前坐标:

(gdb) p x $1 = 0 (gdb) p y $2 = 2

这说明起点是(0,1),输入d后y变成了2,列坐标 +1。反过来,如果输入s,x应该 +1。这样一分钟就能验证方向映射有没有搞错。

提示:在 gdb 里打p的前提是程序带调试信息,或者你手动用set var指定符号地址。如果符号被 strip 了,就先用info address找全局变量地址,再用p *(int *)地址。

3.2 在内存中直接导出完整地图

静态分析时从 IDA 复制数据可能会抄错,而且如果地图特别大,手动复制根本不现实。这时候我更喜欢直接在 gdb 里把地图数组从内存里 dump 出来。

先找到地图数组的地址。如果程序没有 strip,直接:

(gdb) p &map $3 = (short (*)[10]) 0x404020

然后按字节把整个数组导出:

(gdb) dump binary memory map.bin 0x404020 0x404020+160

这里我算的是 8 行 × 10 列 × 2 字节 = 160 字节。如果地图更大,就按实际行列数算。导出后用 Python 按 short 解析,得到的就是程序真正运行时使用的内存数据,比从 IDA 抄出来的靠谱得多。

我个人习惯是把“gdb 导出内存”当成标准操作,因为它在面对大型地图时特别省心。有的题目地图是运行时生成的,静态分析根本看不到完整数据,这时候 gdb 几乎是唯一的选择。

3.3 手动喂一个方向串,观察程序反馈

还有一个很简单的动态验证技巧:在真实程序里输入几条已知的路径,看程序是输出wrong还是继续等待输入。比如你知道起点在(0,1),如果输入d能让坐标走到(0,2),那再输入一个w就一定会越界报错。这种“试错法”虽然笨,但对新手来说特别直观。

如果程序支持连续输入,你会发现走一步就校验一次。这意味着你不能像某些题那样“最后才检查路径”,每一步都不能撞墙。这是迷宫题里最容易踩的坑:BFS 求出来的路径如果包含撞墙动作,程序会立刻把你踢出去。

我在实际调试时还发现,某些迷宫题对方向键位的映射不是wasd,而是hjkl甚至数字键。这种变种不多,但一旦遇到,动态调试就是唯一能快速确认映射关系的手段。

4. 用 BFS 代替手工走迷宫

4.1 地图预处理:把原始数组变成可遍历的图

地图转成二维列表之后,接下来要做的就是把“迷宫网格”抽象成“图”。每个坐标是一个节点,相邻的上下左右四个方向如果都是通路0,就有一条边。这是 BFS 的标准输入。

我用 Python 处理时,会把地图包一层边界,避免判断越界写得啰嗦。预处理代码如下:

def load_map(data, rows, cols): # 外层加一圈 1,相当于墙体,这样 BFS 时不用单独特判边界 grid = [[1] * (cols + 2) for _ in range(rows + 2)] for i in range(rows): for j in range(cols): grid[i + 1][j + 1] = data[i * cols + j] return grid

这一步看起来简单,但实际上能帮你省掉大量if x < 0 or x >= rows这种边界判断。迷宫题里很多新手写 BFS 报错,就是因为忘记处理边界,导致路径里出现了“走出地图”的动作。

4.2 BFS 核心代码:最短路径是怎么来的

BFS 求最短路径的思路很简单:从起点开始,逐层向外扩展,第一次到达终点的路径一定是最短的。这里的关键不是“怎么保证最短”,而是“怎么把路径记录下来”。

用parent字典记录每个节点是从哪个节点走过来的,等终点被访问到时,从终点一路回溯到起点,就可以还原完整路径。

from collections import deque def bfs(grid, start, end): rows = len(grid) cols = len(grid[0]) visited = [[False] * cols for _ in range(rows)] parent = {} q = deque() q.append(start) visited[start[0]][start[1]] = True directions = [(-1, 0), (1, 0), (0, -1), (0, 1)] while q: cur = q.popleft() if cur == end: break for dx, dy in directions: nx, ny = cur[0] + dx, cur[1] + dy if grid[nx][ny] == 0 and not visited[nx][ny]: visited[nx][ny] = True parent[(nx, ny)] = cur q.append((nx, ny)) if end not in parent and start != end: return None # 回溯路径 path = [] cur = end while cur != start: path.append(cur) cur = parent[cur] path.append(start) path.reverse() return path

注意一个细节:字典parent只在第一次访问节点时记录,所以 BFS 天然保证到达终点时用的是最短路径。如果你用 DFS,可能绕一大圈才到终点,而且迷宫大的时候递归深度能把栈炸了。

另一个细节是起点等于终点的情况,虽然迷宫题里基本不会出现,但写 BFS 时顺手处理掉总没错。

4.3 方向指令映射:把坐标路径翻译成字符串

BFS 得到的是坐标序列,比如(0,1) -> (1,1) -> (1,2),程序真正要的是s d这样的方向串。所以最后一步是把相邻坐标的差值翻译成方向字符:

def path_to_direction(path): dir_map = { (-1, 0): 'w', (1, 0): 's', (0, -1): 'a', (0, 1): 'd', } result = [] for i in range(len(path) - 1): dx = path[i + 1][0] - path[i][0] dy = path[i + 1][1] - path[i][1] result.append(dir_map[(dx, dy)]) return ''.join(result)

然后把它和前面的 BFS 串起来:

path = bfs(grid, (1, 1), (8, 9)) # 注意这里因为外层加了一圈边界,起点终点坐标都要 +1 if path: route = path_to_direction(path) print(route)

我用前面那个 8×10 示例地图跑了一遍,输出类似sddddddssaassaassddddd。把这个字符串交给程序,它会直接打印you win!和 flag。

这里有个新手容易想不通的地方:为什么 BFS 找到的路径就一定是程序认可的路径?其实程序不关心你走的是不是最短路径,它只关心你有没有撞墙、有没有走到终点。但最短路径有一个额外好处——如果题目限制了步数上限,最短路径是最有可能在步数限制内到达终点的选择。

我建议在写脚本时把起点、终点也打印出来,方便对照 IDA 里的坐标。如果你发现 BFS 输出路径但程序报错,先检查起点终点是不是带偏移了,这是最常见的低级错误。

5. 常见问题与排查技巧实录

5.1 地图行列颠倒,路径完全对不上

这是我第一次做迷宫题时踩的深坑。我在 IDA 里看到map[x][y],就默认 x 是行坐标,结果导出的地图横竖都是反的,BFS 算出来的路径五花八门,提交给程序永远报错。

排查思路很简单:找一个明显的地图特征,比如起点附近的通路分布。用 gdb 观察步进时x和y的变化,确认哪个是行、哪个是列。如果x范围是0..7,而y范围是0..9,那x是行没错。如果反过来,你就要把地图转置再跑 BFS。

5.2 起点和终点找错了

很多迷宫题不是从(0,0)出发,终点也不在右下角。我见过起点在左上角、终点在地图中央的题;也见过起点写死在数组下标里的题。新手容易被惯性思维带偏,默认起点(0,0)、终点(rows-1, cols-1),结果怎么看怎么不对。

解决方式很机械:在伪代码里找x = 0; y = 1;这样的初始赋值,这就是起点;找最后的比较if (x == 7 && y == 8),这就是终点。不要猜,用 IDA 说话。

5.3 输入里混入了换行或空格

有一次我辛辛苦苦算出了路径,结果程序直接报错。后来发现是gets会把回车换行也算进输入长度,但我的脚本生成的路径串末尾带了个\n。CTF 题目里提交路径的时候,大家一般不会手动输入,而是用管道让程序自己读入:

$ python3 solve.py | ./labyrinth

这时候如果脚本额外打印了换行,就可能被当成一个非法字符。所以尽量用sys.stdout.write(route)而不是print(route),避免多出一个换行。不过有时候gets会保留换行但不在 switch 分支里,只在步数统计时多算一个字符。具体要看题目代码怎么写,反正先试试“严格不带换行”的方案就对了。

5.4 程序报错但路径看起来没错:注意越界逻辑

还有一种隐蔽的坑是“边界检查”的顺序。有时候程序先检查map[x][y] == 1,再检查越界,此时如果你的路径走到地图边缘的外侧,就会访问到相邻内存里的随机值,可能错误地判定为通路或墙壁。这类问题你从静态分析里很难看出来,只有在 gdb 里单步跟踪才能发现。

我那次踩坑的解决办法是:在 BFS 里给地图四周加一圈墙壁,确保路径绝对不会走出地图边界。这既符合多数程序的实际逻辑,也避免了越界访问带来的不确定性。

5.5 拿到 flag 的姿势不对

最后一步也别翻车。有的题目会在程序里直接打印 flag,有的题目要求把路径字符串本身作为 flag 提交到比赛平台。我当时在这上面也耗了几分钟,因为光顾着跑通程序,忘了看题目描述里的提交格式。

另外,如果程序打印的是类似flag{...}的字符串,直接复制即可。如果只打印了you win!,那就说明 flag 可能是由路径字符串做某种编码得到的,你需要找一下程序里对路径做 hash 或拼接的逻辑。这类变种在进阶迷宫题里很常见,新手阶段能识别出来就够了。

6. 我这道题跑完后的几点心得

先把结论放前面:迷宫题是 CTF 逆向里最值得新手反复练习的题型之一。它不考偏门知识,但能把读伪代码、数组定位、gdb 调试、脚本编码全串起来,而且反馈极其干脆。

我个人在实际调试中最深的体会是:千万别相信自己的眼睛,对话框里的数组和你脚本里解析的数组可能是两个世界。静态分析定位到地图后,一定要用 gdb 从内存里再印证一次。坐标方向、起点终点、数组类型,这三个点只要错一个,后续全白干。我甚至养成了一个习惯:写 BFS 脚本前,先故意输入几个错误路径给程序,确认程序的“撞墙反馈”和我的地图是一致的,再开始跑自动求解。

还有一个非常实用的小技巧:如果地图特别大,先别急着写 BFS。用脚本把地图输出成可视化的#和空格网格,用眼睛扫一遍迷宫的总体形状。很多时候题目会在视觉上留一个明显的“通道”,你可以先手动走通一小段,验证地图方向,再让 BFS 接手整体路径。这个习惯帮我避免了好几次大方向错误。

后续如果你想继续深入,可以试试给这个脚本加上“多起点”“多终点”的适配,或者把方向键位做成参数化配置。等哪一天你遇到一个没有wasd、没有固定地图、甚至地图藏在函数里动态生成的迷宫题,就会知道当初把基础流程练扎实有多划算。

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

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

立即咨询