APK加壳【1】初步方案实现详解

来源与原理

本文是尝试对CSDN大牛 Jack_Jia 的博客 Android APK加壳技术方案【2】 进行实现的过程记录,该文介绍了一种对源程序APK加壳的思路并提供了对应的源码。

所谓加壳,就是通过给目标APK加一层保护程序,把需要保护的内容加密、隐藏起来,来防止反编译的一种方法。说到底我们要做的是这样一个事情,首先把要加壳的APK用自己的加密算法加个密(实验过程中这步可以省掉),然后藏在另一个APK中(就是壳工程)发布出去,这样防止破解者直接拿到源程序的APK去反编译。不好处理的是还需要壳工程在各种版本的Android系统里运行时,要把源程序解密出来还要跟直接装源程序有同样的运行效果才行。如何实现原文都已经写清楚了:

  1. 通过反射置换android.app.ActivityThread 中的mClassLoader为加载解密出APK的DexClassLoader,该DexClassLoader一方面加载了源程序、另一方面以原mClassLoader为父节点,这就保证了即加载了源程序又没有放弃原先加载的资源与系统代码;
  2. 找到源程序的Application,通过反射建立并运行;

基本流程

方案

整个方案里面涉及到三种角色:

  1. 源程序——等待被加壳的目标程序,一个APK;【原文中的加密工具代码、DexShellTool中的g:/payload.apk】
  2. 加密工具——这是一个工具程序,用什么语言实现都是可以的。用来给源程序加密,这段功能对应的解密则在壳程序中实现;【原文中的DexShellTool代码,是一个java程序】
  3. 壳程序——它实际上也是一个Android工程,经过加壳发布出去的APK就是壳程序经过特殊处理之后生成的。它内部保存着已被加密的源程序(apk、dex或者odex),在启动后第一时间将加密后的源程序解出来,通过类加载器动态加载运行;【原文中Menifest、ProxyApplication.java、RefInvoke.java部分】

所以检查这个方案需要有个DEMO APK、有个加密工具JAVA工程DexShellTool 和一个Android壳工程UnShell,最后加壳后的APK实际上是壳工程编译出的、并且把其中的dex文件替换为经过加密工具处理生成的新dex、最后重新打包签名的APK。

源程序

源程序其实没什么好讲的,最好是有个带有服务、广播、网络操作什么的基础功能比较全面的示例程序,这样测试可行性更加有说服力一些。

加密工具

加密工具其实原文中给出的很容易看懂,因为没有涉及到加密算法,所以不到两百行。基本做了这样一件事:把源程序加密之后接到壳工程的dex文件尾,然后修改dex文件的文件长度、校验和什么的。这种隐藏方式略诡异。

壳工程

壳工程既是壳又要有解壳功能,原文只给了两个类,实际上也只需要这两个类。ProxyApplication里有解壳与反射实现动态加载源程序的代码逻辑、RefInvoke则是反射工具。许多童鞋表示反射不好理解,一开始我也是这么觉得。不过经过一行行注释下来、对比系统源码,其实也没有多难。这里要说,静下心来分析,不到三百行的代码,能有多复杂呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
protected void attachBaseContext(Context base) {
super.attachBaseContext(base);
Log.d(TAG, "attachBaseContext hello world~");
try {
File odex = this.getDir("payload_odex", MODE_PRIVATE);
File libs = this.getDir("payload_lib", MODE_PRIVATE);
odexPath = odex.getAbsolutePath();
libPath = libs.getAbsolutePath();
apkFileName = odex.getAbsolutePath() + "/payload.apk";
File dexFile = new File(apkFileName);
if (!dexFile.exists())
dexFile.createNewFile();
// 读取程序classes.dex文件
byte[] dexdata = this.readDexFileFromApk();
// 分离出解壳后的apk文件已用于动态加载
this.splitPayLoadFromDex(dexdata);
// 配置动态加载环境
Object currentActivityThread = RefInvoke.invokeStaticMethod(
"android.app.ActivityThread", "currentActivityThread",
new Class[] {}, new Object[] {});
String packageName = this.getPackageName();
HashMap mPackages = (HashMap) RefInvoke.getFieldOjbect(
"android.app.ActivityThread", currentActivityThread,
"mPackages");
WeakReference wr = (WeakReference) mPackages.get(packageName);
//换Loader操作 动态加载如被加密又装换回来的apk文件
DexClassLoader dLoader = new DexClassLoader(apkFileName, odexPath,
libPath, (ClassLoader) RefInvoke.getFieldOjbect(
"android.app.LoadedApk", wr.get(), "mClassLoader"));
RefInvoke.setFieldOjbect("android.app.LoadedApk", "mClassLoader",
wr.get(), dLoader);
} catch (Exception e) {
e.printStackTrace();
}
}
public void onCreate() {
Log.d(TAG, "on create hello world~");
// 如果源应用配置有Appliction对象,则替换为源应用Applicaiton,以便不影响源程序逻辑。
String appClassName = null;
try {
ApplicationInfo ai = this.getPackageManager().getApplicationInfo(
this.getPackageName(), PackageManager.GET_META_DATA);
Bundle bundle = ai.metaData;
if (bundle != null && bundle.containsKey("APPLICATION_CLASS_NAME")) {
appClassName = bundle.getString("APPLICATION_CLASS_NAME");
} else {
return;
}
} catch (NameNotFoundException e) {
e.printStackTrace();
}
Log.d(TAG, "the app aplication name is " + appClassName);
/**
* 调用静态方法android.app.ActivityThread.currentActivityThread
* 获取当前activity所在的线程对象
*/
Object currentActivityThread = RefInvoke.invokeStaticMethod(
"android.app.ActivityThread", "currentActivityThread",
new Class[] {}, new Object[] {});
/**
* 获取currentActivityThread中的mBoundApplication属性对象,该对象是一个
* AppBindData类对象,该类是ActivityThread的一个内部类
*/
Object mBoundApplication = RefInvoke.getFieldOjbect(
"android.app.ActivityThread", currentActivityThread,
"mBoundApplication");
/**
* 获取mBoundApplication中的info属性,info 是 LoadedApk类对象
*/
Object loadedApkInfo = RefInvoke.getFieldOjbect(
"android.app.ActivityThread$AppBindData", mBoundApplication,
"info");
/**
* loadedApkInfo对象的mApplication属性置为null
*/
RefInvoke.setFieldOjbect("android.app.LoadedApk", "mApplication",
loadedApkInfo, null);
/**
* 获取currentActivityThread对象中的mInitialApplication属性
* 这货是个正牌的 Application
*/
Object oldApplication = RefInvoke.getFieldOjbect(
"android.app.ActivityThread", currentActivityThread,
"mInitialApplication");
/**
* 获取currentActivityThread对象中的mAllApplications属性
* 这货是 装Application的列表
*/
ArrayList<Application> mAllApplications = (ArrayList<Application>) RefInvoke
.getFieldOjbect("android.app.ActivityThread",
currentActivityThread, "mAllApplications");
//列表对象终于可以直接调用了 remove调了之前获取的application 抹去记录的样子
mAllApplications.remove(oldApplication);
/**
* 获取前面得到LoadedApk对象中的mApplicationInfo属性,是个ApplicationInfo对象
*/
ApplicationInfo appinfo_In_LoadedApk = (ApplicationInfo) RefInvoke
.getFieldOjbect("android.app.LoadedApk", loadedApkInfo,
"mApplicationInfo");
/**
* 获取前面得到AppBindData对象中的appInfo属性,也是个ApplicationInfo对象
*/
ApplicationInfo appinfo_In_AppBindData = (ApplicationInfo) RefInvoke
.getFieldOjbect("android.app.ActivityThread$AppBindData",
mBoundApplication, "appInfo");
//把这两个对象的className属性设置为从meta-data中获取的被加密apk的application路径
appinfo_In_LoadedApk.className = appClassName;
appinfo_In_AppBindData.className = appClassName;
/**
* 调用LoadedApk中的makeApplication 方法 造一个application
* 前面改过路径了
*/
Application app = (Application) RefInvoke.invokeMethod(
"android.app.LoadedApk", "makeApplication", loadedApkInfo,
new Class[] { boolean.class, Instrumentation.class },
new Object[] { false, null });
RefInvoke.setFieldOjbect("android.app.ActivityThread",
"mInitialApplication", currentActivityThread, app);
HashMap mProviderMap = (HashMap) 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);
}
if(null == app){
Log.e(TAG, "application get is null !");
}else{
app.onCreate();
}
}

辅助源码看实际上还是很好理解的,不多说。

顺手推荐个android在线源码浏览网址 http://androidxref.com/

为了方便后续调试代码,弄了个shell脚本,同时也可以基本解释整个加壳的流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
#!/bin/bash
ENCRYPT_PATH="/home/kf2lc/develop/apk_encrypt"
UNSHELL_PATH="/home/kf2lc/develop/workspace/BFC/UnShell"
DEX_SHELL_TOOL_PATH="/home/kf2lc/develop/workspace/BFC/DexShellTool"
DEMO_TEMP_PATH="./demopac"
TEMP_PATH="./apk"
ARM_SO_PATH="/libs/armeabi-v7a/libNativeTool.so"
ARM_MIPS_PATH="/libs/mips/libNativeTool.so"
echo "清理中间文件..."
cd $ENCRYPT_PATH
rm Demo-*.apk
rm *.dex
rm UnShell.apk
echo "编译解壳工程..."
cd $UNSHELL_PATH
rm -rf gen bin
android update project -p .
ant clean debug
echo "拷贝壳工程dex文件到工作目录..."
cp $UNSHELL_PATH"/bin/classes.dex" $ENCRYPT_PATH"/unshell.dex"
cp $UNSHELL_PATH"/bin/BlankActivity-debug-unaligned.apk" $ENCRYPT_PATH"/UnShell.apk"
echo "编译加壳工程... 生成新的classes.dex文件到工作目录..."
cd $DEX_SHELL_TOOL_PATH
ant clean compile jar run
echo "解压待加密apk... 替换classes.dex文件为加壳.dex文件..."
cd $ENCRYPT_PATH
unzip -d $TEMP_PATH UnShell.apk
rm $TEMP_PATH"/classes.dex"
mv ./classes.dex $TEMP_PATH
echo "删除签名文件夹 重新打包apk..."
cd $TEMP_PATH
rm -rf ./META-INF
zip -r ../Demo-encrypt-unsign.apk ./*
cd ../
echo "清理中间目录..."
rm -rf $TEMP_PATH
echo "为加壳后的apk重新签名..."
jarsigner -verbose -keystore bfc.keystore -signedjar Demo-encrypt.apk Demo-encrypt-unsign.apk bfc.keystore

注意事项

  1. 无论是虚拟机还是手机、平板,测试时一定要统一使用一个签名,否则很容易出无签名的安装错误;
  2. 由于本方案使用DexClassLoader作为动态加载的方案,从接口上看:
    很明显,这货是需要一个文件路径的,这意味着如果直接使用该类,就必须要有个解密好的文件老老实实的躺在存储器上,这样一来无论你放在什么地方、该文件存在的时间有多短,破解者都有可能绕过壳、直接拿到解密的文件,这明显不科学;
  3. 资源加载,这里面我偷了个懒,壳工程的资源文件和源程序的资源文件是完全一致的,所以加载起来没有问题。但是这样一来整个加壳的APK实际上内部有两份资源文件了,示例APK还好,碰见图片多的那这个数据增量完全无法接受;
  4. 本文仅是个人理解,虽然跑通了但是也不免有瞎猫撞上死耗子的几率,错误加上错误产生正确也是可能的,仅供参考,欢迎质疑。

其实原文所述的方案是加壳的一个基本思路,具体要预防反编译实现起来肯定不会如此简略、加壳也只是预防破解的各路招式之一。但还是要感谢大牛的芸芸分享,使我辈菜鸟有了一条入门之路。

感谢您赏个荷包蛋~