ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Android SharedPreferences加密实战:从明文存储到EncryptedSharedPreferences

Android SharedPreferences加密实战:从明文存储到EncryptedSharedPreferences

如果你维护过一段时间的 Android 项目,一定对SharedPreferences再熟悉不过。它简单、好用、轻量,一个getSharedPreferences就能把用户偏好、登录状态、缓存配置存下来。但问题往往就藏在这种“无脑用”的习惯里——SharedPreferences默认使用明文 XML 文件存储数据,这意味着一旦设备被 root、备份文件被提取或者调试器被接上,你存在本地的 Token、手机号、账号密码就等于直接交给了攻击者。今天讲的这些内容,基于我在 cheesesquare 项目里的实际改造经验,把SharedPreferences的加密实践完整拆开给你看,从原理到方案对比,再附上一套可以直接抄作业的落地代码,目标只有一个:让你在 5 分钟内消除这个最常见的数据安全隐患。

做 Android 开发的朋友应该都有过这种体验:代码上线前做安全测试,扫描工具报出一堆“本地存储明文敏感数据”的风险项,点开一看,全是SharedPreferences里的登录凭证。想改吧,又怕牵一发动全身;不改吧,报告交不过去。这篇文章就是针对这个痛点写的。我会先讲清楚SharedPreferences为什么容易泄露数据,然后对比几种主流的加密思路,最后给出一个适合在类似 cheesesquare 这种模块化项目中快速落地的方案。不管你是刚接触 Android 的新手,还是正在维护老项目的资深开发,都能在文章里找到对应的操作路径。

1. 为什么要给 SharedPreferences 加密:数据泄露的真实场景

1.1 明文存储的秘密:SharedPreferences 的底层机制

SharedPreferences看起来是一个封装好的 key-value 接口,但底层实现并不神秘。每次调用putString、putInt、putBoolean之后,数据会被组织成一个 XML 文件,存放在应用私有目录的shared_prefs文件夹中,对应的文件路径通常是这样:

/data/data/<应用包名>/shared_prefs/<SharedPreferences文件名>.xml

这个路径是 Android 系统的标准行为,所有应用一视同仁。打开这个 XML 文件,你会看到类似下面这样的结构:

<?xml version='1.0' encoding='utf-8' standalone='yes' ?> <map> <string name="access_token">eyJhbGciOiJIUzI1NiJ9.xxxxxxxx</string> <string name="user_phone">13800138000</string> <boolean name="is_vip" value="true" /> </map>

不需要任何解码手段,直接就能读。这就是我说的“明文存储”的含义。SharedPreferences本身没有提供任何加密机制,也不会自动混淆或者压缩,一切换到文件系统层面,数据就是裸露的。

很多人会有一个认识误区,觉得应用私有目录是安全的,因为普通应用访问不了其他应用的私有数据。这个理解在 Android 早期确实成立,但放到今天的威胁模型下已经不够看了。攻击者获取数据的路径远比你想的多,而/data/data/路径下的明文 XML 恰恰是其中最容易被利用的一环。

1.2 攻击者怎么拿到你的数据:几种常见泄露路径

我来梳理几条常见的泄露路径,你对照自己的实际场景看,就知道这不是杞人忧天。

  • root 过的设备:这是最直接的路径。应用沙盒机制依赖 Linux 权限,但 root 权限可以覆盖绝大多数限制。安装了 root 管理工具的设备,拿到shared_prefs目录下的 XML 文件就是一条cat命令的事。
  • 备份文件提取:Android 的adb backup在旧的安卓版本上可以直接导出应用数据,生成的.ab文件经过简单处理后就能解开。即便新版系统收紧了备份策略,一些厂商定制的 ROM 仍然留有流量口。
  • 设备丢失或送修:手机丢了或者送去维修,对方如果对数据有想法,明文 XML 是最容易得手的部分。尤其很多用户习惯把登录 Token 和账号信息一起存,拿到这些基本等于账号失守。
  • 日志与调试通道:一些开发同学为了排查方便,会把关键字段临时打印到 Logcat 里,或者通过StrictMode等方式把数据输出到调试通道。SharedPreferences明文数据在配合这些调试行为时,泄露概率大大增加。
  • 恶意应用与供应链风险:如果设备装了什么不干净的输入法、桌面或者应用商店,一旦它们获取到了不必要的权限,或者利用系统漏洞越权读取其他应用的数据,明文 XML 就是最先被翻的目标之一。

看到这里你应该明白,SharedPreferences的问题不在于它本身,而在于它把所有数据都裸奔在文件系统里。就好比你把家门钥匙和备用钥匙都挂在了门口挂钩上,锁再结实也没意义。要解决这个问题,核心思路只有一个:在数据落盘之前做加密,确保即便文件被拿走,对方看到的也只是密文。

2. 主流 SP 加密方案横向对比:从自研 AES 到系统安全组件

2.1 自研 AES 加密:简单但坑很多

既然要加密,最自然的想法是:自己写一个工具类,在putString的时候先加密,再存进去;在getString的时候先取出密文,再解密。AES 对称加密算法是首选,因为它的加解密速度够快,密钥管理相对可控。

于是很多项目里会出现类似下面的代码:

public class AesPrefs { private static final String KEY = "aHR0cHM6Ly9naXRodWIuY29tL0JvbBhQaWNr"; // Base64编码的“密钥” public static String encrypt(String value) { try { Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); SecretKeySpec keySpec = new SecretKeySpec(KEY.getBytes(), "AES"); cipher.init(Cipher.ENCRYPT_MODE, keySpec); byte[] bytes = cipher.doFinal(value.getBytes()); return Base64.encodeToString(bytes, Base64.NO_WRAP); } catch (Exception e) { return value; } } }

这个做法表面上实现了加密,但问题非常明显。

第一,密钥硬编码在代码里。攻击者只要反编译 APK,在classes.dex里搜一下字符串常量,密钥就全部暴露了。这相当于把保险箱钥匙粘在了保险箱门上。

第二,加密模式使用有隐患。AES/CBC/PKCS5Padding 只是入门选择,每次加密同一个值时输出固定密文,容易受到重放攻击和密文替换攻击,安全性并不达标。GCM(Galois/Counter Mode)这种带认证的加密模式才是更稳妥的选择,但自己实现起来又要处理 IV、认证标签、密钥派生等细节,稍有不慎就埋雷。

第三,异常处理过于随意。加密失败时直接返回明文,这在调试阶段方便排查,但到了生产环境就是灾难——你以为加密了,实际数据还是明的。

所以我的观点很明确:除非你是密码学专家,否则不要自研这套东西。Android 平台已经提供了成熟的安全存储方案,何必自己造一个四处漏风的轮子?

2.2 第三方加密库怎么选:功能与维护成本的双重考量

如果不想自研,那第三方库自然进入视野。市场上曾经流行过一些封装了 AES 加解密和SharedPreferences存储的库,它们通常提供一套类似原生的 API,开发上手快,接口也顺手。

第三方库的好处是省心,类库作者已经帮你处理了 IV 生成、密钥派生、Base64 编码等琐碎问题。但选型时要问自己三个问题:

  • 库是否还在维护?选一个已经停止维护的库,意味着安全问题没人修复,SDK 版本更新后可能随时崩。
  • 密钥放在哪里?很多第三方库为了兼容老设备,密钥仍然存在应用私有目录里或硬编码在代码中,安全性提升有限。
  • API 是否稳定?一些库频繁改动接口,升级版本后需要大面积改代码,维护成本反而更高。

对比之后不难发现,真正值得长期依赖的加密方案,必须是密钥和算法都由系统安全能力兜底的那种。Android 生态里这个角色就是 Keystore。

2.3 Android Keystore 与官方安全组件:为什么推荐

Android Keystore 是系统级的安全硬件抽象层,它的核心价值在于密钥无法被导出。你可以通过它生成密钥,用它加密数据,但密钥本身始终留在 Keystore 的隔离环境里,即使设备被 root,攻击者也拿不走私钥。这一点是任何纯软件方案都做不到的。

我推荐的落地路径是用 Android 官方提供的 Security 组件。它基于 Keystore 和 Tink 组合,对外暴露了EncryptedSharedPreferences这个类,直接可以以原生SharedPreferences的 API 形式使用。

MasterKey masterKey = new MasterKey.Builder(context) .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) .build(); SharedPreferences prefs = EncryptedSharedPreferences.create( context, "secure_prefs", masterKey, EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM );

这段代码看起来简单,背后做的事情却不少:MasterKey会在 Android Keystore 中生成一个 AES-256 密钥;写数据时,key 会经过 AES-SIV 模式加密,value 会经过 AES-GCM 模式加密;读数据时自动解密。你不需要关心 IV、认证标签、数据格式,这些细节全部由库内部处理。

之所以推荐这个方案,除了安全收益,还有两个实际的考虑:

  • API 兼容性高,EncryptedSharedPreferences实现了SharedPreferences接口,老项目替换成本低。
  • 加密过程默认使用 AES-GCM 这类带认证的加密模式,密文被篡改后会在解密时报异常,避免攻击者悄悄修改本地数据。

唯一需要注意的是,任何加密方案都会引入额外的内存复制和多层序列化开销。好在SharedPreferences本身只适合存少量轻量级数据,加密后的性能损耗对绝大多数应用来说可以忽略。

3. 在 cheesesquare 项目中落地加密 SP:5 分钟实操

3.1 项目结构梳理:确定需要加密的字段

cheesesquare 是一个典型的采用 Material Design 设计风格的示例项目,结构上通常包含数据层、业务层和 UI 层。如果你曾经基于这个项目做二次开发,会发现它里面大量使用了SharedPreferences来做偏好存储,日期、排序方式、用户操作记录等等。和很多真实项目一样,它并没有从一开始就考虑数据加密。

这次改造的切入点是:先全局搜索项目中所有getSharedPreferences、getDefaultSharedPreferences的调用点,按存储内容做分类,判断哪些字段需要加密。

存储内容是否敏感加密必要性
登录 Token / Session ID高度敏感必须加密
用户手机号 / 邮箱敏感必须加密
用户昵称 / 头像 URL低敏感可视情况加密
界面主题 / 语言偏好非敏感可以不加密

我的经验是:宁可多加密几个字段,也不要在“这个字段不重要”的判断上太自信。数据一旦被拖走,影响的往往不是单条数据,而是整个账号体系的安全。

3.2 封装一个可替换的 SecurePrefs 工具类

确定了要加密的字段后,接下来要做的不是在所有调用点把SharedPreferences替换成EncryptedSharedPreferences,而是先封装一个上层工具类。这样做的好处是,将来如果你想换加密方案或者调整算法细节,只需要改一个类,所有调用点都能自动感知。

下面这个SecurePrefs工具类,是 cheeseesquare 项目改造时最核心的部分。

public class SecurePrefs { private static final String PREF_NAME = "cheesesquare_secure"; private final SharedPreferences prefs; public SecurePrefs(Context context) { MasterKey masterKey = new MasterKey.Builder(context) .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) .build(); this.prefs = EncryptedSharedPreferences.create( context, PREF_NAME, masterKey, EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM ); } public void putString(String key, String value) { prefs.edit().putString(key, value).apply(); } public String getString(String key, String defaultValue) { return prefs.getString(key, defaultValue); } public void putBoolean(String key, boolean value) { prefs.edit().putBoolean(key, value).apply(); } public boolean getBoolean(String key, boolean defaultValue) { return prefs.getBoolean(key, defaultValue); } public void remove(String key) { prefs.edit().remove(key).apply(); } public void clear() { prefs.edit().clear().apply(); } }

这段代码值得解释的几个点:

MasterKey.Builder负责在 Android Keystore 中生成或加载主密钥。同一个应用,同一套参数,多次构建得到的是同一个密钥,这样才能保证数据加密后还能正确解密。

EncryptedSharedPreferences.create的参数里,PrefKeyEncryptionScheme.AES256_SIV用于加密 key,PrefValueEncryptionScheme.AES256_GCM用于加密 value。为什么 key 和 value 用不同模式?因为 key 通常很短,SIV 模式在短数据加密和防篡改方面更有优势,而 value 长度不定,GCM 模式可以提供认证加密和完整性验证。

apply()方法在异步写盘的同时,会立即把数据更新到内存缓存中,对于常规 UI 操作是足够的。如果你明确需要在写盘完成后拿到结果,再改用commit(),但这在大项目里通常不建议,因为commit()是同步操作,耗时较长时会造成 UI 卡顿。

3.3 让老项目快速迁移:透明替换策略

cheesesquare 项目这种老代码,最大的痛点在于调用点分散在多个 Activity 和 Fragment 里。如果逐个去改,工作量大不说,还容易漏掉。我建议采用透明替换策略。

所谓透明替换,就是保持原有SharedPreferences的获取方式不变,只替换底层实现。具体做法是写一个PrefsProvider类:

public class PrefsProvider { private static SharedPreferences prefs; public static SharedPreferences get(Context context) { if (prefs == null) { prefs = new SecurePrefs(context).getSharedPreferences(); } return prefs; } }

然后在所有调用点统一改成PrefsProvider.get(context).getString(...)。如果你觉得所有原生的getSharedPreferences调用点都需要动大量代码,也可以在基类里做一层统一处理,比如在 BaseActivity 中暴露protected SharedPreferences getSecurePrefs(),让子类直接调用。

在改造过程中,我强烈建议分两步走:先让所有调用点都切到SecurePrefs,跑一遍全流程回归测试,确认功能没有异常后,再考虑把工具类内部的实现从原生SharedPreferences替换为EncryptedSharedPreferences。这样风险可控,出了问题也能快速定位是哪一步引入的。

3.4 关键代码实操:数据迁移与兼容性细节

替换成加密SharedPreferences之后,有一个很容易被忽略的问题:旧的明文数据怎么办?如果你原来的项目已经在生产环境运行过,用户设备上保存了明文的登录状态和用户信息,直接换成加密实现后,从旧文件里读出来的数据会是乱码或者解密异常,因为旧数据根本没有加密过。

处理方式是在SecurePrefs初始化时增加一段数据迁移逻辑:

private void migrateLegacyPrefs(Context context) { SharedPreferences legacy = context.getSharedPreferences("legacy_prefs", Context.MODE_PRIVATE); if (legacy.contains("access_token")) { String token = legacy.getString("access_token", null); if (token != null) { prefs.edit().putString("access_token", token).apply(); legacy.edit().remove("access_token").apply(); } } }

迁移时注意两点:第一,只迁移确实需要加密的敏感字段,不要一股脑把所有数据都拷过来,避免历史脏数据污染新文件;第二,迁移完成后记得删除旧数据,否则旧文件里仍然留着明文线索。

另外还要关注密钥的版本问题。Android Keystore 中生成的密钥与设备的锁屏状态、系统升级状态都可能有关联。一旦用户改变了锁屏方式,或者在恢复出厂设置后从备份中恢复了应用数据,可能出现密钥失效、数据无法解密的情况。这种情况下,业务侧通常要设计一套“重新登录”的兜底逻辑:检测到解密异常时,清除本地加密数据并引导用户重新登录。

4. 常见问题与排查技巧实录

4.1 解密失败与 Key 丢失:遇到SecurityException怎么办

在实际压测和灰度过程中,最常见的问题就是解密失败。表现是调用prefs.getString时抛出了SecurityException,或者返回的字符串是一串无法解析的字符。

原因大概率有两个方向:

  • Keystore 中的密钥失效或丢失。比如用户在系统设置里清除了应用数据,或者系统更新导致 Keystore 重设了硬件密钥。
  • 原本的数据是用旧密钥加密的,当前新生成的密钥无法解开旧密文。

排查思路也不复杂。先看异常堆栈里是否有KeyPermanentlyInvalidatedException,如果有,那说明密钥已经被系统标记为永久失效,唯一的办法是清掉旧数据重新生成密钥。如果没有这个异常,再检查是不是数据迁移逻辑没有执行,导致新旧数据互相覆盖。

我建议在SecurePrefs中增加一个兜底方法,专门处理解密失败的情况:

public String getStringSafely(String key, String defaultValue) { try { return prefs.getString(key, defaultValue); } catch (Exception e) { // 密钥失效或数据异常,清除并重新初始化 reset(); return defaultValue; } }

这里要注意一个度的问题:不能所有字段都无脑走兜底,否则就把加密的防护意义削弱了。只有那些丢了也无所谓的偏好类字段适合走兜底,Token 这类关键凭证一旦解密失败应该直接走重新登录流程。

4.2 版本升级后的兼容性坑

加密SharedPreferences在不同版本的 Android 系统上表现并不完全一致。我在几个不同厂商的测试设备上跑过,发现主要差异集中在两点。

一是 Android 6.0 以下对 Keystore 的支持力度有限。虽然系统组件会在不支持硬件 Keystore 的老设备上退化为软件 Keystore,但安全性会打折扣。如果项目 minSdk 放得很低,建议只在关键场景使用加密 SP,不要把整套业务都架在上面。

二是部分厂商 ROM 对 Keystore 的备份与迁移机制实现不同。当用户开启云备份或换机迁移时,加密 SP 的数据可能被恢复到了新设备上,但 Keystore 中的密钥没有跟着迁移,导致应用数据全部不可用。遇到这种情况,单靠应用层代码很难完全解决,合理的做法是在应用层增加异常检测与数据清理引导,不要让用户卡在“加密数据读不出来”的死循环里。

另外需要提醒的是,EncryptedSharedPreferences禁用了多进程模式。如果项目里某处用了android:process=":xxx"并尝试跨进程访问同一个 SP 文件,会出现 IllegalStateException,因为加密 SP 的缓存机制是进程内的。遇到多进程需求,更靠谱的方式是转而使用其他跨进程存储方案,或者在网络请求层做数据同步。

4.3 性能开销与 ANR 风险

大家可能担心加密会引入额外的性能消耗。实测下来,单条数据的加解密耗时在 1~2 毫秒左右,如果只是存储 token、用户 ID 这种少量字段,对用户体验几乎无感。

但有两个风险点需要提醒。

第一,不要在读取频繁的路径上反复创建EncryptedSharedPreferences实例。每次创建实例都可能触发密钥加载和初始化,这个过程如果放在循环里或者放在 getView 这种高频调用处,会拖慢性能。正确做法是把实例做单例化,整个应用生命周期只初始化一次。

第二,SharedPreferences的apply()虽然是异步写盘,但加密操作本身是同步的,如果一次性写入大量数据,比如你把一个大的 JSON 字符串直接塞进去,加解密耗时会明显增加,极端情况下还会导致 ANR。我的建议是:保持SharedPreferences存储的数据小而稀疏,超过几十 KB 的复杂数据,请改用数据库或文件存储。

4.4 常见问题速查表

现象可能原因处理方式
解密后返回乱码新旧数据混淆或密钥不一致走迁移逻辑,清空旧明文数据
抛 SecurityExceptionKeystore 密钥失效清除本地数据并重新生成密钥
跨进程访问异常加密 SP 不支持多进程改为单进程访问,或换存储方案
性能突然变差高频创建实例或存储了超大值单例化初始化,控制单条数据大小
从旧版本升级后数据丢失替换了 SP 文件但未做迁移在工具类中实现旧数据迁移逻辑

5. 一点过来人的补充建议

在 cheesesquare 项目的加密改造过程中,我踩了不少坑,最后总结出几条经验,供大家参考。

如果你准备在老项目里引入加密 SP,不要试图一次性把全部SharedPreferences调用点都替换完。分模块做,每替换一个模块就回归测试一轮,这样才能精确定位问题。优先替换登录态、用户信息、支付凭证这类最敏感的数据,偏好设置类字段可以往后放。

另外,无论你用哪种加密方案,请务必在产品层面保留“用户主动退出登录”的入口。因为本地加密数据一旦因为密钥原因不可用,用户能做的只有重新登录,如果应用没有提供这个路径,用户就会困在登录状态无法使用的死局里。

最后再说一句:加密SharedPreferences是提升 Android 应用基础安全水位的好手段,但它不是灵丹妙药。它解决的是“本地静态存储泄露”这一环的风险,至于网络传输层的数据安全、服务端的脱敏和风控,依然要靠各自对应的体系来兜底。对于大部分中小团队来说,先用最低成本把本地明文存储这件事补上,已经是一次非常值得投入的安全加固了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进