公司动态

CTF逆向工程:Swift AST与树结构加密算法实战解析

📅 2026/8/23 12:44:32
CTF逆向工程:Swift AST与树结构加密算法实战解析
1. 项目背景与核心挑战“2022国赛Re1 baby_tree”这个标题对于参加过CTFCapture The Flag竞赛特别是逆向工程Reverse Engineering, Re方向的选手来说一眼就能看出其分量。它直指2022年全国性CTF大赛的一道逆向题目而“baby_tree”这个后缀通常意味着题目难度是入门或简单级别旨在考察选手对某个特定基础概念的理解和运用。结合你提供的“Re, swift, AST, 语法树, 加密”这些关键词这道题的轮廓就清晰了起来这是一道涉及Swift编程语言、抽象语法树AST概念并最终与加密逻辑相关的逆向分析题。逆向工程从来不是漫无目的地翻看二进制代码它更像是一场有明确目标的侦探游戏。题目叫“baby_tree”暗示了“树”结构是解题的关键。在编程语言领域“树”最直接的联想就是抽象语法树AST。Swift作为苹果主推的现代编程语言其编译器前端就会将源代码解析成AST以便进行语义分析和代码生成。因此这道题很可能提供了一个经过混淆或加密的、与Swift AST相关的程序或数据要求选手通过逆向分析理解其AST的结构或变换规则从而还原出被隐藏的flag比赛的目标字符串。这其中的挑战是多维度的。首先选手需要具备基本的逆向分析能力能够使用IDA Pro、Ghidra、Hopper或radare2等工具静态分析二进制文件或者动态调试程序执行流程。其次需要对Swift语言的运行时特性、内存布局有一定了解因为Swift为了安全性和性能引入了不少自己的约定比如字符串的存储方式、枚举的内存表示等。最后也是本题的核心即理解题目如何利用或模拟“树”结构来封装加密逻辑。可能是将flag的每个字符作为叶子节点通过树的遍历顺序进行重排也可能是将加密算法本身表示为一棵操作树每个节点代表一种运算如异或、加减、置换。逆向的目标就是把这棵“树”重新画出来并理解其生长加密规则然后执行逆过程解密。我最初看到这类题目时觉得它巧妙地将语言特性和密码学结合了起来。它不像传统的CrackMe那样直接让你找比较字符串和跳转指令而是要求你上升到程序结构的层面去思考。这对于新手理解“程序即数据数据亦可描述程序”这一概念是一个绝佳的切入点。下面我就带你一起拆解这道题可能涉及的几个核心层面并分享从静态分析到动态验证的完整思路。2. 解题环境准备与工具链选择工欲善其事必先利其器。面对一个未知的二进制文件第一步永远是搭建一个稳定、高效的逆向分析环境。这道题目标明是“Re1”且与Swift相关这直接影响了我们的工具选型。2.1 操作系统与调试环境首选环境是macOS。原因很简单Swift是苹果生态的语言其编译器、标准库以及生成的可执行文件都与macOS系统库深度耦合。在Linux上虽然也能运行Swift程序但可能会遇到链接库缺失或行为不一致的问题增加不必要的调试复杂度。我通常会准备一台macOS虚拟机如VMware Fusion或Parallels Desktop安装的macOS确保环境纯净。如果只有Linux环境则需要安装完整的Swift运行时并做好应对未知链接问题的心理准备。2.2 静态分析工具静态分析是逆向的基石目标是理解程序的结构和逻辑而不运行它。主力工具IDA Pro 或 Ghidra。IDA Pro的交互性和反编译能力尤其是Hex-Rays插件依然是行业标杆对Swift符号的解析也相对较好。Ghidra作为免费开源工具其反编译引擎非常强大并且通过社区脚本可以增强对Swift的支持。我通常会两个工具交叉使用IDA用于快速浏览和重命名Ghidra用于深入分析复杂函数。辅助工具Hopper Disassembler。Hopper对macOS和Swift的支持也不错界面友好有时能提供不同于IDA的反编译视图作为交叉验证很有用。专门针对Swift需要关注工具是否支持Swift的元数据Metadata解析。Swift二进制文件中包含了丰富的类型信息、函数签名等元数据用于反射和运行时。这些元数据区如__swift5_types,__swift5_proto等是理解程序结构的关键。一些Ghidra脚本或IDA的Swift插件可以帮助解析这些区域还原出更可读的类名、方法名。2.3 动态分析工具动态分析用于观察程序运行时的行为验证静态分析的猜想。调试器LLDB。这是Swift和macOS原生调试的不二之选。相比于GDBLLDB对Swift语言特性的支持是天生的。你需要熟悉LLDB的基本命令如file加载文件、run启动、breakpoint set设置断点、step单步执行、frame variable查看变量。对于Swift对象可以使用expression -l swift -O --来以更友好的方式打印对象。跟踪与监控Dtrace 和strace/dtruss。用于监控系统调用和库函数调用。比如程序是否读取了某个文件是否进行了网络通信这些调用往往是解题的突破口。dtrussmacOS或straceLinux可以帮你快速看到这些。文件与进程监控fs_usage(macOS) 或inotify(Linux)。实时监控文件系统的访问看看程序在运行过程中创建、读取、修改了哪些文件。2.4 编程与辅助脚本逆向到最后往往需要自己写代码来模拟或解密。Python是首选因为它有丰富的库支持。二进制处理pwntools库虽然更偏向Pwn但其二进制操作功能很好用、struct、binascii。加密算法pycryptodome或cryptography库包含了常见的AES、DES、RSA等算法实现。题目中的“加密”关键词意味着最终很可能需要调用这些库来解密一段数据。树结构处理如果确认涉及AST或自定义树结构Python的anytree库或直接使用class定义节点会非常方便。交互与自动化使用subprocess模块调用命令行工具或者用ptrace在Linux上进行更底层的控制。注意在搭建环境时务必确保调试器能正常附加Attach到进程。macOS新版本有SIP系统完整性保护和签名Code Signing限制可能需要临时禁用相关保护或对二进制文件进行重签名。这是一个常见的坑如果发现无法下断点首先要检查这里。准备好这些工具就像侦探准备好了放大镜、指纹刷和数据库。接下来我们就可以打开这个名为“baby_tree”的谜题盒子了。3. 初步静态分析与入口点定位拿到一个二进制文件不要急着运行。先进行一遍全面的静态“体检”这能避免很多运行时才暴露的陷阱比如反调试、代码自修改等。3.1 文件类型与基础信息首先使用file命令查看文件类型。对于一道CTF题它可能是一个标准的macOS可执行文件Mach-O也可能是一个ELFLinux文件甚至是一个Python字节码.pyc或Java class文件伪装成的二进制。file baby_tree命令会告诉我们答案。假设输出是baby_tree: Mach-O 64-bit executable x86_64这确认了它是一个64位的macOS程序。接着用otool -L baby_treemacOS或ldd baby_treeLinux查看它依赖的动态库。如果看到libswiftCore.dylib等Swift相关库那就进一步坐实了我们的判断。同时也要留意是否链接了加密库如libcryptoOpenSSL或CommonCrypto苹果系统加密库。3.2 字符串提取运行strings baby_tree是逆向工程中的“规定动作”。它能直接提取出二进制文件中所有可打印的字符串。在这些字符串里你可能会发现明显的提示信息如“Please input your flag:”, “Wrong!”, “Correct!”。这直接指明了程序的交互逻辑。函数名和符号如果符号表Symbol Table没有被剥离你可能会看到Swift风格的函数名如$s9baby_tree5checkSSSgSS_tF这是Swift编译器修饰后的名称对应一个名为check的函数。密钥或常量数据加密算法中使用的常量、初始向量IV、或硬编码的密钥片段。与“树”相关的线索如“node”、“leaf”、“parent”、“child”、“traverse”等单词。将strings的输出重定向到文件然后仔细过滤分析是快速获取上下文的有效方法。3.3 使用IDA/Ghidra进行反汇编将文件拖入IDA或Ghidra。分析器会先进行自动分析识别函数、数据引用等。分析完成后我们首先寻找入口点。对于C/C/Swift编写的命令行程序入口点通常是main函数。在IDA的“Functions”窗口里搜索main或者查看导出符号。Swift程序的main函数有时会被编译器特殊处理但反编译工具通常能识别出来。找到main函数后按F5IDA Hex-Rays或使用Ghidra的反编译功能将其转换为伪代码。现在不要陷入每一行代码的细节先进行“高空侦察”观察程序流程看看main函数里调用了哪些主要的子函数。典型的流程是初始化 - 获取用户输入 - 处理/校验输入 - 输出结果。识别关键函数寻找那些可能负责核心逻辑的函数。函数名如果经过还原或通过字符串引用推测是checkFlag、validate、decryptTree之类的就是重点目标。注意数据流查看用户输入可能来自scanf、fgets或Swift的readLine()被传递到了哪里。跟踪这个参数看它经过哪些函数处理。3.4 定位“树”与“加密”逻辑结合题目名“baby_tree”我们在静态分析时要特别关注两种结构数据结构在全局数据区.data段或栈上寻找可能表示树节点的结构体。例如连续的内存区域每个块包含一个数据域和两个指针域左孩子、右孩子这很可能就是二叉树。在反编译的伪代码中它可能表现为一个struct里面有value、left、right成员。控制流图CFG函数内部复杂的条件分支和循环有时在逻辑上就构成了一棵树。例如一个根据输入字符的不同进行多路跳转的switch语句或if-else链其执行路径就像一棵决策树。同时搜索加密相关的常数或操作常数在IDA中搜索像0x9E3779B9TEA算法常数、0x67452301MD5初始值、0x63AES S盒的常见起始值等魔法数字。操作在反编译代码中寻找大量的异或^、循环移位rol/ror、模加后跟%操作这些是流密码或分组密码的常见特征。函数调用识别对已知加密函数的调用如CCCryptCommonCrypto、AES_set_encrypt_key等。通过这一步我们应该能勾勒出程序的大致轮廓它从哪里开始大概分成几个阶段关键的数据结构和算法可能藏在哪里。这为我们接下来的深入分析划定了重点区域。4. 深入逆向Swift AST与树结构解析这是本题最核心、也最具特色的部分。题目名为“baby_tree”且关键词包含“AST”几乎可以肯定程序内部以某种形式实现或模拟了一棵树而这棵树很可能与Swift的AST有直接或间接的关系。4.1 理解Swift AST在二进制中的可能表现形式Swift编译器swiftc在编译时会将源代码转换为AST。但在已编译的二进制中完整的AST信息通常不会保留。然而题目可以“模拟”AST。常见的手法有自定义树节点结构体在程序中显式地定义struct Node包含类型、值、子节点指针等字段然后在堆上动态构建一棵树。这棵树可能是在程序初始化时硬编码的也可能是根据某种规则如输入动态生成的。利用Swift的反射Mirror或元数据虽然困难但高级题目可能利用Swift的运行时类型信息来构建某种“类型树”。不过在逆向题中更常见的是第一种——自定义结构。将树编码为线性数据例如用数组表示二叉树的层次遍历结果或者用括号表示法如(A(B)(C))表示一棵树。程序内部包含了解码和操作这段线性数据的逻辑。在静态分析时我们需要在数据段或代码初始化部分寻找这种结构的构建过程。例如在main函数之前或之初可能会有一个初始化函数如__static_initialization_and_destruction_0或 Swift的全局变量初始化器里面有一连串的malloc和指针赋值操作这就是在“种树”。4.2 逆向自定义树结构假设我们在反编译代码中找到了类似下面的模式以C伪代码表示struct TreeNode { char data; // 或 int value struct TreeNode *left; struct TreeNode *right; }; struct TreeNode *createNode(char d) { struct TreeNode *n malloc(sizeof(struct TreeNode)); n-data d; n-left NULL; n-right NULL; return n; } void buildTree() { struct TreeNode *root createNode(R); root-left createNode(A); root-right createNode(B); root-left-left createNode(C); // ... 更多节点 }这就是一棵明确的二叉树。我们的任务是画出这棵树根据代码中的链接关系在纸上或使用工具画出树形图。明确每个节点存储的值data。理解树的遍历方式程序如何访问这棵树后续的关键函数中必然包含对树的遍历操作如前序Pre-order、中序In-order、后序Post-order或层次遍历Level-order。在反编译代码中遍历表现为对某个节点递归或迭代地访问其left和right指针。节点值与flag的关系节点中存储的data或value是什么它可能是flag明文字符的直接存储也可能是经过变换的如ASCII码加减某个数。也可能节点本身不直接存储字符而是存储一个操作码opcode这棵树实际上是一棵“表达式树”遍历过程就是对输入flag进行一系列运算。4.3 结合加密逻辑“加密”关键词提示我们这棵树很可能参与了加密或校验过程。有两种典型的结合方式树作为加密映射表将输入flag的每个字符作为“钥匙”按照某种遍历顺序在树中移动。例如字符L代表向左孩子移动R代表向右孩子移动最终到达的叶子节点的值就是加密后的输出。或者反过来程序根据一个预设的路径在树中收集节点值拼接起来就是flag。树作为表达式树执行加密计算每个内部节点是一个操作符如,-,^,叶子节点是操作数来自输入flag或常量。程序后序遍历这棵树计算结果最终得到一个校验值。输入flag正确与否取决于这个计算结果是否等于某个目标值。在逆向时我们需要找到连接“输入”和“树”的代码。通常会有一个函数接收输入字符串然后在一个循环中依次取出每个字符并根据字符值或索引调用一个树操作函数如traverse、evaluate。分析这个树操作函数的逻辑就是破解本题的关键。实操心得面对复杂的树结构动态调试比静态分析更直观。你可以在树构建完成后buildTree函数返回后和遍历开始前设置断点使用调试器LLDB直接打印出根节点的内存地址然后手动跟随指针left/right来探索整棵树的结构。在LLDB中如果知道结构体类型可以用memory read或frame variable命令来查看。把这个过程记录下来就能快速还原树形。5. 动态调试与数据流追踪静态分析让我们有了蓝图动态调试则是亲临施工现场验证蓝图并发现隐藏的细节。对于“baby_tree”这种可能包含复杂状态和交互的题目动态调试必不可少。5.1 启动调试与基础断点使用LLDB启动程序lldb ./baby_tree。在(lldb)提示符下输入run或r启动程序。程序可能会等待输入。先不急按CtrlC中断程序。设置第一个断点在main函数入口。命令是breakpoint set --name main或b main。然后重新run。程序会在main开始处停下。5.2 跟踪输入处理流程单步执行step或s或逐过程执行next或n直到看到调用输入函数的地方。在Swift命令行程序中这可能是通过Swift.readLine(strippingNewline:)实现的但在反编译的底层最终会调用到C库的fgets或类似函数。在输入函数调用处设置断点。当程序再次停在这里时查看其参数找到存储输入缓冲区的地址。输入一个测试flag例如flag{test_123}。程序读入后继续执行。5.3 观察树结构的构建与遍历根据静态分析找到的树构建函数例如叫buildTree在其返回前设置断点。当程序执行到此时树已经存在于堆内存中。使用LLDB检查内存找到根节点的指针通常是一个全局变量或存放在某个寄存器中。使用x/gx 根节点地址查看该地址存放的8字节值64位系统这就是根节点结构体的地址。根据逆向出的TreeNode结构体布局计算各字段偏移。假设data在偏移0left在偏移8right在偏移16。使用x/s 根节点地址0查看data如果是字符串或x/b 根节点地址0查看单字节。使用x/gx 根节点地址8查看left孩子指针同理查看right。如果指针非空继续递归查看就能手动“走”遍整棵树。可以将这些地址和值记录下来用于后续的脚本化重建。接下来在树遍历或计算的函数入口设置断点。当程序运行到这里时观察传入的参数很可能就是之前输入的测试flag的地址。单步跟进这个函数观察程序是如何根据输入来访问树的。它是在做比较吗是在计算哈希吗还是在根据输入字符选择路径5.4 捕获加密运算与最终比较在遍历函数的末尾或者另一个明显的校验函数如strcmp、memcmp或一个循环比较处设置断点。程序执行到这里时通常会生成一个结果可能是一个计算出的哈希值或者一个变换后的字符串并将其与一个硬编码在程序里的目标值进行比较。使用LLDB的memory read命令将参与比较的两块内存一块是计算结果一块是目标值都打印出来。目标值很可能就是加密后的flag或者是一个中间校验值。记下这个目标值它是我们编写解密脚本的最终目标。注意事项动态调试时程序可能包含反调试技术。例如检测调试器ptrace的PT_DENY_ATTACH参数、检查进程状态等。如果发现程序行为异常如直接退出、分支跳转诡异需要警惕。对付简单的反调试可以在调试器中修改标志位或跳过检测代码。更复杂的可能需要静态Patch二进制文件。6. 算法还原与解密脚本编写通过静态分析和动态调试我们应该已经掌握了以下信息树的结构节点值、指针关系。程序如何使用输入遍历方式、运算规则。最终用于比较的目标值加密后的flag。现在需要将这个过程逆向编写一个解密脚本。脚本的核心逻辑就是模拟程序对树的正向操作但以目标值为起点反向求解出原始输入。6.1 重建树数据结构首先在Python中重建我们逆向出来的树。根据节点的复杂程度可以定义一个简单的类class TreeNode: def __init__(self, value, leftNone, rightNone): self.value value self.left left self.right right # 根据逆向结果手动构建树例如 # R # / \ # A B # / / \ # C D E node_c TreeNode(C) node_d TreeNode(D) node_e TreeNode(E) node_a TreeNode(A, leftnode_c) node_b TreeNode(B, leftnode_d, rightnode_e) root TreeNode(R, leftnode_a, rightnode_b)如果树的结构是编码在数组中的如层次遍历则需要编写一个从数组构建树的函数。6.2 模拟正向加密过程编写一个函数模拟程序对输入字符串的操作。例如如果程序是前序遍历树并将节点值收集起来与输入比较def encrypt_flag(input_str, tree_root): # 模拟程序的前序遍历收集节点值 collected [] def preorder(node): if node: collected.append(node.value) preorder(node.left) preorder(node.right) preorder(tree_root) # 假设程序将收集的值连接成字符串然后与输入比较 expected .join(collected) return input_str expected但更可能的情况是输入本身决定了树的遍历路径或参与运算。例如每个输入字符决定向左(L)或向右(R)最终到达的叶子节点值构成输出。那么正向函数就是def tree_cipher_encrypt(input_str, tree_root): output [] current_node tree_root for char in input_str: # 假设输入字符是L或R if char L: current_node current_node.left elif char R: current_node current_node.right else: return None # 非法输入 if current_node is None: return None # 路径错误 output.append(current_node.value) # 记录到达节点的值 current_node tree_root # 重置到根节点为下一个字符准备假设每字符独立 return .join(output)6.3 逆向推导解密算法解密是加密的逆过程。我们需要根据已知的输出即动态调试时捕获的目标值和树的结构反推输入。对于上面的路径选择例子如果知道目标输出字符串是X Y Z ...并且知道树的结构那么解密就是为输出中的每个字符在树中寻找值为该字符的叶子节点并记录从根节点到该节点的路径L/R序列。这个路径序列就是输入flag。def tree_cipher_decrypt(target_output, tree_root): input_path [] for target_char in target_output: # 在树中搜索值为target_char的节点并记录从根到它的路径 path find_path_to_value(tree_root, target_char, path[]) if path is None: return None # 在树中找不到该字符 input_path.extend(path) # 假设路径是字符列表如[L, R] return .join(input_path) def find_path_to_value(node, target, current_path): if node is None: return None if node.value target: return current_path[:] # 返回当前路径的副本 # 搜索左子树 left_path current_path [L] found_left find_path_to_value(node.left, target, left_path) if found_left: return found_left # 搜索右子树 right_path current_path [R] found_right find_path_to_value(node.right, target, right_path) if found_right: return found_right return None如果加密过程是表达式树计算那么解密可能涉及求解方程或逆向运算这通常更复杂可能需要使用约束求解器如Z3来求解。但考虑到是“baby”级别算法通常设计为可逆的比如简单的异或、加减、置换等。6.4 整合与验证将重建的树、模拟的加密函数和编写的解密函数整合到一个脚本中。首先用解密函数输入捕获到的目标值得到候选的flag。然后用这个候选flag作为输入运行模拟的加密函数看输出是否与目标值一致。如果一致那么解密就成功了。最后将解密得到的字符串提交到题目环境中进行验证。通常CTF平台会有一个网络服务或本地可执行文件输入flag后会返回是否正确。常见问题解密脚本运行后得到乱码或明显不对检查以下几点1. 树的结构是否还原正确节点值和指针关系是否与二进制中完全一致2. 遍历顺序前序、中序、后序是否正确3. 加密/解密算法中是否有额外的变换如字符ASCII码加减固定值被遗漏4. 目标值是否提取正确有时比较的是哈希值如MD5需要确保提取的是完整的哈希字节而不是其字符串表示的一部分。动态调试时多打印一些上下文内存确保没有漏掉数据。