本文仅作学习移动安全交流,请勿用于非法用途。
目标app:5LqU6I+x5rG96L2m
包名:Y29tLmNsb3VkeS5saW5nbGluZ2Jhbmc=
版本:8.2.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 这个方法。
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:这个加密参数的定位,十分投机取巧。能找到纯属运气,正常情况下应该对应用进行脱壳再一层层通过调用栈进行定位。
二
libencrypt.so分析
(1)在应用包lib/arm64-v8a下拿到libencrypt.so 放到ida中,在导出函数中搜索checkcode。
有两个有关checkcode的函数,根据函数名,另外一个应该就是解密函数了。
(2)跟入checkcode函数 按下f5看伪代码
发现有大量的控制流混淆,好在混淆的不算特别严重,认真分析下还是能看出一个大概的。
(3)把函数的几个入参改一下类型和名字,方便ida识别出JNI结构体,也方便我们后续分析。
往下看,开始先判断了传入的第一个参数的ascii码判断采用哪个加密函数。
接着就是取了一些指纹信息。
取完指纹信息后,把字符串进行拼接,最后加密,并把结果base64编码。
(4)拿到加密的函数了,写个代码hook下看看入参和返回。
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数组中,并返回结果。
三
Unidbg模拟执行
因为是白盒aes,密钥被隐藏在一个大表中,没办法直接获得加密的key,又有控制流混淆,所以先考虑模拟执行,再进行分析。
(1)先搭个架子:
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有没有什么问题:
报了个环境异常,补上:
@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);
}
接着补:
@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);
}
补上补上:
@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的调用细节打印出来了。
写一个call_checkcode():
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中分析到的,一些环境指纹,补上补上:
@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方法,把我们模拟执行的结果调用一下解密看看有没有问题:
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会在控制台抛出一条提示,序有进行文件读取的操作,写代码把这个文件访问补上:
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函数完成。
四
寻找DFA(差分故障攻击)攻击点
(1)根据前面在ida中对libencrypt.so进行的静态分析,判断函数
WBACRAES_EncryptOneBlock应该是整个白盒加密的关键部位,在ida中找到这个函数的地址0x86F8 unidbg下个断点看看入参。
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 这段存放了明文的地址,看看哪里对明文进行了读取。
emulator.traceRead(0x40559020,0x40559030);
这里对这段地址进行了十六次的读取,刚好对应了我们前面下断点读取到的明文,ida跳到0x7888 看看怎么个事。
这看起来好像是对明文进行了某种排序,我们hook下看看,在函数入口0x7874下个断点。看看a3的地址,在函数结束后读一下看看是什么内容。
emulator.attach().addBreakPoint(module.base + 0x7874);//PrepareAESMatrix start
emulator.attach().addBreakPoint(module.base + 0x7910);//PrepareAESMatrix over
在0x7874处拿到x2寄存器的地址 0xbfffeb10 再让程序运行到函数尾部,看看这个地址存的内容变成什么样。
根据对这个地址内存的查看,我们知道了 PrepareAESMatrix 这个函数就是对明文进行排序,应该是我们aes加密中的plaintext->state阶段,我们再对这段地址进行trace read。
emulator.traceRead(0xbfffeb10L,0xbfffeb30L);
ida跳到0x8c00看看:
根据数组符号和前面state转换传入的参数,确定了这一部分就是进行aes加密中的轮运算的地方,有几个do..while循环嵌套。
(3)走到这里,发现几个do..while 循环的嵌套,单单静态分析还是很难看出哪个循环是单独走完了一轮加密,所以对几个do..while循环进行hook,看看哪里是只走了9次(对应aes中的前九轮运算)。
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一下,最终确定最下面的控制流是最终轮计算。
五
开始攻击还原密钥
(1)上面我们找到了前九轮计算,每一轮计算的开始点0x877C 且有了排序好的state的地址0xbfffeb10,接下来就是要开始故障攻击了。
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)调用一次看看结果
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)多次攻击,取不同的故障密文
public static void main(String[] args) {
CheckCodeUtil checkCodeUtil = new CheckCodeUtil();
checkCodeUtil.dfaAttack();
for (int i = 0; i < 30; i++) {
checkCodeUtil.checkcode();
}
}
(4)利用python的phoenixAES模块,对这些故障密文进行分析。
import phoenixAESwith 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。
学逆向一年了,今天第一次写一篇完整的文章。样本难度不高,混淆不算太严重,部分符号没有抹去,有了攻击点。希望这篇文章能对正在学习移动安全的朋友有所帮助
最后感谢@白龙的公开文章,令我受益匪浅学到了不少东西。
看雪ID:劫__
https://bbs.kanxue.com/user-home-949812.htm
# 往期推荐
球分享
球点赞
球在看
点击阅读原文查看更多