应用私有数据与 SharedPreferences

应用私有目录是移动取证里数据密度最高、体积最小、解析难度最低的那一块。

最后更新 2026-10-09版本 v1.0维护 DigiForensics查看历史 0

关键词:SharedPreferences、内部存储、外部存储、XML 键值、分区存储 难度:入门 前置知识:Linux 目录结构、XML 基础、ADB 基本操作、Python 文件遍历 相关文章:Android 数据存储总览、APK 逆向与 Manifest 分析

一、概述

应用私有目录是移动取证里数据密度最高、体积最小、解析难度最低的那一块。

物理镜像动辄上百 GB,一个应用私有目录可能只有几 MB。账号标识、登录状态、配置项、本地缓存、密钥痕迹,全挤在这几 MB 里。

它也是每个案件里最先被打开、也最先出结论的目录。

其中 shared_prefs/ 尤其特殊:明文 XML,不需要任何解密工具,文本编辑器直接读。用户 ID、服务器地址、上次登录时间、搜索历史、已保存的账号摘要都在这里。

少掉这一层,分析人员就得先去啃数据库的表结构。而数据库里能回答的问题,往往比 prefs 少得多。

没有这套读法会发生什么:结论会从「应用记录了什么」退化成「应用目录里有若干文件」。

同一个应用、同一个版本,目录里躺着七八个子目录。挑哪个、先看什么、哪个可以丢,直接决定报告有没有实际内容。

更隐蔽的失败是看起来有结论,其实是错的。只读 app.db 而漏了 -wal,数据库会安静地退回上一次 checkpoint,最近几天的记录凭空消失,不报任何错(见 陷阱 1)。

它在流程里的位置是分析环节的最前段。输入是 Android 数据存储总览 已经提取出来的应用私有目录,输出是可供时间线与关联分析的明文配置和数据库路径。

上游依赖提取工艺与完整性校验(流式导出、哈希固定、tr -d '\r' 这类批量细节见 陷阱 9)。下游支撑三条线:

  • 账号关联:用 prefs 里的账号标识串并多应用
  • 加固识别:lib/ 里的 so 决定静态分析能不能看到真实逻辑,见 APK 逆向与 Manifest 分析
  • 动态分析的路线判断:no_backup/ 里的密钥材料决定 Frida 动态 Hook 要 hook 什么

涉及的证据形态(具体到文件名与结构):

形态 具体位置 结构特征
偏好配置 shared_prefs/<name>.xml 根标签 <map>,键在 name 属性上,值类型由元素标签决定
旧版偏好 shared_prefs/<name>.xml.bak 上一个完整版本,可能比主文件更新鲜
加密偏好 shared_prefs/encrypted_shared_prefs/ androidx.security 写入的密文,无 XML 声明
业务库 databases/app.db + -wal + -shm 三件套必须同在,漏 WAL 丢最近写入
任意文件 files/、cache/、code_cache/ cache/ 可重建但含"访问过"的独立旁证
密钥材料 no_backup/*.key 高熵二进制、无文件头,取证只固定哈希
加固 so lib/<abi>/*.so 与 APK 内同名目录 库名含 jiagu/packer 等即加固信号
外部目录 /sdcard/Android/data/ 与 /sdcard/Android/media/ 前者卸载即删,后者卸载保留,是卸载取证唯一入口

能回答什么:这个应用登录过哪些账号、上次活跃在什么时候、连过哪些服务器地址、搜索过什么关键词、是否被加固、卸载后还留下什么。

不能回答什么:聊天内容与文件传输记录在加密库里能不能读出来(取决于加固与密钥材料,见 2.3);应用服务端存了什么;cache/ 里的内容是不是用户真实看过——它只证明访问,不证明使用。

一句话:本篇给的是「从文件里能读出什么」,不是「用户做了什么」。

本文不做的事:不做应用运行时分析,不提取任何凭据的明文用途,不尝试在线使用检材内的会话令牌。遇到加密库时只做三件事:识别、固定哈希、标注解密条件。

读者前提:Linux 目录结构、XML 基础、ADB 基本操作,以及一个可执行的 Python 环境(本篇的解析脚本只用标准库)。

读 二、核心原理 之前,先确认设备侧已经完成目录级提取并做过哈希。本篇的分析动作全部建立在「目录完整」这个前提上,前提不成立时本章所有读法都不适用。

二、核心原理

2.1 应用私有目录的七个子目录

/data/data/com.digiforensics.sample/        (等价于 /data/user/0/com.digiforensics.sample/)
├── shared_prefs/     ← ★ XML 键值配置,明文,首要目标
├── databases/        ← ★ SQLite 数据库 + -wal / -shm
├── files/            ← 任意文件(图片、文档、下载、自定义格式)
├── cache/            ← 临时缓存,可安全丢弃但对判断"访问过"有价值
├── no_backup/        ← 应用声明的"不参与备份"数据
├── lib/              ← ★ 应用自带 so 库(可能是加固壳)
└── code_cache/       ← 优化后的 DEX(Android 11+)

优先级:shared_prefs/ 与 databases/ 并列第一(明文配置与业务数据密度最高);files/ 与 lib/ 次之(导出文件与加固识别,见 03);no_backup/ 第三(部分加密密钥材料、临时 DB 在此);cache/ 与 code_cache/ 最后(前者能证明"曾访问过某功能",后者含混淆后的类结构)。

2.2 SharedPreferences 的文件格式

每个 shared_prefs/<prefs_name>.xml 对应一个偏好设置文件(对应代码里的 getSharedPreferences(name, mode))。格式是标准 Android XML:

<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
<map>
    <string name="user_id">8827341</string>
    <boolean name="is_logged_in" value="true" />
    <int name="login_count" value="17" />
    <long name="last_login_time" value="1717213492000" />
    <float name="upload_quality" value="0.75" />
    <string name="last_search">优盤清倉</string>
</map>

解析要点(这几条决定提取准不准):根标签是** <map>**,不是 <preferences>,也不是 PreferenceScreen。键在 name 属性上,不在元素名上,所有键值都是同一层级的子元素。值类型由元素标签决定:string 是文本,int / long 是数值,float 是浮点,boolean 写成 value="true"。

long 时间戳几乎都是毫秒级,而且必须 64 位解析。1717213492000 用 32 位会溢出成负数。

还有三条容易翻车的:

  • ① .bak 文件:写入中断会留下 <name>.xml.bak,它是上一个完整版本,内容可能比当前文件更新鲜
  • ② encrypted_shared_prefs/:Android 7+ 的 androidx.security 库使用,内容是密文,不能按普通 XML 解析
  • ③ 重复键:同名键可能重复出现(写入异常,或从 .bak 恢复所致),按出现顺序全部读取并记录次数,不要只取第一个

.bak 的实战价值经常被忽略。 Android 写 SharedPreferences 是「写临时文件 → 改名覆盖」,异常中断(应用被杀、空间不足)会留下 .bak。用户已经「退出登录」(当前文件 is_logged_in=false),.bak 里还留着 true 和旧账号。两个文件的时间戳对比本身就是一条时间线证据。

# 提取与初筛(shared_prefs 全是明文 XML,可直接 grep)
adb shell su -c "tar -cf - -C /data/data/com.digiforensics.sample shared_prefs" > prefs.tar
tar -xvf prefs.tar

# 全文搜索敏感关键词(一次性扫出所有应用配置)
grep -ril -E 'password|passwd|token|secret|api_key|session|cookie|auth' extracted/ \
  | tee suspicious-prefs.txt

2.3 lib/ 目录:加固识别的第一现场

/data/data/com.digiforensics.sample/lib/arm64-v8a/
├── libappcore.so
├── libnative-lib.so
└── libjiagu.so          ← 名称含"jiagu/加固/bang"等即强加固信号

/data/app/ 下安装的 APK 解包后也有 lib/<abi>/,两处应合并检查。

这套结构要回答的问题是:这个应用运行时到底加载了哪些 native 代码。

商业 App 里,加固库的存在意味着静态分析只能看到壳,解密 key 通常在 so 运行时初始化。这直接决定 05 即时通讯取证 中「能否拿到数据库密钥」这条路径的成本。见 03 的加固识别与 04 的动态分析。

PKG=com.digiforensics.sample
adb shell su -c "find /data/data/$PKG/lib /data/app/*/$PKG*/lib -name '*.so' 2>/dev/null" \
  | tee so-list.txt
file extracted/lib/arm64-v8a/*.so          # 确认架构与是否被剥符号

2.4 外部存储与分区存储的变化

Android 版本 存储模型 取证影响
≤ 9(Pie) 传统全权限,任一应用读写整个 /sdcard 第三方应用数据散落在 /sdcard/<App名>/
10(Q) 分区存储:应用只能访问自己的专属目录 + 媒体库 旧路径大量失效
11–12 分区存储 + 权限收紧 更多数据留在应用私有目录
13–15 作用域化更严,READ_MEDIA_* 细分 内外部差异进一步扩大

Android 10+ 的实际目录形态(≤9 时代数据散落在 /sdcard/<App名>/,分区存储后这些旧路径大量失效):

路径 内容 备注
/sdcard/Android/data/<包名>/ 应用专属外部目录 卸载应用时会被一并删除(取证价值高但易灭失)
/sdcard/Android/media/<包名>/ Android 11+ 的媒体专属目录 卸载后保留,是卸载应用的取证入口
/sdcard/Download/ 下载目录 安装包、转账凭证导出常在此
/sdcard/DCIM/、Pictures/、Movies/、Documents/、Android/obb/<包名>/ 媒体库(MediaStore 索引源)与扩展资源包 Android 10+ 需通过 MediaStore 访问

两个高价值推论:① 已卸载应用的数据线索主要在 /sdcard/Android/media/<包名>/——Android/data 随卸载清除但 Android/media 保留(Android 11+ 明确如此),这是"卸载后取证"的唯一常规入口。② /sdcard/Download/ 是安装包与导出文件的高发地,赌博、诈骗类案件的 APK 残留、转账截图导出常在此(见 01 案例)。

adb shell su -c "ls -la /sdcard/" | tee sdcard-root.txt
adb shell su -c "du -sh /sdcard/Android/data/* /sdcard/Android/media/* 2>/dev/null" | sort -h | tail -20
adb pull /sdcard/Download/ extracted/Download/

三、操作步骤

第 1–2 步:定位并流式导出

PKG=com.digiforensics.sample
adb shell pm path $PKG                            # APK 位置(/data/app/...)
adb shell su -c "du -sh /data/data/$PKG"           # 体积,决定提取策略
adb shell su -c "tar -cf - -C /data/data $PKG" > app.tar
sha256sum app.tar | tee app.tar.sha256             # tar 包本身先哈希固定
tar -tf app.tar | head -60 && tar -xf app.tar     # 先列后拆,确认包完整
find extracted -type f -exec sha256sum {} \; > hashes-app.txt

第 3 步:优先处理 shared_prefs/

ls -l extracted/shared_prefs/
find extracted/shared_prefs -name '*.bak' -ls       # 别漏 .bak
python3 parse_prefs.py extracted/shared_prefs prefs-report.csv
column -s, -t prefs-report.csv | head -40

可直接运行的 Python 脚本(批量解析并汇总 SharedPreferences):

#!/usr/bin/env python3
# 批量解析 shared_prefs/*.xml 汇总为 CSV。Python 3.8+,仅标准库
# 用法: python3 parse_prefs.py <shared_prefs目录> <输出CSV>
import csv, datetime, os, sys
import xml.etree.ElementTree as ET

SENSITIVE = ("password", "passwd", "pwd", "token", "secret", "key",
             "session", "cookie", "auth", "sign", "credential", "salt")

def human_time(v):
    """毫秒/秒时间戳 → 可读时间;明显不是时间戳则原样返回。"""
    try:
        n = int(v)
    except (TypeError, ValueError):
        return v
    if 1_000_000_000 < n < 4_000_000_000:            # 秒级
        n *= 1000
    if 946_684_800_000 < n < 4_102_444_800_000:       # 2000 ~ 2100
        return datetime.datetime.utcfromtimestamp(
            n / 1000).strftime("%Y-%m-%d %H:%M:%S")
    return v

def parse_file(path):
    """返回 [(key, type, value, flag), ...];解析失败返回空表。"""
    rows = []
    try:
        root = ET.parse(path).getroot()
    except ET.ParseError as e:
        print(f"[!] 解析失败 {path}: {e}", file=sys.stderr)
        return rows
    for el in root:                                   # 根标签通常是 <map>
        key = el.get("name", "")
        value = el.get("value")
        if value is None:
            value = (el.text or "").strip()
        flag = "SENSITIVE" if any(s in key.lower() for s in SENSITIVE) else ""
        if el.tag == "long" and not flag:
            value = human_time(value)                 # 疑似时间戳则转换
        rows.append((key, el.tag, value, flag))
    return rows

src, out = sys.argv[1], sys.argv[2]
total = sens = 0
with open(out, "w", newline="", encoding="utf-8") as fh:
    w = csv.writer(fh)
    w.writerow(["prefs_file", "key", "type", "value", "flag"])
    for fn in sorted(os.listdir(src)):
        if not fn.endswith((".xml", ".xml.bak")):
            continue
        for key, vtype, value, flag in parse_file(os.path.join(src, fn)):
            w.writerow([fn, key, vtype, value, flag])
            total += 1
            sens += 1 if flag else 0
print(f"共 {total} 个键值,其中敏感键 {sens} 个 → {out}")

第 4 步:databases/ 正确提取(不要漏 WAL)

ls -l extracted/databases/                          # app.db / app.db-wal / app.db-shm
adb shell su -c "tar -cf - -C /data/data/com.digiforensics.sample databases" > db.tar
tar -xvf db.tar
sqlite3 extracted/databases/app.db "PRAGMA integrity_check;"
sqlite3 extracted/databases/app.db ".tables"

-wal / -shm 漏掉的后果:数据库会"退回"到上次 checkpoint 的状态,最近写入的数据全部消失。这会让人误判"数据被删了",是移动取证里最高频的误判之一。详见 09-SQLite-WAL取证。

第 5–7 步:外部存储、跨应用搜索与验证

adb pull /sdcard/Android/data/com.digiforensics.sample/  extracted/ext-data/
adb pull /sdcard/Android/media/com.digiforensics.sample/ extracted/ext-media/  # 卸载后仍保留
adb pull /sdcard/Download/  extracted/Download/
find extracted -type f -exec sha256sum {} \; > hashes-ext.txt

# 跨应用:一次性抓出所有应用的 shared_prefs
mkdir -p all-prefs
for d in $(adb shell su -c "ls /data/data/" | tr -d '\r'); do   # tr -d '\r' 必须加
  adb shell su -c "tar -cf - -C /data/data/$d shared_prefs 2>/dev/null" \
    | tar -xf - -C all-prefs 2>/dev/null
done
grep -ril -E 'password|token|secret|api_key|session' all-prefs/ | head -40

# 验证:设备端与本地哈希一致 + 库完整 + prefs 时间线
adb shell su -c "md5sum /data/data/com.digiforensics.sample/databases/app.db"
md5sum extracted/databases/app.db
sqlite3 extracted/databases/app.db "PRAGMA integrity_check;"     # 期望 ok
find extracted/shared_prefs -type f -printf '%T@ %p\
' | sort -n

四、常见陷阱

应用私有目录是「用文本编辑器就能读」的部分。

正因为门槛低,这一章的陷阱几乎全部来自

安全验证 当前请求需要先完成一次滑块验证。