小工具知识网

当前位置: 首页 > 行业知识 > 记一次某汽车app白盒aes还原过程

行业知识

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

2026-11-14 02:58:17 来源:互联网转载

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

目标app:5LqU6I+x5rG96L2m

包名:Y29tLmNsb3VkeS5saW5nbGluZ2Jhbmc=

版本:8.2.1

加固:梆梆企业版

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

(1)抓取应用点击登录的接口 可以看到请求体和返回体被加密了 加密的字段名都为sd

2) 使用xposed尝试注入自吐脚本:发现没有需要的结果 猜测这是一个native层函数

不管静态注册还是动态注册,最终都要走RegisterNative 这个函数 直接使用frida hook RegisterNative 看看有哪些native层函数 

发现应用注册了非常多的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;

};

    })

}

注入后 在手机上点一下登录 发现控制台输出了内容 我们与重新抓包的内容比对一下 看看是不是我们需要的结果

 

 

可以看到 这个checkcode函数 传入的参数中有我们在应用中输入的手机号 且函数返回的内容与我们抓包抓到的结果一致 至此 定位加密的结果有了

ps:这个加密参数的定位 十分投机取巧 能找到纯属运气 正常情况下 应该对应用进行脱壳 再一层层通过调用栈进行定位 (有点繁琐就偷懒了)

2.libencrypt.so分析

(1)在应用包lib/arm64-v8a下拿到libencrypt.so 放到ida中 在导出函数中搜索checkcode

 有两个有关checkcode的函数 根据函数名 另外一个应该就是解密函数了

(2)跟入checkcode函数 按下f5看伪代码 

 

发现有大量的控制流混淆 好在混淆的不算特别严重 认真分析下还是能看出一个大概的

(3)把函数的几个入参改一下类型 和名字 方便ida识别出JNI结构体 也方便我们后续分析 

 

 

取完指纹信息后 把字符串进行拼接 最后加密 并把结果base64编码 

(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)

        }

    })

     

}

 

 

可以看到 第一个是我们要加密的明文 后面两个看不出是什么 先不管

(5)跟入这个aes_encrypt1函数 

可以看到 这个函数貌似是一个write box(白盒aes加密) 先是初始化了一个CWAESCipher对象 然后进行表转换,最后加密并返回 跟入encryptCBC
 

这里作了一个循环 根据函数名字判断是进行一个块加密 并判断是否全部加密完成 就跳出循环

(6)再跟入EncryptOneBlock函数
 

这里应该就是白盒加密的核心部分了 有一些有关AES加密流程的相关符号 

 

最后把当前块加密的结果放入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有没有什么问题:

 

 

报了个环境异常 补上:

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);

 

接着补

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);

}

 

补上补上

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的调用细节打印出来了

 

(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);

}

调用一下

 

叕叕叕叕报错了 这里是我们前面在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);

}

 

结果出来了 也不知道对不对 前面提到有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());

}

调用一下

发现结果并不正确 应该是环境补的有问题 这时候得向上排错了

(3)补环境排错

好在unidbg很贴心的在控制台中打印了JNI的调用细节 可以看到最后一行 0x2c604 这个地址调用了一个jni函数之后 程序就结束了 ida跳过去看看: 

发现程序在走到LABEL_71这个代码块这里就直接退出了 按x看看这个代码块是从哪里被调用了 这里貌似是做了一个有关签名校验 或者包名校验的东西 ,一旦有一个不匹配的 就会跳转到LABEL_71 代码块

 

除此之外 这个地方的判断也会让程序的控制流走向LABEL_71 代码块 

 v6,v7这两个参数在上面sub_1B2F0中有引用 进去看看

 

 

 

 

unidbg会在控制台抛出一条提示 程序有进行文件读取的操作 写代码把这个文件访问补上:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

FileResult f1;

//补文件访问

public FileResult getF1(String pathname, int oflags) {

    if (f1 == null) {

        f1 = FileResult.success(new ByteArrayFileIO(oflags, pathname, "com.cloudy.linglingbang".getBytes()));

    }

    return f1;

}

@Override

public FileResult resolve(Emulator 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函数:

 

 

发现没问题了 解密能解出来了 至此 unidbg 调用checkcode函数完成

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

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

1

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

 

 

起来x0处存的还是一个指针 根据ida中的分析来看 应该是CWAESCipher结构体的指针

而x1存的是我们输入的明文(为了方便分析 我把加密的明文改成了aaaaa)

我们记住x1存的地址0x40559020 这个地址在我们每次重新调用程序进行分析都是不变的 这也是unidbg在算法还原方面的一个优势所在

(2)利用unidbg中的emulator.traceRead api 追踪一下0x40559020-0x40559030 这段存放了明文的地址 看看哪里对明文进行了读取

1

emulator.traceRead(0x40559020,0x40559030);

 

这里对这段地址进行了十六次的读取 刚好对应了我们前面下断点读取到的明文 ida跳到0x7888 看看怎么个事 

这看起来好像是对明文进行了某种排序 我们hook下看看 在函数入口0x7874下个断点 看看a3的地址 在函数结束后读一下 看看是什么内容

1

2

emulator.attach().addBreakPoint(module.base + 0x7874);//PrepareAESMatrix start

emulator.attach().addBreakPoint(module.base + 0x7910);//PrepareAESMatrix over

 

 

 

根据数组符号和前面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;

            }

        });

    }

结果很明显 0x877C就是一轮计算开始的位置 共循环了九次

(4)前九轮计算循环的位置找到了 接下来就是要找第十轮计算的位置(因为aes加密中第十轮计算少了一个列混淆的步骤 所以程序应该有一个单独的代码块来进行第十轮计算)

运气很好 因为符号没有抹去 根据符号判断 这里进行了最后一轮计算 有三个控制流 都进行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

        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)

 

 最终拿到了第十轮的密钥:8A6E30D74045AE83634D6ECDE1516CA1

(5)计算原始密钥
GitHub - SideChannelMarvels/Stark: Repository of small utilities related to key recovery

用这个开源项目 根据轮密钥计算出原始密钥

 最终拿到了我们的key:F6F472F595B511EA9237685B35A8F866

(6)拿到逆向之友里验证一下:

 

没毛病 是标准的aes

6.总结

学逆向一年了 今天第一次写一篇完整的文章 样本难度不高 混淆不算太严重 部分符号没有抹去 才有了攻击点

希望这篇文章能对正在学习移动安全的朋友有所帮助

最后感谢 @白龙 的公开文章 令我受益匪浅 学到了不少东西

 

上一篇:服务器系统怎么备份?

下一篇:base64、file和blob用JS进行互转的方法大全【前端】