简介
这篇文章主要讲了美团walle多渠道打包工具的基本原理,并且基于此原理利用python实现类似功能,然后用walle工具将python写入的渠道号读出,python采用3.6版本实现
关键字:walle python 多渠道 android v2签名
原理介绍
android的apk是基于zip格式压缩的,所以在文件打包生成的apk文件中,存储格式是严格依照zip的标准的,另外android利用v2签名生成的apk,是在zip的标准格式之中插入了一个 apk sign block
除了apk sign block 可以插入很多数据外,其他三个区域是几乎不能修改的(End of Central Directory 区块里面有个标志位表示Central Directory 区块的起始位置,这个是要改的,其他内容完全不能改变)
代码要做的事情就是先找到apk sign block 区块,然后插入我们要加入的渠道信息然后把关联的字节内容改下即可
所以问题第一步是怎么找到apk sign block 这个区块
1.1 apk sign block 区块查找
查找方式我们是按照倒过来查找到,先找找到 End of Central Directory 区块,然后根据 End of Central Directory区块里面的标志Central Directory的开头位置到Central Directory,Central Directory前面就是apk sign block
那么怎么能找到 End of Central Directory?这需要看zip格式的定义
简单的来说就是从文件最后的22个字节开始往前遍历,直到找到符合 0x50 0x4b 0x05 0x06 组合的4个字节(注:zip文件中字节按照 little-endian 方式存放的),这就是 End of Central Directory的开头,理论上android的apk应该就是倒数第22个字节为End of Central Directory的开头,然后End of Central Directory区域的第17个字节到第20个字节记录的就是Central Directory的位置,这个位置可以直接索引到Central Directory的开头,同时在插入数据后这个位置的值需要改变
接下来就是apk sign block的文档说明:
意思就是在Central Directory的前16个字节对应的ascii值为“APK Sig Block 42”这个校验通过说明此文件为v2签名过的apk,否则不是我们就不应该继续改动
然后“APK Sig Block 42”前面8个字节表示apk sign block区域除了开8个字节以外的长度,这个值需要和开头8个字节的一致,这样在Central Directory的开头再往前加apk sign block区块size再加8就得到了apk sign block 的开头位置
对应的python代码如下
def pack(filebytes):
# apk文件的字节
readbytes = bytearray(filebytes)
# 从倒数22个字节开始向前查找End of central directory
for i in range(len(readbytes) - 22):
if (readbytes[-22 - i] == 0x50 and readbytes[-21 - i] == 0x4b and readbytes[-20 - i] == 0x05 and readbytes[
-19 - i] == 0x06):
print("find i is %d" % i)
break
# 标记着start of central directory的4个字节的位置以前读出来的值
start_central_directory_value_pos = -6 - i
start_central_directory = readbytes[start_central_directory_value_pos + 3] * 256 ** 3 + readbytes[
start_central_directory_value_pos + 2] * 256 ** 2 + \
readbytes[
start_central_directory_value_pos + 1] * 256 + \
readbytes[start_central_directory_value_pos]
# 校验 APK Sig Block 42是否在 start_central_directory之前
if readbytes[start_central_directory - 16:start_central_directory] == 'APK Sig Block 42'.encode("utf-8"):
print("check v2 true")
else:
print("check v2 fail")
return
# 读取sign_block长度
sign_block_size = 0
sgin_block_value_pos = start_central_directory - 16 - 8
for i in range(8):
sign_block_size += 256 ** i * readbytes[sgin_block_value_pos + i]
print("sigin size is" + str(sign_block_size))
# sign_block的开头位置
sign_block_start_pos = start_central_directory - sign_block_size - 8
1.2 将渠道信息写入APK Sig Block
从apk sign block的文档说明可以看到 在apk sign block的开头位置后8个字节开始,是一序列的id-value pairs,这些id - value paies 是由 8个字节的 id+values 的总长度,4个字节的id长度,和长度可变的values组成,为了简单处理可以限定它的长度小于等于251 即 id+value小于等于0xff,这样我们在sgin_block_value_pos之前插入渠道数据即可,同时看了下walle读取渠道数据的源码,它在读取一个id为0x71777777的 json字符串,渠道信息存储为{"channel":"xxxx"},这里的id需要按照ittle-endian 方式存放,即 0x77 0x77 0x77 0x71的顺序插入,json串转为utf-8的字节数组,不需要改变顺序(注:此为walle的读取规则,这个理论上是可以自定义的),这里我们就写渠道号”002“和"百度"试一下
整个python代码如下,上面的代码包含在这个里面
# encoding=utf-8
filepath = "/Users/tom/Downloads/OneDevice/app/build/outputs/apk/app-release.apk"
onlysee = False
# filepath="/Users/tom/Downloads/OneDevice/app/build/outputs/apk/app-release-channel002.apk"
# onlysee=True
def getvaluefrombytes(tbytes):
va = 0
for j in range(len(tbytes)):
va += tbytes[j] * 256 ** j
return va
def decodebytesfromnum(num, lens=4):
tbytes = bytearray()
for i in range(lens):
tbytes.append((num & (0xff << 8 * i)) >> 8 * i)
return tbytes
def pack(filebytes,channelname):
# apk文件的字节
readbytes = bytearray(filebytes)
# 从倒数22个字节开始向前查找End of central directory
for i in range(len(readbytes) - 22):
if (readbytes[-22 - i] == 0x50 and readbytes[-21 - i] == 0x4b and readbytes[-20 - i] == 0x05 and readbytes[
-19 - i] == 0x06):
print("find i is %d" % i)
break
# 标记着start of central directory的4个字节的位置以前读出来的值
start_central_directory_value_pos = -6 - i
start_central_directory = readbytes[start_central_directory_value_pos + 3] * 256 ** 3 + readbytes[
start_central_directory_value_pos + 2] * 256 ** 2 + \
readbytes[
start_central_directory_value_pos + 1] * 256 + \
readbytes[start_central_directory_value_pos]
# 校验 APK Sig Block 42是否在 start_central_directory之前
if readbytes[start_central_directory - 16:start_central_directory] == 'APK Sig Block 42'.encode("utf-8"):
print("check v2 true")
else:
print("check v2 fail")
return
# 读取sign_block长度
sign_block_size = 0
sgin_block_value_pos = start_central_directory - 16 - 8
for i in range(8):
sign_block_size += 256 ** i * readbytes[sgin_block_value_pos + i]
print("sigin size is" + str(sign_block_size))
# sign_block的开头位置
sign_block_start_pos = start_central_directory - sign_block_size - 8
k = 0;
keybytes = {}
tempbytes = []
# 打印区块里面的所有内容,调试用,没实际意义
for i in range(sign_block_start_pos + 8, sgin_block_value_pos):
if k == 4:
print("----", end=" ")
if k == 8:
print("")
k = 0
print(hex(readbytes[i]), end=" ")
k += 1
# 如果插入值的话,block的长度和centroffset要变,对应位置的字节也要变
strkey = 0
print("start ouptaaa")
k = 0
#walleid 0x71777777
walleIDbytes = bytearray()
walleIDbytes.append(0x77)
walleIDbytes.append(0x77)
walleIDbytes.append(0x77)
walleIDbytes.append(0x71)
print(hex(walleIDbytes[0]))
pairlen = 0
# 遍历区块,打印里面所有的id_value组合出来看看
for i in range(sign_block_start_pos + 8, sgin_block_value_pos):
tempbytes.append(readbytes[i])
if k == 7:
pairlen = getvaluefrombytes(tempbytes)
print("pairlen is %d" % pairlen)
tempbytes.clear()
if k == 11:
strkey = getvaluefrombytes(tempbytes)
tempbytes.clear()
if k == pairlen + 7:
keybytes[strkey] = bytes(tempbytes)
tempbytes.clear()
k = -1
k += 1
print("lask k is %d" % k)
print(keybytes)
#
# if onlysee:
# return
channel = "{\"channel\":\"%s\"}"%channelname
channelbytes = bytearray(channel.encode("utf-8"))
if len(channelbytes) > 252:
print("只接受252字符以下的渠道信息")
return
insertbytes = bytearray()
#插入的渠道号信息长度+id 4个字节长度
#因为限定了小于0xff,所以对应的8个字节长度为 value 0 0 0 0 0 0 0
channelbytes_len = len(channelbytes) + 4
insertbytes.append(channelbytes_len)
for i in range(7):
insertbytes.append(0)
#插入id和渠道值
for i in walleIDbytes:
insertbytes.append(i)
for i in channelbytes:
insertbytes.append(i)
print(insertbytes)
#因为要插入数据,所以对应的start_central_directory的值和sign_block_size的值要发生变化
start_central_directory += len(insertbytes)
dictoffsetbytes = decodebytesfromnum(start_central_directory)
for index in range(4):
readbytes[start_central_directory_value_pos + index] = dictoffsetbytes[index]
sign_block_size += len(insertbytes)
signsizebytes = decodebytesfromnum(sign_block_size, 8)
for index in range(8):
readbytes[sign_block_start_pos + index] = signsizebytes[index]
readbytes[sgin_block_value_pos + index] = signsizebytes[index]
# 将要插入的渠道数据格式的idvalue字节插入文件bytes中
for b in insertbytes[::-1]:
print(hex(b))
readbytes.insert(sgin_block_value_pos, b)
#输出文件
outfilepath = "/Users/tom/Downloads/OneDevice/app/build/outputs/apk/app-release-channel%s.apk"%channelname
with open(outfilepath, "wb") as fout:
fout.write(readbytes)
readbytes = bytearray()
with open(filepath, "rb") as f:
readbytes = bytearray(f.read())
#写个002和百度的测试一下
pack(readbytes,"002")
pack(readbytes,"百度")
代码里面filepath是打好了v2签名的apk包,
android代码里面依赖walle,然后我在一个按钮里面加入
findViewById(R.id.fab_4) .setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
Toast.makeText(MainActivity.this, WalleChannelReader.getChannel(getApplicationContext()),Toast.LENGTH_SHORT).show();
}
});
执行结果如下
总结
这篇文章主要就是从底层分析了下walle构建多渠道包的原理,整体来说了解了原理对于工作中的使用还是有所帮助的