微信号:gh_7322a0f167b5

介绍:小时光茶社(Tech Teahouse),Tencent QQ会员技术团队.

Android动态库压缩壳的实现

2016-12-17 13:07 周科

周科,腾讯工程师,QQ动漫Android主力开发,从事过Rom开发,参与过手Q阅读、手Q趣味来电等项目,对Android底层原理有深入理解。


〇、前言

说起壳可能有的同学并不太了解,简单的说,计算机软件领域所说的壳实际上是一种软件加密技术。与自然界中的壳类似,花生用壳保护种子,乌龟用壳保护自己的身体,而我们写的程序为了在一定程度上防止被逆向分析,也可以给它加壳。壳主要分为两大类:加密壳和压缩壳,加密壳侧重于防止软件被篡改,而压缩壳则侧重于减小软件体积。其实,在Windows上已经有许多壳了,但Android(或者可以说Linux)上的壳相对而言就少了一些。本文就主要讲讲Android动态库(so文件)压缩壳要如何实现。


一、压缩

说到压缩,我们可能首先会想到一些常用的压缩工具,例如7-zipWinRARtar等等。使用这些工具可以实现so文件的压缩吗?答案是肯定的,但如果我们使用这些工具去压缩so,在使用上却会有一些不方便,主要体现在以下几个方面。

1. 程序中需要引入额外的解压代码;

2. 压缩/解压算法不能随意切换;

3. 需要先解压成原始文件后才能被调用。

那么,如何才能避免这些麻烦呢?在计算机领域有一句名言“计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决”。这里我们就可以通过加中间层的方式去解决这个问题,请看下图。



图上的loader就是我们要增加的中间层。我们知道soELF格式的二进制文件,所以要实现对so的压缩,就要自己实现一个ELF加载器去加载压缩后的so这里的loader本质上也是一个so文件,只不过它里面被写入了我们压缩后的so数据。它的作用主要有三个。

1. 代替原始so被应用程序加载;

2. 内存中解压出原始so

3. 将原始so加载到内存中。

有人可能会说这样每次使用前还要在内存里解压,那不会变慢么?事实上,虽然多了解压的过程,但由于so的体积减小,加载soIO的耗时也会减小,所以这里速度上并不会慢多少(有兴趣的朋友可以做做实验)。

上面的图示中我们把so的压缩过程分成了压缩与合成两个步骤,接下来就分别说说这两个步骤是怎么做的。

a) 压缩

关于压缩算法的选择,因为压缩的过程是在PC上进行的,所以压缩时内存占用和压缩的速度并不重要,我们主要需要关注压缩率和解压速度对于各种压缩算法,其实已经有人做过对比试验了,看下面两张图。

我们so文件属于Bin(二进制文件)类型,可以看到lzma算法的压缩率是非常给力的,解压速度说不上特别快,但也能接受。结合官网上对其特性的介绍,lzma算法是非常适合在嵌入式系统中使用的。


虽然在lzma的基础上又发展了更高级的lzma2xz等算法,但由于使用这些算法需要引入更多的代码,导致loader体积增加,所以这里我最终还是选择了lzma算法。

b) 合成

由于loader的本质也是一个so,要把原始so压缩之后的数据嵌入loader,需要对ELF格式有一定的了解网上有很多分析ELF格式的文章,写得都很不错,文末的参考资料中有相关链接这里主要讲一下我们插入数据会涉及到的一些知识点。这是一张经典的ELF文件格式视图。

我们需要把loader中嵌入的数据加载到内存中解压并执行,所以这里只需要关注ELF的执行视图,执行时是按照段(Segment,各个段的信息定义在程序头部表里)来加载的,所以ELF头部中与节区(Section相关的内容我们可以随意修改。

此外,为了简化数据插入的过程,我这里把要嵌入的数据放在最后一个段的末尾,这样做的好处是,不会涉及.text内各种跳转地址的修正只需要调整最后一个段的大小,就能够方便的被加载到内存里去Talk is cheap, show me the code.”看了半天文字,似乎略显枯燥,我们来看看ELF HeaderProgram Header的定义片段就能知道要怎么做了。

定义中标记为斜体的内容就是我们需要修改的地方,可以看到数据插入后,我们需要修改Program Header中的文件大小和加载到内存里的大小即可。同时,ELF头部中与Section相关的有8个字节,足够让我们存储插入数据的大小和偏移了,这样可以方便loader加载后快速找到我们要解压和执行的数据

综上,一个so的压缩过程就可以用一个简单流程图来描述。




二、加载

Androidso的加载全靠Linker,所以要理解so的加载过程,需要对Linker有一定的了解。虽然Android各个版本的Linker实现都不尽相同,实现的语言也从C变成了C++不过也是大同小异,乌云上有一篇讲解Android4.4 Linker源码的文章,写得挺好,不过乌云上的文档现在貌似访问不了了,文末参考资料放了转载到酷推上的链接。这里我就简单罗列一下so加载的过程

1. 打开so文件

2. 解析ELF(获得段的偏移、大小、虚拟地址等等信息)

3. 根据解出来的信息申请足够的内存

4. so文件中可加载(PT_LOAD)的段依次映射(mmap)到申请的内存上,并找到PT_DYNAMIC表数据的起始地址

5. 根据PT_DYNAMIC表中的数据,找到字符串表、符号表、重定位表、初始化/反初始化函数地址,并执行函数的重定位

6. 执行初始化函数

so加载完成之后会返回一个soinfo结构,所有so相关的信息都存在里面,Linker会把这个soinfo结构用一个链表维护起来

基于Linker加载so的过程,我们要实现自己的so加载器就比较容易了主要有三步。

1. 根据ELF头部信息,找到我们插入的数据,并解压到内存中;

2. 参考Linker的实现,把读文件的地方,改成从内存取数据,完成so的加载;

3. 最后还需要将我们加载so构造出来的soinfo的内容拷贝至loadersoinfo

至此,我们就成功的把原始的so加载到内存中去了。至于为什么需要上面的第3步,是因为如果我们的so被其他程序链接,查找符号时会从Linker维护的soinfo链表中去搜索,所以原始so对应的的soinfo必须出现在Linker维护的链表中,不然是找不到的。

加载的过程图示如下。

三、一些问题

至此,原理部分就介绍完了,在实现的过程中也遇到了一些问题,在这里总结一下。当然我的解法不一定是最好的,但可以解决问题,希望能给大家一些参考吧。

Q:如何让我们解压和加载的代码自动执行?

A:通过so加载的流程,我们知道so加载之后会执行初始化函数,所以我们需要自动执行的代码可以放在constructor function中。

Q:如何拿到Linker里维护的soinfo链表?

ALinker并没有提供接口让外部拿到这个链表,但我们可以利用Linker加载so的特性,通过dlopen打开一个基础的so(例如:libc),dlopen函数返回的内容实际上就是其对应的soinfo的节点,我们就可以用这个节点作为链表的“头”节点。

Q:为什么在Android 5.0上测试时一跑起来就crash

A:我的代码是参考的Android4.1linker,而soinfo的数据结构在4.3开始发生变化,记录so在内存里基地址的变量跟以前不一样了,需要判断版本将基地址赋值给正确的变量。

事实上,目前还有一些问题需要解决,例如一些奇奇怪怪的兼容性问题、如何让loader体积更小等等。本文主要是抛砖引玉,如果各位读者有什么想法和建议,欢迎一起探讨。


四、参考

Lzma SDK

http://www.7-zip.org/sdk.html

压缩算法对比:

http://bashitout.com/2009/08/30/Linux-Compression-Comparison-GZIP-vs-BZIP2-vs-LZMA-vs-ZIP-vs-Compress.html

ELF文件格式总结:

http://blog.csdn.net/flydream0/article/details/8719036

Android Linker学习笔记:

http://www.tuicool.com/articles/AfMZRbZ


(李涛编辑)


 
小时光茶社 更多文章 告知你不为人知的UDP-连接性和负载均衡 Vuejs 2.0源码解析之渲染篇 有没有人告诉你—写时拷贝的真相 kubernetes容器编排系统介绍 深刻理解 React(一)
猜您喜欢 SQL四种语言:DDL DML DCL TCL 如何十倍提升你的 webpack 构建效率 BaiduFM: Swift实现百度FM客户端 美团网校招北京、厦门、成都、杭州 宣讲及笔试即将开始 事件/故障处理思路