有人的地方就有竞争,在Android的发展过程中就伴随着逆向和安固加固的发展。逆向工作者可以通过一些非常好用的软件,如IDA、JEB等,来加快逆向的速度;应用开发工作者也会通过各种手段来阻止逆向工作者对自己的应用进行逆向。

但是往往情况下逆向是不可能做到百分百阻止的,所有只能通过其他的手段来提高自己应用被逆向时的难度,让逆向工作者需要(可不绕过)花费足够多的时间才能把应用逆向成功。在实际情况下,只要不明显影响应用运行速度,我们都可以采用这种思想来进行保护。

在这种背景下,膨胀与混淆就应运而生了,这就是我们最开始的一种保护方式。这种方式将代码中的方法名和变量名用非常易于混淆的方式进行命名,如0、o、O、l、I、1等组合命名方式。除了这种混淆方式外,为了提高逆向工作量,会在代码中加入相同或者类似方法名的方法或者增加一些没有必要的父类来达到代码膨胀的目的。

但是这种方式不能阻止逆向工作者的脚步,后来开发者发现可以通过DexClassLoader这个类来进行DEX文件的动态加载。我们一般情况下称呼这种加固方式为加壳,由于这种是动态加载DEX文件,所以我们一般可以称为DEX壳。但是Android 主要是由 Java 代码编写的,而 Java 代码是非常容易被逆向分析的,所以渐渐地将动态加载DEX的代码放入 so 层进行运行,so 层的代码主要为c/c++代码,逆向的难度比 Java 会高很多,提高了应用的安全性。

随着这种加固方式的不断普及,这种方式已经不能阻挡大多数的逆向工作了,加固人员就需要一种新的加固方式来和逆向进行对抗。后期不管是加固还是逆向,都将目光放在了so层的对抗,此时就发现了一种利用elf文件格式(Android中.so的共享库为elf文件格式)来进行加固的方法。

在so中,自己定义一个节,在这个节中存放我们的一些关键功能代码,通过elf文件格式将这部分的代码进行加密,然后在elf文件加载执行初始化函数数组时,将被加密的代码解密出来。

加壳或者其他加固的优势: 能在一定程度上保护自己核心代码算法,提高破解、盗版或者二次打包的难度,还可以防范代码注入、动态调试、内存注入攻击。

加壳或者其他加固的劣势: 从理论上来说,只要加了保护都可能会影响应用的兼容性、运行效率。

由于受Android手机的电池、CPU等硬件的限制,一般的应用都不可能像PC上进行强度非常大的保护。

混淆、膨胀

混淆

主题思想:用没有意义的字符,如a、b、c或者易于混淆的字符,如0、o、O、l、I、1代替原本的有意义的类名。

参数配置: 将release下的minifyEnabled的值改为true,打开混淆;加上shrinkResources true,打开资源压缩。

#压缩级别0-7,Android一般为5(对代码迭代优化的次数)
-optimizationpasses 5#不使用大小写混合类名
-dontusemixedcaseclassnames#混淆时记录日志
-verbose#不警告org.greenrobot.greendao.database包及其子包里面未应用的应用
-dontwarn org.greenrobot.greendao.database.**
-dontwarn rx.**
-dontwarn org.codehaus.jackson.**......
#保持jackson包以及其子包的类和类成员不被混淆
-keep class org.codehaus.jackson.** {*;}
#---------重要说明-------
#-keep class 类名 {*;}
#-keepclassmembers class 类名{*;}
#一个*表示保持了该包下的类名不被混淆;
# -keep class org.codehaus.jackson.*
# 二个**表示保持该包以及它包含的所有子包下的类名不被混淆
# -keep class org.codehaus.jackson.**
#---------------------------------
#保持类名、类里面的方法和变量不被混淆
-keep class org.codehaus.jackson.** {*;|
#不混淆类ClassTwoOne的类名以及类里面的public成员和方法
#public 可以换成其他java属性如private、public static、final等
#还可以使<init>表示构造方法、<methods>表示方法、<fields>表示成员,
#这些前面也可以加public等java属性限定
-keep class com.dev.demo.two.ClassTwoOne {public *;
}
#不混淆类型,以及里面的构造函数
-keep class com.dev.demo.ClassOne {public <init>();
}
#不混淆类名,以及里面的构造函数
-keep class com.dev.demo.ClassOne {public <init>();
}
#不混淆类名,以及参数为int的构造函数
-keep class com.dev.demo.two.ClassTwoTwo {public <init>(int);
}
#不混淆类的public修饰的方法,和private修饰的变量
-keepclassmembers class com.dev.demo.two.ClassTwoThree {public <methods>;private <fields>;
}#不混淆内部类,需要用$修饰
#不混淆内部类ClassTwoTwoInner以及里面的全部成员
-keep class com.dev.demo.two.ClassTwoTwo$ClassTwoTwoInner{*;}

更多混淆配置参考:

https://juejin.cn/post/6844903471095742472

https://www.huaweicloud.com/articles/ae151e2f60923097cefc473bd131addf.html

膨胀

代码混淆能在一定程度上增加逆向的难度,但是给逆向工作者增加的工作量是比较小的,代码膨胀就能够增加总的代码量,让逆向工作者必须分析全部的代码才能得到最终的一些结果。代码膨胀也是初始防御的方法之一,主要思想是编写一些垃圾代码来扩充代码量,这样在逆向分析时就可能会消耗攻击者大量的时间,从而达到保护APK的目的。

膨胀代码有很多种实现的思想。比如乘法改加法、加法改自加等等,只要把代码量变大,不影响功能的实现就可以了。

这里自己写了一个简单的自动生成代码的工程。

https://gitee.com/koifishly/function_generator

DEX壳

之前Android的主要代码为Java代码,但是在逆向分析中,Java代码是很容易被分析出来的。为了解决这个问题,我们就希望在app运行起来后动态加载我们Java代码(.dex文件)。这种方法主要利用了DexClassLoader这个类来实现动态加载。DexClassLoader类支持动态加载.apk或者.dex。

动态加载APK

动态加载apk简单来说,就是将已经编译好的.apk文件放入到一个.dex文件中。这个.apk文件为我们真正的应用程序,以下啊就成为这个apk为源APK; .dev文件为另外一个工程的.dex文件,这个工程主要是为了在运行时释放出源APK,然后将流程转到源APK执行。

根据上面的原理图,我们需要3个对象.

  • 源APK: 需要加壳的apk。
  • 壳APK: 将apk解密还原并执行。
  • 加密工具: 将源apk和壳dex进行组合成新的dex并且修正新的dex。

项目实现demo代码

IDE:Android Studio 4.1.3

Android版本:4.4+

项目源码:nisosaikou/AndroidDEX壳 - 码云 - 开源中国 (gitee.com)

源APK

1、正常编写功能逻辑代码。这里的代码为简单的ctf判断代码。

2.新建类APP类并且这个类继承于类Application,实现onCreate方法。

3.生成一个release版本apk,把这个apk保存起来。

修改MainActivity.java的父类,使得MainActivity继承于Activity。将文本显示修改为运行的是源APK。

壳APK

Proxy.java

新建一个代理类叫Proxy,继承于类Application。这个类用来释放和解密原始的APK。

attachBaseContext()

重写Application中的attachBaseContext方法。这个方法会在Activity的onCreate方法之前执行。

方法实现的功能主要有:

把壳dex中包含的源apk释放出来。

把释放的apk进行加密。

把源apk中的lib目录中的文件复制到当前程序(壳)的路径下。

创建一个新的DexClassLoader,替换到父节点的DexClassLoader。

DexClassLoader继承自BaseDexClassLoader,这个比较灵活,每个参数都可以自定义,我们一般用这个来加载自定义的apk/dex/jar文件。

代码例子:


@Override
protected void attachBaseContext(Context base) {super.attachBaseContext(base);// the getDir method will create a directory in /data/user/0(uid)/packagename/// the dx directory holds the file of the source apkFile relesaeDir = this.getDir("dx", MODE_PRIVATE);mSouceAPKLibAbsolutePath = this.getDir("lx", MODE_PRIVATE).getAbsolutePath();mSourceAPKReleaseDir = relesaeDir.getAbsolutePath();mSourceAPKAbsolutePath = mSourceAPKReleaseDir + "/" + mSouceAPKName;// create the source apk// if the source apk exist, do nothing, otherwise create the source apk file.File sourceApk = new File(mSourceAPKAbsolutePath);if (!sourceApk.exists()){try{sourceApk.createNewFile();} catch (Exception e) {Log.e(TAG, "failed to create file.");}// the source apk file is empty, you need to read source apk file from the dex// file of the shell apk and save it.byte[] shellDexData;// get dex of shell apk.shellDexData = getShellDexFileFromShellApk();// get the source apk and decrypt it.// copy the libs in the decrypted apk file to the lib directory.getSourceApkFile(shellDexData);}// Configure dynamic load environmentObject currentActivityThread = RefInvoke.invokeStaticMethod("android.app.ActivityThread", "currentActivityThread", new Class[] {}, new Object[] {});String packageName = this.getPackageName();ArrayMap mPackages = (ArrayMap) RefInvoke.getFieldOjbect("android.app.ActivityThread", currentActivityThread, "mPackages");WeakReference weakReference = (WeakReference) mPackages.get(packageName);DexClassLoader newDexClassLoader = new DexClassLoader(mSourceAPKAbsolutePath, mSourceAPKReleaseDir, mSouceAPKLibAbsolutePath, (ClassLoader) RefInvoke.getFieldOjbect("android.app.LoadedApk", weakReference.get(), "mClassLoader"));RefInvoke.setFieldOjbect("android.app.LoadedApk", "mClassLoader", weakReference.get(), newDexClassLoader);
}

onCreate()

加载源apk资源

获取mainifest.xml中记录的源apk的启动类名。

设置ActivityThread信息(android.app.ActivityThread->currentActivityThread)。

代码例子:

@Override
public void onCreate() {super.onCreate();// 源apk启动类String srcAppClassName = "";// 原apk所在路径try{ApplicationInfo applicationInfo = this.getPackageManager().getApplicationInfo(this.getPackageName(), PackageManager.GET_META_DATA);Bundle bundle = applicationInfo.metaData;if (bundle != null && bundle.containsKey(SRC_APP_MAIN_ACTIVITY)) {srcAppClassName = bundle.getString(SRC_APP_MAIN_ACTIVITY);//className 是配置在xml文件中的。}else {return;}}catch (Exception e){}//获取ActivityThread类下AppBindData类的成员属性 LoadedApk info;Object currentActivityThread = RefInvoke.invokeStaticMethod("android.app.ActivityThread", "currentActivityThread", new Class[] {}, new Object[] {});Object mBoundApplication = RefInvoke.getFieldOjbect("android.app.ActivityThread", currentActivityThread, "mBoundApplication");Object loadedApkInfo = RefInvoke.getFieldOjbect("android.app.ActivityThread$AppBindData", mBoundApplication, "info");// 将原来的loadedApkInfo置空RefInvoke.setFieldOjbect("android.app.LoadedApk", "mApplication", loadedApkInfo, null);// 获取壳线程的ApplicationObject oldApplication = RefInvoke.getFieldOjbect("android.app.ActivityThread", currentActivityThread, "mInitialApplication");ArrayList<Application> mAllApplications = (ArrayList<Application>) RefInvoke.getFieldOjbect("android.app.ActivityThread", currentActivityThread, "mAllApplications");mAllApplications.remove(oldApplication);// 构造新的Application// 1.更新 2处classNameApplicationInfo appinfo_In_LoadedApk = (ApplicationInfo) RefInvoke.getFieldOjbect("android.app.LoadedApk", loadedApkInfo, "mApplicationInfo");ApplicationInfo appinfo_In_AppBindData = (ApplicationInfo) RefInvoke.getFieldOjbect("android.app.ActivityThread$AppBindData", mBoundApplication, "appInfo");appinfo_In_LoadedApk.className = srcAppClassName;appinfo_In_AppBindData.className = srcAppClassName;// 2.注册applicationApplication app = (Application) RefInvoke.invokeMethod("android.app.LoadedApk", "makeApplication", loadedApkInfo, new Class[] { boolean.class, Instrumentation.class }, new Object[] { false, null });//替换ActivityThread中的mInitialApplicationRefInvoke.setFieldOjbect("android.app.ActivityThread", "mInitialApplication", currentActivityThread, app);//替换之前的 内容提供者为刚刚注册的appArrayMap mProviderMap = (ArrayMap) RefInvoke.getFieldOjbect("android.app.ActivityThread", currentActivityThread, "mProviderMap");Iterator it = mProviderMap.values().iterator();while (it.hasNext()) {Object providerClientRecord = it.next();Object localProvider = RefInvoke.getFieldOjbect("android.app.ActivityThread$ProviderClientRecord", providerClientRecord, "mLocalProvider");RefInvoke.setFieldOjbect("android.content.ContentProvider", "mContext", localProvider, app);}app.onCreate();
}

ActivityThread功能

它管理应用进程的主线程的执行(相当于普通Java程序的main入口函数),并根据AMS的要求(通过IApplicationThread接口,AMS为Client、ActivityThread.ApplicationThread为Server)负责调度和执行activities、broadcasts和其他操作。

在Android系统中,在默认情况下,一个应用程序内的各个组件(如Activity、BroadcastReceiver、Service)都会在同一个进程(Process)里执行,且由此进程的【主线程】负责执行。

在Android系统中,如果有特别指定(通过android:process),也可以让特定组件在不同的进程中运行。无论组件在哪一个进程中运行,默认情况下,他们都由此进程的【主线程】负责执行。

【主线程】既要处理Activity组件的UI事件,又要处理Service后台服务工作,通常会忙不过来。为了解决此问题,主线程可以创建多个子线程来处理后台服务工作,而本身专心处理UI画画的事件。

类结构参考

调用currentActivityThread方法获取ActivityThread中的成员变量sCurrentActivityThread。

Object currentActivityThread = RefInvoke.invokeStaticMethod("android.app.ActivityThread", "currentActivityThread", new Class[] {}, new Object[] {});

获取sCurrentActivityThread中的mBoundApplication。

Object mBoundApplication = RefInvoke.getFieldObject("android.app.ActivityThread", currentActivityThread, "mBoundApplication");

获取mBoundApplication中成员变量info。

Object loadedApkInfo = RefInvoke.getFieldObject("android.app.ActivityThread$AppBindData", mBoundApplication, "info");

观察LoadedApk这个类,能发现一些重要的属性,这个下面会用到。

将info中的mApplication属性置空。

RefInvoke.setFieldObject("android.app.LoadedApk", "mApplication", loadedApkInfo, null);

在sCurrentActivityThread下的链表mAllApplications中移除mInitialApplication。mInitialApplication存放初始化的应用(当前壳应用),mAllApplications存放的是所有的应用。

把当前的应用,从现有的应用中移除掉,然后再把新构建的加入到里面去。

Object oldApplication = RefInvoke.getFieldOjbect("android.app.ActivityThread", currentActivityThread, "mInitialApplication");ArrayList<Application> mAllApplications = (ArrayList<Application>) RefInvoke.getFieldOjbect("android.app.ActivityThread", currentActivityThread, "mAllApplications");mAllApplications.remove(oldApplication);

构造新的Application

更新2处className。

ApplicationInfo appinfo_In_LoadedApk = (ApplicationInfo) RefInvoke.getFieldOjbect("android.app.LoadedApk", loadedApkInfo, "mApplicationInfo");ApplicationInfo appinfo_In_AppBindData = (ApplicationInfo) RefInvoke.getFieldOjbect("android.app.ActivityThread$AppBindData", mBoundApplication, "appInfo");appinfo_In_LoadedApk.className = srcAppClassName;appinfo_In_AppBindData.className = srcAppClassName;

注册application(用LoadedApk中的makeApplication方法注册)。

Application app = (Application) RefInvoke.invokeMethod("android.apo.LoadedApk", "makeApplication", loadedApkInfo, new Class[] { boolean.class, Instrumentation.class }, new Object[] {false, null});

替换mInitialApplication为刚刚创建的app。

RefInvoke.setFieldObject("android.app.ActivityThread", "mInitialApplication",currentActivityThread, app);

更新ContentProvider。

ArrayMap mProviderMap = (ArrayMap) RefInvoke.getFieldObject("android.app.ActivityThread", currentActivityThread, "mProviderMap");Iterator it = mProviderMap.values().iterator();
while (it.hasNext()) {Object providerClientRecord = it.next();Object localProvider = RefInvoke.getFieldObject("android.app.ActivityThread$ProviderClientRecord", providerClientRecord, "mLocalProvider");RefInvoke.setFieldObject("android.content.ContentProvider", "mContext", localProvider, app);
}

执行新app的onCreate方法。

app.onCreate();

RefInvoke.java

Java反射调用的方法。

package org.koi.dexloader;import java.lang.reflect.Field;
import java.lang.reflect.Method;public class RefInvoke {public static Object invokeStaticMethod(String class_name, String method_name, Class[] pareType, Object[] pareValues) {try {Class obj_class = Class.forName(class_name);Method methodd = obj_class.getMethod(method_name, pareTyple);return method.invoke(null, pareValues);} catch (Exception e) {e.printStackTrace();}return null;}public static Object getFieldObject(String class_name, Object obj, String fieldName) {try {Class obj_class = Class.forName(class_name);Field field = obj_class.getDeclaredField(fieldName);field.setAccessible(true);} catch (Exception e) {e.printStackTrace();}return null;}public static void setFieldObject(String classname, String fieldName, Object obj, Object fieldValue) {try {Class obj_class = Class.forName(classname);Field field = obj_class.getDeclaredField(fieldName);field.setAccessible(true);field.set(obj, fieldValue);} catch (Exception e) {e.printStackTrace();}}public static Object invokeMethod(String class_name, String method_name, Object obj, Class[] pareTyple, Object[] pareValues) {try {Class obj_class = Class.forName(class_name);Method method = obj_class.getMethod(method_name, pareTyple);return method.invoke(obj, pareValues);} catch (Exception e) {e.printStackTrace();}return null;}}

AndroidManifest.xml

<?xml version="1.0" encoding="utf-8"?><manifest xmlns:android="http://schemas.android.com/apk/res/android"    package="org.koi.dexloader">     <application        android:allowBackup="true"        android:icon="@mipmap/ic_launcher"        android:label="@string/app_name"        android:roundIcon="@mipmap/ic_launcher_round"        android:supportsRtl="true"        android:name=".Proxy"        android:theme="@style/Theme.DexLoader">        <meta-data            android:name="APPLICATION_CLASS_NAME"            android:value="org.koi.ctf20200802.APP"/>        <activity android:name="org.koi.ctf20200802.MainActivity">            <intent-filter>                <action android:name="android.intent.action.MAIN" />                 <category android:name="android.intent.category.LAUNCHER" />            </intent-filter>        </activity>    </application> </manifest>

关于资源的问题

到目前为止,源程序能够运行起来了,但是apk在运行的时候肯定会用到相关的资源,如布局文件等等,我们并没有介绍如何处理资源。

资源有2种大的处理方法。第一种是在壳dex解压出源apk时,把apk中的资源复制到现在程序下。第二种是替换壳apk中dex文件时,顺便用源apk中的资源文件替换到壳apk中。因为本文不重点讨论资源的处理问题,所以采用第二种方法,直接复制替换资源即可。

Dex组合修复工具

将APK和壳DEX文件合并,生成一个新的DEX文件,并且校正新的DEX文件头。

加壳步骤

  • src.apk: 源APK。

  • des.apk: 壳APK。

  • DexFixed.jar: Dex工具

  • classes.dex: des.apk的classes.dex。

  • res: 源APK中的文件夹。

  • resources.arsc: 源APK中的文件。

1、用DEXFixed.jar工具把src.apk和classes.dex进行合并,生成一个新的Dex,替换到壳APK中。

2、替换壳APK中的classes.dex、res、resources.arsc。

3、apk重新签名。

4、正常运行。

总结

dex壳是比较基础的壳,只是将源APK加密后放入dex文件中,在运行时进行释放。我们只需要在壳程序解密出原始的APK运行后,在内存中把dexdump下来就可以了,我们也可以用frida框架进行脱壳。

动态加载DEX(Java)

我们在上面动态加载APK时采用了两个工程,一个工程负责加载APK,一个负责业务流程,业务流程工程核心文件就是一个dex文件,可以考虑只将dex文件作为附件,然后进行动态加载dex。

项目实现demo代码

简单来说,这里存放git的链接。

源工程

  • 新建一个简单功能的 Android 工程。

  • 创建assets文件夹。

保存编译之后apk文件中的.dex文件,把.dex文件保存到assets目录下。dex文件重命名为origin.dex(可以重命名为任意文件名)。

删除MainActivity.java。注意:这里只删除源文件,不要删除Activity。

加密DEX

新建一个Java工程实现一个简单的加密。

import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.FileInputStream;
import java.io.FileOutputStream;public class Main {public static void main(String[] args) {if (args.length != 2) {System.out.println("jar : <source file> <encrypted file>");return;}String sourceFile = args[0];String encryptedFile = args[1];try {FileInputStream fis = new FileInputStream(sourceFile);BufferedInputStream bis = new BufferedInputStream(fis);FileOutputStream fos = new FileOutputStream(encryptedFile);BufferedOutputStream bos = new BufferedOutputStream(fos);byte[] buffer = new byte[10240];int acount = 0;while ((acount = bis.read(buffer)) != -1) {}bos.flush();//关闭的时候只需要关闭最外面的流就行了bos.close();bis.close();} catch (Exception e) {e.printStackTrace();}}public static byte[] encrypt(byte[] sourceData) {for (int i=0; i<sourceData.length; i++) {sourceData[i] ^= 273;}return sourceData;}
}

把得到的加密文件放入刚刚创建的assets目录下。

把重命名后的文件可以通过加密后再放入assets目录下,然后再加载dex前进行解密。

壳工程

这里壳工程就在源工程的基础上修改就可以了,不需要在新建一个工程。

分别创建ProxyApplication.java和RefInvoke.java。这两个类的代码和上面基本一样,这里就不赘述了,直接看代码。

ProxyApplication.java

package org.koi.ctf20210813;import android.app.Application;
import android.content.Context;
import android.util.ArrayMap;import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.lang.ref.WeakReference;import dalvik.system.DexClassLoader;public class P extends Application {private final static String encryptedFileName = "flag";private final static String package_name = "org.koi.ctf20210813";private final static String activity_thread = "android.app.ActivityThread";private final static String current_activity_thread = "currentActivityThread";@Overrideprotected void attachBaseContext(Context base) {super.attachBaseContext(base);try {File cacheDir = getCacheDir();if (!cacheDir.exists()){cacheDir.mkdirs();}File outFile = new File(cacheDir, "out.dex");InputStream is = getAssets().open(encryptedFileName);FileOutputStream fos = new FileOutputStream(outFile);byte[] buffer = new byte[1024];int byteCount;while ((byteCount = is.read(buffer)) != -1) {buffer = decrypt(buffer);fos.write(buffer, 0, byteCount);}fos.flush();is.close();fos.close();String file_abs_path = outFile.getAbsolutePath();Object currentActivityThread = I.invokeStaticMethod(activity_thread, current_activity_thread, new Class[]{}, new Object[]{});ArrayMap mPackages =  (ArrayMap)I.getFieldOjbect(activity_thread, currentActivityThread, "mPackages");WeakReference weakReference = (WeakReference) mPackages.get(package_name);ClassLoader parent = (ClassLoader)I.getFieldOjbect("android.app.LoadedApk", weakReference.get(), "mClassLoader");DexClassLoader dLoader = null;File dexOpt = base.getDir("dexOpt", base.MODE_PRIVATE);dLoader = new DexClassLoader(file_abs_path, dexOpt.getAbsolutePath(), null, parent);I.setFieldOjbect("android.app.LoadedApk", "mClassLoader", weakReference.get(), dLoader);} catch (IOException e) {e.printStackTrace();}}public static byte[] decrypt(byte[] sourceData){for (int i = 0; i < sourceData.length; i++){sourceData[i] ^= 273;}return sourceData;}@Overridepublic void onCreate() {super.onCreate();}
}

DexClassLoader加载Dex文件:


DexClassLoader(dexPath, optimizedDirectory, libraryPath, parent)dexPath:目标类所在的APK或者jar包,/.../xxx.jaroptimizedDirectory:从APK或者jar解压出来的dex文件存放路径libraryPath:native库路径,可以为nullparent:父类装载器,一般为当前类的装载器、

RefInvoke.java


package org.koi.ctf20210813;import java.lang.reflect.Field;
import java.lang.reflect.Method;public class I {public static  Object invokeStaticMethod(String class_name, String method_name, Class[] pareTyple, Object[] pareVaules){try {Class obj_class = Class.forName(class_name);Method method = obj_class.getMethod(method_name,pareTyple);return method.invoke(null, pareVaules);} catch (Exception e) {e.printStackTrace();}return null;}public static Object getFieldOjbect(String class_name,Object obj, String filedName){try {Class obj_class = Class.forName(class_name);Field field = obj_class.getDeclaredField(filedName);field.setAccessible(true);return field.get(obj);} catch (Exception e) {e.printStackTrace();}return null;}public static void setFieldOjbect(String classname, String filedName, Object obj, Object filedVaule){try {Class obj_class = Class.forName(classname);Field field = obj_class.getDeclaredField(filedName);field.setAccessible(true);field.set(obj, filedVaule);} catch (Exception e) {e.printStackTrace();}}public static  Object invokeMethod(String class_name, String method_name, Object obj ,Class[] pareTyple, Object[] pareVaules){try {Class obj_class = Class.forName(class_name);Method method = obj_class.getMethod(method_name,pareTyple);return method.invoke(obj, pareVaules);} catch (Exception e) {e.printStackTrace();}return null;}
}

AndroidManifest.xml

确认删除MainActivity.java,然后修改AndroidManifest.xml。

这样在执行时能解密原来dex文件。

APK中的DEX文件中,不包含重要代码。

动态加载DEX(SO)

在上面的基础上,想到可以把ProxyApplication.java和RefInvoke.java中的主要代码移到so中来运行,这就是我们这种壳的主要思路。和上面的实现方式是一样的,只是换到lib中运行而已。

创建一个Android 原生工程,和上面一样,在MainActivity中写一些简单代码。把dex文件加密后放入assets文件夹中。

新建ProxyApplication类,继承Application,把加载Dex这部分代码提取出来放入到一个新的类AttackBaseContent中。

ProxyApplication.java

import android.app.Application;
import android.content.Context;public class P extends Application {static {System.loadLibrary("ctf20210814");}@Overrideprotected void attachBaseContext(Context base) {super.attachBaseContext(base);attachBase(base);}@Overridepublic void onCreate() {super.onCreate();}public static native void attachBase(Context base);
}

这里新建一个koi.cpp文件,其中有一个Java_org_koi_dexsoshell_AttachBaseContext_onAttach函数,对应Java中AttachBaseContext类下的onAttach方法。

native-lib.cpp

#include <jni.h>
#include <string>// only update here
#define ENCRYPTED_FILE_NAME         "flag"
#define DECRYPTED_FILE_NAME         "ot.dex"
#define PACKAGE_NAME                "org.koi.ctf20210814"extern "C"
JNIEXPORT void JNICALL
Java_org_koi_ctf20210814_P_attachBase(JNIEnv *env, jclass clazz, jobject base) {jclass clz_File = env->FindClass("java/io/File");jclass clz_Context = env->FindClass("android/content/Context");jclass clz_AssetManager = env->FindClass("android/content/res/AssetManager");jclass clz_InputStream = env->FindClass("java/io/InputStream");jclass clz_FileOutputStream = env->FindClass("java/io/FileOutputStream");jclass clz_ActivityThread = env->FindClass("android/app/ActivityThread");jclass clz_ArrayMap = env->FindClass("android/util/ArrayMap");jclass clz_WeakReference = env->FindClass("java/lang/ref/WeakReference");jclass clz_LoadedApk = env->FindClass("android/app/LoadedApk");jclass clz_DexClassLoader = env->FindClass("dalvik/system/DexClassLoader");jmethodID mid_File_init = env->GetMethodID(clz_File, "<init>","(Ljava/io/File;Ljava/lang/String;)V");jmethodID mid_FileOutputStream_init = env->GetMethodID(clz_FileOutputStream, "<init>","(Ljava/io/File;)V");jmethodID mid_DexClassLoader_init = env->GetMethodID(clz_DexClassLoader, "<init>","(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/ClassLoader;)V");jmethodID mid_Context_getCacheDir = env->GetMethodID(clz_Context, "getCacheDir","()Ljava/io/File;");jmethodID mid_Context_getAssets = env->GetMethodID(clz_Context, "getAssets","()Landroid/content/res/AssetManager;");jmethodID mid_Context_getDir = env->GetMethodID(clz_Context, "getDir","(Ljava/lang/String;I)Ljava/io/File;");jmethodID mid_AssetManager_open = env->GetMethodID(clz_AssetManager, "open","(Ljava/lang/String;)Ljava/io/InputStream;");jmethodID mid_File_exists = env->GetMethodID(clz_File, "exists", "()Z");jmethodID mid_File_mkdirs = env->GetMethodID(clz_File, "mkdirs", "()Z");jmethodID mid_File_getAbsolutePath = env->GetMethodID(clz_File, "getAbsolutePath","()Ljava/lang/String;");jmethodID mid_InputStream_read = env->GetMethodID(clz_InputStream, "read", "([B)I");jmethodID mid_InputStream_close = env->GetMethodID(clz_InputStream, "close", "()V");jmethodID mid_InputStream_available = env->GetMethodID(clz_InputStream, "available", "()I");jmethodID mid_FileOutputStream_write = env->GetMethodID(clz_FileOutputStream, "write","([BII)V");jmethodID mid_FileOutputStream_flush = env->GetMethodID(clz_FileOutputStream, "flush", "()V");jmethodID mid_FileOutputStream_close = env->GetMethodID(clz_FileOutputStream, "close", "()V");jmethodID mid_ActivityThread_currentActivityThread = env->GetStaticMethodID(clz_ActivityThread,"currentActivityThread","()Landroid/app/ActivityThread;");jmethodID mid_ArrayMap_get = env->GetMethodID(clz_ArrayMap, "get","(Ljava/lang/Object;)Ljava/lang/Object;");jmethodID mid_WeakReference_get = env->GetMethodID(clz_WeakReference, "get","()Ljava/lang/Object;");jfieldID fid_ActivityThread_mPackages = env->GetFieldID(clz_ActivityThread, "mPackages","Landroid/util/ArrayMap;");jfieldID fid_LoadedApk_mClassLoader = env->GetFieldID(clz_LoadedApk, "mClassLoader","Ljava/lang/ClassLoader;");try {jobject cacheDir = env->CallObjectMethod(base, mid_Context_getCacheDir);if (!env->CallBooleanMethod(cacheDir, mid_File_exists)) {env->CallBooleanMethod(cacheDir, mid_File_mkdirs);}jstring str = env->NewStringUTF(DECRYPTED_FILE_NAME);jobject outFile = env->NewObject(clz_File, mid_File_init, cacheDir, str);jobject AssetManager = env->CallObjectMethod(base, mid_Context_getAssets);jstring out_file_name = env->NewStringUTF(ENCRYPTED_FILE_NAME);jobject is = env->CallObjectMethod(AssetManager, mid_AssetManager_open, out_file_name);jobject fos = env->NewObject(clz_FileOutputStream, mid_FileOutputStream_init, outFile);jint file_size = env->CallIntMethod(is, mid_InputStream_available);jbyteArray buffer = env->NewByteArray(file_size);env->CallIntMethod(is, mid_InputStream_read, buffer); //readjbyte* p_bt_ary = (jbyte*)env->GetByteArrayElements(buffer, 0);// here you can add decryption function.for (jint i = 0; i < file_size; ++i) {p_bt_ary[i] ^= 273;}env->SetByteArrayRegion(buffer, 0, file_size, p_bt_ary);env->CallVoidMethod(fos, mid_FileOutputStream_write, buffer, 0, file_size);env->DeleteLocalRef(buffer);env->CallVoidMethod(fos, mid_FileOutputStream_flush);env->CallVoidMethod(is, mid_InputStream_close);env->CallVoidMethod(fos, mid_FileOutputStream_close);jstring file_abs_path = (jstring) env->CallObjectMethod(outFile, mid_File_getAbsolutePath);jobject currentActivityThread = env->CallStaticObjectMethod(clz_ActivityThread,mid_ActivityThread_currentActivityThread);jobject mPackages = env->GetObjectField(currentActivityThread,fid_ActivityThread_mPackages);jstring package_name = env->NewStringUTF(PACKAGE_NAME);jobject weakReference = env->CallObjectMethod(mPackages, mid_ArrayMap_get, package_name);jobject loadedApk = env->CallObjectMethod(weakReference, mid_WeakReference_get);jobject parent = env->GetObjectField(loadedApk, fid_LoadedApk_mClassLoader);jstring jstr_dexOpt = env->NewStringUTF("dexOpt");jobject dexOpt = env->CallObjectMethod(base, mid_Context_getDir, jstr_dexOpt, 0);jstring dexOpt_abs_path = (jstring) env->CallObjectMethod(dexOpt, mid_File_getAbsolutePath);jstring str_null = env->NewStringUTF("");jobject dLoader = env->NewObject(clz_DexClassLoader, mid_DexClassLoader_init, file_abs_path,dexOpt_abs_path, str_null, parent);env->SetObjectField(loadedApk, fid_LoadedApk_mClassLoader, dLoader);} catch (...) {}
}

这个例子中,进行加密解密的操作,可以根据实际情况进行修改。

JNI中有部分代码可以提取到JNI_OnLoad或者initarray中进行处理。

JNI中的所有字符串可以进行一些处理,不直接暴露在源码中。

AndroidManifest.xml

按照上面的方法进行修改。

注意: 关闭minifyEnabled。

ELF文件壳

在学习这部分内容之前需要熟悉ELF的文件格式。

ELF节加密

主要思想

编写代码时:自定义一个代码节(.mytext)(以后要进行加密,现在没有处理),然后一个初始化函数(.init_array),在这个函数中找到elf文件加载到内存中的地址,然后根据elf文件格式找到.mytext节,对这个节的内容进行解密。

加密:在原始apk编译好后,利用自己写的代码,把目标lib中的.mytext进行加密。

最后进行签名。

代码

创建一个ndk项目。编写一段代码放入自定义的(代码)节.koitext中。

用__attribute__((section(".koitext")))来指定节。


#include <jni.h>
#include <string>#define SECTION_NAME ".koitext"#define JNIHIDDEN __attribute__((visibility("hidden")))// save the result.
int fw[40] = {13, 18, 14, 64, 11, 65, 16, 14, 20, 14, 11, 14, 18,61, 12, 13, 60, 60, 20, 62, 16, 61, 61, 64, 63, 63, 15, 18, 12, 63, 14, 64,13, 18, 14, 64, 11, 65, 16, 14};
int fs[38];
void str2ints (const char* fw, int* results);
char* jstring2charAry(JNIEnv* env, jstring jstr);extern "C"
JNIEXPORT __attribute__((section(SECTION_NAME))) jboolean JNICALL
Java_org_koi_ctf20210821_MainActivity_checkflag(JNIEnv *env, jobject thiz, jstring flag) {char fg[]="flag{helloboy_ewri346hHeewr34dr}";str2ints(jstring2charAry(env, flag), fs);for (int i = 0; i < strlen(fg); ++i) {if(fw[i] != fs[i] )return false;}return true;
}__attribute__((section(SECTION_NAME))) void str2ints (const char* fw, int* results)
{for (int mX4WyHKgmwSPY1V = 0; mX4WyHKgmwSPY1V < 32; mX4WyHKgmwSPY1V++){results[mX4WyHKgmwSPY1V] = fw[mX4WyHKgmwSPY1V];}for (int _ZKdmdmEjiQ_Ouw = 0; _ZKdmdmEjiQ_Ouw < 32; _ZKdmdmEjiQ_Ouw++){results[_ZKdmdmEjiQ_Ouw] = results[_ZKdmdmEjiQ_Ouw] + 3;}for (int zbTK_I56tB0GevN = 0; zbTK_I56tB0GevN < 32; zbTK_I56tB0GevN++){results[zbTK_I56tB0GevN] = results[zbTK_I56tB0GevN] + 10;}for (int DfeXBWD6dcNPXKo = 0; DfeXBWD6dcNPXKo < 32; DfeXBWD6dcNPXKo++){results[DfeXBWD6dcNPXKo] = results[DfeXBWD6dcNPXKo] - 58;}for (int jqJhXnPQwPYi2G6 = 0; jqJhXnPQwPYi2G6 < 32; jqJhXnPQwPYi2G6++){results[jqJhXnPQwPYi2G6] = results[jqJhXnPQwPYi2G6] - 66;}for (int xA7fCVlKruHZC4Y = 0; xA7fCVlKruHZC4Y < 32; xA7fCVlKruHZC4Y++){results[xA7fCVlKruHZC4Y] = results[xA7fCVlKruHZC4Y] + 66;}for (int sGVbaq_poAxfJ3O = 0; sGVbaq_poAxfJ3O < 32; sGVbaq_poAxfJ3O++){results[sGVbaq_poAxfJ3O] = results[sGVbaq_poAxfJ3O] + 8;}for (int EIGWrEGI6UaAjH8 = 0; EIGWrEGI6UaAjH8 < 32; EIGWrEGI6UaAjH8++){results[EIGWrEGI6UaAjH8] = results[EIGWrEGI6UaAjH8] + 49;}for (int nHJAUmNRoQs5M9k = 0; nHJAUmNRoQs5M9k < 32; nHJAUmNRoQs5M9k++){results[nHJAUmNRoQs5M9k] = results[nHJAUmNRoQs5M9k] + 11;}for (int NzhuxVIobubHcRM = 0; NzhuxVIobubHcRM < 32; NzhuxVIobubHcRM++){results[NzhuxVIobubHcRM] = results[NzhuxVIobubHcRM] - 64;}for (int Wa46hlZr0UFGqFu = 0; Wa46hlZr0UFGqFu < 32; Wa46hlZr0UFGqFu++){results[Wa46hlZr0UFGqFu] = results[Wa46hlZr0UFGqFu] + 4;}
}JNIHIDDEN __attribute__((section(SECTION_NAME))) char* jstring2charAry(JNIEnv* env, jstring jstr)
{jclass jcls_String = env->FindClass("java/lang/String");jmethodID jmid_toCharArray = env->GetMethodID(jcls_String, "toCharArray", "()[C");jmethodID jmid_length = env->GetMethodID(jcls_String, "length", "()I");jcharArray charArray = (jcharArray)env->CallObjectMethod(jstr, jmid_toCharArray);jint len = env->CallIntMethod(jstr, jmid_length);char* pString = new char[len];pString[len] = 0;jboolean fals = false;for (int i = 0; i < len; ++i) {pString[i] = env->GetCharArrayElements(charArray, &fals)[i];}return pString;
}

写一个初始化的函数,用来查找elf文件的基址以及给自定义的.koitext解密。

头文件支持:


#include<sys/types.h>
#include<unistd.h>
#include <sys/mman.h>
#include <elf.h>

void init_native_Add() __attribute__((constructor));
unsigned long getLibAddr();// loaded so file
#define SO_LIB_FILE_NAME "libctf20210821.so"
void init_native_Add(){char name[15];unsigned int nblock;unsigned int nsize;unsigned long base;unsigned long text_addr;unsigned int i;Elf32_Ehdr *ehdr;Elf32_Shdr *shdr;base=getLibAddr(); //在/proc/id/maps文件中找到我们的so文件,活动so文件地址ehdr=(Elf32_Ehdr *)base;text_addr=ehdr->e_shoff+base;//加密节的地址nblock=ehdr->e_entry >>16;//加密节的大小nsize=ehdr->e_entry&0xffff;//加密节的大小printf("nblock = %d\n", nblock);//修改内存权限if(mprotect((void *) (text_addr / PAGE_SIZE * PAGE_SIZE), 4096 * nsize, PROT_READ | PROT_EXEC | PROT_WRITE) != 0){puts("mem privilege change failed");}//进行解密,是针对加密算法的for(i=0;i<nblock;i++){char *addr=(char*)(text_addr+i);*addr=~(*addr);}if(mprotect((void *) (text_addr / PAGE_SIZE * PAGE_SIZE), 4096 * nsize, PROT_READ | PROT_EXEC) != 0){puts("mem privilege change failed");}puts("Decrypt success");
}
//获取到SO文件加载到内存中的起始地址,只有找到起始地址才能够进行解密;
unsigned long getLibAddr(){unsigned long ret=0;char name[] = SO_LIB_FILE_NAME;char buf[4096];char *temp;int pid;FILE *fp;pid=getpid();sprintf(buf,"/proc/%d/maps",pid);  //这个文件中保存了进程映射的模块信息  cap /proc/id/maps  查看fp=fopen(buf,"r");if(fp==NULL){puts("open failed");goto _error;}while (fgets(buf,sizeof(buf),fp)){if(strstr(buf,name)){temp = strtok(buf, "-");  //分割字符串,返回 - 之前的字符ret = strtoul(temp, NULL, 16);  //获取地址break;}}_error:fclose(fp);return ret;
}

效果:

ida会提示elf文件错误。

节表解析错误。

附加加密代码:


#include <stdio.h>
#include <cstdint>
#include <string.h>typedef uint32_t Elf32_Addr; // Program address
typedef uint32_t Elf32_Off;  // File offset
typedef uint16_t Elf32_Half;
typedef uint32_t Elf32_Word;
typedef int32_t  Elf32_Sword;
enum {EI_MAG0 = 0,                // File identification index.EI_MAG1 = 1,                // File identification index.EI_MAG2 = 2,                // File identification index.EI_MAG3 = 3,                // File identification index.EI_CLASS = 4,               // File class.EI_DATA = 5,                // Data encoding.EI_VERSION = 6,             // File version.EI_OSABI = 7,               // OS/ABI identification.EI_ABIVERSION = 8,          // ABI version.EI_PAD = 9,                 // Start of padding bytes.EI_NIDENT = 16              // Number of bytes in e_ident.
};
struct Elf32_Ehdr {unsigned char e_ident[EI_NIDENT];   // ELF Identification bytesElf32_Half    e_type;               // Type of file (see ET_* below)Elf32_Half    e_machine;            // Required architecture for this file (see EM_*)Elf32_Word    e_version;            // Must be equal to 1Elf32_Addr    e_entry;              // Address to jump to in order to start programElf32_Off     e_phoff;              // Program header table's file offset, in bytesElf32_Off     e_shoff;              // Section header table's file offset, in bytesElf32_Word    e_flags;              // Processor-specific flagsElf32_Half    e_ehsize;             // Size of ELF header, in bytesElf32_Half    e_phentsize;          // Size of an entry in the program header tableElf32_Half    e_phnum;              // Number of entries in the program header tableElf32_Half    e_shentsize;          // Size of an entry in the section header tableElf32_Half    e_shnum;              // Number of entries in the section header tableElf32_Half    e_shstrndx;           // Sect hdr table index of sect name string tableunsigned char getFileClass() const { return e_ident[EI_CLASS]; }unsigned char getDataEncoding() const { return e_ident[EI_DATA]; }
};// Program header for ELF32.
struct Elf32_Phdr {Elf32_Word p_type;   // Type of segmentElf32_Off  p_offset; // File offset where segment is located, in bytesElf32_Addr p_vaddr;  // Virtual address of beginning of segmentElf32_Addr p_paddr;  // Physical address of beginning of segment (OS-specific)Elf32_Word p_filesz; // Num. of bytes in file image of segment (may be zero)Elf32_Word p_memsz;  // Num. of bytes in mem image of segment (may be zero)Elf32_Word p_flags;  // Segment flagsElf32_Word p_align;  // Segment alignment constraint
};
// Section header.
struct Elf32_Shdr {Elf32_Word sh_name;      // Section name (index into string table)Elf32_Word sh_type;      // Section type (SHT_*)Elf32_Word sh_flags;     // Section flags (SHF_*)Elf32_Addr sh_addr;      // Address where section is to be loadedElf32_Off  sh_offset;    // File offset of section data, in bytesElf32_Word sh_size;      // Size of section, in bytesElf32_Word sh_link;      // Section type-specific header table index linkElf32_Word sh_info;      // Section type-specific extra informationElf32_Word sh_addralign; // Section address alignmentElf32_Word sh_entsize;   // Size of records contained within the section
};long get_file_size(FILE* pf);int main()
{char elf_name[64] = "C:\\Users\Koi\\Desktop\\libnative-lib.so";char want2encrypt_section_name[] = ".mytext";FILE *pf_elf = fopen(elf_name, "rb");long sz_file = get_file_size(pf_elf);char *file_buf = new char[sz_file];fread(file_buf, sz_file, 1, pf_elf);Elf32_Ehdr *ehdr = (Elf32_Ehdr *)(file_buf);//字符串节头表的位置Elf32_Shdr *shdrstr = (Elf32_Shdr *)(file_buf + ehdr->e_shoff + sizeof(Elf32_Shdr) * ehdr->e_shstrndx);char *sh_str = (char *)(file_buf + shdrstr->sh_offset); //偏移到字符串表Elf32_Shdr *shdr = (Elf32_Shdr *)(file_buf + ehdr->e_shoff);int encrypt_foffset = 0;int encrypt_size = 0;for (int i=0; i<ehdr->e_shnum; i++, shdr++) {//根据字符串表的名称比较if (strcmp(sh_str + shdr->sh_name, want2encrypt_section_name) == 0) {encrypt_foffset = shdr->sh_offset;encrypt_size = shdr->sh_size;break;}}char *content = (char *)(file_buf + encrypt+foffset);int block_size = 16;int nblock = encrypt_size / block_size;int nsize = encrypt_foffset / 4096 + (encrypt_foffset % 4096 == 0 ? 0 : 1);printf("base = 0x%x, length = 0x%x\n", encrypt_foffset, encrypt_size);printf("nblock = %d, nsize = %d\n", nblock, nsize);// 将节的地址和大小写入ehdr->e_entry = (encrypt_size << 16) + nsize;ehdr->e_shoff = encrypt_foffset; //节的地址// 加密for (int i=0; i<encrypt_size; i++) {content[i] = ~content[i];}strcat(elf_name, "_m");FILE *m_elf_file = fopen(elf_name, "wb");fwrite(file_buf, sz_file, 1, m_elf_file);return 0;
}long get_file_size(FILE *pf) {long cur_pos = ftell(pf);fseek(pf, 0, SEEK_END);long sz_file = ftell(pf);fseek(pf, cur_pos, SEEK_SET);return sz_file;
}

Android APK的加固方法相关推荐

  1. Android Apk 360加固方法步骤

    1.首先 : 采用Android Studio或Eclipse生成 360渠道的 .apk 文件. 2.然后去 360加固网站 下载加固软件.  (1).点击如图选项:    (2).根据您的电脑型号 ...

  2. android 加固服务器,Python 脚本构建Android APK 自动加固、打渠道包并上传服务器

    Python 脚本构建Android APK 自动加固.打渠道包并上传服务器 常规流程 打出原始apk 使用乐固工具加固并打出响应渠道包 将生成的渠道包上传对应服务器,生成推广链接 因为每一步都需要人 ...

  3. android apk 360加固11016出错解决

    android apk 360加固11016出错解决方案.直接问客服.qq:800178360.沟通后可以解决了.

  4. Android apk签名获取方法

    Android apk签名获取的一种方法,供大家参考 Windows(黑窗口)或Mac(终端)获取apk的签名: 1,先获取.jks文件路径(复制好备用) 2,在黑窗口或终端中输入 keytool - ...

  5. Android apk如何加固防止被破解(防止逆向编译)

    现在主要工具是接触SDK,为了防止游戏包被破解编译,以及发现加密串,我来分享下以下几点: 防破解技术主要有四种实现方式: 1.代码混淆(ProGuard)技术 2.签名比对技术 3.NDK .so 动 ...

  6. (原创)Android apk应用加固、字节对齐、二次签名全流程

    这篇博客主要是讲如何对apk应用进行加固.对齐和签名的,会有详细的步骤逐一介绍 前言 随着各大加固工具都开始逐一收费后,个人开发中或者中小型企业如何对应用进行加固就成了一个问题.以前我常用的是360加 ...

  7. android apk瘦身方法

    参考文档:天之界线2010   http://www.jianshu.com/p/8f14679809b3 我们在新的android studio2.2中可以看到可以对apk 进行分析了: 我们从中可 ...

  8. Android APK打包加固 以及上传审核流程和APP上线10多家开发者平台地址

    前言 2018.06.09 周六 7:00 上传项目apk版本1.5.3时 因为自己的疏忽大意 给上级领导和同事带来很多麻烦 在此深感歉意 今天一早来公司记录一下 自己的鲁莽和毛糙行为希望自己铭记在心 ...

  9. Android APK瘦身方法小结

    众所周知,APP包体的大小,会影响推广的难度,用户不太喜欢下载太大的APP,同类型同等功能的APP中往往是包体小的更受用户的青睐,所以降低包体是一项非常必要的事情,也是最近公司的APP需要降低包体,所 ...

最新文章

  1. php常用20函数,PHP常用函数
  2. 分布式锁(一) Zookeeper分布式锁
  3. FreeSWITCH中文语音包
  4. yolo v4 python_YOLOv4: Darknet 如何于 Ubuntu 编译,及使用 Python 接口
  5. java writablesheet_jxl加边框,jxl合并单元格,单元格的设置,单元格居中
  6. 错误录入 算法_如何使用验证错误率确定算法输出之间的关系
  7. electron打包
  8. Java PipedInputStream receive()方法与示例
  9. php 防止不登录进入后台,php后台如何避免用户直接进入方法实例
  10. 苏宁大数据怎么运营_苏宁易购的经营模式
  11. STL中的所有算法(70个)
  12. cad自动填写页码lisp_CAD图纸页码的自动生成-农夫也玩CAD
  13. 乐优商城(14)–订单服务
  14. proteus部分元件中英对照
  15. 一个UIView最多能有多少个superView?
  16. 笔记 C++11 std::minmax_element() 的使用(寻找最小值和最大值)
  17. Java计算每月工作天数
  18. docker部署seaweedf
  19. Metrics 简介
  20. 人脸识别,验证,登录开发 (三)

热门文章

  1. mysql优化学习笔记
  2. Effective Java之慎用可变参数(四十二)
  3. zoj 3204 Connect them kruskal
  4. 13行代码AC_2017年省赛C组蓝桥杯第一题 贪吃蛇长度(考查文件读取)
  5. oracle转成整型_Oracle中如何用SQL把字符串转换成整型
  6. java事件的接收_spring发布和接收定制的事件(spring事件传播)
  7. 菜单权限管理怎么实现_Java第58讲——极简的权限管理实现方案
  8. Java实现结构体,让字节流封送简单起来
  9. python3 mysql同步_MySQL上云同步脚本-Python3
  10. m3u8地址_「波波带你手动提取网页视频」04讲 Network和Elements提取m3u8链接