大家好,今天给大家的是本周技术拆解官的第二篇文章,主题依然是沿用上一篇文章的主题--Android端SQLite的“食用指南”,上篇文章我们讲到了基本的SQLite的定义、使用方法以及开发了一个基本的演示DEMO,有不太了解的伙伴可以戳这里预习下。
本篇文章带来的是《独家食用指南系列|Android端SQLCipher的攻与防新编》,这个题目是由于之前在调研SQLCipher时发现的一篇文章,原文地址是这里:FreeBuf|SQLCipher之攻与防,本次也是借用此标题带大家重新缕缕SQLCipher。
可能大家不太清楚SQLCipher是什么东西?没事,我们慢慢来,首先顺着之前的思路,接着来分析Android端SQLite,上周我们了解了SQLite的使用,那这次我们开篇先来看看SQLite有哪些优缺点?
本篇文章使用到的项目源码都在我的个人Github上面:https://github.com/lateautumn4lin/TechPaoding/tree/main/practice_demo/Cattle
1 SQLite的缺陷以及应对方案
SQLite作为在Android端频繁使用的轻量型数据库,它的优点不言而喻:易上手、易安装、具备关系型数据库的特征等等,那它有哪些缺陷呢?而这些缺陷又是通过什么技术手段或者方案来解决的呢?
1.1 SQLite的缺陷
1.1.1 性能问题
对于一般的APP开发者来说,SQLite易上手的特点会让人很兴奋,但是对于企业级的APP开发来说,性能永远是APP开发的第一指标,而SQLite的最初的设计理念是作为一个轻量级的高性能数据库,大家可能会疑问既然这么设计为什么还会遇到性能问题呢?这里的高性能其实是有条件限制的,在小数据量、并发低、查询结构简单的场景下,在Android端使用SQLite无非是性价比非常高的选择,可是一旦超出了这些限制条件,数据库设计的问题就暴露的很明显了。
1.1.2 安全性
对于安全性这个问题是怎么理解的呢?想一想对于每个使用了SQLite的APP来说,它们或多或少都会保存些私密的数据在其中,使用SQLite这对于开发者来说是很方便,但是方便不仅仅是对于他们来说,同时对于一些有“额外想法”的人来说的。免费版本的SQLite有一个致命缺点:不支持加密。意味着,你APP上的数据库其实是在裸奔!对于那些想获取数据的人来说,只要让手机ROOT之后,就可以进入每个APP的目录下面获取db文件从而轻易的得到数据。
1.2 应对方案
1.2.1 性能问题的解法
性能方面的优化是改造SQLite的最重要一步,优化可以从多个方面切入去提升SQLite的性能。
-
ORM框架的取舍
可能大部分应用为了提高开发效率,会引入ORM框架。ORM(Object Relational Mapping)也就是对象关系映射,用面向对象的概念把数据库中表和对象关联起来,可以让我们不用关心数据库底层的实现。在Android中最常用的ORM框架有开源greenDAO和Google官方的Room,虽然这些ORM底层优化的很好,对于部分执行效率的损耗已经降到最低,但过多的使用ORM还是会造成性能方面的影响,所以对于ORM框架的取舍和依赖程度也是影响性能的关键。
-
并发问题
如果我们在项目中使用SQLite,那么在下面这个SQLiteDatabaseLockedExecption就是经常会出现的一个问题。
SQLiteDatabaseLockedExecption归根到底是因为并发问题导致,而SQLite的并发有两个维度,一个是多进程并发,另一个是多线程并发。
我们首先来看看多进程并发
SQLite默认是支持多进程并发操作的,它通过文件锁来控制多进程并发。SQLite锁的粒度并没有非常细,它针对的是整个db文件,内部有5个状态,具体你可以参考下面的文章-SQLite Locking。
简单来说,多进程可以同时获取SHARED锁来读取数据,但是只有一个进程可以获取EXCLUSIVE锁来写数据库。并且EXCLUSIVE会阻止其它进程再获取SHARED锁来读取数据。在EXCLUSIVE模式下,数据库连接在断开前都不会释放SQLite文件锁,从而避免不必要的冲突,提高数据库访问的速度。
再来看看多线程并发
相比多进程,多线程的数据库访问可能会更加常见。SQLite支持多线程并发模式,需要开启SQLITE_THREADSAFE配置,当然系统SQLite会默认开启多线程 Multi-thread模式。
跟多进程的锁机制一样,为了实现简单,SQLite锁的粒度都是数据库文件级别,并没有实现表级甚至行级的锁。还有需要说明的是,同一个句柄同一时间只有一个线程在操作,这个时候我们需要打开数据库连接池 Connection Pool。
跟多进程类似,多线程可以同时读取数据库数据,但是写数据库依然是互斥的。SQLite提供了Busy Retry的方案,即发生阻塞时会触发Busy Retry,此时可以让线程休眠一段时间后,重新尝试操作。
为了进一步提高并发性能,我们可以打开WAL (Write-Ahead-Logging)模式。WAL模式会将修改的数据单独写到一个WAL文件中,而读操作开始时,会记下当前的WAL文件状态,并且只访问在此之前的数据,同时也会引入WAL日志文件锁。通过WAL模式读和写也可以完全地并发执行,不会互相阻塞。
但是需要注意的是,写之间是仍然不能并发。如果出现多个写并发操作的情况,依然有可能出现SQLiteDatabaseLockedExecption。这个时候我们可以让应用中捕获这个异常,然后等待一段时间再重试。
总的来说通过连接池与WAL模式,我们可以很大程度上提高SQLite的读写并发,大大减少由于并发导致的等待耗时,建议大家在应用中尝试开启。
-
查询优化
说起查询方面的优化,一般来说对于开发者第一个想到的方案就是建立索引,下面我们就围绕索引来看看如何优化。
如何正确建立索引在网上都有一系列的文章,比如:
-
SQLite 索引的原理
-
官方文档:Query Planning
重点要说的是很多时候我们以为已经建立了索引,但事实上并没有真正生效。这里关键在于如何正确的建立索引。例如使用了BETWEEN、LIKE、OR这些操作符、使用表达式或者CASE WHEN等。
建立索引是有代价的,需要一直维护索引表的更新,比如对于一个很小的表来说就没有必要建索引;如果一个表经常是执行插入更新操作,那么也需要节制的建立索引。总的来说有几个原则:
-
建立正确的索引。这里不仅需要确保索引在查询中真正生效,我们还希望可以选择最高效的索引。如果一个表建立太多的索引,那么在查询的时候 SQLite 可能不会选择最好的来执行。
-
单列索引、多列索引与复合索引的选择。索引要综合数据表中不同的查询与排序语句一起考虑,如果查询结果集过大,还是希望可以通过符合索引直接在索引表返回查询结果。
-
索引字段的选择。整型类型索引效率会远高于字符串索引,而对于主键 SQLite 会默认帮我们建立索引,所以主键尽量不要使用复杂字段。
-
总的来说索引优化是SQLite优化中最简单同时也是最有效的,但是它并不是简单的建一个索引就可以了,有的时候我们需要进一步调整查询语句甚至是表的结构,这样才能达到最好的效果。
关于SQLite的性能方面的解法可以围绕上面三个方面去做,当然,这只是针对于SQLite的优化来说的,要是想要更方便的直接获取“前人果实”,可以参考2017年微信开源了内部使用SQLite数据库WCDB,这个项目也是填了很多SQLite遗留的坑,针对于微信的场景做了很多优化,相信大家用起来也是会避免很多不必要的麻烦。
1.2.2 安全问题的解法
针对于SQLite的安全问题的解法类似于很多数据库的安全解法,目前常用的方案主要是两类:
-
这种方式并不是彻底的加密,还是可以通过数据库查看到表结构等信息。
-
对于数据库的数据,数据都是分散的,要对所有数据都进行加解密操作会严重影响性能。
2 认识SQLCipher
上面我们分析了SQLite的优缺点,也基本了解了目前有哪些通用的解决方案,回到我们的主题《攻与防》,这期我们就开始介绍我们的主角-SQLCipher。
2.1 定义
基于SQLite接口设计的采用256-bit AES加密算法的安全加密数据库。
2.2 特点
SQLite数据库设计中考虑了安全问题并预留了加密相关的接口。但是并没有给出实现。SQLite数据库源码中通过使用SQLITE_HAS_CODEC宏来控制是否使用数据库加密。并且预留了四个结构让用户自己实现以达到对数据库进行加密的效果。这四个接口分别是:
- sqlite3_key(): 指定数据库使用的密钥
- sqlite3_rekey():为数据库重新设定密钥;
- sqlite3CodecGetKey():返回数据库的当前密钥
- sqlite3CodecAttach(): 将密钥及页面编码函数与数据库进行关联。
而SQLCipher就是基于以上的四个接口以及自定义的接口来实现加密的,下面通过图片了解整个加密流程:
上图为SQLCipher的实现原理,加密流程为以下步骤:
- 传入密钥,通过Rand_bytes算法生成16个字节的salt,并存储在数据库第一页的头部(SQLite的db文件,头部前16个字节固定为SQLite Format,所以可以利用文件头来存储一些数据)。
- 通过PKCS5_PBKDF2_HMAC_SHA1算法将密钥和salt一起加密并多次迭代,生成AES加密所用的key;此处是对key的加密,即使原始的密码泄露,也无法解密数据。
- 通过AES对称加密算法对每一页的文件内容(有效的内容,不包含文件头和reserved字段)进行加密。
- 加密时,文件执行过AES加密后,对文件内容,通过Hmac算法,获取文件校验码,填充在page尾部(SQLite提供了reserved字段,自动在page尾部预留一段空间)。
- 解密时,先调用Hmac算法获取文件标识码,与page尾部的数据进行对比,如果数据一致,则证明文件没有被篡改过,不然证明文件已经被篡改,则抛出异常。
PS:以上为默认的算法,SQLCipher没有固定算法,用户可以自己设置。
3 调试工具
3.1 命令行
“最基本的调试方案”,只需要在Linux系统安装好SQLCipher,其他的操作和SQLite无异,唯一需要注意的是SQLCipher是加密库,也就是我们需要给db手动添加密码,操作大致如下:
3.1.1 Linux安装SQLCipher
3.1.2 Linux生成SQLCipher加密库
针对原始的tech_paoding.db数据库生成新的decrypted_database.db数据库
3.2 再上神器SQLiteStudio
可视化工具调试方面我们依然采用上篇文章我们介绍的SQLiteStudio,不过我们需要在Add a database时需要选择添加一个SQLCipher类型的数据库。
这里的Cipher(也就是加密算法)、KDF、Page Size的值都是默认好的,是SQLCipher3的默认算法的值,如果使用的SQLCipher4的值话,这些数据就需要改变。
4 SQLCipher加固流程
对于开发者来说,使用SQLCipher并不会对原本的APP逻辑入侵,只需要按照下面两个步骤即可将SQLite数据库加固。
4.1 类替换
对于正常使用Android端SQLite来说,引入的SQLite库是android.database.sqlite,比如我们要使用SQLiteDatabase,就需要这样引入:
import android.database.sqlite.SQLiteDatabase;
而对于SQLCipher来说,没有破坏SQLite的类以及相关的API,只是重写了它们,所以我们需要更换原有的包,还是举SQLiteDatabase的例子,我们引入它们需要这样:
import net.sqlcipher.database.SQLiteDatabase;
另外需要注意的是,android.database.sqlite是Android项目的内置包,而net.sqlcipher.database.SQLiteDatabase则需要我们自己引入,我的引入方案是从官网拉下对应的aar包,加入到本地的libs中,然后在build.gradle中指定引入路径
implementation(name:'android-database-sqlcipher-3.5.5', ext:'aar')
这样,我们就可以顺利的使用SQLCipher进行开发了。
4.2 加载加密SO库
由于SQLCipher的算法是基于SO库开发的,所以我们在正常使用SQLCipher之前需要使用loadLibs
方法来加载SQLCipher的SO加密库,例如:
SQLiteDatabase.loadLibs(this);
loadLibs
方法的逻辑如下:
5 SQLCipher Demo开发
照例和上篇文章一样,开发基本的Demo
5.1 创建MySQLiteOpenHelper
5.2 创建SQLiteCattleActivity
5.3 动态效果展示
主要的代码如上图所示,下面看看开发好的Demo的展示:SQLite
和SQLCipher的功能已经整合在同一个APP中,通过不同的Activity来展示。
6 企业级的SQLCipher攻防案例
上面我们了解了SQLCipher的由来、原理、基本的使用方法,这一部分我们呼应一下标题《攻与防》,我们结合市面上的用到了SQLCipher加密数据库的APP来看看它们是如何做过安全防护的以及那些“不法者”是怎么去破解这些防护的。
6.1 百度汉语
关于百度汉语APP我主要下载了以下几个版本的APK
我也发现了他们在不同版本对于整个APP的不同加固方案,这个我们在分析好整个SQLCipher方面的防护之后我们再总结,首先我们使用最新版本APP来分析下,作为词典类型的APP,总会有一个额外内部的数据库来作为离线时的查询方案,那么我们可以寻找下百度汉语APP他们的数据库方案,我们寻找下哪里是他们离线文件的入口,可以很容易在这里看到这里
这里有个离线文件下载的功能,于是可以猜测是通过下载离线db文件来填充他们的离线数据库,如第二幅图所示的免费离线包这个列表极可能是通过网络接口动态获取的,因为我们在离线状态下是获取不到的。
通过抓这个接口的网络请求包可以发现,可以获取请求db文件的地址
根据地址我们获取到了相应的db文件,下载完成后得到这样的文件baidu_dict.db
,判断是基于SQLite的文件,放入SQLiteStudio以SQLite方式打开会提示错误,说明应该是基于SQLCipher的加密文件,我们需要寻找的是SQLCipher的秘钥,于是下面我们开始关于秘钥的追踪。
6.1.1 SQLCipher秘钥追踪流程
在下载阶段我们获取到了baidu_dict.db
这个关键词,我们在jadx中搜索下
得到的数据量不多,其他的都是跟Baidu_Dict.apk
相关的,我们自然可以忽略,我们主要观察第一个搜索结果,进入具体的代码查看
虽然这里面并没有很明显的提到baidu_dict.db
这个关键词,可是让我们发现了一个重要的线索DB_PATH
的值/data/data/com.baidu.dict/databases/
,这个地址是什么呢?回想上一篇文章,这个目录是每个APP特有的私有文件的保存目录,而databases通常是大家存放SQLite数据文件的地方,也就是说我们离线下载的文件通常会保存在这个地方,好了,这下我们不用在每次通过下载来获取db文件了,我们现在可以直接去这个目录下看看有哪些db文件
基本上相关的文件都保存在这里,下一步我们需要获取的是具体的秘钥,那么获取秘钥我们该从哪里入手呢?最简单的,我们都知道SQLCipher加密数据库是需要通过getWritableDatabase来获取db实例进行操作的,那我们可以直接搜索getWritableDatabase关键词不就好了?
搜索结果很多,我们直接寻找参数非空的调用
结果很明显,调用了一个SO函数的方法来保存秘钥,也算是对于秘钥的保护了。这当然是寻找秘钥其中的一种方案,不过这样有点取巧,我们换另一种思路来看看,离线包是服务了离线搜索的,那么我们在离线状态下看看搜索的Activity的调用流程是什么样的
首先获取搜索的Activity
由图可知是com.baidu.dict.activity.NewSearchActivity
,之后去看看这个Activity的代码,算了,代码太多,我们直接用Objection去Hook这个类看看调用流程
我们重新进入这个Activity,可以从Objection界面看到这样的信息
使用搜索功能,信息变成这样
定位到了searchAllInfo
这个方法
从上面的代码我们可以发现一个类很值得关注,也就是DictExtDBManager
,代码中调用了DictExtDBManager.checkLocalDB
和DictExtDBManager.getInstance
获取的结果,如果获取不到就采用searchOnline
的在线获取方式,我们深入这个类看看,部分代码删除了
可以根据这个类的代码缕我们之前的逻辑
兜兜转转最后我们也找到了具体的秘钥算法位置
6.1.2 SQLCipher秘钥获取
现在我们找到了APP获取秘钥的方法,我们继续跟踪,秘钥采用了SO的函数,调用了libimagerender.so
,我们使用IDA具体查看下,直接查看Exports的tab会发现具体的函数属于静态注册的
定位到了具体的函数,修复下即可获取真实的秘钥,接着我们使用这个秘钥再去解密数据库即可。
6.1.3 SQLCipher秘钥加固历程
上面分析好了SQLCipher秘钥的获取历程之后,再说说百度汉语APP对于它们的SQLCipher秘钥的加固的演变历程吧,文字就不多说了,直接上图。
6.2 新华字典
本来还想写一段分析新华字典APP的流程,不过受限于文字篇幅,到这里已经是1.6w字了,就不继续了,之后有机会给大家再分享下,大家也可以自己动手试试。
7 食用荐语
以上就是本篇《独家食用指南系列|Android端SQLCipher的攻与防新编》的所有内容了,相比于上篇文章,这篇会更多的从原理、实战角度出发讲述下攻与防的内容,包括企业级的SQLCipher攻防案例。现在关于SQlite的系列还剩最后一篇文章,关于SQlite的源码剖析。