zoukankan      html  css  js  c++  java
  • 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条分支,其他分支都是干扰静态分析的:

      

       为了便于静态跟踪,先把参数改过来:

      

      vm赋值给了v44,这里改成vm1,便于辨认;jni_onload中,javaVM是最重要的参数(这不废话么?不重要就没必要传进来了):需要先用vm调用GetEnv得到JNIEnv,然后再通过JNIEnv反射获取java类、动态注册native方法vm1只在这里被用到了,而且是3个参数,这到底是个啥函数?又干了啥?

           

           进入这个函数,老规矩,先把第一个JavaVM* 参数改过来,方便分析代码;追踪得知vm赋值给了v5+512;这里像极了JNIEnv* 对象+偏移得到vm对象;

           

          先把参数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函数看看: 看起来还算正常,比jni_onload好看多了!

      

       因为mmap有可能是加载dex的函数,所以可以在函数开始的地方下断点,但这里现在末尾下断点,看看此时context的值(尤其时前面几个传参的寄存器):好几次F9后,终于在R0这里看到了希望:

      

       dump到本地看看了:

       

      用jadx打开一看:这又是一个悲伤的故事:关键代码和指令都被抽取了!所以脱壳还未完成,同志仍需努力!说明后面还有指令还原的代码我们并未执行到,所以继续往后分析和调试!

       

       重新回到前面几层:这里有另一个比较关键的sub_5110函数,如下:

      (1)这个函数的参数和刚才动态调试函数的参数大都是一样的,由此大胆猜测:这两个函数功能类似(否则为啥参数这么像了?)!
      (2)这两个函数在if末尾,并不在前面两个if中,说明很重要,需要无条件执行
      (3)sub_5110这个函数还在其他很多地方被调用了(包括刚才单步动态调试的AD24函数)

       

       进入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   动态调试脱壳

    2、https://github.com/maiyao1988/elf-dump-fix dump工具

  • 相关阅读:
    Linux服务器上监控网络带宽命令
    boost编译很慢的解决方法
    python select poll
    python SocketServer
    boost implicit_cast
    函数名 函数名取地址 区别
    STL make_heap push_heap pop_heap sort_heap
    STL: fill,fill_n,generate,generate_n
    gcc __attribute__
    Linux命令 lsof使用
  • 原文地址:https://www.cnblogs.com/theseventhson/p/14801582.html
Copyright © 2011-2022 走看看