某虚拟相机app分析

so脱壳

dynamic段混淆

对so文件libSecShell.so脱壳,so加壳肯定要在init/init_array中还原原始so文件,readelf查看so的.dynamic节区发现只有NEEDEDSONAME类型,符号表和字符串表等其他重定位信息都没有这显然是有问题的。

ida加载文件是可以正常解析符号表和字符串表等信息的,难道是readelfida解析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的效果。

  1. 判断program header table中的dynamic文件偏移是否等于section header table中dynamic的文件偏移
  2. 判断program header table中的dynamic文件大小是否等于section header table中dynamic的文件大小
  3. 利用section header table中的dynamic文件偏移将dynamic节区map到内存中
  4. 利用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-loopgmain

还会通过/proc/self/fd进行fd反查,看是否存在linjector管道。

绕过方法就是通过frida hook sub_59530自己实现的strstr函数,对 gum-js-loop,gmainlinjector进行过滤。

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的一些基本函数,如readwrite

hook libdexfile.soart::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到当前classloaderdexElements头部完成dex的加载。

com.SecShell.SecShell.H.e是一个native函数对应的地址为libSecShell.so!0x21ED8open 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调用mmap2base.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 injectcameraserver中注入libhookProxy.so,还会执行CHMP4 play $remain $now $deviceId $token $mp4file $filterstr命令将之前的命令行参数传入。

libhookProxy.so会加载libCHMP4.so并执行其main_hook导出函数,此导出函数中libCHMP4.so利用shadowhookcameraserver中的一系列函数进行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点并没有进一步分析了。

posted @ 2023-12-23 04:22  怎么可以吃突突  阅读(2782)  评论(4编辑  收藏  举报