找回密码
立即注册
搜索
发回帖 发新帖

5083

积分

0

好友

649

主题
发表于 半小时前 | 查看: 3| 回复: 0

AES 是对称加密算法,在逆向分析里经常遇到。AES 相关的白盒实现资料中,白盒 AES 算法详解写得很不错,读起来也容易理解。但我在从原理落到代码实现的过程中常常卡壳,所以这里结合 C 语言源码把 AES 的加密流程串一遍。本文使用的源码是 tiny-AES-c: https://github.com/kokke/tiny-AES-c

密钥扩展

下面以 AES-128 为例。输入密钥长度是 16 字节,因此每次参与加密的明文长度也要与之匹配。加密前需要先把明文按 16 字节一组切分,再按组分别加密,这与 ECB 加密模式的处理方式一致。

AES ECB 模式加密流程图:多组明文分别经密钥控制的分组加密后得到密文

AES 加密一般要经过 10 轮运算,所以总共需要 11 个密钥:每轮一个,再加上最初的一个。用初始密钥生成后续 10 个密钥的过程,就是密钥扩展。如下图所示,k0 到 k3 为初始密钥,每个块占 4 字节,其余 k4 到 k44 则通过初始密钥扩展计算得到。

AES 密钥扩展示意图:从初始密钥 k0-k3 扩展到 k4-k44,共 11 轮密钥

扩展密钥依赖公式:后面的密钥块由前面相邻密钥块异或得到。例如 k_n = k_{n-4} XOR k_{n-1},后面依次类推。

不过 k4、k8 直到 k40 这些位置比较特殊,它们的计算需要先经过 G 运算,再与前面的密钥块异或。

AES 密钥扩展示意图:标出第一列 k0、k4、k8、k40 需要经过 G 运算

G 运算

G 运算可以拆成三个步骤:行位移、S 盒替换、与常数进行异或。这里假设 k3 = 0x11223344,方便说明。

G 运算输入示意:k3 = 11 22 33 44

行位移

行位移实际上就是做一次循环左移,让每个字节整体向左移动一个字节的位置。

G 运算中行位移与循环左移示意:k3 由 11 22 33 44 变为 22 33 44 11

在 tiny-AES-c 中,行位移是通过临时变量交换实现的,核心逻辑如下:

// 行位移
const uint8_t u8tmp = tempa[0];
tempa[0] = tempa[1];
tempa[1] = tempa[2];
tempa[2] = tempa[3];
tempa[3] = u8tmp;

S 盒替换

S 盒是一个长度为 256 的数组,里面存放着固定的数值。S 盒替换就是把字节的值作为下标,到数组中取出对应的值。

S 盒替换示意:k3 经行位移后变为 22 33 44 11,再替换为 S[0x22] S[0x33] S[0x44] S[0x11]

S 盒的数值如下,因此逆向时可以把 S盒的值作为AES算法的特征值 来判断目标程序是否使用了 AES。

AES S 盒数组定义截图:包含 256 个十六进制常量值

S 盒替换的实现方式是先定义 sbox 数组,然后把行位移后得到的密钥字节值作为下标,从 sbox 中取值,示例代码如下:

#define getSBoxValue(num) (sbox[(num)])

// ...
tempa[0] = getSBoxValue(tempa[0]);
tempa[1] = getSBoxValue(tempa[1]);
tempa[2] = getSBoxValue(tempa[2]);
tempa[3] = getSBoxValue(tempa[3]);
// ...

常量异或

参与常量异或的数值保存在名为 Rcon 的数组中。

AES Rcon 常量数组定义截图:包含 0x8d、0x01、0x02 等 11 个十六进制值

接下来把 S 盒替换的结果与这些常量进行异或。这里的 n 代表轮数,对应后续 10 轮扩展中使用的 Rcon 值。需要注意的是,Rcon 数组下标从 1 开始使用,而且每个元素只占一个字节,所以只需要取密钥的第一个字节进行异或即可。

G 运算完整流程示意:行位移、循环左移、S 盒替换、常数异或逐步变换 k3

进一步看代码,由于 Nk=4,i 只会取 4 的倍数,因此 i/Nk 恰好表示轮数:第一轮使用 Rcon[1],第二轮使用 Rcon[2],依此类推。

// 常量异或
tempa[0] = tempa[0] ^ Rcon[i/Nk];

经过以上步骤就得到了 G 运算的结果。比如扩展密钥 k4 就是经过 G 运算后的 k3 与 k0 异或得到。也就是说,k4 = G(k3) XOR k0。

这里有个实现细节:代码是以字节为单位处理的,而 AES 算法中的 k_n 是以 4 字节为单位的,所以处理下标时要乘以 4。无论 tempa 中存的是经过 G 运算的结果还是普通的前一轮密钥块,都可以统一用下面这段异或逻辑处理:

// j 为密钥具体字节的下标,k 代表的是 n-4
// tempa 数组存储经过 G 函数处理后的密钥字节
j = i * 4;
k = (i - Nk) * 4;

RoundKey[j + 0] = RoundKey[k + 0] ^ tempa[0];
RoundKey[j + 1] = RoundKey[k + 1] ^ tempa[1];
RoundKey[j + 2] = RoundKey[k + 2] ^ tempa[2];
RoundKey[j + 3] = RoundKey[k + 3] ^ tempa[3];

加密阶段

正式加密前,需要先把明文转换为 state 状态矩阵。转换方式并不复杂,本质就是按列存储明文数据。

AES 明文按列填充 state 状态矩阵的示意图

整体加密过程如下图所示:先经过轮密钥加、字节替换、行位移、列混淆,其中最后一轮不再进行列混淆。

AES 整体加密流程图:从 Plaintext 经密钥扩展、9 轮 SubBytes/ShiftRows/MixColumns/AddRoundKey 后得到 Ciphertext

轮密钥加

在 AES 中,加法表现为异或操作。轮密钥加就是按字节把明文与当前轮密钥逐字节异或。

AES 轮密钥加示意图:两个 4x4 矩阵逐字节进行 XOR 运算

在 tiny-AES-c 中,state 实际按行存储,但轮密钥加只是逐字节异或,因此按行存储并逐字节取出明文与密钥异或,最终结果不会受影响。相关实现如下:

static void AddRoundKey(uint8_t round, state_t* state, const uint8_t* RoundKey)
{
  uint8_t i, j;

  // 轮密钥加,逐个字节异或
  for (i = 0; i < 4; ++i)
  {
    for (j = 0; j < 4; ++j)
    {
      // 每轮密钥是 16 字节
      (*state)[i][j] ^= RoundKey[(round * Nb * 4) + (i * Nb) + j];
    }
  }
}

字节替换

字节替换与密钥扩展中的 S 盒替换完全一样。这里逐行逐列取出字节,再进行 S 盒替换:

static void SubBytes(state_t* state)
{
  uint8_t i, j;

  for (i = 0; i < 4; ++i)
  {
    for (j = 0; j < 4; ++j)
    {
      (*state)[j][i] = getSBoxValue((*state)[j][i]);
    }
  }
}

行位移

行位移以 state 矩阵为单位,对每一行做循环左移。第一行不移动,第二行左移 1 字节,第三行左移 2 字节,第四行左移 3 字节。

AES 行位移示意:四行分别左移 0、1、2、3 个字节

由于 tiny-AES-c 将明文按行存储方式转换为 state,所以实际移位时要“按列”来实现。

AES 与 tiny-AES-c 存储方式对比:行存储与列存储下循环左移的方向差异

static void ShiftRows(state_t* state)
{
  uint8_t temp;

  // Rotate first row 1 columns to left
  // [1][1] 移动到 [0][1],相当于向上移动 1 个字节
  temp           = (*state)[0][1];
  (*state)[0][1] = (*state)[1][1];
  (*state)[1][1] = (*state)[2][1];
  (*state)[2][1] = (*state)[3][1];
  (*state)[3][1] = temp;

  // Rotate second row 2 columns to left
  // [2][2] 移动到 [2][2],相当于向上移动 2 个字节
  temp           = (*state)[0][2];
  (*state)[0][2] = (*state)[2][2];
  (*state)[2][2] = temp;

  temp           = (*state)[1][2];
  (*state)[1][2] = (*state)[3][2];
  (*state)[3][2] = temp;

  // Rotate third row 3 columns to left
  // [3][3] 移动到 [0][3],相当于向上移动 3 个字节
  temp           = (*state)[0][3];
  (*state)[0][3] = (*state)[3][3];
  (*state)[3][3] = (*state)[2][3];
  (*state)[2][3] = (*state)[1][3];
  (*state)[1][3] = temp;
}

这段代码的作用可以这样理解:把表格翻转一下,原本的“向左移动”就等价于代码中的“向上移动”。

列混淆

列混淆通过矩阵乘法实现。

AES 列混淆矩阵乘法示意:系数矩阵与字节列矩阵相乘

展开后得到的式子如下:

AES 列混淆结果矩阵展开示意

在 AES 中加法就是异或,所以式子进一步变为:

AES 列混淆异或表达式

列混淆中的乘法属于伽罗瓦域内乘法。根据前面的式子,核心情况有三种:乘以 1、乘以 2、乘以 3。

  • 乘以 1:保持原值。
  • 乘以 2:左移一位,同时判断是否溢出。如果最高位为 1,左移后需要额外异或 0x1b。
  • 乘以 3:可以拆成乘以 2 后再异或原值。

tiny-AES-c 中的列混淆实现如下。其中 xtime 是 GF(2^8) 下的二倍乘:先左移一位,再右移 7 位判断最高位是否为 1。若最高位为 1,说明左移发生溢出,需要异或 0x1b,否则不需要。

实际代码中会先算出 A xor B xor C xor D 作为公共部分,因为每一轮列混淆都会反复用到它,提前计算可以避免重复操作。以 2A xor 3B xor C xor D 为例,它的化简过程如下:

列混淆公式化简推导过程:2A xor 3B xor C xor D 的代数化简

因此,列混淆的计算可以归纳为三个部分:

  • 二倍乘计算
  • 公共部分计算
  • 自身值
// xtime 为 GF(2^8) 的二倍乘
static uint8_t xtime(uint8_t x)
{
  // 左移一位相当于乘以 2,然后右移 7 位判断最高位是否为 1
  // 为 1 就需要异或 0x1b,否则不用
  // 最高位为 1,左移会溢出,因此需要加上 0x1b,而 GF(2^8) 中加法等于异或
  return ((x << 1) ^ (((x >> 7) & 1) * 0x1b));
}

// MixColumns function mixes the columns of the state matrix
static void MixColumns(state_t* state)
{
  uint8_t i;
  uint8_t Tmp, Tm, t;

  for (i = 0; i < 4; ++i)
  {
    // t 是 A
    t   = (*state)[i][0];

    // 先求 A xor B xor C xor D,因为这是求解的公共部分,避免重复操作
    Tmp = (*state)[i][0] ^ (*state)[i][1] ^ (*state)[i][2] ^ (*state)[i][3];

    // 2A+3B+C+D = 2A+2B+B+C+D = 2*(A+B)+B+C+D = 2*(A+B)+(A+B+C+D)+A
    Tm  = (*state)[i][0] ^ (*state)[i][1];
    Tm  = xtime(Tm);
    (*state)[i][0] ^= Tm ^ Tmp;

    // A+2B+3C+D
    Tm  = (*state)[i][1] ^ (*state)[i][2];
    Tm  = xtime(Tm);
    (*state)[i][1] ^= Tm ^ Tmp;

    // A+B+2C+3D
    Tm  = (*state)[i][2] ^ (*state)[i][3];
    Tm  = xtime(Tm);
    (*state)[i][2] ^= Tm ^ Tmp;

    // 3A+B+C+2D
    Tm  = (*state)[i][3] ^ t;
    Tm  = xtime(Tm);
    (*state)[i][3] ^= Tm ^ Tmp;
  }
}

逆向中 AES 的识别

这里以 [SCTF2019]creakme 为例,说明如何 在 IDA 的反编译结果中识别 AES 算法。

密钥扩展

当看到一串明文字符时,可以先根据字符串长度判断它是否可能是密钥,进而推断 AES 的种类。例如下图中存在字符串 sycloversyclover,长度正好是 16,并且代码把它按字节拆开存储。结合 tiny-AES-c 的源码可以知道,实际运算时密钥需要以字节为单位处理,因此可以猜测该程序可能使用了 AES-128,这个字符串很可能就是密钥。

IDA 反编译代码截图:16 字节字符串 sycloversyclover 被逐一拆成字节存储

再结合后续操作能发现,185 行出现了 S 盒替换 S_BOX[v31] 以及 << 8 这类行位移特征。可以看到,IDA 反编译会把 G 运算的多步操作合并到一起。

IDA 伪代码截图:出现 S_BOX 查找与多次左移 8 位操作,疑似 AES 密钥扩展

G 运算中还有一个常量异或操作,因此 *v32 大概率是在取 Rcon 数组的值。v32 由 v59 赋值而来,而 v59 又来自 unk_406B40。继续查看 unk_406B40 的数据,可以发现这些值与 AES 的 Rcon 数组完全一致,这就进一步验证了目标算法是 AES,当前函数属于密钥扩展操作。

IDA 数据段截图:unk_406B40 的值与 AES Rcon 数组一致

另一个关键点是循环次数。AES 密钥扩展需要扩展到 k44,所以循环下标最大值也是 44。从调试信息中可以看到循环变量上限与这个特征吻合。

IDA 调试器截图:循环条件与 v60 变量值显示为 0x2C,即 44

加密阶段

加密阶段可以直接看最后一轮,因为最后一轮不会执行列混淆。下图中可以明显看到 S 盒替换和轮密钥加操作。可能有人会疑惑:行位移在哪里?

仔细看每次参与 S 盒替换的变量其实不同,分别是 v21、v5、v23、v24。这些变量都是 int 类型,每个变量实际保存了 4 字节,这就相当于按行存储。前面已经分析过,如果 state 是按行存储的,那么移位就要按列向上移动,所以第二次替换顺序变成了 v5、v23、v24、v21。

IDA 伪代码截图:最后一轮 AES 加密中存在 S 盒查找与轮密钥异或

那么前面的部分是否只是少了列混淆?这部分的字节替换看起来确实存在,但替换用的表似乎并不是普通 S 盒。

IDA 伪代码截图:加密循环中调用 key_expand 与 encrypt,整体逻辑类似分段加密

实际上这是 AES 的一种“空间换时间”实现,也就是 T 盒(T-table)实现。前面介绍的标准实现是:明文输入后依次经过轮密钥加、S 盒替换、行位移、列混淆。这些操作都以字节为单位,字节之间互不影响。一个字节的范围是 0 到 255,因此可以提前把每个字节经过 S 盒替换、行位移、列混淆后的结果算好,存成一张新表,这就是 T 盒。当输入一个明文字节时,只需直接查 T 盒即可得到上述多步结果,从而大幅减少运算时间。这也是为什么这里替换用的表不是原始 S 盒。

分析到这里,基本可以确认该函数就是加密阶段。

加密模式

只要能识别出密钥扩展或加密阶段,通常已经能判断目标算法是 AES。但为了后续逆向更完整,还是建议快速区分这两个阶段。原因是分组对称加密还存在 ECB、CBC、CFB 等工作模式。例如下图中,加密前会先与 v15 进行异或,这就可以猜测是 CBC 模式,接下来就需要重点寻找 IV 初始向量。

IDA 伪代码截图:加密前存在与 v15 逐字节异或逻辑,疑似 CBC 模式

而在密钥扩展期间还能看到 IV 向量的拷贝过程,这也进一步验证了 CBC 加密模式的判断。

IDA 数据段与伪代码关联截图:key_expand 附近同时出现 IV 向量数据拷贝

总结

AES 是常见的对称加密算法,如果熟悉它的加密流程,在逆向分析时可以显著节省时间。

识别 AES 算法时,可以重点从以下几个特征入手:

  • 密钥扩展的循环次数
  • S 盒特征值
  • 第十轮加密流程中是否缺少列混淆

参考链接




上一篇:英伟达被曝禁止采用 AMD/TPU 芯片的云厂商参加 GTC 柏林大会
下一篇:互连芯片千亿市场:国产厂商如何补上算力运力短板
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-10-20 23:30 , Processed in 0.069613 second(s), 38 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表