本文仅作学习移动安全交流 请勿用于非法用途
目标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结构体 也方便我们后续分析
往下看 开始先判断了传入的第一个参数的ascii码判断采用哪个加密函数接着就是取了一些指纹信息取完指纹信息后 把字符串进行拼接 最后加密 并把结果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)
}
})
}
|
可以看到打印出来的是几个地址 再hexdump看看 是什么内容可以看到 第一个是我们要加密的明文 后面两个看不出是什么 先不管
(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();
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" ));
vm.setVerbose( true );
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 );
module = dvm.getModule();
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(){
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中有引用 进去看看
又是长长的恶心人的控制流混淆
sub_1B2F0 这个函数大概就是取到当前应用的一个包名和签名 再看看sub_1AB74 函数
sub_1AB74 函数 应该是做了一个文件的读取 进shell cat一下这个文件 看看里面什么内容
可以看到 这个文件是储存了当前进程的包名 到这里 我们就能理解为什么
unidbg会在控制台抛出一条提示 程序有进行文件读取的操作 写代码把这个文件访问补上:
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函数:发现没问题了 解密能解出来了 至此 unidbg 调用checkcode函数完成
4.寻找DFA(差分故障攻击)攻击点
(1)根据前面在ida中对libencrypt.so进行的静态分析 判断函数WBACRAES_EncryptOneBlock应该是整个白盒加密的关键部位 在ida中找到这个函数的地址0x86F8 unidbg下个断点 看看入参
1 | emulator.attach().addBreakPoint(module.base + 0x86F8 );
|
unidbg在0x86F8 处断下 注意到x0和x1都是指针 在控制台输mx0和mx1 看看这两个地址存的什么内容看起来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 );
emulator.attach().addBreakPoint(module.base + 0x7910 );
|
在0x7874处拿到x2寄存器的地址 0xbfffeb10 再让程序运行到函数尾部 看看这个地址存的内容变成什么样
根据对这个地址内存的查看 我们知道了 PrepareAESMatrix 这个函数就是对明文进行排序 应该是我们aes加密中的plaintext->state阶段 我们再对这段地址进行trace read
1 | emulator.traceRead(0xbfffeb10L,0xbfffeb30L);
|
ida跳到0x8c00看看根据数组符号和前面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 ;
}
});
}
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(
.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.总结
学逆向一年了 今天第一次写一篇完整的文章 样本难度不高 混淆不算太严重 部分符号没有抹去 才有了攻击点
希望这篇文章能对正在学习移动安全的朋友有所帮助
最后感谢 @白龙 的公开文章 令我受益匪浅 学到了不少东西
[培训]二进制漏洞攻防(第3期);满10人开班;模糊测试与工具使用二次开发;网络协议漏洞挖掘;Linux内核漏洞挖掘与利用;AOSP漏洞挖掘与利用;代码审计。
最后于 2024-2-21 16:25
被劫__编辑
,原因: