AES算法逆向分析实战:从特征识别到密钥追踪与混淆对抗
1. 项目概述:当AES遇上逆向分析
在软件安全、数字取证和恶意代码分析领域,我们经常会遇到一个核心挑战:如何从一堆加密的二进制数据或混淆的代码中,识别出其中使用的加密算法,并进一步追踪其密钥的生成与使用流程,最终实现对抗代码混淆、还原算法逻辑。这听起来像是电影里的情节,但却是我们日常分析工作中的“家常便饭”。而AES(高级加密标准)作为当今应用最广泛的对称加密算法,从网络通信、文件加密到软件保护,几乎无处不在。因此,“识别、追踪与混淆对抗”围绕AES展开,本质上是一套针对现代软件中AES算法实现进行深度逆向工程的方法论与实践指南。
这份白皮书要解决的,正是当你面对一个未知的二进制程序(可能是某个商业软件、一个可疑的样本,或是一段需要审查的代码)时,如何系统性地回答以下几个问题:这里面用AES加密了吗?用的是哪种模式(如CBC, ECB, GCM)?密钥和初始向量(IV)藏在哪里?代码被混淆了,怎么绕过这些保护看清逻辑?以及,如何验证我们的分析结果?整个过程,就像是在数字迷宫中寻找一把特定的锁(AES算法),并设法找到开锁的钥匙(密钥)和说明书(算法逻辑)。这不仅需要扎实的密码学知识,更需要丰富的逆向工程经验和一套行之有效的战术。
2. AES算法核心特征与识别指纹
在进行逆向分析之前,我们必须对目标有足够清晰的认识。AES算法虽然标准统一,但在不同的编程语言、编译环境和开发者习惯下,其实现会留下各具特色的“指纹”。识别这些指纹,是我们定位算法代码的第一步。
2.1 静态特征:常量、S盒与查表操作
AES算法最显著的静态特征是其常量表和S盒(Substitution Box)。在绝大多数实现中,尤其是追求性能的C/C++实现,开发者会预定义这些常量数组。
- S盒与逆S盒:这是256字节的查找表,用于字节替换步骤。在IDA Pro、Ghidra等反编译工具中,如果你在数据段看到一个连续的、长度为256字节的数组,并且其内容符合AES标准S盒的特定值(通常以
0x63, 0x7c, 0x77...开头),这就是一个极强的指示信号。逆S盒用于解密,其值也固定。 - 轮常量(Rcon):这是一个用于密钥扩展的小数组,通常只有10或14个值(对应AES-128和AES-256的轮数)。在代码中搜索
0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1b, 0x36这个序列,命中率极高。 - 列混合矩阵:在加密和解密的
MixColumns步骤中,会用到固定的矩阵系数(如0x02, 0x03, 0x01, 0x01等)。这些常数也可能以查找表(T-Table)的形式出现,即将整个MixColumns与SubBytes合并的预计算大表(通常4KB)。在IDA中看到引用0x00000100、0x00000200、0x00000300地址的大段数据访问,很可能就是T-Table。
注意:现代编译器优化和代码混淆可能会将这些常量表动态计算生成,或者进行编码隐藏。此时,静态特征会减弱,需要结合动态分析。
2.2 动态特征与模式识别
当静态特征被隐藏后,我们需要在程序运行时捕捉其行为特征。
- 数据块操作:AES是分组密码,固定处理16字节(128位)的数据块。在动态调试中(如使用x64dbg, OllyDbg),可以关注那些对内存进行16字节对齐读取、写入或异或(XOR)操作的循环。特别是看到循环次数为9、11或13(对应AES-128/192/256的轮数减1)时,嫌疑巨大。
- 密钥扩展过程:密钥扩展函数会生成一系列轮密钥。这个过程包含对密钥字节的S盒替换和与Rcon的异或。在调试器中,如果你看到一个函数在初始化阶段被调用一次,其输入是一个密钥(16/24/32字节),输出是一大段(如176/208/240字节)扩展后的密钥数据,这很可能就是密钥扩展例程。
- 模式特征:不同的AES工作模式会留下不同的痕迹。
- ECB模式:最简单,每个数据块独立加密,没有反馈机制。在代码中看不到前一个密文块参与下一个块加密的运算。
- CBC模式:最常见。必然存在一个“初始化向量”(IV),并且在加密时,会看到明文块先与IV或前一个密文块进行异或,然后再进行AES加密核心操作。解密过程则相反。追踪异或操作的数据源是关键。
- GCM模式:用于认证加密。除了加密流程,还会涉及GHASH操作,其中包含在GF(2^128)域上的乘法,这通常会通过查表实现,形成另一组特征循环。
实操心得:在实际分析中,我习惯先用反编译工具(如IDA Pro)的“二进制搜索”功能,直接搜索AES S盒的字节序列。如果找到了,恭喜你,目标明确。如果没找到,我会转而寻找那些对16字节数据进行复杂位运算(特别是异或、移位、查表)的循环函数,然后下断点进行动态跟踪。很多时候,算法会被封装在encrypt、decrypt、AES_set_encrypt_key等命名的函数或虚表里,但更多时候,函数名会被混淆或剥离,这时特征匹配就是我们的主要武器。
3. 密钥与初始向量(IV)的追踪技术
识别出AES算法只是第一步,找到加密解密的“钥匙”——密钥和IV——才是逆向分析的核心价值所在。它们可能被硬编码、动态生成、从网络获取或由用户输入。
3.1 密钥来源的常见藏匿点
- 硬编码在二进制中:最简单也最不安全的方式。密钥可能以字符串形式(如
"MySecretKey12345")或字节数组形式直接存储在程序的.data或.rdata段。使用十六进制编辑器或反编译器的字符串查找功能,可以尝试搜索可能的密钥。但要注意,密钥可能被编码(如Base64)或简单异或加密后存储。 - 运行时动态生成:密钥可能由程序通过特定算法生成,例如:
- 基于固定种子:使用伪随机数生成器(PRNG)如
rand(),配合一个固定种子(如srand(0x1234))。找到种子就等同于找到了密钥生成规律。 - 派生自其他信息:从机器特征(硬盘序列号、MAC地址)、用户输入(用户名、密码)或配置文件内容通过哈希函数(如SHA-256)派生而来。需要分析派生算法。
- 密钥交换协议:在网络通信中,可能通过ECDH、RSA等非对称算法协商出会话密钥。需要分析密钥交换的握手过程。
- 基于固定种子:使用伪随机数生成器(PRNG)如
- 外部输入:从命令行参数、环境变量、配置文件、注册表或网络服务器获取。分析程序启动初期的文件/网络读取操作是关键。
3.2 动态调试中的密钥捕获术
当静态分析难以定位时,动态调试是“抓捕”密钥的利器。
- 在密钥扩展函数下断点:一旦通过特征识别出
KeyExpansion或类似函数,就在其入口下断点。函数的第一个参数(在x86调用约定中可能是栈上传参,在x64中可能是RCX/EDI寄存器)往往就是原始密钥的指针。在调试器中dump出该指针指向的内存,即可获得密钥。 - 在加密/解密函数入口下断点:AES加密函数(如
AES_encrypt)通常接受密钥调度表(即扩展后的轮密钥)和输入数据块作为参数。虽然这里不是原始密钥,但我们可以回溯是谁生成了这个密钥调度表。通过栈回溯(Stack Backtrace)功能,查看调用链,找到生成并传入密钥调度表的函数,往往就能追溯到原始密钥。 - 内存扫描与访问断点:如果你通过其他途径(如已知一段明密文对)推测出了密钥的可能值或部分字节,可以在内存中搜索该值。或者,在程序将加密后的数据写入文件或发送网络之前下内存访问断点,然后反向追踪参与运算的密钥数据来源。
一个典型追踪案例:分析一个使用CBC模式AES加密配置文件的软件。首先,通过搜索S盒定位到加密函数。动态调试,在加密函数入口断下,发现其参数之一是一个16字节的缓冲区(IV),另一个是密钥调度表指针。对IV缓冲区设置硬件写入断点,重新运行,断在程序初始化阶段的一个函数,该函数从一个全局变量中拷贝数据到IV缓冲区。顺藤摸瓜,找到该全局变量在.data段的硬编码值,成功获取IV。接着,回溯密钥调度表指针的来源,发现它来自一个AES_set_encrypt_key函数,该函数的参数是一个指向0x405020的指针。查看0x405020处的内存,发现是字符串"ThisIsASecretKey"的ASCII码,但长度只有17字节(包含结尾\0)。AES-128需要16字节密钥,观察发现程序只取了前16字节"ThisIsASecretKey"作为密钥。至此,密钥和IV全部获取。
提示:密钥和IV可能不是以直观形式存在。我曾遇到一个案例,密钥是
"Password"的MD5哈希值的前16字节。这就需要结合对程序其他部分(如用户认证逻辑)的分析来联想。
4. 对抗代码混淆与反调试技巧
现代软件,尤其是恶意软件和商业保护壳,会大量使用代码混淆和反调试技术来阻碍逆向分析。我们的AES识别与追踪工作必须能穿透这些迷雾。
4.1 常见混淆手段及其应对
- 常量展开与计算:不直接存储S盒、Rcon等常量表,而是在运行时通过一系列算术和逻辑运算动态计算出来。这增加了静态识别的难度。
- 应对:动态调试时,不必关心计算过程,只需在AES轮函数(如
SubBytes)的入口或出口下断点,直接观察输入输出。或者,关注计算结果的存储位置,这些位置最终会被用作查表地址。
- 应对:动态调试时,不必关心计算过程,只需在AES轮函数(如
- 控制流扁平化:将正常的
if-else、switch、循环结构打乱,变成一个巨大的switch语句或状态机,使得函数逻辑难以阅读。- 应对:专注于数据流而非控制流。混淆通常不改变算法的数据依赖关系。我们依然可以追踪密钥数据、明文/密文数据的流向。使用调试器的“运行到光标处”和“单步步入”功能,耐心跟随数据的传递。一些反编译插件(如IDA的Hex-Rays Decompiler)对控制流扁平化有一定优化能力。
- 代码虚拟化:将原始的x86/ARM指令转换为自定义的字节码,由一个虚拟机解释执行。这是最强的混淆之一。
- 应对:完全静态分析极其困难。策略包括:
- 识别虚拟机:寻找大的
switch-case结构、字节码分派器、庞大的处理函数(handler)表。 - 动态脱壳:寻找虚拟机解释执行完毕、原始代码被还原到内存中执行的时机(即“虚拟机出口”),在此处下断点并dump内存,获取原始的、未被虚拟化的代码段。这需要经验和对程序行为的深刻理解。
- 不透明谓词:插入大量永远为真或永远为假的判断分支,干扰分析者的思路。
- 应对:通常可以忽略。在动态执行时,程序只会走实际路径。静态分析时,一些反编译器的优化可以消除部分不透明谓词。
- 识别虚拟机:寻找大的
- 应对:完全静态分析极其困难。策略包括:
4.2 反调试检测与绕过
程序可能检测是否被调试,如果发现,则改变执行流程或直接退出,阻碍分析。
- 常见反调试技术:
IsDebuggerPresent()、CheckRemoteDebuggerPresent()(Windows API)PTRACE_TRACEME(Linux)- 检查进程
PEB(进程环境块)中的BeingDebugged标志。 - 测量代码执行时间(
rdtsc指令),调试下单步执行会导致时间异常。 - 检测硬件断点(通过
CONTEXT结构)。
- 绕过方法:
- 使用插件:OllyDbg的
HideDebugger插件、x64dbg的ScyllaHide插件可以自动隐藏调试器。 - 手动修补:在调试器中,将检测调试器的API调用(如
IsDebuggerPresent)的返回值强制修改为0(False)。 - 修改内存:直接修改
PEB.BeingDebugged的值为0。 - 时间对抗:对于
rdtsc检测,可以通过修改rdtsc的返回值,或者使用调试器插件来模拟正常执行时间。
- 使用插件:OllyDbg的
实操心得:面对高强度混淆和反调试,心态要稳。优先目标是让程序“跑起来”并执行到加密/解密逻辑附近。不要一开始就试图理解所有混淆代码。可以尝试先找到程序的输入/输出点(例如,读取一个加密文件,然后解密显示)。在这两个点下断点,然后从输出点向输入点反向追踪,这样往往能更快地穿透无关的混淆代码,直达核心的算法逻辑。此外,准备好多个调试器和分析环境(如虚拟机快照)也很重要,因为某些反调试技术可能只针对特定调试器。
5. 从识别到验证:构建完整分析闭环
识别了算法,追踪到了密钥,最终我们需要验证整个分析是否正确。一个完整的分析必须形成闭环,能够用我们获得的信息重现加密或解密过程。
5.1 验证分析结果的标准化流程
- 提取关键参数:明确记录下你找到的以下信息:
- 算法:AES-128/192/256?
- 模式:CBC、ECB、GCM等?
- 密钥(Key):具体的字节序列。
- 初始向量(IV):如果是CBC等模式,具体的字节序列。
- 数据填充方式:PKCS#7、ZeroPadding等?(这通常需要观察解密后数据的尾部处理逻辑)
- 使用标准工具进行交叉验证:这是最直接有效的方法。
- OpenSSL命令行:例如,对于AES-128-CBC,PKCS#7填充,可以使用:
比较# 解密验证 openssl enc -aes-128-cbc -d -in ciphertext.bin -out plaintext.bin -K `hex密钥` -iv `hexIV` # 加密验证 openssl enc -aes-128-cbc -e -in plaintext.bin -out ciphertext_new.bin -K `hex密钥` -iv `hexIV`plaintext.bin是否与预期明文一致,或ciphertext_new.bin是否与原密文一致。 - Python
cryptography库:编写一个小脚本,用获取的参数进行加解密,与目标程序的结果对比。 - 在线工具:作为快速检查,可以使用一些可靠的在线AES计算工具,但注意不要上传敏感数据。
- OpenSSL命令行:例如,对于AES-128-CBC,PKCS#7填充,可以使用:
- 在调试器中实时验证:在动态调试时,可以在加密函数执行前,手动修改输入缓冲区为我们已知的测试明文,执行加密函数后,查看输出的密文是否符合预期。反之亦然。
5.2 处理非标准实现与自定义修改
有时,你可能会遇到“魔改”的AES。开发者可能修改了S盒、调整了行移位或列混合的细节,以实现一种自定义的加密(虽然这严重违背密码学原则,但确实存在)。
- 如何发现魔改:当你用标准的AES参数无法正确加解密时,就要怀疑是否被修改了。
- 对比静态分析提取的S盒与标准S盒是否一致。
- 单步跟踪一轮加密过程,记录下
SubBytes、ShiftRows、MixColumns、AddRoundKey每一步之后的数据状态,与标准AES计算的结果进行对比。
- 应对策略:
- 白盒分析:如果魔改不复杂,就彻底逆向其自定义的算法步骤,用脚本重新实现。
- 黑盒调用:如果算法逻辑过于复杂,但程序提供了调用接口,可以考虑将其加密/解密函数“剥离”出来,制作成一个可供外部调用的DLL或SO库,然后在我们自己的程序中直接调用这个黑盒函数。
- 模拟执行:使用像
Unicorn这样的CPU模拟器框架,将目标代码片段(包含魔改AES)在受控环境中运行,并hook其输入输出,从而无需完全理解内部逻辑即可使用其功能。
常见问题与排查技巧实录
在实战中,你肯定会遇到各种奇怪的问题。下面是我总结的一些常见坑点及其解决方法:
| 问题现象 | 可能原因 | 排查思路与解决方案 |
|---|---|---|
| 用找到的密钥解密后是乱码 | 1. 密钥错误(长度不对、值不对) 2. 模式判断错误(如以为是ECB实际是CBC) 3. IV错误或未使用IV 4. 填充方式错误 5. 数据本身不是纯AES加密,可能有压缩、编码或附加其他数据。 | 1. 确认密钥长度(16/24/32字节)和值。检查密钥生成过程是否有遗漏步骤(如哈希后取前N字节)。 2. 动态跟踪加密过程,确认前一个密文块是否参与下一个块的运算(CBC特征)。 3. 仔细追踪IV的来源,确认其是否参与首次异或。 4. 观察程序解密后如何去除尾部字节,判断填充方式。尝试PKCS#7和ZeroPadding。 5. 检查加密前数据是否经过Base64、Hex编码,或解密后数据是否需解压(如zlib)。 |
| 动态调试时,加密函数未被调用 | 1. 程序逻辑分支未走到加密部分。 2. 反调试导致程序提前退出或跳过了加密逻辑。 3. 加密在另一个线程中发生。 | 1. 检查触发条件。确保提供了正确的输入(如打开了特定文件、点击了某个按钮)。 2. 检查并绕过反调试。在程序入口点(如main, WinMain)或更早的地方下断点,观察反调试检测代码。 3. 在创建线程的API(如 CreateThread)下断点,或使用调试器的线程跟踪功能。 |
| 静态搜索不到任何AES常量 | 1. 常量被动态计算或加密存储。 2. 使用了第三方加密库(如OpenSSL, Crypto++),其代码被静态链接并优化。 3. 算法根本不是AES。 | 1. 转向动态分析,在可能处理16字节数据块的函数上下断点。 2. 寻找第三方库的函数特征或字符串(如“OpenSSL”)。 3. 考虑其他分组算法(如DES, 3DES, SM4),或流密码。分析数据块大小和操作特征。 |
| 解密结果部分正确,部分错误 | 1. 可能使用了分段加密,且每段的IV或密钥不同。 2. 密文在传输或存储过程中部分损坏。 3. 算法模式可能是CFB、OFB等,错误地使用了CBC解密。 | 1. 分析加密流程,看是否在循环中重新获取了IV或密钥。 2. 校验数据完整性(如是否有CRC校验和)。 3. 仔细分析代码,确认反馈模式。CFB和OFB模式解密时使用的是加密函数,而非解密函数,这是常见错误点。 |
最后,我想分享的一点个人体会是,AES算法逆向分析就像一场耐心的狩猎。它没有一成不变的公式,更多依赖于对密码学原理的深刻理解、对逆向工具的精通,以及大量的实战经验积累。最重要的技巧往往是“大胆假设,小心求证”——先根据特征做出快速判断,然后设计精巧的调试实验去验证它。每一次成功追踪到密钥、破解一段混淆代码,都是对分析者逻辑思维和工程能力的双重锻炼。这个过程本身,其价值有时甚至超过了最终获取的那个密钥。
