由于当前公司正在开发的Android客户端需要用到大量的图片资源,而为了保证市场分发的apk尽可能的小,所以在开发的过程中,都尽可能的让引入的图片尽可能的小,除了普遍使用的用style代替图片,用.9处理图片方式之外,还有大家公认的做法是,使用Tiny服务对图片进行有损压缩。
当前github上面也有对应的gradle插件,可以对项目中所使用的图片自动上传到tiny进行压缩优化的项目(TinyPngPlugin),也有对应的Intellij插件(TinyPic)。但是都有点不满足于当前自己的需求,gradle插件的项目,使用Groovy实现,需要讲tiny token等配置配置到项目中,且通过匹配文件的文件路径和md5值来判断是否需要上传优化图片,如果不小心保存md5的文件被删除了,则会导致重新上传优化的情况,且当前只支持设置一个tiny token。而Intellij插件同样只支持一个tiny token,且需要用户手动选择需要优化的图片。
鉴于当前我们项目和开发的一些情况,我们期望这样的工具能有以下功能:
针对上面几个目标,都是可以实现的:
以上针对三种情况的解决方式,难的地方在于怎么为图片打上标记。事实上,图片本身会携带出图片显示内容之外的信息,如图片作者、拍摄位置的经纬度、时间等等,我们完全可以把优化标记,放在这些字段上面。
由上图可知,一个JPEG图片文件,由多个段落组成,且每个段落的起始标示为两个字节的0xFF
加两个字节的段落类型标识。
由于当时使用Python来进行实现该脚本,且Python有对应的类库来进行读写JPEG的APPn段落的exif信息,所以,此处直接将图片的优化标记放置在了图片元数据的copyright字段。实现代码如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26class JPGMarker(Marker):
def __init__(self, filename, marker):
self._fileName = filename
self._marker = marker
def file_name(self):
return self._fileName
def marker(self):
return self._marker
def mark(self):
"""
open image and add mark to it
:return: True if success, False if fail
"""
exif_dict = piexif.load(self._fileName)
if exif_dict is not 0 and '0th' in exif_dict:
exif_dict['0th'][piexif.ImageIFD.Copyright] = self._marker
exif_bytes = piexif.dump(exif_dict)
piexif.insert(exif_bytes, self._fileName)
return True
return False
处理后的图片,可以直接看到图片元数据中的copyright变为了图片优化后的标志:
一个PNG图片文件,由文件头89 50 4E 0D 0A 1A 0A
+ 多个数据块组成,每个数据块由如下4部分组成:
由于PNG中某些数据块可以存在多个,且其位置没有限制,因此,完全可以构造一个数据块然后插入到图片中,此处我选择的是tExt数据块。代码实现如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26class PNGMarker(Marker):
def __init__(self, filename, marker):
self._fileName = filename
self._marker = marker
def file_name(self):
return self._fileName
def marker(self):
return self._marker
def mark(self):
"""
open image and add mark to it
:return: True if success, False if fail
"""
with open(self._fileName, 'r+b') as f:
f.seek(-len(_png_end), os.SEEK_END)
end_sign = f.read()
f.seek(-len(_png_end), os.SEEK_END)
f.write(self._marker)
f.write(end_sign)
return True
其中对应数据块数据的生成:1
2
3
4
5
6
7
8
9
10
11class MarkCheckFactory(object):
def _generate_png_mark(mark_sign):
content = bytearray(b'tEXt')
content.extend(mark_sign)
content_bytes = bytearray(struct.pack('>L', len(mark_sign)))
content_bytes.extend(content)
content_bytes.extend((binascii.crc32(content)).to_bytes(
4, byteorder='big'))
return content_bytes
最后脚本还实现了以线程池来针对目标目录下的所有子目录下文件进行遍历优化,具体代码可参考:ImageOptimize。
《APK签名机制说明》中已经了解Android的签名和校验机制,此篇基于此前提,来说明一下如何绕过Android的签名校验机制,以实现在APK中添加一些自定义的信息。
由于使用Signature Scheme v1签名的APK,仅仅只会校验META-INF文件夹以外的文件的是否被修改过,那么针对此情况,我们完全可以将我们需要携带的信息,添加到META-INFO文件夹下,只要我们不修改其中与签名有关的文件信息和其中被校验的文件,那么都可以校验通过的。
此处,使用Python脚本addEmptyFileToMETA.py
来将文件写入到APK的META-INFO文件夹下,其代码实现如下:1
2
3
4
5
6
7
8
9from sys import argv
import zipfile
script, apkFile, emptyFile, channelName = argv
zipped = zipfile.ZipFile(apkFile, 'a', zipfile.ZIP_DEFLATED)
empty_channel_file = "META-INF/{channel}".format(chanel=channelName)
zipped.write(emptyFile, empty_channel_file)
zipped.close()
下面尝试将一张二维码图片写入到META-INFO文件夹下,并校验APK是否能够正常覆盖安装,以校验是否破坏了APK的签名:
如上可知,虽然使用jarsigner
命令检测时,会多了一个警告信息,但是在Android系统上,却能够正常的覆盖安装原有的app的,所以针对使用Signature Scheme v1签名的APK,可以通过在META-INFO文件夹下添加文件,以实现在APK中携带一下自定义信息。
至于对应渠道信息的获取,只需要从java.util.zip.ZipFile
中获取出对应java.util.zip.ZipEntry
的输入流并读取即可:1
2
3
4
5
6
7
8
9ZipFile file = new File(context.getApplicationInfo().sourceDir);
Enumeration<?> entries = file.entries();
while (entries.hasMoreElements()) {
ZipEntry entry = ((ZipEntry) entries.nextElement());
if (TextUtils.equals(entry.getName(), channelFileName)) {
InputStream is = file.getInputStream(entry);
...
}
}
由于使用Signature Scheme v2签名的APK,会对APK除Signing Block之外的数据都进行摘要验证,所以没办法像v1将渠道信息写入META-INFO文件夹来实现。但这并不代表着无法在APK内写入额外的信息,通过查看APK签名校验时获取签名信息的逻辑可以发现,系统通过遍历Signing Block中的所有键值对,并获取id为0x7109871a
的value,而对于id非0x7109871a
的键值对,则直接进行忽略(最好将自定义的键值对插在id为0x7109871a
键值对之后,不然使用apksigner进行校验apk的时候,会有warning提示;另外尽量避免使用0x7109871a
作为id,即使要用,也一定要放在原有0x7109871a
id的后面):
针对此情况,完全可以构建一个自定义的键值对,用于保存渠道或其他信息,然后插入到APK的Signing Block中,从而避免系统校验签名后插入的内容:
由于在Signing Block中插入了额外的数据,会导致ZIP文件格式错乱,因此,必须要修改End of Central Directory块中Central Directory在文件中的偏移量,在原有偏移量的基础上加上插入额外数据的大小即可。因为在校验签名的时候,系统会现将该字段还原为没有Signing Block时的偏移量再进行计算摘要数据,所以此处修改该字段的数据,并不会破坏原有的签名信息。
由于代码比较长,所以就不在此贴出对应代码,具体代码可参考:ApkV2ChannelTools
与系统读取APK签名信息的流程一样,整个算法流程就是根据ZIP文件格式解析流程,先找到End of Central Directory块的起始位置,然后查找对应的Central Directory位置,从而解析出对应的Signing Block信息(其中有个需要注意的是,APK不支持使用ZIP64的格式,所以在获取Signing Block的时候如果遇到ZIP64标示块的话,会支持抛出异常)。
由于代码比较长,所以就不在此贴出对应代码,具体代码可参考:ApkV2ChannelTools
由于业务需求,我们需要统计用户下载安装apk的来源,所以针对不同的应用市场或活动页面,我们发布的apk都带了对应的渠道信息,针对apk携带对应的渠道信息,网上已经有很多成熟的方案了,例如使用gradle来构建不同的渠道包等方式。然而考虑到打包效率的问题,我们并没有使用gradle来构建多渠道的方式,而是使用美团的自动化生成渠道包的方式来实现的。
由于这种方式并不能适配APK Signature Scheme v2机制,所以我们的项目中一直都关闭了使用v2的签名机制。然后由于最近一些安全的问题,所以我们不得不在项目中开始使用v2机制来进行签名apk,虽然美团针对v2签名机制提供了另外的生成渠道包方案,但是还是想在使用之前,更多了解一下android的签名机制。
APP签字在Android系统中有着极其重要的作用,主要体现在如下:
apk文件,本质上就是一个zip格式的文件:
而apk签名,则是基于此文件格式,添加apk文件中部分数据的数字签名信息;而在检验的时候,读取该部分签名信息并对其所对应的部分数据进行校验。
因为apk本质就是一个zip格式的文件,所以,在开始了解apk的签名之前,有必要了解一下zip的文件格式:
.ZIP文件格式总览:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15[文件标头 1]
[文件数据 1]
[数据描述符记录 1]
.
.
.
[文件标头 n]
[文件数据 n]
[数据描述符记录 n]
[归档解密标头]
[归档额外数据记录]
[中央目录结构]
[中央目录记录的 Zip64 结尾]
[中央目录定位器的 Zip64 结尾]
[中央目录记录的结尾]
其中有几个数据在后续的漏洞和签名分析中,有重要作用,在这进行一下详细说明:
Local File Header:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16- A 文件标头 --------------------
文件标头签名 4 字节 [开始 0] (0x04034b50)
所需版本 2 字节 [开始 4]
一般用途位标记 2 字节 [开始 6]
压缩方法 2 字节 [开始 8] (8=DEFLATE; 0=UNCOMPRESSED)
文件的最后修改时间 2 字节 [开始 10]
文件的最后修改日期 2 字节 [开始 12]
crc-32 4 字节 [开始 14]
压缩后的大小 4 字节 [开始 18]
解压缩后的大小 4 字节 [开始 22]
文件名长度 2 字节 [开始 26]
额外字段长度 2 字节 [开始 28]
文件名 变量
额外字段 变量
End of Central Directory1
2
3
4
5
6
7
8
9
10
11- I 中央目录记录的结尾 --------------------
中央目录记录签名 4 字节 [开始 0] (0x06054b50) 注:使用“冒泡”从文件尾追查上来,找到这个签名。
磁盘编号 2 字节 [开始 4]
中央目录开始磁盘编号 2 字节 [开始 6]
本磁盘上在中央目录里的入口总数 2 字节 [开始 8]
中央目录里的入口总数 2 字节 [开始 10] 注:文件总数,文件夹也算一个文件。
中央目录的大小 4 字节 [开始 12]
中央目录对第一张磁盘的偏移量 4 字节 [开始 16]
.ZIP 文件注释长度 2 字节 [开始 20]
.ZIP 文件注释 变量 [开始 22]
而解析zip文件的过程,则是通过从文件尾部逆向查找End of Central Directory的起始标识,从而解析对应的Central Directory,进而解析zip里面的所有文件信息。
v1 scheme是基于jar签名来实现,而jar的签名机制,则是往zip文件中,插入一个META-INFO文件夹,然后将所有与签名相关的信息都保存在这个文件夹下。同样,通过对比已签名和未签名apk解压后的META-INFO文件夹下的文件,以分析v1 scheme签名的实现原理:
相比未签名的apk,已签名的apk的META-INFO文件夹下,多了{name}.RSA和{name}.SF文件,根据JAR文件格式描述可知:
以上三个文件的生成逻辑,都可以在build/tools/signapk/src/com/android/signapk/SignApk.java
源码文件中找到。
addDigestsToManifest
方法中。签名工具会遍历apk文件中的所有文件(过滤其中的一些文件),并针对这些文件通过摘要算法生成摘要信息并base64编码之后保存在MANIFEST.MF文件中:writeSignatureFile
方法中。签名工具会读取MANIFEST.MF文件中的所有section,然后针对每个section通过摘要算法生成摘要信息并base64编码之后保存在{name}.SF文件中,此处很容易让人误解{name}.SF文件中的内容是经过非对称加密之后所产生的,然而并非如此,该方法连加密所需要用到的公钥和私钥都没有被传入,只是针对每个section进行摘要并base64编码而已:writeSignatureBlock
方法中。该方法将生成的{name}.SF文件经过非对称加密生成密文之后,与公钥合并生成{name}.RSA文件:
而在对apk进行验签的过程,则是与生成apk相反的过程,系统会先根据{name}.RSA中的公钥和密文,还原出原文并与{name}.SF对比,以验证{name}.SF文件是否被修改过,然后在根据MANIFEST.MF文件(读取MANIFEST.MF的时候,会根据MANIFEST.MF中section信息进行验证apk中的文件是否会修改过的)中的section与{name}.SF文件中的section进行对比验证。
由于v1签名不会对META-INFO文件夹内的文件进行校验,所以可以通过在META-INFO文件夹下添加文件以达到APK携带渠道等信息的目的。
鉴于APK Sinature Scheme v1存在安全漏洞,所以google推出了新的签名校验的方式:APK Signature Scheme v2。
v2是针对整个文件进行签名校验的机制,所以具有更高的安全性和更好的校验速度。其实现机制在于,在ZIP 的Central Directory 部分前面插入一个APK Signature Block,如下图:
Signing Block 格式如下:1
2
3
4
5
6
7
8[size of block] 8 字节
[length prefixed ID-values pairs] 8 字节 + 4 字节 + (length - 4)字节 (8字节的长度,标识后面这对id和value的长度)
.
.
.
[length prefixed ID-values pairs]
[size of block] 8 字节
[magic] 16 字节,内容为"APK Sig Block 42"
而apk的签名校验信息(公钥及加密的密文)则是存在ID为0x7109871a
的value中,所以获取apk签名校验信息的过程,其实就是查找Signing Block中id为0x7109871a
的过程:
而在查找签名校验信息的过程中,系统会过滤ID为其他值的键值对。
v2签名会计算除Signing Block之外的所有数据的摘要,然后使用非对称加密之后保存在Signing block中:
在对apk进行签名的时候,签名工具会将原的Central Directory前的数据(不包含signing block)、Central Directory和End of Central Directory分别进行分段并计算摘要(每段都分成1M大小的数据,然后计算0xa5 + Chunk Lenght(4 byte) + Chunk
的摘要),然后再针对计算出来的摘要数据(计算0xa5 + Chunks Count(4 byte) + Chunks Digests
)再进行一次摘要计算,从而得到最终待加密的摘要数据,最后合并摘要加密后的数据和证书的公钥组成Signing Block。
其中有个让人困惑的地方是,签名工具在Central Directory前面插入Signing Block之后,会导致End of Central Directory中保存的Central Directory offset变更(不然会破坏其原有的ZIP格式而导致无法解压出其中的文件):
那么这必然会导致后续验签的时候,根据End of Central Directory计算出来的摘要数据与之前不匹配。很多资料或者博文都没有提到这点是怎么解决的,甚至还有资料说Central Directory offset字段不参与计算摘要的,但是通过查看计算摘要信息的算法中,并没有对该字段进行特殊处理的:
其中contents数组对应上面的beforeCentralDir, centralDir 和 eocd
既然这个问题,在生成签名的时候没有绕过,那么只可能是在验签的时候进行绕过了。事实上,在验签的过程中,系统在获取到End of Central Directory数据之后,会先将Central Directory offset字段还原回没有Signing Block的时候Central Directory的offset(即Signing Block的起始位置):
由于Android的签名校验机制是向前兼容的,所以Signature Scheme v2是在v1的基础上,再次进行了全文件的摘要验签,以保证即使使用v2进行签名,在Android N之前的系统中也能够正常校验签名,所以本质上使用v2签名,同时也会有v1的签名信息的。而Android N及之后的签名校验流程如下:
系统会先检测APK是否存在v2的Signing Block,如果没有,则执行v1的校验机制。由于Android N之前的系统,并不存在v2的校验机制,所以即使使用了v2的签名,在Android N之前的系统上,也不能做到防篡改的(毕竟之前的系统已经发布出去了,没法进行修改逻辑)。
鉴于以上验签流程,可能有人会想通过破坏v2签名的APK的Signing Block块,以达到让Android N及以上系统走v1校验的流程,从而使用漏洞来篡改APK。但可惜此方式是行不同的,对比v1和v2签名的APK,v2并不只是多了Signing Block块,而且对应的{name}.SF文件内容也不一样:
v2的{name}.SF文件中,标识了APK是使用Signature Scheme v2进行签名的,如果检测不到Signing Block,同样会报找不到签名信息错误的。
1. Application Signing
2. APK Signature Scheme v2
3. APK Signature Scheme v1
4. JAR File Specification
5. Zip (file format)
6. .ZIP File Format Specification
7. 美团Android自动化之旅—生成渠道包
8. 新一代开源Android渠道包生成工具Walle
9. 独家分析:安卓“Janus”漏洞的产生原理及利用过程
HTTP(HyperText Transfer Protocol, 缩写:HTTP),是一种用于分布式、协作式和超媒体信息系统的应用层协议。HTTP是基于文本的协议,也就是说我们可以很直观的看到每个HTTP报文的相关内容,基于此前提,本文通过分析对应的HTTP报文来了解HTTP协议的相关信息。
在了解HTTP协议之前,有必要先了解一下URI,毕竟HTTP协议是基于URI来实现的。
每个Web服务器资源都有一个名字,被成为URI (Uniform Resource Identifier),用于在世界范围内标识并定位信息资源。
URL (Uniform Resource Locator)是URI最常见的一种形式,用于描述一台特定服务器上某资源的特定位置,它可以明确的说明如何从一个精确、固定的位置获取资源。
大多数URL方案的URL语法都建立在由以下9部分构成的通用格式上:<schema>://<user>:<password>@<host>:<port>/<path>;<params>?<query>#<frag>
其中:
组件 | 描述 | 默认值 |
---|---|---|
schema | 访问服务器获取资源时要使用哪种协议 | 无 |
user | 某些方案访问时所需要的用户名 | 匿名 |
password | 某些方案访问时所需要的密码 | 无 |
host | 资源服务器的主机名或这IP | 无 |
port | 资源服务器所监听的端口号 | 与协议相关 |
path | 资源在服务器上的路径,使用/与前面组件隔开 | 无 |
params | 某些方案使用此组件来输入参数,参数为名/值对;可以包含多个参数字段,相互之间及与路径使用”;”隔开 | 无 |
query | 某些方案使用此组件来传递参数,用”?”将其与URL的其余部分分隔 | 无 |
frag | 用来标识资源的某个片段,用”#”将其与URL的其余部分分隔 | 无 |
事实上,几乎没有哪个URL中包含了以上所有组件;其中最为重要的是schema、host、path。
例如:
https://twitter.com/Jiasheng_Lee
http://ljsalm089.github.io/git/2015/11/10/Android项目版本管理.html
因一些协议在实现传输的过程中,会剥去一些特定的字符;且在使用URL的过程中,一些字符因有特殊的意义而被保留。为了避开这两种限制,人们设计了一种编码机制,通过“转义”来表示对应的非安全字符及关键字符。
这种转义表示法包含一个”%”,后面跟随两个标识字符ASCII码的十六进制数。
下表列出了保留及受限的字符:
字符 | 保留/受限 |
---|---|
% | 保留作为编码字符的转义标志 |
/ | 保留作为路径组件中分隔路径的定界符 |
. | 保留在路径组件中使用 |
.. | 保留在路径组件中使用 |
# | 保留作为分段定界符使用 |
? | 保留作为查询字符串定界符使用 |
; | 保留作为参数定界符使用 |
: | 保留作为协议、用户/密码,以及主机/端口组件的定界符使用 |
$, + | 保留 |
@&= | 在某些方案的上下文中有特殊的含义,保留 |
{}\|\^~[]’ | 由于各种传输Agent代理,比如各种网关的不安全处理,使用受限 |
<>” | 不安全,这些字符在URL范围之外通常是有意义的 |
0x00-0x1F, 0x7F | 受限,这些十六进制范围内的字符都在US-ASCII字符集的不可打印区间内 |
>0x7F | 受限,十六进制值在此范围内的字符都不在US-ASCII字符集的二进制范围内 |
URL转义Demo:
http://ljsalm089.github.io/git/2015/11/10/Android项目版本管理.html ——> http://ljsalm089.github.io/git/2015/11/10/Android%E9%A1%B9%E7%9B%AE%E7%89%88%E6%9C%AC%E7%AE%A1%E7%90%86.html
所有的HTTP报文都可以分为两类:请求报文和**响应报文。
HTTP请求报文:
HTTP响应报文:
HTTP报文是简单的格式化数据块,它们由三部分组成:
起始行和首部是有行分隔的ASCII文本,每行都以一个CRLF(回车换行)作为结束;报文的主体是一个可选的数据块,可包含文本或者二进制数据,也可为空。HTTP报文的首部以一行空行结束,即使没有头部和实体也应如此。
以下是请求报文的格式:
HTTP/<主版本号>.<次版本号>
以下是响应报文的格式:
关于HTTP报文格式的说明,则止于以上。而HTTP协议,则是基于此报文格式来进行实现并扩展的,而其中涉及到连接、代理、缓存、网关、隧道等,都是基于HTTP报文的header或者实体进行展开处理。
常用HTTP请求方法:
方法 | 描述 | 是否包含主体 |
---|---|---|
GET | 从服务器获取一份文档 | N |
HEAD | 只从服务器获取文档的header | N |
POST | 向服务器发送需要处理的数据 | Y |
PUT | 将请求的body存储在服务器上 | Y |
TRACE | 对可能经过代理服务器传送到服务器上的报文进行追踪 | N |
OPTIONS | 决定可以在服务器上执行哪些方法 | N |
DELETE | 从服务器上删除一份文档 | N |
状态码分类
范围 | 已定义范围 | 分类 |
---|---|---|
100 ~ 199 | 100 ~ 101 | 信息提示 |
200 ~ 299 | 200 ~ 206 | 成功 |
300 ~ 399 | 300 ~ 305 | 重定向 |
400 ~ 499 | 400 ~ 415 | 客户端错误 |
500 ~ 599 | 500 ~ 505 | 服务器错误 |
常用状态码
状态码 | 原因短语 | 含义 |
---|---|---|
200 | OK | 成功 |
401 | Unauthorized(未授权) | 需要输入用户名和密码 |
404 | Not Found(未找到) | 服务器无法找到所请求的URL对应的资源 |
未完待续
]]>Nuwa是由腾讯QQ空间团队实现的为Android APP进行紧急热修复方案的实现,该方案基于Android dex分包方案。
Nuwa通过将有问题的Class打包成Dex文件,APP动态加载对应的Dex文件中的Class来覆盖原来的Class文件,从而实现Hot Fix。
Nuwa主要包含两个项目:Nuwa(Android Library project)和NuwaGradle(Gradle Plugin),其作用分别如下.
Nuwa: Android Library project。
NuwaGradle: Gradle Plugin project。
Nuwa,作为Android Library project,其主要作用是为APP提供加载Dex文件的功能以及提供Hack.class类。加载Dex文件的功能主要在如下类中实现:
Nuwa.java 提供初始化及加载Patch(dex or apk)文件的方法,初始化方法中将存在asset中的hack.apk copy到app的内部储存文件夹中,并加载hack.apk,为app的所有类(not include Application class)提供Hack类支持(后面叙述Hack类的作用)。
public static void init(Context context) { File dexDir = new File(context.getFilesDir(), DEX_DIR); dexDir.mkdir(); String dexPath = null; try { dexPath = AssetUtils.copyAsset(context, HACK_DEX, dexDir); } catch (IOException e) { Log.e(TAG, "copy " + HACK_DEX + " failed"); e.printStackTrace(); } loadPatch(context, dexPath);}public static void loadPatch(Context context, String dexPath) { if (context == null) { Log.e(TAG, "context is null"); return; } if (!new File(dexPath).exists()) { Log.e(TAG, dexPath + " is null"); return; } File dexOptDir = new File(context.getFilesDir(), DEX_OPT_DIR); dexOptDir.mkdir(); try { DexUtils.injectDexAtFirst(dexPath, dexOptDir.getAbsolutePath()); } catch (Exception e) { Log.e(TAG, "inject " + dexPath + " failed"); e.printStackTrace(); }}
DexUtils.java 主要提供Patch(dex/apk)的加载方法。在Android中,使用BaseDexClassLoader来加载dex/apk文件,而BaseDexClassLoader使用DexPathList类型的成员变量pathList来存储所有的dex信息,ClassLoader findClass 的过程就是遍历DexPathList中的数组成员变量dexElements来查找对应的Class。BaseDexClassLoader有两个继承类:DexClassLoader和PathClassLoader,PathClassLoader主要用于加载已经进行optdex优化后的dex,而DexClassLoader则用于加载未进行optdex优化的dex/apk。Android默认的classloader是PathClassLoader。关于ClassLoader的更多介绍,请参考Java ClassLoader基础
/** * 加载dex并插入到PathClassLoader的成员变量pathList中 */public static void injectDexAtFirst(String dexPath, String defaultDexOptPath) throws NoSuchFieldException, IllegalAccessException, ClassNotFoundException { DexClassLoader dexClassLoader = new DexClassLoader(dexPath, defaultDexOptPath, dexPath, getPathClassLoader()); Object baseDexElements = getDexElements(getPathList(getPathClassLoader())); Object newDexElements = getDexElements(getPathList(dexClassLoader)); Object allDexElements = combineArray(newDexElements, baseDexElements); Object pathList = getPathList(getPathClassLoader()); // 通过反射来设置PathClassLoader的pathList的dexElements ReflectionUtils.setField(pathList, pathList.getClass(), "dexElements", allDexElements);}/** * 获取Android默认的PathClassLoader */private static PathClassLoader getPathClassLoader() { PathClassLoader pathClassLoader = (PathClassLoader) DexUtils.class.getClassLoader(); return pathClassLoader;}/** * 获取DexPathList对象中获取其dexElements成员变量 */private static Object getDexElements(Object paramObject) throws IllegalArgumentException, NoSuchFieldException, IllegalAccessException { return ReflectionUtils.getField(paramObject, paramObject.getClass(), "dexElements");}/** * 从BaseDexClassLoader对象中获取其pathList成员变量 */private static Object getPathList(Object baseDexClassLoader) throws IllegalArgumentException, NoSuchFieldException, IllegalAccessException, ClassNotFoundException { return ReflectionUtils.getField(baseDexClassLoader, Class.forName("dalvik.system.BaseDexClassLoader"), "pathList");}/** * 合并两个dexElements列表 */private static Object combineArray(Object firstArray, Object secondArray) { Class<?> localClass = firstArray.getClass().getComponentType(); int firstArrayLength = Array.getLength(firstArray); int allLength = firstArrayLength + Array.getLength(secondArray); Object result = Array.newInstance(localClass, allLength); for (int k = 0; k < allLength; ++k) { if (k < firstArrayLength) { Array.set(result, k, Array.get(firstArray, k)); } else { Array.set(result, k, Array.get(secondArray, k - firstArrayLength)); } } return result;}
按照如上的原理,那么使用一个Android Library就可以实现Hot Fix功能,那么为啥Nuwa还要提供一个Gradle Plugin来做支持?
原来,apk在安装之时,classes.dex会被虚拟机(dexopt)优化称为odex文件,然后才拿去执行,而虚拟机在启动的时候,包含一项verify的选项,该选项致使虚拟机对class进行校验,如果校验成功,对应的类会被打上CLASS_ISPREVERIFIED的标志,而被打上该标示的class,在查找其所引用的class时,会进行判断该class与被引用的class是否在同一个dex文件中,如果不是,则会抛出”Class resolved by unexpected DEX”的IllegalAccessException。由于Patch中的class用于覆盖classes.dex重的类,所以会导致部分类在classes.dex文件中,部分在patch.dex文件中,故会出现如上错误。
而解决此问题的方法就是防止class被打上CLASS_ISPREVERIFIED的标志,因此需在虚拟机对class进行校验时返回false,虚拟机对class的校验如下:
NuwaGradle,使用Groovy实现的Gradle plugin项目,其主要作用有:1. gradle task插入,2. java字节码级别的代码插入, 3. 记录编译后各class文件的hash值, 4. 根据hash变化与否来进行打包patch.jar
查看一个标准Android Project的gradle task:
$ gradle -q tasks --all...app:assembleRelease - Assembles all Release builds. [app:compileReleaseSources]app:dexReleaseapp:packageReleaseapp:preDexRelease...app:compileReleaseSourcesapp:checkReleaseManifestapp:compileReleaseAidlapp:compileReleaseJavaWithJavacapp:compileReleaseNdkapp:compileReleaseRenderscriptapp:generateReleaseAssetsapp:generateReleaseBuildConfigapp:generateReleaseResValuesapp:generateReleaseResourcesapp:generateReleaseSourcesapp:mergeReleaseAssetsapp:mergeReleaseResourcesapp:preBuildapp:preDebugBuildapp:preReleaseBuildapp:prepareComAndroidSupportAppcompatV72220Library - Prepare com.android.support:appcompat-v7:22.2.0app:prepareComAndroidSupportDesign2220Library - Prepare com.android.support:design:22.2.0app:prepareComAndroidSupportSupportV42220Library - Prepare com.android.support:support-v4:22.2.0app:prepareReleaseDependenciesapp:processReleaseJavaResapp:processReleaseManifestapp:processReleaseResources...
根据输出可知,在构建Android Project的过程中,存在dex** 和preDex** 任务。而NuwaGradle则在对应的preDex** Task前插入修改字节码的任务及在dex**任务前把有与上一版本相比有修改的class文件提取到另外的文件夹并打包成Patch文件用于发布。
...if (preDexTask) { // 定义在preDex** 任务之前插入的任务名为nuwaJarBeforePreDex** def nuwaJarBeforePreDex = "nuwaJarBeforePreDex${variant.name.capitalize()}" // 定义任务nuwaJarBeforePreDex** 所执行的动作 project.task(nuwaJarBeforePreDex) << { Set<File> inputFiles = preDexTask.inputs.files.files inputFiles.each { inputFile -> def path = inputFile.absolutePath if (NuwaProcessor.shouldProcessPreDexJar(path)) { // 对Jar文件中的class进行代码插入 NuwaProcessor.processJar(hashFile, inputFile, patchDir, hashMap, includePackage, excludeClass) } } } // 修改preDex** 与nuwaJarBeforePreDex** 任务的依赖顺序 def nuwaJarBeforePreDexTask = project.tasks[nuwaJarBeforePreDex] nuwaJarBeforePreDexTask.dependsOn preDexTask.taskDependencies.getDependencies(preDexTask) preDexTask.dependsOn nuwaJarBeforePreDexTask // nuwaJarBeforePreDex** 之前执行一些初始化动作 nuwaJarBeforePreDexTask.doFirst(nuwaPrepareClosure) // 定义在dex** 任务之前插入的任务名为nuwaClassBeforeDex** def nuwaClassBeforeDex = "nuwaClassBeforeDex${variant.name.capitalize()}" // 定义任务nuwaClassBeforeDex** 所执行的动作 project.task(nuwaClassBeforeDex) << { Set<File> inputFiles = dexTask.inputs.files.files inputFiles.each { inputFile -> def path = inputFile.absolutePath // 对非R及BuildConfig类进行代码插入 if (path.endsWith(".class") && !path.contains("/R\$") && !path.endsWith("/R.class") && !path.endsWith("/BuildConfig.class")) { if (NuwaSetUtils.isIncluded(path, includePackage)) { if (!NuwaSetUtils.isExcluded(path, excludeClass)) { def bytes = NuwaProcessor.processClass(inputFile) path = path.split("${dirName}/")[1] def hash = DigestUtils.shaHex(bytes) hashFile.append(NuwaMapUtils.format(path, hash)) // 判断此class的hash知否有变化,有的话则将该class拷贝至patch的文件夹 if (NuwaMapUtils.notSame(hashMap, path, hash)) { NuwaFileUtils.copyBytesToFile(inputFile.bytes, NuwaFileUtils.touchFile(patchDir, path)) } } } } } } // 修改dex** 与nuwaClassBeforeDex** 任务的依赖顺序 def nuwaClassBeforeDexTask = project.tasks[nuwaClassBeforeDex] nuwaClassBeforeDexTask.dependsOn dexTask.taskDependencies.getDependencies(dexTask) dexTask.dependsOn nuwaClassBeforeDexTask // 将保存class hash的map文件copy到其他目录 nuwaClassBeforeDexTask.doLast(copyMappingClosure) nuwaPatchTask.dependsOn nuwaClassBeforeDexTask beforeDexTasks.add(nuwaClassBeforeDexTask)} else { ...}
NuwaGradle使用的是javaassist库来进行字节码插入的, 对Jar文件的代码注入操作:
public static processJar(File hashFile, File jarFile, File patchDir, Map map, HashSet<String> includePackage, HashSet<String> excludeClass) { if (jarFile) { // 创建tmp 的jar文件 def optJar = new File(jarFile.getParent(), jarFile.name + ".opt") def file = new JarFile(jarFile); Enumeration enumeration = file.entries(); JarOutputStream jarOutputStream = new JarOutputStream(new FileOutputStream(optJar)); while (enumeration.hasMoreElements()) { // 读取jar文件中的每个类 JarEntry jarEntry = (JarEntry) enumeration.nextElement(); String entryName = jarEntry.getName(); ZipEntry zipEntry = new ZipEntry(entryName); InputStream inputStream = file.getInputStream(jarEntry); jarOutputStream.putNextEntry(zipEntry); // 判断是否需要注入代码 if (shouldProcessClassInJar(entryName, includePackage, excludeClass)) { // 代码注入 def bytes = referHackWhenInit(inputStream); // 写入到tmp jar文件中 jarOutputStream.write(bytes); def hash = DigestUtils.shaHex(bytes) hashFile.append(NuwaMapUtils.format(entryName, hash)) // 判断是否需要打包进patch if (NuwaMapUtils.notSame(map, entryName, hash)) { NuwaFileUtils.copyBytesToFile(bytes, NuwaFileUtils.touchFile(patchDir, entryName)) } } else { jarOutputStream.write(IOUtils.toByteArray(inputStream)); } jarOutputStream.closeEntry(); } jarOutputStream.close(); file.close(); if (jarFile.exists()) { jarFile.delete() } // 覆盖原jar文件 optJar.renameTo(jarFile) }}
对class文件的代码注入操作:
public static byte[] processClass(File file) { def optClass = new File(file.getParent(), file.name + ".opt") FileInputStream inputStream = new FileInputStream(file); FileOutputStream outputStream = new FileOutputStream(optClass) // 代码注入 def bytes = referHackWhenInit(inputStream); outputStream.write(bytes) inputStream.close() outputStream.close() if (file.exists()) { file.delete() } optClass.renameTo(file) return bytes}private static byte[] referHackWhenInit(InputStream inputStream) { ClassReader cr = new ClassReader(inputStream); ClassWriter cw = new ClassWriter(cr, 0); ClassVisitor cv = new ClassVisitor(Opcodes.ASM4, cw) { @Override public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) { MethodVisitor mv = super.visitMethod(access, name, desc, signature, exceptions); mv = new MethodVisitor(Opcodes.ASM4, mv) { @Override void visitInsn(int opcode) { // Java编译器在为它编译的每个类都至少生成一个实例化方法,即<init>方法。 // 在类的实例化方法<init>中,给其添加一个Hack类型的常量 if ("<init>".equals(name) && opcode == Opcodes.RETURN) { super.visitLdcInsn(Type.getType("Lcn/jiajixin/nuwa/Hack;")); } super.visitInsn(opcode); } } return mv; } }; cr.accept(cv, 0); return cw.toByteArray();}
如此则可避免所有的类被打上CLASS_ISPREVERIFIED标志,即class调用不同dex文件中的class也不会出错(PS:Application不应该被注入Hack类型常量,因为Hack.apk是在Application的attachBaseContext方法中加载,而在构造方法就引用了Hack类,因此会抛出异常)。
使用了Nuwa之后,执行gradle clean && gradle build –info 命令会出现一下错误:
:app:nuwaJarBeforeDex***Release (Thread[Daemon worker,5,main]) completed. Took 0.011 secs.
FAILURE: Build failed with an exception.
* What went wrong:
Execution failed for task ‘:app:nuwaJarBeforeDex***Release’.
> $(ProjectPath)/app/build/intermediates/classes-proguard/***/release/classes.jar (No such file or directory)
出错原因:在VIPME项目中,针对debug和release的构建使用了不同的规则,为方便开发人员调试,在debug的构建规则中未打开代码混淆,而在release的规则重则打开了代码混淆,而使用gradle build进行构建的时候,是同时构建debug和release的版本。
通过查看gradle build | grep “:*:*“的输出:
...:facebook:compileReleaseJavaWithJavac:facebook:extractReleaseAnnotations:facebook:mergeReleaseProguardFiles UP-TO-DATE:facebook:packageReleaseJar:facebook:compileReleaseNdk UP-TO-DATE:facebook:packageReleaseJniLibs UP-TO-DATE:facebook:packageReleaseLocalJar UP-TO-DATE:facebook:packageReleaseRenderscript UP-TO-DATE:facebook:packageReleaseResources:facebook:bundleRelease:app:prepareCnJiajixinNuwaNuwa100Library:app:prepareComAndroidSupportAppcompatV72220Library:app:prepareComAndroidSupportDesign2220Library:app:prepareComAndroidSupportRecyclerviewV72220Library:app:prepareComAndroidSupportSupportV42221Library:app:prepareComFacebookAndroidFacebook451Library:app:prepareComGoogleAndroidGmsPlayServicesAnalytics780Library:app:prepareComGoogleAndroidGmsPlayServicesBase780Library:app:prepareComGoogleAndroidGmsPlayServicesGcm780Library:app:prepareComTwitterSdkAndroidTweetComposer100Library:app:prepareComTwitterSdkAndroidTwitterCore160Library:app:prepareIoFabricSdkAndroidFabric136Library:app:prepareVipmeAndroidViewPagerIndicatorUnspecifiedLibrary:app:prepareOfficialDebugDependencies:app:compileOfficialDebugAidl:app:compileOfficialDebugRenderscript:app:generateOfficialDebugBuildConfig:app:generateOfficialDebugAssets UP-TO-DATE:app:mergeOfficialDebugAssets:app:generateOfficialDebugResValues:app:processOfficialDebugGoogleServices...:app:preDexOfficialDebug:app:nuwaClassBeforeDexOfficialDebug:app:dexOfficialDebug:app:validateDebugSigning:app:packageOfficialDebug:app:assembleOfficialDebug:app:assembleDebug:app:checkOfficialReleaseManifest:app:prepareOfficialReleaseDependencies:app:compileOfficialReleaseAidl:app:compileOfficialReleaseRenderscript:app:generateOfficialReleaseBuildConfig:app:generateOfficialReleaseAssets UP-TO-DATE:app:mergeOfficialReleaseAssets:app:generateOfficialReleaseResValues:app:processOfficialReleaseGoogleServices:app:generateOfficialReleaseResources...
可知gradle对第三方依赖库项目(如VIPME所依赖的facebook和viewpagerindicator)的处理在是在所有的debugtask之前,而debug 的所有task又在release task之前,在app/build/intermediates/exploded-aar/文件夹中查看到,gradle对第三方依赖库处理的输出文件并没有对debug和release进行分开存放。
同时,在build的过程中,混淆过程输出很多warning信息:
:app:proguardOfficialRelease...Warning: com.facebook.AccessTokenManager$1: can't find referenced class cn.jiajixin.nuwa.HackWarning: com.facebook.AccessTokenManager$2: can't find referenced class cn.jiajixin.nuwa.HackWarning: com.facebook.AccessTokenManager$3: can't find referenced class cn.jiajixin.nuwa.HackWarning: com.facebook.AccessTokenManager$4: can't find referenced class cn.jiajixin.nuwa.HackWarning: com.facebook.AccessTokenManager$RefreshResult: can't find referenced class cn.jiajixin.nuwa.HackWarning: com.facebook.AccessTokenSource: can't find referenced class cn.jiajixin.nuwa.HackWarning: com.facebook.AccessTokenTracker: can't find referenced class cn.jiajixin.nuwa.HackWarning: com.facebook.AccessTokenTracker$CurrentAccessTokenBroadcastReceiver: can't find referenced class cn.jiajixin.nuwa.HackWarning: com.facebook.BuildConfig: can't find referenced class cn.jiajixin.nuwa.HackWarning: com.facebook.CallbackManager$Factory: can't find referenced class cn.jiajixin.nuwa.HackWarning: com.facebook.FacebookActivity: can't find referenced class cn.jiajixin.nuwa.Hack...
由以上错误信息可知,release的proguard task失败导致未输出对应的class文件,而使app:nuwaJarBeforeDex task缺失输入文件而失败。但是NuwaGradle 是在preDex* task之前也就是proguard之后才注入Hack代码的,怎么会在proguard之前就已经有Hack了呢?猜测是debug和release都使用了同一份的第三方依赖库build输出的产物,而在debug 的proguard task已经对第三方依赖库的代码进行了注入,所以导致release proguard task运行失败。
于是提取app/build/intermediates/exploded-aar/文件夹中facebook的classes.jar文件重命名为classes.zip并解压出来,提取其中的某个类\**.class并对其反编译所得:
$javap -v ***.class | grep Hack #803 = Utf8 cn/jiajixin/nuwa/Hack #804 = Class #803 // cn/jiajixin/nuwa/Hack 6: ldc_w #804 // class cn/jiajixin/nuwa/Hack 9: ldc_w #804 // class cn/jiajixin/nuwa/Hack 85: ldc_w #804 // class cn/jiajixin/nuwa/Hack 552: ldc_w #804 // class cn/jiajixin/nuwa/Hack
由此证明debug和release使用相同的第三方依赖库产物来进行下一步的动作从而导致出现以上的错误。
解决方法:
(Done)
]]>主分支master
代码库有且只有一个master分支,用于保存正式版本对应的tag,并不用于version的发布。
开发分支dev
remote端必须存在一个dev分支,主要用于项目功能的开发以及多个开发者之间代码的同步。另,不允许直接将dev分支的代码merge到master分支上。
测试分支Pre-Release
remote端在适当的时候应该存在一个Pre-Release分支,其主要功能是打上tag以提交到测试组进行测试、测试结果bug的修复以及测试通过之后的version的发布。dev、Pre-Release、master分支关系如下:
当某一版本的功能开发完毕时,由dev分支checkout出Pre-Release分支,并打上tag提交到测试组进行测试,tag的格式为:TEST-$(VersionName)-$(TestTime),其中VersionName为版本名,TestTime为该版本的第几次测试,测试的tag由指定的一位负责人进行标示。当一轮测试完成之后,开发者在Pre-release分支进行bug的修复。修复完bug之后负责人打上对应tag并提交到测试组进行新一轮的测试,并将此分支merge到dev分支。当该版本的测试通过之后,在对应的tag上打出release的包进行线上验证,只有线上验证通过之后,才允许将Pre-Release分支的代码merge到master分支。
注意:将Pre-Release分支的代码merge到master的时候,需添加–no-ff参数,以在master分支生成一个commit;当一个版本的发布之后,可以将remote端对应的Pre-Release分支删除。
修复分支fixbug/hotfix
当项目发布之后,如果发现某个version有bug,则在master对应的tag上checkout出fixbug/hotfix分支来进行bug修复,fixbug/fotfix分支本质上跟Pre-Release分支类似,需要修复bug之后打对应的tag提交到测试组,通过测试之后根据情况打出version或patch。fixbug/hotfix与dev和master分支的关系如下:
fixbug/hotfix分支的最终是否删除也需要视情况而定:
a. 如果在该version后续的version中都存在该bug,但是并不影响项目的主流程,则可以将该分支的提交cherry-pick到dev分支,并将该分支merge到master以发布一个小版本的更新,最后可删掉对应的fixbug分支。
b. 如果在该version后续的version中都存在该bug,且影响到项目的主流程,则应该将该分支的提交cherry-pick到dev,并合并代码到master,同时根据该hotfix分支打出相应的patch进行发布,以及发布对应版本更新,而对应的hotfix分支,则不能删除。
c. 如果只是某个旧版本存在该bug,则且影响到主流程,则应该根据hotfix分支打出对应的patch进行发布,以及对应的hotfix分支不能删除。
主分支master
同remote端master分支。
开发分支dev
同remote端dev分支。
测试分支Pre-Release
同remote端Pre-Release分支。
修复分支fixbug/hotfix
同remote端fixbug/hotfix分支。
功能实现分支feature
该分支主要用于开发某类功能时使用,由dev分支checkout出来,在完成了该类功能的开发之后,将该分支merge到dev分支,并将该分支删除。
注意:原则上不允许将feature分支push到remote,除非某个feature是由多人共同开发;另外,将feature分支merge到dev的时候请尽量添加–no-ff参数,以在dev分支生成一个commit。
(Done)
]]>