Android簽名與渠道包製作-V2/V3渠道包原理 渠道信息寫入 渠道信息讀取 完整代碼

系列文章:

上一篇文章我們詳細描述了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;
}

基本流程並不複雜,就是:

  1. 通過魔數查找eocd
  2. 在eocd中查找central directory的偏移地址socd
  3. socd往前讀就是APK簽名塊
  4. 在APK簽名塊中插入渠道信息
  5. 由於APK簽名塊在socd的簽名,插入渠道信息後需要將socd往後移
  6. 將修改後的各部分重新寫入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;
}

渠道信息讀取

讀取部分的邏輯也比較清晰:

  1. 通過魔數查找eocd
  2. 在eocd中查找central directory的偏移地址socd
  3. socd往前讀就是APK簽名塊
  4. 跳過APK簽名塊頭8個字節(長度信息)就是第一個id-value鍵值對
  5. 遍歷鍵值對查找渠道信息鍵值對的id
  6. 找到渠道信息鍵值對之後讀取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,我將添加渠道信息的操作放到了單元測試裏,編譯完之後執行插入渠道信息。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章