手机取证这行里,通话记录和联系人往往是最容易被低估的两类数据。很多人一上手就盯着聊天记录、图片、位置轨迹,觉得那才是“有料”的东西,结果真正把时间线钉死的,恰恰是那几百条通话记录和一堆看着平平无奇的联系人条目。通话记录带着精确到毫秒的时间戳和时长,联系人为每个号码提供了身份映射,两者一交叉,一个人的社交半径、活动节奏、关系亲疏基本就浮出水面了。我在实际项目里见过太多情况:设备到手,数据已经被删了一轮,机主信誓旦旦说“没打过这个电话”,最后从数据库空闲页里刨出半条残留记录,配上联系人里那个被改过备注的号码,事情就清楚了。这篇内容主要聊的就是在合法授权前提下,怎么把 Android 与 iOS 设备上的通话记录、联系人完整取出来、正确解析出来,以及在数据被删除后还剩多少可恢复的空间。面向的是刚入门的取证从业者、做企业合规调查的技术人员,以及需要处理自己设备数据恢复的普通用户——不管你手上有没有专业设备,里面的思路和大多数操作都能直接照着做。
1. 先弄清楚“找回”这件事的边界
1.1 三种完全不同的数据状态,别混为一谈
“找回”这个词太笼统了,落到技术层面必须拆成三种状态,因为对应的方案、难度、结论可信度完全不是一个量级。
第一种是可见但导不出来。数据明明在手机里,通讯录能翻、通话记录能看,但设备锁着、没 root、系统版本又新,普通导出手段拿不到完整的原始数据库。这种情况下你面对的是“访问权限”问题,不是“数据丢失”问题。
第二种是逻辑删除但物理还在。用户在界面上点了删除,系统把数据库里的行标记为不可见或者直接释放,但底层的存储页并没有立刻被覆盖。这时候数据还躺在闪存的某个角落,能不能捞出来取决于删除之后设备被写过多少东西。
第三种是已被覆盖。删除之后设备继续高强度使用,文件系统把那块区域重新分配了,原始字节被新数据冲掉。到了这一步,老实说,除了极少数残留的碎片,基本没戏。任何声称“百分百恢复”的说法都值得警惕。
我见过不少需求方开口就是“删了三个月,能不能全部恢复”。这时候你得先把预期管理做在前面,把上面这三种状态讲清楚,否则后面交付的时候解释成本极高。我的习惯是在受理阶段就做一次快速判断:看设备型号、系统版本、加密状态、剩余空间和最近的写入活跃度,给出一个粗略的可行性区间,白纸黑字记下来。
1.2 合法前提是所有操作的地基
这一点必须放在最前面说。手机里的通话记录和联系人属于典型的个人信息,处理这类数据的前提是取得合法授权:设备所有权人本人的明确同意、司法机关依法出具的调取手续、或者企业在其内部合规制度与员工告知同意框架下对配发设备的检查。没有这个前提,后面所有的技术动作都是空中楼阁。
实操上我一般会做三件事,缺一不可:一是留存授权文件的原件或扫描件,注明授权范围和时间;二是在操作开始前对设备外观、屏幕状态进行拍照记录,包括串号、型号、系统版本等信息;三是全程录像或者至少保留每一步的时间戳日志。这些看着繁琐,但真到了需要出结论、需要对结论负责的时候,它们就是你的护身符。
1.3 取证的底线原则:不改变原始数据
行业里有个说法叫“一次接触,一次污染”。手机和电脑不一样,它一直在联网、在后台跑服务、在自动同步。你把它开机连上电脑的瞬间,系统可能已经悄悄改了好几处数据:未接来电的“新”标记被清掉、联系人同步状态被刷新、甚至云端同步把本地删掉的东西又拉回来,或者反过来把本地的东西推到云端。
所以标准动作是:能不开机就不开机,必须开机就先断网。用屏蔽袋或者屏蔽箱隔离信号,飞行模式只是辅助手段,因为部分设备在飞行模式下仍然会尝试定位和短距通信。开机之后立刻检查是否开启了自动同步,如果可以控制,先关掉账号同步再动手。
然后就是镜像优先于解析。很多人图省事,直接连上设备用工具读一遍数据库,导出一份 CSV 就完事。这样做的问题是:你拿到的是“某一次读取的结果”,而不一定是原始状态。一旦中间出现误操作或者工具自身有 bug,原始数据可能就变了,而你没有任何回退余地。规范做法是先做一份完整的、带哈希校验的副本,所有后续分析都在副本上进行,原始设备封存。
哈希这块别偷懒。至少算两种算法(比如 SHA-256 和 SHA-1 各一份),在提取前、提取后各算一次,把结果写进工作记录。有人觉得这是形式主义,但真遇到对方质疑“你凭什么说这份数据没被动过”,一份前后一致的哈希值比一百句解释都有用。
2. 提取层级的整体设计与方案选型
2.1 四种提取层级,能力和代价完全不同
手机数据提取从来不是“一个工具搞定”的事,它是一个从浅到深的分层体系。理解这个分层,你才知道自己手上的工具能拿到什么、拿不到什么。
| 层级 | 名称 | 能拿到什么 | 前提条件 | 对设备的侵入性 |
|---|---|---|---|---|
| L1 | 手动/界面导出 | 界面可见的联系人、通话记录,可导出 vCard/CSV | 设备解锁 | 极低 |
| L2 | 逻辑提取 | 应用沙盒内数据库文件、系统共享目录文件 | 调试接口可用或已获授权凭证 | 低 |
| L3 | 文件系统提取 | 完整分区文件结构,含各应用私有目录 | 高权限或特定引导状态 | 中 |
| L4 | 物理镜像 | 整个闪存块的原始字节,含空闲区和未分配区 | 专用硬件或深度引导方案 | 高 |
L1 最省事,但拿到的东西最“加工过”。比如通话记录你在界面上看到的,是系统把号码、联系人名称、运营商信息、地理位置都拼好之后渲染的结果,中间做过归一化,甚至可能把同一通电话的多次转接合并显示。这种数据用来做初步梳理可以,作为结论依据就偏弱了。
L2 是绝大多数轻中度取证的主力。Android 上通过调试接口配合备份机制,或者在有授权凭证的情况下直接读取应用数据目录,能拿到contacts2.db这类原始 SQLite 文件。这一步的价值在于:你拿到的是数据库本身,而不是界面渲染结果,字段齐全,未显示的信息也在。缺点是有时候备份机制会跳过部分文件,或者对加密数据库无能为力。
L3 和 L4 是深水区。文件系统提取能让你看到完整的目录结构和残留文件,比如备份目录里的旧版本数据库、应用缓存里的临时拷贝。物理镜像更进一步,能覆盖到文件系统层面已经“看不见”的区域。这两层需要专用设备和技术储备,不是日常必备,但在数据被大量删除的场景里往往是唯一出路。
2.2 为什么“先逻辑后物理”是默认顺序
有人会问,既然物理镜像最全,为什么不直接上 L4 一步到位?
原因有三个,都很现实。
第一是成本与风险不对称。物理提取对设备状态要求苛刻,操作过程中出现意外(断电、连接中断、引导失败)可能导致设备无法正常启动,而这时候你连 L1、L2 的机会都没了。先用低侵入手段把能拿的保住,再去啃硬骨头,这是风险控制的基本逻辑。
第二是逻辑提取的结果更容易被采信。逻辑提取拿到的是应用层原始文件,解析路径清晰、字段含义明确,对方不容易质疑“你是不是从空闲区里拼凑了一条不存在的记录”。物理镜像里的碎片恢复则需要更严谨的论证过程。
第三是很多场景根本用不着物理层。如果需求只是“把这个人的通话记录整理出来做时间线梳理”,数据完好无损,那 L2 完全够用,多花几个小时做物理镜像纯属浪费。
我的常规顺序是:L1 做快速摸底和预期确认 → L2 拿原始数据库做主力分析 → 根据删除量和需求方的证据要求,决定是否追加 L3/L4。这个顺序不是教条,但它能帮你在 80% 的场景里用 20% 的成本拿到 95% 的结果。
2.3 工具选型的取舍逻辑
工具这个话题容易吵起来,我只讲取舍逻辑,不讲品牌。
开源工具链的优势是透明可复现。你用的每一条命令、每一个脚本都可以写进报告,对方可以自己跑一遍验证。缺点是拼装成本高,需要你自己处理格式转换、编码、时区、字段映射这些脏活。对于通话记录和联系人这种结构相对规整的数据,开源方案完全够用——一个只读方式打开的 SQLite 连接加几十行 Python,解决大部分问题。
商业取证套件的优势是省事,图形界面、一键解析、报告自动生成,对新手友好。但它有两个隐患:一是解析逻辑是黑盒,你没法确定它对某个字段的理解是不是和系统原意一致;二是软件版本更新滞后于系统更新,遇到新版本系统经常解析不出来或者解析错误。
我自己的做法是两条腿走路:商业套件用来做快速初筛和交叉验证,核心结论一定用开源工具自己再跑一遍,两边对不上就以原始数据库为准,去查字段、查文档、查系统源码。这样既有效率,又不至于被工具牵着走。
2.4 数据完整性校验怎么落地
校验不是算个哈希就完事,它是一个贯穿全程的动作。
提取前,记录设备的型号、系统版本、存储容量、剩余空间、当前时间、时区设置。这些信息看着琐碎,但后面解析时间戳、判断数据合理性全靠它们。
提取中,每一份产物单独存放、单独命名,命名规则里带上时间戳和数据源路径。比如contacts2.db.20250115T0930.raw和.working两份,前者只读封存,后者用来分析。
提取后,对原始产物算哈希并记录,之后每次基于它派生的新文件也记一笔,形成一条可追溯的链条。我习惯用一个简单的表格维护:
| 文件名 | 来源路径 | 生成时间 | SHA-256 前 16 位 | 用途 |
|---|---|---|---|---|
| contacts2.db.raw | 应用私有目录 | 09:30 | 3f8a...c21d | 原始封存 |
| contacts2.db.working | 由 raw 复制 | 09:32 | 3f8a...c21d | 分析用副本 |
| calls_export.csv | 由 working 导出 | 10:05 | 7b2e...9a04 | 结果输出 |
哈希前 16 位足够做人工比对,完整值存在日志里。这份表格最后会直接进报告附录,看着不起眼,但它是你结论可信度的来源。
3. 数据结构解剖:通话记录和联系人到底长什么样
3.1 Android 上的两个核心数据库
在 Android 生态里,通话记录和联系人这两类数据长期由同一个系统组件管理,数据库文件就在应用私有目录下:
/data/data/com.android.providers.contacts/databases/ ├── contacts2.db # 主数据库,联系人与通话记录都在这里 ├── contacts2.db-wal # 预写日志,可能含最新未落盘数据 ├── contacts2.db-shm # 共享内存索引 ├── contacts2.db-journal # 回滚日志(旧模式) └── profile.db # 机主本人信息早期版本里通话记录是独立的calllog.db,后来合并进了contacts2.db的calls表。这个演变很重要:如果你拿到的是一台老设备,按新路径去找会扑空;反过来,新设备上找calllog.db也会找不到。先看系统版本再定路径,能省掉不少无效搜索。
contacts2.db里的表不少,真正跟我们的目标相关的就那么几张:
calls:通话记录明细,一行一通电话raw_contacts:原始联系人条目,对应某一个账号下的某一条记录contacts:聚合后的联系人,多个 raw_contacts 合并成一条data:联系人的具体数据项,一个电话、一个邮箱、一个备注都是一行mimetypes:数据类型字典,把mimetype_id翻译成人类看得懂的类型deleted_contacts/deleted_raw_contacts:某些版本里的删除影子表
3.2 calls 表:时间线的主要来源
calls表是通话记录的富矿,字段多而且信息密度高。挑几个关键的讲:
| 字段 | 含义 | 实操注意点 |
|---|---|---|
_id | 行号 | 自增,删除后不回收,可用来判断删除空洞 |
number | 号码原文 | 可能是短号、带格式符、带国家码,别直接当唯一键 |
date | 通话起始时间 | Unix 毫秒时间戳,UTC,解析时要转本地时区 |
duration | 通话时长 | 单位秒,未接通为 0,注意和“未接”区分 |
type | 通话类型 | 1 来电 / 2 去电 / 3 未接 / 4 语音留言 / 5 拒接 / 6 拦截 |
new | 是否未读 | 0/1,取证时这个值可能已被系统刷新 |
name | 缓存的联系人名 | 快照性质,可能和当前通讯录不一致 |
numbertype | 号码类型 | 对应联系人里的手机、家庭、工作等 |
countryiso | 国家码 | 影响号码归一化 |
geocoded_location | 地理编码位置 | 不一定有值,取决于系统设置 |
subscription_id | 卡槽标识 | 双卡设备区分是哪张卡的通话 |
有两个细节特别容易出错。
第一个是date的时区。存的是 UTC 毫秒数,你直接fromtimestamp按本地时区转,在跨时区场景下会整体偏移。比如设备是在东八区用的,但分析机设成了 UTC,导出结果全差 8 小时,跟其他证据对不上,这种低级错误在报告里非常致命。正确做法是显式指定时区,并把设备的时区设置也记录下来作为交叉验证。
第二个是name字段的性质。它是写入时的快照,不是实时关联。也就是说,如果机主在通话之后把联系人备注改成了别的名字,calls表里的name可能还是旧的;如果联系人被删了,name可能还留着。这个特性在取证上反而是好事——它能反映“当时”的状态,比当前通讯录更有时间维度的价值。
3.3 联系人不是一张表,是一次“拼装”
很多人第一次打开contacts2.db会懵:联系人名字在哪?翻遍raw_contacts也找不到“张三”两个字。
原因是 Android 的联系人模型是分层的。
contacts表只存聚合结果和排序用的元信息,真正的内容在data表里,而data表用mimetype_id区分这一行是什么东西。名字是一行、电话是一行、邮箱又是一行,它们通过raw_contact_id归属到同一个人。
mimetypes表负责翻译。常见取值:
| mimetype | 含义 | 数据落在哪 |
|---|---|---|
vnd.android.cursor.item/name | 姓名 | data2 姓氏 / data3 名字 / data1 全名 |
vnd.android.cursor.item/phone_v2 | 电话号码 | data1 号码 / data2 类型 / data4 归一化号码 |
vnd.android.cursor.item/email_v2 | 邮箱 | data1 地址 / data2 类型 |
vnd.android.cursor.item/postal-address_v2 | 地址 | data1 格式化地址 |
vnd.android.cursor.item/note | 备注 | data1 内容 |
vnd.android.cursor.item/nickname | 昵称 | data1 内容 |
所以“把联系人还原成人能看的样子”这件事,本质是写一个多表连接的查询。SQL 长这样:
SELECT r._id AS raw_id, r.contact_id AS agg_id, r.account_type AS account, n.data1 AS display_name, p.data1 AS phone_number, p.data2 AS phone_type, p.data4 AS normalized FROM raw_contacts r LEFT JOIN data n ON n.raw_contact_id = r._id AND n.mimetype_id = (SELECT _id FROM mimetypes WHERE mimetype = 'vnd.android.cursor.item/name') LEFT JOIN data p ON p.raw_contact_id = r._id AND p.mimetype_id = (SELECT _id FROM mimetypes WHERE mimetype = 'vnd.android.cursor.item/phone_v2') WHERE r.deleted = 0 ORDER BY r._id;注意r.deleted = 0这个条件。raw_contacts表有个deleted字段,值非 0 表示这条记录被标记为删除但物理上还在。这个字段是联系人恢复的第一入口:如果机主只是删了联系人而没有触发深度清理,很多条目会以deleted = 1的状态留在表里,名字、号码一应俱全,只是界面上不显示。我做过一个案子,设备上通讯录只有 40 多条,按这个思路一查,deleted = 1的还有 200 多条,直接把对方“我跟他没联系”的说法推翻了。
3.4 SQLite 的“删除”到底删了什么
要理解恢复的原理,得先理解 SQLite 的存储模型。
SQLite 数据库是一个文件,内部按固定大小分页,默认 4096 字节一页。第一页开头 100 字节是文件头,记录页大小、编码、版本等元信息。从第 1 页开始,每页开头有页头,紧接着是单元指针数组,指向本页内各个单元(cell)的偏移。真正的记录数据存在单元里。
删除一行时,SQLite 做的是:把该单元从页内链表中摘掉,指针数组对应位置填 0,页头里的“空闲字节数”增加。单元的原始字节通常不会被清零,它们还躺在页里,只是没有指针指向了。这就是恢复的物理基础。
再往上,如果一个页上的所有单元都被删光,这个页可能被整页释放,进入空闲页链表(freelist)。PRAGMA freelist_count;能查到这个链表里有多少页。这些页的内容同样不会被清空。
还有两个重要的机制:
回滚日志(-journal):在事务进行中,原始页的副本会被写进 journal 文件,用于回滚。事务提交后 journal 被删除,但如果删除操作本身还没提交或者异常中断,journal 里可能保留着删除前的数据。
预写日志(-wal):新版本默认模式。修改先写进 WAL 文件,积累到一定量再合并进主库(checkpoint)。这意味着最近的操作可能完全还在 WAL 里,主库文件反而是旧的。取证时如果只拿主库不拿 WAL,会漏掉最新的数据——包括最近的删除动作之前的完整记录。
所以正确的做法是:接触设备的第一时间,把contacts2.db、-wal、-shm、-journal四个文件一起取走,一个都不能少。少取一个,后面的恢复能力就打折扣。
iOS 那边的模型也类似但路径不同。通话记录在Library/CallHistoryDB/CallHistory.storedata,核心表是ZCALLRECORD,字段ZADDRESS存号码,ZDATE存时间,ZDURATION存时长,ZORIGINATED区分呼出呼入,ZANSWERED区分接没接。联系人则在Library/AddressBook/AddressBook.sqlitedb,表是ABPerson和ABMultiValue。iOS 的时间戳是个坑,Core Data 用的纪元是 2001-01-01 00:00:00 UTC,跟 Unix 纪元差 978307200 秒。换算写错,全部时间平移三十多年,这个错误我在别人的报告里见过不止一次。
4. 实操:从提取到重建的完整链路
4.1 前置准备:动手之前先想清楚五件事
在插线之前,我一般会先确认五件事,缺一件都可能让后面的工作白做。
设备状态。是否能正常开机、锁屏类型是什么、系统版本号、存储剩余空间多大。剩余空间越小,删除的数据被覆盖的概率越高,这个数值直接影响你对恢复率的判断。
网络状态。是否已断网、是否开启了账号自动同步、是否有推送服务在跑。能关的先关,关不掉的记录下来,因为同步行为可能在分析期间改变数据。
时间基准。设备显示的当前时间、时区设置、是否与真实时间有偏差。有些设备被手动改过时间,这会让所有时间戳的解读都出问题。我遇到过一台设备时间被设快了 3 天,不做记录的话,导出的时间线会整体前移,跟其他证据线完全对不上。
授权范围。授权文件写的范围是不是覆盖了“提取通讯录与通话记录”,有没有说清楚是否包含云端数据。范围不清的,先问清楚再动手。
产物存放。准备一个干净的、容量足够的存储位置,按项目建目录,把原始产物和工作副本分开。这一步是习惯问题,但习惯不好的话,一个项目几百个文件混在一起,后面自己都找不到哪个是原始的。
4.2 逻辑提取的两种路径
有授权的场景下,Android 的逻辑提取主要有两条路。
第一条是通过备份机制。系统自带的备份功能可以把应用数据打成归档文件,取出后解包就能拿到数据库。这条路的优点是侵入性低、不需要高权限;缺点是备份机制会跳过一些文件,而且部分应用会明确排除在备份之外。对于通讯录和通话记录这类系统组件,通常是可以备份出来的,但要注意备份文件是加密的,需要处理口令。
第二条是直接读取应用私有目录。需要设备处于可访问状态。拿到文件后第一件事不是打开,而是复制并计算哈希。
# 建立工作目录,所有操作都在副本上做 mkdir -p case_2025/raw case_2025/work # 拉取四个文件,一个都不能少 adb pull /data/data/com.android.providers.contacts/databases/contacts2.db case_2025/raw/ adb pull /data/data/com.android.providers.contacts/databases/contacts2.db-wal case_2025/raw/ adb pull /data/data/com.android.providers.contacts/databases/contacts2.db-shm case_2025/raw/ adb pull /data/data/com.android.providers.contacts/databases/contacts2.db-journal case_2025/raw/ 2>/dev/null # 立刻算哈希,写入日志 cd case_2025/raw sha256sum * | tee ../hash_before.txt # 制作工作副本 cp contacts2.db* ../work/ cd ../work sha256sum * | tee ../hash_after.txt # 比对,确认副本和原始一致 diff <(cut -d' ' -f1 ../hash_before.txt) <(cut -d' ' -f1 ../hash_after.txt) && echo "副本校验通过"拿到文件之后,先别急着查数据,做一次完整性检查:
PRAGMA integrity_check; PRAGMA freelist_count; PRAGMA page_size; PRAGMA journal_mode;integrity_check返回ok说明库结构没坏。freelist_count告诉你有多少空闲页,这个数字越大,可恢复的残留越多。journal_mode说明当前模式,如果是wal,那你必须把-wal文件一起打开才有最新数据。
打开数据库时一定要用只读模式,避免 SQLite 自己做 checkpoint 把 WAL 合并进主库、或者写入临时文件改变原始状态:
import sqlite3 # immutable=1 告诉 SQLite 这个文件不会被改动,跳过锁和 WAL 重放检查 conn = sqlite3.connect( "file:contacts2.db?mode=ro&immutable=1", uri=True )这里有个取舍要说清楚:immutable=1能保证绝对不碰原文件,但它会跳过 WAL 的重放,也就是说你看到的是主库的旧状态。如果你需要最新数据,得先把主库和-wal、-shm一起复制到工作目录,然后用mode=ro打开(不带immutable),让 SQLite 正常重放 WAL。重放之后,把结果导出一份新的库文件,后续分析都基于这份导出。
我一般的做法是两步走:先用immutable打开做一次“历史快照”分析,再用允许重放的方式打开做一次“最新状态”分析,两份结果都留着,后面的差异对比本身就是有价值的线索——比如某条通话记录在主库里存在,在重放 WAL 之后消失了,说明这条记录是最近被删的。
4.3 重建通话记录时间线
解析通话记录的核心是把毫秒时间戳转成人类可读的本地时间,同时把类型码翻译成语义。
import sqlite3 import datetime from zoneinfo import ZoneInfo TZ = ZoneInfo("Asia/Shanghai") TYPE_MAP = {1: "来电", 2: "去电", 3: "未接", 4: "语音留言", 5: "拒接", 6: "拦截"} conn = sqlite3.connect("file:contacts2.db?mode=ro&immutable=1", uri=True) conn.row_factory = sqlite3.Row rows = conn.execute(""" SELECT _id, number, date, duration, type, name, numbertype, countryiso, subscription_id FROM calls ORDER BY date ASC """).fetchall() def fmt_time(ms): if not ms: return "" dt = datetime.datetime.fromtimestamp(ms / 1000.0, datetime.timezone.utc) return dt.astimezone(TZ).strftime("%Y-%m-%d %H:%M:%S") for r in rows: print( fmt_time(r["date"]), TYPE_MAP.get(r["type"], f"未知({r['type']})"), r["number"] or "", r["name"] or "", f'{r["duration"]}s' )跑完这一步,你会得到一条按时间排好的流水。但流水本身不是结论,接下来还要做三件事。
第一件:号码归一化。同一个号码在库里可能以13800138000、+8613800138000、013800138000、138-0013-8000多种形式出现。不归一化,统计的时候会把一个人算成四个人。归一化规则要写进报告:去除非数字字符(保留前导+)、去掉中国区号86、补全 11 位手机号。注意不要过度归一化,短号、分机号、境外号码各有规则,强行套模板会出错。
第二件:和联系人做关联。用归一化后的号码去匹配data表里的data4(系统已经算好的归一化号码),能匹配上的补上联系人姓名和备注。匹配不上的单独列出来——这些“无名号码”往往是最有信息量的部分,机主没存进通讯录却频繁通话的号码,本身就值得追一下。
第三件:和时间线其他数据交叉。通话记录单独看只是“打了几个电话”,跟短信、应用使用记录、位置数据叠在一起,才能构成完整叙事。比如某通电话的起止时间正好覆盖某条位置记录的时段,这两条数据就互相印证了。
4.4 联系人重建:处理聚合与去重
联系人这部分的复杂度比通话记录高一个档次,因为它有聚合逻辑。
raw_contacts是“来源条目”,contacts是“聚合结果”。同一个人如果在本地存了一份、在某个账号下又存了一份,系统会把它们聚合到一起,contacts表里只有一条,raw_contacts里有两条,两条都指向同一个contact_id。
重建的时候,我一般输出两个版本:
版本 A:按 raw_contacts 展开。每一行是一个原始条目,能看到来源账号、是否被标记删除、有没有被聚合。这个版本适合做取证分析,因为它保留了完整的状态信息。
SELECT r._id, r.contact_id, r.account_type, r.account_name, r.deleted, r.dirty, r.version, n.data1 AS full_name, p.data1 AS phone FROM raw_contacts r LEFT JOIN data n ON n.raw_contact_id = r._id AND n.mimetype_id = (SELECT _id FROM mimetypes WHERE mimetype LIKE '%/name') LEFT JOIN data p ON p.raw_contact_id = r._id AND p.mimetype_id = (SELECT _id FROM mimetypes WHERE mimetype LIKE '%/phone_v2') ORDER BY r.contact_id, r._id;版本 B:按 contacts 聚合。每一行是一个最终联系人,多个号码合并到一个单元格里。这个版本适合做阅读和汇报,但不适合做分析,因为聚合过程丢掉了来源信息。
去重这件事要小心。不能简单地按姓名去重,因为同名不同人很常见;也不能简单按号码去重,因为一个人多个号也很常见。我的做法是:先按归一化号码建立索引,再按contact_id分组,最后人工核对那些“一个名字对多个号码”和“一个号码对多个名字”的异常组。这些异常组恰恰是最需要关注的——要么是机主存错了,要么是有人刻意改了备注。
还有一个容易漏的点:SIM 卡联系人。这类联系人的account_type通常是com.android.contacts.sim,它们独立于账号体系,界面上可能显示在一起,但存储位置不同。如果只查账号类联系人,SIM 卡里的会全部漏掉。做统计前先看一眼account_type的分布:
SELECT account_type, COUNT(*) FROM raw_contacts GROUP BY account_type;4.5 iOS 那边的时间戳换算
前面提过 iOS 的 Core Data 纪元问题,这里给出换算代码:
import datetime CORE_DATA_EPOCH = 978307200 # 2001-01-01 00:00:00 UTC 对应的 Unix 秒 def cd_to_local(cd_ts, tz_offset_hours=8): if not cd_ts: return "" unix_ts = cd_ts + CORE_DATA_EPOCH dt = datetime.datetime.fromtimestamp(unix_ts, datetime.timezone.utc) return dt.astimezone( datetime.timezone(datetime.timedelta(hours=tz_offset_hours)) ).strftime("%Y-%m-%d %H:%M:%S")如果你按 Unix 时间戳直接解析ZDATE,得到的日期会落在 2001 年前后,看着像“设备时间错乱”,实际上是纪元搞错了。这个错误排查起来要花不少时间,提前知道能省事。
5. 已删除数据的恢复尝试与原理
5.1 从软删除标记入手,这是成本最低的一步
前面提过raw_contacts.deleted字段。这是恢复工作的第一站,因为它是逻辑层面的删除标记,数据完整度最高,几乎等于直接读出来。
-- 被标记删除但数据还在的联系人 SELECT r._id, r.contact_id, r.deleted, r.account_type, n.data1 AS name, p.data1 AS phone FROM raw_contacts r LEFT JOIN data n ON n.raw_contact_id = r._id AND n.mimetype_id = (SELECT _id FROM mimetypes WHERE mimetype LIKE '%/name') LEFT JOIN data p ON p.raw_contact_id = r._id AND p.mimetype_id = (SELECT _id FROM mimetypes WHERE mimetype LIKE '%/phone_v2') WHERE r.deleted <> 0;某些系统版本还保留了deleted_contacts或deleted_raw_contacts这样的影子表,里面存的是删除时的完整快照。先查一下库里有没有这些表:
SELECT name FROM sqlite_master WHERE type='table' AND name LIKE '%delet%';有的话直接读,没有就转向下一层。
通话记录没有deleted这么友好的标记,删除通常是直接摘除单元。但还有两个可查的地方:_id的空洞和sqlite_sequence。如果calls表的_id明显不连续,中间缺号,说明那些行被删过。缺号的数量和位置本身就是信息——能告诉你删了多少条、大概什么时间段删的。
5.2 WAL 与 journal:最近删除的最后窗口
如果删除动作发生在最近,而且设备之后没有被高强度使用,删除前的数据很可能还完整地躺在 WAL 文件里。
WAL 的机制是:修改先写入 WAL 帧,主库文件保持不变。每一帧包含页号、提交标记和页数据。当你用正常方式打开数据库时,SQLite 会重放 WAL 里已提交的帧,让主库“看起来”是最新的。但如果你用immutable=1打开,重放被跳过,你看到的是主库的旧状态——这个旧状态里就有那些“后来被删掉”的记录。
所以对比两个视图的差异,就是一条重要的恢复路径:
import sqlite3 # 视图一:主库原始状态(跳过 WAL) conn_a = sqlite3.connect("file:contacts2.db?mode=ro&immutable=1", uri=True) # 视图二:重放 WAL 后的状态 conn_b = sqlite3.connect("file:contacts2.db?mode=ro", uri=True) def dump_calls(c): return { r[0]: r[1:] for r in c.execute( "SELECT _id, number, date, duration, type FROM calls" ).fetchall() } a = dump_calls(conn_a) b = dump_calls(conn_b) # 只在主库里有、重放后消失的,就是最近被删的 only_in_a = set(a) - set(b) print("疑似最近删除的记录数:", len(only_in_a)) for i in sorted(only_in_a): print(a[i])这个方法很实用,但有两个前提要注意。一是 WAL 文件必须取到,而且没被 checkpoint 清空。二是打开时不能触发新的 checkpoint,所以mode=ro打开后不要执行任何写操作,分析完立刻关闭。
journal 文件是另一条线。它存的是事务进行中原始页的副本,事务提交后就该被删掉。如果设备在删除操作过程中异常断电或者被强制关机,journal 可能残留,里面的内容就是删除前的完整页。这种情况概率不高,但遇到了就是完整恢复。
5.3 空闲页扫描:把没有指针指向的字节捞出来
当软删除标记和 WAL 都用尽,就得往下挖到存储层了。
最直接的方法是整文件正则扫描。contacts2.db是二进制文件,但文本内容(号码、姓名)以 UTF-8 或 UTF-16 编码直接存在里面。用正则扫整个文件,虽然没有结构信息,但能捞到大量残留。
import re # 匹配中国大陆手机号,允许前导 +86 或 86 phone_pat = re.compile(rb'(?:\+?86)?1[3-9]\d{9}') # 匹配 11 位以内、以 0 开头的固话 tel_pat = re.compile(rb'0\d{2,3}[- ]?\d{7,8}') data = open("contacts2.db", "rb").read() hits = [] for m in phone_pat.finditer(data): start = max(0, m.start() - 64) end = min(len(data), m.end() + 64) context = data[start:end] hits.append((m.start(), m.group().decode("ascii", "ignore"), context)) print(f"共命中 {len(hits)} 处")这个方法的产出是“疑似号码 + 周边上下文”,需要人工判读。上下文里往往能看出这条记录的其他字段——比如号码前面紧挨着的可能就是姓名,后面跟着的可能是时间戳的原始字节。熟练之后,从上下文里能读出不少东西。
更精细的做法是解析页结构,按单元提取。这需要写代码遍历每个页:读页头拿到单元数量和指针数组,按指针跳到单元位置,读变长整数拿到 payload 长度和 rowid,再按记录格式解析各字段。工作量大,但输出的是结构化数据,能直接辨别“这是一条完整的两字段记录,还是碎片”。
对于空闲页,逻辑是一样的,只是这些页在正常结构里已经不被引用,需要从freelist_count配合页遍历来定位。sqlite3_analyzer这个工具能给出每页的使用状态,配合dbstat虚拟表(需要 SQLite 编译时开启)可以做更细的分析。
-- 如果当前编译支持 dbstat SELECT name, pageno, pagetype, ncell, payload, unused FROM dbstat WHERE name = 'calls' ORDER BY pageno;unused字段就是该页里没被使用的字节数,这个数越大,残留空间越多。把这些页单独导出来做扫描,命中率会高很多。
5.4 结果怎么验证:三条互相独立的线索
恢复出来的东西不能直接写进报告,必须验证。我一般用三条互相独立的线索交叉。
第一条:内部一致性。一条通话记录,date、duration、type三个字段要能自洽。未接电话时长应该为 0;时长几百秒的记录,起始时间加时长不应该跨到不合理的位置。字段之间互相矛盾的,大概率是碎片拼错了。
第二条:跨表印证。恢复出来的号码,能不能在data表或calls表的其他记录里找到同样的号码?一个从来没在任何地方出现过的号码,单独从空闲区里捞出来,可信度就要打折。
第三条:外部比对。如果有其他来源的数据——比如运营商详单(合法取得的前提下)、另一台设备的记录、纸质记录——拿来做时间点比对。对得上的,可信度大增;对不上的,要能解释原因。
这三条里能过两条,我才敢把它写进结论。只过一条的,写成“疑似”或者放进附录,不作为主要论据。
6. 常见问题与排查技巧实录
6.1 数据库打不开、报错、字段对不上
现象一:file is not a database。大概率是文件加密了。新版本 Android 对应用数据目录有额外保护,直接拉出来的可能是加密后的文件。判断方法是看文件头:正常 SQLite 文件开头是SQLite format 3\0(16 字节魔数),如果开头是乱码或者别的标识,就是加密或者压缩过。这种情况得用支持解密的方案,或者改走更高层级的提取路径。
现象二:database disk image is malformed。文件损坏,可能是提取过程中断,也可能是闪存本身有坏块。先重新提取一次;还是不行的话,用.recover命令尝试抢救:
sqlite3 broken.db ".recover" | sqlite3 recovered.db.recover会跳过损坏的页,尽最大努力把能读的记录导出来重建一个新库。它是 SQLite 3.29 之后才有的功能,老版本没有。重建出来的库会丢掉rowid的连续性,分析_id空洞的时候要注意这个前提。
现象三:表在,但字段名不认识。不同系统版本的表结构有差异,字段可能增删改。先查一下:
PRAGMA table_info(calls);把实际字段列出来再写查询,别照抄网上的模板。我见过有人拿 Android 5 时代的 SQL 去查 Android 13 的库,字段名对不上,报错报了一下午。
6.2 时间对不上:最常见的四类原因
时间线错误是取证报告里最致命的硬伤,因为一旦有一处对不上,整份报告的可信度都会被质疑。排查顺序我固定为四步。
第一步查时区。数据库存 UTC,你的解析代码有没有正确转成本地时区?设备本身的时区设置是什么?这两个要一致。
第二步查纪元。是 Unix 毫秒还是秒?是 Unix 纪元还是 Core Data 纪元?单位错一位,时间差一千倍;纪元错一个,差几十年。这一点尤其在混用 Android 和 iOS 数据的时候容易出错。
第三步查设备时钟。设备本身的时间设置准不准?有没有被手动改过?判断方法是看有没有“时间跳变”——同一台设备上的记录,如果时间线在某个点突然倒退或大幅前跳,很可能就是时钟被调过。这种情况要做整体校正,并在报告里说明校正依据。
第四步查字段含义。date是起始时间这个没错,但有些系统的字段语义有细微差别。比如某些记录里存的是“最后更新时间”而不是“通话时间”。遇到可疑记录,结合duration和邻近记录一起看,能反推出正确的语义。
6.3 号码显示异常与匹配失败
现象:号码前面多出+86,匹配不上联系人。这是归一化没做。系统在data4字段里存了归一化结果,直接用它匹配最省事。自己写归一化的话,规则要覆盖:去除空格、横线、括号;去掉前导+86、0086、86;保留短号和特殊号码原样。
现象:号码显示成一串乱码。编码问题。SQLite 强制用 UTF-8 或 UTF-16 存储文本,但有些字段存的是二进制号码(比如某些 SIM 卡联系人的编码格式)。先看typeof()的返回,确认是 text 还是 blob,blob 的话要按对应的编码规则解码。
现象:同一个联系人对应了几十个号码。聚合出问题了。查contacts表的_id和raw_contacts.contact_id的对应关系,看是不是聚合规则把不该合的合了。这种情况常见于批量导入联系人之后,系统按姓名或相似性错误聚合。处理方式是按raw_contacts展开,人工核对后再合并。
6.4 恢复率远低于预期怎么办
先别怀疑工具,按这个顺序排查:
设备在删除后有没有被大量使用?剩余空间越小、使用强度越高,覆盖概率越大。查一下设备的最后活跃时间和当前的存储占用。
WAL 有没有取到?只拿主库不拿 WAL,等于放弃了最近删除的完整数据。回设备上看看-wal还在不在。
打开方式对不对?用了immutable=1就看的是旧状态,用了普通mode=ro就会重放 WAL 丢失旧状态。两种都要跑一遍做对比。
扫描范围够不够?只扫主库文件,漏掉了-journal和系统备份目录里的旧副本。把整个应用数据目录和相关备份目录都纳入扫描范围。
正则规则是不是太窄?只匹配 11 位手机号,固话、短号、境外号码全漏。放宽规则,多写几组模式。
下面这张表是我平时放在手边对照的:
| 现象 | 高概率原因 | 首选动作 |
|---|---|---|
| 文件打不开,报 not a database | 文件加密 / 提取不完整 | 查文件头魔数,重新提取 |
| 报 malformed | 文件损坏 | 重新提取,或.recover抢救 |
| 时间整体偏移 8 小时 | 时区未转换 | 检查解析代码时区设置 |
| 时间偏移三十多年 | 纪元错误(Core Data) | 加 978307200 秒 |
| 时间线中途倒退 | 设备时钟被改过 | 查找跳变点,记录校正依据 |
| 联系人姓名全空 | 只查了 raw_contacts,没 join data 表 | 补上 mimetypes 关联 |
| 联系人数量翻倍 | 聚合前按 raw 展开统计 | 按 contact_id 去重 |
| 号码匹配不上 | 未归一化 | 用 data4 或自写归一化规则 |
| 恢复率极低 | WAL 未取 / 覆盖严重 / 扫描范围窄 | 逐项排查,扩扫描范围 |
6.5 几个我踩过的坑
坑一:用工具直接打开原始文件。有些 GUI 工具打开数据库时会执行写入操作(建临时表、改 journal 模式),一打开原始文件就变了。所有分析必须在副本上做,这不是形式要求,是保命要求。
坑二:忘了取-shm文件。只取-wal不取-shm,某些情况下 WAL 无法正确重放,你看到的可能是残缺状态。三个文件一起取,这是标配。
坑三:导出 CSV 用 Excel 打开。Excel 会把长号码当成数字处理,超过 11 位就自动转成科学计数法,前导零也被吃掉。号码字段一定要以文本形式导出,或者在 CSV 里给号码加引号前缀。这个错误极其常见,而且一旦发生在报告里,对方一眼就能看出来你不专业。
坑四:只看calls表的name字段就下结论。这个字段是写入时的快照,可能过时。要做身份关联,得用归一化号码去data表实时匹配,两边结果不一致的,以数据库关联结果为准,name字段作为时间维度的补充。
坑五:忽略双卡。双卡设备的calls表里有subscription_id区分卡槽,单卡设备这个字段可能是空或者固定值。做通话统计的时候不分卡槽,会把两张卡的记录混在一起,数量对不上。
最后分享一个我常做的收尾动作:把整份数据按天聚合,输出一张“每日通话数 + 联系人新增数”的表格。这张表看着简单,但异常点会自己跳出来——某一天通话数突然从个位数涨到几十,或者某一天集中新增了一批联系人,这些都是值得往下挖的线索。数据的价值往往不在单条记录里,而在密度的变化里。