VasDolly对V2签名机制的支持
为啥需要V2签名?
v1有如下两个弊端:
- MANIFEST.MF中的数据摘要是基于原始未压缩文件计算的。因此在校验时,需要先解压出原始文件,才能进行校验。
- V1签名仅仅校验APK第一部分中的文件,缺少对APK的完整性校验。因此,如上面文章的分析,我们还是可以改apk文件的,而且不需要再签名。
而V2的出现就是为了解决上面的问题。提升速度,对apk本身进行数据摘要计算,这样就不用解压apk来校验了;增加安全性,对整个apk校验,因此任何的修改都无法通过签名的校验。
在开发中如何应对这种变化?
- JDK中的jarsigner与V2签名是不兼容的
- apksigner同时支持v1、v2的支持
Gradle2.2以上默认开启V2签名,所以如果想关闭V2签名,可将下面的v2SigningEnabled设置为false
signingConfigs { release { ... v1SigningEnabled true v2SigningEnabled false } debug { ... v1SigningEnabled true v2SigningEnabled false } }
V2签名的apk的文件格式
V2签名的apk也是zip,不过不是标准的zip包。而是一种扩展的zip。
V2签名会生成一个单独的签名块,插入到apk中。
就是在文件内容块之后,目录中心之前插入。为了使得V2的apk依然是个zip文件,V2签名同时修改了EOCD中的中央目录的偏移量,使签名后的APK还符合ZIP结构。
ApkSignerV2源码分析
主要代码(也是签名的入口)是在ApkSignerV2.java#ByteBuffer[] sign()
方法里。代码很长,我们只对关键的代码做下分析。
1. 获取apk的全部数据
inputApk.clear();
ByteBuffer beforeCentralDir = getByteBuffer(inputApk, centralDirOffset);
ByteBuffer centralDir = getByteBuffer(inputApk, eocdOffset - centralDirOffset);
// Create a copy of End of Central Directory because we'll need modify its contents later.
byte[] eocdBytes = new byte[inputApk.remaining()];
inputApk.get(eocdBytes);
ByteBuffer eocd = ByteBuffer.wrap(eocdBytes);
eocd.order(inputApk.order());
通过定位,找出位置,从而可以分段读取zip文件里的每段的数据。
2. 计算内容的摘要
// Compute digests of APK contents.
Map<Integer, byte[]> contentDigests; // digest algorithm ID -> digest
try {
contentDigests =
computeContentDigests(
contentDigestAlgorithms,
new ByteBuffer[] {beforeCentralDir, centralDir, eocd});
} catch (DigestException e) {
throw new SignatureException("Failed to compute digests of APK", e);
}
生成摘要信息是非常耗性能的,因此google也做了相应的优化,可以并行计算:
主要的思路是:
- 将上述APK中文件内容块、中央目录、EOCD按照1MB大小分割成一些小块。
- 计算每个小块的数据摘要,基础数据是0xa5 + 块字节长度 + 块内容。
- 计算整体的数据摘要,基础数据是0x5a + 数据块的数量 + 每个数据块的摘要内容。
2. 生成apk签名块
private static byte[] generateApkSigningBlock(
List<SignerConfig> signerConfigs,
Map<Integer, byte[]> contentDigests) throws InvalidKeyException, SignatureException {
byte[] apkSignatureSchemeV2Block =
generateApkSignatureSchemeV2Block(signerConfigs, contentDigests);
return generateApkSigningBlock(apkSignatureSchemeV2Block);
}
private static byte[] generateApkSigningBlock(byte[] apkSignatureSchemeV2Block) {
int resultSize =
8 // size
+ 8 + 4 + apkSignatureSchemeV2Block.length // v2Block as ID-value pair
+ 8 // size
+ 16 // magic
;
ByteBuffer result = ByteBuffer.allocate(resultSize);
result.order(ByteOrder.LITTLE_ENDIAN);
long blockSizeFieldValue = resultSize - 8;
result.putLong(blockSizeFieldValue);
long pairSizeFieldValue = 4 + apkSignatureSchemeV2Block.length;
result.putLong(pairSizeFieldValue);
result.putInt(APK_SIGNATURE_SCHEME_V2_BLOCK_ID);
result.put(apkSignatureSchemeV2Block);
result.putLong(blockSizeFieldValue);
result.put(APK_SIGNING_BLOCK_MAGIC);
return result.array();
}
内部代码逻辑太过复杂~~,只说明下签名块的格式:
对格式的进一步解释:
魔数的存在是为了方便确认apk签名分块的,因为它是固定的值,比较好定位到它;而当它被确定了,再又上面的签名块的大小,就能确定签名分块在文件中的起始位置。但是没搞懂为什么最上面需要记录签名块大小。
V2的验证
gradle2.2以上默认会同时走V1和V2签名(没有v1的签名,是可以编译通过的,但是在7.0上运行会报签名错误,所以会同时开启v1、v2签名)
主要流程简单的说:优先V2,没有V2,走V1的校验流程
V1的流程在签名已经介绍过,现在介绍下V2的流程。它的主要代码在ApkSignatureSchemeV2Verifier.java
中。
1.
VasDolly对的多渠道支持
在上面的分析中,可以看到,上面的签名也是存在漏洞的,即Android系统只会关注ID为0x7109871a的V2签名块,并且忽略其他的ID-Value,同时V2签名只会保护APK本身,不包含签名块。
因此基于这个漏洞,可以在apk的签名块中添加一个ID-Value,存储渠道信息。
1. 读取V2签名的apk信息
public static ApkSectionInfo getApkSectionInfo(File baseApk) throws IOException, ApkSignatureSchemeV2Verifier.SignatureNotFoundException {
RandomAccessFile apk = new RandomAccessFile(baseApk, "r");
//1.find the EOCD and offset
Pair<ByteBuffer, Long> eocdAndOffsetInFile = ApkSignatureSchemeV2Verifier.getEocd(apk);
ByteBuffer eocd = eocdAndOffsetInFile.getFirst();
long eocdOffset = eocdAndOffsetInFile.getSecond();
if (ZipUtils.isZip64EndOfCentralDirectoryLocatorPresent(apk, eocdOffset)) {
throw new ApkSignatureSchemeV2Verifier.SignatureNotFoundException("ZIP64 APK not supported");
}
//2.find the APK Signing Block. The block immediately precedes the Central Directory.
long centralDirOffset = ApkSignatureSchemeV2Verifier.getCentralDirOffset(eocd, eocdOffset);//通过eocd找到中央目录的偏移量
Pair<ByteBuffer, Long> apkSchemeV2Block =
ApkSignatureSchemeV2Verifier.findApkSigningBlock(apk, centralDirOffset);//找到V2签名块的内容和偏移量
//3.find the centralDir
Pair<ByteBuffer, Long> centralDir = findCentralDir(apk, centralDirOffset, (int) (eocdOffset - centralDirOffset));
//4.find the contentEntry
Pair<ByteBuffer, Long> contentEntry = findContentEntry(apk, (int) apkSchemeV2Block.getSecond().longValue());
ApkSectionInfo apkSectionInfo = new ApkSectionInfo();
apkSectionInfo.mContentEntry = contentEntry;
apkSectionInfo.mSchemeV2Block = apkSchemeV2Block;
apkSectionInfo.mCentralDir = centralDir;
apkSectionInfo.mEocd = eocdAndOffsetInFile;
System.out.println("baseApk : " + baseApk.getAbsolutePath() + " , ApkSectionInfo = " + apkSectionInfo);
return apkSectionInfo;
}
2. 从V2签名块中取出所有的Id-Value对
public static Map<Integer, ByteBuffer> getAllIdValue(ByteBuffer apkSchemeBlock) {
... ...
}
3. 生成新的V2签名块
public static ByteBuffer generateApkSigningBlock(Map<Integer, ByteBuffer> idValueMap) {
if (idValueMap == null || idValueMap.isEmpty()) {
throw new RuntimeException("getNewApkV2SchemeBlock , id value pair is empty");
}
// FORMAT:
// uint64: size (excluding this field)
// repeated ID-value pairs:
// uint64: size (excluding this field)
// uint32: ID
// (size - 4) bytes: value
// uint64: size (same as the one above)
// uint128: magic
long length = 16 + 8;//length is size (excluding this field) , 24 = 16 byte (magic) + 8 byte (length of the signing block excluding first 8 byte)
for (Map.Entry<Integer, ByteBuffer> entry : idValueMap.entrySet()) {
ByteBuffer byteBuffer = entry.getValue();
length += 8 + 4 + (byteBuffer.remaining());
}
ByteBuffer newApkV2Scheme = ByteBuffer.allocate((int) (length + 8));
newApkV2Scheme.order(ByteOrder.LITTLE_ENDIAN);
newApkV2Scheme.putLong(length);//1.write size (excluding this field)
for (Map.Entry<Integer, ByteBuffer> entry : idValueMap.entrySet()) {
ByteBuffer byteBuffer = entry.getValue();
//2.1 write length of id-value
newApkV2Scheme.putLong(byteBuffer.remaining() + 4);//4 is length of id
//2.2 write id
newApkV2Scheme.putInt(entry.getKey());
//2.3 write value
newApkV2Scheme.put(byteBuffer.array(), byteBuffer.arrayOffset() + byteBuffer.position(), byteBuffer.remaining());
}
newApkV2Scheme.putLong(length);//3.write size (same as the one above)
newApkV2Scheme.putLong(ApkSignatureSchemeV2Verifier.APK_SIG_BLOCK_MAGIC_LO);//4. write magic
newApkV2Scheme.putLong(ApkSignatureSchemeV2Verifier.APK_SIG_BLOCK_MAGIC_HI);//4. write magic
if (newApkV2Scheme.remaining() > 0) {
throw new RuntimeException("generateNewApkV2SchemeBlock error");
}
newApkV2Scheme.flip();
return newApkV2Scheme;
}
4. 将新的签名块写入到apk中
渠道的读取
public static String getChannelByV2(Context context) {
String apkPath = getApkPath(context);
String channel = ChannelReader.getChannel(new File(apkPath));
Log.i(TAG, "getChannelByV2 , channel = " + channel);
return channel;
}
public static String getStringValueById(File channelFile, int id) {
if (channelFile == null || !channelFile.exists() || !channelFile.isFile()) {
return null;
}
byte[] buffer = getByteValueById(channelFile, id);
try {
if (buffer != null && buffer.length > 0) {
return new String(buffer, ChannelConstants.CONTENT_CHARSET);
}
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
return null;
}
public static ByteBuffer getByteBufferValueById(File channelFile, int id) {
if (channelFile == null || !channelFile.exists() || !channelFile.isFile()) {
return null;
}
Map<Integer, ByteBuffer> idValueMap = getAllIdValueMap(channelFile);
System.out.println("getByteBufferValueById , destApk " + channelFile.getAbsolutePath() + " IdValueMap = " + idValueMap);
if (idValueMap != null) {
return idValueMap.get(id);
}
return null;
}
可以看出,对于V2签名的apk,读取channel的值是先解析出整个的Id-value块,然后根据id得到具体的值。