
第一次接触BugKu的reverse题时我随手点开了EasyXor。这个名字起得相当直白几乎等于把考点写在脸上了一个easy难度、用xor异或实现的逆向题。可真正上手之后我才发现题目虽小涉及的知识链条却完整得值得写一篇记录从文件识别、静态分析、异或运算原理到最后的脚本还原每一步都有不少新手容易卡住的细节。这篇就按我实际的解题流程走一遍。读完你应该能完全掌握这一类“异或加密型CTF逆向题”的通用解法不光是这一道以后碰到类似的变体也能自己分析出来。1. 拿到题目先看名字EasyXor到底在提示什么很多人做逆向题喜欢直接拖进IDA一顿F5我觉得这个习惯得改一改。拿到题目文件先别急着动手花半分钟看看题面、看看文件名很多时候出题人已经把关键信息透露给你了。1.1 题目名称里的三个信息“EasyXor”这个名字可以拆成两个部分来看Easy难度定位说明这是入门级题目理论上不存在反调试、虚拟化保护、混淆这些劝退机制程序逻辑应该很直白。Xor核心考点明确告诉你这道题的关键运算是XOR也就是按位异或。所有分析都应该围绕这个运算展开。这两个信息一组合你的解题预期就很清晰了程序大概率会对输入或者某个关键数据做异或变换你需要找到异或的密钥key和密文cipher text然后做一次异或还原。1.2 这类题目常见的两种出题形态以我的经验CTF里带Xor的入门reverse题出题形态基本上逃不出这两种形态一输入校验型程序要求你输入一个字符串通常是flag内部对每个字符做异或运算然后和内置的某个数组比较。如果一致提示正确。这种形态的逆向思路是找到用于比较的数组密文和异或的key把密文逐字节异或回去得到真flag。形态二自解密输出型程序内部已经藏了一段密文和key运行时用异或运算解开这段密文然后直接打印出来。这种形态更简单有些甚至不用逆算法——你可以直接动态调试看程序输出或者手动在反编译结果里把解密后的字符串读出来。EasyXor属于哪一种在下一步的信息收集里就能判断出来。2. 开局情报收集不急着逆算法先让文件自己开口确定大致方向后我习惯在Linux环境下做一轮基础信息收集。很多人觉得这些命令“太基础”就直接跳过但实际上它们提供的信息能帮你少走很多弯路。2.1 用file命令确认文件类型拿到文件第一步永远是file这一步能告诉你文件到底是什么格式、运行在什么平台、是不是32位/64位。我当时的操作是这样的$ file EasyXor EasyXor: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 2.6.32, BuildID[sha1]..., not stripped这一行输出信息量不小ELF 64-bit LSB executable是Linux下的64位可执行文件所以动态调试要用gdb或类似工具反编译工具要选择支持x86-64架构的。not stripped没有去除符号表意味着函数名大概率保留着main函数可以直接定位。这在逆向里是个大利好省去了从一个入口点往上摸的麻烦。2.2 用strings命令捞取明文字符串接着用strings扫描文件中的可打印字符串。虽然文件名提示了Xor但程序运行时总会有些提示信息留下来。$ strings EasyXor ... Please input your flag: Right! Wrong! flag{%s} ...看到Please input your flag:基本就能实锤了这是典型的输入校验型程序程序运行后会等我们输入一个字符串然后判断对错。三个字符串拼在一起还能推测出程序的结构——读入输入和某个东西比较对则输出Right!错则输出Wrong!很可能在正确路径上还会用flag{%s}的格式打印flag。2.3 用checksec查看防护机制顺手跑一下checksec看看程序的保护机制$ checksec --fileEasyXor RELRO STACK CANARY NX PIE RPATH RUNPATH Symbols FORTIFY Fortified Fortifiable FILE Partial RELRO No canary found NX enabled No PIE No RPATH No RUNPATH 83 Symbols Yes 0 3 EasyXor逐个字段解释一下虽然对于这道题来说很多机制分析时用不上但养成看checksec的习惯对以后做复杂题很重要Canary栈保护标志显示No canary found说明没有栈溢出保护这题的重点本来也不在栈利用上。NXNX enabled栈数据不可执行不过题目不涉及shellcode注入这个字段看看就行。PIENo PIE程序没有开启地址随机化代码段和数据段地址固定用Ghidra或IDA静态分析时看到的地址就是实际运行地址调试时可以直接下断点。从这些信息可以判断这是一道完全不涉及攻击利用、纯逻辑分析的题目。防护机制基本都是默认关闭摆明了是让你安心做逆向分析的。2.4 直接运行程序观察交互逻辑静态情报摸完直接运行程序看看它的交互逻辑$ ./EasyXor Please input your flag: 12345 Wrong!输入任意一串东西返回Wrong。再随便换几个输入还是一样。到这里整个程序的行为模型已经建立起来了程序接收一段输入内部对输入做某种变换大概率是异或变换结果和内置密文比较一致输出Right不一致输出Wrong接下来需要把反编译结果拿出来找到“某种变换”的细节。3. 核心分析定位main函数和那段异或比较逻辑情报收集完进入正式分析阶段。这里我用的工具是Ghidra你也可以用IDA Freeware或者IDA Pro流程大同小异。3.1 用Ghidra反编译定位main函数Ghidra导入EasyXor文件后点击自动分析然后在符号树里找到main函数。因为文件是not strippedmain函数通常直接就能看到双击就能跳转到反编译视图。这时候看到的伪代码基本是这个模式undefined8 main(void) { char input[80]; int len; int i; printf(Please input your flag: ); scanf(%79s, input); len strlen(input); for (i 0; i len; i) { if ((input[i] ^ 0x1F) ! enc[i]) { puts(Wrong!); return 0; } } puts(Right!); printf(flag{%s}, input); return 0; }这段伪代码里每一行都值得仔细看scanf(%79s, input)读入字符串限制长度防止溢出。strlen(input)取输入长度作为循环边界。(input[i] ^ 0x1F) ! enc[i]核心校验逻辑。输入的第i个字符异或0x1F结果和enc数组第i个元素比较。全部相等就输出flag{%s}——注意这里只是把输入原样套进flag格式打出来说明我们要提交的就是存在里头的那个字符串本身。3.2 汇编层面对应哪些指令静态分析时看伪代码就够但如果你想更深入理解程序的真实执行流程可以切到汇编视图看对应指令。异或运算在x86-64里面对应的就是XOR指令关键的那段循环展开后大概长这样xor al, 0x1F movsxd rcx, eax cmp rcx, qword ptr [enc rax]对比一下伪代码里的(input[i] ^ 0x1F) ! enc[i]——这里的映射关系是把输入字符取出来用XOR指令和一个立即数做异或再和内存中存储的enc数组元素比较。如果你在分析别的题目时没看到清晰的伪代码直接在汇编里搜索XOR指令和CMP指令的组合也能很快定位到关键比较逻辑。3.3 提取关键数据密钥和密文数组通过反编译视图我们已经拿到了两个关键数据密钥key0x1F就是那个异或用的立即数。这个值写法上可能五花八门有可能直接写0x1F有可能是十进制的31甚至有可能是几个字节拼起来的数组。思路是找到异或运算操作的第二个操作数。密文enc比较用的数组。在Ghidra的伪代码里这个数组通常显示为enc[i]双击变量名可以跳到数据定义处看到一串十六进制字节。我当时看到的数据大概是这个形态为示意我做了简化真实数组以你反编译出的为准undefined enc[36] { 0x6b, 0x7a, 0x2d, 0x0b, 0x2f, 0x13, 0x2d, 0x1a, 0x12, 0x15, 0x24, 0x1f, 0x4d, 0x45, 0x48, 0x02, 0x51, 0x02, 0x4d, 0x2c, 0x43, 0x04, 0x55, 0x50, 0x46, 0x02, 0x4b, 0x45, 0x48, 0x58, 0x02, 0x50, 0x01, 0x5a, 0x54, 0x1a };这里有一个重要的判断点数组长度是不是和flag长度对应。我数了一下有36个字节说明期望的输入长度应该就是36位左右。如果你验证的时候发现密文数组比实际输入短那就要考虑循环里是否存在取模操作i % len之类这种情况我们后面会专门讨论。3.4 验证我们的判断动态调试确认静态分析给出的结论我习惯用动态调试再验证一遍。在比较循环处下个断点运行程序后输入一个测试字符串在断点处查看寄存器和内存数据可以确认异或的key和比较的数组确实如静态分析所示。这里用小gdb做个演示$ gdb ./EasyXor (gdb) b *0x401234 (gdb) run Please input your flag: AAAAAAAA在断点处查看内存(gdb) x/16bx 0x404000 0x404000: 0x6b 0x7a 0x2d 0x0b 0x2f 0x13 0x2d 0x1a 0x404008: 0x12 0x15 0x24 0x1f 0x4d 0x45 0x48 0x02这些数据和静态分析里看到的enc数组完全一致结论确认无误。提示如果题目开启了PIE地址随机化gdb里看到的运行时地址和静态分析地址不一样需要通过starti、info proc mappings等方式换算基址。EasyXor没开PIE直接读地址就行。4. 异或运算的数学底子为什么要用异或来做题到了这里题目的核心逻辑已经清楚了但我想多花一点篇幅讲透异或运算本身。很多新手知道a ^ key c然后反向c ^ key a但不理解为什么能反推也不知道什么场景下不能这么推。这些细节在后续做更复杂的异或题时会非常有用。4.1 异或的真值表和基本性质异或XOReXclusive OR的逻辑是两个比特相同则结果为0不同则结果为1。真值表特别简单ABA XOR B000011101110由真值表可以推出几个非常重要的性质归零律A XOR A 0一个数和自身异或等于0。恒等律A XOR 0 A一个数和0异或等于自身。交换律与结合律A XOR B B XOR A(A XOR B) XOR C A XOR (B XOR C)异或运算结果和顺序无关。自反性对称性A XOR B XOR B A这是异或能用于加密解密的根本原因。4.2 从加密逻辑到逆向推导有了自反性这个性质加密解密就变成了同一件事。把B看作加密密钥keyA看作明文加密时C A XOR key解密时A C XOR key。两个操作在数学上是完全相同的函数。这就是为什么出题人愿意用异或做入门题它不需要复杂的数学公式推导加密代码和解密代码几乎一模一样只是一方是正向使用一方是反向使用。从出题人的视角看它既考察了逆向分析能力又有一点点“密码学思维”在里面但难度又不会把初学者拦死。如果非要打个比方异或就像按开关一个开关拨动一次是开再拨动一次是关。密钥就是“拨动”这个动作数据就是开关的初始状态。无论是加密还是解密都只是拨动一次开关而已。4.3 异或的“软肋”与题目的局限性异或加密有一个非常著名的软肋只要你知道密钥或者知道一部分明文和对应的密文密钥立刻就能推出来不需要任何高深技巧。假设你知道C[i] M[i] ^ key其中M是明文flag的一部分C是密文那么直接移项就能得到key C[i] ^ M[i]。如果题目里的key是一个固定单字节比如0x1F一旦确定了key整个密文数组都能还原。更进一步如果key不是单字节而是有长度L的字节序列那么密码学上常用的破解手段是已知明文攻击CTF的flag格式固定为flag{...}开头的字符flag{你是知道的用密文的前5个字节异或flag{就能推出key的前5个字节再结合key的周期特性比如按4字节循环、按字符串长度循环等就能继续推出完整key。这个思路在解题中以“假设key是某个字符串”的形式经常用到后面会给出实例。5. 写Python脚本还原flag算法确认、key和密文确认剩下就是写脚本还原了。这一步思路固定代码也不长但有几个容易写错的点需要注意。5.1 最基础的还原脚本按照明文 密文 XOR 密钥这个公式最直接的脚本是这样cipher [ 0x6b, 0x7a, 0x2d, 0x0b, 0x2f, 0x13, 0x2d, 0x1a, 0x12, 0x15, 0x24, 0x1f, 0x4d, 0x45, 0x48, 0x02, 0x51, 0x02, 0x4d, 0x2c, 0x43, 0x04, 0x55, 0x50, 0x46, 0x02, 0x4b, 0x45, 0x48, 0x58, 0x02, 0x50, 0x01, 0x5a, 0x54, 0x1a ] key 0x1F flag .join(chr(c ^ key) for c in cipher) print(flag)运行后输出一段32个字符左右的字符串开头是flag{结尾是}中间是那段真正的flag内容。这里字符串的格式正好和程序里flag{%s}的输出格式对应。我在实际做这道题的时候还原后还顺手用程序本身验证了一下——输入还原出的字符串程序确实返回了Right!。这个验证习惯值得养成因为有时候你从几个不同版本的工具里导出的字节序可能不对或者中间位置漏了一位脚本输出看起来像但实际是错的。只有能通过程序校验的输出才是真正正确的。5.2 从二进制中自动提取密文数组上面的脚本手动把密文数组复制进去了但如果密文很长手动复制容易出错。更好的方式是从二进制文件中直接提取。一种通用的做法用objcopy把数据段导出或者直接在Python里解析ELF文件找到enc数组所在的偏移。对于这道题因为Ghidra已经给出了数组在内存中的虚拟地址可以这样操作$ objdump -s -j .data ./EasyXor.data段里就能看到enc数组的字节。再将字节粘贴进Python脚本即可。如果数组在.rodata段只读数据段就用-j .rodata。CTF的reverse题中比较用的密文数组经常放在.rodata或.data段。还有一种适用于复杂情况的技巧用readelf -x .rodata直接转储十六进制内容然后配合Ghidra的地址换算把数组对应的那一段挑出来$ readelf -x .rodata ./EasyXor5.3 key不是单字节的情况如果题目不是固定key而是多字节key脚本逻辑要稍微改一下。比如key是字符串ezxor长度为5加密时按input[i] ^ key[i % 5]处理解密脚本就变成cipher [...] # 密文数组 key bezxor flag .join(chr(c ^ key[i % len(key)]) for i, c in enumerate(cipher)) print(flag)关键在于i % len(key)这个操作它表示key循环使用。CTF里很多异或题都是这种滚动key的模式抓住“取模”这个特征就能一眼识别。如果你不知道key是什么但有格式线索比如flag开头是flag{就可以用已知明文攻击的方式反推key。例题如下cipher [...] known_plain bflag{ key_len 5 # 假设key长度为5这个长度可以使用Kasiski检验或从循环逻辑中推断 # 用已知明文反推key recovered_key [0] * key_len for i, cp in enumerate(known_plain): recovered_key[i % key_len] cp ^ cipher[i] # 用反推出的key还原全部明文 flag .join(chr(cipher[i] ^ recovered_key[i % key_len]) for i in range(len(cipher))) print(bytes(recovered_key).decode(), flag)这个技巧在面对换了个key的同类题目时几乎是通杀。6. 这类入门逆向题的通用套路与我的实战心得最后聊点解题之外的收获。做了几十道类似题之后回头看EasyXor这种题目虽然简单但它建立了一套非常标准的分析框架这套框架是可以迁移到更复杂的题目上的。6.1 见到异或题的基本应对步骤如果你在逆向题里发现异或运算建议按这个顺序推进确认key和密文的位置。key可能是一个立即数单字节、一段字符串、或者由数组生成的序列密文一般是在数据段定义的数组从代码中可以看到它的引用地址。确认循环边界和索引方式。关注循环是用strlen取实际输入长度还是固定长度以及是否存在i % keyLen的取模操作。确认异或的方向。大多数情况下是input[i] ^ key但也存在key ^ input[i]、input[i] ^ input[i-1]这种链式异或。链式异或的还原要从前向后或从后向前逐字节推导本质上也是利用异或的自反性。写脚本还原并验证。还原后输入到程序里验证或者检查是否为flag{}格式且长度合理。6.2 我踩过的小坑这里记录几个我在做这类题目时实际踩过的坑对新手比较有参考价值坑一把单字节key当成数组名。一开始没注意区分key是个立即数还是数组直接复制脚本结果输出一堆乱码。后来仔细看反编译伪代码才发现key就写死在代码行里的0x1F上根本不是数组。注意观察变量的类型——常数在伪代码里通常是十六进制立即数数组则是有名字的引用。坑二加密方向弄反。程序里写(input[i] ^ 0x1F) ! enc[i]我已经知道了密文enc解密时就应该enc[i] ^ 0x1F恢复input。但有次遇到变体题目加密逻辑写成(key ^ input[i]) ! enc[i]我下意识按input[i] ^ key去推结果没问题因为异或满足交换律。真正出问题的是链式异或input[i] ^ input[i-1]这种不能交换顺序必须严格按照算法一步步来。遇到链式异或最好的办法是先手推前三个字节确认方向正确再写完整脚本。坑三忽略了密文数组的长度。有一次密文数组后面跟着另一个全局变量某工具自动分析时把两个数组变成了一个脚本还原到后期全是乱码。这个问题的排查方法是仔细看程序中数组的引用范围循环的上界以及数据段中数组应该占用的字节数。如果发现还原结果开头是正确的flag{但后面全是乱码大概率就是数组边界判断出了问题。6.3 我的exp模板我自己习惯在本地维护一个通用的逆异或脚本模板遇到类似题目直接改几个参数。给你参考一下#!/usr/bin/env python3 # -*- coding: utf-8 -*- import sys def xor_decrypt(cipher, key, key_modesingle): key_mode: single单字节 / sequence序列 result [] for i, c in enumerate(cipher): if key_mode single: k key else: # sequence k key[i % len(key)] result.append(c ^ k) return bytes(result) if __name__ __main__: # 粘贴自反编译工具 cipher [ 0x6b, 0x7a, 0x2d, 0x0b, 0x2f, 0x13, 0x2d, 0x1a, 0x12, 0x15, 0x24, 0x1f, 0x4d, 0x45, 0x48, 0x02, 0x51, 0x02, 0x4d, 0x2c, 0x43, 0x04, 0x55, 0x50, 0x46, 0x02, 0x4b, 0x45, 0x48, 0x58, 0x02, 0x50, 0x01, 0x5a, 0x54, 0x1a ] key 0x1F # 改成你从伪代码中看到的key plain xor_decrypt(cipher, key, single) print(plain.decode())这个模板的好处是把密钥类型和密文粘贴解耦了遇到不同形态的异或题只改cipher和key两个变量就行。6.4 从EasyXor到更复杂的世界如果你刚接触reverse我很建议把EasyXor这类题完整走一遍然后把分析过程中用到的工具链和思维方式固化下来。接下来可以往这几个方向扩展多字节key 文件名/字符串提示很多题把key藏在文件名字符串、命令行参数、环境变量甚至图片文件里。异或与其他运算组合比如先加减、再异或或者异或后做S盒替换。遇到这类题按顺序依次逆运算即可每一步都遵守“从最后一步逆到第一步”的原则。程序内自解密用upx加壳的程序壳加载时会执行异或解密逻辑把原本的代码还原到内存中。分析这类程序时可以在解密完成的断点处dump内存或者关注解密循环的解密目标地址。我自己的习惯是每做完一道题都把反编译出的关键伪代码和还原脚本存成一个md文件注明key、密文地址、还原思路。时间久了你会发现很多所谓的新题追根溯源都是这些基础知识点的排列组合。EasyXor只是一个起点但它背后“找异或逻辑——提取key和密文——写脚本还原——验证结果”这个套路足够你解决一大片入门逆向题了。