【前言】
Android Apk V1簽名方式是一開始時使用的簽名方案,不過V1簽名方式也稱作
Jar簽名,顧名思義,就是V1簽名并不是Android獨有的簽名方式,而且在Android還沒出來時候,Jar 包也是用這種方式進行簽名檢驗的,直到Android 7.0開始才推出V2簽名,這個就是Android獨創的簽名方案,簽名與校驗的效率方面提高很多,后面Android 9.0又推出了V3簽名,再到Android 11推出了V4簽名方案
一、V1簽名程序分析

1、MANIFEST.MF
遍歷Apk中除了META-INF目錄下以下檔案之外的所有檔案,
META-INF/MANIFEST.MF
META-INF/*.SF
META-INF/*.DSA
META-INF/*.RSA
META-INF/SIG-*
并對他們逐一使用SHA1或者SHA256演算法,計算出摘要值,Base64之后保存到 MANIFEST.MF檔案中,
最終 MANIFEST.MF檔案內容大致如下:

1.1、前面3行是主屬性記錄:
Manifest-Version: 1.0
Built-By: Signflinger
Created-By: Android Gradle 4.1.2
1.2、 其中每個檔案摘要前的SHA-256-Digest,這個由簽名apk時所用到簽名檔案的簽名演算法以及apk本身適配的最小SDK版本號共同決定,取值可能是SHA-1-Digest 與 SHA-256-Digest,下面是簽名工具的代碼實作部分:
public static DigestAlgorithm getSuggestedSignatureDigestAlgorithm(PublicKey signingKey, int minSdkVersion) throws InvalidKeyException {
String keyAlgorithm = signingKey.getAlgorithm();
if ("RSA".equalsIgnoreCase(keyAlgorithm)) {
if (minSdkVersion < 18) {
return DigestAlgorithm.SHA1;
}
return DigestAlgorithm.SHA256;
}
if ("DSA".equalsIgnoreCase(keyAlgorithm)) {
if (minSdkVersion < 21) {
return DigestAlgorithm.SHA1;
}
return DigestAlgorithm.SHA256;
}
if ("EC".equalsIgnoreCase(keyAlgorithm)) {
if (minSdkVersion < 18) {
throw new InvalidKeyException("ECDSA signatures only supported for minSdkVersion 18 and higher");
}
return DigestAlgorithm.SHA256;
}
throw new InvalidKeyException("Unsupported key algorithm: " + keyAlgorithm);
}
1.3、上面說到META目錄下除了MENIFEST.MF、*.SF、*.RSA、*.DSA、SIG-* 檔案之外都參與摘要簽名計算,而且也只是META根目錄的這些檔案不參與計算簽名, 在META目錄的子目錄中的所有檔案都是參與簽名的,看下圖也可得知:

1.4、遍歷對apk中的檔案解壓之后,再利用SHA1或者SHA256演算法計算摘要簽名,然后Base64之后保存到MENIFEST.MF中,主要代碼實作邏輯如下:
//解壓并更新摘要類
private static class InflateSinkAdapter
implements DataSink, Closeable {
private final DataSink mDelegate;
.....
public void consume(byte[] buf, int offset, int length) throws IOException {
checkNotClosed();
this.mInflater.setInput(buf, offset, length);
if (this.mOutputBuffer == null) {
this.mOutputBuffer = new byte[65536];
}
while (!this.mInflater.finished()) {
int outputChunkSize;
try {
//對檔案進行解壓,outputChunkSize為解壓之后的大小,mOutputBuffer保存解壓之后的資料
outputChunkSize = this.mInflater.inflate(this.mOutputBuffer);
} catch (DataFormatException e) {
throw new IOException("Failed to inflate data", e);
}
if (outputChunkSize == 0) {
return;
}
//mDelegate為MessageDigestSink物件,對解壓之后的檔案進行摘要簽名更新
this.mDelegate.consume(this.mOutputBuffer, 0, outputChunkSize);
this.mOutputByteCount += outputChunkSize;
}
}
.....
}
// 摘要資料更新類
public class MessageDigestSink implements DataSink {
private final MessageDigest[] mMessageDigests;
public MessageDigestSink(MessageDigest[] digests) {
this.mMessageDigests = digests;
}
public void consume(byte[] buf, int offset, int length) {
for (MessageDigest md : this.mMessageDigests) {
md.update(buf, offset, length);
}
}
public void consume(ByteBuffer buf) {
int originalPosition = buf.position();
for (MessageDigest md : this.mMessageDigests) {
buf.position(originalPosition);
md.update(buf);
}
}
}
//計算摘要
private static void fulfillInspectInputJarEntryRequest(DataSource lfhSection, LocalFileRecord localFileRecord, ApkSignerEngine.InspectJarEntryRequest inspectEntryRequest) throws IOException, ZipFormatException {
//解壓本地檔案資料出來并放入到MessageDigestSink中
localFileRecord.outputUncompressedData(lfhSection, inspectEntryRequest.getDataSink());
// 計算出檔案資料的摘要簽名
inspectEntryRequest.done();
}
1.5、MANIFEST.MF的行最長只允許70個字符,這里面包括:Name 與 檔案名中間的冒號與空格(不包括\r\n,加上回車換行符共72個字符),要是超出70個字符就回車換行,然后在新行先寫入1個空格,再繼續寫入剩下的檔案名,代碼實作如下:
private static final byte[] CRLF = new byte[]{13, 10};
private static final int MAX_LINE_LENGTH = 70;
private static void writeAttribute(OutputStream out, String name, String value) throws IOException {
writeLine(out, name + ": " + value);
}
private static void writeLine(OutputStream out, String line) throws IOException {
byte[] lineBytes = line.getBytes(StandardCharsets.UTF_8);
int offset = 0;
int remaining = lineBytes.length;
boolean firstLine = true;
while (remaining > 0) {
int chunkLength;
if (firstLine) {
//一行最高70個字符,超過70個就換行顯示
chunkLength = Math.min(remaining, MAX_LINE_LENGTH);
} else {
//回車換行
out.write(CRLF);
//空格
out.write(32);
//因為這一行多了1個空格,所以最多只能69個字符
chunkLength = Math.min(remaining, 69);
}
out.write(lineBytes, offset, chunkLength);
offset += chunkLength;
remaining -= chunkLength;
firstLine = false;
}
//末尾回車換行
out.write(CRLF);
}
1.6、因為每次寫入1個資料塊就寫入2對回車換行符,所以在MANIFEST.MF末尾會有2個空行,下面看看每次寫入1個資料塊的代碼實作:
//寫入資料塊
public static void writeIndividualSection(OutputStream out, String name, Attributes attributes) throws IOException {
//寫入類似:Name: AndroidManifest.xml\r\n
writeAttribute(out, "Name", name);
if (!attributes.isEmpty()) {
//寫入類似:SHA1-Digest: tJkLYKjlAku97m4hDC7yxlJK4XA=\r\n
writeAttributes(out, getAttributesSortedByName(attributes));
}
//寫入:\r\n
writeSectionDelimiter(out);
}
// 寫入回車換行符
static void writeSectionDelimiter(OutputStream out) throws IOException {
out.write(CRLF);
}
2、CERT.SF
.SF檔案名是由簽名時候所傳入的引數v1-signer-name、ks-key-alias或者keystore檔案名所決定,不是固定為CERT.SF,.SF檔案的主要作用是對MANIFEST.MF做校驗,防止MANIFEST.MF的資料被篡改,.SF檔案主要保存了MANIFEST.MF整個檔案的簽名摘要資訊以及每一個資料塊的簽名摘要資訊,資訊如下:

2.1、第一個資料塊是.SF檔案的主屬性資訊:
Signature-Version: 1.0
Created-By: 1.0 (Android)
SHA-256-Digest-Manifest: RF3bTyfX9uHZesEx91eumLw7u4EYS1cMc5emxi0npso=
X-Android-APK-Signed: 2, 3
其中,SHA-256-Digest-Manifest這個屬性的值是對MANIFEST.MF整個檔案用SHA-256散列演算法計算出的摘要Base64的值;
X-Android-APK-Signed,這個屬性指定是否開啟比V1更高級的簽名方式,這里值為2,3,說明開啟了V2、V3簽名,那么應用安裝時候,假如跳過V2、V3簽名驗證(即破壞或者去掉V2、V3簽名資訊), 直接去驗證V1就會拋例外,這個是為了防止降級驗證
2.2、有些簽名工具還會在.SF主屬性中寫入SHA-256-Digest-Manifest-Main-Attributes,這個屬性的值是MANIFEST.MF主屬性塊的摘要Base64的值,驗證簽名的時候會優先驗證這一塊的摘要,只有驗證通過之后才去驗證整個MANIFEST.MF檔案的資料摘要;對于資料塊這個概念定義需要注意一下,下面這樣一整塊是屬于MANIFEST.MF的一個主屬性塊,一起參與摘要計算:
Manifest-Version: 1.0
Created-By: 1.8.0_161 (Oracle Corporation)
上面把回車換行符顯式表示出來的話,實際是這樣:Manifest-Version: 1.0\r\nCreated-By: 1.8.0_161 (Oracle Corporation)\r\n\r\n,那么對這一整塊進行SHA-256演算法計算得到值為:

可以用以下代碼計算:
public static void main(String[] args) {
String data= "Manifest-Version: 1.0\r\nCreated-By: 1.8.0_161 (Oracle Corporation)\r\n\r\n";
MessageDigest md;
try {
md = MessageDigest.getInstance("SHA-256");
byte[] digest = md.digest(data.getBytes("utf-8"));
String base64Digest = Base64.getEncoder().encodeToString(digest);
System.out.println("\n******************** 計算結果 ******************** ");
System.out.println(base64Digest);
System.out.println("************************************************** ");
} catch (Exception e) {
}
}
這里一定要注意的是:計算摘要時候,一定要把最后的兩個回車換行符一起參與計算,后面各個檔案對應的摘要資料塊亦是如此,來看看簽名工具計算出來記錄在.SF檔案的數值:

由于一行超過了70個字符,所以SHA-256-Digest-Manifest-Main-Attributes這一行換行了,洗掉空格跟換行之后,跟我們計算出來的值是一致的
2.3、接下來就是對各個檔案摘要資料塊的進行摘要簽名計算,計算方式跟主屬性摘要計算一樣,比如對于MANIFEST.MF下圖這一檔案摘要資料塊:

字串表示為:Name: res/drawable-mdpi/ic_currency_mad.png\r\nSHA-256-Digest: tFS4pZtxah1Uc84XRqsMhYVcBxN0bdI9PKinhLj79UA=\r\n\r\n, 用SHA-256算出摘要再Base64的結果如下:

看看.SF檔案對應的值,的確也是一致的

3、CERT.RSA
CERT.RSA檔案名也不是固定的,命名規則跟.SF檔案一樣,而且后綴名也根據不同的簽名演算法,取不同的后綴名:.DSA 、.RSA、.EC
3.1、CERT.RSA檔案實際上是PKCS#7格式的資料經過DER規則編碼之后的二進制檔案
PKCS#7,即密碼訊息語法標準(Cryptographic Message Syntax Standard),是公鑰加密標準(Public Key Cryptography Standards, PKCS)的1.5版本,資料格式大致如下:

DER(Distinguished Encoding Rules),即可分辨編碼規則,是ASN.1標準(Abstract Syntax Notation One,抽象語法標記)的一種編碼規則ContentInfo這個欄位,理論上來說是存放待簽名內容,在這里的話,也就是對應.SF檔案資料,但是因為可以直接去讀取.SF檔案資料來進行簽名校驗,所以實際上ContentInfo并沒有保存.SF檔案資料
3.2、PKCS#7中包含了X.509證書(密碼學里公鑰證書的格式標準),X.509證書格式如下

可以通過openssl以下命令查看CERT.RSA檔案中包含的所有x509證書詳情
openssl pkcs7 -inform DER -in <*.RSA檔案路徑> -text -noout -print_certs
顯示資訊如下:

3.3、PKCS#7中包含的簽名者資訊SignerInfo資料結構如下:

其中,EncryptedDigest中存盤的就是*.SF檔案資料用SHA-1或者SHA-256演算法算出的摘要值,然后用私鑰簽名之后的資料
看看運行時signerInfo物件:
列印出來的資料如下:

二、V1簽名校驗程序分析
1、先在META-INF目錄下查找后綴名為.DSA 或 .RSA 或 .EC 的簽名檔案,找到之后,根據簽名檔案的檔案名推出.SF檔案的檔案名,比如:找到META-INF目錄下檔案名為:KK.RSA的簽名檔案, 那么,可以推出.SF檔案的檔案名為:KK.SF
2、讀取.RSA簽名檔案資料,構造出PKCS#7格式的物件pkcs7, 從pkcs7的X.509證書中讀取出公鑰pk,從pkcs7的signerInfo中讀取出簽名資料encryptedDigest,然后用公鑰pk對簽名資料encryptedDigest進行解密得到摘要資料digest, 讀取.SF檔案資料然后計算摘要得到摘要資料sfDigest,最后比對摘要資料sfDigest與摘要資料digest是否相等,如果相等,說明.SF檔案沒有被篡改,否則簽名校驗失敗
3、假如.SF檔案中 Created-By的屬性值不存在:signtool字串,同時SHA-256-Digest-Manifest-Main-Attributes(或SHA-1-Digest-Manifest-Main-Attributes)的屬性值存在,那么先校驗MANIFEST.MF的主屬性資料塊的摘要是否跟SHA-256-Digest-Manifest-Main-Attributes屬性值相等,相等的話才繼續進行下一步的校驗,否則簽名校驗失敗
4、計算MANIFEST.MF整個檔案的摘要值,跟.SF檔案記錄的SHA-256-Digest-Manifest-Main-Attributes對應的值比較,假如相等,那么可以肯定MANIFEST.MF檔案沒有被篡改,否則需要進一步對MANIFEST.MF檔案中的每一個資料塊進行計算摘要值,然后跟.SF檔案中記錄的摘要值進行比對,如果每一個資料塊的摘要值都相等才進行下一步的校驗,否則簽名校驗失敗
5、先讀取AndroidManifest.xml檔案資料計算出摘要值,跟MANIFEST.MF中記錄的摘要值比對,如果相等,繼續遍歷所有檔案并計算出摘要值跟MANIFEST.MF中記錄的摘要值比對,否則簽名校驗失敗

三、V1簽名校驗程序原始碼分析
因為V1簽名原始碼部分比較繞,所以這里對原始碼閱讀進行一個簡要的分析,以便大家快速找到自己想要閱讀的部分

1、verifyCertificate里面的包括:方法verifyBytes(校驗.SF檔案摘要是否跟.RSA檔案中記錄的摘要值一致)、verify(校驗MANIFEST.MF檔案的摘要是否.SF檔案的記錄一致)
2、loadCertificates方法主要是校驗apk內的檔案計算出的摘要值是否跟MANIFEST.MF中記錄的一致,其中,計算摘要的詳細實作是在read方法中,從上圖可以看出,read方法最侄訓呼叫MessageDigest#digest方法計算出檔案的摘要值,然后呼叫verifyMessageDigest方法比對計算出來的摘要值跟MANIFEST.MF中記錄的是否一致
【擴展問題】
V1簽名的主要目的是為了防止apk內的檔案被篡改,在整個簽名程序中,我們可以看到先對Apk內每個檔案計算摘要記錄到MANIFEST.MF中,然后又對MANIFEST.MF整個檔案以及每個資料塊計算摘要記錄到.SF中,最后再對.SF整個檔案計算摘要并用私鑰簽名記錄到.RSA中,那么這個程序就會有一個疑問,為啥要多此一舉去創建一個.SF檔案呢?直接對MANIFEST.MF整個檔案計算摘要并用私鑰簽名記錄到.RSA中,不是一樣可以達到防止篡改的目的嗎?從簽名校驗的程序中分析可以得知,.SF存在的意義應該是在對MANIFEST.MF整個檔案的摘要值校驗失敗時,可以再對MANIFEST.MF中的每一個資料塊進行摘要計算,要是每一個資料塊的摘要校驗可以通過,那么簽名校驗依然是可以通過的,只不過這樣的一個校驗設計邏輯是基于什么方面的考慮呢?這個不得而知,有知道的小伙伴歡迎告知一二

轉載請註明出處,本文鏈接:https://www.uj5u.com/yidong/289631.html
標籤:其他
下一篇:稍等,我手機幫你遠程除錯下代碼!
