欧博allbet记录一次加固逆向分析以及加固步骤详解
最近想要重点学习一下类抽取这种类型的加固是如何实现的,欧博allbet故在网上搜寻。最终看到了luoyesiqiu大佬的dpt-shell这个项目。对这个项目研究后发现这一款开源加固已经可以说很成熟了。故先对其逆向分析后再从代码层面研究如何实现的。
项目地址:https://github.com/luoyesiqiu/dpt-shell
分析版本:V1.12.2
欸嘿,是时候请出来之前的老朋友了(在之前分析某加固时用到的demo),然后采用dptshell进行加固
非常的方便,只需要现在编译好了的dpt.jar 然后在命令框念出如下咒语:
(吾爱破解传不了大附件,又不太像搞网盘,附件就放一个脚本吧)
等待程序吟唱:
吟唱结束我们就得到了:
(wow,太贴心了,还帮我们进行了签名)
另外如果不想签名可以看如下帮助:
这里可以发现类都已经被抽取了。
首先我们可以看到工厂类已经被替换成了壳的代理工厂类
那么这里其实就涉及到了一个知识点:
这个androidx.core.app.AppComponentFactory是用来动态控制组件示例话的,允许在 Activity、Service、BroadcastReceiver、ContentProvider 等组件被系统创建时拦截并替换其实例。dptshell在此处进行壳so的加载,以及对一些系统函数的Hook。
详细的我们可以继续往下面看
ActivityThread.handleBindApplication()
是按照什么顺序加载 APK 并加载应用程序组件的呢,我们可以看下图:
这里我们着重要看的就是instantiateClassLoader这个方法了。
这里主要载入了壳so,然后到达代理Application,完成了源dex的Application的替换了生命周期函数的调用,开始运行源dex的程序代码,并且为了其能够被正常加载做处理,后续会在源码分析中,详细来分析。
壳so解密分析:那么对于此类抽取壳的分析,当然是要从Native层入手了。
这里我们选择分析arm64架构下的dpt.so
这里我们直接IDA打开会发现ELF文件中bitcode段都被加密了
一般出现这种情况我们就需要在从initArray段入手分析了,应为initArray段执行的是构造函数,在loadlibray之后就会立马被linker所执行。
正好我们在initArray 段的sub_C67C函数中发现了如下函数:
函数直接就以bitCode作为参数了,实属可疑
sectionName被传入了sub_FD4C,这里就是在寻找bitcode段的地址,好对其进行后续的处理
在sub_EB7C对内存页的权限进行修改,我们可以在segment窗口中看到bitcode段的权限是不可写的,所以要通过mprotect来修改内存页的权限,方便对代码进行动态解密。
那么上面分析完了,内存页权限修改完了,接下来要做的就是对内存中被加密的字节进行解密了,
流程上来看肯定就是这两个了。
相信大家都能一眼看出来这个是一个RC4吧。
根据下面的参数可以找到key
这里需要注意的是,key的长度被限定到了16,我们在写代码的时候不能用lenkey,因为key后面很多0。
使用idapython解密bitcode段:
执行完后保存再重载文件会看到bitcode段代码被成功识别了:
Dump SO的话,不管你用GDA也好,还是什么小工具都可以,我这里展示frida的。
frida的话首先还是得hook dlopen找到dlopen打开我们需要dump的so的时机,然后就可以开始获取so的Base和Size了具体实现如下:
这里dump_so 1和2的区别在于一个是dump到私有目录,另一个是sdcard,到sdcard是为了方便我们pull,所以我默认使用2。
启动!
欸嘿,虽然sodump下来了但是居然崩溃了,显然是frida被检测了,但是问题不大我们稍后分析。
先看看dump下来的so,dump的内存中的SO通常IDA是没有办法识别出导入导出表和一些字符的,需要用SOFixer来修正:
注意-m的参数是我们so在内存中的基地址。
这样就是修复好了,打开IDA检查一下:
发现已经没有bitcode段了,可能是由于section节不完整,但是对应的代码肯定是被解密的:
但是依旧出现了一些IDA识别失误的问题,但这都不是影响,能够正常的查看代码。
FRIDA检测绕过:之前在DumpSO的时候就发现了存在Frida,检测。
在initArray的调用中可以看到此处创建了一个线程,我们看看线程函数是什么:
检测frida的关键字,这就好说了。
逻辑中检测可以发现就是遍历字符串扫描
sub_100E0是自实现的一个strstr
可以看到很经典的逐字节匹配算法。在长串中寻找字串
所以这个检测函数的功能就是通过遍历maps,寻找是否出现了frida-agent的特招,如果存在特征就直接进行崩溃,这里做的好的地方就是通过自己实现的strstr来遍历maps,可以防止直接通过hook strstr来防止检测,但是frida-agent这个特征串居然是明文存储在内存中,实属不该。
他们检测到之后都调用了同一个函数,这个函数就是处理检测到frida之后的崩溃逻辑的。
点开发现没有东西,需要查看汇编代码:
X30寄存器在ARM64中相当于rsp,在ret之前储存的是返回地址,这里函数将X30赋值为0之后就会产生一个 Process crashed: Bad access due to invalid address 的报错,导致程序崩溃。
那么既然这样,我们直接用一个空函数将其替换掉就好了。
完整:
function antiDetectFrida(Base) { var crashAddr = Base.add("0x4E864"); var originalFunc = new NativeFunction(crashAddr, 'void', []); Interceptor.replace(originalFunc, new NativeCallback(function () { // console.log("[Replaced] - Empty function executed"); console.log('sub_4E894 called from:\n' + Thread.backtrace(this.context, Backtracer.FUZZY).map(DebugSymbol.fromAddress).join('\n') + '\n'); }, 'void', [])); } function NativeFunc() { console.info("[Hook Beging]"); var Base = Module.getBaseAddress("libdpt.so"); console.warn("[Base]->", Base); antiDetectFrida(Base); } function hook_android_dlopen_ext() { var isHook = false; Interceptor.attach(Module.findExportByName(null, "android_dlopen_ext"), { onEnter: function (args) { this.name = args[0].readCString(); if (this.name.indexOf("libdpt.so") > 0) { console.log(this.name); var symbols = Process.getModuleByName("linker64").enumerateSymbols(); var callConstructorAdd = null; for (var index = 0; index < symbols.length; index++) { const symbol = symbols[index]; if (symbol.name.indexOf("__dl__ZN6soinfo17call_constructorsEv") != -1) { callConstructorAdd = symbol.address; } } console.log("callConstructorAdd -> " + callConstructorAdd); Interceptor.attach(callConstructorAdd, { onEnter: function (args) { if (!isHook) { NativeFunc(); isHook = true; } }, onLeave: function () { } }); } }, onLeave: function () { } }); } setImmediate(hook_android_dlopen_ext);这样我们就成功hook上程序了。
DEX填充分析:首先我们需要知道抽取壳,肯定是要对dex处理并且回填CodeItem的,那么程序肯定是要对DefineClass或者loadMEthod来在执行方法之前回填正确的字节码,那么让我们看一下在执行一个Java方法时的调用链(复制自luoyesiqiu博客):
ClassLoader.java::loadClass -> DexPathList.java::findClass -> DexFile.java::defineClass -> class_linker.cc::LoadClass -> class_linker.cc::LoadClassMembers -> class_linker.cc::LoadMethod那么既然这样,我们思路就很明确了,看看程序在哪里注册的hook就好了。
这个函数就非常的像了,这里大家凭借经验应该是可以猜测出在进行hook了,但是这里似乎是使用了两个hook框架,可以尝试恢复一下符号
首先我们还是先注意一下如下格式
是否想起
https://github.com/bytedance/android-inline-hook
shadowhook的注册hook格式呢
那就可以大胆猜测shadowhook,或者利用shadowhook的模板了。
我们直接搞一个libshadowhook.so,自己编译或者去现成的app里面搞都是可以的,我们只需要利用bindiff来载入符号就好了,类似的操作可以在
中详细查阅,我这里就简单的概述一下:
这里直接就能看出来壳用的基本还是shadowhook的框架,但是是存在改动的,其实到这里恢复符号的意义以及不大了。
sub_1F640的那个参数肯定就是注册的hook,但是这个时候我们又该发现问题了:
怎么hookDefineClass不是这个板子了,看起来也不像是ShadowHook了
但是我们看这个写法,显然还是在做hook。那么我们就应该知道
sub_4DAC0
这个就是在DefineClass之前通过hook执行的函数了,这里大概率也就是对Dex进行填充了。
这个操作也是非常模板的操作了,在我们执行完hook之后还是需要还原现场的,所以return回了原本记录的originMethod。那么sub_4D608(a3, a6, a7);肯定有一个参数是DexFile了,我们只需要在这个函数之后利用frida介入就好了。
另外指的注意的是,我们要分析这个sdk的版本,他这里sdk版本大于22走的是下面的hook小于22走的是上面的hook,不要hook错了。
后面就是sub_4D608的逻辑了
逻辑中可以翻找到
读取了静态资源,那么既然是抽取壳肯定是要从Assets中去读的。所以基本可以猜测这里是有对DexFile处理的逻辑了。
这里在对不同版本的SDK版本做不同的处理。
另外有一个非常指的注意的地方,就是处理文件传入的时候,首先我们肯定是要进行空指针判断的,这里对应的地方则是:
那么这里我们就可以发现,a2其实就是Dexfile的指针了。
那么这里我们只需要在sub_4D608执行完之后解析传入时的a4即可:
既然要解析这个DexFile,那么我们不妨看看这个DexFile对象的结构
DexFile::DexFile( const uint8_t* base, //dex文件基址 size_t size, // dex文件长度 const uint8_t* data_begin, size_t data_size, const std::string& location, uint32_t location_checksum, const OatDexFile* oat_dex_file, std::unique_ptr<DexFileContainer> container, bool is_compact_dex )第一个是基地址,第二个是长度,那么只需要这两个我们就可以dump下来完整的dexfile了,那么这个时候我们,使用如下(frida代码spwn启动,注意dlopen时机,我这里就只是粘贴部分代码了)
function analysisDex(Base) { var originalDefineClass = Base.add("0x4DB44"); console.log("originalDefineClassAddr->", originalDefineClass) Interceptor.attach(originalDefineClass, { onEnter: function (args) { this.dex_file = this.context.x5; console.log(hexdump(this.context.x5)) }, onLeave: function (args) { var dex_file = this.dex_file; } }) }阅读这里我们发现,前面8个bytes好像并不是dex的基地址,应为第2组8bytes 显然是一个地址,而不是size,而第三组8bytes才是size的样子,这是为何呢。其实是应为C++的调用约定里面第一个参数实际上是this指针,我们如果要解析的话是需要跳过这个指针的,接下来我们再看这一段内存就可以和之前Dexfile对应的参数呼应了。
获取Dexfile基地址代码如下:
为了确保我们读取的是否正确,我们可以读取base的前8个字节来看一下magic:
console.log("[DexFile]-> magic = ", magic);满足我们DexFile的格式,那么聪明的你肯定发现了,这同一个Base怎么调用这么多次啊,应为抽取壳并不是一次性填充好的,他是调用的时候动态回填insns的,所以会多次的操作一个dex文件。
并且在hook的逻辑中我们可以发现,dpt-shell并没有把已经装载好的method卸载,其实也很少会有厂商这样做,会导致过多的性能损失。
那么我们只要用一个maps创建映射,存入所有不同的Dex的base和size,在我们程序加载完了之后,我们遍历这个maps进行dump不就好了嘛?
具体实现如下:
const dexMap = new Map(); function analysisDex(Base) { var originalDefineClass = Base.add("0x4DB44"); console.log("originalDefineClassAddr->", originalDefineClass) Interceptor.attach(originalDefineClass, { onEnter: function (args) { this.dex_file = this.context.x5; var base = ptr(this.dex_file).add(Process.pointerSize).readPointer(); var size = ptr(this.dex_file).add(Process.pointerSize + Process.pointerSize).readUInt(); console.log("[DexFile]-> Base = ", base); console.log("[DexFile]-> size = ", size); var magic = ptr(base).readCString(); console.log("[DexFile]-> magic = ", magic); // 检查 base 和 size 是否已存在 let isDuplicate = false; for (let [existingBase, existingSize] of dexMap.entries()) { if (existingBase.equals(base) && existingSize === size) { isDuplicate = true; break; } } if (isDuplicate) { console.log(`[WARN] DexFile with base ${base} and size ${size} already exists, skipping...`); } else { dexMap.set(base, size); console.log(`[INFO] New DexFile found: base=${base}, size=${size}`); } }, onLeave: function (args) { } }) } function printDexMap() { console.log("Current DexFile Map:"); for (let [base, size] of dexMap.entries()) { console.log(`Base: ${base}, Size: ${size}`); } }当我们frida输出变得缓慢的时候,或者不再输出的时候我们调用一下printDexMap():
这样我们就获得了所有加载的dex的base与size,然后写一个遍历脚本进行dump就行了:
这里我已经写好了一个直接dump到私有目录的:
function get_self_process_name() { var openPtr = Module.getExportByName('libc.so', 'open'); var open = new NativeFunction(openPtr, 'int', ['pointer', 'int']); var readPtr = Module.getExportByName("libc.so", "read"); var read = new NativeFunction(readPtr, "int", ["int", "pointer", "int"]); var closePtr = Module.getExportByName('libc.so', 'close'); var close = new NativeFunction(closePtr, 'int', ['int']); var path = Memory.allocUtf8String("/proc/self/cmdline"); var fd = open(path, 0); if (fd != -1) { var buffer = Memory.alloc(0x1000); var result = read(fd, buffer, 0x1000); close(fd); result = ptr(buffer).readCString(); return result } return "-1" } function Mkdir(path) { if (path.indexOf("com") == -1) { console.log("[Mkdir]-> Pass:", path); return 0; } var mkdirPtr = Module.getExportByName('libc.so', 'mkdir'); var mkdir = new NativeFunction(mkdirPtr, 'int', ['pointer', 'int']); var opendirPtr = Module.getExportByName('libc.so', 'opendir'); var opendir = new NativeFunction(opendirPtr, 'pointer', ['pointer']); var closedirPtr = Module.getExportByName('libc.so', 'closedir'); var closedir = new NativeFunction(closedirPtr, 'int', ['pointer']); var cPath = Memory.allocUtf8String(path); var dir = opendir(cPath); if (dir != 0) { closedir(dir); return 0 } mkdir(cPath, 0o755); chmod(path) console.log("[Mkdir]->", path); } function chmod(path) { var chmodPtr = Module.getExportByName('libc.so', 'chmod'); var chmod = new NativeFunction(chmodPtr, 'int', ['pointer', 'int']); var cPath = Memory.allocUtf8String(path); chmod(cPath, 755) } function dumpDex() { dexMap.forEach((size, base) => { console.log(`Base: ${base}, Size: ${size}`); var magic = ptr(base).readCString(); console.log("DesFileMagic->", magic); if (magic.indexOf("dex") == 0) { var process_name = get_self_process_name(); if (process_name != "-1") { var dex_dir_path = "/data/data/" + process_name + "/files" Mkdir(dex_dir_path) dex_dir_path += "/dump_dex_" + process_name Mkdir(dex_dir_path) var dex_path = dex_dir_path + "/class" + (dex_count == 1 ? "" : dex_count) + ".dex"; console.log("[find dex]:", dex_path); var fd = new File(dex_path, "wb"); if (fd && fd != null) { dex_count++; var dex_buffer = ptr(base).readByteArray(size); fd.write(dex_buffer); fd.flush(); fd.close(); console.log("[dump dex]:", dex_path) } } } }); }等待程序加载好后我们直接调用DumpDex即可:
私有目录中即可找到这个file
反编译即可发现被抽取的类都填充好了:
这里主要分析一下程序如何处理被抽取的类填充回Class,对照源码进行分析。另外的步骤在上文的逆向过程说以及解释的差不多了
源码可以看到此处是DobbyHook
校验了SDK版本,不同SDK版本不同处理方式
这里直接就走patchClass了,那么我们重点要分析的就是patchClass的逻辑了
这里就是在根据不同的SDK版本来解析 DexFile
uint64_t static_fields_size = 0; read += DexFileUtils::readUleb128(class_data, &static_fields_size); uint64_t instance_fields_size = 0; read += DexFileUtils::readUleb128(class_data + read, &instance_fields_size); uint64_t direct_methods_size = 0; read += DexFileUtils::readUleb128(class_data + read, &direct_methods_size); uint64_t virtual_methods_size = 0; read += DexFileUtils::readUleb128(class_data + read, &virtual_methods_size);获取类中字段和方法的数量,为后续解析做准备
dex::ClassDataField staticFields[static_fields_size]; read += DexFileUtils::readFields(class_data + read, staticFields, static_fields_size); dex::ClassDataField instanceFields[instance_fields_size]; read += DexFileUtils::readFields(class_data + read, instanceFields, instance_fields_size); dex::ClassDataMethod directMethods[direct_methods_size]; read += DexFileUtils::readMethods(class_data + read, directMethods, direct_methods_size); dex::ClassDataMethod virtualMethods[virtual_methods_size]; read += DexFileUtils::readMethods(class_data + read, virtualMethods, virtual_methods_size);获取类中所有字段和方法的详细信息,为后续修补做准备
这里就将之前读取到的所有的方法都传入patchMethod中来修改。
然后就是patchMethod了,这里主要是利用了之前维护好的dexMap,修改对应内存段权限后在Map查找CodeItem,然后使用memcopy填入。
这样的流程就完成了类的动态回填。
总结dpt-shell上有很多值得学习的技术和加固原理,一次非常充实的学习过程