android逆向奇技淫巧八:apk加壳(二代)和通用脱壳分析
这次同样以T厂的x固加壳为例:为了方便理解,减少不必要的干扰,这里只写了一个简单的apk,在界面静态展示一些字符串,如下:
用x固加壳后,用jadx打开后,先看看AndroidMainfest这个全apk的配置文件:入口是“MyWrapperProxyApplication”;
<application android:theme="@style/AppTheme" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:name="MyWrapperProxyApplication" android:allowBackup="true" android:supportsRtl="true" android:roundIcon="@mipmap/ic_launcher_round" android:appComponentFactory="androidx.core.app.CoreComponentFactory"> <activity android:name="com.example.test.MainActivity"> <intent-filter> <action android:name="android.intent.action.MAIN"/> <category android:name="android.intent.category.LAUNCHER"/> </intent-filter> </activity> </application>
进入这个类查看: 先执行attachBaseContext,得到一个context,然后修复名称,最后初始化proxyApplication;然后执行onCreate,在里面调用了一个名为Ooo0ooO0oO的native方法,这里明显有问题:正常的开发人员会这样取名字?
public abstract class WrapperProxyApplication extends Application { static Context baseContext = null; static String className = "android.app.Application"; static ClassLoader mLoader = null; static Application shellApp = null; static String tinkerApp = "tinker not support"; /* access modifiers changed from: package-private */ public native void Ooo0ooO0oO(); /* access modifiers changed from: protected */ public abstract void initProxyApplication(Context context); static Context getWrapperProxyAppBaseContext() { return baseContext; } private synchronized boolean Fixappname() { if (className.startsWith(".")) { className = super.getPackageName() + className; } else if (className.indexOf(".") < 0) { className = super.getPackageName() + "." + className; } return true; } public static void fixAndroid(Context context, Application application) { if (Build.VERSION.SDK_INT == 28) { try { mLoader = AndroidNClassLoader.inject(context.getClassLoader(), application); } catch (Throwable th) { th.printStackTrace(); } } } private static String getVersionCode(Context context) { try { return context.getPackageManager().getPackageInfo(context.getPackageName(), 0).versionName; } catch (PackageManager.NameNotFoundException e) { e.printStackTrace(); return "0"; } } /* access modifiers changed from: protected */ public void attachBaseContext(Context context) { super.attachBaseContext(context); baseContext = getBaseContext(); if (shellApp == null) { shellApp = this; } Fixappname(); initProxyApplication(context); } public void onCreate() { super.onCreate(); Ooo0ooO0oO(); } }
进入iniProxyApplication函数:这里先是打开一个文件,如果文件打开失败直接退出(换句话说文件打开失败的后果很严重,直接没法运行程序了)!最后加载so库;从整个java代码执行的过程看,解密dex大概率就是从加载这个so开始的了!
public void initProxyApplication(Context context) { ZipFile zipFile; try { zipFile = new ZipFile(context.getApplicationInfo().sourceDir); } catch (IOException e) { e.printStackTrace(); zipFile = null; } if (zipFile == null) { Process.killProcess(Process.myPid()); System.exit(0); } Util.PrepareSecurefiles(context, zipFile); try { zipFile.close(); } catch (IOException e2) { e2.printStackTrace(); } if (Util.CPUABI == "x86") { System.load(context.getFilesDir().getAbsolutePath() + "/prodexdir/" + Util.libname); return; } System.loadLibrary(Util.libname); }
libs目录下面有3个so,很明显最后一个是解密dex的so, 因为第二个只有1K,哪有这么小的so文件!
用IDA打开,先看看segment的情况:貌似比较正常;
在export这里居然找到了jni_onload函数,用graph view查看,发现几百个分支,正常人有这样写代码的嘛? 明显是控制流平坦化了(块之间的分支)
jni_onload参数是V3,根据V3的值走不同的分支:V3的值只有1个,所以只能走1条分支,其他分支都是干扰静态分析的:
为了便于静态跟踪,先把参数改过来:
先把参数a3改成JNIEnv* 试试了,结果发现不对:
把a3改成char* 试试,因为通过观察发现,a3(也就是v5)好多次被当成基址,然后加上某个偏移赋值,并且不同偏移的数据类型还不同,如下:
这里大胆猜测:这有可能是个结构体;除此外,再也找不到vm被使用了;接下来怎么继续分析了? 这里有大量的加密字符串,并在init_array看到了很多异或的解密操作,很有可能是在init_array解密的,所以下一步可以尝试从内存dump这个so,看看这些字符串到底是啥!
运行起来后查pid:9165
把进程在内存的数据全部dump出来:
dump出来的so看看segment:很正常
最重要的是:字符串都解密了成明文的了!
接下来就好分析多了:这里打开一个文件,直观感觉要开始解密文件了!
这个文件刚好在asset目录下,貌似被加密了,而且很小,应该不是重要的文件;
同在asset目录,另一个文件就很大了,有906K,试试这个了:
文件头被抹掉了,从文件大小看,像是被加密的dex了。现在静态分析阶段,暂时无法解密,找找其他的突破口;
上面分析了V5有可能是结构体,红框框这个函数是第一个使用V5的,进去看看了:
这里根据libdvm、libart这些关键的字眼,都能猜到是在获取虚拟机的版本:把版本信息存放在字符串604偏移的地方;
为什么要找这个了?art和dvm是两种不同的dex文件加载方式,所以必须要先确定虚拟机类型,才会对dex进一步做操作(所以这两个分支肯定是成双成对出现的,缺一不可)!所以解密dex的操作可以直接从这里开始分析了,减少了很多需要分析的代码!整个代码用到604偏移的只有3个地方,根据取值不同走不同的分支。我用的的是4.4版本(低版本防护功能弱,利于逆向分析),很显然用的是dvm,所以选择下面这个分支继续:
进入每个函数挨个分析,根据字符串、参数个数等特征,大概猜了一下这些函数和变量的作用,标记到下面了:核心就是找openDexFileNative和openDexFile;
接下来就是关键的代码了:decryptDex(名字是我自己改的),里面有很多calloc函数分配内存,一看就知道要加载dex解密了(三代壳涉及到dex映射、修复和还原);
为了便于理解:这里改个名;这个变量被应用了很多次,每次都是加上一个偏移,就得到函数。然后传入参数就能使用了,疑似JNIEnv* 变量,这里先改成试试:
这里改变量类型失败,重新把jni.h导入,然后再改类型,这现在看起来舒服多了:
静态分析到这里基本基本到头了,再分析也分析不出个啥了,接下来动态调试:找到刚才分析的shell,记住基址,后面会根据偏移定位关键函数和变量;
加上函数的FOA=29DC,绝对地址就是8D2D0000+29DC=8D2D29DC
找到了,下个断点:看红框框的地方,字符串还没被解密了:F9继续执行
字符串被解密了:可以确定init_array肯定在解密字符串:
从这里一路开始F8,来到了分发的地方:这也是这种混淆最头疼的地方:这里有大量的分支,根据取值不同走不同的分支;
这里有绿色的线,说明那是下一步跳转的地方。对于这种控制流平坦化的分支,建议每跳一次,就在ida静态分析时标注一次,方便后续静态分析时剔除杂音!
然后一路F8,终于来到了另一个很重要的函数:偏移是0x3126(这种情况通过静态分析时不可能找得到的,只能通过动态调试找到)
继续动态调试前,先静态分析一下函数大概是干啥的。看不懂的细节再通过动态调试去理解;这里有点经验之谈:前面这些代码考前,而且比价“平坦”,没有较大的分支跳转,按照一般正向开发经验来看,大概率做很多基础性质的工作,比如初始化某些变量,读取某些关键数据,换句话说就是“预处理”;
这里也不像是dex加载到内存:
从这里开始又在判断虚拟机是dvm还是art,两个分支都考虑了;同时前面也注入了classloader,所以这里有可能是在映射dex(这里ida反编译有些小问题,看汇编更直接);
如果android版本不是19,那么只调用sub_ad24一个函数,说明这个函数包含了所有dex的处理逻辑,值得进去看看:这种指针加偏移形式的,很有可能是JNIEnv *,可以转换变量类型试试:
因为mmap有可能是加载dex的函数,所以可以在函数开始的地方下断点,但这里现在末尾下断点,看看此时context的值(尤其时前面几个传参的寄存器):好几次F9后,终于在R0这里看到了希望:
dump到本地看看了:
用jadx打开一看:这又是一个悲伤的故事:关键代码和指令都被抽取了!所以脱壳还未完成,同志仍需努力!说明后面还有指令还原的代码我们并未执行到,所以继续往后分析和调试!
重新回到前面几层:这里有另一个比较关键的sub_5110函数,如下:
进入sub_5110,和jni_onload一个鸟样(甚至更离谱),也被控制流平坦化了,呵呵,又是一个“此地无银三百两”!
老规矩:v3有可能是env,先改type,方便理解代码;
这里明显是通过反射得到java层的一个installdex的方法:
java层的installdex函数在这里:这就容易看懂了吧?通过classloader加载dex的:
这里实锤了sub_5110就是install dex的方法,先改个名,方便辨认;动态调试时在这里下个断点,成功断下;由于1B47C只是dex加载失败后“善后”的功能,这里直接忽略,看代码直接跳转到LABEL_74这里了;
回到sub_CC9C函数,继续往下走:这里调用fork创建子进程,这里也很可疑:一般一个进程自己跑自己的代码,有些并行计算的需求就创建线程单独跑,这里居然新生成一个进程,这种操作不常见,下断点跟踪后发现:在sub_10B8C断下了,这个函数值得进去看看;
本想用老规矩看看有没有被混淆,结果IDA直接提示这个:不用想了,肯定有问题!
F5的代码是这样的,正常人有这么写代码的么?
在这个函数继续下断点的单步跟踪:来到了FB64函数的_aeabi_memcpy这里:为啥要重点关注这个函数了? 前面已经把dex解密dump出来了,但是关键指令还被抽取掉了;要把指令还原,肯定要copy回去呀!
在_aeabi_memcpy这里下个断点(动态调试居然没识别函数名…….),R0就是dex的首地址了,同样导出来:
成功还原dex:
总结:
1、重要的函数都会被混淆,这是一种典型的“此地无银三百两”的行为!所以一旦发现函数被混淆,都建议下个断点调试一下,看看这个函数到底干了啥!
2、字符串、dex、so这些文件被加密,但是在运行的时候肯定会解密,否则app怎么被cpu、android正确运行了? 所以dump内存是必须的步骤,这个一定不能少(这里抓住了两个关键的函数:mmap和_aeabi_memcpy)!本人以前做windows逆向的,很多时候都是直接用CE搜索内存,找到关键数据开始逆向的。android下的逆向也能借鉴类似的思路,后续继续分享!
3、还有个比较明显的通用的dex脱壳处:DexFile,不同版本的系统对这个类的定义可能不完全相同,建议从http://androidxref.cn这里查查类的具体定义,这里以7版本的举例:http://androidxref.cn/android-7.1.2_r39/xref/art/runtime/dex_file.h,头文件里面有两个关键的成员变量,如下:
// The base address of the memory mapping. 1235 const uint8_t* const begin_; 1236 1237 // The size of the underlying memory allocation in bytes. 1238 const size_t size_;
这两个分别是dex文件的指针和dex文件的大小,所以只要能得到这个指针,就能得到内存中解密后的dex文件,就可以dump出来!那么脱壳的问题又转换成了:怎么找到DexFile这个指针了?这个简单,用ida打开libart.so,函数名用DexFile去搜索,能找到一大堆使用了DexFile作为参数的函数,如下:
这里以LoadMethod方法为例,第二个参数就是DexFile,很容易通过hook这个方法得到内存中的dex;然后在根据dex文件头得到整个dex的大小,整个过程简单粗暴,如下:
hook的脚本如下: 这个脚本也能做成通用的dex脱壳方法(注意:4.4-7.0版本的DexFile参数是args[1],8.0-11.0版本的DexFile参数是args[0],其他的都通用);
function getDexFile() { //32 _ZN3art11ClassLinker10LoadMethodERKNS_7DexFileERKNS_21ClassDataItemIteratorENS_6HandleINS_6mirror5ClassEEEPNS_9ArtMethodE //64 _ZN3art11ClassLinker10LoadMethodERKNS_7DexFileERKNS_21ClassDataItemIteratorENS_6HandleINS_6mirror5ClassEEEPNS_9ArtMethodE var loadmethodaddr = Module.getExportByName("libart.so", "_ZN3art11ClassLinker10LoadMethodERKNS_7DexFileERKNS_21ClassDataItemIteratorENS_6HandleINS_6mirror5ClassEEEPNS_9ArtMethodE"); console.log(Process.arch + "--loadmethod:" + loadmethodaddr);//Process.arch:运行模式是32还是64位 Interceptor.attach(loadmethodaddr, { onEnter: function (args) { var dexfileptr = args[1]; console.log("DexFile pointer:" + dexfileptr); var begin_ = ptr(dexfileptr).add(Process.pointerSize).readPointer(); var size_ = ptr(dexfileptr).add(Process.pointerSize * 2).readU32(); console.log(hexdump(ptr(begin_))); console.log("dexfile begin:" + begin_ + "--size:" + size_); //console.log(hexdump(ptr(dexfileptr))); }, onLeave: function (retval) { } }); } setImmediate(getDexFile);
4、个人的一点感悟:windows下3环和0环是严格分开的:3环是一般的exe或dll,0环就是驱动下的sys,有严格的隔离;要想hook操作系统内核,必须通过驱动进入0环;但是android下貌似简单一些:只要手机root,就能hook libart这种系统级别的so库,感觉简单多了!一旦修改系统级别的so库,和修改操作系统的源代码已经没有本质区别了,利用这一点可以做好多有趣的应用!
参考:
1、https://blog.csdn.net/m0_37344790/article/details/79102031 动态调试脱壳