Android V1及V2簽名原理簡析

看書的小蝸牛發表於2019-05-08

Android為了保證系統及應用的安全性,在安裝APK的時候需要校驗包的完整性,同時,對於覆蓋安裝的場景還要校驗新舊是否匹配,這兩者都是通過Android簽名機制來進行保證的,本文就簡單看下Android的簽名與校驗原理,分一下幾個部分分析下:

  • APK簽名是什麼
  • APK簽名如何保證APK資訊完整性
  • 如何為APK簽名
  • APK簽名怎麼校驗

Android的APK簽名是什麼

簽名是摘要與非對稱金鑰加密相相結合的產物,摘要就像內容的一個指紋資訊,一旦內容被篡改,摘要就會改變,簽名是摘要的加密結果,摘要改變,簽名也會失效。Android APK簽名也是這個道理,如果APK簽名跟內容對應不起來,Android系統就認為APK內容被篡改了,從而拒絕安裝,以保證系統的安全性。目前Android有三種簽名V1、V2(N)、V3(P),本文只看前兩種V1跟V2,對於V3的輪密先不考慮。先看下只有V1簽名後APK的樣式:

image.png

再看下只有V2簽名的APK包樣式:

image.png

同時具有V1 V2簽名:

image.png

可以看到,如果只有V2簽名,那麼APK包內容幾乎是沒有改動的,META_INF中不會有新增檔案,按Google官方文件:在使用v2簽名方案進行簽名時,會在APK檔案中插入一個APK簽名分塊,該分塊位於zip中央目錄部分之前並緊鄰該部分。在APK簽名分塊內,簽名和簽名者身份資訊會儲存在APK簽名方案v2分塊中,保證整個APK檔案不可修改,如下圖:

image.png

而V1簽名是通過META-INF中的三個檔案保證簽名及資訊的完整性:

image.png

APK簽名如何保證APK資訊完整性

V1簽名是如何保證資訊的完整性呢?V1簽名主要包含三部分內容,如果狹義上說簽名跟公鑰的話,僅僅在.rsa檔案中,V1簽名的三個檔案其實是一套機制,不能單單拿一個來說事,

MANIFEST.MF:摘要檔案,儲存檔名與檔案SHA1摘要(Base64格式)鍵值對,格式如下,其主要作用是保證每個檔案的完整性

摘要

如果對APK中的資原始檔進行了替換,那麼該資源的摘要必定發生改變,如果沒有修改MANIFEST.MF中的資訊,那麼在安裝時候V1校驗就會失敗,無法安裝,不過如果篡改檔案的同時,也修改其MANIFEST.MF中的摘要值,那麼MANIFEST.MF校驗就可以繞過。

CERT.SF:二次摘要檔案,儲存檔名與MANIFEST.MF摘要條目的SHA1摘要(Base64格式)鍵值對,格式如下

image.png

CERT.SF個人覺得有點像冗餘,更像對檔案完整性的二次保證,同繞過MANIFEST.MF一樣,.SF校驗也很容易被繞過。

CERT.RSA 證照(公鑰)及簽名檔案,儲存keystore的公鑰、發行資訊、以及對CERT.SF檔案摘要的簽名資訊(利用keystore的私鑰進行加密過)

CERT.RSA與CERT.SF是相互對應的,兩者名字字首必須一致,不知道算不算一個無聊的標準。看下CERT.RSA檔案內容:

image.png

CERT.RSA檔案裡面儲存了證照公鑰、過期日期、發行人、加密演算法等資訊,根據公鑰及加密演算法,Android系統就能計算出CERT.SF的摘要資訊,其嚴格的格式如下:

X.509證照格式

從CERT.RSA中,我們能獲的證照的指紋資訊,在微信分享、第三方SDK申請的時候經常用到,其實就是公鑰+開發者資訊的一個簽名:

image.png

除了CERT.RSA檔案,其餘兩個簽名檔案其實跟keystore沒什麼關係,主要是檔案自身的摘要及二次摘要,用不同的keystore進行簽名,生成的MANIFEST.MF與CERT.SF都是一樣的,不同的只有CERT.RSA簽名檔案。也就是說前兩者主要保證各個檔案的完整性,CERT.RSA從整體上保證APK的來源及完整性,不過META_INF中的檔案不在校驗範圍中,這也是V1的一個缺點。V2簽名又是如何保證資訊的完整性呢?

V2簽名塊如何保證APK的完整性

前面說過V1簽名中檔案的完整性很容易被繞過,可以理解單個檔案完整性校驗的意義並不是很大,安裝的時候反而耗時,不如採用更加簡單的便捷的校驗方式。V2簽名就不針對單個檔案校驗了,而是針對APK進行校驗,將APK分成1M的塊,對每個塊計算值摘要,之後針對所有摘要進行摘要,再利用摘要進行簽名。

image.png

也就是說,V2摘要簽名分兩級,第一級是對APK檔案的1、3 、4 部分進行摘要,第二級是對第一級的摘要集合進行摘要,然後利用祕鑰進行簽名。安裝的時候,塊摘要可以並行處理,這樣可以提高校驗速度。

簡單的APK簽名流程(簽名原理)

APK是先摘要,再簽名,先看下摘要的定義:Message Digest:摘要是對訊息資料執行一個單向Hash,從而生成一個固定長度的Hash值,這個值就是訊息摘要,至於常聽到的MD5、SHA1都是摘要演算法的一種。理論上說,摘要一定會有碰撞,但只要保證有限長度內碰撞率很低就可以,這樣就能利用摘要來保證訊息的完整性,只要訊息被篡改,摘要一定會發生改變。但是,如果訊息跟摘要同時被修改,那就無從得知了。

而數字簽名是什麼呢(公鑰數字簽名),利用非對稱加密技術,通過私鑰對摘要進行加密,產生一個字串,這個字串+公鑰證照就可以看做訊息的數字簽名,如RSA就是常用的非對稱加密演算法。在沒有私鑰的前提下,非對稱加密演算法能確保別人無法偽造簽名,因此數字簽名也是對傳送者資訊真實性的一個有效證明。不過由於Android的keystore證照是自簽名的,沒有第三方權威機構認證,使用者可以自行生成keystore,Android簽名方案無法保證APK不被二次簽名。

知道了摘要跟簽名的概念後,再來看看Android的簽名檔案怎麼來的?如何影響原來APK包?通過sdk中的apksign來對一個APK進行簽名的命令如下:

 ./apksigner sign  --ks   keystore.jks  --ks-key-alias keystore  --ks-pass pass:XXX  --key-pass pass:XXX  --out output.apk input.apk
複製程式碼

其主要實現在 android/platform/tools/apksig 資料夾中,主體是ApkSigner.java的sign函式,函式比較長,分幾步分析

private void sign(
        DataSource inputApk,
        DataSink outputApkOut,
        DataSource outputApkIn)
                throws IOException, ApkFormatException, NoSuchAlgorithmException,
                        InvalidKeyException, SignatureException {
    // Step 1. Find input APK's main ZIP sections
    ApkUtils.ZipSections inputZipSections;
    <!--根據zip包的結構,找到APK中包內容Object-->
    try {
        inputZipSections = ApkUtils.findZipSections(inputApk);
    ...
複製程式碼

先來看這一步,ApkUtils.findZipSections,這個函式主要是解析APK檔案,獲得ZIP格式的一些簡單資訊,並返回一個ZipSections,

 public static ZipSections findZipSections(DataSource apk)
            throws IOException, ZipFormatException {
        Pair<ByteBuffer, Long> eocdAndOffsetInFile =
                ZipUtils.findZipEndOfCentralDirectoryRecord(apk);
        ByteBuffer eocdBuf = eocdAndOffsetInFile.getFirst();
        long eocdOffset = eocdAndOffsetInFile.getSecond();
        eocdBuf.order(ByteOrder.LITTLE_ENDIAN);
        long cdStartOffset = ZipUtils.getZipEocdCentralDirectoryOffset(eocdBuf);
        ...
        long cdSizeBytes = ZipUtils.getZipEocdCentralDirectorySizeBytes(eocdBuf);
        long cdEndOffset = cdStartOffset + cdSizeBytes;
        int cdRecordCount = ZipUtils.getZipEocdCentralDirectoryTotalRecordCount(eocdBuf);
        return new ZipSections(
                cdStartOffset,
                cdSizeBytes,
                cdRecordCount,
                eocdOffset,
                eocdBuf);
    }
複製程式碼

ZipSections包含了ZIP檔案格式的一些資訊,比如中央目錄資訊、中央目錄結尾資訊等,對比到zip檔案格式如下:

image.png

獲取到 ZipSections之後,就可以進一步解析APK這個ZIP包,繼續走後面的簽名流程,

    long inputApkSigningBlockOffset = -1;
    DataSource inputApkSigningBlock = null;
    <!--檢查V2簽名是否存在-->
    try {
        Pair<DataSource, Long> apkSigningBlockAndOffset =
                V2SchemeVerifier.findApkSigningBlock(inputApk, inputZipSections);
        inputApkSigningBlock = apkSigningBlockAndOffset.getFirst();
        inputApkSigningBlockOffset = apkSigningBlockAndOffset.getSecond();
    } catch (V2SchemeVerifier.SignatureNotFoundException e) {
    <!--V2簽名不存在也沒什麼問題,非必須-->
}
 <!--獲取V2簽名以外的資訊區域-->
 DataSource inputApkLfhSection =
            inputApk.slice(
                    0,
                    (inputApkSigningBlockOffset != -1)
                            ? inputApkSigningBlockOffset
                            : inputZipSections.getZipCentralDirectoryOffset());
複製程式碼

可以看到先進行了一個V2簽名的檢驗,這裡是用來簽名,為什麼先檢驗了一次?第一次簽名的時候會直接走這個異常邏輯分支,重複簽名的時候才能獲到取之前的V2簽名,懷疑這裡獲取V2簽名的目的應該是為了排除V2簽名,並獲取V2簽名以外的資料塊,因為簽名本身不能被算入到簽名中,之後會解析中央目錄區,構建一個DefaultApkSignerEngine用於簽名

      <!--解析中央目錄區,目的是為了解析AndroidManifest-->
    // Step 2. Parse the input APK's ZIP Central Directory
    ByteBuffer inputCd = getZipCentralDirectory(inputApk, inputZipSections);
    List<CentralDirectoryRecord> inputCdRecords =
            parseZipCentralDirectory(inputCd, inputZipSections);

    // Step 3. Obtain a signer engine instance
    ApkSignerEngine signerEngine;
    if (mSignerEngine != null) {
        signerEngine = mSignerEngine;
    } else {
        // Construct a signer engine from the provided parameters
        ...
        List<DefaultApkSignerEngine.SignerConfig> engineSignerConfigs =
                new ArrayList<>(mSignerConfigs.size());
        <!--一般就一個-->
        for (SignerConfig signerConfig : mSignerConfigs) {
            engineSignerConfigs.add(
                    new DefaultApkSignerEngine.SignerConfig.Builder(
                            signerConfig.getName(),
                            signerConfig.getPrivateKey(),
                            signerConfig.getCertificates())
                            .build());
        }
        <!--預設V1 V2都啟用-->
        DefaultApkSignerEngine.Builder signerEngineBuilder =
                new DefaultApkSignerEngine.Builder(engineSignerConfigs, minSdkVersion)
                        .setV1SigningEnabled(mV1SigningEnabled)
                        .setV2SigningEnabled(mV2SigningEnabled)
                        .setOtherSignersSignaturesPreserved(mOtherSignersSignaturesPreserved);
        if (mCreatedBy != null) {
            signerEngineBuilder.setCreatedBy(mCreatedBy);
        }
        signerEngine = signerEngineBuilder.build();
    }
複製程式碼

先解析中央目錄區,獲取AndroidManifest檔案,獲取minSdkVersion(影響簽名演算法),並構建DefaultApkSignerEngine,預設情況下V1 V2簽名都是開啟的。

    // Step 4. Provide the signer engine with the input APK's APK Signing Block (if any)
    <!--忽略這一步-->
    if (inputApkSigningBlock != null) {
        signerEngine.inputApkSigningBlock(inputApkSigningBlock);
    }

    // Step 5. Iterate over input APK's entries and output the Local File Header + data of those
    // entries which need to be output. Entries are iterated in the order in which their Local
    // File Header records are stored in the file. This is to achieve better data locality in
    // case Central Directory entries are in the wrong order.
    List<CentralDirectoryRecord> inputCdRecordsSortedByLfhOffset =
            new ArrayList<>(inputCdRecords);
    Collections.sort(
            inputCdRecordsSortedByLfhOffset,
            CentralDirectoryRecord.BY_LOCAL_FILE_HEADER_OFFSET_COMPARATOR);
    int lastModifiedDateForNewEntries = -1;
    int lastModifiedTimeForNewEntries = -1;
    long inputOffset = 0;
    long outputOffset = 0;
    Map<String, CentralDirectoryRecord> outputCdRecordsByName =
            new HashMap<>(inputCdRecords.size());
    ...

    // Step 6. Sort output APK's Central Directory records in the order in which they should
    // appear in the output
    List<CentralDirectoryRecord> outputCdRecords = new ArrayList<>(inputCdRecords.size() + 10);
    for (CentralDirectoryRecord inputCdRecord : inputCdRecords) {
        String entryName = inputCdRecord.getName();
        CentralDirectoryRecord outputCdRecord = outputCdRecordsByName.get(entryName);
        if (outputCdRecord != null) {
            outputCdRecords.add(outputCdRecord);
        }
    }
複製程式碼

第五步與第六步的主要工作是:apk的預處理,包括目錄的一些排序之類的工作,應該是為了更高效處理簽名,預處理結束後,就開始簽名流程,首先做的是V1簽名(預設存在,除非主動關閉):

    // Step 7. Generate and output JAR signatures, if necessary. This may output more Local File
    // Header + data entries and add to the list of output Central Directory records.
    ApkSignerEngine.OutputJarSignatureRequest outputJarSignatureRequest =
            signerEngine.outputJarEntries();
    if (outputJarSignatureRequest != null) {
        if (lastModifiedDateForNewEntries == -1) {
            lastModifiedDateForNewEntries = 0x3a21; // Jan 1 2009 (DOS)
            lastModifiedTimeForNewEntries = 0;
        }
        for (ApkSignerEngine.OutputJarSignatureRequest.JarEntry entry :
                outputJarSignatureRequest.getAdditionalJarEntries()) {
            String entryName = entry.getName();
            byte[] uncompressedData = entry.getData();
            ZipUtils.DeflateResult deflateResult =
                    ZipUtils.deflate(ByteBuffer.wrap(uncompressedData));
            byte[] compressedData = deflateResult.output;
            long uncompressedDataCrc32 = deflateResult.inputCrc32;

            ApkSignerEngine.InspectJarEntryRequest inspectEntryRequest =
                    signerEngine.outputJarEntry(entryName);
            if (inspectEntryRequest != null) {
                inspectEntryRequest.getDataSink().consume(
                        uncompressedData, 0, uncompressedData.length);
                inspectEntryRequest.done();
            }

            long localFileHeaderOffset = outputOffset;
            outputOffset +=
                    LocalFileRecord.outputRecordWithDeflateCompressedData(
                            entryName,
                            lastModifiedTimeForNewEntries,
                            lastModifiedDateForNewEntries,
                            compressedData,
                            uncompressedDataCrc32,
                            uncompressedData.length,
                            outputApkOut);


            outputCdRecords.add(
                    CentralDirectoryRecord.createWithDeflateCompressedData(
                            entryName,
                            lastModifiedTimeForNewEntries,
                            lastModifiedDateForNewEntries,
                            uncompressedDataCrc32,
                            compressedData.length,
                            uncompressedData.length,
                            localFileHeaderOffset));
        }
        outputJarSignatureRequest.done();
    }

    // Step 8. Construct output ZIP Central Directory in an in-memory buffer
    long outputCentralDirSizeBytes = 0;
    for (CentralDirectoryRecord record : outputCdRecords) {
        outputCentralDirSizeBytes += record.getSize();
    }
    if (outputCentralDirSizeBytes > Integer.MAX_VALUE) {
        throw new IOException(
                "Output ZIP Central Directory too large: " + outputCentralDirSizeBytes
                        + " bytes");
    }
    ByteBuffer outputCentralDir = ByteBuffer.allocate((int) outputCentralDirSizeBytes);
    for (CentralDirectoryRecord record : outputCdRecords) {
        record.copyTo(outputCentralDir);
    }
    outputCentralDir.flip();
    DataSource outputCentralDirDataSource = new ByteBufferDataSource(outputCentralDir);
    long outputCentralDirStartOffset = outputOffset;
    int outputCentralDirRecordCount = outputCdRecords.size();

    // Step 9. Construct output ZIP End of Central Directory record in an in-memory buffer
    ByteBuffer outputEocd =
            EocdRecord.createWithModifiedCentralDirectoryInfo(
                    inputZipSections.getZipEndOfCentralDirectory(),
                    outputCentralDirRecordCount,
                    outputCentralDirDataSource.size(),
                    outputCentralDirStartOffset);
複製程式碼

步驟7、8、9都可以看做是V1簽名的處理邏輯,主要在V1SchemeSigner中處理,其中包括建立META-INFO資料夾下的一些簽名檔案,更新中央目錄、更新中央目錄結尾等,流程不復雜,不在贅述,簡單流程就是:

image.png

這裡特殊提一下重複簽名的問題:對一個已經V1簽名的APK再次V1簽名不會有任何問題,原理就是:再次簽名的時候,會排除之前的簽名檔案。

  public static boolean isJarEntryDigestNeededInManifest(String entryName) {
        // See https://docs.oracle.com/javase/8/docs/technotes/guides/jar/jar.html#Signed_JAR_File

        // Entries which represent directories sould not be listed in the manifest.
        if (entryName.endsWith("/")) {
            return false;
        }

        // Entries outside of META-INF must be listed in the manifest.
        if (!entryName.startsWith("META-INF/")) {
            return true;
        }
        // Entries in subdirectories of META-INF must be listed in the manifest.
        if (entryName.indexOf('/', "META-INF/".length()) != -1) {
            return true;
        }

        // Ignored file names (case-insensitive) in META-INF directory:
        //   MANIFEST.MF
        //   *.SF
        //   *.RSA
        //   *.DSA
        //   *.EC
        //   SIG-*
        String fileNameLowerCase =
                entryName.substring("META-INF/".length()).toLowerCase(Locale.US);
        if (("manifest.mf".equals(fileNameLowerCase))
                || (fileNameLowerCase.endsWith(".sf"))
                || (fileNameLowerCase.endsWith(".rsa"))
                || (fileNameLowerCase.endsWith(".dsa"))
                || (fileNameLowerCase.endsWith(".ec"))
                || (fileNameLowerCase.startsWith("sig-"))) {
            return false;
        }
        return true;
    }
複製程式碼

可以看到目錄、META-INF資料夾下的檔案、sf、rsa等結尾的檔案都不會被V1簽名進行處理,所以這裡不用擔心多次簽名的問題。接下來就是處理V2簽名。

    // Step 10. Generate and output APK Signature Scheme v2 signatures, if necessary. This may
    // insert an APK Signing Block just before the output's ZIP Central Directory
    ApkSignerEngine.OutputApkSigningBlockRequest outputApkSigingBlockRequest =
            signerEngine.outputZipSections(
                    outputApkIn,
                    outputCentralDirDataSource,
                    DataSources.asDataSource(outputEocd));
    if (outputApkSigingBlockRequest != null) {
        byte[] outputApkSigningBlock = outputApkSigingBlockRequest.getApkSigningBlock();
        outputApkOut.consume(outputApkSigningBlock, 0, outputApkSigningBlock.length);
        ZipUtils.setZipEocdCentralDirectoryOffset(
                outputEocd, outputCentralDirStartOffset + outputApkSigningBlock.length);
        outputApkSigingBlockRequest.done();
    }

    // Step 11. Output ZIP Central Directory and ZIP End of Central Directory
    outputCentralDirDataSource.feed(0, outputCentralDirDataSource.size(), outputApkOut);
    outputApkOut.consume(outputEocd);
    signerEngine.outputDone();
}
複製程式碼

V2SchemeSigner處理V2簽名,邏輯比較清晰,直接對V1簽名過的APK進行分塊摘要,再集合簽名,V2簽名不會改變之前V1簽名後的任何資訊,簽名後,在中央目錄前新增V2簽名塊,並更新中央目錄結尾資訊,因為V2簽名後,中央目錄的偏移會再次改變:

image.png

APK簽名怎麼校驗

簽名校驗的過程可以看做簽名的逆向,只不過覆蓋安裝可能還要校驗公鑰及證照資訊一致,否則覆蓋安裝會失敗。簽名校驗的入口在PackageManagerService的install裡,安裝官方文件,7.0以上的手機優先檢測V2簽名,如果V2簽名不存在,再校驗V1簽名,對於7.0以下的手機,不存在V2簽名校驗機制,只會校驗V1,所以,如果你的App的miniSdkVersion<24(N),那麼你的簽名方式必須內含V1簽名:

簽名校驗流程

校驗流程就是簽名的逆向,瞭解簽名流程即可,本文不求甚解,有興趣自己去分析,只是額外提下覆蓋安裝,覆蓋安裝除了檢驗APK自己的完整性以外,還要校驗證照是否一致只有證照一致(同一個keystore簽名),才有可能覆蓋升級。覆蓋安裝同全新安裝相比較多了幾個校驗

  • 包名一致
  • 證照一致
  • versioncode不能降低

這裡只關心證照部分:

    // Verify: if target already has an installer package, it must
    // be signed with the same cert as the caller.
    if (targetPackageSetting.installerPackageName != null) {
        PackageSetting setting = mSettings.mPackages.get(
                targetPackageSetting.installerPackageName);
        // If the currently set package isn't valid, then it's always
        // okay to change it.
        if (setting != null) {
            if (compareSignatures(callerSignature,
                    setting.signatures.mSignatures)
                    != PackageManager.SIGNATURE_MATCH) {
                throw new SecurityException(
                        "Caller does not have same cert as old installer package "
                        + targetPackageSetting.installerPackageName);
            }
        }
    }
複製程式碼

V1、V2簽名下美團多渠道打包的切入點

  • V1簽名:META_INFO資料夾下增加檔案不會對校驗有任何影響,則是美團V1多渠道打包方案的切入點
  • V2簽名:V2簽名塊中可以新增一些附屬資訊,不會對簽名又任何影響,這是V2多渠道打包的切入點。

總結

  • V1簽名靠META_INFO資料夾下的簽名檔案
  • V2簽名依靠中央目錄前的V2簽名快,ZIP的目錄結構不會改變,當然結尾偏移要改。
  • V1 V2簽名可以同時存在(miniSdkVersion 7.0以下如果沒有V1簽名是不可以的)
  • 多去到打包的切入點原則:附加資訊不影響簽名驗證

作者:看書的小蝸牛

Android V1及V2簽名簽名原理簡析

僅供參考,歡迎指正

相關文章