☰
Brainfuck/Ook解码脚本:从混淆文本到可执行载荷的还原指南
2026/10/12 14:22:41 网站建设 项目流程

简介:面向CTF竞赛与逆向爱好者,这是一份Brainfuck/Ook离线解码工具包。Brainfuck是1993年诞生的一种极小化编程语言,代码仅由八种简单指令构成,常被CTF出题人用于隐藏敏感内容;Ook语言则基于单词组合,常见在线工具虽方便,但比赛现场往往禁止或无法联网,离线脚本因此很有必要。作者基于GitHub既有项目做了针对性优化与封装,提供可直接运行的Python脚本,同时支持brainfuck、ook及short ook三种主流编码形式,输入原始编码即可得到解码结果,输出清晰简洁,适合快速还原flag。压缩包体积仅13KB,共5个文件,除核心脚本外,还包含三个txt编码样例与一张示例图,分别用于三类编码自测对照和直观查看运行效果,便于使用者理解差异并快速确认脚本正确性。目前已有7950人学习/下载,无论用于CTF备赛、算法研究还是本地日常练习,都是一份轻量实用的随手工具。

1. Brainfuck / Ook 解码脚本到底是什么:从一波乱码里捞回可读载荷

如果你在 CTF 赛题或样本分析里见过一段全是Ook. Ook? Ook!的文本,第一反应多半是「这谁写的加密」。它确实不是人话,但它也不是加密——它是 Brainfuck 的拟声词变体,把 Brainfuck 的八个指令符号映射成了三种「音节」的组合。所谓 Brainfuck / Ook 解码脚本,就是把这套音节还原成 Brainfuck 指令、再解释执行拿到真正输出的一小段工具代码。

这玩意儿典型出现在三类场景:恶意文档里藏着的混淆载荷、CTF 逆向题的「解码+执行」两步链、以及一些喜欢用 esolang 藏 flag 的脑洞题。它解决的核心问题只有一个:你手上有一堆看着像猴子打出来的符号,你需要快速确定它是不是 Ook、如果是就把它变成可执行的 Brainfuck、然后跑出结果。适合谁?做恶意代码分析的人、打 CTF 的人、以及任何被 esolang 糊了一脸又不想手工翻译的开发者。下面我就从映射规则讲到完整脚本,再把这东西的坑一个个踩给你看。

2. Ook 到 Brainfuck 的映射规则:先搞懂八个符号怎么翻

2.1 一张表说清全部映射关系

Brainfuck 之所以叫 Brainfuck,是因为它整个语言只有八个指令,分别是><+-.,[]。Ook! 则把每个指令拆成了三个词元,每个词元是Ook.Ook?Ook!三者之一,一个完整指令由两个词元组成。社区通行的映射表非常固定,直接抄:

Ook 指令Brainfuck 指令含义
Ook. Ook.>指针右移一格
Ook? Ook?<指针左移一格
Ook. Ook?+当前内存格加 1
Ook? Ook.-当前内存格减 1
Ook! Ook!.输出当前格的值(按 ASCII)
Ook! Ook.,读入一个字节到当前格
Ook. Ook![当前格为 0 则跳到匹配的]后
Ook! Ook?]当前格非 0 则跳回匹配的[后

这个映射不是猜的,是 Ook! 语言作者定死的。所以解码脚本的本质只是「查表替换」,没有任何逆向难度,难点全在怎么把文本切分成合法的词元对,以及切完之后怎么处理那些混进来的空白和脏字符。

2.2 一个最小的 Ook 解析函数:按空白切分的问题

最常见的做法是先把整个文件按空白split(),得到一串词元,然后两两配对查表。我最早写的版本就是这个,代码长这样:

import sys OOK_TO_BF = { ("Ook.", "Ook."): ">", ("Ook?", "Ook?"): "<", ("Ook.", "Ook?"): "+", ("Ook?", "Ook."): "-", ("Ook!", "Ook!"): ".", ("Ook!", "Ook."): ",", ("Ook.", "Ook!"): "[", ("Ook!", "Ook?"): "]", } def ook_to_bf(text: str) -> str: tokens = text.split() if len(tokens) % 2 != 0: raise ValueError(f"词元数量为奇数 {len(tokens)},说明有残缺指令") result = [] for i in range(0, len(tokens), 2): key = (tokens[i], tokens[i + 1]) if key not in OOK_TO_BF: raise ValueError(f"无法识别的 Ook 指令: {tokens[i]} {tokens[i + 1]}") result.append(OOK_TO_BF[key]) return "".join(result)

逻辑说明:split()按任意空白切分后,每个合法的 Ook 指令恰好占两个词元,所以步长取 2 配对。查表用元组做 key,避免字符串拼接再拆解的麻烦。参数上需要注意两点:第一,text必须是解码后的文本,如果你从文件读的是bytes,先做decode('utf-8', errors='replace'),因为 Ook 文件里可能混着注释或不可见字符;第二,这个函数是纯函数,不做任何 I/O,方便后面接测试。

这套写法有一个隐患——它假设词元之间一定有空白。如果源文件里出现Ook.Ook?这种词元之间没有空格的粘连写法,split()会把Ook.Ook?当成一个整体,直接落入「无法识别」。真实的 Ook 样本里这种粘连极其常见,尤其是经过混淆器压缩过的。所以解析器不能这么天真,下一步就得换成正则提取。

2.3 用正则提取词元:处理粘连和脏字符

我现在的做法是用正则直接抓所有形如Ook后跟.?!的词元,忽略其余任何字符。这样无论词元之间是空格、换行、还是完全没分隔,都能稳定切出来。代码改成这样:

import re OOK_TOKEN_RE = re.compile(r"Ook[.!?]") def ook_to_bf_robust(text: str) -> str: tokens = OOK_TOKEN_RE.findall(text) if not tokens: raise ValueError("没找到任何 Ook 词元,确认输入是不是 Ook 程序") if len(tokens) % 2 != 0: raise ValueError(f"词元数量为奇数 {len(tokens)},末尾可能有截断") result = [] for i in range(0, len(tokens), 2): key = (tokens[i], tokens[i + 1]) if key not in OOK_TO_BF: raise ValueError(f"无法识别的 Ook 指令: {tokens[i]} {tokens[i + 1]}") result.append(OOK_TO_BF[key]) return "".join(result)

逻辑说明:re.findall会把所有能匹配的独立词元全抓出来,顺序保持原样,所以即使原文件里混了 HTML、注释、甚至二进制垃圾,只要 Ook 词元本身完整,就能提取成功。这里有个设计取舍:findall是贪婪匹配,碰到Ook.Ook?会正确切出Ook.和Ook?,因为正则里的[.!?]是单个字符,不会跨词元吞噬。如果文本里有Ook.Ook!这种,也能正确切为Ook.和Ook!。参数re.DOTALL不需要,因为[.!?]不受换行影响。

一个常见的翻车点:findall会把文本里恰好以Ook开头的普通英文单词也抓进来,比如Book.会抓到Ook.。这属于误报,处理方式是先做一次整体合法性判断——如果抓到的词元数不是偶数,或者配对后有无法识别的指令,基本可以断定输入不是合法 Ook,这时候抛错比硬解好得多。

3. 解释执行 Brainfuck:写一个能跑的 BF 运行时

3.1 为什么解码脚本必须带上执行器

很多人在这一步会犯一个方向性错误:写了个 Ook 到 Brainfuck 的转换器,输出一段++++++++[>++++++++<-]>+.就完事了,然后问「怎么拿 flag?」。因为题目要的是执行结果——把 Brainfuck 当成程序跑起来,输出那串对你有意义的内容。Ook 解码只是第一步,执行才是拿到答案的那一步。

所以完整的解码脚本一定包含两部分:Ook 到 BF 的映射层,和 BF 解释器。BF 解释器本身不复杂,但有两个细节直接决定脚本能不能用:循环跳转怎么匹配、内存格溢出怎么处理。下面这个实现我用了很久,该踩的坑都踩过了。

3.2 核心解释器:预计算括号跳转表

Brainfuck 的[和]需要配对跳转。朴素的做法是每次遇到[就扫描找匹配的],但这会让嵌套循环的程序慢到怀疑人生。正确做法是先把指令串扫一遍,用栈构造一张「跳转表」,执行时查表 O(1) 跳转。代码:

class BFInterpreter: def __init__(self, code: str, tape_size: int = 30000, max_steps: int = 10_000_000): self.code = code self.tape = bytearray(tape_size) # 内存纸带 self.tape_size = tape_size self.max_steps = max_steps self.jump = self._build_jump_table(code) def _build_jump_table(self, code: str) -> dict: jump = {} stack = [] for idx, ch in enumerate(code): if ch == "[": stack.append(idx) elif ch == "]": if not stack: raise ValueError(f"第 {idx} 个指令 ']' 缺少匹配的 '['") open_idx = stack.pop() jump[open_idx] = idx jump[idx] = open_idx if stack: raise ValueError(f"有 {len(stack)} 个 '[' 未闭合: 位置 {stack}") return jump def run(self, stdin_data: bytes = b"") -> bytes: pc = 0 # 指令指针 ptr = 0 # 数据指针 steps = 0 input_pos = 0 output = bytearray() while pc < len(self.code): steps += 1 if steps > self.max_steps: raise TimeoutError(f"执行超过 {self.max_steps} 步,疑似死循环") op = self.code[pc] if op == ">": ptr += 1 if ptr >= self.tape_size: raise IndexError(f"数据指针越界: {ptr}") elif op == "<": ptr -= 1 if ptr < 0: raise IndexError(f"数据指针越界: {ptr}") elif op == "+": self.tape[ptr] = (self.tape[ptr] + 1) % 256 elif op == "-": self.tape[ptr] = (self.tape[ptr] - 1) % 256 elif op == ".": output.append(self.tape[ptr]) elif op == ",": if input_pos < len(stdin_data): self.tape[ptr] = stdin_data[input_pos] input_pos += 1 else: self.tape[ptr] = 0 elif op == "[": if self.tape[ptr] == 0: pc = self.jump[pc] elif op == "]": if self.tape[ptr] != 0: pc = self.jump[pc] pc += 1 return bytes(output)

逻辑说明:bytearray初始化 30000 格,每格取值 0-255,加减用% 256做环绕,这符合 Brainfuck 的内存模型语义。>和<都要做边界检查,否则指针飘出纸带后 Python 会直接报IndexError,但那个报错信息没有指针位置,排错困难,所以我在越界时主动抛带位置信息的异常。[的执行规则是「当前格为 0 就跳到匹配的]之后」,所以pc = self.jump[pc]后还要走一次pc += 1,正好落到]的下一条指令;]的规则是「当前格非 0 就跳回匹配的[之后」,同理pc += 1后落在[的下一条指令。这两个跳转方向是新人最容易写反的地方。

参数说明:tape_size默认为 30000,这是经典 Brainfuck 规范值,遇到需要更大内存的程序(比如算大数阶乘)可以加大;max_steps默认 1000 万,这是保证脚本不会在+[]这种合法但永不终止的程序上挂死的关键——BF 是图灵完备的,你没法静态判断它会不会停,只能在运行时卡步数。stdin_data用bytes类型,因为,指令读的是原始字节,不是字符串。

3.3 把两步串起来:从 Ook 文本到执行输出

有了映射层和执行器,整个解码脚本的主流程就三行话:读文件、转 BF、跑 BF。我把入口写成一个函数,输入文件路径,输出执行结果:

def decode_ook_file(path: str, stdin_data: bytes = b"", tape_size: int = 30000, max_steps: int = 10_000_000) -> bytes: with open(path, "r", encoding="utf-8", errors="replace") as f: text = f.read() bf_code = ook_to_bf_robust(text) interpreter = BFInterpreter(bf_code, tape_size=tape_size, max_steps=max_steps) return interpreter.run(stdin_data)

逻辑说明:文件读取用errors="replace"而不是默认的strict,因为 Ook 文件里常混着各种不可见控制字符,严格模式会在读取阶段直接抛UnicodeDecodeError,而 replace 会把非法字节替换成\ufffd,不影响后续正则提取。stdin_data对应程序的,指令输入,CTF 里常见「程序先读一个字符再判断」的逻辑,这一步不传的话,会读到 0,结果天差地别。tape_size和max_steps在这里透传下去,是因为实际场景里你经常会遇到需要调大内存或调大步数的样本,写死在函数里反而麻烦。

4. 落地一个可直接用的命令行工具:参数设计与完整代码

4.1 常见用法对应三个模式

实际的解码场景逃不出三种:拿到一个.ook文件要跑出结果、拿到一段 Ook 文本直接喂给脚本、以及只需要中间产物 Brainfuck 源码去做进一步分析。所以命令行工具要支持文件输入、字符串输入和「只转换不执行」三种模式。我不喜欢把参数设计得太复杂,能用一个参数解决的绝不用两个。

下面是我整理后的完整 CLI 实现,基于 Python 标准库argparse,没有任何第三方依赖,拿到任何一台有 Python 3 的机器就能跑:

import argparse import sys from pathlib import Path def main(): parser = argparse.ArgumentParser(description="Brainfuck / Ook 解码执行工具") group = parser.add_mutually_exclusive_group(required=True) group.add_argument("-f", "--file", type=Path, help="Ook 程序文件路径") group.add_argument("-c", "--command", help="直接从命令行传入 Ook 字符串") parser.add_argument("-i", "--input", type=bytes, help="BF 程序的 stdin 数据(字节串)") parser.add_argument("-o", "--output-bf", type=Path, help="只输出转换后的 Brainfuck 源码,不执行") parser.add_argument("--tape-size", type=int, default=30000, help="内存纸带大小,默认 30000") parser.add_argument("--max-steps", type=int, default=10_000_000, help="最大执行步数,默认 1000 万") parser.add_argument("--strict", action="store_true", help="严格模式:遇到非法词元直接报错") args = parser.parse_args() if args.file: text = args.file.read_text(encoding="utf-8", errors="replace") source_name = str(args.file) else: text = args.command source_name = "<command>" try: bf_code = ook_to_bf_robust(text) except ValueError as e: print(f"[!] Ook 解析失败({source_name}):{e}", file=sys.stderr) sys.exit(1) if args.output_bf: args.output_bf.write_text(bf_code, encoding="utf-8") print(f"[+] 已输出 Brainfuck 源码到 {args.output_bf}") return try: result = BFInterpreter( bf_code, tape_size=args.tape_size, max_steps=args.max_steps, ).run(args.input or b"") except (IndexError, TimeoutError) as e: print(f"[!] BF 执行失败:{e}", file=sys.stderr) sys.exit(1) sys.stdout.buffer.write(result) if result and not result.endswith(b"\n"): print()

逻辑说明:-f和-c用mutually_exclusive_group保证只选一个,避免歧义。-i参数类型设为bytes,这样可以直接传b"\x00\x01"这类二进制输入,也可以传b"abc"。-o参数是「只转换不执行」的开关,输出 BF 源码而不是跑它——这个模式在你看不懂 BF 程序想在本地用调试器单步跟踪时特别有用。--strict目前是个占位开关,如果你想在解析阶段把「非 Ook 但被正则误抓的词元」也当错误处理,可以在ook_to_bf_robust里加一个strict分支,我这里默认是宽松模式,因为 CTF 里的样本往往混着题面描述文字,宽松模式才能解出来。

4.2 用几个典型样本验证脚本行为

写完之后必须验证,不然脚本有没有 bug 全靠猜。我一般用三个样本做冒烟测试:一个空程序、一个输出单字符的、一个带循环的。

空程序Ook.是非法输入(单数词元),应该报错;Ook! Ook!对应.,执行后输出一个值为 0 的字节(\x00),终端上看不到东西但程序不报错;带循环的用这个:++++++++[>++++++++<-]>+.,它计算 8×8+1 = 65,输出大写A。它的 Ook 形式太长,不适合手敲,但你可以用-o参数先转成 BF 再核对,这是验证链路最方便的方式:

python3 ook_decoder.py -c "Ook. Ook? Ook. Ook? Ook. Ook? Ook. Ook? Ook. Ook? Ook. Ook? Ook. Ook? Ook. Ook? Ook. Ook! Ook? Ook. Ook. Ook. Ook? Ook. Ook? Ook. Ook? Ook. Ook? Ook. Ook? Ook. Ook? Ook. Ook? Ook. Ook? Ook. Ook! Ook. Ook? Ook. Ook! Ook. Ook! Ook! Ook. Ook. Ook? Ook. Ook? Ook? Ook. Ook. Ook. Ook." -o /tmp/check.bf cat /tmp/check.bf

命令说明:这串 Ook 对应的 BF 就是++++++++[>++++++++<-]>+.,-o模式让你不需要手工数括号就能验证解析是否正确。输出到/tmp/check.bf后直接 cat 看内容,如果解析器有问题,这里一眼就能看出来。实际做题时我习惯两步走:先用-o确认 BF 源码符合预期,再不带-o跑执行,这样能区分是解析错了还是执行错了。

5. Brainfuck / Ook 解码脚本的五个常见坑与排查方法

5.1 词元被正则误吞:Ook.和Ook?之间没有空格导致配对错位

现象:一段肉眼看着完全正常的 Ook 代码,解析后得到一堆无法识别的指令,或者干脆词元数量变成奇数。原因:split()版本没处理粘连写法,两个词元之间没有空格时Ook.Ook?被判成未知 token;findall版本则不会出现这个问题,但如果你用的是re.findall(r"Ook[.!?]")之外的更贪婪的正则(比如Ook\w+),就会把Ook.后面的内容也吞进匹配里。解决:统一用Ook[.!?]这种精确匹配,并且在解析前先打印tokens[:20]看一眼提取结果,确认没有把英文单词里的Ook子串也算进去。

5.2 括号跳转方向写反:[跳到]后还是跳到]本身

现象:程序卡死,或者输出完全不符合逻辑。比如+++[>++<-]>+.本应输出7,却输出了乱码或什么都不输出。原因:[的语义是「当前格为 0 时跳到匹配]之后」,如果你只跳到]的位置,下一次pc += 1会进入]的指令分支,把跳转逻辑又触发一遍,轻则多走一轮循环,重则死循环。解决:对照 3.2 节的实现,[跳转后pc指向],紧接着pc += 1落到]的下一条,这才是「之后」;反过来]跳转后pc指向[,紧接着pc += 1落到[的下一条,也就是循环体的第一条指令。这个细节用一句话记:跳转表里存的都是配对位置,真正的位移靠统一的那行pc += 1完成。

5.3 内存纸带越界:指针冲出预设范围

现象:报IndexError或data pointer out of range position: -1。原因:BF 程序里<和>的移动是动态的,如果程序本身逻辑有误,或者tape_size设得太小,指针就会漂出纸带边界。Python 的bytearray对负索引是合法的(指向尾部),所以tape[ptr]在ptr = -1时不会报错,只会悄悄指向最后一格——这是最隐蔽的一类 bug,程序不报错但结果全错。解决:在>和<分支里显式检查ptr范围,越界就抛异常;同时用--tape-size把纸带调大,遇到递归型 BF 程序(比如算斐波那契)直接给 100000。

5.4 死循环识别:+[]永远跑不完,脚本挂死

现象:脚本运行超过几十秒没任何输出,CPU 占用率 100%。原因:BF 是图灵完备的,+[]这种程序合法但永不终止,任何静态分析都无法确定它是否停机,必须靠运行时限制。解决:max_steps参数就是干这个的,默认 1000 万步,到达上限抛TimeoutError。但这里有个新坑:有些合法程序(比如大数乘法)步数轻松超过 1000 万,导致误杀。我的习惯是先用默认步数跑,超时后调大一个数量级再跑一次,如果还是超时,基本可以断定这程序不是让你跑完的,题目意图可能在别处。

5.5 输出编码:.输出的是字节,不是字符串

现象:程序明明输出A,终端上却显示65,或者中文全部乱码。原因:BF 的.指令输出的是当前内存格的原始字节值,print()默认按文本模式编码,遇到非 UTF-8 字节序列就会替换或报错。解决:执行结果用sys.stdout.buffer.write()直接写原始字节,不做任何编码转换;如果需要转成字符串看,用result.decode('latin-1'),因为 latin-1 是单字节映射,不会抛解码异常。文本模式下 Python 还会把\n转成\r\n,这在 Windows 上会改变输出长度,所有涉及二进制输出的场景都必须走.buffer。

6. 进阶用法:把解码脚本变成自动化分析链的一环

到这你已经有一条能跑的 Ook 解码链路了,但它还只是个单发工具。实际分析任务里,你面对的往往是几百个可疑文件,每个文件里可能嵌着 Ook 程序,也可能只是普通文本碰巧含Ook子串。这时候手动跑就太慢了,我习惯把它接到一个批处理流程里:扫目录、识别可疑文件、提取 Ook 片段、解码、记日志,一步到位。

一个实用的做法是把核心函数改造成「输入 bytes 输出 bytes」的纯函数,然后写一个批量脚本:

def try_decode_ook(data: bytes) -> bytes | None: text = data.decode("utf-8", errors="replace") if "Ook." not in text and "Ook?" not in text and "Ook!" not in text: return None try: bf_code = ook_to_bf_robust(text) if len(bf_code) < 3: # 太短不像有效程序 return None return BFInterpreter(bf_code, max_steps=100_000).run() except (ValueError, IndexError, TimeoutError): return None

这段代码的用法是把「是不是 Ook」的判断也交给解析器——先快速排除完全不含 Ook 词元的文件,再尝试解析和执行,解析失败或执行超时一律返回None。这样批量跑的时候,只有真正能解出内容的文件才会出现在结果列表里。max_steps从 1000 万调小到 10 万,因为批量场景下你宁可漏判,也不能让一个死循环拖死整个队列。

验证这套脚本正不正确,除了手工冒烟测试,还可以写一个随机测试:随机生成合法的 Brainfuck 程序,用你的解释器跑一遍,再转成 Ook、再解码、再跑一遍,两次结果必须完全一致。这个「转出去再转回来」的往返测试能一次性验证映射表的八个键值、正则提取的完整性、以及执行器的状态一致性,比手写一百个测试用例都管用。我当时第一次跑这个往返测试就抓到了]跳转方向的问题——写的时候脑子想着「非 0 跳回」,代码里却写成了「为 0 跳回」,普通样本测不出来,随机样本一跑就现原形。

最后说一个习惯:分析这种 esolang 混淆样本时,永远把「解码」和「执行」分成两步看。解码是确定性的查表,不会有错;执行才是有状态、有副作用的黑匣子。脚本报错时先确认是哪个阶段的问题——-o模式先把 BF 源码导出来人工看一眼,确认没问题再去查执行器。这套排查顺序帮我在不少比赛里省下过半小时的冤枉时间,希望也能帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询