Frida 脱壳脚本解读
警告
仅适用于安卓5及以上版本(ART模式) 安卓4.4以前使用的是Dalvik模式,两者在脱壳上有所区别。
壳可以分为几类
| 加壳技术 | 技术特点 | 脱壳难度 | 说人话就是: |
|---|---|---|---|
| 第一代:DEX 整体加密型壳 | 采用 Dex 整体加密,动态加载运行的机制 | 较容易被还原,通过自动化脱壳工具或脚本即可从内存中 dump 出 dex 文件 | 把dex隐藏起来 |
| 第二代:DEX 函数抽取型壳 | 粒度更细,将方法单独抽取出来,加密保存,解密执行 | 可以从根本上进行还原的,dump 出所有的运行时的方法体,填充到 dump 下来的 dex 中去的,这也是 fart 的核心原理 | 隐藏dex并删掉期中中部分方法 |
| 第三代:VMP、Dex2C 壳 | 独立虚拟机解释执行、语义等价语法迁移,强度最高 | Dex2C 目前是没有办法还原的,只能跟踪进行分析; VMP 虚拟机解释执行保护的是映射表,只要心思细、功夫深,是可以将映射表还原的 | 虚拟机里跑虚拟机 |
不过目前遇到的大多是一代和二代壳,三代暂未遇到过。实际上,APP加的壳越复杂,运行效率越低,像支付宝和微信,就从不加壳(因为他们的服务都在云端,本地的修改不影响云数据,所以加不加壳其实无所谓)
本次脱壳目标为一代壳。
加壳APP运行原理
三代壳是先运行虚拟机,再运行dex。
脱壳原理
目的:拿到可供分析的字节码(dex)
目标:内存中已解密的源dex
那么如果已知解密的源dex的内存基址以及文件长度,那么拿到这个dex源码就简单多了。
翻一翻安卓源码(戳我直达)或者你自己动态调试一下安卓App的加载过程,就会发现,在libart.so中有一个函数Openmemory提供了dex加载,从这里可以获得源dex的内存基址(base)以及文件长度(size):
std::unique_ptr<const DexFile> DexFile::OpenMemory(const uint8_t* base, size_t size, const std::string& location, uint32_t location_checksum, MemMap* mem_map, const OatDexFile* oat_dex_file, std::string* error_msg) { CHECK_ALIGNED(base, 4); // various dex file structures must be word aligned std::unique_ptr<DexFile> dex_file( new DexFile(base, size, location, location_checksum, mem_map, oat_dex_file)); if (!dex_file->Init(error_msg)) { dex_file.reset(); } return std::unique_ptr<const DexFile>(dex_file.release());}Android 4.4版本之前 系统函数在libdvm.so
Android 5.0之后 系统函数在libart.so
Dex文件头部格式
dex文件分三块:头部、索引区、数据区。此处只讲解头部格式。

文件头部格式:
struct header_item{ ubyte[8] magic;//文件魔数 8字节 unit checksum;//校验 4字节 ubyte[20] siganature;//签名 20字节 uint file_size;//文件大小 4字节 uint header_size;//头文件大小 unit endian_tag;//一般为固定值0x12345678 uint link_size;//链接数据大小 uint link_off;//链接数据偏移 uint map_off;//data区的数据集合 uint string_ids_size;//字符串数量 uint string_ids_off;//字符串偏移 uint type_ids_size;//所有类型数量 uint type_ids_off;//类型偏移 uint proto_ids_size;//方法参数信息数量 uint proto_ids_off;//偏移 uint method_ids_size;//方法信息数量 uint method_ids_off;//偏移 uint class_defs_size;//类信息个数 uint class_defs_off;//偏移 uint data_size;//数据大小 uint data_off;//偏移}此处主要关注文件魔数,这是用于识别dex文件的标志位。前四位是dex标志,后四位是版本号(安卓编译版本)。
{ 0x64 0x65 0x78 0x0a 0x30 0x33 0x35 0x00 } = “dex\n035\0” 由于dex文件中采用的是小端字节序的编码方法(不理解什么是字节序吗?戳我!),实际存储的数据应该是:
{ 0x0a 0x78 0x65 0x64 0x00 0x35 0x33 0x30 } = “\nxed\0530” Java怎么调用OpenMemory?
javaCode.OpenMemory的导出函数名(参数等...); ⬇️OpenMemory导出函数名映射到libart.so文件中的OpenMemory方法可以理解为:OpenMemory导出函数名是libart.so文件的对外开放的接口。
但是这个导出函数名不是一成不变的,在不同的安卓版本上,这个名称是不一样的。
我们可以通过导出虚拟机上的libart.so文件来获取导出函数名。
Android 10.0以前,libart.so在/system/lib文件夹下,64位的在/system/lib64
Android 10.0以后,libart.so被集成到了apex中
- 使用IDA打开libart.so文件,搜索OpenMemory获取导出函数名:

dexElements 数组
- 在 Android 中,App 安装到手机后,apk 里面的 class.dex 中的 class 均是通过 PathClassLoader 来加载的。
- DexClassLoader 可以用来加载 SD 卡上加载包含 class.dex 的 .jar 和 .apk 文件
- DexClassLoader 和 PathClassLoader 的基类 BaseDexClassLoader 查找 class 是通过其内部的 DexPathList pathList 来查找的
- DexPathList 内部有一个 Element[] dexElements 数组,其 findClass() 方法(源码如下)的实现就是遍历该数组,查找 class ,一旦找到需要的类,就直接返回,停止遍历:
public Class findClass(String name, List<Throwable> suppressed) { for (Element element : dexElements) { DexFile dex = element.dexFile; if (dex != null) { Class clazz = dex.loadClassBinaryName(name, definingContext, suppressed); if (clazz != null) { return clazz; } } } if (dexElementsSuppressedExceptions != null) { suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions)); } return null;}总结一下,一个class的加载过程为:
而,Element存储了我们的Dex文件数据,下面截取了部分代码:
public Element(File file, ZipFile zipFile, DexFile dexFile) { this.file = file; this.zipFile = zipFile; this.dexFile = dexFile;}那么dexElements 数组就是存储了所有的Dex文件数据。
那么基础问题都解决了,就可以上脚本了~
脱壳脚本讲解
脚本地址:戳我直达,这是个典型的一代壳脱壳脚本。
JS部分
var DEX_MAGIC = 0x0A786564;//dex魔数前四位实际存储的16进制表示,详见【知识补充-Dex文件头部格式】var dexrec = [];//获取到的Dex资源var openmemory = Module.findExportByName( "libart.so", //第一个参数是文件名 "_ZN3art7DexFile10OpenMemoryEPKhjRKNSt3__112basic_stringIcNS3_11char_traitsIcEENS3_9allocatorIcEEEEjPNS_6MemMapEPKNS_10OatDexFileEPS9_" //第二个参数为导出函数名,获取方法见【知识补充-Java怎么调用OpeMemmory?】);//用法参考:https://frida.re/docs/javascript-api/#moduleif(openmemory != undefined) {//确定hook上了 console.log("openmemory at" + openmemory); Interceptor.attach(openmemory, {//每当OpenMemory被调用时,执行下面的操作 onEnter: function (args) { //args第一个参数是JniEnv,不是openmemory的参数 //读取前四个字节,判断为Dex执行下一步 //用法参考:https://frida.re/docs/javascript-api/#memory if(Memory.readU32(args[1]) == DEX_MAGIC) { //添加到dex列表中 dexrec.push(args[1]); } }, onLeave: function (retval) { } });}//此部分参考:https://frida.re/docs/javascript-api/#javaif(Java.available) { //Frida API:判断JAVA虚拟机是否加载 Java.perform(function(){ //Frida API:附加到当前JAVA虚拟机进程,并进行回调 var dexBase64 = "";//此处是Dex代码的Base64,太长拿掉了,可以直接去看下面的【Dex部分】 var application = Java.use("android.app.Application");//JAVA API:动态获取JAVA类 var BaseDexClassLoader = Java.use("dalvik.system.BaseDexClassLoader"); var Base64 = Java.use("android.util.Base64"); var FileOutputStream = Java.use("java.io.FileOutputStream"); var DexClassLoader = Java.use("dalvik.system.DexClassLoader"); var reflectField = Java.use("java.lang.reflect.Field"); var reflectMethod = Java.use("java.lang.reflect.Method"); var reflectObject = Java.use("java.lang.Object"); var reflectClass = Java.use("java.lang.Class"); var reflectString = Java.use("java.lang.String"); var reflectClassloader = Java.use("java.lang.ClassLoader"); if(application != undefined) { //Hook Application的attach函数,使用overload指定具体的重载函数 application.attach.overload('android.content.Context').implementation = function(context) { //context为安卓上下文对象【不懂啥是安卓上下文?直达链接:https://zhuanlan.zhihu.com/p/104255774】 //先执行原有的attach var result = this.attach(context); //获取类加载器 var classloader = context.getClassLoader(); //获取路径:/data/user/0/应用包名/files //包含应用程序文件的目录的路径。不需要额外的权限来读取或在返回的路径下写入文件。 var filesDir = context.getFilesDir(); //获取路径:/data/user/0/应用包名/code_cache //保存应用程序代码缓存文件的目录路径。适合在运行时存放应用产生的编译或者优化的代码。不需要额外权限。 var codeCacheDir = context.getCodeCacheDir(); console.log("files dir: " + filesDir); console.log("code cache dir: " + codeCacheDir); if(classloader != undefined) { //对类加载器进行类型转换,将classloader转换成BaseDexClassLoader var casedloader = Java.cast(classloader, BaseDexClassLoader); //对下面的【Dex部分】进行Base64解码 var dexbytes = Base64.decode(dexBase64, 0); //拼接自定义加载的Dex路径 var dexpath = filesDir + "/emmm.dex"; //获取该路径的文件指针 var fout = FileOutputStream.$new(dexpath); //写入自定义加载的Dex数据 fout.write(dexbytes, 0, dexbytes.length); //关闭文件指针 fout.close(); console.log("write dex to " + dexpath); //转换为String类型,或者说获得该对象的文本串 var dexstr = dexpath.toString(); var cachestr = codeCacheDir.toString(); //使用$new()实例化DexClassLoader var dyndex = DexClassLoader.$new( dexstr, //dex路径 cachestr,//代码缓存路径 cachestr, //代码缓存路径 classloader//类加载器 ); console.log(dyndex.toString()); //使用Dex类加载器加载自己写的com.smartdone.EnumerateClass类 var EnumerateClass = dyndex.loadClass("com.smartdone.EnumerateClass"); //对类加载器进行类型转换,将EnumerateClass转换成reflectClass,也就是ClassLoader类 var castedEnumerateClass = Java.cast(EnumerateClass, reflectClass); //获取该类的所有方法 var methods = castedEnumerateClass.getDeclaredMethods(); // loadAllClass var loadAllClass = undefined; for(var i in methods) { //遍历方法名(EnumerateClass的方法名) console.log(methods[i].getName()); //判断获取的方法名是否为loadAllClass if(methods[i].getName() == "loadAllClass") { console.log("find loadAllClass"); loadAllClass = methods[i];//赋值给loadAllClass //我个人认为这边加个break更好,找到loadAllClass之后就不需要再找了 } } //判断有没有找到loadAllClass if(loadAllClass != undefined) { //输出loadAllClass函数名的文本串 console.log("loadAllClass: " + loadAllClass.toString()); //创建数组,用于传递参数 var args = Java.array('Ljava.lang.Object;',[classloader]); //反射调用loadAllClass var classlist = loadAllClass.invoke(null , args); //loadAllClass调用的时候会将这个app中所有类进行遍历调用,与此同时我们上述hook openmemory函数的部分也会成功hook到对应的dex console.log("start dump dex "); //遍历已经获取的dex for(var i in dexrec) { //再次判断是dex if(Memory.readU32(dexrec[i]) == DEX_MAGIC) { //读取dex长度 //dexrec[i]是dex的基址,0x20转为10进制是32,dex基址+偏移量32字节=dex的长度地址 //参考【Dex文件头部格式】 var dex_len = Memory.readU32(dexrec[i].add(0x20)); //构建输出地址,dex名称用dex长度转16进制输出 var dumppath = filesDir.toString() + "/" + dex_len.toString(0x10) + ".dex"; console.log(dumppath); //获取读写文件指针 var dumpdexfile = new File(dumppath, "wb"); //内存读取dex数据 //写出到dex路径 dumpdexfile.write(Memory.readByteArray(dexrec[i], dex_len)); //关闭文件指针 dumpdexfile.close(); console.log("write file to " + dumppath); } } } } else { console.error("unable get classloader"); } return result; } } });}Dex部分
此处JAVA代码由Smali转换而来
package com.smartdone;import android.util.Log;import dalvik.system.DexFile;import java.lang.reflect.Array;import java.lang.reflect.Field;import java.lang.reflect.Method;import java.util.ArrayList;import java.util.Collections;import java.util.Enumeration;import java.util.Iterator;public class EnumerateClass { private static final String TAG = "FRiDA_UNPACK"; public static ArrayList getClassNameList(ClassLoader classLoader) { int i;//迭代累加变量 //局部类名列表 ArrayList classNameList = new ArrayList(); try { //dexElements对象 Object dexElements = EnumerateClass.getObjectField(EnumerateClass.getObjectField(classLoader, "pathList"), "dexElements"); //先获取classLoader对象的属性pathList的值,记作Object A; //再获取A对象的属性dexElements的值 //获取dex数组长度 int dexElementsLength = Array.getLength(dexElements); i = 0; while(true) { label_8: //java的label标签,标记此处为代码块 if(i >= dexElementsLength) { //跳转24号代码块,应该是执行24号代码块后break; goto label_24; } //先获取dexElements的第i个数据,即Element对象 //再获取Element对象的dexFile属性值,就是实际的dex文件数据 //对dex数据进行迭代(枚举),目的是为了获取所有类 Enumeration enumerations = ((DexFile)EnumerateClass.getObjectField(Array.get(dexElements, i), "dexFile")).entries(); while(true) { //直到迭代(枚举)到最后一个项目才退出循环 if(!enumerations.hasMoreElements()) { ++i; break; } //将迭代出来的class类全部加入classNameList列表 classNameList.add(((String)enumerations.nextElement())); } } } catch(Exception v1) { //出现异常直接退出 goto label_24; } ++i; goto label_8; label_24: //对classNameList进行排序(ASCII码排序) Collections.sort(classNameList); return classNameList; } //将List返回值转为Stirng[],与上述功能一致,不再赘述。 public static String[] getClassNameListArray(ClassLoader classLoader) { ArrayList namelist = EnumerateClass.getClassNameList(classLoader); String[] retval = new String[namelist.size()]; namelist.toArray(((Object[])retval)); return retval; } /** * 获取属性值 * object 对象 * fieldName 属性名称 * return Object 返回的属性值 **/ public static Object getObjectField(Object object, String fieldName) { Class clazz = object.getClass();//读取类 while(!clazz.getName().equals(Object.class.getName())) { //不是Object自己 try { //获取类属性字段 Field field = clazz.getDeclaredField(fieldName); //设置可以访问 field.setAccessible(true); //读取字段值 return field.get(object); } catch(NoSuchFieldException e) { e.printStackTrace(); //出现异常获取父类 clazz = clazz.getSuperclass(); } catch(IllegalAccessException e2) { //再有异常就放弃 e2.printStackTrace(); } } return null; } //加载所有类 public static void loadAllClass(ClassLoader classLoader) { int v6_1;//迭代累加变量 Method[] methods; Class clazz; try { //使用迭代器进行迭代className Iterator v1 = EnumerateClass.getClassNameList(classLoader).iterator(); while(true) { if(!v1.hasNext()) { return; } Object v2 = v1.next(); //获取对应的class类,并使用classLoader进行加载 clazz = classLoader.loadClass(((String)v2)); //获取这个类的所有方法 methods = clazz.getDeclaredMethods(); //输出调试信息 Log.d("FRiDA_UNPACK", "load class: " + clazz.getName()); v6_1 = 0; label_19: //当累加变量小于methods数组长度时,跳转代码块20,实际上label_20可以直接写在里面,此处是反编译工具处理不妥导致。 while(v6_1 < methods.length) { goto label_20; } } label_20://此处循环获取所有method并进行反射调用 //获取指定的method Method method = methods[v6_1]; //获取这个方法的参数类型 Object[] objs = new Object[method.getParameterTypes().length]; Log.d("FRiDA_UNPACK", "try to load method: " + clazz.getName() + "-->" + method.getName()); //反射调用 //invoke(对象实例,参数列表),对象实例为null表示调用静态方法 //调用不是目的,目的是为了让这个类的所有method都执行一遍,frida才好从classloader中提取dex method.invoke(null, objs); Log.d("FRiDA_UNPACK", "success"); ++v6_1; goto label_19; } catch(Throwable v0) { } }}另一种脱壳方案
之前讲过了,安卓的libart.so文件提供了OpenMemory函数来加载dex,如果在加载后,将dex直接写出去,也可达到脱壳效果。
std::unique_ptr<const DexFile> DexFile::OpenMemory(const uint8_t* base, size_t size, const std::string& location, uint32_t location_checksum, MemMap* mem_map, const OatDexFile* oat_dex_file, std::string* error_msg) { CHECK_ALIGNED(base, 4); // various dex file structures must be word aligned std::unique_ptr<DexFile> dex_file( new DexFile(base, size, location, location_checksum, mem_map, oat_dex_file)); if (!dex_file->Init(error_msg)) { dex_file.reset(); } //Start:此处开始加入自己的dex导出代码 __android_log_print(ANDROID_LOG_DEBUG,"DexFile::OpenMemory","size is:%zu,location is:%s", size, location.c_str()); //此处只是针对com.autohome.mycar编写的导出代码,并不具有通用性,通用型的应该有更好的写法 //此处还有一个问题,这个只针对单dex项目写的导出,多dex未做处理 if (!strcmp(location.c_str(),"/data/data/com.autohome.mycar/.jiagu/classes.dex")) { //获取文件指针 int fd = open("/data/data/com.autohome.mycar/classes.dex",O_CREAT|O_EXCL|O_WRONLY,S_IRWXU); __android_log_print(ANDROID_LOG_DEBUG,"copy is starting!","hello"); if (fd>0) write(fd,base,size);//写出dex数据 else __android_log_print(ANDROID_LOG_DEBUG,"copy is failed!","codeis:%d",fd); close(fd); } //End:此处开始加入自己的dex导出代码 return std::unique_ptr<const DexFile>(dex_file.release());}- 理论上这种方案可以脱大部分的壳,不过有一个缺点,需要自己编译rom并刷机。