这篇文章上次修改于 1735 天前,可能其部分内容已经发生变化,如有疑问可询问作者。
前言
因为最近要准备一些东西,所以决心把这个远古方法的加固给完成了,借此来了解一下基本的加固原理与实现
GitHub:https://github.com/Herrrb/DexShell
环境
- 真机:Zuk Z2 Pro, Android8.0
- Android Studio
68747470733a2f2f6865726277656e2e636f6d2f7573722f75706c6f6164732f696d6167652f6465787368656c6c2f312e706e67.png
- 环境:
68747470733a2f2f6865726277656e2e636f6d2f7573722f75706c6f6164732f696d6167652f6465787368656c6c2f322e706e67.png)
注意避免dex分包:
multiDexEnabled false
原理分析
需要的三个对象:
- 需要加固的apk(源apk)
- 壳程序apk,即上图中的脱壳Dex的出处
- 加密工具(将源Apk进行加密和壳Dex合并成新的Dex)
主要步骤:首先需要写一个源Apk,即需要被加壳的apk,只需要一个实现简单跳转和Toast功能的demo即可;然后是一个脱壳apk,这里理解了很久为什么是个脱壳apk,因为我们需要把源apk打进脱壳apk的classes.dex里,然后安装的时候是安装的这个脱壳apk,既然安装的是脱壳apk,那么如何执行源apk的功能呢?这里就涉及到脱壳apk的功能,就是脱壳,将自身dex载入,再将打进去的apk拿出来执行,这样就执行了原来的apk;
最后就是加密工具,即将源apk与脱壳dex合并的工具,这里工具的输出是一个新的classes.dex;
工具操作的主体是dex,因为dex结构,加壳的时候需要注意,checksum、signature、filesize三个字段,即当apk和apk大小(4字节)附加到dex之后,这三个字段需要进行修正;
源APK
这边定义包名为:com.herbwen.unshell 结构如下
![68747470733a2f2f6865726277656e2e636f6d2f7573722f75706c6f6164732f696d6167652f6465787368656c6c2f332e706e67.png]
MainActivity
即正常的功能,设定TextView和Intent;
package com.herbwen.unshell;
import android.content.Intent;
import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;
import android.util.Log;
import android.view.View;
import android.widget.TextView;
public class MainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
TextView content = new TextView(this);
content.setText("I am Source Apk");
content.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
Intent intent = new Intent(MainActivity.this, SubActivity.class);
startActivity(intent);
}
});
setContentView(content);
Log.i("Herrrb", "app:"+getApplicationContext());
}
}
SubActivity
即Intent关联的Activity
package com.herbwen.unshell;
import android.os.Bundle;
import android.support.annotation.Nullable;
import android.support.v7.app.AppCompatActivity;
import android.util.Log;
import android.view.View;
import android.widget.Button;
import android.widget.Toast;
public class SubActivity extends AppCompatActivity {
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_sub);
Button button = findViewById(R.id.button);
button.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
Toast.makeText(SubActivity.this, "唉,难受", Toast.LENGTH_SHORT).show();
}
});
Log.i("Herrrb", "App:" + getApplicationContext());
}
}
加壳程序
首先来看加壳程序才能知道脱壳apk为什么要这样写; 因为总要上传到GitHub上,这里就分段来写 首先是导入
package com.example.reforceapk;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.zip.Adler32;
类下有一下几个方法
- main:主要执行载入以及加壳功能;
- encrypt:对apk文件进行加密;
- fixCheckSumHeader:修正更改后的dex头checksum字段;
- intToByte:字面意思,int转byte
- fixSHA1Header:修正signature字段;
- fixFileSizeHeader:修正filesize字段;
- readFileBytes:以二进制读出文件内容;
main
让我们来看看加壳程序代码
public static void main(String[] args) {
// TODO Auto-generated method stub
try {
File payloadSrcFile = new File("force/source_app.apk");
// 获取到需要加壳的程序
System.out.println("apk size:"+payloadSrcFile.length());
// 打印需要加壳的apk的大小,即长度
File unShellDexFile = new File("force/unshell.dex");
// 载入解客dex
byte[] payloadArray = encrpt(readFileBytes(payloadSrcFile));
// 以二进制形式读出apk,并进行加密处理,后面可以看到这里采用的是异或加密
byte[] unShellDexArray = readFileBytes(unShellDexFile);
// 以二进制形式读出dex
int payloadLen = payloadArray.length;
// 加密后源apk大小
int unShellDexLen = unShellDexArray.length;
int totalLen = payloadLen + unShellDexLen +4;
// 总长度,多出4字节是存放长度的。
byte[] newdex = new byte[totalLen];
// 申请了新的长度的数组
// 添加解壳代码
System.arraycopy(unShellDexArray, 0, newdex, 0, unShellDexLen);
// 先拷贝dex内容
// 添加加密后的解壳数据
System.arraycopy(payloadArray, 0, newdex, unShellDexLen, payloadLen);
//再在dex内容后面拷贝apk的内容
//添加解壳数据长度
System.arraycopy(intToByte(payloadLen), 0, newdex, totalLen-4, 4);
//最后4为长度
//修改DEX file size文件头
fixFileSizeHeader(newdex);
//修改DEX SHA1 文件头
fixSHA1Header(newdex);
//修改DEX CheckSum文件头
fixCheckSumHeader(newdex);
String str = "force/classes.dex";
File file = new File(str);
if (!file.exists()) {
file.createNewFile();
}
FileOutputStream localFileOutputStream = new FileOutputStream(str);
localFileOutputStream.write(newdex);
localFileOutputStream.flush();
localFileOutputStream.close();
} catch (Exception e) {
e.printStackTrace();
}
}
这里可以总结出一个Java的文件写入流程
String str = "file_name";
File file = new File(str);
if (!file.exists()){
file.createNewFile();
}
FileOutputStream localFileOutputStream = new FileOutputStream(str);
localFileOutputStream.write(content_needed_to_be_added);
localFileOutputStream.flush();
localFileOutputStream.close();
抓报错流程
try {
...
}catch (Exception e) {
Log.i("Herrrb", Log.getStackTraceString(e));
e.printStackTrace();
}
主要实现了:
- 读apk;
- 读dex;
- 转化成byte[]合并;
- 修正几个头部属性;
- 写入classes.dex;
- 完成
encrypt
private static byte[] encrpt(byte[] srcdata){
for(int i = 0;i<srcdata.length;i++){
srcdata[i] = (byte)(0xFF ^ srcdata[i]);
}
return srcdata;
}
每个字节都与0xFF异或,注意是字节;
fixCheckSumHeader
修改dex头,CheckSum校验码
private static void fixCheckSumHeader(byte[] dexBytes) {
Adler32 adler = new Adler32();
// 定义在java.util.zip.Adler32
adler.update(dexBytes, 12, dexBytes.length - 12);//从12到文件末尾计算校验码
long value = adler.getValue();
int va = (int) value;
byte[] newcs = intToByte(va);
//高位在前,低位在前掉个个
byte[] recs = new byte[4];
for (int i = 0; i < 4; i++) {
recs[i] = newcs[newcs.length - 1 - i];
System.out.println(Integer.toHexString(newcs[i]));
}
System.arraycopy(recs, 0, dexBytes, 8, 4);//效验码赋值(8-11)
System.out.println(Long.toHexString(value));
System.out.println();
}
首先:Adler32是个计算校验码的包,定义对象之后引用update方法来获得checksum 第一点:为什么是从12到文件末尾计算校验码? 因为dex文件头,magic魔数,是固定的八个字节,"dexn035",然后checksum是四个字节,checksum的计算范围是除去magic和checksum的其余所有部分,所以需要计算从12到文件末尾的校验码
得到之后转long,再转int,然后转换成byte,然后低位高位调个个,然后存入原dex序列的第8-11个字节,这边不需要返回,操作的就是原dex序列
intToByte
int 转 byte[]
public static byte[] intToByte(int number) {
byte[] b = new byte[4];
for (int i=3; i>=0; i--){
b[i] = (byte) (number % 256)
number >>= 8;
}
return b;
}
大意就是转字节,4字节一单位 例如:1096 二进制:100 01001000 byte[]: 0 0 4 72
fixSHA1Header
修改dex头,sha1值
private static void fixSHA1Header(byte[] dexBytes) throws NoSuchAlgorithmException {
MessageDigest md = MessageDigest.getInstance("SHA-1");
md.update(dexBytes, 32, dexBytes.length - 32);//从32为到结束计算sha--1
byte[] newdt = md.digest();
System.arraycopy(newdt, 0, dexBytes, 12, 20);//修改sha-1值(12-31)
//输出sha-1值,可有可无
String hexstr = "";
for (int i = 0; i < newdt.length; i++) {
hexstr += Integer.toString((newdt[i] & 0xff) + 0x100, 16).substring(1);
}
System.out.println(hexstr);
}
首先是调用import java.security.MessageDigest;
,拿到sha1方法的对象 类似python的用法去update来传输数据,digest方法得到加密后的结果,因为计算的输入是出去magic、checksum、signature三部分,故需要从32开始;
保存在newdt中,从arraycopy中也可以看出这个签名是20字节的;到这就结束了,后面的输出就可有可无了;
fixFileSizeHeader
修改dex头,file_size值
private static void fixFileSizeHeader(byte[] dexBytes) {
//新文件长度
byte[] newfs = intToByte(dexBytes.length);
System.out.println(Integer.toHexString(dexBytes.length));
byte[] refs = new byte[4];
for (int i = 0; i < 4; i++) {
refs[i] = newfs[newfs.length - 1 - i];
System.out.println(Integer.toHexString(newfs[i]));
}
System.arraycopy(refs, 0, dexBytes, 32, 4);//修改(32-35)
}
得到文件长度的byte[],低位高位颠倒再存入dexBytes,修改32-35四个字节
readFileBytes
以二进制读出文件
private static byte[] readFileBytes(File file) throws IOException {
byte[] arrayOfByte = new byte[1024];
ByteArrayOutputStream localByteArrayOutputStream = new ByteArrayOutputStream();
FileInputStream fis = new FileInputStream(file);
while (true) {
int i = fis.read(arrayOfByte);
if (i != -1) {
localByteArrayOutputStream.write(arrayOfByte, 0, i);
} else {
return localByteArrayOutputStream.toByteArray();
}
}
}
最后返回的是ByteArrayOutputStream类型的值,首先FileInputStream,将file传入,一次read1024字节,然后写入ByteArrayOutputStream,调用toByteArray方法返回;
这样加密工具分析就结束了;
正式使用时可在eclipse中
总结起来就是,结合源apk(加密后)与脱壳程序dex,dex在前apk在后,并修正dex文件头的三个属性;
脱壳APK
个人觉得这种方法脱壳的核心就在于动态加载, LoadedApk.java,负责加载一个apk程序,类内部有一个mClassLoader变量,负责加载apk,只要获取这个类加载器并替换为解密出的apk的dexclassloader,该dexclassloader一方面加载了源程序、另一方面以原mClassLoader为父节点,保证了即加载了源程序又没有放弃原先加载的资源和系统代码;
然后是找到源程序的Application,通过反射建立并运行,这是会Log出一条消息; 单纯地载入是不会运行的,所以需要找到Application类(也就是apk的全局类),运行其onCreate方法,这样源apk才开始它的生命周期;
接下来看一下具体的步骤:(还是不贴完整代码,详情请见GitHub 自定义类ProxyApplication,继承自Application类
首先是定义全局变量:
private static final String appkey = "APPLICATION_CLASS_NAME";
private String apkFileName;
private String odexPath;
private String libPath;
RefInvoke
具体解释见 https://herbwen.com/index.php/archives/57/#%E5%8A%A8%E6%80%81%E5%8A%A0%E8%BD%BDActivity
attachBaseContext
然后是override一下attachBaseContext方法,因为这个方法在onCreate之前执行, 在这个方法内,需要提取出源apk,解密,
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);
Log.i("demo", "apk size:"+dexFile.length()); // 第一次的时候输出0
if (!dexFile.exists())
{
dexFile.createNewFile();
// 从apk中读取dex,具体方法放放入readDexFileFromApk中
byte[] dexdata = this.readDexFileFromApk();
// 再从dex中取出源apk,放入dexdata
this.splitPayLoadFromDex(dexdata);
}
然后替换LoadedApk中的mClassloader变量,将原mClassLoader设置为父类,DexClassLoader加载了。
Object currentActivityThread = RefInvoke.invokeStaticMethod(
"android.app.ActivityThread", "currentActivityThread", new Class[] {}, new Object[] {});
String packageName = this.getPackageName();
// 得到当前的包名,配合mPackage得到与LoadedApk的映射关系
ArrayMap mPackages = (ArrayMap) RefInvoke.getFieldObject(
"android.app.ActivityThread", currentActivityThread, "mPackages");
// 存放的是apk包名和LoadedApk类的映射关系
WeakReference wr = (WeakReference) mPackages.get(packageName);
// 得到LoadedApk
DexClassLoader dLoader = new DexClassLoader(apkFileName, odexPath, libPath, (ClassLoader) RefInvoke.getFieldObject("android.app.LoadedApk", wr.get(), "mClassLoader"));
// DexClassLoader,将得到的apk载入,父类设置为脱壳apk包名对应的LoadedApk的mClassLoader变量,这样才符合前面说的,既加载源程序,又保持现有程序
RefInvoke.setFieldObject("android.app.LoadedApk", "mClassLoader", wr.get(), dLoader);
// 将脱壳apk包名对应的LoadedApk的mClassLoader替换为dLoader,即上面的DexClassLoader
这里就涉及动态加载的东西了,具体解释见上面代码的注释
接着就是调用loadClass看能不能找到MainActivity,也不需要做什么
try{
Object actObj = dLoader.loadClass("com.herbwen.unshell.MainActivity");
Log.i("demo", "actObj:"+actObj);
}catch(Exception e){
Log.i("demo", "activity:"+Log.getStackTraceString(e));
}
onCreate
首先是获得ApplicationInfo和Bundle,并将类名赋给appClassName变量
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 {
Log.i("demo", "have no application class name");
return;
}
} catch (NameNotFoundException e) {
Log.i("demo", "error:"+Log.getStackTraceString(e));
e.printStackTrace();
}
这个APPLICATION_CLASS_NAME和metaData在manifest.xml中有定义,如下所示
接下来就是一系列的ActivityThread层面的调用与赋值;
Object currentActivityThread = RefInvoke.invokeStaticMethod( "android.app.ActivityThread", "currentActivityThread", new Class[] {}, new Object[] {});Log.i("Herrrb", "currentActivityThread:"+currentActivityThread);Object mBoundApplication = RefInvoke.getFieldObject( "android.app.ActivityThread", currentActivityThread, "mBoundApplication");Log.i("Herrrb", "mBoundApplication:"+mBoundApplication);Object loadedApkInfo = RefInvoke.getFieldObject("android.app.ActivityThread$AppBindData", mBoundApplication, "info");Log.i("Herrrb", "loadedApkinfo:"+loadedApkInfo);RefInvoke.setFieldObject("android.app.LoadedApk", "mApplication", loadedApkInfo, null);
mApplication设置为null
Object oldApplication = RefInvoke.getFieldObject(
"android.app.ActivityThread", currentActivityThread,
"mInitialApplication");
ArrayList<Application> mAllApplications = (ArrayList<Application>) RefInvoke
.getFieldObject("android.app.ActivityThread",
currentActivityThread, "mAllApplications");
mAllApplications.remove(oldApplication);
remove掉oldApplication
ApplicationInfo appinfo_In_LoadedApk = (ApplicationInfo) RefInvoke
.getFieldObject("android.app.LoadedApk", loadedApkInfo,
"mApplicationInfo");
ApplicationInfo appinfo_In_AppBindData = (ApplicationInfo) RefInvoke
.getFieldObject("android.app.ActivityThread$AppBindData",
mBoundApplication, "appInfo");
appinfo_In_LoadedApk.className = appClassName;
appinfo_In_AppBindData.className = appClassName;
Log.i("Herrrb", "appClassName:"+appClassName);
Application app = (Application) RefInvoke.invokeMethod(
"android.app.LoadedApk", "makeApplication", loadedApkInfo,
new Class[] { boolean.class, Instrumentation.class },
new Object[] { false, null });
拿到LoadedApk中的appinfo与appBindData中的appinfo,并将其类名设置为源程序的类名,再调用LoadedApk中loadedApkInfo对应的makeApplication方法,参数类型Boolean和Instrumentation,传入参数false和null,然后会返回一个Application类型的实例 app
再将ActivityThread中currentActivityThread方法mInitialApplication变量替换为app; 这里先记一下mContentProvider,ActivityThread的mProviderMap会缓存已经获取的ContentProvider接口或定义在自己进程内的ContentProvider接口;然后这里使用迭代生成的方法,遍历所有接口,将所有接口的localProvider属性对应的mContext变量全部设置为app,这样应该就接管了content provider了
RefInvoke.setFieldObject("android.app.ActivityThread",
"mInitialApplication", currentActivityThread, app);
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();
就开始了源apk的生命周期;
splitPayLoadFromDex
System.arraycopy(Object src, int srcPos, Object dest, int destPos, int length);
- src:源数组;
- srcPos:源数组要复制的起始位置
- dest:目标数组
- destPos:目标数组放置的起始位置
- length:复制的长度
拿到长度,并新建apk的byte[]序列(arraycopy -> ByteArrayInputStream -> DataInputStream -> readInt
int ablen = apkdata.length;
byte[] dexlen = new byte[4];
System.arraycopy(apkdata, ablen - 4, dexlen, 0, 4);
ByteArrayInputStream bais = new ByteArrayInputStream(dexlen);
DataInputStream in = new DataInputStream(bais);
int readInt = in.readInt();
System.out.println(Integer.toHexString(readInt));
byte[] newdex = new byte[readInt];
然后进行解密
newdex = decrypt(newdex);
写入备份文件,即上面的payload.apk
File file = new File(apkFileName);
try {
FileOutputStream localFileOutputStream = new FileOutputStream(file);
localFileOutputStream.write(newdex);
localFileOutputStream.close();
} catch (IOException localIOException) {
throw new RuntimeException(localIOException);
}
然后找入口,加载so文件,
ZipInputStream localZipInputStream = new ZipInputStream(
new BufferedInputStream(new FileInputStream(file)));
while (true) {
ZipEntry localZipEntry = localZipInputStream.getNextEntry();
if (localZipEntry == null) {
localZipInputStream.close();
break;
}
String name = localZipEntry.getName();
if (name.startsWith("lib/") && name.endsWith(".so")) {
File storeFile = new File(libPath + "/"
+ name.substring(name.lastIndexOf('/')));
storeFile.createNewFile();
FileOutputStream fos = new FileOutputStream(storeFile);
byte[] arrayOfByte = new byte[1024];
while (true) {
int i = localZipInputStream.read(arrayOfByte);
if (i == -1)
break;
fos.write(arrayOfByte, 0, i);
}
fos.flush();
fos.close();
}
localZipInputStream.closeEntry();
}
localZipInputStream.close();
decrypt
因为是异或,所以解密和加密一样
private byte[] decrypt(byte[] srcdata) {
for(int i=0;i<srcdata.length;i++){
srcdata[i] = (byte)(0xFF ^ srcdata[i]);
}
return srcdata;
}
readDexFileFromApk
返回一个ByteArray的输出流,那么首先进行定义
ByteArrayOutputStream dexByteArrayOutputStream = new ByteArrayOutputStream();
拿到Zip的输入流
ZipInputStream localZipInputStream = new ZipInputStream(
new BufferedInputStream(new FileInputStream(
this.getApplicationInfo().sourceDir)));
找到classes.dex,然后读取
while (true) {
ZipEntry localZipEntry = localZipInputStream.getNextEntry();
if (localZipEntry == null) {
localZipInputStream.close();
break;
}
if (localZipEntry.getName().equals("classes.dex")) {
byte[] arrayOfByte = new byte[1024];
while (true) {
int i = localZipInputStream.read(arrayOfByte);
if (i == -1)
break;
dexByteArrayOutputStream.write(arrayOfByte, 0, i);
}
}
localZipInputStream.closeEntry();
}
最后关闭返回
localZipInputStream.close();
return dexByteArrayOutputStream.toByteArray();
其实中间编译测试有很多奇奇怪怪的问题,一定要注意AndroidManifest.xml中的设置,res目录的话脱壳程序的res目录不需要保留,直接将源程序的res目录放入即可;
最后一个报错是关于Theme的,将AndroidManifest.xml中application标签内加入
android:theme="@style/Theme.AppCompat"
即可
使用
先将源apk程序与脱壳apk程序打包成apk,将源apk改名为source_app.apk,脱壳apk解压删除META-INF目录,将classes.dex更名为unshell.dex,放入加壳程序的force目录下;
运行程序,得到classes.dex,然后放入脱壳apk解压的目录下替换掉classes.dex,然后打包成zip,更名为apk,再使用signapk进行签名安装即可;
没有评论