简介:Brainfuck及其变体Ook是CTF赛事中常见的极简编程语言,选手常需将其解码为可读文本,但比赛环境经常无法访问在线解码网站。这套离线解码工具正是为解决这一痛点而设计,主要面向CTF参赛者、安全竞赛选手以及对异种编程语言有兴趣的开发者。核心脚本ook.py在GitHub项目基础上做了优化,支持Brainfuck、Ook和Short Ook三种格式的解析,并附带三种编码格式的测试样例文本,方便立即验证输出效果;同时提供一张运行示例截图,帮助使用者快速掌握调用方式。资源共5个文件,由txt文本、Python脚本和png图片组成,压缩包仅13KB,轻量且便于拷贝到比赛环境使用。已有7830人学习下载,适合在离线环境下需要迅速解码此类编码的选手参考使用。 这些年搞CTF、做逆向、抓协议,我发现自己总绕不开两个奇怪的东西:一个是看起来像是猴子在打字的Brainfuck代码,另一个是名字里同样带OOK、但完全是另一个次元的无线调制方式。很多人第一次见到Ook!的时候,第一反应是"这玩意儿真的是代码吗",而第一次在逻辑分析仪上看到OOK波形的时候,又会疑惑"这一坨方波到底哪里是0哪里是1"。我最早接触Brainfuck是在某个CTF的逆向题里,题目给了一堆Ook. Ook? Ook!,当时我还傻乎乎地打算硬看,后来发现这东西根本就不是给人读的,老老实实写脚本才是正路。这篇就聊聊Brainfuck和Ook!解码脚本怎么写,顺便把OOK波形里怎么判断0和1这件事一次讲透。
这两个东西名字撞了,但方向完全不一样,如果搜资料的时候混在一起看,很容易越看越糊涂。我尽量把解码思路、脚本实现、波形判断这些操作层面的东西掰开揉碎讲清楚,适合正在啃CTF题目的新手,也适合做无线电信号分析的入门玩家,当然,如果你只是被同事甩了个.ook文件过来,这篇文章也能帮你快速搞清楚眼前的东西到底是什么。
1. 先分清两个"OOK":编程语言和调制方式
在写脚本之前,我非常推荐你先搞清楚一个问题:你手上的"OOK"到底是哪一边。这个区分看起来基础,但我在各种技术群里见过太多人把这两个概念混为一谈,拿着射频信号的思路去解编程代码,或者拿着编程的映射关系去套波形,最后折腾半天毫无进展。
1.1 编程界的Ook!是Brainfuck的另一种写法
Brainfuck是1993年Urban Müller发明的一种极小化语言,它只认8个指令:> < + - . , [ ]。这8个指令分别对应指针移动、数值增减、输出输入和循环跳转。它的设计初衷就是写出最小的编译器,而不是让人方便编程,所以代码看起来像一串乱码。
Ook!则是把Brainfuck的8个指令重新映射成了三种短语的组合,也就是Ook.、Ook?、Ook!这三个词的各种配对。比如Brainfuck的加号+对应Ook. Ook.,减号-对应Ook! Ook!。为什么要搞这么一层?常见说法是Ook!来源于《碟形世界》里的猩猩 librarian,适合猩猩"编程",本质就是一种Brainfuck的变体编码。
这里有个关键点:Ook!本身不是新语言,它只是Brainfuck的token替换。所以只要你把Ook!的每个"单词对"还原成对应的Brainfuck指令,剩下的解释执行逻辑完全可以直接复用Brainfuck的解释器。
再补充一个很难注意到的细节:Brainfuck在不同场景下还有别的变体,比如Short OOK、COW等,原理都一样,只是映射表不同。写解码脚本的时候,最好把映射表单独抽出来配置,这样后续遇到别的变体改一行就行。
1.2 无线通信里的OOK是"开关键控"
射频和通信圈里的OOK是On-Off Keying的缩写,中文叫开关键控。它是最简单的数字调制方式之一——用载波的有无来表示二进制数据。载波存在代表1,载波不存在代表0,就这么朴素。也可以反过来定义,但是大多数协议都约定有载波为1。
这种调制方式在遥控器、门禁卡、315MHz/433MHz射频模块里极其常见。它的优点是实现成本低、解码简单,缺点是抗干扰能力差、频谱效率低。如果你用SDR或者逻辑分析仪抓了一段遥控器的信号,看到的波形就是一截一截的方波包络,这个就是OOK信号。
1.3 怎么区分你面前的是哪种
区分方法非常简单:看你的资料是"文本"还是"波形"。如果打开文件看到的是一堆Ook. Ook? Ook!这样的字符,那就是编程语言的Ook!;如果看到的是示波器截图、音频WAV文件、IQ数据或者逻辑分析仪的导出CSV,那就是调制方式OOK。文本用解码脚本处理,波形用包络检测处理,两条路完全不交叉。
写到这里顺便提一嘴,有的CTF题目会把Ook!藏在音频或者图片里,这种属于隐写术范畴,和纯文本的Ook!又不一样,需要先提取出文本内容再走解码流程,后面讲脚本的时候会补充这个场景。
2. Brainfuck / Ook! 解码脚本的核心思路
解码脚本分两层:第一层是把Ook!文本转换成Brainfuck指令,第二层是把Brainfuck指令真正跑起来。理解清楚这两层的关系,比直接copy代码要重要,因为题目出题人经常在输入格式上做一些小手脚,比如允许带前后空格、夹杂换行,或者把代码换行风格改得特别随性。
2.1 Brainfuck的8个指令先过一遍
我把8个指令的行为整理成了一张表,结合内存模型来理解会更快。Brainfuck运行时有一块内存数组,还有一个指针指向当前内存单元,外加一个输入输出流:
| 指令 | 含义 | 类比 |
|---|---|---|
| > | 指针右移一格 | 光标向右移 |
| < | 指针左移一格 | 光标向左移 |
| + | 当前单元格的值加1 | 计数器加1 |
| - | 当前单元格的值减1 | 计数器减1 |
| . | 输出当前单元格对应的ASCII字符 | 打印 |
| , | 读取一个输入字符存入当前单元格 | 键盘输入 |
| [ | 如果当前单元格为0,跳到对应]后 | 循环开始 |
| ] | 如果当前单元格不为0,跳回对应[前 | 循环结束 |
绝大多数Brainfuck程序都是围绕着"移动指针、增减数值、循环、输出"这个套路展开的,做解码脚本解释执行其实没有太多魔法,就是一个状态机。
2.2 Ook!到Brainfuck的完整映射表
Ook!的映射表遵循一个简单规律:每个Brainfuck指令由两个Ook单词组成。我用元组的形式把完整对应关系列出来:
| Brainfuck指令 | Ook!编码 |
|---|---|
| > | Ook. Ook? |
| < | Ook? Ook. |
| + | Ook. Ook. |
| - | Ook! Ook! |
| . | Ook! Ook. |
| , | Ook. Ook! |
| [ | Ook! Ook? |
| ] | Ook? Ook! |
这个表是之后写脚本的基础。你会发现Ook.、Ook?、Ook!三个词两两排列一共有9种组合(允许相同),但表里只用了8种,剩下的那个组合在标准Ook!里是用于注释或者扩展的,我写脚本的时候会直接跳过它,不报错,这是从兼容性角度考虑的。
2.3 Python解码脚本实现
我看网上很多脚本都是先转Brainfuck再解释执行,其实还有另一种做法:直接把Ook!的每对token映射到对应的操作函数,一步到位。两种方式区别不大,先转Brainfuck的版本更直观,也方便Debug,所以我用的是先转换再执行的方案。下面是完整的Python脚本,这里贴出来可以直接跑:
import sys # Ook! to Brainfuck mapping OOK_TO_BF = { ("Ook.", "Ook?"): ">", ("Ook?", "Ook."): "<", ("Ook.", "Ook."): "+", ("Ook!", "Ook!"): "-", ("Ook!", "Ook."): ".", ("Ook.", "Ook!"): ",", ("Ook!", "Ook?"): "[", ("Ook?", "Ook!"): "]", } def ook_to_bf(code: str) -> str: tokens = code.replace("(", " ").replace(")", " ").split() bf = [] i = 0 while i + 1 < len(tokens): pair = (tokens[i], tokens[i + 1]) if pair in OOK_TO_BF: bf.append(OOK_TO_BF[pair]) i += 2 else: # 跳过不匹配的单token,防止格式不干净导致崩溃 i += 1 return "".join(bf) def bf_interpreter(code: str, input_data: bytes = b"") -> str: cells = [0] * 30000 ptr = 0 pc = 0 input_idx = 0 output = [] # 预扫描括号匹配,CTF题里代码经常很长,每次运行时现找括号会拖慢速度 bracket_map = {} stack = [] for idx, c in enumerate(code): if c == "[": stack.append(idx) elif c == "]": if not stack: raise ValueError("unmatched ]") start = stack.pop() bracket_map[start] = idx bracket_map[idx] = start if stack: raise ValueError("unmatched [") while pc < len(code): c = code[pc] if c == ">": ptr += 1 elif c == "<": ptr -= 1 elif c == "+": cells[ptr] = (cells[ptr] + 1) & 0xFF elif c == "-": cells[ptr] = (cells[ptr] - 1) & 0xFF elif c == ".": output.append(chr(cells[ptr])) elif c == ",": if input_idx < len(input_data): cells[ptr] = input_data[input_idx] input_idx += 1 else: cells[ptr] = 0 elif c == "[": if cells[ptr] == 0: pc = bracket_map[pc] elif c == "]": if cells[ptr] != 0: pc = bracket_map[pc] pc += 1 return "".join(output) if __name__ == "__main__": if len(sys.argv) != 2: print("Usage: python ook_decode.py <file.ook>") sys.exit(1) with open(sys.argv[1], "r", encoding="utf-8") as f: ook_code = f.read() bf_code = ook_to_bf(ook_code) print("BF code:", bf_code) print("Output:", bf_interpreter(bf_code))这个脚本有两个设计细节值得说明:
第一个是bracket_map的预扫描。Brainfuck里的循环是嵌套的,如果不预扫描,每次碰到[都要去匹配对应的],复杂度会变成O(n^2),代码一旦到几千个字符就会明显卡顿。预扫描一遍把配对关系存成字典,执行时查表就行,这个优化在CTF大赛里不是可选项,而是必需项。
第二个是对未知token的容错处理。真实题目里的Ook!文件经常混入额外的括号、换行、注释文本,如果脚本严格要求每两个token必须是一个合法映射对,遇到脏数据就崩溃,体验很差。我这里的策略是:匹配成功就消费两个token,匹配不成功就跳过当前token,宁可少解一些也不用异常打断整个流程。实际效果是大概99%的脏数据都能被自动忽略掉。
2.4 处理带干扰的Ook!文本
还有一种常见情况是Ook!文本藏在别的文本里,比如某个网页源码的注释里、图片的EXIF信息里,或者题干描述里混着大段废话。碰到这种情况,你可以用正则先把合法的Ook!片段提出来。我常用的提取规则是:
import re def extract_ook(text: str) -> str: # 匹配形如 Ook. / Ook? / Ook! 的连续token,允许中间有空格和括号 pattern = re.findall(r"Ook[\.\!\?]", text) return " ".join(pattern)这个正则会匹配出文本中所有Ook开头的token,并把它们重组为干净的标准格式。实测下来对99%的隐写场景都有效,如果题目故意在token之间插入了干扰词,那就要先根据上下文手动清洗,没有万能脚本能完全替代人眼判断。
3. 从波形里找0和1:OOK信号的解码实操
现在切换到另一个频道的OOK。很多人手持逻辑分析仪或者SDR设备,抓了一段波形却不知道该怎么下手,核心问题就一句话:怎么判断波形里的0和1。
我直接说结论:OOK信号判断0和1,主要靠载波的有无和持续时间的长短,而不是靠频率高低。频率高低是FSK(频移键控)的事,和OOK没关系。你看到的那一段段有载波、没载波交替出现的长方形包络,就是最原始的数字比特流。
3.1 先把波形长什么样认出来
你打开一个OOK信号的波形图,肉眼看到的是类似这样的东西:高电平区间和低电平区间交替排列,宽度长短不一。高电平代表有载波(通常是1),低电平代表无载波(通常是0)。这里的高电平低电平和普通方波不太一样,OOK的有载波部分实际是一串高频正弦波,只是被采样或包络检测后变成了高电平的矩形包络。
我在实际分析的时候,一般不会直接看原始射频波形,而是会先做一次包络检测,把高频载波滤掉,只留下高低电平的包络曲线。这个操作用硬件比较器或者软件Hilbert变换都能做。拿到包络之后,剩下的就是纯纯的方波,判断0和1就变成了判断每个电平持续了多长时间。
3.2 判断0和1的两种方案
方案一:固定时长阈值法
这是最常用也最简单的方法。先用一个已知的引导码(preamble)来校准单位时间T,然后设定一个判断阈值。比如有一个厂商的遥控器协议,约定1个bit的宽度是0.5ms,0的宽度是1ms,那么你在包络上量到0.6ms的高电平和1.1ms的高电平,就能合理地区分,前者是1,后者是0。
但这里有个很容易踩的坑:实际信号的电平宽度不可能正好是理论值,它总是有偏差的。晶体振荡器有误差,采样率有限,传输过程还可能有一点畸变。所以阈值不能卡在理论值的一半,我一般会把分界阈值设置在两种理想脉宽的中间值,比如0和1的理想宽度分别是1.0ms和0.5ms,那阈值就取0.75ms。而且阈值是相对整个信号统计出来的,不是绝对固定的。实现的时候可以先统计所有脉宽的分布,做个聚类,再取两个聚类中心的中点作为阈值,这样比拍脑袋定值要稳得多。
方案二:相对宽度判断法
如果你拿到的信号没有已知的校准码,或者不同遥控器的脉宽差异太大,就用相对宽度判断。把捕获到的所有高电平和低电平宽度分别列出来,统计中位数。假设PWM编码中短脉宽一般是长脉宽的一半,那你取一个中间阈值,就能把长短分开。这个方法对大多数OOK遥控器都有效,因为遥控器协议普遍采用类似的脉宽调制策略。
这个方法的核心伪代码如下:
import numpy as np def decode_ook(edges, durations, threshold_ratio=0.5): # edges: 边沿类型,True为上升沿,False为下降沿 # durations: 每个状态的持续时间 bits = [] for i, dur in enumerate(durations): # 根据阈值判断该段是长还是短 is_long = dur > threshold_ratio * max(durations) # 再根据当前是载波还是空闲,组合判断0/1 if edges[i]: # 高电平(有载波) bits.append("1" if not is_long else "0") else: bits.append("0" if not is_long else "1") return bits这个伪代码的例子只是为了表达思路,具体每个协议定义不一样,不通用。判断0和1的关键不是记住某一套规则,而是理解"有载波/无载波 + 长/短"这四种组合,然后对照你正在处理的协议手册去映射。
3.3 从波形到比特流的完整实操
我现在用的流程大致是这么四步:先用逻辑分析仪抓取信号边沿,导出CSV数据;然后用Python读取CSV,算出每段电平的持续时间;接着用KMeans或者简单阈值把持续时间聚类成短和长两类;最后按协议规则把长短+电平映射成二进制流,再组帧。
这套流程里最费时间的是最后一步,因为不同协议不仅脉宽不同,0和1的定义也可能完全相反,有些协议甚至用的是曼彻斯特编码。处理这种变体就只能一案一议了,没有一把万能钥匙。
3.4 分析OOK波形时的工具推荐
我常用的工具是Audacity插件的频谱分析来观察包络,写Python时主要用numpy做信号处理。如果是协议分析,URH(Universal Radio Hacker)是强烈推荐的,它内置了OOK解调、差分解码、脉冲宽度分析这些功能,把波形导进去点几下就能出来比特流,特别适合快速验证你的判断。
有个实用技巧:URH的"Realistic"模式会模拟实际信道里的噪声和码间串扰,调试解码脚本时这个模式非常能暴露问题。我在调一个433MHz遥控器信号的时候,就是靠这个模式发现自己的判决阈值设太低了,导致长串的1被切成了几段。
4. 常见问题与排查技巧实录
写解码脚本和调波形总会遇到一些特别折磨人的问题。我这几年自己踩过的坑,加上帮朋友debug看到的典型问题,整理几个高频的出来,希望能帮你少走点弯路。
4.1 解码Ook!时常见的问题
Q1:脚本报错"unmatched ["或者"unmatched ]"
这个最常见的原因是文本里混入了残缺的token,比如有人复制代码时漏了半个括号,或者OCR识别时Ook?识别成了Ook,导致配对失败。排查方式很简单:先把提取出来的token数打印出来,看是不是奇数,奇数一定说明有残缺token。解决办法是逐个检查无法配对的token,手动补齐或者删掉。
Q2:解码出来全是乱码
这个要看你把结果当什么编码看。Brainfuck输出的是字节流,如果程序是在输出ASCII字符,那你直接打印字符串就行;如果程序是在输出非ASCII的字节序列,可能需要进一步用hex编码查看。我一般会同时打印输出字符串和hex两种形式,对照着看就能判断是编码问题还是脚本问题。
Q3:代码里出现了不认识的变体
有的出题人会魔改Ook!,比如把Ook.换成Blah.,或者把三个token改成一个区间,这种情况下固定映射表就没用了。你可以先用频率分析找出哪些token重复出现,再结合Brainfuck代码特征(比如[和]配对、.出现在循环后面等)猜测映射关系。这个方法听着玄学,实际用起来命中率很高,我在一次线下赛就是靠这个逆出了一个自定义变体。
4.2 判断OOK波形0和1的常见问题
Q1:为什么我看到的波形高电平和低电平宽度差别很大
这太正常了。OOK信号经过无线信道后会有衰落和畸变,如果发送端本身的脉宽就不是严格均匀的,那波形自然高高低低参差不齐。不要纠结于每个脉宽完全相等,用统计的方法做聚类绝大多数情况下都够用。
Q2:高电平到底是1还是0
没有统一答案,取决于协议。手动遥控器里常见的是高电平有载波表示1,但也有协议定义为有载波表示0。你要做的是找一个已知的引导码或者已知的按键码去反推。如果什么都找不到,那就两种映射都试一遍,看哪种结果能解析出合理的帧结构。
Q3:小数据量下KMeans聚类不好用
如果你一个信号里总共只有十几个脉冲,KMeans这种统计方法基本就废了。这种时候我建议你手动量几个典型的脉宽,直接根据经验判断长短阈值。毕竟KMeans也是要样本量的,样本太少它聚出来的中心就是错的。
5. 几个值得收藏的实操技巧
最后写几个代码之外的小技巧,都是我在实战里磨出来的,不一定写在文档里,但用起来非常顺手。
技巧一:Ook!解码脚本参数化
把Ook!到Brainfuck的映射表放到JSON文件里,脚本启动时读文件加载映射关系。这样一来,遇到变体之后只需要改JSON里的映射,不用改脚本本身。代码逻辑和数据分离,在快速迭代题目的时候很省事。
技巧二:给Ook!脚本加一个输出缓冲区
Brainfuck的.指令每次输出一个字符,如果直接调用print输出,大量字符的时候会非常慢。我在脚本里把输出字符先append到一个list里,最后join一下再整体返回,这个优化虽然简单,但对超长Brainfuck代码影响是真大。
技巧三:OOK波形分析先找同步头
绝大多数OOK协议都会在数据帧前发送一段同步头或者前导码,它的脉宽通常会比数据位的脉宽长很多。找到这段特殊的波形,你就能直接反推出发送端的时钟精度和单位时隙长度,之后判断数据位0和1就有了一把标准尺子。这个方法比统计聚类快得多,成功率也更高。
技巧四:善用对比验证
不管解码什么,都尽量找一组已知的输入输出对来验证。Ook!的话,拿一个已知输出Hello World的脚本跑一遍;OOK的话,如果能拿到遥控器的原厂协议文档,找个对照表比对着解。验证通过之后再处理未知数据,心里就有底了。
我自己的习惯是把这个脚本和脉冲分析工具链都放在一个项目目录里,遇到相关题目直接拖进去跑。解码这种事,很多时候拼的就是谁手头的工具更顺手、更快出结果。
本文还有配套的精品资源,点击获取