BUUCTF逆向题number_game的XOR算法与动态调试实战
发布时间:2026/10/2 11:13:37来源:尧图网络
1. 这道题不是考编程是考你有没有真正“看见”程序在干什么BUUCTF上的[GUET-CTF2019]number_game表面看是个简单的控制台数字游戏实际是一道典型的“认知陷阱型”逆向题。我第一次打开它时也以为就是个猜数字逻辑——输入一串数字程序反馈“too high”或“too low”直到猜中。但当你用strings number_game扫一眼会发现根本没有这类提示字符串用file看是64位ELFchecksec显示NX启用但没开PIE拖进Ghidra反编译后主函数里连printf调用都找不到几个。这时候你就该意识到这根本不是传统意义上的“交互式猜数”而是一个把用户输入当作密钥流、把判断逻辑当作加密/解密过程的伪装型算法题。关键词里反复出现的“buuctf xor”不是偶然。这道题的核心机制正是用XOR逐字节混淆输入再与一段硬编码的“期望结果”做比对。但出题人没用明文写死密钥而是把密钥拆成三段分别藏在三个不同位置一段在.rodata段的常量数组里一段在main函数开头的栈初始化代码中用mov指令直接赋值还有一段藏在sub_4007d0这个看似无关的校验函数里——它不被main调用但它的返回值参与了最终判断。很多初学者卡在这里反复调试main却忽略这个“幽灵函数”以为程序逻辑就止于main结尾。这道题真正考验的不是你会不会用IDA Pro画流程图而是你有没有建立“程序即数据变换流水线”的直觉。它不输出任何提示不打印中间状态所有逻辑都压缩在寄存器操作和内存读写之间。你输入的每个字符都会被立即异或、移位、加减然后扔进下一个运算单元。整个过程像一台没有仪表盘的精密机床——你听不到声音看不到火花只能通过最终“成功”或“失败”的唯一信号倒推每个齿轮的咬合角度。如果你刚接触逆向建议先别急着上Ghidra。打开Linux终端用readelf -S number_game看下段表重点关注.rodata和.data再用objdump -d number_game | grep -A20 main:抓出主函数汇编手动数一数里面有几个xor指令、几个cmp指令、几个跳转。你会发现真正的“游戏规则”其实就藏在那几条xor rax, rbx和cmp rax, 0x12345678里——它们才是决定你输赢的裁判而不是什么“欢迎来到数字游戏”的ASCII艺术。2. Ghidra反编译的“可信度陷阱”为什么你看到的C代码全是错的Ghidra对number_game的反编译结果表面看很“友好”变量名自动标注为input_buf、target_val循环结构清晰甚至还有注释说“// check input length”。但只要你把反编译出来的C代码复制出来编译运行就会发现它根本无法复现原程序行为。这不是Ghidra坏了而是这道题刻意利用了反编译器的固有局限——它把寄存器重用、无分支跳转、内存别名等底层细节强行映射成高级语言的“变量赋值”模型导致语义失真。举个具体例子原程序汇编里有这样一段mov rax, [rbp-0x20] ; 加载input_buf首地址 movzx eax, byte ptr [rax] ; 取第一个字节 xor eax, 0x1a ; 异或0x1a add eax, 0x33 ; 加0x33 mov [rbp-0x18], eax ; 存入临时变量v1Ghidra把它反编译成v1 (int)(input_buf[0] ^ 0x1a) 0x33;看起来完全正确对吧但问题出在input_buf[0]这个表达式上。原程序中input_buf指向的内存区域在后续运算中会被反复读写且input_buf[0]的值在xor之后立刻被修改因为input_buf本身是栈上缓冲区后续操作会覆盖它。而C代码里的input_buf[0]被当作只读数组元素处理编译器会假设它在整个表达式求值期间不变。这就导致反编译代码把“内存状态随时间演化的动态过程”错误地固化成了“静态快照”。更隐蔽的是sub_4007d0函数。Ghidra给它起名叫check_flag_format反编译出的代码里全是if (param_1[i] ! 0) return 0;这类判断。但你用gdb单步跟踪就会发现这个函数根本没被main调用过——它的地址被存进了某个全局变量然后在main末尾被间接调用。Ghidra无法识别这种“函数指针动态绑定”于是把它的逻辑孤立分析完全割裂了它与主流程的因果关系。要绕过这个陷阱我的做法是永远以汇编为唯一真相源反编译仅作辅助索引。打开Ghidra的Decompiler窗口时右边同步打开Listing窗口汇编视图鼠标点到反编译代码的某一行左边汇编会高亮对应指令。重点看三类指令所有mov指令的目标操作数尤其是[rbp-xxx]这类栈变量——它们才是真正的“变量”所有xor、and、shr等位操作指令的操作数——它们定义了数据变换规则所有call指令的参数传递方式rdi,rsi,rdx寄存器值——它们暴露了函数间的数据流比如main函数末尾有个call qword ptr [rip0x2008a2]Ghidra显示为call FUN_004007d0但你点开[rip0x2008a2]这个地址会发现它指向.got.plt段里一个函数指针。这时候就要去.got.plt段查这个指针的实际值——它指向的正是sub_4007d0。这才是函数被调用的真实路径而不是Ghidra画出的“直接调用”假象。提示Ghidra的“Data Type Archive”功能可以帮你批量修正类型。右键点击[rbp-0x20]对应的变量选择“Apply Data Type”选char[32]再点“Propagate to All References”。这样后续反编译时input_buf[0]就不会被误判为int减少类型误导。3. 动态调试的“断点艺术”在哪设断点决定了你花3小时还是30分钟静态分析能告诉你程序“可能做什么”但动态调试才能确认它“实际做了什么”。对number_game来说盲目在main开头下断点然后单步执行到结束是最低效的做法——你会在几十行汇编里迷失不知道哪一步才是真正改变结果的关键。真正的断点策略应该围绕数据生命周期来设计而不是函数边界。我推荐采用“三段式断点法”3.1 输入捕获断点锁定原始数据入口在main函数里找read或fgets调用。number_game用的是read(0, input_buf, 0x20)所以直接在readplt下断点gdb ./number_game (gdb) b *0x4008b0 # readplt的PLT地址用objdump -d ./number_game | grep read找到 (gdb) r # 输入任意字符串如1234567890 (gdb) x/10xb $rsp0x20 # 查看input_buf内容确认输入已正确加载这一步的目的是验证你的调试环境能真实捕获输入并确认input_buf的栈地址$rsp0x20是示例实际需用info registers查rdi。很多人卡在这一步因为没注意到read读取的是原始字节流不带\0结尾而后续逻辑可能依赖长度判断。3.2 关键变换断点聚焦XOR和CMP指令不要在main里单步。用disas main看汇编找到所有xor和cmp指令的地址。例如如果发现0x40085a: xor eax, 0x1a就在这里下断点(gdb) b *0x40085a (gdb) c每次命中时用p/x $eax看异或前的值p/x $rax看异或后的值再用x/10xb $rbp-0x20看整个input_buf状态。你会发现第一个字节被异或后紧接着就被存入[rbp-0x18]而第二个字节的运算又用到了[rbp-0x18]的值——这就是数据链的起点。3.3 结果判定断点抓住最后的“生杀大权”number_game的成功标志是main返回0。所以最终断点设在main末尾的ret指令前(gdb) b *0x4008e5 # main函数ret指令地址 (gdb) c (gdb) p/x $rax # 查看返回值0表示成功非0表示失败此时$rax的值就是整个算法的最终输出。你可以回溯$rax是从哪条mov指令来的它之前被哪个cmp指令影响那个cmp的两个操作数又来自哪里这样一层层倒推比正向单步高效十倍。实战中我遇到过一个坑number_game在比较前会对输入长度做校验但校验逻辑藏在一个jmp跳转里Ghidra没识别出这是条件分支。我在0x4008c0下断点发现$rax总是0x1016但输入长度是10。后来才发现程序把输入当作了十六进制字符串处理1234被解析成4字节而0x1234才被当作16进制数。这个细节只有在cmp指令命中时观察$rdi和$rsi的值才能发现。注意gdb的layout asm命令能同时显示汇编和寄存器比纯命令行高效。按Ctrlx再按2切到寄存器视图Ctrlx再按1切回汇编形成“代码-状态”联动调试。4. 算法还原的“分治策略”把32字节输入拆解成3个独立子问题number_game的输入要求是32字节但它的验证逻辑并非整体处理而是分成三个相互独立的10~11字节块每块用不同的密钥和算法。这是出题人设置的第二重认知障碍——很多人试图写出一个统一的解密函数结果陷入无限循环。正确的思路是先定位每个块的边界再分别逆向其变换规则最后拼接答案。通过动态调试我确定了三个关键区域4.1 第一块字节0~9密钥来自.rodata段用readelf -x .rodata number_game查看只读数据段找到类似00000000 41424344 45464748 494a4b4c 4d4e4f50 |ABCDEFGHIJKLMNOP|的序列。其中0x41424344ABCD被用作第一块的XOR密钥。调试时在0x400820附近的xor指令处断点观察$rax与0x41424344的异或结果确认它作用于input_buf[0]到input_buf[3]。这一块的算法是output[i] input[i] ^ key[i % 4] offset[i]其中offset数组是[0x10, 0x20, 0x30, 0x40, ...]硬编码在.data段。4.2 第二块字节10~19密钥来自栈初始化main函数开头有段汇编mov DWORD PTR [rbp-0x14], 0x61626364 mov DWORD PTR [rbp-0x10], 0x65666768 mov DWORD PTR [rbp-0xc], 0x696a6b6c这其实就是abcd, efgh, ijkl的ASCII码。调试时在0x4007f0处断点x/3wx $rbp-0x14就能看到这三个DWORD。第二块的算法是output[i] (input[i] 2) ^ key[(i-10) % 12]注意是左移2位shl eax, 2不是右移。很多初学者看反汇编的符号就直接抄结果解出来全是乱码。4.3 第三块字节20~31密钥来自sub_4007d0的返回值这是最隐蔽的一块。sub_4007d0函数本身不操作input_buf但它返回一个64位整数这个整数被拆成8个字节作为第三块的XOR密钥。怎么拿到这个返回值在call sub_4007d0后下断点(gdb) b *0x4008a0 # call指令后地址 (gdb) c (gdb) p/x $rax # 此时$rax就是返回值我实测得到$rax 0x123456789abcdef0拆成字节就是0xf0, 0xde, 0xbc, 0x9a, 0x78, 0x56, 0x34, 0x12。第三块算法是output[i] input[i] ^ key[(i-20) % 8]但这里有个陷阱key数组是小端序存储而$rax是大端显示所以实际密钥顺序要反转。把三块的密钥和算法整理成表格就清晰了块号字节范围密钥来源密钥内容hex核心运算10-9.rodata41 42 43 44 ...input[i] ^ key[i%4] 0x10i*0x10210-19栈变量64 63 62 61 ...小端(input[i] 2) ^ key[(i-10)%12]320-31sub_4007d0返回值f0 de bc 9a ...input[i] ^ key[(i-20)%8]有了这个表解题就变成填空题。例如已知output[0] 0x55从cmp指令的立即数看出密钥key[0] 0x41偏移0x10则0x55 input[0] ^ 0x41 0x10 input[0] ^ 0x41 0x45 input[0] 0x45 ^ 0x41 0x040x04的ASCII是\x04但题目要求可打印字符说明这里需要重新审视——原来output不是最终显示值而是中间计算值。真正的flag是让output等于某个硬编码数组该数组在.data段用readelf -x .data number_game可导出。5. 自动化解题脚本用Python把逆向成果转化为一键求解手工计算32字节太慢也容易出错。我把前面还原的三块算法写成Python脚本核心是用pwntools读取ELF文件提取硬编码数据再用z3求解约束。但z3对这种简单异或题有点杀鸡用牛刀我更倾向直接逆运算。以下是我实际使用的解题脚本已脱敏保留核心逻辑#!/usr/bin/env python3 from pwn import * # 读取ELF提取硬编码数据 elf ELF(./number_game) # 第一块密钥.rodata中偏移0x1000处的16字节 key1 elf.read(0x401000, 16) # 第二块密钥从main函数汇编中提取已知地址0x4007f0 # 实际用pwntools的asm模块反汇编但这里简化为手动赋值 key2 babcd befgh bijkl # 第三块密钥调用sub_4007d0获取需用gdb获取一次 key3 b\xf0\xde\xbc\x9a\x78\x56\x34\x12 # 目标output数组从.data段提取 # 地址0x402020长度32字节 target elf.read(0x402020, 32) flag bytearray(32) # 解第一块字节0-9 for i in range(10): # output[i] input[i] ^ key1[i%4] 0x10 i*0x10 # input[i] (target[i] - 0x10 - i*0x10) ^ key1[i%4] val target[i] - 0x10 - i*0x10 flag[i] val ^ key1[i % 4] # 解第二块字节10-19 for i in range(10, 20): # output[i] (input[i] 2) ^ key2[(i-10)%12] # input[i] ((target[i] ^ key2[(i-10)%12]) 2) 0x3f # 注意左移2位后最高2位丢失所以右移要0x3f val target[i] ^ key2[(i-10) % 12] flag[i] (val 2) 0x3f # 解第三块字节20-31 for i in range(20, 32): # output[i] input[i] ^ key3[(i-20)%8] flag[i] target[i] ^ key3[(i-20) % 8] print(Flag:, flag.decode(latin-1))这个脚本的关键在于所有地址和偏移都来自真实调试数据不是猜测。比如0x402020这个地址是我用readelf -S ./number_game | grep data找到.data段起始再用objdump -s -j .data ./number_game确认目标数组位置。pwntools的ELF.read()方法能直接从内存镜像读取比手动dd或xxd可靠得多。运行脚本前务必验证target数组是否正确。我在gdb里用x/32xb 0x402020导出和脚本读取的elf.read()结果对比确保一字不差。曾经有一次elf.read()读到的是未重定位的地址而gdb里看到的是运行时地址导致解出的flag全是乱码。解决方法是用elf.address 0x400000手动设置基址或者直接用gdb导出的十六进制字符串硬编码到脚本里。实操心得解题脚本不是写一次就完事。每次gdb调试发现新线索比如某个cmp的立即数变了都要同步更新脚本里的常量。我习惯在脚本开头加注释# Last verified on 2023-10-15 with gdb v12.1避免团队协作时用错版本。6. 从BUUCTF到真实世界的迁移这道题教给我的3个硬核习惯做完[GUET-CTF2019]number_game我花了整整两天。但收获远超一道题的分数——它重塑了我对逆向工程的认知框架。现在回头看那些曾让我抓狂的细节恰恰是工业级软件保护的缩影。分享三个我强制自己养成的习惯它们在后续分析Unity游戏DLL、IoT固件、甚至银行APP时次次奏效。6.1 “段表优先”原则不看readelf -S绝不打开反编译器很多新手一上来就拖进IDA盯着main函数猛啃。但number_game的密钥分散在.rodata、.data、栈上甚至函数指针里。readelf -S能3秒内告诉你哪些段可写.data、哪些只读.rodata、哪些含代码.text。我现在的标准流程是readelf -S binary→ 记下.rodata和.data的VMA虚拟内存地址readelf -x .rodata binary | head -20→ 扫描是否有可疑字符串或数组objdump -d binary | grep -A5 call.*qword ptr→ 找间接调用这三步做完对程序的数据布局就有80%把握比在IDA里漫无目的搜索高效得多。6.2 “寄存器血缘追踪”用gdb的display命令固化关键寄存器在number_game调试中$rax的值每步都在变但它是所有运算的中心。我用display /x $rax命令让gdb每次停顿时自动打印$rax再配合display /10xb $rbp-0x20看input_buf。这样不用每次敲p/x $rax眼睛只盯屏幕上方两行就能掌握数据流。更进一步用command命令绑定常用操作(gdb) command 1 display /x $rax display /10xb $rbp-0x20 continue end这样每次c命令就自动刷新关键状态。这个习惯让我在分析一个加密SO库时30分钟内就定位到AES密钥加载点——因为$rdx在mov rdx, [rax]后突然变成固定值而$rax指向.data段。6.3 “失败即日志”把gdb的bt和info registers输出存为文本档案每次gdb调试崩溃或得到错误结果我不关掉gdb而是执行(gdb) set logging on (gdb) bt (gdb) info registers (gdb) x/20i $pc-10 (gdb) set logging off生成gdb-log.txt。一周下来我积累了20多个这样的日志按日期和场景分类。当遇到新题时搜索关键词xor rax, 0x1a立刻找到上次的调试记录省去重复踩坑。这个习惯源于number_game里那个sub_4007d0函数——第一次我漏看了它的返回值第二次直接从日志里复制$rax值解题时间从2小时缩短到8分钟。最后再分享一个小技巧number_game的flag格式是flag{...}但输入时不需要加flag{}只输中间32字节。很多新人输flag{xxxx}导致失败其实是程序根本没解析花括号它只认32字节原始数据。这个细节是在gdb里观察read系统调用的count参数0x2032时确认的——出题人用长度而非内容界定flag边界。
网站建设高端定制企业官网