首页
论坛
专栏
课程

[原创]一种还原白盒AES秘钥的方法

2019-8-21 18:41 2257

[原创]一种还原白盒AES秘钥的方法

2019-8-21 18:41
2257

一种还原白盒AES秘钥的方法

背景

在日常逆向分析工作有遇到过一个白盒AES算法,在网上找到这样一篇还原该白盒算法秘钥的文章:
DFA分析白盒AES算法 ,通过学习该文章,总结了一些心得,在这里分享下。

Differential Fault Analysis

详细的理论可以看上面那篇blog,我这里只挑一些重点来说明下。

AES算法

AES算是我们日常开发中最常用一种对称加密算法了,加密过程如下:

主要有这四种操作:
1.S盒字节代换
2.行移位
3.列混淆
4.轮秘钥加

白盒AES算法

白盒算法是将秘钥混淆到算法中,让攻击者即便能够获取算法的内部细节(能够动态调试),也无法 还原出秘钥的一种算法,常见的白盒算法有:白盒AES,白盒SMS4。

DFA分析AES-128加密

由AES加密算法流程可以看出:第10次轮秘钥加之前是没有列混淆的。如果我们在第九轮列混淆之前构造如下两组数据:

从上图两个状态矩阵可以看出,状态矩阵中只有第一个字节不一样。如果当前状态继续往下推导,可以有如下:

  • MixColumns
  • AddRoundKey K9
  • SubBytes
  • ShiftRows
  • AddRoundKey K10
    详细推导过程可以参照原文blog:
    最终可以推导得到如下表达式:

    四个表达式表示了Z与(Y0,Y1,Y2,Y3)的关系。
    对Y0从0~255就能得到对应Z的取值集合,同理对Y1,Y2,Y3取值,都能得到一个Z的取值范围(这里是多对一映射)。
    所以最终Z的取值只能是这4个z的取值范围的交集

    Z的取值范围确定后,对应也可以确定一组(Y0,Y1,Y2,Y3)的值,继而由:
    可以得到一组K10的(0,7,10,13)的位置值。
    同理可以改变X的值,通过合并得到唯一确定的K10(0,7,10,13)的值。
    同理改变其他位置上一个字节的值,可以得到另外3组位置的值,继而可以还原得到整个K10的值,再根据AES秘钥拓展算法,最终可以还原原始的加密秘钥。

DFA算法实现

这里的算法分为两个部分:
1.产生这些fault数据的方法:
该算法在DFA 产生Fault数据
该算法主要是通过静态修改二进制文件方式来修改R9的一个字节,从而输出Fault数据的。(这里面具体的实现细节没太弄懂,有兴趣都可以自行去看代码,后面的实战,我主要用IDA动态Patch方式实现输出Fault数据,没有用到这里算法。)

 

2.拿到这些fault数据后,怎么样还原出白盒AES秘钥
该算法实现是在github上:phoenixAES
下面主要对该算法的学习,解读。
首先该算法的输入一个格式化的文件,每一行分别是输入状态矩阵(16进制字符串表示),输出状态矩阵(16字节128位)

算法输入

经过分析代码可以发现,其实代码中没有用到输入的值,只需对输出值进行计算,并且第一行的输出值定为golden_ref,即没有做改变的原始正常的值,其他输出值为diff,即在第九轮状态更改一字节后输出的值。

算法输出

最终算法输出的是还原的第10轮的秘钥,要得到原始AES秘钥,还需逆推一下。

算法关键点说明

_AesFaultMaps= [
# AES decryption
 [[True, False, False, False, False, True, False, False, False, False, True, False, False, False, False, True],
  [False, True, False, False, False, False, True, False, False, False, False, True, True, False, False, False],
  [False, False, True, False, False, False, False, True, True, False, False, False, False, True, False, False],
  [False, False, False, True, True, False, False, False, False, True, False, False, False, False, True, False]],
# AES encryption
 [[True, False, False, False, False, False, False, True, False, False, True, False, False, True, False, False],
  [False, True, False, False, True, False, False, False, False, False, False, True, False, False, True, False],
  [False, False, True, False, False, True, False, False, True, False, False, False, False, False, False, True],
  [False, False, False, True, False, False, True, False, False, True, False, False, True, False, False, False]]
]

该算法实现中有这样一个列表,这个列表是来判断当前diff是属于哪一类,对应秘钥哪4个位置,比如
拿加密来说,在第九轮列变换之前改变状态矩阵第一列中某一个字节的值,最后的结果只会在(0,7,10,13)的位置上发生改变,也就对应第10轮秘钥的(0,7,10,13)位置上的值。同理改变第二列上的值,只会改变(1,4,11,14)位置上的值。

状态矩阵乘法


AES加密列变换阵数值只在:1,2 ,3 中取,
AES解密列变换的数值只在:9, 13, 11, 14中取
这里的列表
_AesMult[1]可以表示(0~255)中分别与1相乘的结果。
_AesMult[9]可以表示(0~255)中分别与9相乘的结果。

核心算法

def _get_compat(diff, tmult, encrypt):
    print ("_get_compat","diff:%x" % diff, "tmult:%x" %tmult)
    ibox = [_AesSBox, _AesInvSBox][encrypt]
    itab = [0]*256
    for i,mi in enumerate(_AesMult[tmult]):
        itab[mi] = i
    candi = [itab[ibox[j ^ diff] ^ ibox_j] for j, ibox_j in enumerate(ibox)]
    return candi

这里 j=S(Y0), ibox_j = invSBox(Y0) = Y0
由上述推导公式:diff = S(Y0) + S(Y0+2z) 可以变换得(加号均表示异或):
diff + S(Y0) = S(Y0+2z)
invSBox(diff + S(Y0)) = Y0 + 2z
invSBox(diff + S(Y0)) + Y0 = 2z
candi = [itab[ibox[j ^ diff] ^ ibox_j] for j, ibox_j in enumerate(ibox)]
所以这里这句是返回是S(Y0)从0~255取值时,对应z的取值。
所以后面会对z进行求交集操作,然后反过来根据z的值,去取S(Y0)的取值列表,最后通过多组数据的求解归并得到唯一确定的S(Y0)的值,最后求秘钥时会有一个:
key[Keys[j]]=list(c[index][0][j])[0] ^ Gold[j]

实战

讲了这么多理论算法实现,还是来个具体的示例更容易说明问题,这里选的列子是这篇blog上提的:
LIFE破解白盒AES
它里面提到的解法,是通过用LIEF将Android so转化成Linux上的可执行程序,然后对接上述的DFA
静态产生Fault数据方法得到一组数据,然后调用[phoenixAES]进行秘钥还原。我这边主要是通过IDA 动态Patch来得到一组Fault数据。

 

里面的APK是SECCON2016 CTF中的一题:SECCON2016 Online CTF-Binary / Crypto500 Obfuscated AES

java层分析


主要从资源文件arrays中随机取一个字符串传入到native函数 a 进行加密,然后每隔0.01s递归调用一次,所以程序运行后界面上的encrypted_flag一直在变化。
arrays列表:

native层分析

用IDA打开lib-native.so可以发现该so经过了ollvm混淆了,而且题目也说清楚了这个是一个ollvm混淆的白盒AES算法。
通过IDA静态分析,可以定位到静态注册的JNI函数:Java_kr_repo_h2spice_crypto500_MainActivity_a
简单分析一下可以定位到关键函数: TfcqPqf1lNhu0DC2qGsAAeML0SEmOBYX4jpYUnyT8qYWIlEq
通过frida hook 一下该函数查看该函数的输入输出值:

def hook_AES():
    jsCode = """

    function hookOAES() {
        var fnPtr = Module.findExportByName("libnative-lib.so", "_Z48TfcqPqf1lNhu0DC2qGsAAeML0SEmOBYX4jpYUnyT8qYWIlEqPhS_");
        var oldfnPtr = new NativeFunction(fnPtr, 'int', ['pointer', 'pointer']);
        Interceptor.replace(fnPtr, new NativeCallback(function (input, output) {
            send("***********OAES***********")
            var arg0 = Memory.readByteArray(input,16);
            console.log("a0:" + hexdump(arg0));

            var arg1 = Memory.readByteArray(output,16);
            console.log("a1 before:" + hexdump(arg1));

            var ret = oldfnPtr(input,output);

            var arg2 = Memory.readByteArray(output,16);
            console.log("a1 after:" + hexdump(arg2));

            return ret;
        }, 'int', ['pointer', 'pointer']));
    }

    Java.perform(function(){
        send("***************Start hook***************");

        var Main = Java.use("kr.repo.h2spice.crypto500.MainActivity");
        Main.a.overload("java.lang.String").implementation = function (input) {
            input = "Gew1cqzKp5K8sejh3FlTZlS/CISCpO81WmZ/oU4SJOk=";
            console.log("native-input:" + input);
            var ret = this.a(input);
            console.log("native-output:" + ret);
            return ret;
        }
        Main.onCreate.overload("android.os.Bundle").implementation= function (bundle) {
             console.log("MainActivity onCreate!!!");
             return this.onCreate(bundle);
        }
       hookOAES();
    });
    """
    return jsCode

这里有两点说明下:
1.在hook native层导出函数时用的replace的方式,而不是attach方式是为了能够在函数执行完后再打印一遍输入参数,因为在C中会经常传个指针,在函数中操作的结果也是保存在这个指针中。在本列中TfcqPqf1lNhu0DC2qGsAAeML0SEmOBYX4jpYUnyT8qYWIlEq函数a0参数是加密前java层传下来的数据,a1是经过加密后的数据。

 

2.这里也hook了Java层的JNI函数,为了让每次传下来的值保持一致,这里我是随机选取一个值:"Gew1cqzKp5K8sejh3FlTZlS/CISCpO81WmZ/oU4SJOk=",这样没隔0.01s都会触发一次调用。

 

经过上面的hook分析可以得出:
java层传下来的加密数据都是:32字节的数据,所以在C层调用了两次TfcqPqf1lNhu0DC2qGsAAeML0SEmOBYX4jpYUnyT8qYWIlEq函数,分别加密前16字节和后16字节(ECB模式),然后将加密后的数据Base64一下返回给java层,显示到界面。

秘钥还原

有了上面分析,结合phoenixAES算法,要还原秘钥,只需要想办法去产生R9 Fault数据,也就是在
调用TfcqPqf1lNhu0DC2qGsAAeML0SEmOBYX4jpYUnyT8qYWIlEq函数内部,某个时间点(刚好在R9列变换前)更改状态矩阵的一个字节的数据,得到加密后的数据,对比输出数据与golden_ref是否刚好只有4字节不同。(如果只有1字节改变,说明patch太晚,如果有16字节不同说明patch太早)。

 

所以问题就变成如何刚好找到Patch的时间点,由于so被混淆了不能很直观的看到函数执行流程,这里用IDA Python 去打印该函数的核心块的执行过程:

 

这里我调试是armeabi-v7a的so,其中断点4个位置分别是:
1.OAES函数的开始
2.OAES函数的结尾
3.OAES函数其中一处明显的子函数调用
4.OAES函数其中一处明显的数据处理块的起始地址。
运行完可以明显观察到子函数调用了10次,每次调用之间调用了4次核心处理模块共九组,是不是可以类比AES10轮加密操作,所以我选择在第九组核心操作前进行patch一字节,然后观察到最后的输出结果果然是符合预期的,最后的patch代码如下:

# -*- coding: UTF-8 -*-
import idc
import idaapi
import breakFunctions
import time
import linecache
import dumpmemory
import random

soName = 'libnative-lib.so'
module_base  = breakFunctions.findModleBaseByName(soName)
gloden_ref = "868FC14BCCC36E3C657B73271A32DCD7"

# AESEnc
sub_4250  = module_base + 0x4250
sub_59A6  = module_base + 0x59A6

sub_46A4  = module_base + 0x46A4

sub_4DBC  = module_base + 0x4DBC

def main():

    idc.AddBpt(sub_4250)
    idc.MakeComm(sub_4250, "### WBoxAES  START###")
    print "[+]set breakpoint addr=>0x%X %s" % (sub_4250, "sub_4250")

    idc.AddBpt(sub_59A6)
    idc.MakeComm(sub_59A6, "### WBoxAES  END###")
    print "[+]set breakpoint addr=>0x%X %s" % (sub_59A6, "sub_59A6")

    idc.AddBpt(sub_4DBC)
    idc.MakeComm(sub_4DBC, "### BL      Z48lrsFdMdlAT0vSMVedxmqOkCBF7sCTbhCjYEp1rLP8vatWEGDPh###")
    print "[+]set breakpoint addr=>0x%X %s" % (sub_4DBC, "sub_4DBC")

    idc.AddBpt(sub_46A4)
    idc.MakeComm(sub_46A4, "### sub_46A4 ###")
    print "[+]set breakpoint addr=>0x%X %s" % (sub_46A4, "sub_46A4")

    auto_run_test()

def auto_run_test():
    logpath = "C:\\Users\\felix.li\\Desktop\\Crypto500_WAESCrack1.txt"
    f = open(logpath, "a")
    f.write(time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(time.time())) + "*****START******\n")

    for i in range(16):
        runTo(sub_4250)
        count = 0
        is_this_round = True
        while True:
            addr = idc.GetEventEa()
            if addr == sub_4250:
                f.write("OAES START\n")
                print ("OAES START\n")

                R0 = idc.GetRegValue("R0")
                R1 = idc.GetRegValue("R1")

                srcR0 = toHexString(getMemory(R0, 16))
                print  srcR0

                if srcR0.lower() != "19ec3572accaa792bcb1e8e1dc595366":
                    print "not this round"
                    f.write("not this round\n")
                    is_this_round = False
                    break
                f.write("srcHexstr:" + srcR0 + "\n")

                ret_address = R1
                print hex(ret_address)

            if addr == sub_59A6:
                f.write("OAES END\n")
                print ("OAES END")

                print  hex(ret_address)
                desret = toHexString(getMemory(ret_address, 16))
                print "resHexstr:" + desret
                f.write("resHexstr:" + desret + "\n")

                break
            if addr == sub_4DBC:
                # f.write("BL lrsFdMdlAT0vSMVedxmqOkCBF7sCTbhCjYEp1rLP8vatWEGD\n")
                print ("BL lrsFdMdlAT0vSMVedxmqOkCBF7sCTbhCjYEp1rLP8vatWEGD")

            if addr == sub_46A4:
                # f.write("sub_46A4\n")
                print ("sub_46A4")
                count = count + 1
                if count == 33:
                    print hex(ret_address)
                    desret = toHexString(getMemory(ret_address, 16))
                    print "patch current state:" + desret
                    idc.PatchByte(ret_address + random.randint(0,15), random.randint(0,255))

            idaapi.continue_process()
            idaapi.wait_for_next_event(idc.WFNE_ANY, -1)
            event = idc.GetDebuggerEvent(idc.WFNE_ANY, -1)

            if (event <= 1):
                break
        if not is_this_round:
            idaapi.continue_process()
            idaapi.wait_for_next_event(idc.WFNE_ANY, -1)

def runTo(ea):

    addr = idc.GetEventEa()
    while addr != ea:
        idaapi.continue_process()
        idaapi.wait_for_next_event(idc.WFNE_ANY, -1)
        addr = idc.GetEventEa()

这里在调试时需要用frida hook java层的输入以保持每次输入数据都一样,所以这里的调试步骤为:
1.运行android_server -p12345
2.运行frida_server
3.adb shell am start -D -n kr.repo.h2spice.crypto500/.MainActivity 进入调试模式
4.运行frida hook java 层代码(c层hook注释掉)
5.ida attach
6.jdb -connect com.sun.jdi.SocketAttach:hostname=127.0.0.1,port=8700
7.运行IDA Python patch脚本

 

注意这里的第4步不能跟第5步互换,不然frida会报错,通过这种方式意外的发现可以hook 到Actity的OnCreate函数,我之前用 frida spawn的方式老是报错。

 

整理脚本的输出的Fault结果,放入[phoenixAES]中:

    # Cryptor500 OAES
    with open('tracefile', 'wb') as t:
        t.write("""
        868FC14BCCC36E3C657B73271A32DCD7
        998FC14BCCC36E7F657B61271AC0DCD7
        86C5C14B32C36E3C657B73911A32E8D7
        868FAF4BCC6D6E3C267B73271A32DC1B
        8630C14B4BC36E3C657B73A41A32B7D7
        868FE74BCC096E3C957B73271A32DC9E
        86ADC14B7DC36E3C657B73881A32A4D7
        868FC1FFCCC3A13C653E7327A032DCD7
        9B8FC14BCCC36E3F657B0A271A87DCD7
        BC8FC14BCCC36E09657BF3271AAEDCD7
        8622C14B62C36E3C657B73E71A3224D7
        868FA34BCC8B6E3C1B7B73271A32DC4C
        868FC1CACCC3D73C654473270032DCD7
        658FC14BCCC36E52657B79271AE4DCD7
        867EC14B23C36E3C657B73A91A328AD7
        258FC14BCCC36E83657B55271A26DCD7
        E78FC14BCCC36EE1657B44271AA7DCD7
         """.encode('utf8'))

    phoenixAES.crack_file('tracefile',[],True,False,3)

还原出K10秘钥为: 040D08DA68001026F3DC0D68897148B4
再调用Key scheduling reversers中的
aes_keyschedule 逆推得到round0秘钥 即AES秘钥。

$ aes_keyschedule 040D08DA68001026F3DC0D68897148B4 10

K00: 6C2893F21B6185E8567238CB78184945
K01: C013FD4EDB7278A68D00406DF5180928
K02: 6F12C9A8B460B10E3960F163CC78F84B
K03: D7537AE36333CBED5A533A8E962BC2C5
K04: 2E76DC734D45179E17162D10813DEFD5
K05: 19A9DF7F54ECC8E143FAE5F1C2C70A24
K06: FFCEE95AAB2221BBE8D8C44A2A1FCE6E
K07: 7F4576BFD46757043CBF934E16A05D20
K08: 1F09C1F8CB6E96FCF7D105B2E1715892
K09: A7638E006C0D18FC9BDC1D4E7AAD45DC
K10: 040D08DA68001026F3DC0D68897148B4

得到该AES秘钥为:6C2893F21B6185E8567238CB78184945

 

然后按道理应该循环遍历arrays中加密的flag,解出flag。
刚好加密的flag为第一个数据:g1UlZafiuGdCgpTkWYjaZg3kE6qCd7kF3kV+nMKcGHc=

 

验证如下:

flag为:SECCON{owSkwPeH1CHQdPV9KWrSmz9n}

总结

1.要通过DFA还原白盒AES的秘钥,其实只需要想办法构造一组合理Fault数据,后面的输入到phoenixAES的实现中,即可还原秘钥。
2.在构造Fault数据一般有两种方式:

* 官方给的静态更改的方式,这种方式好处是全自动化,不需要分析任何二进制代码。
* IDA 动态Patch的方式,这种需要分析代码,找准patch的时机。

3.在动态Patch过程中,注意观察输出数据,如果输出只有1字节改变,说明patch太晚,如果输出有16字节不同说明patch太早,如果输出刚刚为4字节,且4字节的位置也能符合规则,则说明时机找准了。



[公告]安全服务和外包项目请将项目需求发到看雪企服平台:https://qifu.kanxue.com

最后于 2019-8-22 10:46 被lfyyy编辑 ,原因:
打赏 + 11.00
打赏次数 3 金额 + 11.00
收起 
赞赏  bengou   +1.00 2019/08/25
赞赏  雪衫   +5.00 2019/08/22
赞赏  junkboy   +5.00 2019/08/21
最新回复 (15)
繁华皆成空 2019-8-21 19:40
2
0
大佬牛逼
Breathleas 2019-8-22 10:47
3
0
大佬牛逼
fxbfxb 2019-8-22 14:41
4
0
遇到同样的白盒aes,但其实现的解密操作,请问该如何获取密钥
lfyyy 2019-8-22 14:56
5
0
fxbfxb 遇到同样的白盒aes,但其实现的解密操作,请问该如何获取密钥
https://github.com/SideChannelMarvels/JeanGrey/tree/master/phoenixAES
解密同样可以呀。
fxbfxb 2019-8-22 19:10
6
0
我使用c++还原了那个白盒aes解密算法,使用工具没有任何输出,麻烦大佬帮忙看看,算法已经验证没有问题。
上传的附件:
lfyyy 2019-8-22 19:55
7
0

看这个代码很好还原哟,如果确定是AES的话。

 

你patch这里看对比输出结果是否跟没有patch之前有4字节不同,如果是说明patch对地方了。

最后于 2019-8-22 19:56 被lfyyy编辑 ,原因:
fxbfxb 2019-8-22 20:22
8
0
lfyyy 看这个代码很好还原哟,如果确定是AES的话。 ![](upload/attach/201908/608531_AVPGZQQPKAB8CHE.png) 你patch这里看对比输出结果是否跟没 ...
感谢,成功了,主要是phoenixAES.crack_file函数的参数设置的有问题,导致没有解出来,其实没有必要还原算法的
lscmxl 2019-8-23 09:58
9
0
实现过白盒SM4,也借鉴了白盒AES,和我印象中不一样。
当初是硬生生屏蔽了自己逆向的思维,仅从密码学角度实现和破解。
密码学破解角度是开源白盒算法,破解白盒库(查找表),那种方式。目前都是可被攻破的,因为都是线性白盒,非线性是国际难题。
本就是防御两种人,会密码学的,会逆向的。这篇符号执行破解方式也着实然我慌得一逼。
AqCxBoM 2019-8-24 17:33
10
0
mark
Lenus 3 2019-8-26 14:54
11
0
牛逼了。mark一下
ccfer 13 2019-8-26 15:48
12
0
意思是要hook?那为什么不能hook在一个更好的位置直接拿到密钥?
lfyyy 2019-8-27 09:40
13
0
ccfer 意思是要hook?那为什么不能hook在一个更好的位置直接拿到密钥?
这个是白盒AES,秘钥都混淆到查找表里面了,所以需要通过算法还原秘钥。
backahasten 1 2019-8-27 14:52
14
0
侧信道和错误注入在白盒里焕发新生
backahasten 1 2019-8-27 14:56
15
1
还有另外一种方法,抓取下来关键函数的二进制,之后用unicorn去跑,随机输入明文(密文),之后抓取内存和寄存器的变化,最后通过侧信道的方法去跑出密钥
miyuecao 2019-8-28 09:27
16
0
学习了
游客
登录 | 注册 方可回帖
返回