Zig逆向实战:CTF题zig-show的特征识别与破解
发布时间:2026/10/9 11:38:15 锦皓数字建站

DEFCON CTF qualifier 最后一个凌晨桌面只剩一道zig-show。文件名里的 zig 不是某种压缩算法首字母而是今年绕不开的 Zig 语言。Zig 编译出来的二进制在我印象里一直是个冷门怪东西没有标准库依赖、符号特别少、panic 信息藏得深可一旦摸清楚它的脾气它甚至比 C 写的题更好认。这篇 Write-up 我尽量把从零开始的思路写全适合正在入门逆向、想了解 Zig 程序怎么分析、以及遇到类似反调试情况不知道怎么下手的读者。先说结论zig-show表面上是个演出节目单程序让你输入票号和名字实际上就是两道串起来的校验题。前半段是带魔数的异或校验后半段是一张 256 字节的 S 盒置换表。难点不在算法本身而在你怎么从 Zig 混淆痕迹和反调试里把它扒出来。整场比赛里队友帮我卡住了前面两道 Pwn我才有时间在这题上慢慢磨下面按我实际分析的顺序讲。1. 开局Zig 语言在二进制里留下的三种指纹拿到zig-show这个 ELF 文件第一步不是急着拖进 IDA而是先看它是用什么编译器产出的。Zig 编译器和 Rust、Go 不一样它默认不链接 libc还会把std的启动代码整个编进去所以二进制里有非常固定的特征字符串。用file和strings扫一眼就能锁定方向。$ file zig-show zig-show: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, not stripped $ strings -n 6 zig-show | head -50 ... /usr/lib/zig/lib/std/start.zig /usr/lib/zig/lib/std/panic.zig zig-show.zig:29:5 error.Nope ...第一类指纹就是这种/usr/lib/zig/lib/std/...的路径串。Zig 的 panic 处理和 C 的assert不同它会在运行时打印源文件路径和行号比如zig-show.zig:41:5这些字符串在去掉调试符号之后仍然会留在.rodata里。看到它们基本可以确定这是 Zig 写出来的程序。第二类指纹是符号表里的入口函数。Zig 的标准启动流程不是传统 C 的_start - main而是_start - std.start - callMain - main。如果二进制没有 strip你用readelf -s直接能看到std.start、callMain、posixWrite这类 Z 字打头的符号。strip 之后符号少了但反汇编里callMain那一层调用顺序还是能看出来。第三类指纹是错误处理特征。Zig 用 error union 表达错误常见的写法是return error.Nope;。在二进制里体现为.rodata中存在一堆error.Nope、error.BadTicket这样的字符串而正常 C 程序不会有这种命名。如果你在字符串窗口里搜error.经常能直接把校验分支的提示消息找全。我做了一个简单对照表方便分类特征ZigRustGopanic 字符串zig-show.zig:xxrust_panicruntime.throw入口符号std.start/callMain_ZN...一长串 mangleruntime.main错误信息error.Nopeunwrap failedpanic: runtime error静态链接倾向高中高看到这里你大概明白了Zig 题目的第一手线索往往不需要深度逆向strings和readelf就能给你画出地图。这一步的教训是别急着把 Ghidra 打开先让文件自己开口说话。2. 顺着演出脚本圈定主逻辑区域确定了是 Zig 程序之后我直接跑了一遍看它正常情况下的交互行为。$ ./zig-show ############################################ # # # WELCOME TO THE ZIG-SHOW # # Tonights program: A Tale of a Flag # # # ############################################ Enter your ticket and name (32 bytes):输入什么都会先走到一个长度检查。我随便塞了 32 个A返回Invalid show ticket。这一步告诉我们主逻辑分阶段先验票号再验名字。接下来打开 Ghidra不急着搜函数而是从字符串的交叉引用切入。Zig 的std.debug.print调用很容易认——它本质上是一堆底层 IO 调用不像 C 的puts那么干净但打印的文案字符串位置会直接指向调用它的代码块。我搜Welcome to the zig-show交叉引用到了0x4012a0这个函数。这个函数规模不大内部调用了两个子函数地址分别在0x401160和0x4011c0。用 Ghidra 的 F5 反编译看到的是被 Zig 风格扭曲过的 C 伪代码undefined8 FUN_004012a0(void) { char input[32]; FUN_00401040(Enter your ticket and name (32 bytes): ); read(0, input, 0x20); if (FUN_00401160(input) 0) { FUN_00401040(Invalid show ticket\n); return 0; } if (FUN_004011c0(input 4) 0) { FUN_00401040(Invalid guest name\n); return 0; } print_flag(input 4); return 0; }需要提醒一句Zig 的main返回值用的是noreturn或voidF5 给的伪代码里return 0是反编译器脑补的不用管。真正的逻辑在两个校验函数里。这里有一个很关键的细节input 4看起来很奇怪。原始 Zig 代码应该是把input[0..16]当作票号input[16..32]当作名字传入。Ghidra 在这类数组切片上经常推断出偏移量你得结合函数签名的长度自己纠正。比如FUN_004011c0的第二个参数如果明确是 16 字节缓冲区那就说明实际传的是input[16..32]不是input 4。这种伪代码会撒谎的情况在 Zig 逆向里很常见因为 Zig 不像 C 有明确的数组边界概念切片操作会编译成基础指针加长度反编译器经常迷失方向。我的做法是每个函数都先看汇编层确认它是lea reg, [rbp - 0x20]还是lea reg, [rbp - 0x10]再去读伪代码。3. 反调试与第一次动态调试静态分析还没走多远我发现main的开头不是打印横幅而是先调用了一个可疑函数0x4013c0。这个函数很小汇编长这样mov eax, 0x65 xor edi, edi xor esi, esi xor edx, edx xor r10d, r10d syscall test eax, eax jz ok_path lea rdi, [rip debugger_msg] call std.debug.paniceax 0x65在 x86_64 Linux 下是系统调用号 101也就是ptrace。配合rdi 0PTRACE_TRACEME这程序在告诉你它检测自己是不是被调试器跟踪了。如果ptrace(PTRACE_TRACEME)返回-1说明已经有调试器在跟踪它它就会直接 panic 退出返回0则说明环境干净继续演出。比赛环境里大家都是授权来打题的绕这种检查是常规操作。我采用的方案是静态 patch把syscall指令直接改成xor eax, eax。syscall是 2 字节xor eax, eax也是 2 字节长度完全一致不会破坏后面的指令流。原始字节序列0x4013d0: 0f 05 syscall 0x4013d2: 85 c0 test eax, eax改成0x4013d0: 31 c0 xor eax, eax 0x4013d2: 85 c0 test eax, eax这样test eax, eax得到零标志位jz必定走正常分支。用 Python 或者printf直接改文件偏移就行改完加个chmod x重新跑。如果你不想动文件也可以在 gdb 里绕过。关键点是断点要下在syscall的下一行因为syscall执行结束后返回值才会写进eax。操作顺序是这样(gdb) b *0x4013d0 (gdb) run (gdb) nexti (gdb) set $eax 0 (gdb) continue这里还有个小坑ptrace(TRACEME)是在main最开始调用的而 gdb 启动程序时它已经用 ptrace 机制接管了进程。所以程序会认为我被跟踪了静态 patch 是最省事的。动态调试我主要用于看清逻辑最终 payload 可以用干净的程序跑。除了 ptrace我还注意到程序里有一处rdtsc前后计数比较的代码时间窗口差得离谱就会走隐藏分支。这种反调试不会影响静态分析但如果你在 gdb 里疯狂 step 单步很容易踩中。我一般会先把rdtsc相关的比较结果改成固定值或者干脆在断点处直接跳过那段逻辑。比赛里时间紧能 patch 就不要反复试错。4. 票号校验和名字校验拆开看两套算法4.1 票号校验一个带着 TEA 魔数的异或循环绕过反调试后我重新在0x401160下断单步看它如何处理输入的前 16 字节。反编译之后的逻辑很清晰就是逐字节异或再比较uint32_t key 0x9E3779B9; // TEA 增量常数 uint8_t *ticket_ref 0x403f20; bool check_ticket(uint8_t *input) { for (int i 0; i 16; i) { uint8_t kb (uint8_t)(key ((i * 2) 7)); if ((input[i] ^ kb) ! ticket_ref[i]) return false; } return true; }0x9E3779B9是 TEA 系列算法里非常经典的黄金比例魔数CTF 老玩家看到它就条件反射。Zig 程序里嵌这个数更像是给逆向者留的暗示别慌这是标准套路。整个校验的核心不是加密而是拿 key 的某几个字节做了个伪随机序列然后和输入异或最后跟固定的ticket_ref数组比对。因为(i * 2) 7只在 0 到 7 之间循环所以实际是用 key 的低 8 位、第 9 到 16 位这种 8 位切片反复作为异或掩码。逆向的时候只要把ticket_ref抄出来再对每个i算一次掩码就能恢复出合法票号。我直接在.rodata对应的0x3f20偏移处 dump 出来的参考数组是5a a2 ea b0 e2 b1 f4 e9 6f be f5 c0 6a b2 f8 bc用掩码异或回去之后得到了一段可读字符串ZIG-SHOW-2025看到这个结果我还挺感慨票号校验看似绕实际上就是厂家留给你的节目单指向今年的比赛和题目名。这是 Zig 题里常见的趣味彩蛋不直接影响解题但能确认你的推导方向没问题。4.2 名字校验256 字节 S 盒置换真正的重点在第二个校验函数0x4011c0。它的逻辑更短uint8_t enc_name[16] { 0x??, ... }; uint8_t sbox[256] { 0x??, ... }; bool check_name(uint8_t *name) { for (int i 0; i 16; i) { if (sbox[name[i]] ! enc_name[i]) return false; } return true; }我看了一眼 Ghidra 里的sbox它是一个 256 字节的完整置换表——也就是 0 到 255 每个值恰好出现一次。程序拿输入名字的每个字节当成下标查表后的结果和一个固定的enc_name数组逐字节比较。这种结构在 Zig 里很值得展开一句S 盒大概率是编译期用comptime生成的。Zig 支持在编译阶段执行代码比如用线性同余生成器把一张表算好然后直接把最终结果固化到.rodata。你在二进制里只能看到表本身看不到任何生成表的循环代码。原理上有点像 C 的constexpr但 Zig 的comptime用得更随意经常出现在各类常量构造里。一个可能的生成逻辑长这样const sbox: [256]u8 blk: { var table: [256]u8 undefined; var x: u8 0; for (table) |*b| { x % 7; x ^ 0x5a; b.* x; } break :blk table; };这不一定是题目用的公式我只是想说明如果你在反汇编里找不到生成的循环不要怀疑自己那是comptime的锅。要做的不是逆向生成过程而是直接把表 dump 出来用。因为置换表是可逆的求输入名字就很简单对enc_name的每一个字节在sbox里找它出现在哪个下标那个下标就是合法名字字节。这本质上是查逆置换表。5. 从 S 盒提取到最终输入构造把两张表从二进制里弄出来是这道题最机械的一步。用 Python 直接按文件偏移读就行读之前先用readelf -x .rodata确认一下段的位置和权限。关键偏移数据文件偏移虚拟地址长度sbox0x3d480x403d48256ticket_ref0x3f200x403f2016enc_name0x40a80x4040a816我的解题脚本长这样#!/usr/bin/env python3 from pathlib import Path data Path(zig-show).read_bytes() # 提取 S 盒 sbox_off 0x3d48 sbox data[sbox_off:sbox_off 256] # 提取票号参考数组 ref_off 0x3f20 ticket_ref data[ref_off:ref_off 16] # 提取名字密文 enc_name_off 0x40a8 enc_name data[enc_name_off:enc_name_off 16] # 票号求解input[i] ^ kb ticket_ref[i] magic 0x9E3779B9 ticket bytearray() for i in range(16): kb (magic ((i * 2) 7)) 0xFF ticket.append(ticket_ref[i] ^ kb) # 名字求解sbox[name[i]] enc_name[i] inv_sbox [0] * 256 for idx, val in enumerate(sbox): inv_sbox[val] idx name bytes(inv_sbox[c] for c in enc_name) payload bytes(ticket) name Path(payload.bin).write_bytes(payload) print(ticket , bytes(ticket)) print(name , name) print(payload , payload.hex())跑完之后得到 32 字节的合法输入。注意它大概率包含不可打印字符所以别直接塞到终端回显里用文件喂给程序最稳$ python3 solve.py ticket bZIG-SHOW-2025 name b\x17\x83... $ ./zig-show payload.bin ... Ladies and gentlemen, the grand finale: ctf{Z1g_Sh0w_1s_4_L1v3_Sh0w}如果你在这个过程中发现 dump 的表和程序行为对不上先检查是不是文件偏移算错了。Zig 的.rodata可能被链接器对齐过不同编译参数的偏移会差那么几十字节不要照抄别人的偏移直接用readelf -S找到自己二进制的段起点再算。另外如果票号校验不是异或而是散列比如 FNV 或自定义 LFSR那就没法用简单的逆运算这时候可以上 Z3。不过我处理 CTF 逆向题的习惯是能纯数学求逆就绝不引入约束求解器因为 SMT 求解在真实比赛里经常会超时反而拖时间。6. 拿到二进制之外固件版 zig-show 的思路扩展这道题赛后我在仓库里还看到有人提了另一个版本的玩法用 Zig 交叉编译到 ARM Cortex-M以 STM32 固件的形式出题文件名依然是zig-show.bin。这个思路很贴近实际因为 Zig 对嵌入式支持非常好用zig build-target thumb-freestanding-eabi可以非常干净地产出裸机固件。如果你遇到的是固件版第一步不是搜字符串而是先分析向量表。STM32 的固件默认加载在0x08000000文件开头第一个 4 字节是初始栈指针__stack第二个 4 字节是Reset_Handler的地址。Ghidra 导入裸二进制时要指定处理器为ARM:LE:32:v7M或 v8M加载地址填0x08000000。Zig 固件版的入口逻辑和 ELF 版不完全一样它没有posix.start而是走start.zig的裸机启动路径包括设置栈顶、清 BSS、跳callMain。你在反汇编里能看到几个非常短的函数名字可能被 strip 成FUN_080001xx但函数尾部的b 0x08000xxx跳转关系还是能看出调用链。分析固件版时sbox这类常量表不会在.rodata而会被放进.rodata或直接并到 flash 里。因为裸机程序没有 ELF 段表我习惯用arm-none-eabi-objdump -D -b binary -m arm把固件 dump 成汇编再人工定位字符串引用。如果内存空间允许也可以用binwalk先看一眼有没有可识别的 Zig 特征头。固件版的动态调试和 ELF 版不一样不能直接 gdb attach。一个比较稳的路径是用 QEMU 模拟 STM32再开-gdb调试qemu-system-arm -machine stm32vld -nographic -kernel zig-show.bin -gdb tcp::1234 -S然后在另一个终端(gdb) target remote :1234 (gdb) x/16i $pc (gdb) x/8bx 0x08000000模拟器跑 UART 输入输出会稍微有点别扭但核心逆向流程完全没变找输入处理函数、还原校验循环、提取常量表、求逆。所以如果你只做过 x86 的 ELF 逆向也不必怕固件题思维方式是一模一样的。7. 复盘几条对 Zig 逆向有用的经验题解完了回头总结一下。zig-show本身算法不难难在识别语言运行时特征和绕过反调试。下面是我这次实际踩出来的几条经验。第一Zig 的字符串路径是最高效的路标。只要在strings里看到/usr/lib/zig/lib/std/...或者error.Nope就能确定语言类型然后直接去搜 panic 路径附属的源文件行号那通常是逆向者最好的入口。zig-show.zig:41:5这种东西虽然不能直接告诉我们 flag但能帮你快速定位主函数位置。第二不要依赖 IDA/Ghidra 的 F5 伪代码理解 Zig 的切片参数。Zig 的切片和错误联合类型会让反编译器的类型推断跑偏经常出现input 4这类误导。遇到疑似切片操作回到汇编看寄存器赋值确认传的是哪个偏移。第三comptime生成的常量表是 Zig 逆向的一道坎处理方式就是别去逆生成逻辑。Zig 在编译期算完二进制里只留结果所以你顺着交叉引用找到一张 256 字节的置换表就直接当成黑盒数据用。数学上可逆就直接逆不可逆就 Z3 上不要试图还原编译器期的算法。第四反调试 patch 要精准。遇到ptrace(PTRACE_TRACEME)优先找syscall那 2 字节改成xor eax, eax比改test、改jz都干净。如果后面还有rdtsc时间差检测分析阶段尽量少单步用断点跳段代替长距离逐步调试。第五工具链比想象中重要。我这次用的主力是 Ghidra 加 gdb配合 Python 处理偏移和表数据。如果你经常接触这类题建议把rizin和pwntools也装好rizin 的脚本化提取比 GUI 顺手很多pwntools 可以直接交互式发送 payload。我把这轮用得最顺的工具整理在下面阶段工具用途文件识别file,binwalk判断架构、文件类型字符串分析strings,grep找 Zig 特征和提示文案静态逆向Ghidra, rizin还原主流程、提取常量表动态调试gdb pwndbg绕反调试、确认分支走向数据提取Python,readelf解析偏移、构造 payload固件模拟qemu-system-arm分析 ARM 固件版最后分享一个日常习惯每次拿到逆向题我会先花十分钟把文件特征记下来写成一个 text 笔记包括编译器指纹、入口地址、关键字符串、反调试类型。看起来多此一举但真到比赛后半程人已经是半迷糊状态这种笔记能帮你快速回到分析状态。zig-show这题的解法本身不花哨但它的分析路径恰好覆盖了 Zig 逆向的典型问题值得在本地复现时多玩几遍。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。