android逆向奇技淫巧八:apk加壳(二代)和通用脱壳分析

时间:2024-02-18 10:03:56

  这次同样以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工具