前言
相比于DES算法,AES算法更为复杂,逆向的难度也相对较大,广义上来说AES可以算是DES的升级版。
比较与DES固定8字节密钥,AES有三种密钥模式可选,分别为128位,192位和256位,同时AES也有ECB,CBC和CTR三种模式,所以在出题和实际生产生活中AES的选择更多,组合更多,实际难度更大。虽然AES算法的组合方式有很多种,整体看起来很复杂,但其实在这多种模式中主体上都是不变的,而所谓的密钥长度和模式只不过更改了密钥处理函数和最终的处理方式,相当于在AES核心算法外套了层皮,并不是什么特别的东西。
以及AES是分组密码。
AES的核心组件
在AES九种模式都不变的组件
总览
1 | static const uint8_t sbox[256] = |
S盒与逆S盒
1 | static const uint8_t sbox[256] = |
作用与DES中的相同,就不多赘述了。
轮常数
1 | static const uint8_t Rcon[11] = |
作用在于输入数据进行S盒查找替换时与第一个数据进行异或。
固定矩阵常量与逆固定矩阵常量
1 | static const uint8_t AES_MIX_COL_MATRIX[4][4] = |
一般在代码中不会直接显示出来,详细的内容在后文的MixColumns函数中会讲。
AES核心逻辑
xtime乘法
1 | static uint8_t xtime(uint8_t x) |
为AES实现快速的模约减放溢出和乘2
轮密钥异或(AddRoundKey)
1 | static void AddRoundKey(uint8_t *state, const uint8_t *roundKey) |
字节替换与逆字节替换(SubBytes)
1 | static void SubBytes(uint8_t *state) |
加密时使用S盒,解密时使用逆S盒
1 | static void InvSubBytes(uint8_t *state) |
行位移(ShiftRows)
1 | static void ShiftRows(uint8_t *state) |
很明显,这里在进行数组内的元素替换,并且由于state数组是以矩阵的形式存在的,所以这里可以分开看:第一块分块是第一行元素,其在数组中的位置统一向左移动四位;第二块是第二行,向左移动八位;第三块是第三行,向左移动十二位。每一行自重复。
逆位移如下
1 | static void InvShiftRows(uint8_t *state) |
值得注意的是,这一部分由于反编译出来后阅读量偏大,所以很容易出现魔改(比如在某处添加异或什么的),在做题时需要小心审阅。
隐式矩阵乘法(MixColumns)
1 | static void MixColumns(uint8_t *state) |
AES算法的扩展核心,加密过程使用上文的固定矩阵常量,解密过程使用上文的逆固定矩阵常量。解密如下
1 | static void InvMixColumns(uint8_t *state) |
加解密执行流总结
加密轮结构
1 | Round0: AddRoundKey |
解密轮结构
1 | Round0: AddRoundKey |
密钥扩展函数(KeyExpansion)
极其重要的函数,并且特征性非常明显,在做题中可以做到通过函数处理的轮数立刻判断出密钥的长度。
128位密钥的处理函数如下
1 | void KeyExpansion128(const uint8_t *Key, uint8_t *RoundKey) |
192位密钥的处理函数如下
1 | void KeyExpansion192(const uint8_t *Key, uint8_t *RoundKey) |
256位密钥的处理函数如下
1 | void KeyExpansion256(const uint8_t *Key, uint8_t *RoundKey) |
通过上面三个函数,我们不难看出每一个长度的密钥处理都有其不变的特征:
128位的密钥完整复制前16字节数据,要处理44轮,最后与轮常数异或时分母为4;192位的密钥完整复制前24字节数据,要处理52,最后与轮常数异或时分母为6;256位的密钥完整复制前32字节数据,要处理60轮,最后与轮常数异或时分母为8,并且i%8==4时要再S盒替换一遍。一般来说这里不怎么会魔改。
例题
以furryctf2025的分组密码为例来讲讲AES。
进入main逻辑
1 | int __cdecl main(int argc, const char **argv, const char **envp) |
首先说明了flag格式,遍历所有函数后确认这是略有魔改的AES算法128位密钥模式,那么main逻辑里面那个地方就是密钥扩展混淆逻辑。
可以确认xmmword_403270和xmmword_403280是目标密文
1 | int __fastcall sub_4010B0(char *a1, int a2) |
可知sub_4010B0为AES算法中的轮换和列混淆函数。现在来看看传参,这个函数传入了v13和v37,由于main逻辑存在以下代码
1 | n0x20 = 0; |
可以推断v13为输入明文,而由于这个mm_xor_ps()是SSE指令,作用是将128位寄存器按位异或存储到另一个寄存器上,即v13^=v12,所以v12,即v39,就是初始向量。if的条件判断是放溢出,保证数据安全,无需过多在意。那么此时就可以确定v37是密钥。
那么现在我们密钥,初始向量,密文都有了,由于密钥混淆扩展函数写在main逻辑里面,那么那里的byte_403258就无疑是轮常数了。好,现在数据全齐,逻辑也大多摸清楚。检查魔改。
发现在轮换逻辑中
1 | sub_401050(v4); |
与普通的AES轮换不同,这里异或了一个常数0x66。函数sub_401050为
1 | char *__thiscall sub_401050(char *this) |
那么很明显这个就是标准的S盒查表替换混淆。
最终逆向脚本如下
1 |
|
噢噢对,值得注意的一点是,这里的轮换函数与标准的AES也不同,所以在逆向的时候需要按着IDA里面的逻辑来,以及异或的位置不要放错了。
TIP
AES在128,192和256模式中,除了上文提及的区别外,还有在执行时的总循环次数也是不一样的,128是10次,192是12次,256是14次。
附录
https://pan.quark.cn/s/593a5034540b
再次提醒,所谓的密钥长度只不过更改了密钥扩展函数和总体循环次数,所以需要修改的地方不多,这里只提供128长度的完整代码。