摘要
由于android應用程序使用java代碼開發,java編譯生成的.dex文件或.class代碼反編譯之后很容易得到源代碼。雖然已經有混淆技術可以大大提高代碼反編譯之后的可讀性,但反編譯的源碼還是暴露無遺,所以出現了許多android加固方案,本文分析一種android加固方案和多渠道打包的整合。
class文件和DEX文件
- Android Java代碼編譯之后產生.class文件,然后dex工具會把 .class文件處理成 .dex文件,再把資源文件和.dex文件等打包成 .apk文件 (apk就是android package的意思)。
- .class文件存在很多的冗余信息,dex工具會去除冗余信息,并把所有的.class文件整合到.dex文件中。減少了I/O操作,提高了類的查找速度。
- Android使用dvm(Dalvik Virtual Machine)作為虛擬機,dvm執行的是.dex格式文件。jvm執行的是.class文件。
- dvm是基于寄存器的虛擬機,而jvm執行是基于虛擬棧的虛擬機。寄存器存取速度比棧快的多,dvm可以根據硬件實現最大的優化,比較適合移動設備。
DEX文件結構
dex文件結構如下:
如圖,整個dex文件分為三個模塊
- 文件頭,文件頭記錄了dex文件的一些基本信息, 以及大致的數據分布. dex文件頭部總長度是固定的0x70
- 索引區,索引區中索引了整個dex中的字符串、類型、方法聲明、字段以及方法的信息, 其結構體的開始位置和個數均來自dex文件頭中的記錄(或通過map_list也可以索引到記錄)
- 字符串索引區,描述dex文件中所有的字符串信息
- 類型索引區, 描述dex文件中所有的類型, 如類類型、基本類型、返回值類型等
- 方法聲明索引區, 描述dex文件中所有的方法聲明
- 數據區
dex文件頭結構
字段名稱 | 偏移值 | 長度 | 描述 |
---|---|---|---|
magic | 0x0 | 0x8 | dex魔數字,文件類型的標識。 固定信息: dex\n035,035是結構的版本 |
checksum | 0x8 | 0x4 | 去除了magic和checksum字段之外的所有內容的校驗碼,(alder32算法) |
signature | 0xc | 0x14 | SHA-1簽名, 去除了magic、checksum和signature字段之外的所有內容的簽名 |
fileSize | 0x20 | 0x4 | 整個dex的文件大小 |
headerSize | 0x24 | 0x4 | 整個dex文件頭的大小 (固定大小為0x70) |
... | ... | ... | ... |
上面了解了dex文件和dex文件的文件頭,接下來進入主題,看一下本文所要介紹的apk的加固過程:
APK加固過程總圖解
以上過程大致可總結為:
需要準備的項目有兩個:
- 需要加固的Android項目,即需要保護的源代碼。
- 解密的項目,即負責解密出原apk并使用代理去啟動原apk的Android項目;還包括包含加固工具的java工具類和簽名的java工具類(以Library形式包含在此項目中,不參打包),負責加密和合并apk,并寫入渠道信息。
加固工具的加密過程:
- 將原項目和解密的項目分別編譯,取原項目生成的apk文件和解密項目生成的dex文件,讀入字節流;
- 使用自己定義的對稱加密算法加密原apk文件的數組流,生成新的數組;
- 將原apk文件流和解密項目dex的文件流進行拼接,生成一個新的dex文件;
- 修改新的dex文件的文件頭,使得新的拼接而成的dex文件格式合法;
代碼:
public static String forceApk() throws Exception {
File payloadSrcFile = new File(payloadSrcFilePath); // 需要加殼的程序
System.out.println("input apk size:" + payloadSrcFile.length());
File unShellDexFile = new File(unShellDexFilePath); // 解客dex
byte[] payloadArray = encrpt(readFileBytes(payloadSrcFile));// 以二進制形式讀出apk,并進行加密處理//對源Apk進行加密操作
byte[] unShellDexArray = readFileBytes(unShellDexFile);// 以二進制形式讀出dex
int payloadLen = payloadArray.length;
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);
File file = new File(outputDexFileName);
if (file.delete()||!file.exists()) {
file.createNewFile();
}
FileOutputStream localFileOutputStream = new FileOutputStream(
outputDexFileName);
localFileOutputStream.write(newdex);
localFileOutputStream.flush();
localFileOutputStream.close();
return replaceDex(outputDexFileName);
}
private static byte[] encrpt(byte[] srcdata) {
for (int i = 0; i < srcdata.length; i++) {
srcdata[i] = (byte) (0xFF ^ srcdata[i]);
}
return srcdata;
}
dex文件需要修改的內容:
- 從上面列出的dex文件頭各結構的含義中可以得知,拼接dex文件之后需要改變dex文件頭的checksum、signature、fileSize字段以保證dex文件合法;
- 新dex文件的大小fileSize,寫在0x20 即新dex文件數組流的第32個元素,占用占四位的長度;
- 新dex的SHA-1簽名signature,寫在0xc處,即0x20往前0x14個長度,所以是12-31位置;
- 新dex的校驗碼checksum寫在0x8處,占四位(8-11);
代碼:
/**
* 修改dex頭 sha1值
*/
private static void fixCheckSumHeader(byte[] dexBytes) {
Adler32 adler = new 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)
}
/**
* 修改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)
}
/**
* 修改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.arraycopy(refs, 0, dexBytes, 32, 4);// 修改(32-35)
}
以上步驟把原apk和殼文件寫成了一個合法的dex文件。接下來需要簽名殼項目代碼生成合法的apk文件,用戶才能把它正常安裝到手機上去。
(所以需要了解apk的構建流程,了解apk打包過程的童鞋可跳過)
APK構建流程
APP的構建流程涉及許多將項目轉換成 Android 應用軟件包 (APK) 的工具和流程。構建流程非常靈活,因此了解它的一些底層工作原理會對我們很有幫助。
典型 Android 應用模塊的構建流程如下:
如上圖,典型 Android 應用模塊的構建流程通常依循下列步驟:
- 編譯器將源代碼轉換成 DEX(Dalvik Executable) 文件(其中包括運行在 Android 設備上的字節碼),將所有其他內容轉換成已編譯資源。
- APK 打包器將 DEX 文件和已編譯資源合并成單個 APK。不過,必須先將APK簽名,才能將應用安裝并部署到 Android 設備上。
- APK 打包器使用調試或發布密鑰庫簽署您的 APK:
- 如果您構建的是調試版本的應用(即專用于測試和分析的應用),打包器會使用調試密鑰庫簽署您的應用。Android Studio 自動使用調試密鑰庫配置新項目。
- 如果您構建的是打算向外發布的發布版本應用,打包器會使用發布密鑰庫簽署您的應用。要創建發布密鑰庫,請閱讀在 Android Studio 中簽署您的應用。
- 在生成最終 APK 之前,打包器會使用 zipalign 工具對應用進行優化,減少其在設備上運行時的內存占用。
構建流程結束時,將獲得可用來進行部署、測試的調試 APK,或者可用來發布給外部用戶的release版本的APK。
回到加固過程,把生成的dex文件替換到殼apk中去,需要先簽名
APK簽名
- 這里使用jarsigner命令簽名:
public static String sign(String apkPath) throws Exception {
String nameFlag = apkPath.replace(".apk", "");
String output = nameFlag + "_singed.apk";
String shell = "jarsigner -verbose -digestalg SHA1 -sigalg MD5withRSA -keystore "
+ keystorePath + " -signedjar " + output + " " + apkPath + " "+ alias + " -storepass " + storepass + " -keypass " + keypass;
System.out.println(shell);
callShell(shell);
return output;
}
public static void callShell(String shellString) throws Exception {
Process process = Runtime.getRuntime().exec(shellString);
int exitValue = process.waitFor();
BufferedReader br = new BufferedReader(new InputStreamReader(process.getInputStream()));
StringBuilder sb = new StringBuilder();
String line;
while ((line = br.readLine()) != null) {
sb.append(line).append("\n");
}
String result = sb.toString();
System.out.println(result);
if (0 != exitValue) {
throw new Exception("call shell failed. error code is :"
+ exitValue);
}
}
Android多渠道打包#
背景
Android應用程序會發布到各個平臺的應用市場上去,以便不同品牌的手機可以方便的在自己的應用市場內下載到想要的apk,但由于Android手機品牌和應用市場非常多,一般大型的app會發布到幾十個甚至更多的應用市場中去。為了統計用戶來源,需要分別統計這些渠道的用戶量或其他屬性,因此需要給apk文件加入特殊標識 以識別應用來源。
如果按照傳統打打包方式,需要修改一次AndroidManifest.xml文件的渠道號重新打包一次,往往幾十個包需要幾個小時甚至更久,效率及其的低下。
為了解決這個問題,業內誕生的較早的多渠道快速打包方案有美團多渠道打包方案。
美團多渠道打包
美團多渠道打包的思路是,先打包并簽名一個沒有渠道標識的apk文件,然后每打一個渠道包復制一個apk文件出來,這個apk文件的META-INF目錄中添加一個使用渠道號命名的空文件即可(v1.0簽名機制下 添加一個空文件不會影響apk文件的簽名),apk安裝后代碼中讀取空文件文件名就可以得到渠道信息了。這種打包方式速度非常快,900多個渠道不到一分鐘就能打完。
增加渠道標識文件:
public static boolean changeChannel(final String zipFilename,
final String channel) {
try (FileSystem zipfs = FileUtils.createZipFileSystem(zipFilename, false)) {
final Path root = zipfs.getPath("/META-INF/");
ChannelFileVisitor visitor = new ChannelFileVisitor();
Files.walkFileTree(root, visitor);
Path existChannel = visitor.getChannelFile();
Path newChannel = zipfs.getPath(CHANNEL_PREFIX + channel);
if (existChannel != null) {
Files.move(existChannel, newChannel, StandardCopyOption.ATOMIC_MOVE);
} else {
Files.createFile(newChannel);
}
return true;
} catch (IOException e) {
e.printStackTrace();
}
return false;
}
操作結果:
(后續在apk中讀取渠道信息的代碼就不展示了,有興趣的可以查看美團多渠道打包方案)
packer-ng打包
原理
packer-ng使用了另一種思路。android使用的apk包的壓縮方式是zip,與zip有相同的文件結構,在zip的核心目錄Central directory file header中包含一個File comment區域,可以存放一些數據。File comment是zip文件如果可以正確的修改這個部分,就可以在不破壞壓縮包、不用重新打包的的前提下快速的給apk文件寫入自己想要的數據。
Central directory file header 即zip核心目錄,記錄了壓縮文件的目錄信息,在這個數據區中每一條紀錄對應在壓縮源文件數據區中的一條數據。
End of central directory record(EOCD) 目錄結束標識存在于整個歸檔包的結尾,用于標記壓縮的目錄數據的結束。每個壓縮文件必須有且只有一個EOCD記錄。zip目錄結束標識結構:
偏移值 | 長度 | 描述 | 說明 |
---|---|---|---|
0 | 4 | End of central directory signature = 0x06054b50 | 核心目錄結束標記(0x06054b50) |
4 | 2 | Number of this disk | 當前磁盤編號 |
6 | 2 | number of the disk with the start of the central directory | 核心目錄開始位置的磁盤編號 |
8 | 2 | total number of entries in the central directory on this disk | 該磁盤上所記錄的核心目錄數量 |
10 | 2 | total number of entries in the central directory | 核心目錄結構總數 |
12 | 2 | Size of central directory (bytes) | 核心目錄的大小 |
16 | 4 | offset of start of central directory with respect to the starting disk number | 核心目錄開始位置相對于archive開始的位移 |
20 | 2 | .ZIP file comment length(n) | 注釋長度 |
22 | n | .ZIP Comment | 注釋內容 |
目錄結束標識區域包含zip comment 區域可以寫入少量信息并不會印象apk簽名,所以可以將渠道數據直接寫在這里。
長度處理
由于數據是不確定的,我們無法知道comment的長度,從表中可以看到zip定義comment的長度的位置在comment之前,所以無法從zip中直接獲取comment的長度。這里我們需要自定義comment的長度,在自定義comment內容的后面添加一個區域儲存comment的長度。將數據寫入comment
public static void writeApk(File file, String comment) {
ZipFile zipFile = null;
ByteArrayOutputStream outputStream = null;
RandomAccessFile accessFile = null;
try {
zipFile = new ZipFile(file);
String zipComment = zipFile.getComment();
if (zipComment != null) {
return;
}
byte[] byteComment = comment.getBytes();
outputStream = new ByteArrayOutputStream();
outputStream.write(byteComment);
outputStream.write(short2Stream((short) byteComment.length));
byte[] data = outputStream.toByteArray();
accessFile = new RandomAccessFile(file, "rw");
accessFile.seek(file.length() - 2);
accessFile.write(short2Stream((short) data.length));
accessFile.write(data);
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
if (zipFile != null) {
zipFile.close();
}
if (outputStream != null) {
outputStream.close();
}
if (accessFile != null) {
accessFile.close();
}
} catch (Exception e) {
}
}
}
- 讀取渠道信息
獲取apk路徑,找到comment開始位置,找到我們自己寫入的渠道信息的長度。讀出寫到comment中的信息。
(后續讀取渠道信息的代碼就不展示了,感興趣的童鞋可以去閱讀PackerNg源碼)
至此,從apk加固到簽名、寫入多渠道數據的整個流程就結束了
后續工作
- 替換殼apk文件的icon圖標為原apk的icon,修改殼apk的配置文件中的包名;
- 搭建加固工具的UI,使其桌面化。