系列文章:
上一篇文章我們詳細描述了V2/V3簽名的原理,大概的原理的就是在APK中插入簽名塊保存簽名信息:
APK簽名塊的格式如下:
V2的簽名數據id爲0x7109871a,V3的簽名數據id爲0xf05368c0。由於校驗流程裏面對APK本身做了校驗,所以V1版本的添加zip file comment的方法就失效了。
但是由於V2/V3簽名對這個APK簽名塊是沒有做校驗的,所以我們可以添加一個自定義的渠道包信息鍵值對保存渠道信息。這篇文章就通過分析Demo代碼來講解V2/V3渠道包的原理。
渠道信息寫入
基本原理就是渠道信息打包成一個id-value鍵值對放到鍵值對序列的最後:
插入的代碼如下:
@Override
public boolean addChannelInfo(String srcApk, String outputApk, String channelInfo) {
// [APK簽名塊]插入在[central directory]之前,而[central directory]的起始位置可以在[EOCD]的socdOffset部分讀取
// 我們在[APK簽名塊]裏面插入渠道信息,會影響到[central directory]的位置,
// 所以需要同步修改[EOCD]裏面的socdOffset
//
// [zip包其餘內容](不變) ...
//
// 1. APK簽名塊大小(不包含自己的8個字節) 8字節
// [APK簽名塊](需要插入渠道信息) 2. ID-Value鍵值對 大小可變
// 3. APK簽名塊大小(和第1部分相等) 8字節
// 4. 魔法數(固定爲字符串"APK Sig Block 42") 16字節
// <--------------------------
// [central directory](不變) ... |
// |
// end of central dir signature |
// ... |
// [EOCD](需要修改socdOffset) socdOffset ------------------------
// ...
//
if (channelInfo == null || channelInfo.isEmpty()) {
return true;
}
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 eocd = Utils.findEocd(srcChannel);
if (eocd == null) {
return false;
}
// 獲取舊的APK簽名塊
long socdOffset = Utils.getSocdOffset(eocd);
Utils.Pair<Long, ByteBuffer> oldSignV2Block = Utils.getSignV2Block(zipFile, socdOffset);
if (oldSignV2Block == null) {
return false;
}
// 往APK簽名塊插入渠道信息,得到新的APK簽名塊
ByteBuffer newSignV2Block = addChannelInfo(oldSignV2Block.second, channelInfo);
// 修改eocd中的socd
changeSocdOffset(eocd, channelInfo);
// APK簽名塊前的數據是沒有改過的,可以直接拷貝
srcChannel.position(0);
Utils.copyByLength(srcChannel, dstChannel, oldSignV2Block.first);
// 往後插入新的APK簽名塊的數據
dstChannel.write(newSignV2Block);
// 往後插入[central directory]的數據,這部分也是沒有修改的
srcChannel.position(socdOffset);
Utils.copyByLength(srcChannel, dstChannel, srcChannel.size() - socdOffset - eocd.capacity());
// 往後插入修改後的eocd
eocd.position(0);
dstChannel.write(eocd);
} catch (Exception e) {
e.printStackTrace();
return false;
} finally {
Utils.safeClose(srcChannel, zipFile, dstChannel, fos);
}
return true;
}
基本流程並不複雜,就是:
- 通過魔數查找eocd
- 在eocd中查找central directory的偏移地址socd
- socd往前讀就是APK簽名塊
- 在APK簽名塊中插入渠道信息
- 由於APK簽名塊在socd的簽名,插入渠道信息後需要將socd往後移
- 將修改後的各部分重新寫入apk
socd的修改很簡單,讀取原來的值加上渠道信息鍵值對的長度重新寫入即可:
private void changeSocdOffset(ByteBuffer eocd, String channelInfo) {
// 由於APK簽名塊在socd offset的前面
// 而我們又在APK簽名塊裏面插入了渠道信息
// 所以socd offset應該再往後移動插入的渠道信息鍵值對的大小
// 讀取原本的socd offset
eocd.position(Utils.EOCD_POSITION_SOCD_OFFSET);
int originOffset = eocd.getInt();
// 鍵值對格式如下:
//
// 鍵值對長度(不包含自己的8個字節) 8字節
// ID 4字節
// Value 鍵值對長度-ID的4字節
//
// 所以應該加上鍵值對長度(8字節)、ID長度(4字節)、渠道信息長度
eocd.position(Utils.EOCD_POSITION_SOCD_OFFSET);
eocd.putInt(originOffset + Long.BYTES + Integer.BYTES + channelInfo.getBytes().length);
}
往APK簽名塊中插入渠道信息稍微複雜一點。我們想將渠道信息插入到id-value鍵值對的最後,但是這裏並沒有去遍歷這個鍵值對序列,而是用了一種取巧的方式。
由於鍵值對直接並沒有什麼指針關係去指定下一個鍵值對,僅僅只是將將它們排列在一起,所以我們只需要從APK簽名塊的末尾往前跳過16字節的魔數和8字節的長度信息就能找到插入位置的地址偏移:
由於插入之後APK簽名塊的長度會增加,所以長度信息需要同步修改,完整的插入代碼如下:
private static ByteBuffer addChannelInfo(ByteBuffer oldSignV2BlockSize, String channelInfo) {
// ID-Value鍵值對的格式如下:
//
// 鍵值對長度(不包含自己的8個字節) 8字節
// ID 4字節
// Value 鍵值對長度-ID的4字節
// 所以整個ID-Value鍵的長度應該是 Value長度 + ID長度(4字節) + 鍵值對長度(8字節)
long infoLength = channelInfo.getBytes().length;
long channelBlockRealSize = Long.BYTES + Integer.BYTES + infoLength;
ByteBuffer buffer = ByteBuffer.allocate((int) (oldSignV2BlockSize.capacity() + channelBlockRealSize));
buffer.order(ByteOrder.LITTLE_ENDIAN);
// 先將原本的APK完整拷貝出來
oldSignV2BlockSize.position(0);
buffer.put(oldSignV2BlockSize);
// 讀取原本的APK簽名塊長度
oldSignV2BlockSize.position(0);
long originSize = oldSignV2BlockSize.getLong();
// 該長度要加上插入的渠道信息鍵值對長度
buffer.position(0);
buffer.putLong(originSize + channelBlockRealSize);
// APK簽名塊結構如下:
//
// 1. APK簽名塊大小(不包含自己的8個字節) 8字節
// 2. ID-Value鍵值對 大小可變
// 3. APK簽名塊大小(和第1部分相等) 8字節
// 4. 魔法數(固定爲字符串"APK Sig Block 42") 16字節
// 我們把渠道包鍵值對放到整個APK簽名塊的最後
// 所以從後往前減去魔法數的16字節,減去APK簽名塊大小的8字節
// 定位到渠道包鍵值的起始位置
long magicNumberSize = Utils.SIG_V2_MAGIC_NUMBER.getBytes().length;
buffer.position((int) (oldSignV2BlockSize.capacity() - magicNumberSize - Long.BYTES));
// 插入渠道包鍵值對數據
buffer.putLong(infoLength + Integer.BYTES);
buffer.putInt(Utils.CHANNEL_INFO_SIG);
buffer.put(channelInfo.getBytes());
// 插入APK簽名塊長度
buffer.putLong(originSize + channelBlockRealSize);
// 插入魔法數
buffer.put(Utils.SIG_V2_MAGIC_NUMBER.getBytes());
buffer.flip();
return buffer;
}
渠道信息讀取
讀取部分的邏輯也比較清晰:
- 通過魔數查找eocd
- 在eocd中查找central directory的偏移地址socd
- socd往前讀就是APK簽名塊
- 跳過APK簽名塊頭8個字節(長度信息)就是第一個id-value鍵值對
- 遍歷鍵值對查找渠道信息鍵值對的id
- 找到渠道信息鍵值對之後讀取value部分返回即可
public String getChannelInfo(Context context) {
String apkPath = Utils.getApkPath(context);
if (apkPath == null) {
return null;
}
RandomAccessFile apk = null;
try {
apk = new RandomAccessFile(apkPath, "r");
// 查找eocd
ByteBuffer eocd = Utils.findEocd(apk.getChannel());
if (eocd == null) {
return null;
}
// 獲取APK簽名塊
Utils.Pair<Long, ByteBuffer> signV2Block = Utils.getSignV2Block(apk, Utils.getSocdOffset(eocd));
if (signV2Block == null) {
return null;
}
// APK簽名塊結構如下:
//
// 1. APK簽名塊大小(不包含自己的8個字節) 8字節
// 2. ID-Value鍵值對(有多個鍵值對) 大小可變
// 2.1 鍵值對長度(不包含自己的8個字節) 8字節
// 2.2 ID 4字節
// 2.3 Value 鍵值對長度-ID的4字節
// 3. APK簽名塊大小(和第1部分相等) 8字節
// 4. 魔法數(固定爲字符串"APK Sig Block 42") 16字節
int id;
long length,realLength;
long positionLimit = signV2Block.second.capacity()
- Long.BYTES // APK簽名塊大小的長度(8字節)
- Utils.SIG_V2_MAGIC_NUMBER.getBytes().length; // 結尾魔數的長度(16字節)
int position = Long.BYTES; // 跳過開頭APK簽名塊大小的8字節纔是第一個ID-Value鍵值對
do {
signV2Block.second.position(position);
// 讀取鍵值對長度(不包含自己的8個字節)
length = signV2Block.second.getLong();
// 鍵值對長度是不包含長度信息的8個字節的,所以要加上這8個字節
realLength = Long.BYTES + length;
// 讀取ID
id = signV2Block.second.getInt();
// 移動到下一個鍵值對
position += realLength;
// 判斷是否找到渠道信息鍵值對的ID,或者已經遍歷完整個APK簽名塊
} while (id != Utils.CHANNEL_INFO_SIG && position <= positionLimit);
if (id == Utils.CHANNEL_INFO_SIG) {
// 如果可以找到渠道信息鍵值對,往後讀取就可以讀到渠道信息
// 鍵值對長度是包含ID的四個字節的,要減去
return Utils.readString(signV2Block.second, (int) (length - Integer.BYTES));
}
return null;
} catch (Exception e) {
e.printStackTrace();
} finally {
Utils.safeClose(apk);
}
return null;
}
完整代碼
由於V2/V3的原理其實是大致相同的,都是在APK簽名塊裏面插入id-value鍵值對,所以我們這個做法是能夠兼容V2/V3版本的簽名的。完整的demo已經上傳到Github,我將添加渠道信息的操作放到了單元測試裏,編譯完之後執行插入渠道信息。