Kerckhoffs
原则提出,一个密码系统的安全仅依赖于密钥的安全,除密钥外,密码系统的一切都被视为是公开的信息。然而在现实中,软件逆向技术和动静态调试技术已经能够成功对密码系统进行密钥提取攻击
。
鉴于上述事实,工业界和学术界开始了对白盒密码算法
的研究。白盒密码算法的宗旨非常明确,就是保证密钥在白盒攻击环境
下的安全。而差分计算分析
和差分故障分析
在白盒密码算法上的成功应用,又给白盒密码算法带来了新的挑战。本文主要讲解差分故障分析,以及介绍使用Unicorn
框架对白盒密码算法进行动态故障注入的实例。
1. 原理篇
由于篇幅有限,原理篇不会长篇大论的介绍相关理论知识,仅对影响文章理解的知识进行相关的补充。读者可以通过以下链接补充知识。
-
白盒密码学:A Tutorial on White-box AES
-
差分故障分析:DFA on Whitebox AES
1.1 白盒分组密码
白盒分组密码学
主要围绕着两种模式进行设计,分别是:
-
对已有分组密码算法实现其白盒版本算法。如:AES、SM4的白盒实现版本。
-
设计新的能够抵抗密钥提取攻击的白盒算法。如:SPNBOX。
针对第一种模式,算法设计者往往通过对分组密码算法的关键步骤 sbox
转换为查找表操作。然后再对查找表进行编码保护。为了保证兼容性,算法设计者一般不会对算法的输入和输出做编码保护。这也直接导致了对普通分组密码算法有效的攻击手段同样也能应用到该算法的白盒实现版本上,如:差分故障分析和差分计算分析。
1.2 差分故障分析
1.2.1 差分故障分析原理
差分故障分析(DFA)可以理解为,在一个加密系统正常运行的过程中,注入故障到指定位置,使得加密系统运行出错,从而得到错误的密文。通过对错误密文和正确的密文进行差分分析,再结合 sbox 的输入差分和输出差分表,最终得到轮密钥。
差分故障分析的概述如下图所示:
现假设加密程序 Cipher 对消息 m 进行加密,正常运行后得到密文 c。现有一攻击者,捕获到了 m 和 c, 并且该攻击者尝试再次执行 Cipher,输入仍为 m, 并且在运行过程中,如愿以偿的注入了故障到某一轮运行之前,导致 Cipher 运行出错,输出了 c*。再结合 sbox 的输入差分和输出差分分布表,最终攻击者能够提取出轮密钥,进而恢复出主密钥。
为了方便读者理解,本文以一个 3 比特的简单的加密算法 E 举例。E 对应的 S 盒同样为 3 比特,具体如下表:
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
---|---|---|---|---|---|---|---|
6 | 2 | 4 | 7 | 1 | 0 | 3 | 5 |
经过上述 S 盒,可在密码算法中起到非线性变换的作用,如: Sbox(3) = 7。假设加密算法 E 如下表示:
现有 E 的一个程序实现,未知,攻击者 A 对 E 进行差分故障分析。具体步骤如下:
1.正常执行程序 E ,分别设置输入为 6,2,得到的输出分别为为 0,2,如下表:
M | K | C |
---|---|---|
6 | - | 0 |
2 | - | 2 |
2.攻击者 A 在 E 运行过程中注入故障(在此例中表现为修改输入)。如下表:
M' | K | C' |
---|---|---|
7 | - | 1 |
5 | - | 3 |
3.为了进一步分析,攻击者 A 根据 S 盒构造了输入差分与输出差分分布表,对于差分分布表的理解,本文举一个小例子:Sbox(0) = 6; Sbox(3)=7。那么输入差分为 ,输出差分为 ,此时就可以说,当输入差分为3,输出差分为1的时候,S盒的可能存在输入为0,3。由于为了方便举例说明,本案例使用的S盒为3比特,而对于AES算法来说,S盒是8比特的,那么差分分布表将会非常大。本案例S盒的差分分布表如下表所示, 其中纵坐标表示输入差分,横坐标表示输出差分。
DIDO | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
---|---|---|---|---|---|---|---|
1 | 4,5 | 2,3 | 0,1 | 6,7 | |||
2 | 2,4,6 | 1,3,5,7 | |||||
3 | 0,3 | 5,6 | 4,7 | 1,2 | |||
4 | 1,3,5,7 | 0,2,4,6 | |||||
5 | 2,7 | 1,4 | 3,6 | 0,5 | |||
6 | 0,2,4,6 | 1,3,5,7 | |||||
7 | 1,6 | 0,7 | 2,5 | 3,4 |
4.结合输入输出差分表,构造M和M‘,C和C’的差分关系,可得到如下表:
M M' | C C' | input(查差分表) | K=input M |
---|---|---|---|
6 ^ 7 = 1 | 0 ^ 1 = 1 | {4,5} | {2,3} |
2 ^ 5 = 7 | 2 ^ 3 = 1 | {1,6} | {3,4} |
5.得到的两组密钥候选值取一下交集,最后确定密钥为 3 。
1.2.2 基于单字节故障的 AES 差分故障分析
基于单字节故障的 AES 差分故障分析是由 Dusart 等人在2003年提出的,在该方案中, 攻击者无需知道注入错误的确切位置与确切值, 仅通过对比分析正确密文和故障注入后所引导产生的错误密文, 即可分析出第十轮密钥。详细内容可以参考文章DFA on Whitebox AES(https://blog.quarkslab.com/differential-fault-analysis-on-white-box-aes-implementations.html)。中文的解说可以参考论文一种NoisyRounds保护的白盒AES实现及其差分故障分析(http://www.jcr.cacrnet.org.cn/CN/abstract/abstract396.shtml)。
该方案首先在第 9 轮列混合操作之前注入一个字节的故障,经过后续的轮操作,最终会产生带有故障的密文,如下图所示。这些故障密文和正确的密文存在一定的差分关系,通过分析这种差分关系可以得到 S 盒的输入差分和输出差分关系,进一步的确定密钥 。
2. 工具篇
2.1 Unicorn
Unicorn 是一个轻量级、多架构平台的CPU模拟器。由于其显著的特点,被众多逆向分析工作者和二进制爱好者使用。Unicorn 可以有不同方式的hook,包括但不限于指令hook、内存读写hook、读写异常hook等。对于差分故障分析而言,最为重要的前提条件就是需要能够获得满足条件的错误密文,而 Unicorn 框架正好能够满足需求,可以通过hook程序运行时的读内存的时的指令并修改其数据来达到故障注入的目的。但 Unicorn 仅仅是模拟CPU功能,对于一般的二进制程序而言,还存在一些系统调用函数,如: scanf, printf,strcpy 等,这些函数需要使用者自行 hook 并手动实现,这要求使用者需要具备一定的汇编能力。
2.2 rainbow
rainbow (https://github.com/Ledger-Donjon/rainbow)是由比特币硬件钱包制造商 Ledger 公司的安全研究团队Ledger Donjon (https://donjon.ledger.com/about/)在 github 开源的 DBI 工具,这款工具基于 Unicorn 开发,在 Unicorn 的基础上封装了很多实用的功能,在加载 ELF 文件、读写内存、hook function 方面做了很大改进,更重要的是,该项目主要针对侧信道攻击做了优化,能够自动生成 traces 记录,对差分计算分析特别有用。本文将使用该工具进行 DFA 实验。
2.3 phoenixAES
phoenixAES(https://github.com/SideChannelMarvels/JeanGrey/tree/master/phoenixAES)是由 Side-Channel Marvels (侧信道分析研究组织) 在 github 开源的针对 AES 做差分故障分析的工具。在错误密文充足的情况下,该工具可以分析出轮密钥。在使用rainbow 工具得到足够多的错误密文后,本文将使用该工具对错误密文进行差分分析,提取出轮密钥。
3. 实现篇
3.1 故障注入
对于白盒分组密码算法的注入实际上可以分为动态注入和静态注入,以下分别介绍,
动态注入:在加密程序运行过程中,可以通过修改寄存器的值、修改指令、修改内存中的值来达到故障注入的目的。这种方式的优点是能够将故障注入到程序执行的任何地方。但需要使用者有一定的逆向和汇编基础。
静态注入:在加密程序执行前,通过修改程序依赖的二进制文件来达到故障注入,例如:白盒加密程序往往会依赖一个白盒查找表,这个查找表可能独立存在,也可能嵌入到加密程序中,如果将故障注入到这个白盒查找表后再执行这个程序,程序输出结果改变了,那就达到了注入的效果。这种方式的优点是操作简单,但缺点也很明显,首先白盒查找表有非常多的值是不会被用到的,这意味着很可能注入失败。其次静态注入无法全面覆盖到算法执行过程中的细节部分。如果查找表做了一定的保护,那么注入也可能失败。
本文将使用 rainbow 工具对AES白盒算法实现whitebox进行动态注入。该实例来自 RHME3 CTF 。
3.1.1 确定注入区间
为了能够更加精准的注入故障到AES算法的第9轮列混合操作之前,首先对 whitebox 执行过程进行分析,使用 Tracer 工具记录一下 whitebox 的执行过程。该工具基于 unbuntu 18.04,为了方便大家使用,本文已经事先将环境集成到了一个 docker 镜像中,方便大家使用。
docker pull tobyssd/side_channel_tool_for_whitebox_cipher:latest
进入容器以后,将whitebox放入后,在whitebox 所在地址执行以下命令:
echo -n 00000000000000000000000000000000|Tracer -t sqlite -o rhme3.sqlite -- ./whitebox --stdin
然后生成了trace记录 rhme3.sqlite,这是一个sqlite 数据库文件,可以使用 navicat 打开,也可使用 TraceGraph打开,TraceGraph 可以直接编译安装。TraceGraph打开如下图所示
其中纵坐标表示时间,从上到下增长。横坐标表示地址,从左到右增长。黑色表示指令运行,绿色表示读内存、红色表示写内存。橙色表示内存读或写。通过使用放大缩小功能可以使得执行细节更详细。图上标注的 A区,可以看出随着时间的推移,几乎没有重复的指令,并且对应的几乎是写内存操作。所以暂时认为此处是白盒表的处理过程。而 B 区,随着时间的推移,使用的指令集中到了一小块地址上,这说明此处有循环操作,我们知道AES算法共有10轮,那么肯定存在循环。并且按时间顺序,有一个递增的对内存地址读的操作。说明 B 区极有可能是 AES 真正的执行过程。对 B 区放大看,如下图:
可以发现,放大以后有着明显的循环操作,尝试对每一个循环进行区分,可以大胆的假设,这就是 AES 执行过程,由于AES第10轮并无列混合操作,所以和之前的轮存在不一样,那么有9轮循环操作也是说的通的。我们把注入的范围定在第8-9轮内,找到对应的内存区间。如上图所示的 0x682000 - 0x689000。
3.1.2 注入故障
确定了注入区间以后,就可以尝试注入故障了。接下来,我将对注入脚本的部分细节进行阐述。
-
初始化环境
from rainbow.generics import rainbow_x64 # 导入rainbow 环境
e = rainbow_x64() #生成一个64位的虚拟机
e.load("whitebox", typ=".elf") #导入 whitebox 程序 -
对系统调用函数进行hook并手动实现
def pyprints(emu):
src = emu['rdi']
i = 0
c = emu[src]
while c != b'x00':
i += 1
c = emu[src+i]
return True
def pyput(emu):
src = emu['rdi']
i = 0
c = emu[src]
while c != b'x00':
i += 1
c = emu[src + i]
return True
def pystrlen(emu):
src = emu['rdi']
i = 0
c = emu[src]
while c != b'x00':
i += 1
c = emu[src + i]
emu['rax'] = i
return True
def pystrncmp(emu):
a = emu['rdi']
b = emu['rsi']
n = emu['rdx']
i = 0
flag = 0
for i in range(n):
ar = emu[a+i]
br = emu[b+i]
if ar != br:
flag = 1
break
if flag == 0:
emu['rax'] = 0
else:
emu['rax'] = 1
return True
def pystrncpy(emu):
dest = emu['rdi']
src = emu['rsi']
n = emu['rdx']
for i in range(n):
c = emu[src + i][0]
emu[dest + i] = c
return True
e.stubbed_functions['puts'] = pyput
e.stubbed_functions['strlen'] = pystrlen
e.stubbed_functions['freopen'] = pystrlen
e.stubbed_functions['strncmp'] = pystrncmp
e.stubbed_functions['strncpy'] = pystrncpy
e.stubbed_functions['printf'] = pyprints -
手动设置 main 的入参(明文输入)
input_buf = 0xCAFE1000
argv = 0xCAFE0000
e[input_buf] = b"0011223344556677"
e[argv + 8] = input_buf
e["rdi"] = 2 # argc
e["rsi"] = argv -
设置 hook,
#主要判断是否已经被注入过,是否满足长度为4字节
def should_fault(evtId, targetId, fault, address, size):
return evtId > targetId and fault and size == 4
def hook_mem_access_fault(mu, access, address, size, value, user_data):
global output, evtId, fault,p
evtId += 1
targetId = user_data[0]
if access == uc.UC_MEM_READ: #如果是读内存操作则进一步判断
if should_fault(evtId, targetId, fault, address, size):
print("FAULTING AT ", evtId)
p = evtId
fault = False
bitfault = 1 << random.randint(0, 31)
value = u32(mu.mem_read(address, size)) # 获取到需要被读取的值
#注入故障
nv = p32(value ^ bitfault)
e[address + 0] = nv[0]
e[address + 1] = nv[1]
e[address + 2] = nv[2]
e[address + 3] = nv[3]
# 其中 begin 和 end 表示监听内存区间
e.emu.hook_add(uc.UC_HOOK_MEM_READ, hook_mem_access_fault, begin=0x682000,end=0x689000,user_data=target) -
捕获密文,经过事先分析,发现密文会在指令地址 0x0400670 出现,捕获 rsi 即可得到密文。
def hook_code(mu, address, size, user_data):
global output
# 拿到输出
if address == 0x0400670:
if e['rsi'] < 256:
output.append(e['rsi'])
e.emu.hook_add(uc.UC_HOOK_CODE, hook_code)
以上是对注入的一些重要细节做了分析。具体的代码请看dfa_rhme3。
成功运行代码后,大概5分钟左右就能得到足够多的错误密文。
3.2 差分故障分析
使用 phoenixAES 对错误密文进行分析,最终将得到轮密钥。
import aes_dfa
aes_dfa.crack_file("faults.txt", verbose=2)
如图,得到轮密钥以后,根据AES的密钥编排的特点,可以直接回溯到第一轮主密钥。
原文始发于微信公众号(山石网科安全技术研究院):使用 Unicorn 对白盒密码算法进行动态故障注入实践
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论