某虚拟相机app分析
so脱壳
dynamic段混淆
对so文件libSecShell.so
脱壳,so加壳肯定要在init/init_array
中还原原始so文件,readelf
查看so的.dynamic
节区发现只有NEEDED
和SONAME
类型,符号表和字符串表等其他重定位信息都没有这显然是有问题的。
ida加载文件是可以正常解析符号表和字符串表等信息的,难道是readelf
和ida
解析dynamic
段的方式有区别?
readelf继续查看so文件的program header table
发现一个问题,dynamic
是在第二个PT_LOAD
段中,但是其文件段内偏移和内存段内偏移不一样,正常情况下dynamic
文件段内偏移和内存段内偏移是相等的。内存地址肯定不会错的,因为linker是通过内存地址解析的dynamic
节区,那也就是文件偏移被修改了,正常情况下文件偏移应该为0xa10b8 + 0x794 = 0xa184c
查看0xa184c
偏移对应的数据果然是一个标准的dynamic
段,将对应的文件偏移修正。
修正了dynamic
段的文件偏移后,再次用readelf查看对应的dynamic
信息发现已经可以正确解析。事实证明ida是通过内存偏移去解析的dynamic
段和linker程序一样,而readelf
是通过文件偏移去解析dynamic
段。
readelf继续看一下section header tabel
发现其描述的的dynamic
段的文件偏移也是指向错误的0xab320
,android 7.0之后linker
程序需要解析section header table
,linker解析program header table
去读取dynamic
的时候是通过内存偏移所以不会有问题,但是section header table
只有dynamic
的文件偏移,所以linker
通过文件偏移读取到阉割版的dynamic
段不会出现问题吗?
查看linker
加载so的源码,ElfReader::ReadDynamicSection
会进行如下操作,所以加壳程序将program header table
中的dynamic
对应的文件偏移改为和section header table
一样都指向被阉割的dynamic
节区可能目的并不是为了混淆readelf的解析,而是为了兼容linker的检查,只不过恰好起到了混淆readelf
的效果。
- 判断program header table中的dynamic文件偏移是否等于section header table中dynamic的文件偏移
- 判断program header table中的dynamic文件大小是否等于section header table中dynamic的文件大小
- 利用section header table中的dynamic文件偏移将dynamic节区map到内存中
- 利用section header table中的strtab文件偏移将strtab节区map到内存中
bool ElfReader::ReadDynamicSection() {
// 1. Find .dynamic section (in section headers)
const ElfW(Shdr)* dynamic_shdr = nullptr;
for (size_t i = 0; i < shdr_num_; ++i) {
if (shdr_table_[i].sh_type == SHT_DYNAMIC) {
dynamic_shdr = &shdr_table_ [i];
break;
}
}
if (dynamic_shdr == nullptr) {
DL_ERR_AND_LOG("\"%s\" .dynamic section header was not found", name_.c_str());
return false;
}
// Make sure dynamic_shdr offset and size matches PT_DYNAMIC phdr
size_t pt_dynamic_offset = 0;
size_t pt_dynamic_filesz = 0;
for (size_t i = 0; i < phdr_num_; ++i) {
const ElfW(Phdr)* phdr = &phdr_table_[i];
if (phdr->p_type == PT_DYNAMIC) {
pt_dynamic_offset = phdr->p_offset;
pt_dynamic_filesz = phdr->p_filesz;
}
}
// 判断program header table中的dynamic文件偏移是否等于section header table中dynamic的文件偏移
if (pt_dynamic_offset != dynamic_shdr->sh_offset) {
// 如果Android版本大于7就直接报错
if (get_application_target_sdk_version() >= 26) {
DL_ERR_AND_LOG("\"%s\" .dynamic section has invalid offset: 0x%zx, "
"expected to match PT_DYNAMIC offset: 0x%zx",
name_.c_str(),
static_cast<size_t>(dynamic_shdr->sh_offset),
pt_dynamic_offset);
return false;
}
// Android版本小于7给出警告
DL_WARN_documented_change(26,
"invalid-elf-header_section-headers-enforced-for-api-level-26",
"\"%s\" .dynamic section has invalid offset: 0x%zx "
"(expected to match PT_DYNAMIC offset 0x%zx)",
name_.c_str(),
static_cast<size_t>(dynamic_shdr->sh_offset),
pt_dynamic_offset);
add_dlwarning(name_.c_str(), "invalid .dynamic section");
}
// 判断program header table中的dynamic文件大小是否等于section header table中dynamic的文件大小
if (pt_dynamic_filesz != dynamic_shdr->sh_size) {
if (get_application_target_sdk_version() >= 26) {
DL_ERR_AND_LOG("\"%s\" .dynamic section has invalid size: 0x%zx, "
"expected to match PT_DYNAMIC filesz: 0x%zx",
name_.c_str(),
static_cast<size_t>(dynamic_shdr->sh_size),
pt_dynamic_filesz);
return false;
}
DL_WARN_documented_change(26,
"invalid-elf-header_section-headers-enforced-for-api-level-26",
"\"%s\" .dynamic section has invalid size: 0x%zx "
"(expected to match PT_DYNAMIC filesz 0x%zx)",
name_.c_str(),
static_cast<size_t>(dynamic_shdr->sh_size),
pt_dynamic_filesz);
add_dlwarning(name_.c_str(), "invalid .dynamic section");
}
if (dynamic_shdr->sh_link >= shdr_num_) {
DL_ERR_AND_LOG("\"%s\" .dynamic section has invalid sh_link: %d",
name_.c_str(),
dynamic_shdr->sh_link);
return false;
}
const ElfW(Shdr)* strtab_shdr = &shdr_table_[dynamic_shdr->sh_link];
if (strtab_shdr->sh_type != SHT_STRTAB) {
DL_ERR_AND_LOG("\"%s\" .dynamic section has invalid link(%d) sh_type: %d (expected SHT_STRTAB)",
name_.c_str(), dynamic_shdr->sh_link, strtab_shdr->sh_type);
return false;
}
if (!CheckFileRange(dynamic_shdr->sh_offset, dynamic_shdr->sh_size, alignof(const ElfW(Dyn)))) {
DL_ERR_AND_LOG("\"%s\" has invalid offset/size of .dynamic section", name_.c_str());
return false;
}
// 利用section header table中的dynamic文件偏移将dynamic节区map到内存中
if (!dynamic_fragment_.Map(fd_, file_offset_, dynamic_shdr->sh_offset, dynamic_shdr->sh_size)) {
DL_ERR("\"%s\" dynamic section mmap failed: %s", name_.c_str(), strerror(errno));
return false;
}
dynamic_ = static_cast<const ElfW(Dyn)*>(dynamic_fragment_.data());
if (!CheckFileRange(strtab_shdr->sh_offset, strtab_shdr->sh_size, alignof(const char))) {
DL_ERR_AND_LOG("\"%s\" has invalid offset/size of the .strtab section linked from .dynamic section",
name_.c_str());
return false;
}
// 利用section header table中的strtab文件偏移将strtab节区map到内存中
if (!strtab_fragment_.Map(fd_, file_offset_, strtab_shdr->sh_offset, strtab_shdr->sh_size)) {
DL_ERR("\"%s\" strtab section mmap failed: %s", name_.c_str(), strerror(errno));
return false;
}
strtab_ = static_cast<const char*>(strtab_fragment_.data());
strtab_size_ = strtab_fragment_.size();
return true;
}
利用section header table中的dynamic文件偏移将阉割版的dynamic节区map到内存中,后续linker
解析会出现问题吗,继续分析linker
哪里会对map到内存的dynamic_
进行访问。查看源码发现后续只会对map到内存的dynamic_
中的DT_NEEDED, DT_SONAME, DT_RUNPATH, DT_FLAGS_1
这四种类型的信息进行解析,所以只要阉割版的dynamic
段中包含原始dynamic
中的DT_NEEDED, DT_SONAME, DT_RUNPATH, DT_FLAGS_1
这四种类型的信息linker
就可以正常加载so,所以一开始用readelf查看到的阉割版的dynamic
只有DT_NEEDED, DT_SONAME
这两种类型。
INIT段解密代码和重定位
前面查看dynamic
段可以得知init
函数地址为0xc0648
,这里通过向init
函数头部写入死循环指令,然后使用ida附加上去动态调试。
首先将0x1031C ~ 0x74BB1
加密压缩的代码读取到map中
接着解密代码,解密后代码的地址范围是0x1031c ~ 0xA0EFB
,ida将解密后的代码dump并patch回原so中。
然后进行重定位修复,其会将AB3A4
开始的数据copy到AB110
地址数组中的每一个地址中。
AB3A4
地址是符号相关重定位信息默认的got表,linker程序会将重定位的数据写入到此地址处,而上述重定位将默认got表中的重定位数据copy到AB110
地址数组中的每一个地址中完成程序的重定位修复,这说明符号相关重定位信息的got表地址实际保存在AB110
地址数组中,现在符号相关重定位信息中的got表地址是错误的(壳修改的)。
需要将符号相关重定位信息中的got表地址修复为AB110
地址数组中实际的got地址,使用idc脚本patch恢复。
#include <idc.idc>
static main(void)
{
//start address
auto start_address1 = 0xFDF4;
auto end_address1 = 0x10314;
auto i = start_address1;
auto start_address2 = 0xAB110;
auto offset = 0;
for(i = start_address1; i <= end_address1; i = i + 8){
auto dword = Dword(start_address2 + offset);
PatchDword(i , dword);
offset = offset + 4;
}
}
壳运行时在修复了重定位数据后还会将符号相关重定位信息默认的got表中的重定位数据销毁,通过破坏符号相关重定位信息。
在完成了解密代码patch和重定位表的修复后,将section信息清空,这样ida就可以正常解析so了。可以看到so中字符串相关信息时运行时解密的,并且还有大量的间接跳转,switch跳转和动态跳转指令计算跳转,这也导致了JNI_Onload不能F5反编译。
anti frida check
JNI_Onload函数中会通过/proc/self/task/%s/status
枚举线程名称,并调用自己实现的strstr
函数sub_59530
进行比较是否有frida线程gum-js-loop
和gmain
。
还会通过/proc/self/fd
进行fd
反查,看是否存在linjector
管道。
绕过方法就是通过frida hook sub_59530
自己实现的strstr函数,对 gum-js-loop
,gmain
和linjector
进行过滤。
var my_strstr_add = SecShell_module.add(0x59530 + 1)
Interceptor.replace(my_strstr_add, new NativeCallback(function(arg1, arg2){
const arg2String = Memory.readUtf8String(arg2)
if (-1 != arg2String.indexOf("gum-js-loop") ||
-1 != arg2String.indexOf("gmain") ||
-1 != arg2String.indexOf("linjector")){
return new NativePointer(0x0)
}
var my_strstr = new NativeFunction(my_strstr_add, 'pointer', ['pointer', 'pointer']);
return my_strstr(arg1, arg2)
}, 'pointer', ['pointer', 'pointer']))
此检测点是动态跟踪出来的,其实更快的方法应该是对open
系统调用hook,看什么地方会打开/proc/self/task/%s/status
和/proc/self/fd/
。
dex文件脱壳
实际此dex加的是一代壳,直接dump dex就可以了,但是为了分析一下其壳运行流程所以通过动态调试和静态分析跟踪了一下JNI_Onload
的运行。
JNI_Onload会调用RegisterNative
注册0x12个com.SecShell.SecShell.H
类的native函数
hook libc.so
的一些基本函数,如read
,write
等
hook libdexfile.so
的art::ArtDexFileLoader::Open(std::string const&, unsigned int, art::MemMap &&, bool, bool, std::string*)
函数
创建/data/data/com.xianyu.gaotong/.cache/oat/arm/classes.odex
文件并写入4个字节
反射调用com.SecShell.SecShell.H.f
,第一个参数为com.SecShell.SecShell.H
类的classloader
,第二个参数为/data/user/0/com.xianyu.gaotong/.cache/classes.jar
,classes.jar文件就是classes0.jar
com.SecShell.SecShell.H.f
函数最后会调用 com.SecShell.SecShell.H.e
加载/data/user/0/com.xianyu.gaotong/.cache/classes.jar
并返回得到的Elements[]
将其copy到当前classloader
的dexElements
头部完成dex的加载。
com.SecShell.SecShell.H.e
是一个native函数对应的地址为libSecShell.so!0x21ED8
会open classes.jar
,调用read读取文件,因为之前hook了read函数,所以在filter_read
中对读取的classes.jar
内容进行解密
最后调用inflate
对解密后的数据进行解压缩得到原始dex文件。
解密后的dex文件数据缓冲区用来构造java.nio.ByteBuffer
类型的参数,最后调用makeInMemoryDexElements
从内存中加载dex文件并返回[Ldalvik/system/DexPathList$Element
对象。
文件指纹校验
重新签名后运行不会崩溃,说明没有签名校验,但是修改apk的文件在重打包后运行会崩溃,说明有文件校验。文件校验的话肯定会open base.apk
文件,hook libc未发现说明使用的是svc
调用。利用ebpf
hook __arm64_compat_sys_open
函数(32位)并进行栈回溯。
查看栈回溯的结果发现有线程会打开base.apk
文件,对应的偏移为0x6aa0c
0x6aa0c
地址处就是通过svc
调用的open
打开base.apk
文件
然后svc
调用mmap2
将base.apk
加载到内存中
后面会检查assets/meta-data/manifest.mf
保存的原始文件文件指纹是否与当前文件一致
绕过文件指纹校验方法,动态调试找到线程函数为0x7BF08
,hook pthread_create并阻止此线程的创建。
app功能分析
app会将assets
文件夹中的sh,chmp4.sh,CHMP4, libCHMP4.so,libshadowhook.so,libhookProxy.so
文件copy到/data/data/com.xianyu.gaotong/cache
点击激活的时候会将输入的激活码发送到服务器校验
并返回一个js数据包含了code,remain,now,token,ns,expiredTime
信息,当code == 200
是继续执行后续流程
点击替换视频文件时会去校验替换的mp4文件格式,然后获取缩放的宽度和高度
然后检验服务器返回的时间(过期时间)和token长度,最后执行shell命令
hooks2.b.C
发现替换视频文件的时候其会执行两条命令
/data/user/0/com.xianyu.gaotong/cache/sh /data/user/0/com.xianyu.gaotong/cache/chmp4.sh testmp4 /sdcard/DCIM/Camera/MV_20231221_110357.mp4
和/data/user/0/com.xianyu.gaotong/cache/sh /data/user/0/com.xianyu.gaotong/cache/chmp4.sh initchmp4 $remain $now $token /sdcard/DCIM/Camera/MV_20231221_110357.mp4
分析/data/user/0/com.xianyu.gaotong/cache/sh
文件,其会解密chmp4.sh
文件,并且用前面命令行参数作为参数执行chmp4.sh
查看解密后的chmp4.sh
,其会执行CHMP4 inject
向cameraserver
中注入libhookProxy.so
,还会执行CHMP4 play $remain $now $deviceId $token $mp4file $filterstr
命令将之前的命令行参数传入。
libhookProxy.so
会加载libCHMP4.so
并执行其main_hook
导出函数,此导出函数中libCHMP4.so
利用shadowhook
对cameraserver
中的一系列函数进行hook。
看一下注入完成后执行的CHMP4 play $remain $now $deviceId $token $mp4file $filterstr
命令会干些什么,首先会将token
等信息去请求服务器进行验证,返回code = 200则验证通过
同时验证通过会返回js数据,包含了remain,now,rc,salt,allow
等信息
接着初始化binder
,利用binder
与注入到cameraserver
进程中的libCHMP4.so
进行跨进程通讯,首先将服务器返回的数据发送到libCHMP4.so
进行验证。
会将remain, now,serialno,rc,salt
发送到binder
服务端也就是cameraserver
进程中的libCHMP4.so
中进行验证,binder
通讯的tag是6
libCHMP4.so
的函数Vedio2CameraService::onTransact
作为binder
通知的回调,对发送过来的消息进行分类处理,对于tag = 6
的就会读取remain, now,serialno,rc,salt
数据进行校验。
对服务器返回的判断和binder
通讯时的校验进行hook直接返回成功,app提示替换成功,但是实际替换视频显示不出来可能还有其他细节需要处理,具体替换视频的hook点并没有进一步分析了。