记一次从脱壳修复到Frida检测对抗

0x00 前言

​ 遇到了一个APP,存在Frida检测,原本是想直接对壳程序进行逆向分析来找到检测点的,但是尝试许久无果,想到可以尝试进行脱壳修复。绕过壳检测,但是一整个过程中遇到了非常多的坑,以及检测点,于是写了这篇文章来记录一下自己的工作。

0x01 脱壳

​ 拿到APP的第一步一般来说我都会看看是否检测了frida。可以看到就算是用了魔改版的frida也无法过检测。

image-20231114095927047

因为存在frida检测,所以我常用的frida-dexdump已经无法使用了,而且这么硬的壳,就只能上Fart了。记得一定要给APP文件操作权限。然后直接运行APP即可。

image-20231114100318369

基本稳定之后可以在Fart中看到,已经脱下来了许多的dex文件

image-20231114100716493

0x02 修复

修复Dex文件

​ 将该文件夹直接adb pull 到电脑中,其中会有一些txt以及bin文件,我们不需要,可以丢到其他目录中去。

image-20231114135114277

然后呢,首先需要对dex文件进行一下重命令来更规范,我写了这个脚本

1
2
3
4
5
6
7
8
9
10
11
12
#!/bin/bash

# 初始化文件计数器
counter=1
# 遍历当前目录下的所有 .dex 文件
for file in *.dex; do
# 重命名文件
mv "$file" "$counter.dex"
# 增加文件计数器
((counter++))
done
echo "所有 .dex 文件已重命名完成。"

执行结果如下

image-20231114101122979

之后需要对dex文件进行一下修复,先去Github中找到DexRepair.jar文件,下载下来。放到当前文件夹,然后执行以下脚本。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#!/bin/bash

# 确认 DexRepair.jar 文件存在
if [[ ! -f "DexRepair.jar" ]]; then
echo "DexRepair.jar 未找到,请确保它在当前目录中。"
exit 1
fi
# 循环遍历当前目录下的所有 .dex 文件
for file in *.dex; do
# 执行 DexRepair.jar
java -jar DexRepair.jar "$file"
echo "已处理文件: $file"
done
echo "所有 .dex 文件处理完成。"

执行结果如下

image-20231114101440280

image-20231114101451277

会生成一些新的dex文件,就需要将原来的文件删除,执行以下脚本即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#!/bin/bash

# 循环遍历当前目录下的所有以数字命名的 .dex 文件
for file in [0-9]*.dex; do
# 检查对应的 _repair.dex 文件是否存在
if [[ -f "${file%.dex}_repair.dex" ]]; then
# 如果存在,删除原始 .dex 文件
rm "$file"
echo "已删除文件: $file"
else
echo "未找到对应的修复文件,保留: $file"
fi
done

echo "所有原始 .dex 文件处理完成。"

image-20231114101555287

最后需要对dex进行转换成smali文件,需要先下载一个baksmali.jar,放到当前文件夹下,来执行以下脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#!/bin/bash

# 确认 baksmali-2.5.2.jar 文件存在
if [[ ! -f "baksmali-2.5.2.jar" ]]; then
echo "baksmali-2.5.2.jar 未找到,请确保它在当前目录中。"
exit 1
fi

# 循环遍历当前目录下的所有 .dex 文件
for file in *.dex; do
# 执行 baksmali 命令
java -jar baksmali-2.5.2.jar d "$file"
echo "已处理文件: $file"
done

echo "所有 .dex 文件处理完成。"

执行结束后会生成一个out文件夹,其中都是smali文件,然后这里我删除了一些没有用的文件夹,比如dalvik,jdk,junit,libcore,sun等等,其实不删除也可以,因为这个是脱壳下来的系统dex,没有必要重打包回APK中,造成不必要的资源浪费。

image-20231114101913222

反编译原APK

​ 想要重打包APK,首先要对原APK进行反编译,使用apktool,最后会生成一个result文件夹。

1
apktool d -f -o result xxx.apk

image-20231114102548188

接下来就是两个工作

  • 修改smali文件夹中的的smali代码
  • 修改AndroidManifest.xml中的程序入口

可以看到代码如下,现在程序的入口类是com.Proxy.ShellApplication,这里也就是壳程序的入口,我们要讲将这个标签的内容修改成真正的程序入口类,也就是下边的<meta-data android:value/>中的内容

1
2
<application android:allowBackup="false" android:appComponentFactory="xxx" android:icon="@drawable/ic_launcher" android:label="@string/app_name" android:name="com.Proxy.ShellApplication" android:networkSecurityConfig="@xml/network_security_config" android:requestLegacyExternalStorage="true" android:supportsRtl="true" android:theme="@style/AppTheme">
<meta-data android:name="_app_name" android:value="com.xxx.applicaion.JMXApplication"/>

image-20231114103004255

修改好之后保存退出即可。然后呢将刚才反编译出来的out文件夹下的内容,放置到smali文件夹下。修改之前是这样的

image-20231114103334664

替换好之后就是如下图所示

image-20231114103435132

这个时候就需要使用apktool来进行回编译了。(这里会可能出现OOM的错误,读者可以自行查询一下增加JVM内存大小,我这里给了20G内存)。

1
apktool b result

笔者这里遇到过这个问题,当时困扰了我许久。

image-20231114103700666

后续发现原因是,单个dex里面的方法数不能超过65535个,而这里我回编译的代码中的方法数量过多,可以看到图中的方法已经是192405个了。遇到这个问题的解决办法是:将smali文件拆分成多个文件夹,然后编译成多个dex文件。最后修改成这样文件夹才成功回编译。

image-20231114105324349

重签名Apk

​ 参考葫芦娃大佬的文章:https://mp.weixin.qq.com/s/xlgtU4xqoMqv2oXCMko7yQ

image-20231114105644766

最后会生成一个新的APK,尝试安装新的APK。可以成功安装

0x03 签名检测

因为是对App进行重签名的所以如果App中存在检测签名信息的话就会出现这种情况,这也是验证应用完整性检测的一种常见手段。

image-20231114105924893

其实可以使用frida来绕过检测,但是这里我实测,即使是壳脱了。依然存在frida检测….(真的硬)。猜测是还有子线程启动来循环检测ART环境。

image-20231114135237563

哎,于是只能xp来尝试Hook但是笔者其实不会用xp,就找到了**算法助手**这个工具。安装好之后呢,需要在LSP中打开模块,然后勾选要Hook的APP(这里如果是没有脱壳修复的,勾选之后就会一直卡启动页,再次吐槽这个APP的检测)。

image-20231114111222871

然后点开APP界面时候继续打开要Hook的APP

image-20231114111306433

点进APP之后存在一个读取应用签名监听

image-20231114111338493

打开之后运行APP,然后呢就会闪退。但是我们进入日志功能呢,就会看到有日志

image-20231114111431882

查看调用栈的时候我们可以看到调用的方法,分别是LauncherActivity.singCheck()方法去调用的utils.SignCheck的构造方法,之后会调用到getCertificateSHA1Fingerprint方法。

image-20231114111526210

因为我们已经有了脱壳之后的APP,所以直接去代码中看逻辑就好了。

image-20231114111837508

构造函数中调用了获取证书的函数

image-20231114111905991

这里调用了getPackageManager().getPackageInfo()等等函数来获取当前APP的证书,这个是常用的代码。所以我们其实Hook这个函数的返回值来返回80dcaf98bb0fdbece2ffe05918c37ca3这个即可。

1
2
3
4
5
6
7
public String getCertificateSHA1Fingerprint(){
try{
return Md5Utils.getMD5String(this.byte2HexFormatted(MessageDigest.getInstance("SHA1").digest(CertificateFactory.getInstance("X509").generateCertificate(new ByteArrayInputStream(this.context.getPackageManager().getPackageInfo(this.context.getPackageName(), 64).signatures[0].toByteArray())).getEncoded())).trim());
}catch(java.lang.Exception e0){
return "80dcaf98bb0fdbece2ffe05918c37ca3";
}
}

好消息是算法助手直接可以自定义Hook,让我这个不会写XP的人可以直接Hook了。

image-20231114112216446

保存后使其生效即可。这个时候就成功的进入APP了。(呼,真的累,终于到这一步了….)

image-20231114112306965

0x04 Frida检测对抗

​ 当然我们不忘初心,想到就算是给壳脱了,APP本身也还是有Frida检测就真的很让人难绷。接下来就是找到检测Frida检测的位置的步骤了。通常Frida检测是在SO层的。那么首先可以尝试看看加载了什么so文件之后应用闪退的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function hook_dlopen() {
Interceptor.attach(Module.findExportByName(null, "android_dlopen_ext"),
{
onEnter: function (args) {
var pathptr = args[0];
if (pathptr !== undefined && pathptr != null) {
var path = ptr(pathptr).readCString();
console.log(path);
}
}
}
);
}

setImmediate(hook_dlopen);

执行结果如下,所以可能检测函数就在这两个之中。

image-20231114122009352

那么我们只能首先使用IDA来分析libmsaoaidsec.so这个文件,一般来说呢,frida检测是会使用pthread_create函数来新建一个子线程来检测的,我们可以尝试直接搜索该函数。确实是存在的

image-20231114131413358

查看一下交叉引用,是有三处位置使用了该函数

image-20231114131440260

分别图如下,所以其实静态分析的时候可以看到是偏移量0x163DC,0x16D30,0x175F8这三个函数被创建了子线程。

image-20231114131524900

但是并不能直接相信静态信息的结果要动态调试才可以,于是尝试frida,(这里可以使用frida的原因是,在被检测之前frida是可以使用的,我们现在一直在Hook的就是检测前的函数。)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function hook_pthread_create() { 
var soAddr = null;
var pthread_create_addr = Module.findExportByName("libc.so", "pthread_create");
Interceptor.attach(pthread_create_addr, {
onEnter: function (args) {
var funcAddr = args[2];
var soName = Process.findModuleByAddress(funcAddr).name;
soAddr = Module.findBaseAddress(soName);
if(soName.indexOf("libmsaoaidsec.so") >= 0 ){
console.log(soName + " ==> " + soAddr);
console.log("function Address ==> ", funcAddr);
}
},
onLeave: function (retval) {

}
})
}

执行结果如下

image-20231114132019728

1
2
3
libmsaoaidsec.so ==> 0x6e32f41000
function Address ==> 0x6e32f585f8
function Address ==> 0x6e32f57d30

这就代表着只有0x175F8和0x16D30这两个函数被创建了子线程。于是我们尝试Hook这两个子线程将其替换成空

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
function hook_pthread_create() { 
var soAddr = null;
var pthread_create_addr = Module.findExportByName("libc.so", "pthread_create");
Interceptor.attach(pthread_create_addr, {
onEnter: function (args) {
var funcAddr = args[2];
var soName = Process.findModuleByAddress(funcAddr).name;
soAddr = Module.findBaseAddress(soName);
if(soName.indexOf("libmsaoaidsec.so") >= 0 ){
console.log(soName + " ==> " + soAddr);
console.log("function Address ==> ", funcAddr);
var offset = funcAddr.sub(soAddr);
if (offset==0x175F8) {
Interceptor.replace(funcAddr, new NativeCallback(function () {
console.log(funcAddr + " replace");
}, 'void', []));
}
if (offset==0x16D30) {
Interceptor.replace(funcAddr, new NativeCallback(function () {
console.log(funcAddr + " replace");
}, 'void', []));
}
}
},
onLeave: function (retval) {

}
})
}

执行结果如下,可以看到成功的替换了函数。

image-20231114132359843

理论上说现在应该就可以正常Hook了,但是~~我只能惊呼牛逼(是真NM硬啊,还有检测??到这里是不可能放弃的,已经变成私人恩怨了。)

image-20231114133421783

那么可能就是存在在libpro.so中了,同样的我们可以来看看IDA中,那么吊诡的情况出现了。这个so文件中根本就没有pthread_create函数的调用。(我只能说:6)

image-20231114133755304

这根本难不倒他,还是上frida直接动态调试试试。

1
2
3
4
if (soName.indexOf("libpro.so") >= 0) {
console.log(soName + " ==> " + soAddr);
console.log("function Address ==> ", funcAddr);
}

image-20231114133940091

其实是可以看到libpro.so中的调用位置的

1
2
3
4
libpro.so ==> 0x6e382f8000
function Address ==> 0x6e3835b3d4
function Address ==> 0x6e38353b64
function Address ==> 0x6e3837163c

于是也是创建了三个子线程函数0x7963C,0x633D4,0x5BB64。那么现在必须去看看这是怎么回事。

image-20231114134331688

直接看代码是pthread_create被混淆成了v26

image-20231114134350028

其他的也是不是直接使用pthread_create调用的

image-20231114134505547

无妨,我们直接继续Hook这几个偏移地址就好

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
41
42
43
44
45
46
47
48
49
function hook_pthread_create() { 
var soAddr = null;
var pthread_create_addr = Module.findExportByName("libc.so", "pthread_create");
Interceptor.attach(pthread_create_addr, {
onEnter: function (args) {
var funcAddr = args[2];
var soName = Process.findModuleByAddress(funcAddr).name;
soAddr = Module.findBaseAddress(soName);
if (soName.indexOf("libmsaoaidsec.so") >= 0 ) {
console.log(soName + " ==> " + soAddr);
console.log("function Address ==> ", funcAddr);
var offset = funcAddr.sub(soAddr);
if (offset==0x175F8) {
Interceptor.replace(funcAddr, new NativeCallback(function () {
console.log(funcAddr + " replace");
}, 'void', []));
}
if (offset==0x16D30) {
Interceptor.replace(funcAddr, new NativeCallback(function () {
console.log(funcAddr + " replace");
}, 'void', []));
}
}
if (soName.indexOf("libpro.so") >= 0) {
console.log(soName + " ==> " + soAddr);
console.log("function Address ==> ", funcAddr);
var offset = funcAddr.sub(soAddr);
if (offset==0x5BB64) {
Interceptor.replace(funcAddr, new NativeCallback(function () {
console.log(funcAddr + " replace");
}, 'void', []));
}
if (offset==0x633D4) {
Interceptor.replace(funcAddr, new NativeCallback(function () {
console.log(funcAddr + " replace");
}, 'void', []));
}
if (offset==0x7963C) {
Interceptor.replace(funcAddr, new NativeCallback(function () {
console.log(funcAddr + " replace");
}, 'void', []));
}
}
},
onLeave: function (retval) {

}
})
}

woc终于可以自由的用frida了。

image-20231114134724460

0x05 后记

​ 后续实测了其实是libpro.so中的0x633d4和libmsaoaidsec.so中的0x175f8这两个子函数在校验,其余的子线程应该不是做frida检测用的,所以只Hook这两个就够了。

​ 搞完这一波这段时间是不想做逆向了,真的累。但是有成就感。:)