传统Android App的代码保护分为DEX加固和SO加固。从反编译的成本来看汇编代码强度要比SMALI代码要高,所以DEX保护代码一般都使用C/C++实现。DEX加固在从DEX文件到指令虚拟化都已形成较为成熟的方案,而SO的加固还有很大的提升空间。
SO加固分有源码SO加固和无源码SO加固。有源码SO加固:需要特定的代码或者编译器支持,可对代码进行加密,混淆甚至虚拟化保护,开发人员可通过主动插入混淆代码到源码内或通过。无源码SO加固:通过壳对SO进行保护,保护方式以代码加密的形式居多,混淆和虚拟化还未成熟,兼容性和性能难以保证。
由于Android平台的性能限制,SO层做虚拟化对性能影响较大,远不及Windows平台的成熟,大部分SO加固还是以代码加密为主。本文旨在通过讲解SO加载流程,分享SO加固的原理,为开发加固功能的技术人员提供一些解决加固问题的思路,也让开发者更明确加固目的,不会因追求加固效果而盲目加固所有SO文件。
01 SO加载原理
SO属于ELF格式文件,ELF格式文件提供两类视图访问文件结构,链接视图和执行视图。
链接视图是以Section为单位访问各段数据,一般程序编译链接过程中需要会使用Section信息去读取SO信息,strip工具也会根据Section信息对ELF文件进行裁剪。
执行视图是以程序加载执行过程中的信息访问方式,大部分Section在破坏的情况下不会影响SO文件的加载。SO加固就是根据SO执行流程以及所依赖的数据结构进行拆解,加密和重组的。
SO加载过程简单描述就是将SO中的代码和数据按照编译时预定的结构,加载到内存,然后进行重定位,即修复必要的数据信息,修复完成后就能保证所有代码的正常执行。
SO加载流程如下:
○ Linker优先从内存中查找已加载的SO,如果SO已加载,则直接返回handle。
○ 如SO没有加载需要新建soinfo结构体,并将SO加载到内存。
○ 执行预加载prelink_image,读取SO中dynamic字段中的内容,初始化重定位需要的数据。
○ 执行link_image,修复内存中的数据,完成重定位。
○ 执行SO中的Init以及Initarray中的函数。
从加载流程看,可以得出以下几点:
1.重定位完成之前,SO中的代码不会主动执行,代码都在Linker中执行。
2.最先执行的代码为Init,其次是Init_array。
因存在这一特性,任何做过加密的SO,只要在SO内函数执行函数前对加密数据进行修复,就能达到加固的目的。其中大部分加固都是利用Init或Init_arrray中的函数来进行解密,也可以在任何一个确定的时机完成解密,如Android下可利用JNI_OnLoad函数,但这种方式只适用于Android下特殊的SO。
02 SO加固方法
2.1 Section加密
Section加密的主要分两步:
1. 将需加密的代码放入特定Section,在加密代码被执行前执行解密逻辑。
2. 编译后的文件,使用工具对特定Section进行加密,即对Section区间的内容加密。
这种加密方式适用于开发者对自实现的代码进行保护,因为Section信息以及解密代码都需要开发者主动添加和编译,需要源码支持。
2.2 UPX以及类UPX的SO加固
开源的SO加固较为常见的是UPX,很多人会根据UPX源码修改一些特征改成自己的版本,避免直接被脱壳。
UPX加固SO的做法是只加固代码段,数据段和重定位相关的结构都保留在文件中,利用原SO的重定位信息完成SO重定位,通过插入的INIT节对代码进行解密,本质上和Section加密类似,只是放大了加密范围,并添加修改Init节的操作。通过命令readelf -d sopath,仍可以看到原SO内的数据和原SO一致,但Section已被破坏无法查看。
2.3 自实现Linker方式加固
自实现Linker的方案目前来说是加固的主要手段。因重定位过程完全自己实现,这样既可以加密SO中的代码,也可以加固SO中的数据。由于自实现Linker,SO结构可以完全破坏和自定义,可以防止被dump出完整的SO。加固后SO静态分析只能看到壳的结构,对于原SO的结构完全隐藏。
2.4 代码混淆
有源码的代码混淆一般可通过插入花指令或通过带混淆功能的编译器进行编译,对生成的代码进行混淆。无源码的代码混淆需要借助壳,对原SO的指令进行抽离,然后对抽离的代码做混淆转换。
2.5 VMP
有源码的VMP方案一般都是借助编译器,在ir层对指令进行虚拟化并插入解释器。无源码的SO加固方案目前还未普及,但原理和有源码方案类似,将原指令处理成虚拟数据,并且插入解释器对虚拟数据解释执行。由于VMP对性能损耗大,对移动端来说,性能和兼容性都需要衡量,实用性不及代码混淆。
03 SO加固兼容性分析
前面讲过SO加固在满足SO中加密代码和数据在被执行使用之前完成解密,理论上兼容性就能得到保证。但SO加固并不能完全兼容所有情况,只要SO被加密处理,其加载流程和数据格式肯定存在修改,所以在一些特殊流程或者代码中有特殊格式校验的情况下,无法避免地存在兼容性问题,但一般正常开发且没有校验行为的SO是有办法做到完美兼容的。
在分析加载流程中的兼容性问题前需要回顾一下加载流程。加载流程中提到SO最先执行的代码为Init,其次是Init_array。这个只是理论层面上的流程,实际情况需要考虑函数重载以及依赖SO的加载流程。Init和Init_array只能说是加载过程中最早能确定执行的代码块。还存在一些可能执行的代码,但触发执行需要特殊条件。以一个简单的示例程序演示SO中函数在Init前执行的情况。
1. 首先需要一个依赖SO,在Init中调用操作符new,将其编译成libdpend.so。
2. 编写一个测试SO,构造Init节,重载new操作符,编译时链接libdepend.so,生成libtest.so。
3. 编写一个主函数加载libtest.so,编译后执行结构如下:
可以看到,真实的执行顺序先执行重载的new函数,再执行依赖SO的Init函数,最后执行测试SO的Init函数。其根本原因在于重定位的流程是有顺序的,符号优先从SO内进行查找,然后再查找依赖,这也是函数能实现重载的原因之一。
该类情况在第三方SO中很难预测哪些符号被重载,且执行时间无法确定,类似UPX和一些简单的代码加密方案无法兼容这类问题,解决方法可以通过隐藏部分符号绕过重载函数问题,或者借助延迟加载技术,在函数执行时触发解密操作。
代码校验格式导致的兼容性问题没有通用的解决方案,因为无法确定校验的方式是通过读文件还是读内存的形式,且校验内容不确定,一般情况开发者添加校验的目的就是为了防篡改,和SO加固存在冲突。所以在进行加固时一般建议加固客户自研的SO,第三方SO的行为存在不确定性,难以保证兼容性。
04 总结
以上介绍的SO加固方式各有优劣,下表是对其实现难度、优缺点的总结:
SO加固能很好地保护客户代码,避免被静态分析。第三方SO加固都是处于无源码的环境,加固功能都依赖于壳代码的实现。在对抗动态分析时,可配置防调试功能减少被动态调试和dump内存的可能性。在SO加固时,我们对关键代码添加一些指令混淆和VMP的处理,即使在攻击者绕过反调试的情况下也能最大限度地保护代码。
来源:freebuf.com 2021-04-25 10:55:35 by: wangyiyunyidun
请登录后发表评论
注册