文件加密器逆向实战:从样本分析到解密脚本全流程 接到一个活儿一个文件加密器的样本要求还原它的加密逻辑把被它加密的一张图片解密回原始文件。这种题在CTF里很常见也是做安全研究的基本功。看完标题你可能觉得不就是逆向个加密器嘛但实际操作中你会发现很多细节文档里不会写比如断点下在哪才能最快摸到算法、怎么判断密钥是固定常量还是动态生成、解密脚本写出来之后如何快速验证对错。这篇文章我就拿这次实战过程当主线把从拿到样本到写出解密器全流程拆开讲其中遇到的坑、用的工具、判断依据都会说明白。这篇文章适合的读者很明确刚学逆向、想找个完整文件型程序练手的同学以及已经会查壳、会看导入表但还没建立起“从文件I/O到加密算法”这条完整分析链路的从业者。看完你至少能学会一套可复用的文件加密器分析套路。1. 拿到样本先别急着双击静态信息收集多数人会犯一个错误拿到一个加密器样本直接双击运行发现界面弹出来一个加密按钮随手把某个文件拖进去点一下输出一个乱码文件。然后呢没有然后了。整个过程黑盒你只看到“输入文件、输出密文”这个外部表现完全没接触到内部逻辑。真正高效的做法是先用静态分析工具把程序扒一层皮搞清楚它是什么编译器写的、有没有加壳、导入表里有哪些关键API再决定下一步怎么动。1.1 查壳、识别编译特征与导入表分析查壳这件事用工具就能做常见的有ExeinfoPE、DIE、PEiD。这次拿到的32位PE程序DIE识别出来是Microsoft Visual C编译没有加壳。这个信息很重要意味着你不需要先考虑脱壳可以直接进入静态反汇编阶段。如果识别出UPX这类壳最好先脱壳不然你在IDA里看到的是一堆被压缩的垃圾代码分析效率极低。查完壳之后打开导入表这里能看出程序大概要干什么。我这次看到几个重点APICreateFileA、ReadFile、WriteFile、GetFileSize这几个组合在一起基本可以断定是文件读写型程序。另外还有GetTickCount和time函数这让我留了个心眼说明密钥有可能是动态生成的不一定是一个写死的常量。导入表里没有看到CryptoAPI相关的函数比如CryptEncrypt也没有OpenSSL的特征字符串这反而说明加密算法大概率是作者自己手写的简单变换逆起来不会牵扯到大段大段的密钥调度代码。1.2 字符串视野从错误提示到密钥线索静态分析阶段千万不要跳过字符串搜索。在IDA里按ShiftF12打开字符串窗口能看到程序里所有明文字符串。我这次搜到了几条关键内容一条是“Usage: encrypt.exe”一条是“Encryption complete”还有一条看起来像提示语但中间嵌了一段奇怪的字符。那段奇怪字符在一串正常提示语中间显得非常突兀大概率是密钥的一部分被直接写进了代码里。为什么字符串搜索这么重要因为很多作者图省事把密钥直接以字符串形式放在代码段里运行时用memcpy或者直接寻址取出使用。你只要搜到可疑的字符串再交叉引用到使用它的函数就能顺藤摸瓜找到加密核心代码。当然也有作者会故意把密钥拆成多处或者用异或等方式动态拼出来那就需要配合动态调试来判断。1.3 建立文件流分析锚点所有加密都得过ReadFile和WriteFile不管加密算法本身多复杂文件加密器有一个无法绕过的特点数据要从磁盘进内存加密完还要从内存写回磁盘。因此ReadFile和WriteFile就是整条分析链路的最佳锚点。你可以用IDA的交叉引用找到调用ReadFile的地方看它读了多少字节、缓存到哪里再找到WriteFile的调用位置看写出去的缓冲区是不是同一个。这个方法非常像在一条河流上打两根桩上游是明文入口下游是密文出口中间那段水流就是加密函数。你不需要一开始就把整个程序的每一条代码都读懂只要跟着文件数据流走顺着缓冲区指针找到中间的变换函数核心逻辑就浮出水面了。这也是为什么经验丰富的逆向工程师从来不从头逐条读汇编而是先定锚点、再顺藤摸瓜。2. 静态还原文件处理流程从CreateFile到WriteFile静态分析阶段的目标不是马上把算法细节全部看出个底朝天而是先把文件处理的完整流程恢复出来文件怎么打开、怎么读取、在哪里被变换、最后怎么写出。画出这个流程框架之后再去读加密函数本身就会轻松很多。2.1 在IDA里跟踪文件操作调用关系我用IDA定位到main函数F5反编译之后能看到一条清晰的调用链main调用CreateFileA打开输入文件调用GetFileSize拿到文件长度接着用malloc申请了一块和文件长度一样大的堆缓冲区再调用ReadFile把文件内容读进缓冲区。然后关键的一步来了main把这块缓冲区指针作为参数传给了sub_401000这个函数。函数调用完之后main又把同一个缓冲区指针传给WriteFile写出去。看到这里基本就明白了sub_401000八成就是加密函数它在原地修改缓冲区内容。所谓原地修改就是读进来的是明文函数执行完之后缓冲区里存的就是密文。这个程序没有把加密结果放到另一个新建缓冲区而是覆盖了原缓冲导致WriteFile写出去的时候已经全部是变换后的数据。跟踪这段调用关系有个细节值得说注意看ReadFile读取的字节数参数。有的程序只读取文件长度有的会额外加几字节这通常意味着它要在密文前面附加一个头部比如长度字段、魔数标识。我这次看到它调用ReadFile时输入的字节数就是GetFileSize返回的文件长度说明加密后文件的总大小会发生变化多出来的部分必然是在WriteFile之前单独写入的。2.2 加密函数静态阅读识别循环结构与关键常量双击sub_401000进入函数体。反汇编代码并不长核心结构是一个for循环循环次数通过一个参数传入而这个参数正是main传进来的文件长度。循环体里看到一个很像XOR的指令序列movzx eax, byte ptr [ecxedx]然后xor eax, 5Ah再mov byte ptr [ecxedx], al。这里的5A十六进制十进制是90就是一个看起来很普通的常量。单从这个片段看你可能会立刻得出结论加密是逐字节异或0x5A。但先冷静因为循环体里还有一条额外的指令它把另一个内存地址里的值读出来再和eax做了第二次异或。那个内存地址的值我静态看不太出来因为它可能在程序运行过程中被修改。这就是典型的“常量加动态密钥”混合模式静态分析只能看到一半真相。2.3 文件头与加密标记的设计多出的4字节是什么在看WriteFile调用之前的代码时我发现程序在写加密数据之前先调用WriteFile写了一个4字节的值。这个值不是从明文文件里读出来的而是程序自己计算出来的从反编译的伪代码看它把GetFileSize返回的文件长度直接写进了输出文件的最前面。也就是说加密后的文件格式是4字节原始文件长度 逐字节处理后的密文数据。这个设计在自写的文件加密器里很常见作者写解密程序的时候需要知道原始文件有多长才能正确截断数据。但这也意味着如果你只看到密文主体不知道开头4字节是长度信息解密出来的文件会带着多余数据导致格式损坏。我这次看到这段逻辑之后给解密脚本预留了长度字段处理这也是为什么最后能一次性还原出完整图片的关键之一。3. 动态调试配合验证把疑似XOR变成实锤静态分析能给你一个“疑似算法”的假设比如可能是不带符号扩展的逐字节XOR操作。但假设终究是假设你还没验证那个额外参与异或的动态值到底是什么。这时候就得开动态调试用实际的数据对比来验证猜想。动态调试的精髓在于用已知明文和已知密文反推算法细节而不是漫无目的地跟着代码一条条跑。3.1 用x64dbg下断点观察缓冲区变化这次我用x64dbg加载样本下断点思路很直接分别在ReadFile返回之后和WriteFile调用之前下断。ReadFile返回之后缓冲区里是刚读出来的原始明文WriteFile调用之前缓冲区里是加密完成后的密文。为了对比我先准备了一个内容已知的测试文件文件头是PNG图片的标准头89 50 4E 47 0D 0A 1A 0A一共8字节。在x64dbg里下断后运行第一次命中断点时我查看缓冲区内存可以看到明文头部确实是89 50 4E 47开头。然后继续运行到第二个断点再看同一块内存发现密文头变成了D3 05 0B 04 2E 38 30 27这是第一轮结果。到这里我已经能确认这个加密器的确是在原地修改缓冲区而且密文与明文长度一致没有额外数据插入主体。3.2 手动推演算法从异或常量到动态密钥的发现拿第一个字节算一下明文0x89异或密文0xD3得到0x5A。第二个字节0x50异或0x05也是0x55不是0x5A。这就奇怪了如果只是固定XOR 0x5A第二个字节应该是0x50 ^ 0x5A 0x0A而不是0x05。看到这个差异我心里就有数了必然存在第二个密钥变量参与运算而且这个变量在不同字节位置时会产生不同效果。回到反汇编看循环体发现有一行代码每次进入循环时都会从某个全局内存地址加载一个字节到edx然后这个edx参与了异或运算。由于它是在每次循环迭代时重新加载我一开始怀疑它是递增的计数。为了确认我在循环体内部下了一个条件断点条件是缓冲区指针偏移等于某个固定值然后连续查看那个全局内存地址的值。结果发现它每次都不变恒为0x12。此时再算一遍第二个字节应该是0x50 ^ 0x5A ^ 0x12 0x08但实际密文是0x05仍然对不上。说明这个密钥还会随着文件位置变化。我又把断点设置成记录每次迭代时edx的值跑完前8次循环后发现edx的值序列是0x12、0x13、0x14、0x15直到0x19每次都加1。原来这个额外的密钥是“全局固定字节0x12加上当前处理的位置索引”。重新推演第二个字节位置索引为1密钥就是0x12 1 0x130x50 ^ 0x5A ^ 0x13 0x03还是和0x05对不上。到这里我意识到第一次看到的固定内存地址可能不是完整故事还需要看更高的数据位。3.3 顺着寄存器溯源长度低字节参与密钥生成之前静态分析看到WriteFile之前先写入了4字节的原始文件长度这个值被保存在某个局部变量中。我在动态调试时发现加密循环的密钥生成引用了这个局部变量只是取的是它的最低字节。对这次测试文件来说文件长度是152630字节换算成十六进制是0x25436最低字节是0x36。把0x36加进去重新推演第二个字节的密钥 0x5A ^ (0x12 1) ^ 0x36 0x5A ^ 0x13 ^ 0x36计算结果是0x6F明文0x50 ^ 0x6F 0x3F仍然不是0x05。我停下来重新审视汇编代码发现我漏掉了一个细节循环里除了XOR指令之外还有一个ADD操作它每处理一个字节之后会把某个寄存器加1而后续XOR引用的密钥源并不是那个固定地址而是从固定地址再加当前寄存器偏移处取出。这意味着真正的密钥不是固定地址里的一个字节而是一段位于固定地址之后的密钥数组。我直接在x64dbg里查看那段内存看到了从固定地址开始连续32字节的数值序列F0 5A 63 2C 9D 48 17 DA ...这才是真正的核心密钥表。此时重新验证第一个字节0x89 ^ 0xF0 0x79但密文是0xD3还是不对。我发现密钥表索引的起始位置不是从0开始的而是由文件长度低字节参与计算后的结果。最终算法推演下来实际是首先计算起始索引 (文件长度 0xFF) % 32然后每个字节按顺序取密钥表中对应位置的值进行异或密钥表用完32个字节后循环重复。测试文件的长度低字节是0x360x36 % 32 22所以第一个字节用的密钥是密钥表第22个元素。验证后面我查出密钥表第22个元素是0x5A0x89 ^ 0x5A 0xD3完全吻合。第二个字节用密钥表第23个元素假设是0x550x50 ^ 0x55 0x05也对上了。3.4 动态验证循环中寄存器与内存的变化经过上面这一连串推演算法本质已经清晰这不是简单单一常量异或而是一个“以32字节密钥表为核心以文件长度低字节作为起始偏移”的循环异或算法。密钥表本身固定存放在程序的只读数据段里算法不复杂但因为起始偏移动态可变静态分析时很难一眼看穿必须靠动态调试采集实际取值才能落实。我在循环体内针对关键位置下条件断点验证了起始偏移计算确实使用文件长度低字节也确认了每个字节处理后指针加1、密钥表索引加1、到32后归零这一整套流程。这一过程的经验是动态调试不能只看几个寄存器的值要把内存地址里的数据也拉出来看密钥数组往往是藏在内存里而不是寄存器里。另外遇到“异或结果怪怪的”时不要急着怀疑编译器先想想是不是有额外的密钥数组或者偏移量。3.5 快速采集数据记录日志代替逐轮盯寄存器如果文件很大比如几十万字节逐轮盯寄存器显然不现实。我这次用了x64dbg的脚本功能在循环体内部设了一个日志记录点每执行一次循环就把当前位置、密钥值、明文字节、密文字节输出到日志窗口。跑完整个加密过程后日志里已经有几百行有效记录再用脚本提取前8行的数据做规律分析一下就定位到了密钥表偏移的规律。这个方法值得推荐。很多人在动态调试时习惯用肉眼看寄存器变化遇到循环多、数据量大的场景效率很低。学会用断点日志或条件记录功能让程序帮你自动采集数据你自己只需要分析日志规律。调试器的作用是帮你看到数据和指令流不是替你想清楚算法所以工具能自动化的事情不要自己手动去干。4. 解密器编写与文件还原验证算法验证完毕后工作重心从逆向分析转移到代码实现。这一步就是把你在调试器里还原出来的加密逻辑倒过来写一遍。要注意解密不是把加密代码抄一遍而是把每一步操作逆向处理。对于异或这种对称运算逆操作和原操作相同但如果有加法、查表、移位等操作就需要写对应的逆向逻辑。4.1 用Python按逆向出的算法写解密脚本我最终写的解密脚本Python版本逻辑很直白import struct import sys def decrypt_file(input_path, output_path): with open(input_path, rb) as f: data f.read() # 前4字节是原始文件长度小端序 orig_len struct.unpack(I, data[:4])[0] cipher data[4:] key_table bytes([ 0xF0, 0x5A, 0x63, 0x2C, 0x9D, 0x48, 0x17, 0xDA, 0x33, 0x80, 0xE5, 0xA4, 0x6B, 0x2E, 0x95, 0x42, 0x18, 0xCA, 0x3F, 0x77, 0xB1, 0x0C, 0x5A, 0x55, 0x99, 0x20, 0xE8, 0xAB, 0x16, 0x7C, 0x81, 0x49 ]) start (orig_len 0xFF) % 32 plain bytearray() for i in range(orig_len): key key_table[(start i) % 32] plain_byte cipher[i] ^ key plain.append(plain_byte) with open(output_path, wb) as f: f.write(plain[:orig_len]) if __name__ __main__: decrypt_file(sys.argv[1], sys.argv[2])这个脚本的核心逻辑就一个循环从头遍历原始文件长度范围内的字节用密钥表按顺序取值逐字节异或。异或是对称操作加密和解密的代码完全一样。注意一点我在这里截断了输出只保留orig_len长度这样即使密文里有额外数据也不会混入解密结果。4.2 验证文件完整性的几个方法脚本写完不要急着宣称成功先验证。我最常用的验证方式有三个第一用file命令识别解密文件的类型。如果解密正确原来加密后一堆随机字节会变回PNG图片file命令会输出“PNG image data”之类的描述。第二用十六进制工具打开解密文件比对标准文件头。PNG文件必须以89 50 4E 47开头如果前8字节能对上基本说明算法方向正确。第三直接尝试打开文件图片能正常显示就是最直观的验证。这三个方法分别从文件类型、格式签名、内容可用性三个层面确认解密正确性互相补充。我在实际测试中遇到一个情况文件能打开但后半部分花屏说明解密逻辑有部分错误。检查后发现是密钥表索引没有在32字节处循环回0导致后续字节用错了密钥。这种边界条件写解密脚本时特别容易漏。4.3 边界情况空文件、超大文件与非常规文件头要处理的反例不少。空文件是最容易出问题的看加密器的逻辑里如果文件长度为0它可能不会进入加密循环但在写出文件时会先把长度字段0写入输出文件然后再写0字节密文这样解密端读到的密文也是空的orig_len是0循环一次都不会执行输出一个空文件这是正常的。超大文件要担心的是密钥表偏移计算溢出问题。如果原始文件长度低字节恰好是0x80这样的值(orig_len 0xFF) % 32还能正常得到0但如果代码里用了带符号的char类型0x80会被当作-128取模运算结果就不对了。碰到这类问题可以在解密脚本里用无符号整数处理避免符号扩展带来的误差。有一些文件格式没有明显的魔数头比如纯文本文件无法用file命令判断是否正确。这种情况下可以比对解密文件与原始文件的大小如果大小一致再去抽查文件中间某几个字节的明文特征。文本文件的话可以看内容是否是可读字符如果全是乱码那说明密钥表偏移或者循环次数还有问题。5. 常见问题与调试技巧实录几乎所有文件加密器逆向项目实际操作中都会碰到几个共性问题。下面这些是我这次以及之前类似项目中踩过的坑列成速查表方便你对照排查。5.1 问题速查表现象可能原因排查建议静态分析看到明显XOR常量但动态验证对不上存在第二个密钥源可能是密钥数组或动态生成值在循环内下日志断点采集每个字节的完整运算数据断在WriteFile时缓冲区看不到明文加密函数在原缓冲区直接修改断在ReadFile返回后查看对比两次内存快照字符串窗口里找不到密钥相关字符串密钥被拆分、异或存储或从外部加载动态调试时在加密函数内对内存数据块下访问断点解出来的文件头部正确但后半部分花屏密钥表索引循环处理有误检查密钥表是否在结尾处重置为0解密后文件大小比原始文件大头部长度字段解析错误可能混入密文额外数据确认长度字段读取位置解密后按长度截断程序有反调试导致无法附加IsDebuggerPresent或时间检测先静态分析定位反调试函数patch跳转绕过这个表里的前三条是我实际过程中最常踩的坑。尤其是第一条很多人看到XOR 0x5A就急着写解密脚本结果跑出来不对就是忽略了动态密钥参与运算的环节。5.2 独家调试心得断点下在哪最稳经验优先级的断点建议第一选择是WriteFile的调用处这里是所有密文的必经之路数据量最大适合观察整体输出。第二选择是ReadFile返回后这里是明文入口适合获取原始数据做对比。第三才是加密函数内部循环虽然能精确看到每一步变换但信息量大需要配合日志采集才有意义。不建议一开始就闷头单步跟踪循环体里的每条指令。循环体通常是重复执行千百遍的地方单步跟到怀疑人生效率极低。正确策略是先看两头再挤中间明确明文入口和密文出口的缓冲区内容再回到加密函数里看有哪些计算步骤最后用日志断点自动采集数据。还有一点就是加密函数如果是被内联优化过的你在IDA里看到的可能不是一个清晰封装的函数调用而是直接内嵌在main里的循环代码。这种情况下不要纠结于函数边界直接找包含XOR指令和循环跳转指令的代码块就行。5.3 从文件加密器逆向延伸到更广的分析能力学会分析文件加密器之后能迁移的技能点非常多。CTF里的misc类题目经常有自定义文件格式解密恶意样本里的勒索软件加密逻辑和分析文件加密器本质上是同一种思路只是勒索软件往往会把密钥发到远程服务器本地只保留加密过程。你还有可能遇到某些私有协议、游戏存档文件的分析原理同样是从文件I/O入手找变换函数。我再强调一遍最核心的方法论不管外在形式多复杂文件型程序一定逃不过文件读取、数据处理、文件写入这三个阶段。你在分析时把握住这三点之间的数据流转换把所有精力聚焦在数据从明文到密文的那一段代码上很多看似复杂的加密算法都能被快速还原。写在最后我个人在实际操作中的体会是文件加密器的逆向拼的从来不是谁汇编功底更好而是谁更能抓住关键的数据流节点。这次算法之所以能快速还原主要靠的是“明文入口加密文出口双向夹击”这套思路加上日志断点自动采集数据最终把看似玄乎的动态密钥问题变成了一次简单的数学推演。最后再分享一个小技巧。如果你怀疑一个加密器用了密钥表但还没找到表的位置做一个长度只有32字节的测试文件去加密然后比较明文和密文你会发现32字节恰好覆盖密钥表的完整周期逐字节异或就能直接推出整个密钥表整个过程不到一分钟。这个方法帮我省过很多次无意义的暴力搜索你下次遇到类似的加密器也能用上。