Android应用程序签名验证过程分析

在前面的《Android应用程序签名过程分析》中,我大致分析了Android应用程序签名的过程,接下来我将结合源代码,分析一下Android应用程序在安装过程中对签名进行验证的过程。

我们还是用前面的例子分析,假设签名后,apk文件中多了一个META-INF目录,里面有三个文件,分别是MANIFEST.MFCERT.SFCERT.RSA

《Android应用程序签名验证过程分析》

通过前面的分析,我们可以知道,MANIFEST.MF中记录的是apk中所有文件的摘要值;CERT.SF中记录的是对MANIFEST.MF的摘要值,包括整个文件的摘要,还有文件中每一项的摘要;而CERT.RSA中记录的是对CERT.SF文件的签名,以及签名的公钥。

大家知道,Android平台上所有应用程序安装都是由
PackageManangerService(代码位于
frameworks\base\services\core\java\com\android\server\pm\PackageManagerService.java)来管理的,Android的安装流程非常复杂,与签名验证相关的步骤位于
installPackageLI函数中:

private void installPackageLI(InstallArgs args, PackageInstalledInfo res) {
    ……
    PackageParser pp = new PackageParser();
    ……
    try {
        pp.collectCertificates(pkg, parseFlags);
        pp.collectManifestDigest(pkg);
    } catch (PackageParserException e) {
        res.setError("Failed collect during installPackageLI", e);
        return;
    }
    ……

PackageParser(代码位于
frameworks\base\core\java\android\content\pm\PackageParser.java,编译后存在于
framework.jar文件中)是一个apk包的解析器,接下来我们来看其
collectCertificates函数的实现:

public void collectCertificates(Package pkg, int flags) throws PackageParserException {
    pkg.mCertificates = null;
    pkg.mSignatures = null;
    pkg.mSigningKeys = null;

    collectCertificates(pkg, new File(pkg.baseCodePath), flags);
    ……

接着调用了collectCertficates的一个重载版本:

private static void collectCertificates(Package pkg, File apkFile, int flags)
            throws PackageParserException {
    final String apkPath = apkFile.getAbsolutePath();

    StrictJarFile jarFile = null;
    try {
        jarFile = new StrictJarFile(apkPath);
        ……

函数的开头,首先创建了一个
StrictJarFile(代码位于
libcore\luni\src\main\java\java\util\jar\StrictJarFile.java,编译后存在于
core.jar文件中)对象,先来看看其构造函数中的内容:

public StrictJarFile(String fileName) throws IOException {
    ……
    try {
        HashMap<String, byte[]> metaEntries = getMetaEntries();
        this.manifest = new Manifest(metaEntries.get(JarFile.MANIFEST_NAME), true);
        this.verifier = new JarVerifier(fileName, manifest, metaEntries);

        isSigned = verifier.readCertificates() && verifier.isSignedJar();
        ……

这里构造了几个重要的对象。首先,获得了META-INF目录下所有文件名及其字节流。然后是构造了一个manifest对象,主要是用来处理对META-INF目录下MANIFEST.MF文件的操作。接着,构造了一个JarVeirifer(代码位于libcore\luni\src\main\java\java\util\jar\JarVerifier.java文件中,编译后存在于core.jar文件中)对象,这个对象主要实现了对Jar文件的验证工作,非常关键,后面的分析中会逐步提到。在构造函数的最后,调用了JarVeirifer.readCertificates函数:

synchronized boolean readCertificates() {
    if (metaEntries.isEmpty()) {
        return false;
    }

    Iterator<String> it = metaEntries.keySet().iterator();
    while (it.hasNext()) {
        String key = it.next();
        if (key.endsWith(".DSA") || key.endsWith(".RSA") || key.endsWith(".EC")) {
            verifyCertificate(key);
            it.remove();
        }
    }
    return true;
}

代码遍历所有
META-INF目录下的文件,找到以
.DSA
.RSA或者
.EC结尾的文件,以这些名字结尾的文件都是所谓的签名证书文件。在本例中对应的是
META-INF目录下的
CERT.RSA签名文件。然后调用
JarVeirifer.verifyCertificate函数:

private void verifyCertificate(String certFile) {
    String signatureFile = certFile.substring(0, certFile.lastIndexOf('.')) + ".SF";
    byte[] sfBytes = metaEntries.get(signatureFile);
    if (sfBytes == null) {
        return;
    }
    ……
    byte[] sBlockBytes = metaEntries.get(certFile);
    try {
       Certificate[] signerCertChain = JarUtils.verifySignature(
                    new ByteArrayInputStream(sfBytes),
                    new ByteArrayInputStream(sBlockBytes));
        if (signerCertChain != null) {
            certificates.put(signatureFile, signerCertChain);
        }
    } catch (IOException e) {
        return;
    } catch (GeneralSecurityException e) {
        throw failedVerification(jarName, signatureFile);
    }
    ……

函数开头,首先找到与证书文件同名,但是以
.SF结尾的签名文件,本例中即为
META-INF目录下的
CERT.SF文件。然后分别获得签名文件
CERT.SF和证书文件
CERT.RSA的字节流,调用
JarUtils(代码位于
libcore\luni\src\main\java\org\apache\harmony\security\utils\JarUtils.java文件中,编译后存在于
core.jar文件中)的
verifySignature函数,验证CERT.RSA文件中包含的对
CERT.SF文件的签名是否正确。如果验证失败,则会抛出
GeneralSecurityException异常;而如果验证成功,则会返回签名的证书链。回到
JarVeirifer.verifyCertificate函数,如果
JarUtils.verifySignature验证失败抛出异常,被捕获后会接着向上抛出
SecurityException异常;

private static SecurityException failedVerification(String jarName, String signatureFile) {
    throw new SecurityException(jarName + " failed verification of " + signatureFile);
}

而如果签名验证成功的话,会将证书链保存在certifcates属性变量中。而JarVerifier自己的isSignedJar函数,就是判断一下这个certificates属性变量是否为空。

boolean isSignedJar() {
    return certificates.size() > 0;
}

如果不为空就代表这个
Jar是签过名的,如果为空则代表其没有签过名。我们接着看
JarVeirifer.verifyCertificate函数:

    ……
    Attributes attributes = new Attributes();
    HashMap<String, Attributes> entries = new HashMap<String, Attributes>();
    try {
        ManifestReader im = new ManifestReader(sfBytes, attributes);
        im.readEntries(entries, null);
    } catch (IOException e) {
        return;
    }

    if (attributes.get(Attributes.Name.SIGNATURE_VERSION) == null) {
        return;
    }

    boolean createdBySigntool = false;
    String createdBy = attributes.getValue("Created-By");
    if (createdBy != null) {
        createdBySigntool = createdBy.indexOf("signtool") != -1;
    }
    ……

函数接下来读取了所谓签名文件,也就是
META-INF目录下
CERT.SF文件中的内容。
CERT.SF文件内容大致如下:

《Android应用程序签名验证过程分析》
接着,判断了是否有“Signature-Version”属性,如果没有的话,直接返回。再下来判断apk是否是由签名工具签的名,判断条件就是在“Created-By”属性值内有没有“signtool”字符串。本例中,签名版本是“1.0”,并且不是用其它签名工具签的名。如果不是用其它工具签名的话,接下来还会验证主属性中是否有“SHA1-Digest-Manifest-Main-Attributes”属性的值,这个属性值记录的是对META-INF目录下MANIFEST.MF文件内,头属性块的hash值。

    ……
    byte[] manifestBytes = metaEntries.get(JarFile.MANIFEST_NAME);
    if (manifestBytes == null) {
        return;
    }
    ……
    if (mainAttributesEnd > 0 && !createdBySigntool) {
        String digestAttribute = "-Digest-Manifest-Main-Attributes";
        if (!verify(attributes, digestAttribute, manifestBytes, 0, mainAttributesEnd, false, true)) {
            throw failedVerification(jarName, signatureFile);
        }
    }
    ……

接着调用了
JarVerifier.verify对该摘要值进行验证:

private boolean verify(Attributes attributes, String entry, byte[] data,
            int start, int end, boolean ignoreSecondEndline, boolean ignorable) {
    for (int i = 0; i < DIGEST_ALGORITHMS.length; i++) {
        String algorithm = DIGEST_ALGORITHMS[i];
        String hash = attributes.getValue(algorithm + entry);
        if (hash == null) {
            continue;
        }

        MessageDigest md;
        try {
            md = MessageDigest.getInstance(algorithm);
        } catch (NoSuchAlgorithmException e) {
            continue;
        }
        if (ignoreSecondEndline && data[end - 1] == '\n' && data[end - 2] == '\n') {
            md.update(data, start, end - 1 - start);
        } else {
            md.update(data, start, end - start);
        }
        byte[] b = md.digest();
        byte[] hashBytes = hash.getBytes(StandardCharsets.ISO_8859_1);
        return MessageDigest.isEqual(b, Base64.decode(hashBytes));
    }
    return ignorable;
}

JarVerifier.verify函数很简单,由于不知道到底是用什么算法算出的散列值,所以其会遍历所有的可能算法。这些算法都预先定义在
DIGEST_ALGORITHMS这个
JarVerifier内的静态字符串数组变量中:

    private static final String[] DIGEST_ALGORITHMS = new String[] {
        "SHA-512",
        "SHA-384",
        "SHA-256",
        "SHA1",
    };

可以看出,一共支持四种算法,本例中用到的是SHA1摘要算法。变量attributes表示的是一个属性块,而变量entry是要在attributes属性块中查找的属性名的一部分,它会与摘要算法的名称拼接成正真的属性名。接着会将在属性块中,对应属性名的属性值取出来,与data数据块中startend之间的数据,用同样算法算出的摘要值进行比较,如果一致就返回“true”,不一致则返回“false”。


ignorable表示这个验证是否可忽略,也就是说如果要查找的属性不存在的情况下,如果可忽略,则仍然返回“
true”。但如果属性值确实存在则这项对判断结果没有任何影响。本例中,根本没有这个属性,但是验证任然是通过的,因为在调用的时候,最后一个参数
ignorable被设置成了“
true”。

    ……
    String digestAttribute = createdBySigntool ? "-Digest" : "-Digest-Manifest";
    if (!verify(attributes, digestAttribute, manifestBytes, 0, manifestBytes.length, false, false)) {
        Iterator<Map.Entry<String, Attributes>> it = entries.entrySet().iterator();
        while (it.hasNext()) {
            Map.Entry<String, Attributes> entry = it.next();
            Manifest.Chunk chunk = manifest.getChunk(entry.getKey());
            if (chunk == null) {
                return;
            }
            if (!verify(entry.getValue(), "-Digest", manifestBytes,
                    chunk.start, chunk.end, createdBySigntool, false)) {
                throw invalidDigest(signatureFile, entry.getKey(), jarName);
            }
        }
    }
    metaEntries.put(signatureFile, null);
    signatures.put(signatureFile, entries);
}

JarVeirifer.verifyCertificate剩下的代码就很简单了,会比较MANIFEST.MF文件的整体摘要值和每一个属性块的摘要值,与CERT.SF文件中记录的是否一致。如果都验证通过的话,会将该签名文件的信息加到metaEntriessignatures属性变量中去。

所以,在StrictJarFile构造的过程中就已经完成了两步验证:一是通过在CERT.RSA文件中记录的签名信息,验证了CERT.SF没有被篡改过;二是通过CERT.SF文件中记录的摘要值,验证了MANIFEST.MF没有被修改过。

所以,到目前为止,还有一步没有被验证,即apk内文件的摘要值要与
MANIFEST.MF文件中记录的一致。接下来,让我们继续回到
PackageParser. collectCertificates函数中:

        ……
        final ZipEntry manifestEntry = jarFile.findEntry(ANDROID_MANIFEST_FILENAME);
        if (manifestEntry == null) {
            throw new PackageParserException(INSTALL_PARSE_FAILED_BAD_MANIFEST,
                        "Package " + apkPath + " has no manifest");
        }

        final List<ZipEntry> toVerify = new ArrayList<>();
        toVerify.add(manifestEntry);

        if ((flags & PARSE_IS_SYSTEM) == 0) {
            final Iterator<ZipEntry> i = jarFile.iterator();
            while (i.hasNext()) {
                final ZipEntry entry = i.next();

                if (entry.isDirectory()) continue;
                if (entry.getName().startsWith("META-INF/")) continue;
                if (entry.getName().equals(ANDROID_MANIFEST_FILENAME)) continue;

                toVerify.add(entry);
            }
        }
        ……

接下来的代码主要是用来确定,到底哪些文件需要进行验证。
AndroidManifest.xml无论如何都要验证。如果不是系统,也就是普通的应用程序安装,必须要验证除去位于
META-INF目录下所有文件之外的所有剩下的文件。

        ……
        for (ZipEntry entry : toVerify) {
            final Certificate[][] entryCerts = loadCertificates(jarFile, entry);
            if (ArrayUtils.isEmpty(entryCerts)) {
                throw new PackageParserException(
                            INSTALL_PARSE_FAILED_NO_CERTIFICATES,
                            "Package " + apkPath + " has no certificates at entry " + entry.getName());
            }
            final Signature[] entrySignatures = convertToSignatures(entryCerts);
            ……

接着是逐项验证前面罗列出的apk中的各个文件。对每个文件,都接着调用了
PackageParser.loadCertificates函数:

private static Certificate[][] loadCertificates(StrictJarFile jarFile, ZipEntry entry)
            throws PackageParserException {
    InputStream is = null;
    try {
        is = jarFile.getInputStream(entry);
        readFullyIgnoringContents(is);
        return jarFile.getCertificateChains(entry);
    } catch (IOException | RuntimeException e) {
        throw new PackageParserException(INSTALL_PARSE_FAILED_UNEXPECTED_EXCEPTION,
                    "Failed reading " + entry.getName() + " in " + jarFile, e);
    } finally {
        IoUtils.closeQuietly(is);
    }
}

貌似没有什么特别的,只是对apk内的文件创建了一个输入流,并且通过函数
PackageParser.readFullyIgnoringContents全读了一遍,而且通过函数名可以看出,具体读出什么内容并不重要。我们先来看看
StrictJarFile.getInputStream函数:

public InputStream getInputStream(ZipEntry ze) {
    final InputStream is = getZipInputStream(ze);

    if (isSigned) {
        JarVerifier.VerifierEntry entry = verifier.initEntry(ze.getName());
        if (entry == null) {
            return is;
        }

        return new JarFile.JarFileInputStream(is, ze.getSize(), entry);
    }

    return is;
}

重点要关注两个函数调用,一是
JarVerifier.initEntry,二是
JarFile.JarFileInputStream。好,我们先来看第一个:

VerifierEntry initEntry(String name) {
    if (manifest == null || signatures.isEmpty()) {
        return null;
    }

    Attributes attributes = manifest.getAttributes(name);
    if (attributes == null) {
        return null;
    }

    ArrayList<Certificate[]> certChains = new ArrayList<Certificate[]>();
    Iterator<Map.Entry<String, HashMap<String, Attributes>>> it = signatures.entrySet().iterator();
    while (it.hasNext()) {
        Map.Entry<String, HashMap<String, Attributes>> entry = it.next();
        HashMap<String, Attributes> hm = entry.getValue();
        if (hm.get(name) != null) {
            String signatureFile = entry.getKey();
            Certificate[] certChain = certificates.get(signatureFile);
            if (certChain != null) {
                certChains.add(certChain);
            }
        }
    }

    if (certChains.isEmpty()) {
        return null;
    }
    Certificate[][] certChainsArray = certChains.toArray(new Certificate[certChains.size()][]);

    for (int i = 0; i < DIGEST_ALGORITHMS.length; i++) {
        final String algorithm = DIGEST_ALGORITHMS[i];
        final String hash = attributes.getValue(algorithm + "-Digest");
        if (hash == null) {
            continue;
        }
        byte[] hashBytes = hash.getBytes(StandardCharsets.ISO_8859_1);

        try {
            return new VerifierEntry(name, MessageDigest.getInstance(algorithm), hashBytes,
                        certChainsArray, verifiedEntries);
        } catch (NoSuchAlgorithmException ignored) {
        }
    }
    return null;
}

该函数主要的用途就是构造一个JarVerifer.VerifierEntry对象:

要构造这个对象,必须事先准备好参数。第一个参数很简单,就是要验证的文件名,直接将
name传进来就好了。第二个参数是计算摘要的对象,可以通过
MessageDigest.getInstance获得,不过要先告知到底要用哪个摘要算法,同样也是通过查看
MANIFEST.MF文件中对应名字的属性值来决定的。本例中的
MANIFEST.MF文件格式大致如下:

《Android应用程序签名验证过程分析》

所以可以知道所用的摘要算法是SHA1。第三个参数是对应文件的摘要值,这是通过读取MANIFEST.MF文件获得的。第四个参数是证书链,即对该apk文件签名的所有证书链信息。为什么是二维数组呢?这是因为Android允许用多个证书对apk进行签名,但是它们的证书文件名必须不同。最后一个参数是已经验证过的文件列表,VerifierEntry在完成了对指定文件的摘要验证之后会将该文件的信息加到其中。

生成好了
entry之后,我们接下来看
JarFile(代码位于)中的
JarFileInputStream函数的实现:

static final class JarFileInputStream extends FilterInputStream {
    private final JarVerifier.VerifierEntry entry;

    private long count;
    private boolean done = false;

    JarFileInputStream(InputStream is, long size, JarVerifier.VerifierEntry e) {
        super(is);
        entry = e;

        count = size;
    }
    ……

其构造函数没有什么特别的,只是完成了赋值的操作。所以,调用
StrictJarFile.getInputStream函数之后,实际返回的是一个
JarFileInputStream对象。在获得了这个输入流对象后,紧接着,
PackageParser.loadCertificates会调用
PackageParser .readFullyIgnoringContents对这个输入流进行读取的操作:

public static long readFullyIgnoringContents(InputStream in) throws IOException {
    byte[] buffer = sBuffer.getAndSet(null);
    if (buffer == null) {
        buffer = new byte[4096];
    }

    int n = 0;
    int count = 0;
    while ((n = in.read(buffer, 0, buffer.length)) != -1) {
        count += n;
    }

    sBuffer.set(buffer);
    return count;
}

没什么特别的,只是调用了
InputStream
read函数,直到读完为止,而且只是返回了读到了多少个字节,并没有返回读到的内容,所以读到什么内容它并不关心。由于实际传进来的是
InputStream的子类,这里也就是
JarFileInputStream,它对
read函数进行了重载,看它是如何实现的:

    public int read(byte[] buffer, int byteOffset, int byteCount) throws IOException {
        if (done) {
            return -1;
        }
        if (count > 0) {
            int r = super.read(buffer, byteOffset, byteCount);
            if (r != -1) {
                int size = r;
                if (count < size) {
                    size = (int) count;
                }
                entry.write(buffer, byteOffset, size);
                count -= size;
            } else {
                count = 0;
            }
            if (count == 0) {
                done = true;
                entry.verify();
            }
            return r;
        } else {
            done = true;
            entry.verify();
            return -1;
        }
    }

玄机原来在这里,这里的
JarFileInputStream.read确实会调用其父类的
read读取指定的apk内文件的内容,并且将其传给
JarVerifier.VerifierEntry.write函数。当文件读完后,会接着调用
JarVerifier.VerifierEntry.verify函数对其进行验证。
JarVerifier.VerifierEntry.write函数非常简单:

        public void write(byte[] buf, int off, int nbytes) {
            digest.update(buf, off, nbytes);
        }

就是将读到的文件的内容传给digest,这个digest就是前面在构造JarVerifier.VerifierEntry传进来的,对应于在MANIFEST.MF文件中指定的摘要算法。万事具备,接下来想要验证就很简单了:

        void verify() {
            byte[] d = digest.digest();
            if (!MessageDigest.isEqual(d, Base64.decode(hash))) {
                throw invalidDigest(JarFile.MANIFEST_NAME, name, name);
            }
            verifiedEntries.put(name, certChains);
        }

通过
digest就可以算出apk内指定文件的真实摘要值。而记录在
MANIFEST.MF文件中对应该文件的摘要值,也在构造
JarVerifier.VerifierEntry时传递给了
hash变量。不过这个
hash值是经过Base64编码的。所以在比较之前,必须通过Base64解码。如果不一致的话,会抛出
SecurityException异常:

    private static SecurityException invalidDigest(String signatureFile, String name,
            String jarName) {
        throw new SecurityException(signatureFile + " has invalid digest for " + name +
                " in " + jarName);
    }

至此,最后一步验证,即apk内所有文件的摘要值要和在
MANIFEST.MF文件中记录的一致,也已经完成了。这还没完,
PackageParser.collectCertificates还要接着验证apk文件中的每个文件对应的签名要和第一个文件一致:

            ……
            if (pkg.mCertificates == null) {
                pkg.mCertificates = entryCerts;
                pkg.mSignatures = entrySignatures;
                pkg.mSigningKeys = new ArraySet<PublicKey>();
                for (int i=0; i < entryCerts.length; i++) {
                    pkg.mSigningKeys.add(entryCerts[i][0].getPublicKey());
                }
            } else {
                if (!Signature.areExactMatch(pkg.mSignatures, entrySignatures)) {
                    throw new PackageParserException(
                        INSTALL_PARSE_FAILED_INCONSISTENT_CERTIFICATES, 
                        "Package " + apkPath + " has mismatched certificates at entry " 
                        + entry.getName());
                }
            }
            ……

到这里,apk安装时的签名验证过程都已经分析完了,来总结一下:

  1. 所有有关apk文件的签名验证工作都是在JarVerifier里面做的,一共分成三步;
  2. JarVeirifer.verifyCertificate主要做了两步。首先,使用证书文件(在META-INF目录下,以.DSA.RSA或者.EC结尾的文件)检验签名文件(在META-INF目录下,和证书文件同名,但扩展名为.SF的文件)是没有被修改过的。然后,使用签名文件,检验MANIFEST.MF文件中的内容也没有被篡改过;
  3. JarVerifier.VerifierEntry.verify做了最后一步验证,即保证apk文件中包含的所有文件,对应的摘要值与MANIFEST.MF文件中记录的一致。
    原文作者:roland_sun
    原文地址: https://blog.csdn.net/roland_sun/article/details/42029019
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞