首页
社区
课程
招聘
[原创]记一次某汽车app白盒aes还原过程
2024-2-21 16:22 13126

[原创]记一次某汽车app白盒aes还原过程

2024-2-21 16:22
13126

本文仅作学习移动安全交流 请勿用于非法用途

目标app:5LqU6I+x5rG96L2m
包名:Y29tLmNsb3VkeS5saW5nbGluZ2Jhbmc=
版本:8.2.1
加固:梆梆企业版

1.抓包&加密字段的定位

(1)抓取应用点击登录的接口 可以看到请求体和返回体被加密了 加密的字段名都为sd图片.png(2) 使用xposed尝试注入自吐脚本:发现没有需要的结果 猜测这是一个native层函数
不管静态注册还是动态注册,最终都要走RegisterNative 这个函数 直接使用frida hook RegisterNative 看看有哪些native层函数图片.png发现应用注册了非常多的native层函数 搜索一下encrypt 其中注意到有一个名为encrypt的so文件 注册了几次一个名为checkcode的方法 我们hook一下这个方法 看看是不是我们想要的
(3)编写frida脚本 hook一下 com.bangcle.comapiprotect.CheckCodeUtil.checkcode 这个方法

1
2
3
4
5
6
7
8
9
10
11
function hook_checkcode(){
    Java.perform(function(){
        let CheckCodeUtil = Java.use("com.bangcle.comapiprotect.CheckCodeUtil");
CheckCodeUtil["checkcode"].overload('java.lang.String', 'int', 'java.lang.String').implementation = function (str1, int1, str2) {
    console.log('checkcode is called' + ', ' + 'str: ' + str1 + ', ' + 'i: ' + int1 + ', ' + 'str2: ' + str2);
    let ret = this.checkcode(str1, int1, str2);
    console.log('checkcode ret value is ' + ret);
    return ret;
};
    })
}

注入后 在手机上点一下登录 发现控制台输出了内容 我们与重新抓包的内容比对一下 看看是不是我们需要的结果
图片.png可以看到 这个checkcode函数 传入的参数中有我们在应用中输入的手机号 且函数返回的内容与我们抓包抓到的结果一致 至此 定位加密的结果有了
ps:这个加密参数的定位 十分投机取巧 能找到纯属运气 正常情况下 应该对应用进行脱壳 再一层层通过调用栈进行定位 (有点繁琐就偷懒了)

2.libencrypt.so分析

(1)在应用包lib/arm64-v8a下拿到libencrypt.so 放到ida中 在导出函数中搜索checkcode
图片.png有两个有关checkcode的函数 根据函数名 另外一个应该就是解密函数了
(2)跟入checkcode函数 按下f5看伪代码
图片.png图片.png
发现有大量的控制流混淆 好在混淆的不算特别严重 认真分析下还是能看出一个大概的
(3)把函数的几个入参改一下类型 和名字 方便ida识别出JNI结构体 也方便我们后续分析图片.png
往下看 开始先判断了传入的第一个参数的ascii码判断采用哪个加密函数图片.png接着就是取了一些指纹信息图片.png取完指纹信息后 把字符串进行拼接 最后加密 并把结果base64编码
图片.png(4)拿到加密的函数了 写个代码hook下看看入参和返回

1
2
3
4
5
6
7
8
9
10
11
12
13
function hook_aesencode(){
    let baseaddr = Module.findBaseAddress("libencrypt.so")
    Interceptor.attach(baseaddr.add(0xA5BC),{
        onEnter:function(args){
            console.log("args0:",args[0])
            console.log("args1:",args[1])
            console.log("args2:",args[2])
        },onLeave:function(retval){
            console.log("ret:",retval)
        }
    })
     
}

图片.png
可以看到打印出来的是几个地址 再hexdump看看 是什么内容图片.png可以看到 第一个是我们要加密的明文 后面两个看不出是什么 先不管
(5)跟入这个aes_encrypt1函数
图片.png
可以看到 这个函数貌似是一个write box(白盒aes加密) 先是初始化了一个CWAESCipher对象 然后进行表转换,最后加密并返回 跟入encryptCBC
图片.png可以看到这个函数先进行了填充 然后进行了一些不知道东西的异或 再往下看
图片.png这里作了一个循环 根据函数名字判断是进行一个块加密 并判断是否全部加密完成 就跳出循环
(6)再跟入EncryptOneBlock函数
图片.png这里应该就是白盒加密的核心部分了 有一些有关AES加密流程的相关符号
图片.png最后把当前块加密的结果放入a3数组中 并返回结果

3.Unidbg模拟执行

因为是白盒aes 密钥被隐藏在一个大表中 没办法直接获得加密的key 又有控制流混淆 所以先考虑模拟执行 再进行分析
(1)先搭个架子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class CheckCodeUtil extends AbstractJni{
    private final AndroidEmulator emulator;
    private final VM vm;
    private final Module module;
 
    private final DvmClass CheckCodeUtil;
 
    private final Memory memory;
 
    private final DalvikModule dvm;
    public CheckCodeUtil(){
        emulator = AndroidEmulatorBuilder.for64Bit()
        .setProcessName("com.cloudy.linglingbang")
        .build(); // 创建模拟器实例,要模拟32位或者64位,在这里区分
        memory = emulator.getMemory(); // 模拟器的内存操作接口
        memory.setLibraryResolver(new AndroidResolver(23)); // 设置系统类库解析
        vm = emulator.createDalvikVM(new File("H:\\JavaProject\\unidbg-0.9.7\\unidbg-android\\src\\test\\java\\com\\cloudy\\linglingbang\\wbaes.apk")); // 创建Android虚拟机
        vm.setVerbose(true); // 设置是否打印Jni调用细节
        vm.setJni(this);
        dvm = vm.loadLibrary(new File("H:\\JavaProject\\unidbg-0.9.7\\unidbg-android\\src\\test\\java\\com\\cloudy\\linglingbang\\libencrypt.so"), true); // 加载libttEncrypt.so到unicorn虚拟内存,加载成功以后会默认调用init_array等函数
        module = dvm.getModule(); // 加载好的libttEncrypt.so对应为一个模块
        vm.callJNI_OnLoad(emulator,module);
        CheckCodeUtil = vm.resolveClass("com/bangcle/comapiprotect/CheckCodeUtil");
    }
    public static void main(String[] args) {
        CheckCodeUtil checkCodeUtil = new CheckCodeUtil();
    }
}

跑一下 看看加载so 调用JNIOnload有没有什么问题:
图片.png报了个环境异常 补上:

1
2
3
4
5
6
7
8
9
@Override
public DvmObject<?> callStaticObjectMethod(BaseVM vm, DvmClass dvmClass, String signature, VarArg varArg) {
    switch (signature) {
        case "android/app/ActivityThread->currentActivityThread()Landroid/app/ActivityThread;":{
            return vm.resolveClass("android/app/ActivityThread").newObject(null);
        }
    }
    return super.callStaticObjectMethod(vm, dvmClass, signature, varArg);
}

图片.png接着补

1
2
3
4
5
6
7
8
9
@Override
public DvmObject<?> callObjectMethod(BaseVM vm, DvmObject<?> dvmObject, String signature, VarArg varArg) {
    switch (signature) {
        case "android/app/ActivityThread->getSystemContext()Landroid/app/ContextImpl;": {
            return vm.resolveClass("android/app/ContextImpl").newObject(null);
        }      
    }
    return super.callObjectMethod(vm, dvmObject, signature, varArg);
}

图片.png补上补上

1
2
3
4
5
6
7
8
9
10
11
12
@Override
public DvmObject<?> callObjectMethod(BaseVM vm, DvmObject<?> dvmObject, String signature, VarArg varArg) {
    switch (signature) {
        case "android/app/ActivityThread->getSystemContext()Landroid/app/ContextImpl;": {
            return vm.resolveClass("android/app/ContextImpl").newObject(null);
        }       
        case "android/app/ContextImpl->getPackageManager()Landroid/content/pm/PackageManager;": {
            return vm.resolveClass("android/content/pm/PackageManager").newObject(null);
        }     
    }
    return super.callObjectMethod(vm, dvmObject, signature, varArg);
}

补完不报错了 这样so加载就没问题了 unidbg还帮我们把jni的调用细节打印出来了
图片.png
(2) 跑目标函数--checkcode
写一个call_checkcode()

1
2
3
4
5
6
7
8
9
public void checkcode(){
    //这里的参数是前面hook java层得到的
    String str1 = "mobile=13288888888&password=123456&client_id=2019041810299999999&client_secret=a72a27b1e11b63d8161f0dfd3cab8cef&state=V6g2Lm8888&response_type=token&ostype=ios&imei=00&mac=00:00:00:00:00:00&model=Pixel 4&sdk=29&serviceTime=1706188888888&mod=Google&checkcode=dd9766a6e55044b08d6880c2430fa6eb";
    String str3 = "1706172888888";
    DvmObject ret = CheckCodeUtil.callStaticJniMethodObject(emulator, "checkcode(Ljava/lang/String;ILjava/lang/String;)Ljava/lang/String;",str1,1,str3);
    String strOut = (String)ret.getValue();
    System.out.println("\ncall checkcode: " + strOut);
 
}

调用一下图片.png叕叕叕叕报错了 这里是我们前面在ida中分析到的 一些环境指纹 补上补上:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Override
public DvmObject<?> getStaticObjectField(BaseVM vm, DvmClass dvmClass, String signature) {
    switch (signature) {
        case "android/os/Build->MODEL:Ljava/lang/String;": {
            return new StringObject(vm, "Pixel");
        }
        case "android/os/Build->MANUFACTURER:Ljava/lang/String;": {
            return new StringObject(vm, "Google");
        }
        case "android/os/Build$VERSION->SDK:Ljava/lang/String;": {
            return new StringObject(vm, "23");
        }
    }
    return super.getStaticObjectField(vm, dvmClass, signature);
}

图片.png结果出来了 也不知道对不对 前面提到有decheckcode方法 把我们模拟执行的结果调用一下解密 看看有没有问题:

1
2
3
4
5
public void decheckcode(){
    String str = "MIAYqXNjLK86IzEnTVphowxg1pOlR2iRGolkgHCMocOPKOIlxMZw1yyH4qYEpz2Wc91ZoI0gIb89LZMUFBv5n+oNepjY3fm4DuFwdRiFUNPvBnqKe23fL98oH9ZLa/Ib4ovZcndvhmMykqQ+c+1kSo8h4aPTUlSBHlhyrNRNVyjtMp/UJEkB7DBF1WtC6iJUKNiL+4uQuTNcofh80ZHnPiUro9Igbq4Do68jJ+uJsMW3W/02KiFP2eCTmJ2l5O14I+iQ4LZzszOibuoqGa8SQ+NeYMpXjf7f981NFLrj/zv0EmLo2DNACH/BQREORMVqxAe3lVNvHRg3TM2ypi4+EUQGzOD+hYVZ+Dv1aGBD4zaDwa2o438nAUDzTKDDLKV1jCa0yTFCAEbqm06h3g1f1kQ==";
    DvmObject ret = CheckCodeUtil.callStaticJniMethodObject(emulator,"decheckcode(Ljava/lang/String;)Ljava/lang/String;",str);
    System.out.println("\ncall decheckcode: " + ret.getValue().toString());
}

调用一下
图片.png发现结果并不正确 应该是环境补的有问题 这时候得向上排错了
(3)补环境排错
好在unidbg很贴心的在控制台中打印了JNI的调用细节 可以看到最后一行 0x2c604 这个地址调用了一个jni函数之后 程序就结束了 ida跳过去看看:图片.png发现程序在走到LABEL_71这个代码块这里就直接退出了 按x看看这个代码块是从哪里被调用了图片.png这里貌似是做了一个有关签名校验 或者包名校验的东西 ,一旦有一个不匹配的 就会跳转到LABEL_71 代码块
图片.png
除此之外 这个地方的判断也会让程序的控制流走向LABEL_71 代码块
图片.pngv6,v7这两个参数在上面sub_1B2F0中有引用 进去看看
图片.png
又是长长的恶心人的控制流混淆
图片.pngsub_1B2F0 这个函数大概就是取到当前应用的一个包名和签名 再看看sub_1AB74 函数
图片.png
sub_1AB74 函数 应该是做了一个文件的读取 进shell cat一下这个文件 看看里面什么内容
图片.png
可以看到 这个文件是储存了当前进程的包名 到这里 我们就能理解为什么
图片.pngunidbg会在控制台抛出一条提示 程序有进行文件读取的操作 写代码把这个文件访问补上:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
FileResult<AndroidFileIO> f1;
//补文件访问
public FileResult<AndroidFileIO> getF1(String pathname, int oflags) {
    if (f1 == null) {
        f1 = FileResult.<AndroidFileIO>success(new ByteArrayFileIO(oflags, pathname, "com.cloudy.linglingbang".getBytes()));
    }
    return f1;
}
 
@Override
public FileResult<AndroidFileIO> resolve(Emulator<AndroidFileIO> emulator, String pathname, int oflags) {
    if ("/proc/self/cmdline".equals(pathname) || ("/proc/" + emulator.getPid() + "/cmdline").equals(pathname)) {
        return getF1(pathname, oflags);
    }
 
    return null;
}

(4)再跑一下decheckcode函数:图片.png发现没问题了 解密能解出来了 至此 unidbg 调用checkcode函数完成

4.寻找DFA(差分故障攻击)攻击点

(1)根据前面在ida中对libencrypt.so进行的静态分析 判断函数WBACRAES_EncryptOneBlock应该是整个白盒加密的关键部位 在ida中找到这个函数的地址0x86F8 unidbg下个断点 看看入参

1
emulator.attach().addBreakPoint(module.base + 0x86F8);

图片.pngunidbg在0x86F8 处断下 注意到x0和x1都是指针 在控制台输mx0和mx1 看看这两个地址存的什么内容图片.png看起来x0处存的还是一个指针 根据ida中的分析来看 应该是CWAESCipher结构体的指针
而x1存的是我们输入的明文(为了方便分析 我把加密的明文改成了aaaaa)
我们记住x1存的地址0x40559020 这个地址在我们每次重新调用程序进行分析都是不变的 这也是unidbg在算法还原方面的一个优势所在
(2)利用unidbg中的emulator.traceRead api 追踪一下0x40559020-0x40559030 这段存放了明文的地址 看看哪里对明文进行了读取

1
emulator.traceRead(0x40559020,0x40559030);

图片.png这里对这段地址进行了十六次的读取 刚好对应了我们前面下断点读取到的明文 ida跳到0x7888 看看怎么个事
图片.png
这看起来好像是对明文进行了某种排序 我们hook下看看 在函数入口0x7874下个断点 看看a3的地址 在函数结束后读一下 看看是什么内容

1
2
emulator.attach().addBreakPoint(module.base + 0x7874);//PrepareAESMatrix start
emulator.attach().addBreakPoint(module.base + 0x7910);//PrepareAESMatrix over

图片.png在0x7874处拿到x2寄存器的地址 0xbfffeb10 再让程序运行到函数尾部 看看这个地址存的内容变成什么样图片.png
根据对这个地址内存的查看 我们知道了 PrepareAESMatrix 这个函数就是对明文进行排序 应该是我们aes加密中的plaintext->state阶段 我们再对这段地址进行trace read

1
emulator.traceRead(0xbfffeb10L,0xbfffeb30L);

图片.pngida跳到0x8c00看看图片.png根据数组符号和前面state转换传入的参数 确定了 这一部分就是进行aes加密中的轮运算的地方 有几个do..while循环嵌套
(3)走到这里 发现 几个do..while 循环的嵌套 单单静态分析还是很难看出哪个循环是单独走完了一轮加密 所以对几个do..while循环 进行hook 看看哪里是只走了9次(对应aes中的前九轮运算)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
public void StatisticalRound(){
 
        emulator.attach().addBreakPoint(module.base + 0x877C, new BreakPointCallback() {
            int add_0x877C = 0;
            @Override
            public boolean onHit(Emulator<?> emulator, long address) {
                add_0x877C += 1;
                System.out.println("add_0x877C onHit:"+add_0x877C);
                return true;
            }
        });
 
        emulator.attach().addBreakPoint(module.base + 0x8BBC, new BreakPointCallback() {
            int add_0x8BBC = 0;
            @Override
            public boolean onHit(Emulator<?> emulator, long address) {
                add_0x8BBC += 1;
                System.out.println("add_0x8BBC onHit:"+add_0x8BBC);
                return true;
            }
        });
        emulator.attach().addBreakPoint(module.base + 0x8BBC, new BreakPointCallback() {
            int add_0x8ABC = 0;
            @Override
            public boolean onHit(Emulator<?> emulator, long address) {
                add_0x8ABC += 1;
                System.out.println("add_0x8ABC onHit:"+add_0x8ABC);
                return true;
            }
        });
        emulator.attach().addBreakPoint(module.base + 0x87A4, new BreakPointCallback() {
            int add_0x87A4 = 0;
            @Override
            public boolean onHit(Emulator<?> emulator, long address) {
                add_0x87A4 += 1;
                System.out.println("add_0x87A4 onHit:"+add_0x87A4);
                return true;
            }
        });
    }

图片.png结果很明显 0x877C就是一轮计算开始的位置 共循环了九次
(4)前九轮计算循环的位置找到了 接下来就是要找第十轮计算的位置(因为aes加密中第十轮计算少了一个列混淆的步骤 所以程序应该有一个单独的代码块来进行第十轮计算)图片.png运气很好 因为符号没有抹去 根据符号判断 这里进行了最后一轮计算 有三个控制流 都进行hook一下 最终确定最下面的控制流是最终轮计算

5.开始攻击 还原密钥

(1)上面我们找到了前九轮计算 每一轮计算的开始点0x877C 且有了排序好的state的地址0xbfffeb10 接下来就是要开始故障攻击了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public void dfaAttack(){
    emulator.attach().addBreakPoint(module.base + 0x877C, new BreakPointCallback() {
        int round = 0;
        UnidbgPointer statePointer = memory.pointer(0xbfffeb10L);
        @Override
        public boolean onHit(Emulator<?> emulator, long address) {
            round += 1;
            if (round % 9 == 0){
                statePointer.setByte(0,(byte)randInt(0,15));//随机注入
            }
            return true;//返回true 就不会在控制台断住
        }
    });
 
}
public static int randInt(int min, int max) {
    Random rand = new Random();
    return rand.nextInt((max - min) + 1) + min;
}

(2)调用一次看看结果

1
2
encode Results:    09 df ee c3 04 eb 14 ce 3c e2 94 68 9d 7d d4 1c
dfaAttack Results: 5a df ee c3 04 eb 14 b6 3c e2 c9 68 9d cf d4 1c

很明显 最终结果的第1,8,11,14 个字节与原始加密的内容不同 符合dfa攻击成功的特征
(3)多次攻击 取不同的故障密文

1
2
3
4
5
6
7
public static void main(String[] args) {
    CheckCodeUtil checkCodeUtil = new CheckCodeUtil();
    checkCodeUtil.dfaAttack();
    for (int i = 0; i < 30; i++) {
        checkCodeUtil.checkcode();
    }
}

(4)利用python的phoenixAES模块 对这些故障密文进行分析

1
2
3
4
5
6
7
8
9
10
import phoenixAES
 
with open('tracefile', 'wb') as t:
    t.write("""09dfeec304eb14ce3ce294689d7dd41c #第一行放正确的密文
6ddfeec304eb146c3ce296689df8d41c
...
09dfeec304eb14ce3ce294689d7dd41c
""".encode('utf8'))
 
phoenixAES.crack_file('tracefile',[],True,False,3)

图片.png
最终拿到了第十轮的密钥:8A6E30D74045AE83634D6ECDE1516CA1
(5)计算原始密钥
GitHub - SideChannelMarvels/Stark: Repository of small utilities related to key recovery
用这个开源项目 根据轮密钥计算出原始密钥 b3831630add5e91fa4ab1fce08ea507.png最终拿到了我们的key:F6F472F595B511EA9237685B35A8F866
(6)拿到逆向之友里验证一下:
图片.png没毛病 是标准的aes

6.总结

学逆向一年了 今天第一次写一篇完整的文章 样本难度不高 混淆不算太严重 部分符号没有抹去 才有了攻击点
希望这篇文章能对正在学习移动安全的朋友有所帮助
最后感谢 @白龙 的公开文章 令我受益匪浅 学到了不少东西


[培训]二进制漏洞攻防(第3期);满10人开班;模糊测试与工具使用二次开发;网络协议漏洞挖掘;Linux内核漏洞挖掘与利用;AOSP漏洞挖掘与利用;代码审计。

最后于 2024-2-21 16:25 被劫__编辑 ,原因:
收藏
点赞8
打赏
分享
最新回复 (10)
雪    币: 498
活跃值: (3776)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
哆啦噩梦 2024-2-21 18:48
2
0
6
雪    币: 3289
活跃值: (4331)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
东关之南 2024-2-21 19:15
3
0
666
雪    币: 1440
活跃值: (1842)
能力值: ( LV5,RANK:61 )
在线值:
发帖
回帖
粉丝
陈可牛 1 2024-2-22 01:03
4
1
这里有要补充一点的是:
在作者的:5.开始攻击 还原密钥下的dfaAttack()的statePointer.setByte(0,(byte)randInt(0,15));//随机注入,这种情况下无论执行到0xff次,最多只能还原4位置
也就是:K0: ['D9', 'E2'] K7: ['D4', 'C1'] Ka: ['A5', 'E0'] Kd: ['E6', 'F0']
Round key bytes recovered:
8A............83....6E....51....
FI: good candidate for encryption!
98dfeec304eb147e3ce25a689da2d41c: gr
到这:(3)多次攻击 取不同的故障密文
下,需要改一下,改成为:
statePointer.setByte(randInt(0, 15), (byte) randInt(0, 0xff));//随机注入
。即可复现!

以及感谢大佬的分享
雪    币: 1034
活跃值: (1660)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
wooyunking 2024-2-22 11:56
5
0
老哥漏了app名字unidbg补环境的地方
雪    币: 540
活跃值: (580)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
mb_fidppcok 2024-2-22 14:55
6
0
强啊。要学习点什么东西才能跟大佬一样?
雪    币: 1671
活跃值: (3987)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
小黄鸭爱学习 2024-2-22 17:38
7
0
mb_fidppcok 强啊。要学习点什么东西才能跟大佬一样?
看白龙文章就行
雪    币: 19323
活跃值: (28938)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
秋狝 2024-2-23 09:50
8
1
感谢分享
雪    币: 387
活跃值: (662)
能力值: ( LV3,RANK:20 )
在线值:
发帖
回帖
粉丝
铭天星 2024-2-23 18:04
9
0
给大佬点赞
雪    币:
活跃值: (205)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
白龙lilac 2024-2-23 19:16
10
0
给大佬点赞,爱来自中国。
雪    币: 32
活跃值: (347)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
tcc0lin 2024-2-29 16:45
11
0
给大佬点赞
游客
登录 | 注册 方可回帖
返回