v1 签名方案重要的原理就是对 apk 中所有的文件计算摘要 保存到 MANIFEST.MF 文件中;然后计算 MANIFEST.MF 中每个条目的摘要以及 MANIFEST.MF 本身的摘要,保存到 CERT.SF 文件中;最后用私钥对 CERT.SF 文件加密,然后保存到 CERT.RSA 文件中(这个文件还包含了签名算法、公钥信息)
(1) .MF文件
apk当中的原始文件信息用摘要算法如SHA1计算得到的摘要信息并用base64编码保存,以及对应采用的摘要算法如SHA1(这个算法的特性是不管多大的文件内容都能够得到长度相同的摘要信息但是不同的文件内容信息得到的摘要信息肯定不同)
(2) .SF文件
.MF文件的摘要信息以及.MF文件当中每个条目在用摘要算法计算得到的摘要信息并用base64编码保存
(3) .RSA文件
存放证书信息,公钥信息,以及用私钥对.SF文件的加密数据即签名信息,这段数据是无法伪造的,除非有私钥,另外.RSA文件还记录了所用的签名算法等信息。
Apk包在安装的时候,是按照从(3)到(1)的顺序依次校验的,先用公钥还原签名信息,然后和.SF文件中的信息比对,然后用同样的摘要算法对.MF文件里面的每一个条目计算对应的摘要信息,然后比对.MF文件是否一致
向META-INF目录中创建文件写入渠道信息,因为在v1签名验证时候,并不会验证META-INF的除了.MF文件的其它文件
在APK的任意位置填写空文件夹(注意是空文件夹),在apk签名过程中,只会对apk中的所有文件进行摘要+签名的,不会对文件夹进行这样的操作(文件夹无法计算摘要),所以也不会验证这个文件夹
这个就要了解下APK结构(其实就是ZIP的结构)了,看下面的图
从图中我们能看出,有一个End of Central Directory的数据块,这块是什么呢?其实这块就是结尾信息,里面记录了部分信息,简称EOCD
我们主要看最后有一个注释长度的标识,那我们是不是可以通过这个加入渠道呢?当然可以,这个只是zip文件的一个注释字段,所以并不影响ZIP里面的文件,所以更不会影响APK的签名,Apk安装的前提是要把Zip解压,在进行验签名等过程,所以想要多渠道修改APK第一点就是保证Zip结构的正确,第二点才是不违背签名
只需要在Zip结尾处增加一个注释即可
逻辑代码:
/**
* 读取渠道信息
* @throws IOException
*/
private static void readCannel() throws IOException {
File in = new File("/Users/wenyingzhi/Desktop/Study/apksign/app-out.apk");
RandomAccessFile inR = new RandomAccessFile(in, "r");
// 读取渠道长度(读取后4个字节)
inR.seek(inR.length() - 4);
ByteBuffer commentLengthBytes = ByteBuffer.allocate(4);
commentLengthBytes.order(ByteOrder.LITTLE_ENDIAN);
inR.readFully(commentLengthBytes.array(), commentLengthBytes.arrayOffset(), commentLengthBytes.capacity());
// 得到注释长度了
int commentLength = commentLengthBytes.getInt();
System.out.println("当前注释长度:" + commentLength);
// 移动文件指针,读取备注 文件长度 - 存储注释长度的int(占4位) -注释长度
inR.seek(inR.length() - 4 - commentLength);
// 读取注释内容
ByteBuffer commentBytes = ByteBuffer.allocate(commentLength);
commentBytes.order(ByteOrder.LITTLE_ENDIAN);
inR.readFully(commentBytes.array(), commentBytes.arrayOffset(), commentBytes.capacity());
String comment = new String(commentBytes.array());
System.out.println("渠道信息:" + comment);
}
/**
* 写渠道信息
*/
private static void writeCannel() {
try {
// 原始包
File in = new File("/Users/wenyingzhi/Desktop/Study/apksign/app-release.apk");
// 生成的新的渠道包
File out = new File("/Users/wenyingzhi/Desktop/Study/apksign/app-out.apk");
FileUtil.copyFile(new FileInputStream(in), new FileOutputStream(out));
// 渠道信息
byte[] channelData = "channel_value".getBytes();
// 渠道长度
byte[] length = StreamTool.intToByte(channelData.length);
// 渠道信息的buffer
ByteBuffer channelBuffer = ByteBuffer.allocate(channelData.length + length.length);
channelBuffer.order(ByteOrder.LITTLE_ENDIAN);
// 写入渠道信息
channelBuffer.put(channelData);
// 写入渠道信息的长度
channelBuffer.put(length);
// todo 开始写入渠道信息
// 计算备注长度
byte[] commentLength = StreamTool.shortToByte((short) (channelData.length + length.length));
RandomAccessFile outR = new RandomAccessFile(out, "rw");
// 移动位置,向前移动2个字节
outR.seek(outR.length() - 2);
// 修改原来的备注长度,改成
outR.write(commentLength);
// 写入注释信息
outR.write(channelBuffer.array());
outR.close();
D("写入渠道包完成");
} catch (Exception e) {
e.printStackTrace();
}
}
学习之前先阅读下这篇文章要哦从这篇文章中我们有一个很重要的图
我们从上一篇位置中得知V2签名是按照Apk分块签名的(1024kb做一次摘要计算),然后将签名信息保存在Apk的指定位置(上门的红色区域),所以V1签名的所有方案都不行了,因为V1的所有方案都会改变Apk一定字节,导致签名不正确,试想下Apk签名是最后生成的,所以这块数据是绝对不会验签的,我感觉我说了句屁话
v2签名的原理可以简单理解为:
我们的apk其实是个zip,我们可以理解为3块:块1+块2+块3
签名让我们的apk变成了4部分:块1+签名块+块2+块3
我们通过zip文件格式,知道末尾都有一块Eocd块中16到20字节存储的是,核心目录开始位置相对于archive开始的位移(简单理解就是快3的其实位置或签名块的结束位置),有了这数据我们就能快速定位到签名块的结束位置了,只需要在得到签名块的大小就能读取出签名块的所有数据,看下签名块的结构
这里要注意一个,签名块大小记录的值是指的红色区域对应的大小
通过签名块的结构我们可以读取EOCD中央偏移量-24,在读取8位转long即可得到签名块的大小了
/**
* 获取签名块的开始位置
* @throws IOException
*/
private static long getSignStartIndex() throws IOException {
File file = new File("/Users/wenyingzhi/Desktop/Study/apksign/app-release.apk");
RandomAccessFile r = new RandomAccessFile(file, "r");
// 读取EOCD渠道中央偏移量 6=注解长度short占2个字节+偏移量int占4个字节
r.seek(r.length() - 6);
ByteBuffer buffer = ByteBuffer.allocate(4);
buffer.order(ByteOrder.LITTLE_ENDIAN);
r.readFully(buffer.array(), buffer.arrayOffset(), buffer.capacity());
int centralOffset = buffer.getInt();
System.out.println("中央偏移量:" + centralOffset);
// 计算签名大小位置 24=魔术16字节+签名大小long8字节
int singLengthOffset = centralOffset - 24;
// 移动位置
r.seek(singLengthOffset);
// 读取签名大小
ByteBuffer singLengthbuffer = ByteBuffer.allocate(8);
singLengthbuffer.order(ByteOrder.LITTLE_ENDIAN);
r.readFully(singLengthbuffer.array(), singLengthbuffer.arrayOffset(), singLengthbuffer.capacity());
long singLength = singLengthbuffer.getLong();
System.out.println("签名长度大小:" + singLength);
// 通过 签名块的开始位置 = 中央偏移量 - 签名块大小 - 8(头部的签名大小)
long singStart = centralOffset - singLength - 8;
r.close();
return singStart;
}
我们根据上面的签名块图可以在图中黄色区域加入Key-Value,其实读取和增加是一个原理,我们直贴读取的代码了,增加只需要忘指定位置写即可
/**
* 读取签名的key-value
*/
private static void readSignKeyValue() {
try {
// 获取签名块的开始位置
long signStartIndex = getSignStartIndex();
File file = new File("/Users/wenyingzhi/Desktop/Study/apksign/app-release.apk");
RandomAccessFile r = new RandomAccessFile(file, "r");
// 读取开头的签名块长度
r.seek(signStartIndex);
ByteBuffer startSignLengthbuffer = ByteBuffer.allocate(8);
startSignLengthbuffer.order(ByteOrder.LITTLE_ENDIAN);
r.readFully(startSignLengthbuffer.array(), startSignLengthbuffer.arrayOffset(), startSignLengthbuffer.capacity());
// 签名块 开始的 签名长度
long startSignLength = startSignLengthbuffer.getLong();
System.out.println("开始签名长度:" + startSignLength);
// 读取key-value的签名数据 读取长度=签名块长度-16位魔数-8位结尾签名长度
ByteBuffer signLengthbuffer = ByteBuffer.allocate((int) (startSignLength - 24));
signLengthbuffer.order(ByteOrder.LITTLE_ENDIAN);
r.readFully(signLengthbuffer.array(), signLengthbuffer.arrayOffset(), signLengthbuffer.capacity());
// 遍历签名信息
while (signLengthbuffer.hasRemaining()) {
// 读取ID-value-size
long size = signLengthbuffer.getLong();
// 读取id
int id = signLengthbuffer.getInt();
// 读取对用的data 长度=size-id占4个字节
byte[] dataBytes = new byte[(int) (size - 4)];
signLengthbuffer.get(dataBytes);
// 十六进制 7109871a 存储的就是签名信息
System.out.println(Integer.toHexString(id) + ":数据长度" + dataBytes.length);
}
// 读取结尾签名长度 这个值应该和开始的是一个值
ByteBuffer endSignLengthbuffer = ByteBuffer.allocate(8);
endSignLengthbuffer.order(ByteOrder.LITTLE_ENDIAN);
r.readFully(endSignLengthbuffer.array(), endSignLengthbuffer.arrayOffset(), endSignLengthbuffer.capacity());
long endSignLong = endSignLengthbuffer.getLong();
System.out.println("结尾签名长度:" + endSignLong + " 开始签名长度:" + startSignLength);
// 读取16位
byte[] dataBytes = new byte[16];
r.readFully(dataBytes);
System.out.println("魔数读取数据:" + Arrays.toString(dataBytes));
r.close();
} catch (Exception e) {
e.printStackTrace();
}
}
这个就很简单了,只需要把seek(lenght-16)读取原来的中央偏移量,新的中央偏移量=原来中央偏移量+渠道数据长度+4+8为何要加4加8是因为key占4个字节,总长度占8个字节
好啦,这样就能实现多渠道打包了