Überprüfung der Integrität der Android-APK-Datei

Offizielle Website-Beschreibung der APK-Dateiintegrität

Integritätsgeschützter Inhalt
  Zum Schutz des APK-Inhalts enthält das APK die folgenden 4 Teile:

  1. Inhalt des ZIP-Eintrags (von Offset 0 bis zum Anfang des „APK Signature Chunk“)
  2. APK-Signaturblock
  3. ZIP-Zentralverzeichnis
  4. ZIP-Ende des zentralen Verzeichnisses

APK-Abschnitte

Signierte einzelne APK-Abschnitte
  APK Signature Scheme v2 ist für den Schutz der Integrität der Abschnitte 1, 3, 4 und des signierten Datenblocks im „APK Signature Scheme v2-Block“ in Abschnitt 2 verantwortlich.

  Die Integrität der Abschnitte 1, 3 und 4 wird durch einen oder mehrere Zusammenfassungen ihres Inhalts geschützt, die in signierten Datenblöcken gespeichert werden, und diese Blöcke werden durch eine oder mehrere Signaturen geschützt.

  Die Zusammenfassungen der Teile 1, 3 und 4 werden wie folgt berechnet, ähnlich einem zweistufigen Merkle-Baum. Jeder Teil ist in zusammenhängende Blöcke von 1 MB (220 Byte) aufgeteilt. Der letzte Block jedes Abschnitts kann kürzer sein. Der Digest jedes Blocks wird als Verkettung des Bytes 0xa5, der Länge des Blocks (uint32-Wert in Little-Endian-Bytereihenfolge, in Bytes) und dem Inhalt des Blocks berechnet. Der Digest der obersten Ebene wird als Verkettung des Bytes 0x5a, der Anzahl der Chunks als uint32-Wert in Little-Endian-Byte-Reihenfolge und dem Digest des Chunks in der Reihenfolge berechnet, in der die Chunks im APK erscheinen. Digests werden in Blöcken berechnet, um die Berechnung durch Parallelverarbeitung zu beschleunigen.
Integrität

APK-Zusammenfassung

  Da Abschnitt 4 (ZIP Central Directory End) den Offset zu „ZIP Central Directory“ enthält, ist der Schutz dieses Abschnitts kompliziert. Wenn sich die Größe des APK-Signaturblocks ändert (z. B. wenn eine neue Signatur hinzugefügt wird), ändert sich der Offset entsprechend. Daher muss das Feld, das den Offset des „ZIP Central Directory“ enthält, so behandelt werden, als ob es den Offset des „APK Signature Chunk“ enthält, wenn der Digest aus dem „Ende des ZIP Central Directory“ berechnet wird.

Spezifische Code-Implementierung

  Die spezifische Code-Implementierung befindet sich in der Klasse „VerifyIntegrity(contentDigests, apk, signaturInfo)“ der Klasse „ApkSigningBlockUtils“. Der Code lautet wie folgt:

    static void verifyIntegrity(
            Map<Integer, byte[]> expectedDigests,
            RandomAccessFile apk,
            SignatureInfo signatureInfo) throws SecurityException {
    
    
        if (expectedDigests.isEmpty()) {
    
    
            throw new SecurityException("No digests provided");
        }

        boolean neverVerified = true;

        Map<Integer, byte[]> expected1MbChunkDigests = new ArrayMap<>();
        if (expectedDigests.containsKey(CONTENT_DIGEST_CHUNKED_SHA256)) {
    
    
            expected1MbChunkDigests.put(CONTENT_DIGEST_CHUNKED_SHA256,
                    expectedDigests.get(CONTENT_DIGEST_CHUNKED_SHA256));
        }
        if (expectedDigests.containsKey(CONTENT_DIGEST_CHUNKED_SHA512)) {
    
    
            expected1MbChunkDigests.put(CONTENT_DIGEST_CHUNKED_SHA512,
                    expectedDigests.get(CONTENT_DIGEST_CHUNKED_SHA512));
        }
        if (!expected1MbChunkDigests.isEmpty()) {
    
    
            try {
    
    
                verifyIntegrityFor1MbChunkBasedAlgorithm(expected1MbChunkDigests, apk.getFD(),
                        signatureInfo);
                neverVerified = false;
            } catch (IOException e) {
    
    
                throw new SecurityException("Cannot get FD", e);
            }
        }

        if (expectedDigests.containsKey(CONTENT_DIGEST_VERITY_CHUNKED_SHA256)) {
    
    
            verifyIntegrityForVerityBasedAlgorithm(
                    expectedDigests.get(CONTENT_DIGEST_VERITY_CHUNKED_SHA256), apk, signatureInfo);
            neverVerified = false;
        }

        if (neverVerified) {
    
    
            throw new SecurityException("No known digest exists for integrity check");
        }
    }

  Der Parameter „expectedDigests“ ist der Digest-Wert, der dem besten Algorithmus in der Unterzeichnersequenz im v2-Block entspricht. Die Rückgabe erfolgt durch Überprüfung der Signatur. Informationen zum Suchen und Überprüfen finden Sie im vorherigen Artikel „ Signatur V2 der Android-APK-Datei“ .
  Wenn „expectedDigests“ eines oder beide von „CONTENT_DIGEST_CHUNKED_SHA256“ oder „CONTENT_DIGEST_CHUNKED_SHA512“ enthält, werden der darin enthaltene Algorithmus und der Digest-Wert in die Variable „expected1MbChunkDigests“ eingefügt. Der nächste Schritt besteht darin, die Integrität der Datei für den Digest-Algorithmus und den Digest-Wert zu überprüfen.
  Wenn erwarteteDigests als Nächstes den Digest-Algorithmus CONTENT_DIGEST_VERITY_CHUNKED_SHA256 enthält, wird ein Merkle-Baum erstellt, der Digest-Algorithmus im Stammverzeichnis ausgeführt und der erhaltene Digest mit dem aus dem v2-Block erhaltenen Digest-Wert verglichen. Wenn sie gleich sind, Die Datei wird als vollständig betrachtet, andernfalls wird eine Ausnahme gemeldet. .

Überprüfen Sie die Dateiintegrität anhand von Dateiblöcken (aufgeteilt in 1 Mio. Blöcke).

  Sein Code ist in verifyIntegrityFor1MbChunkBasedAlgorithm(),

    private static void verifyIntegrityFor1MbChunkBasedAlgorithm(
            Map<Integer, byte[]> expectedDigests,
            FileDescriptor apkFileDescriptor,
            SignatureInfo signatureInfo) throws SecurityException {
    
    
        int[] digestAlgorithms = new int[expectedDigests.size()];
        int digestAlgorithmCount = 0;
        for (int digestAlgorithm : expectedDigests.keySet()) {
    
    
            digestAlgorithms[digestAlgorithmCount] = digestAlgorithm;
            digestAlgorithmCount++;
        }
        byte[][] actualDigests;
        try {
    
    
            actualDigests = computeContentDigestsPer1MbChunk(digestAlgorithms, apkFileDescriptor,
                    signatureInfo);
        } catch (DigestException e) {
    
    
            throw new SecurityException("Failed to compute digest(s) of contents", e);
        }
        for (int i = 0; i < digestAlgorithms.length; i++) {
    
    
            int digestAlgorithm = digestAlgorithms[i];
            byte[] expectedDigest = expectedDigests.get(digestAlgorithm);
            byte[] actualDigest = actualDigests[i];
            if (!MessageDigest.isEqual(expectedDigest, actualDigest)) {
    
    
                throw new SecurityException(
                        getContentDigestAlgorithmJcaDigestAlgorithm(digestAlgorithm)
                                + " digest of contents did not verify");
            }
        }
    }

  Es ist ersichtlich, dass das Array „digestAlgorithms“ die Digest-Methode speichert und das Array „actualDigests“ die Digest-Werte verschiedener Digest-Algorithmen erhält, die von der Methode „computeContentDigestsPer1MbChunk()“ berechnet werden. Vergleichen Sie abschließend die berechnete Zusammenfassung mit der vorherigen aus dem v2-Block. Wenn sie nicht gleich sind, wird eine Ausnahme gemeldet.
  Es ist ersichtlich, dass dieser Teil hauptsächlich dazu dient, die Methode „computeContentDigestsPer1MbChunk ()“ zu verstehen, die gemäß dem auf der vorherigen offiziellen Website beschriebenen Algorithmus berechnet wird.

berechnenContentDigestsPer1MbChunk()

Werfen wir einen Blick auf die Methode „computeContentDigestsPer1MbChunk()“:

    public static byte[][] computeContentDigestsPer1MbChunk(int[] digestAlgorithms,
            FileDescriptor apkFileDescriptor, SignatureInfo signatureInfo) throws DigestException {
    
    
        // We need to verify the integrity of the following three sections of the file:
        // 1. Everything up to the start of the APK Signing Block.
        // 2. ZIP Central Directory.
        // 3. ZIP End of Central Directory (EoCD).
        // Each of these sections is represented as a separate DataSource instance below.

        // To handle large APKs, these sections are read in 1 MB chunks using memory-mapped I/O to
        // avoid wasting physical memory. In most APK verification scenarios, the contents of the
        // APK are already there in the OS's page cache and thus mmap does not use additional
        // physical memory.

        DataSource beforeApkSigningBlock =
                DataSource.create(apkFileDescriptor, 0, signatureInfo.apkSigningBlockOffset);
        DataSource centralDir =
                DataSource.create(
                        apkFileDescriptor, signatureInfo.centralDirOffset,
                        signatureInfo.eocdOffset - signatureInfo.centralDirOffset);

        // For the purposes of integrity verification, ZIP End of Central Directory's field Start of
        // Central Directory must be considered to point to the offset of the APK Signing Block.
        ByteBuffer eocdBuf = signatureInfo.eocd.duplicate();
        eocdBuf.order(ByteOrder.LITTLE_ENDIAN);
        ZipUtils.setZipEocdCentralDirectoryOffset(eocdBuf, signatureInfo.apkSigningBlockOffset);
        DataSource eocd = new ByteBufferDataSource(eocdBuf);

        return computeContentDigestsPer1MbChunk(digestAlgorithms,
                new DataSource[]{
    
    beforeApkSigningBlock, centralDir, eocd});
    }

  Es gibt drei Teile, die an der Überprüfung teilnehmen müssen: der ZIP-Eintragsinhalt vor dem Signaturblock, das zentrale Verzeichnis und das Ende des zentralen Verzeichnisses.
  Der Parameter signaturInfo enthält den Offset jedes Datenblocks. Daher ist es in drei Datenquellen unterteilt: beforeApkSigningBlock, centralDir, eocd. Beachten Sie, dass der zentrale Verzeichnis-Offset am Ende des zentralen Verzeichnisses jetzt der Offset des signierten Blocks ist, sodass er jetzt auf den Offset des nicht signierten Blocks zurückgesetzt wird. Der Vorgang zum Wiederherstellen des Offsets wird durch ZipUtils.setZipEocdCentralDirectoryOffset(eocdBuf, signaturInfo.apkSigningBlockOffset) implementiert.
  Hier werden die drei Datenblöcke in die DataSource-Klasse gekapselt. Die DataSource-Klasse verfügt über eine Methode „feedIntoDataDigester“ (DataDigester md, long offset, int size), deren erster Parameter das DataDigester-Klassenobjekt ist, das zum Generieren einer Zusammenfassung verwendet wird. Da der Datenblock in eine Größe von 1 MB unterteilt werden muss, müssen die Daten in den Daten zum Generieren der Zusammenfassung verwendet werden. Daher werden die letzten beiden Parameter verwendet, um die Daten in der DataSource zu lokalisieren.
  Tatsächlich bestimmt das DataSource-Klassenobjekt hier, ob es sich um ein ReadFileDataSource- oder ein MemoryMappedFileDataSource-Objekt handelt, je nachdem, ob die Datei im inkrementellen Dateisystem abgelegt wird. MemoryMappedFileDataSource verwendet die Speicherzuordnung zum Lesen von Daten, und ReadFileDataSource verwendet den Pread-Systemaufruf, um die Anzahl der Dateioffsets zu lesen. Dies ist langsamer als die Speicherzuordnung, aber sicherer.
  Schauen Sie sich dann die Überladungsmethode von „computeContentDigestsPer1MbChunk“ an. Der Code ist etwas lang. Werfen wir einen Blick auf den ersten Absatz:

    private static byte[][] computeContentDigestsPer1MbChunk(
            int[] digestAlgorithms,
            DataSource[] contents) throws DigestException {
    
    
        // For each digest algorithm the result is computed as follows:
        // 1. Each segment of contents is split into consecutive chunks of 1 MB in size.
        //    The final chunk will be shorter iff the length of segment is not a multiple of 1 MB.
        //    No chunks are produced for empty (zero length) segments.
        // 2. The digest of each chunk is computed over the concatenation of byte 0xa5, the chunk's
        //    length in bytes (uint32 little-endian) and the chunk's contents.
        // 3. The output digest is computed over the concatenation of the byte 0x5a, the number of
        //    chunks (uint32 little-endian) and the concatenation of digests of chunks of all
        //    segments in-order.

        long totalChunkCountLong = 0;
        for (DataSource input : contents) {
    
    
            totalChunkCountLong += getChunkCount(input.size());
        }
        if (totalChunkCountLong >= Integer.MAX_VALUE / 1024) {
    
    
            throw new DigestException("Too many chunks: " + totalChunkCountLong);
        }
        int totalChunkCount = (int) totalChunkCountLong;

        byte[][] digestsOfChunks = new byte[digestAlgorithms.length][];
        for (int i = 0; i < digestAlgorithms.length; i++) {
    
    
            int digestAlgorithm = digestAlgorithms[i];
            int digestOutputSizeBytes = getContentDigestAlgorithmOutputSizeBytes(digestAlgorithm);
            byte[] concatenationOfChunkCountAndChunkDigests =
                    new byte[5 + totalChunkCount * digestOutputSizeBytes];
            concatenationOfChunkCountAndChunkDigests[0] = 0x5a;
            setUnsignedInt32LittleEndian(
                    totalChunkCount,
                    concatenationOfChunkCountAndChunkDigests,
                    1);
            digestsOfChunks[i] = concatenationOfChunkCountAndChunkDigests;
        }

  getChunkCount(input.size()) dient dazu, jede Datenquelle entsprechend 1 MByte in Blöcke zu unterteilen und die Anzahl der Blöcke der Datenquelle zu ermitteln. Wir wissen oben, dass es derzeit drei Hauptdatenquellen gibt und totalChunkCountLong die Gesamtzahl der Blöcke ist, die sie in Blöcke zu je 1 MB aufteilen. Der letzte Block jeder Datenquelle kann weniger als 1 MB groß sein und als ein Block gezählt werden.
  Im Folgenden wird die erste Schicht der Datenzusammenfassungsblöcke entsprechend der Anzahl der Algorithmen buchstabiert.
  In der Schleife wird die Länge des Digests durch den Digest-Algorithmus ermittelt. Dies wird über die Methode getContentDigestAlgorithmOutputSizeBytes(digestAlgorithm) ermittelt. Nachdem Sie die Länge der Zusammenfassungsdaten ermittelt haben, fügen Sie die Anzahl der Zusammenfassungen hinzu, und Sie können die Länge der zusammengefügten Zusammenfassungen ermitteln. Da die gespleißten Daten mit 0x5a beginnen und die nächsten 4 Bytes die Anzahl der Digests darstellen, sollte 5 zur Länge des Digest-Datenblocks hinzugefügt werden. Weisen Sie dann die ersten fünf Bytes zu. Auf diese Weise werden gemäß dem Algorithmus die gespleißten Zusammenfassungsdatenblöcke in das DigestsOfChunks-Array eingefügt.
  Um die Länge des Digests zu verstehen, werfen Sie einen Blick auf getContentDigestAlgorithmOutputSizeBytes(digestAlgorithm):

    private static int getContentDigestAlgorithmOutputSizeBytes(int digestAlgorithm) {
    
    
        switch (digestAlgorithm) {
    
    
            case CONTENT_DIGEST_CHUNKED_SHA256:
            case CONTENT_DIGEST_VERITY_CHUNKED_SHA256:
                return 256 / 8;
            case CONTENT_DIGEST_CHUNKED_SHA512:
                return 512 / 8;
            default:
                throw new IllegalArgumentException(
                        "Unknown content digest algorthm: " + digestAlgorithm);
        }
    }

  Es ist ersichtlich, dass die Digest-Datenlänge bei SHA256 32 beträgt und bei SHA512 die Digest-Länge 64 beträgt.

  Sehen Sie sich den zweiten Code der überladenen Methode von „computeContentDigestsPer1MbChunk“ an:

        byte[] chunkContentPrefix = new byte[5];
        chunkContentPrefix[0] = (byte) 0xa5;
        int chunkIndex = 0;
        MessageDigest[] mds = new MessageDigest[digestAlgorithms.length];
        for (int i = 0; i < digestAlgorithms.length; i++) {
    
    
            String jcaAlgorithmName =
                    getContentDigestAlgorithmJcaDigestAlgorithm(digestAlgorithms[i]);
            try {
    
    
                mds[i] = MessageDigest.getInstance(jcaAlgorithmName);
            } catch (NoSuchAlgorithmException e) {
    
    
                throw new RuntimeException(jcaAlgorithmName + " digest not supported", e);
            }
        }
        // TODO: Compute digests of chunks in parallel when beneficial. This requires some research
        // into how to parallelize (if at all) based on the capabilities of the hardware on which
        // this code is running and based on the size of input.
        DataDigester digester = new MultipleDigestDataDigester(mds);
        int dataSourceIndex = 0;
        for (DataSource input : contents) {
    
    
            long inputOffset = 0;
            long inputRemaining = input.size();
            while (inputRemaining > 0) {
    
    
                int chunkSize = (int) Math.min(inputRemaining, CHUNK_SIZE_BYTES);
                setUnsignedInt32LittleEndian(chunkSize, chunkContentPrefix, 1);
                for (int i = 0; i < mds.length; i++) {
    
    
                    mds[i].update(chunkContentPrefix);
                }
                try {
    
    
                    input.feedIntoDataDigester(digester, inputOffset, chunkSize);
                } catch (IOException e) {
    
    
                    throw new DigestException(
                            "Failed to digest chunk #" + chunkIndex + " of section #"
                                    + dataSourceIndex,
                            e);
                }
                for (int i = 0; i < digestAlgorithms.length; i++) {
    
    
                    int digestAlgorithm = digestAlgorithms[i];
                    byte[] concatenationOfChunkCountAndChunkDigests = digestsOfChunks[i];
                    int expectedDigestSizeBytes =
                            getContentDigestAlgorithmOutputSizeBytes(digestAlgorithm);
                    MessageDigest md = mds[i];
                    int actualDigestSizeBytes =
                            md.digest(
                                    concatenationOfChunkCountAndChunkDigests,
                                    5 + chunkIndex * expectedDigestSizeBytes,
                                    expectedDigestSizeBytes);
                    if (actualDigestSizeBytes != expectedDigestSizeBytes) {
    
    
                        throw new RuntimeException(
                                "Unexpected output size of " + md.getAlgorithm() + " digest: "
                                        + actualDigestSizeBytes);
                    }
                }
                inputOffset += chunkSize;
                inputRemaining -= chunkSize;
                chunkIndex++;
            }
            dataSourceIndex++;
        }

  Dieser Code dient zur Berechnung des Gesamtwerts.
  Da bei der Berechnung der Zusammenfassung des geteilten Datenblocks 0xa5 und eine 4-Byte-Datenblocklänge fest davor hinzugefügt werden sollten, wird hier ein 5-Byte-Präfix chunkContentPrefix generiert und das erste Byte auf 0xa5 gesetzt.
  Durch die Schleife wird dann der Jca-Algorithmusname des entsprechenden Algorithmus erhalten. Verwenden Sie es dann, um das MessageDigest-Klassenobjekt zur Berechnung der Zusammenfassung abzurufen.
  Starten Sie dann die Datenquelle der for-Schleife und ermitteln Sie zunächst die Datenblockgröße chunkSize. InputRemaining sind die verbleibenden Daten der Datenquelle ein Minimum davon. Geben Sie dann die Anzahl der Bytes des Datenblocks in die ersten 1–4 Bytes von chunkContentPrefix ein.
  Aktualisieren Sie dann das Präfix jedes Datenblocks auf den Zusammenfassungsinhalt und rufen Sie dann die Datenquellenmethode „feedIntoDataDigester(digester, inputOffset, chunkSize)“ auf, um den Datenblockinhalt auf den Zusammenfassungsinhalt zu aktualisieren.
  Der Algorithmus wird dann in einer Schleife ausgeführt und der entsprechende Digest-Wert wird über die Methode „digest()“ des Klassenobjekts „MessageDigest“ an der Offset-Position des entsprechenden Blocks in „digestsOfChunks[i]“ abgelegt.
  Legen Sie dann den Variablenwert fest und berechnen Sie den Digest des nächsten Datenblocks. Bis alle drei Datenquellen ausgeführt sind.
  Schauen wir uns hier an, wie die Datenquelle die Daten auf den Zusammenfassungsinhalt aktualisiert. Dies wird durch den Aufruf von input.feedIntoDataDigester(digester, inputOffset, chunkSize) implementiert. Wie bereits erwähnt, kann die Eingabe tatsächlich ein ReadFileDataSource- oder MemoryMappedFileDataSource-Objekt sein. Nehmen wir das MemoryMappedFileDataSource-Objekt als Beispiel und schauen uns seinen Code an:

    @Override
    public void feedIntoDataDigester(DataDigester md, long offset, int size)
            throws IOException, DigestException {
    
    
        // IMPLEMENTATION NOTE: After a lot of experimentation, the implementation of this
        // method was settled on a straightforward mmap with prefaulting.
        //
        // This method is not using FileChannel.map API because that API does not offset a way
        // to "prefault" the resulting memory pages. Without prefaulting, performance is about
        // 10% slower on small to medium APKs, but is significantly worse for APKs in 500+ MB
        // range. FileChannel.load (which currently uses madvise) doesn't help. Finally,
        // invoking madvise (MADV_SEQUENTIAL) after mmap with prefaulting wastes quite a bit of
        // time, which is not compensated for by faster reads.

        // We mmap the smallest region of the file containing the requested data. mmap requires
        // that the start offset in the file must be a multiple of memory page size. We thus may
        // need to mmap from an offset less than the requested offset.
        long filePosition = mFilePosition + offset;
        long mmapFilePosition =
                (filePosition / MEMORY_PAGE_SIZE_BYTES) * MEMORY_PAGE_SIZE_BYTES;
        int dataStartOffsetInMmapRegion = (int) (filePosition - mmapFilePosition);
        long mmapRegionSize = size + dataStartOffsetInMmapRegion;
        long mmapPtr = 0;
        try {
    
    
            mmapPtr = Os.mmap(
                    0, // let the OS choose the start address of the region in memory
                    mmapRegionSize,
                    OsConstants.PROT_READ,
                    OsConstants.MAP_SHARED | OsConstants.MAP_POPULATE, // "prefault" all pages
                    mFd,
                    mmapFilePosition);
            ByteBuffer buf = new DirectByteBuffer(
                    size,
                    mmapPtr + dataStartOffsetInMmapRegion,
                    mFd,  // not really needed, but just in case
                    null, // no need to clean up -- it's taken care of by the finally block
                    true  // read only buffer
                    );
            md.consume(buf);
        } catch (ErrnoException e) {
    
    
            throw new IOException("Failed to mmap " + mmapRegionSize + " bytes", e);
        } finally {
    
    
            if (mmapPtr != 0) {
    
    
                try {
    
    
                    Os.munmap(mmapPtr, mmapRegionSize);
                } catch (ErrnoException ignored) {
    
     }
            }
        }
    }

  Es ist ersichtlich, dass dieser Block die Methode Os.mmap () verwendet, um eine Speicherzuordnung zu implementieren und die Speicheradresse mmapPtr zu erhalten. Lassen Sie es in ein DirectByteBuffer-Objekt einkapseln und rufen Sie dann md.consume(buf) auf, um die Daten in den Inhalt der generierten Zusammenfassung einzulesen. Um eine Speicherseitenausrichtung zu erreichen, werden einige Berechnungen durchgeführt, um den Speicherort der speicherzugeordneten Datei und den Offset von den tatsächlichen Daten zu ermitteln. Daher muss beim Abrufen von Daten auch der Offset hinzugefügt werden.
  Der MD hier ist eigentlich ein MultipleDigestDataDigester-Objekt, das mehrere MessageDigest-Objekte enthält. Schauen Sie sich seine Methode „consume()“ an:

    private static class MultipleDigestDataDigester implements DataDigester {
    
    
        private final MessageDigest[] mMds;

        MultipleDigestDataDigester(MessageDigest[] mds) {
    
    
            mMds = mds;
        }

        @Override
        public void consume(ByteBuffer buffer) {
    
    
            buffer = buffer.slice();
            for (MessageDigest md : mMds) {
    
    
                buffer.position(0);
                md.update(buffer);
            }
        }
    }

  Es ist ersichtlich, dass dieser Block das MessageDigest-Objekt md ​​aufruft, um die Daten zu aktualisieren und zusammenfassende Inhalte zu generieren.

  Sehen Sie sich die überladene Methode von „computeContentDigestsPer1MbChunk“ im letzten Codeabschnitt an:

        byte[][] result = new byte[digestAlgorithms.length][];
        for (int i = 0; i < digestAlgorithms.length; i++) {
    
    
            int digestAlgorithm = digestAlgorithms[i];
            byte[] input = digestsOfChunks[i];
            String jcaAlgorithmName = getContentDigestAlgorithmJcaDigestAlgorithm(digestAlgorithm);
            MessageDigest md;
            try {
    
    
                md = MessageDigest.getInstance(jcaAlgorithmName);
            } catch (NoSuchAlgorithmException e) {
    
    
                throw new RuntimeException(jcaAlgorithmName + " digest not supported", e);
            }
            byte[] output = md.digest(input);
            result[i] = output;
        }
        return result;
    }

  Jetzt befinden sich die Digests jeder Datenquelle gemäß dem Digest-Algorithmus bereits im DigestsOfChunks-Array. Jetzt müssen wir erneut einen Zusammenfassungsalgorithmus ausführen, um eine Zusammenfassung zu generieren.
  Dieser Block ruft also den Digest (Eingabe) des neu generierten MessageDigest-Objekts auf, um den Digest-Wert zu generieren, fügt ihn entsprechend der Reihenfolge des Algorithmus im Array in das Ergebnis ein und gibt ihn zurück.

Digest-Vergleich der Merkle-Baumwurzeln

  Es wird durch verifyIntegrityForVerityBasedAlgorithm() implementiert

    private static void verifyIntegrityForVerityBasedAlgorithm(
            byte[] expectedDigest,
            RandomAccessFile apk,
            SignatureInfo signatureInfo) throws SecurityException {
    
    
        try {
    
    
            byte[] expectedRootHash = parseVerityDigestAndVerifySourceLength(expectedDigest,
                    apk.length(), signatureInfo);
            VerityBuilder.VerityResult verity = VerityBuilder.generateApkVerityTree(apk,
                    signatureInfo, new ByteBufferFactory() {
    
    
                        @Override
                        public ByteBuffer create(int capacity) {
    
    
                            return ByteBuffer.allocate(capacity);
                        }
                    });
            if (!Arrays.equals(expectedRootHash, verity.rootHash)) {
    
    
                throw new SecurityException("APK verity digest of contents did not verify");
            }
        } catch (DigestException | IOException | NoSuchAlgorithmException e) {
    
    
            throw new SecurityException("Error during verification", e);
        }
    }

  Der Parameter „expectedDigest“ ist der aus dem v2-Block erhaltene Digest-Wert, und parseVerityDigestAndVerifySourceLength() ruft den Digest-Wert ab.
  VerityBuilder.generateApkVerityTree() erstellt einen Merkle-Baum und führt dann den Digest-Algorithmus an seiner Wurzel aus, um den Digest-Wert zu erhalten. Der erhaltene abstrakte Wert befindet sich im Member rootHash der Klasse VerityBuilder.VerityResult.
  Wenn die beiden Digests nicht gleich sind, gilt die Validierung als fehlgeschlagen.

Rufen Sie den Digest-Wert des CONTENT_DIGEST_VERITY_CHUNKED_SHA256-Algorithmus im v2-Block ab

    /**
     * Return the verity digest only if the length of digest content looks correct.
     * When verity digest is generated, the last incomplete 4k chunk is padded with 0s before
     * hashing. This means two almost identical APKs with different number of 0 at the end will have
     * the same verity digest. To avoid this problem, the length of the source content (excluding
     * Signing Block) is appended to the verity digest, and the digest is returned only if the
     * length is consistent to the current APK.
     */
    static byte[] parseVerityDigestAndVerifySourceLength(
            byte[] data, long fileSize, SignatureInfo signatureInfo) throws SecurityException {
    
    
        // FORMAT:
        // OFFSET       DATA TYPE  DESCRIPTION
        // * @+0  bytes uint8[32]  Merkle tree root hash of SHA-256
        // * @+32 bytes int64      Length of source data
        int kRootHashSize = 32;
        int kSourceLengthSize = 8;

        if (data.length != kRootHashSize + kSourceLengthSize) {
    
    
            throw new SecurityException("Verity digest size is wrong: " + data.length);
        }
        ByteBuffer buffer = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN);
        buffer.position(kRootHashSize);
        long expectedSourceLength = buffer.getLong();

        long signingBlockSize = signatureInfo.centralDirOffset
                - signatureInfo.apkSigningBlockOffset;
        if (expectedSourceLength != fileSize - signingBlockSize) {
    
    
            throw new SecurityException("APK content size did not verify");
        }

        return Arrays.copyOfRange(data, 0, kRootHashSize);
    }

  Der im v2-Block erhaltene Wert beträgt 40 Bytes, die ersten 32 Bytes sind der Zusammenfassungswert und die letzten 8 Bytes sind die Länge des Inhalts, der die Zusammenfassungsdaten generiert.
  Der Inhalt des in der APK generierten Digests enthält nicht die Daten des Signaturblocks. Beim Vergleich der Länge muss daher die Länge des Signaturblocks entfernt werden.
  Die obige Methode ist sehr offensichtlich: Wenn die Länge nicht übereinstimmt, wird eine Ausnahme gemeldet. Abschließend wird der Inhalt der ersten 32 Bytes herausgenommen und zurückgegeben.

Erstellen Sie einen Merkle-Baum und erhalten Sie den Gesamtwert der Baumwurzel

  Die Implementierung dieses Blocks befindet sich in VerityBuilder.generateApkVerityTree(), und schließlich befindet sich seine Implementierung in genericVerityTreeInternal():

    @NonNull
    private static VerityResult generateVerityTreeInternal(@NonNull RandomAccessFile apk,
            @NonNull ByteBufferFactory bufferFactory, @Nullable SignatureInfo signatureInfo)
            throws IOException, SecurityException, NoSuchAlgorithmException, DigestException {
    
    
        long signingBlockSize =
                signatureInfo.centralDirOffset - signatureInfo.apkSigningBlockOffset;
        long dataSize = apk.length() - signingBlockSize;
        int[] levelOffset = calculateVerityLevelOffset(dataSize);
        int merkleTreeSize = levelOffset[levelOffset.length - 1];

        ByteBuffer output = bufferFactory.create(
                merkleTreeSize
                + CHUNK_SIZE_BYTES);  // maximum size of apk-verity metadata
        output.order(ByteOrder.LITTLE_ENDIAN);
        ByteBuffer tree = slice(output, 0, merkleTreeSize);
        byte[] apkRootHash = generateVerityTreeInternal(apk, signatureInfo, DEFAULT_SALT,
                levelOffset, tree);
        return new VerityResult(output, merkleTreeSize, apkRootHash);
    }

  Ermitteln Sie zunächst die Länge dataSize des Dateninhalts des generierten Merkle-Baums, hauptsächlich um die Länge des Signaturblocks in der APK zu entfernen.
  Ermitteln Sie dann über dataSize die Datengröße des Baums von der Wurzel bis zur Ebene. Es wird durch berechneVerityLevelOffset(dataSize) ermittelt. Daher wird die letzte Ebene, die der letzten Ebene des Arrays entspricht, herausgenommen, um merkleTreeSize zu erhalten, das die Größe des Baums angibt.
  Der Merkle-Baum wird hier anhand der Objektausgabe der ByteBuffer-Klasse beschrieben. Rufen Sie dann „generateVerityTreeInternal()“ auf, um den Datenwert in der Ausgabe zu generieren und den Zusammenfassungswert der Wurzel abzurufen.
  Schließlich werden die Daten des Baums, die Größe des Baums und der Zusammenfassungswert der Baumwurzel in ein VerityResult-Objekt gekapselt und zurückgegeben.

Ermitteln Sie die Datengröße des Merkle-Baums von der Wurzel bis zur Ebene

    private static int[] calculateVerityLevelOffset(long fileSize) {
    
    
        ArrayList<Long> levelSize = new ArrayList<>();
        while (true) {
    
    
            long levelDigestSize = divideRoundup(fileSize, CHUNK_SIZE_BYTES) * DIGEST_SIZE_BYTES;
            long chunksSize = CHUNK_SIZE_BYTES * divideRoundup(levelDigestSize, CHUNK_SIZE_BYTES);
            levelSize.add(chunksSize);
            if (levelDigestSize <= CHUNK_SIZE_BYTES) {
    
    
                break;
            }
            fileSize = levelDigestSize;
        }

        // Reverse and convert to summed area table.
        int[] levelOffset = new int[levelSize.size() + 1];
        levelOffset[0] = 0;
        for (int i = 0; i < levelSize.size(); i++) {
    
    
            // We don't support verity tree if it is larger then Integer.MAX_VALUE.
            levelOffset[i + 1] = levelOffset[i]
                    + Math.toIntExact(levelSize.get(levelSize.size() - i - 1));
        }
        return levelOffset;
    }

  Um diesen Code zu verstehen, müssen Sie wissen, wie der Merkle-Baum aufgebaut ist.
  Entfernen Sie den Signaturblockteil der APK-Datei, teilen Sie ihn alle 4096 Bytes in Blöcke auf und füllen Sie den letzten Block mit 0. Anschließend wird jeder Block durch einen Digest-Algorithmus berechnet, um einen 32-Byte-Digest-Wert zu erhalten. Dies ist die letzte Ebene des Merkle-Baums. Die im Set gespeicherte LevelSize ist die Summe der Anzahl der 4096-Byte-Blöcke, die von allen Datengrößen des 32-Byte-Digests belegt werden.
  Die vorletzte Schicht des Merkle-Baums ist die zusammenfassende Datengröße der vorletzten Schicht und wird gemäß 4096 Bytes in Blöcke unterteilt. Anschließend wird für jeden Block ein 32-Byte-Zusammenfassungswert berechnet. Fügen Sie dann die Summe der 4096-Byte-Blöcke, die durch die Größe dieser Zusammenfassungswerte belegt sind, in die festgelegte LevelSize ein.
  Auf diese Weise endet die Schleife, bis die Größe der Zusammenfassung kleiner oder gleich 4096 Byte ist. Auf diese Weise ist die letzte Größe in der festgelegten levelSize die Größe eines Datenblocks, 4096 Bytes.
  Das nächste LevelOffset-Array speichert die Größe aller Knoten von der obersten Ebene bis zu dieser Ebene. Auf diese Weise wissen wir, dass die letzten Daten des LevelSize-Arrays die Größe aller Knoten sind, was der Größe des Merkle-Baums entspricht.

Erstellen Sie einen Merkle-Baum und erhalten Sie den zusammenfassenden Wert der Wurzel

  Es wird durch genericVerityTreeInternal() implementiert:

    @NonNull
    private static byte[] generateVerityTreeInternal(@NonNull RandomAccessFile apk,
            @Nullable SignatureInfo signatureInfo, @Nullable byte[] salt,
            @NonNull int[] levelOffset, @NonNull ByteBuffer output)
            throws IOException, NoSuchAlgorithmException, DigestException {
    
    
        // 1. Digest the apk to generate the leaf level hashes.
        assertSigningBlockAlignedAndHasFullPages(signatureInfo);
        generateApkVerityDigestAtLeafLevel(apk, signatureInfo, salt, slice(output,
                    levelOffset[levelOffset.length - 2], levelOffset[levelOffset.length - 1]));

        // 2. Digest the lower level hashes bottom up.
        for (int level = levelOffset.length - 3; level >= 0; level--) {
    
    
            ByteBuffer inputBuffer = slice(output, levelOffset[level + 1], levelOffset[level + 2]);
            ByteBuffer outputBuffer = slice(output, levelOffset[level], levelOffset[level + 1]);

            DataSource source = new ByteBufferDataSource(inputBuffer);
            BufferedDigester digester = new BufferedDigester(salt, outputBuffer);
            consumeByChunk(digester, source, CHUNK_SIZE_BYTES);
            digester.assertEmptyBuffer();
            digester.fillUpLastOutputChunk();
        }

        // 3. Digest the first block (i.e. first level) to generate the root hash.
        byte[] rootHash = new byte[DIGEST_SIZE_BYTES];
        BufferedDigester digester = new BufferedDigester(salt, ByteBuffer.wrap(rootHash));
        digester.consume(slice(output, 0, CHUNK_SIZE_BYTES));
        digester.assertEmptyBuffer();
        return rootHash;
    }

  Rufen Sie zunächst genericApkVerityDigestAtLeafLevel() auf, um Blattknoten zu generieren. Jeder Knoten des Baums muss in der Ausgabe gespeichert werden, und der Blattknoten ist seine Position, beginnend bei levelOffset[levelOffset.length - 2] und endend bei levelOffset[levelOffset.length - 1]. Die Bedeutung des levelOffset-Arrays wurde oben erwähnt.
  Anschließend werden durch die Schleife die Zusammenfassungsdaten der Knoten der oberen Schicht nach 4096 Bytes gruppiert und anschließend die Zusammenfassungsdaten der Knoten der unteren Schicht reproduziert. Auf diese Weise werden die Daten jedes Knotens in der Ausgabe zugeordnet. Der Merkle-Baum ist konstruiert.
  Der letzte Schritt besteht darin, einen Zusammenfassungsalgorithmus für die Wurzel des Baums durchzuführen, um die Zusammenfassung zu erhalten. RootHash zurückgeben.

Blattknotenkonstruktion

  Es wird durch genericApkVerityDigestAtLeafLevel() implementiert:


        // 2. Skip APK Signing Block and continue digesting, until the Central Directory offset
        // field in EoCD is reached.
        long eocdCdOffsetFieldPosition =
                signatureInfo.eocdOffset + ZIP_EOCD_CENTRAL_DIR_OFFSET_FIELD_OFFSET;
        consumeByChunk(digester,
                DataSource.create(apk.getFD(), signatureInfo.centralDirOffset,
                    eocdCdOffsetFieldPosition - signatureInfo.centralDirOffset),
                MMAP_REGION_SIZE_BYTES);

        // 3. Consume offset of Signing Block as an alternative EoCD.
        ByteBuffer alternativeCentralDirOffset = ByteBuffer.allocate(
                ZIP_EOCD_CENTRAL_DIR_OFFSET_FIELD_SIZE).order(ByteOrder.LITTLE_ENDIAN);
        alternativeCentralDirOffset.putInt(Math.toIntExact(signatureInfo.apkSigningBlockOffset));
        alternativeCentralDirOffset.flip();
        digester.consume(alternativeCentralDirOffset);

        // 4. Read from end of the Central Directory offset field in EoCD to the end of the file.
        long offsetAfterEocdCdOffsetField =
                eocdCdOffsetFieldPosition + ZIP_EOCD_CENTRAL_DIR_OFFSET_FIELD_SIZE;
        consumeByChunk(digester,
                DataSource.create(apk.getFD(), offsetAfterEocdCdOffsetField,
                    apk.length() - offsetAfterEocdCdOffsetField),
                MMAP_REGION_SIZE_BYTES);

        // 5. Pad 0s up to the nearest 4096-byte block before hashing.
        int lastIncompleteChunkSize = (int) (apk.length() % CHUNK_SIZE_BYTES);
        if (lastIncompleteChunkSize != 0) {
    
    
            digester.consume(ByteBuffer.allocate(CHUNK_SIZE_BYTES - lastIncompleteChunkSize));
        }
        digester.assertEmptyBuffer();

        // 6. Fill up the rest of buffer with 0s.
        digester.fillUpLastOutputChunk();
    }

  Generieren Sie zunächst ein BufferedDigester-Klassenobjekt, das eine Salt- und ByteBuffer-Objektinitialisierung empfängt, bei der es sich um eine zusammenfassende Hilfsklasse für die Cache-Generierung handelt. Es fügt die empfangenen Daten in 4096-Byte-Blöcke zusammen, generiert eine Zusammenfassung und platziert die Zusammenfassung an der entsprechenden Position des ByteBuffer-Objekts, was der Generierung eines Knotens entspricht. Wie wir oben wissen, ist die Startposition der Ausgabe levelOffset[levelOffset.length - 2], also die Startposition des Blattknotens.
  Da der Signaturdatenblock im APK nicht an der Generierung des Digest beteiligt ist, muss er herausgefiltert werden. In der zweiten Methode „consumeByChunk()“ ist die Startposition der Datenquelle also „signaturInfo.centralDirOffset“, also die Startposition des zentralen Verzeichnisses.
  Im Datenblock am Ende des zentralen Verzeichnisses ist zu beachten, dass er die Offset-Position des zentralen Verzeichnisses am Offset ZIP_EOCD_CENTRAL_DIR_OFFSET_FIELD_OFFSET speichert, dies ist jedoch die Position nach dem Einfügen des Signaturdatenblocks, die Berechnung sollte jedoch vorher erfolgen Die Signatur wird mit einem Positionsversatz eingefügt, sodass dieser Teil bearbeitet werden muss. Die Position des Kommentars 3. im Code regelt dies.
  Wenn nach dem Aufruf der Methode „consumeByChunk()“ zum Verarbeiten des ZIP-Eintragsdatenblocks, des zentralen Verzeichnisses und der Enddaten des zentralen Verzeichnisses festgestellt wird, dass der letzte Datenblock weniger als 4096 Bytes groß ist, muss er mit 0 gefüllt werden, bis er 4096 Bytes erreicht . Diese Verarbeitung verwendet apk.length() % CHUNK_SIZE_BYTES, um die Nummer des letzten Datenblocks abzurufen. Aber apk.length() ist die Datengröße des APK, und der Signaturdatenblock wird nicht in die Berechnung einbezogen. Ich verstehe nicht, warum er die Größe des Signaturdatenblocks nicht subtrahiert hat.
  Wenn der letzte 4096-Byte-Block des untergeordneten Blattknotens nicht voll ist, füllt die Funktion „digester.fillUpLastOutputChunk()“ schließlich den Wert mit 0.
  Dieser Block füllt die Knotendaten mit der Methode „consumeByChunk()“ in die Ausgabe ein, daher ist es notwendig, seinen Code zu lesen:

    private static void consumeByChunk(DataDigester digester, DataSource source, int chunkSize)
            throws IOException, DigestException {
    
    
        long inputRemaining = source.size();
        long inputOffset = 0;
        while (inputRemaining > 0) {
    
    
            int size = (int) Math.min(inputRemaining, chunkSize);
            source.feedIntoDataDigester(digester, inputOffset, size);
            inputOffset += size;
            inputRemaining -= size;
        }
    }

  Der Code ist recht einfach. Er ruft die Methode „feedIntoDataDigester()“ der Datenquelle entsprechend der Blockgröße auf. Die Methode „feedIntoDataDigester()“ ruft hauptsächlich die Methode „comsume()“ des Digesters auf. Hier ist der Digester ein BufferedDigester, also schauen Sie sich das an seine Umsetzung:

        @Override
        public void consume(ByteBuffer buffer) throws DigestException {
    
    
            int offset = buffer.position();
            int remaining = buffer.remaining();
            while (remaining > 0) {
    
    
                int allowance = (int) Math.min(remaining, BUFFER_SIZE - mBytesDigestedSinceReset);
                // Optimization: set the buffer limit to avoid allocating a new ByteBuffer object.
                buffer.limit(buffer.position() + allowance);
                mMd.update(buffer);
                offset += allowance;
                remaining -= allowance;
                mBytesDigestedSinceReset += allowance;

                if (mBytesDigestedSinceReset == BUFFER_SIZE) {
    
    
                    mMd.digest(mDigestBuffer, 0, mDigestBuffer.length);
                    mOutput.put(mDigestBuffer);
                    // After digest, MessageDigest resets automatically, so no need to reset again.
                    if (mSalt != null) {
    
    
                        mMd.update(mSalt);
                    }
                    mBytesDigestedSinceReset = 0;
                }
            }
        }

  BUFFER_SIZE ist 4096 und mBytesDigestedSinceReset gibt an, wie viele Daten jedes Mal auf mMd aktualisiert werden. Wenn die aktualisierten Daten nicht für BUFFER_SIZE ausreichen, wird die Zusammenfassung nicht berechnet. Wenn die aktualisierten Daten BUFFER_SIZE erreichen, berechnet mMd den Digest und gibt ihn in mOutput ein. Berechnen Sie dann den Wert von mBytesDigestedSinceReset neu, warten Sie, bis die BUFFER_SIZE-Daten das nächste Mal erreicht sind, und berechnen Sie dann neu. Hier ist mOutput, ein Mitglied von BufferedDigester, ein ByteBuffer-Objekt, das Merkle-Baumknoten speichert.
  Auf diese Weise werden nach der Ausführung von genericApkVerityDigestAtLeafLevel() alle Blattknoten gefüllt.

Zusammenfassen

  Jetzt wissen wir, wie man die APK-Integrität überprüft.
  Eine davon ist die Digest-Überprüfung, die dem Algorithmus CONTENT_DIGEST_CHUNKED_SHA256 (entspricht dem Digest-Algorithmus „SHA-256“) oder CONTENT_DIGEST_CHUNKED_SHA512 (entspricht dem Digest-Algorithmus „SHA-512“) entspricht und die am Digest beteiligten Daten in 1 MB große Bytes aufteilt und verwendet das Byte 0xa5. Die Verkettung, die Länge des Blocks (ein uint32-Wert in Little-Endian-Bytereihenfolge in Bytes) und der Inhalt des Blocks werden für den Digest berechnet. Der Digest der obersten Ebene wird durch die Verkettung des Bytes 0x5a, der Anzahl der Blöcke (uint32-Wert in Little-Endian-Bytereihenfolge) und der Verkettung des Digests des Blocks (entsprechend der Reihenfolge, in der die Blöcke im angezeigt werden) berechnet APK), um den Digest zu erhalten. Nehmen Sie ihn und den v2-Block. Die Zusammenfassungen werden verglichen und die Gleichheit wird übergeben.
  Der andere entspricht CONTENT_DIGEST_VERITY_CHUNKED_SHA256 (entspricht dem Digest-Algorithmus „SHA-256“), der einen Merkle-Baum erstellt, dessen Blockgröße 4096 Byte beträgt, und den Digest-Wert der Wurzel des Baums erhält, ihn und den Digest übernimmt im v2-Block Compare, wenn einstimmig verabschiedet.

おすすめ

転載: blog.csdn.net/q1165328963/article/details/132544902