系列文章:
偶然發現安卓的簽名V3已經出到了版本,想想自己其實也沒有太深入瞭解過v1、v2。本着查漏補缺的想法把三個版本的原理都過了一遍,並且利用簽名的原理手擼了渠道包製作的demo。這系列的文章就帶大家深入瞭解下各個版本的簽名和渠道包製作原理。
這篇我們先來看看V1版本的原理。
V1簽名原理
首先我們要知道用v1簽名的apk包其實就是一個普通的zip壓縮包,我們將後綴改成.zip就可以直接解壓。解壓出來可以在META-INF目錄下看到MANIFEST.MF、CERT.SF、CERT.RSA這三個文件,V1簽名就是靠的這三個文件來驗證的。
MANIFEST.MF
MANIFEST.MF長這個樣子,它記錄了apk所有原始文件的數據摘要的Base64編碼:
Manifest-Version: 1.0
Built-By: Generated-by-ADT
Created-By: Android Gradle 3.5.1
Name: AndroidManifest.xml
SHA-256-Digest: 6gizONW6AQK41R0kXhGh+M60wBxPA06WFrq5KSWrB24=
Name: META-INF/androidx.appcompat_appcompat.version
SHA-256-Digest: n9KGQtOsoZHlx/wjg8/W+rsqrIdD8Cnau4mJrFhOMbw=
...
正如我們所說,apk是個普通的壓縮包,我們解壓完修改裏面的內容(如圖片)再壓縮回去,它仍然符合apk文件格式可以用於安裝。但是V1簽名在安裝的時候會用MANIFEST.MF去檢查原始文件是否被修改,如果被修改就拒絕安裝。
CERT.SF
當然我們也可以將MANIFEST.MF一起修改了,但是安卓還會通過CERT.SF去檢查MANIFEST.MF是否被修改。
CERT.SF長這樣:
Signature-Version: 1.0
Created-By: 1.0 (Android)
SHA-256-Digest-Manifest: aed+nGnbmO5m79Dy1aNQ68aTFC9N5EyZj8kOeE56yyU=
Name: AndroidManifest.xml
SHA-256-Digest: QA9D/hXYs4aCJcZ4nZ8kLP2RnPn/kw15girRaw7xdng=
Name: META-INF/androidx.appcompat_appcompat.version
SHA-256-Digest: ABbgKP0s08CVeuJ5ZMlIZx/AvJtb1QhNA0ffeXfCaHk=
Name: META-INF/androidx.arch.core_core-runtime.version
SHA-256-Digest: PjygIQMN5T6nIKT/hi5PFaxVcEB+W20fr4f0g2n7jrg=
...
它將MANIFEST.MF整個文件和裏面的每一項的摘要信息又做一次SHA摘要和Base64編碼記錄起來。例如CERT.SF第一個AndroidManifest.xml的SHA-256-Digest代表的其實是下面內容SHA摘要的Base64編碼:
Name: AndroidManifest.xml
SHA-256-Digest: 6gizONW6AQK41R0kXhGh+M60wBxPA06WFrq5KSWrB24=
\r\n
PS: 最後一行的\r\n也是要參與計算的。
CERT.RSA
同樣的道理在修改apk的時候我們也可以將MANIFEST.MF和CERT.SF一併修改了。這個時候就輪到CERT.RSA出馬了。
進行V1簽名的時候會先計算CERT.SF的摘要,然後用開發者的私鑰計算數字簽名,然後將數字簽名、開發者公鑰等信息保存到CERT.RSA,在安裝的時候就能進行驗證。如果沒有私鑰,修改完CERT.SF就沒有辦法同步修改CERT.RSA。
簽名與校驗流程
通過上面的介紹我們能總結出V1版本的簽名和校驗流程:
簽名流程:
- 計算每個原始文件的SHA摘要,用Base64編碼保存到MANIFEST.MF
- 對MANIFEST.MF的整個文件和裏面的每一項信息再進行SHA摘要,用Base64編碼保存到CERT.SF
- 計算CERT.SF的摘要並使用開發者的私鑰加密計算出數字簽名,將該數字簽名和開發者公鑰等信息保存到CERT.RSA
驗證流程:
- 在CERT.RSA讀取公鑰和CERT.SF的數字簽名,計算CERT.SF的摘要
- 驗證CERT.SF是否被修改
- 通過CERT.SF驗證MANIFEST.MF是否被修改
- 通過MANIFEST.MF驗證原始文件是否被修改
渠道包原理
由於國內的應用市場衆多,一般需要打多個渠道包上傳,這些渠道包會保存該渠道的一些信息。雖然我們可以通過gradle的productFlavors去編譯多個包,但是由於這種機制沒生成一個渠道包都要走一遍編譯流程,耗時比較多。而且一般會生成不同的BuildConfig.java類導致dex不同,如果使用Tinker需要對不同的渠道包都單獨做差異包去熱修復。
所以一般都不會用這種方式去打渠道包,而是在編譯完之後在apk裏面插入渠道信息。
剛剛我們也有講到V1簽名會對apk裏面的文件進行校驗,但是這裏有個漏洞就是它是對原始文件進行校驗,對整個apk包沒有做校驗。所以我們可以在apk包中插入渠道信息。
zip包格式
使用將數據直接插入apk文件的方式,我們先要了解下apk(也就是zip包)的文件格式:
它主要分成了上面的三個部分,而我們的突破口就在最後一部分,我們來看看這部分的詳細格式:
內容 | 大小 |
---|---|
end of central dir signature (0x06054b50) | 4 bytes |
number of this disk | 2 bytes |
number of the disk with the start of the central directory | 2 bytes |
total number of entries in the central directory on this disk | 2 bytes |
total number of entries in the central directory | 2 bytes |
size of the central directory | 4 bytes |
offset of start of central directory with respect to the starting disk number | 4 bytes |
.ZIP file comment length | 2 bytes |
.ZIP file comment | (variable size) |
這部分我們簡稱eocd,它以一個魔數0x06054b50打頭,後面帶了一些zip包的描述。其中對我們最重要的是最後的.ZIP file comment length和.ZIP file comment。
zip包是可以在末尾攜帶描述信息的。描述信息的長度在.ZIP file comment length字段中獲取。所以我們可以將渠道信息寫到.ZIP file comment裏。我這裏參考VasDolly的實現原理將渠道信息格式定義成下面的樣子插入到apk包的最末尾:
於是我們在運行的時候就能通過讀取apk包的結尾4個字節看看是否能讀到我們定義的魔數判斷有無渠道信息,如果有的話往前兩個字節讀渠道信息的長度,最後根據長度再往前讀取渠道信息。
渠道信息的寫入
這邊實現了個Demo,我們直接來看看代碼:
public boolean addChannelInfo(String srcApk, String outputApk, String channelInfo) {
RandomAccessFile zipFile = null;
FileOutputStream fos = null;
FileChannel srcChannel = null;
FileChannel dstChannel = null;
try {
zipFile = new RandomAccessFile(new File(srcApk), "r");
srcChannel = zipFile.getChannel();
fos = new FileOutputStream(outputApk);
dstChannel = fos.getChannel();
// 查找eocd
ByteBuffer originEocd = Utils.findEocd(srcChannel);
if (originEocd == null) {
return false;
}
// 往eocd插入渠道信息得到新的eocd
ByteBuffer newEocd = addChannelInfo(originEocd, channelInfo);
// eocd前面的數據是沒有改到的,直接拷貝就好
Utils.copyByLength(srcChannel, dstChannel, zipFile.length() - originEocd.capacity());
// 往後插入新的eocd
dstChannel.write(newEocd);
} catch (Exception e) {
e.printStackTrace();
return false;
} finally {
Utils.safeClose(srcChannel, zipFile, dstChannel, fos);
}
return true;
}
eocd的讀取很簡單,從後往前查找eocd魔數即可:
public static ByteBuffer findEocd(FileChannel zipFile) throws IOException {
// end of central directory record 是整個zip包的結尾
// 而且它以0x06054b50這個魔數做起始,所以只需從後往前遍歷找到這個魔數,即可截取整個EOCD
//
// [zip包其餘內容] ...
//
// [EOCD] end of central dir signature (0x06054b50)
// eocd其餘部分
try {
if (zipFile.size() < Utils.EOCD_MIN_LENGTH) {
return null;
}
int length = (int) Math.min(Utils.EOCD_MAX_LENGTH, zipFile.size());
ByteBuffer buffer = ByteBuffer.allocate(length);
buffer.order(ByteOrder.LITTLE_ENDIAN);
zipFile.read(buffer, zipFile.size() - length);
for (int i = length - Utils.EOCD_MIN_LENGTH; i >= 0; i--) {
if (buffer.getInt(i) == Utils.EOCD_SIG) {
buffer.position(i);
return buffer.slice().order(ByteOrder.LITTLE_ENDIAN);
}
}
System.out.println("return null");
return null;
} finally {
zipFile.position(0);
}
}
如果apk本身沒有帶描述,我們主需要直接讀最後的22個字節就好,但是爲了兼容帶描述的情況,我們還是通過查找魔數的方式定位eocd。
.ZIP file comment length只有2字節,所以描述長度最多有0xffff,然後加上eocd前固定的22個字節就得到eocd可能的最大長度
public static final int EOCD_MAX_LENGTH = 0xffff + 22;
所以我們直接從apk最後讀取這麼多個字節去遍歷就好。
查到到eocd之後我們在最後插入渠道信息,然後同步修改.ZIP file comment length字段:
private static ByteBuffer addChannelInfo(ByteBuffer eocd, String channelInfo) {
// end of central directory record 的格式如下:
//
// end of central dir signature 4 bytes (0x06054b50)
// number of this disk 2 bytes
// number of the disk with the start of the central directory 2 bytes
// total number of entries in the central directory on this disk 2 bytes
// total number of entries in the central directory 2 bytes
// size of the central directory 4 bytes
// offset of start of central directory with respect to the starting disk number 4 bytes
// .ZIP file comment length 2 bytes
// .ZIP file comment (variable size)
//
// 我們可以在.ZIP file comment裏面插入渠道信息塊:
//
// 渠道信息 大小記錄在[渠道信息長度]中
// 渠道信息長度 2字節
// 魔數 4字節
//
// 魔數放在最後面方便我們讀取判斷是否有渠道信息
short infoLength = (short) channelInfo.getBytes().length;
short channelBlockSize = (short) (infoLength // 渠道信息
+ Short.BYTES // 渠道信息長度
+ Integer.BYTES); // 渠道信息魔數
ByteBuffer buffer = ByteBuffer.allocate(eocd.capacity() + channelBlockSize);
buffer.order(ByteOrder.LITTLE_ENDIAN);
// eocd前面部分的數據我們沒有改動,直接拷貝就好
byte[] bytes = new byte[Utils.EOCD_MIN_LENGTH - Utils.EOCD_SIZE_OF_COMMENT_LENGTH];
eocd.get(bytes);
buffer.put(bytes);
// 由於插入了渠道信息塊,zip包的註釋長度需要相應的增加
buffer.putShort((short) (eocd.getShort() + channelBlockSize));
// 拷貝原本的zip包註釋
eocd.position(Utils.EOCD_MIN_LENGTH);
buffer.put(eocd);
// 插入渠道包信息塊
buffer.put(channelInfo.getBytes()); // 渠道信息
buffer.putShort(infoLength); // 渠道信息長度
buffer.putInt(Utils.CHANNEL_INFO_SIG); // 魔數
buffer.flip();
return buffer;
}
渠道信息的讀取
講完渠道信息的寫入,我們再來看看運行的時候怎麼去讀取渠道信息:
public String getChannelInfo(Context context) {
String apkPath = Utils.getApkPath(context);
if (apkPath == null) {
return null;
}
RandomAccessFile apk = null;
try {
apk = new RandomAccessFile(apkPath, "r");
// 讀取apk的結尾4字節看看是否爲渠道信息魔數判斷是否有渠道信息
long sigPosition = apk.length() - Integer.BYTES;
int sig = Utils.readInt(apk, sigPosition);
if (sig != Utils.CHANNEL_INFO_SIG) {
return null;
}
// 再往前讀兩個字節獲取渠道信息的長度
long lengthPosition = sigPosition - Short.BYTES;
short length = Utils.readShort(apk, lengthPosition);
if (length <= 0) {
return null;
}
// 根據長度讀取渠道信息
long infoPosition = lengthPosition - length;
return Utils.readString(apk, infoPosition, length);
} catch (Exception e) {
e.printStackTrace();
} finally {
Utils.safeClose(apk);
}
return null;
}
流程很簡單:
- 判斷apk結尾4個字節是否爲渠道信息魔數
- 獲取渠道信息長度
- 讀取渠道信息