Android APP常见安全漏洞

这篇笔记最初写于 2024 年,当时主要按漏洞类型整理。漏洞名可以帮助查漏,但真正落到一个 APK 上,还是要回答五个问题:入口在哪里、谁能调用、输入经过了什么检查、最后触发什么敏感操作、修复后怎样证明合法功能和拒绝路径都正确。

旧文里的案例、命令、四张配图和漏洞分类都保留了下来;涉及旧 Android/WebView 行为的地方加上版本条件,不再把历史默认值直接套到新项目。本文自写的 Kotlin、Java 和 XML 都是用于讲清边界的教学片段,本次没有构建 APK,也没有在 Android 真机或模拟器上运行。文末的 Python 实验是在本机实际执行的,它只验证 URL、SQLite、ZIP 与防重放模型,不代替 Android 框架测试。

概述:先把输入画出来

旧文用下面这张脑图列出了组件、网络、WebView、数据存储和动态加载等攻击面。它仍然适合做目录,但其中“默认设置”和“防护方式”要结合系统版本、targetSdkVersion、WebView 版本以及最终 APK 重新判断。

旧文中的 Android App 常见安全漏洞脑图

旧文配图已本地化保存;原图地址仍保留作出处记录。

原文归纳的五个入口没有变:

  1. 通信协议:本地 IPC、网络协议及其 C/C++ 解析代码,既可能有状态机逻辑错误,也可能出现越界读写、拒绝服务甚至代码执行。
  2. 四大组件:Activity、Service、BroadcastReceiver、ContentProvider 通过 Intent、Binder 或 URI 接收外部输入,常见问题是未授权调用、数据泄露和畸形输入导致崩溃。
  3. 开放端口:应用监听 TCP、UDP 或 Unix socket,却没有调用者认证、消息完整性和资源限制。
  4. IPC:Binder 调用者身份、URI grant、PendingIntent 等能力一旦在错误的时机被放大,就可能越过组件本身的导出限制。
  5. 文件和数据:日志、外部存储、备份、数据库、压缩包、密钥与网络传输都可能让敏感数据离开预期边界。

Android 外部输入经过组件、业务判断,到文件、网络和系统操作的路径

审计时我会先画一条最短数据流:

1
可控输入 → Android 入口 → 身份/权限 → 参数与状态 → 敏感 sink → 可观察结果

例如“Activity 已导出”只是入口事实。若它只展示公开帮助页,风险可能很低;若它接受 orderId 后直接退款,且没有重新检查登录用户与订单归属,才形成可复现的越权。

层次 要留下的证据 常见误判
入口 合并后的 Manifest、动态注册代码、Deep Link、监听地址 只看源码中的一个 Manifest
身份 Binder UID、组件权限、URI grant、服务端会话 信任 extras 中自报的包名或 userId
参数 类型、长度、允许集合、解析器结果 try-catch 后继续执行;只做黑名单
业务授权 对象归属、当前状态、动作范围 有 HTTPS 或随机 ID 就认为不会越权
sink SQL、文件、WebView、Intent 转发、设备控制、升级安装 找到危险 API 就直接报漏洞
结果 未授权读写、崩溃、文件落点、网络请求、系统状态 只展示命令返回成功

版本条件:默认值不是常量

Android 关键行为的版本条件

下面只列本文会用到的边界。这里的“必须”来自平台要求;“建议显式配置”是工程做法,两者不要混在一起。

行为 版本条件 审计含义
带 intent-filter 的 Activity、Service、Receiver 应用 target 31+ 时必须显式声明 android:exported,否则 Android 12+ 上不能正常安装 仍要看合并后的 Manifest;Provider 不属于这条安装要求,但也应显式声明
PendingIntent 可变性 target 31+ 必须给出 FLAG_IMMUTABLE 或 FLAG_MUTABLE 大多数场景选 immutable;确需可变时限制目标和可填字段
动态 Receiver 导出标志 RECEIVER_EXPORTED/NOT_EXPORTED 在 API 33 引入;target 34+ 注册非纯系统广播时必须选择其一 旧文“动态注册默认导出”不能当成现代统一结论
WebView allowFileAccess target 29 及以下默认 true,target 30+ 默认 false 查显式调用和 target,不凭经验猜默认值
file URL 跨域开关 target 15 及以下默认 true,target 16+ 默认 false;相关 setter 在 API 30 弃用 新代码优先 WebViewAssetLoader,旧代码仍要审计残留开关
明文网络 Network Security Config 对 target 28+ 的平台默认是禁止明文 自定义网络库、native 代码和显式放行仍需单独验证
adb backup target 31+ 的应用数据默认不会被 adb backup 导出;云备份与设备迁移另有规则 不能再用一条旧命令概括全部备份面
Intent 重定向 Android 16 增加默认加固 仍需限制嵌套 Intent 的目标、flags 和 URI grant;不能依赖新系统替应用做业务授权

相关平台边界可对照 Android 12 行为变更、Android 14 动态 Receiver 变更、Context.registerReceiver 和 WebSettings。

一次有效复现至少记录:

1
2
3
4
5
6
APK SHA-256 / versionName / versionCode
设备 Android 版本 / API level / 安全补丁级别
minSdk / targetSdk / compileSdk(能取得时)
WebView provider 与版本
构建类型、是否 debuggable、Network Security Config
调用者身份(普通第三方 App、adb shell、root、系统 App)

四大组件

四大组件的问题经常和 android:exported="true" 同时出现,但导出只是“可到达”。组件权限、调用者身份、业务会话、对象归属和参数校验共同决定它能不能被利用。

Intent 畸形输入与本地拒绝服务

旧文列出的典型崩溃包括 NullPointerException、ClassCastException、IndexOutOfBoundsException 和 ClassNotFoundException:攻击者向导出组件发送缺失、类型错误、过长或无法反序列化的 extras,应用在 Intent.getXXXExtra() 之后直接使用,最终崩溃。安全防护、锁屏等 App 若关键进程被反复打崩,影响会比普通页面更大。

只在最外层写一个 catch (Exception) 不够。异常前可能已经写了一半状态,异常后继续走默认分支也可能触发敏感操作。入口应先完成类型、空值、长度和枚举检查,再修改状态:

1
2
3
4
5
6
7
8
9
10
11
12
// 教学片段:本次未在 Android 设备编译运行
private fun parseExportRequest(intent: Intent): ExportRequest? {
val id = intent.getStringExtra("report_id")
?.takeIf { it.matches(Regex("[A-Za-z0-9_-]{1,64}")) }
?: return null

val format = intent.getStringExtra("format")
?.takeIf { it in setOf("pdf", "json") }
?: return null

return ExportRequest(id, format)
}

Parcelable/Serializable 还涉及类加载和对象图大小。对外入口只接收确实需要的简单类型;API 支持时使用带目标类型的读取方法。还要测试超长字符串、大数组、重复 key 与空 Bundle,避免 Binder 事务过大或解析消耗成为另一条拒绝服务路径。

Activity:从“能打开”追到业务动作

导出与任务栈钓鱼

旧文讨论了 Activity 历史栈、AMS 调度和 FLAG_ACTIVITY_NEW_TASK:恶意 App 把伪装登录页或带任意 URL 的 WebView 放到前台,可能造成钓鱼。这个场景应保留,但现代结论不能简化成“加一个 flag 就能任意劫持”。还要结合 taskAffinity、launchMode、allowTaskReparenting、最近任务显示方式、系统版本以及用户实际看到的来源提示复现。

防护不是简单地在 App 进入后台时弹提示。更可靠的做法是:

  • 内部页面明确 exported="false";对外页面只暴露必要功能。
  • Deep Link 到达敏感页面后重新检查会话和对象归属,不把“从首页走过一遍”当授权。
  • 登录、支付、密钥导出等页面显示可核对的账户与动作上下文,切回前台时按风险重新认证。
  • WebView Activity 不接受任意 http/file/content/javascript URL。
  • 审查异常 taskAffinity 和任务栈配置,不把旧系统上的 task hijacking 条件泛化到所有设备。

最小到达性验证可以这样写:

1
2
adb shell am start -W -n com.example.notes/.ReportActivity \
--es report_id B-1007 --es format pdf

这只能说明 adb shell 身份能够启动它。shell 的权限和普通第三方 App 不相同,涉及权限与 Binder UID 时要用一个最小测试 APK 再调用一次。随后观察未登录状态、其他账户资源 ID、畸形参数以及合法参数分别发生什么。

显式 Intent、隐式 Intent 与敏感数据

显式 Intent 直接给出组件名;隐式 Intent 描述 action、data 和 category,由系统匹配能处理它的组件。多个处理者匹配时可能出现选择器。旧文用下面的图说明了隐式 Intent 可能落到其他 App 的公开 Activity:

隐式 Intent 被其他应用组件匹配的示意图

原图地址。图里的旧式选择器界面仅作机制示意,实际 UI 随系统版本变化。

对明确合作方发送敏感信息时,优先设置具体 component 或至少 setPackage(),并确认接收方签名/权限模型。系统 chooser 只让用户选处理者,不保证每个候选都可信。能不放进 Intent 的 token、明文凭据和完整个人信息就不要放;确需共享文件时使用窄范围 content:// URI grant,并及时撤销。

接收 Activity 也不能把 getCallingActivity()!=null、Referer 或某个可伪造 extra 当身份认证。对外 Deep Link 的 orderId、redirect、next 之类参数,都要在当前会话下重新解析和授权。Android Deep Link 风险说明也强调了入口参数与登录/授权检查。

Service:线程和权限是两回事

Service 可以由组件启动,也可以通过 bindService() 暴露客户端—服务端接口。旧文中“启动后可无限期后台运行”的描述适用于早期的简化模型;现代 Android 还有后台执行与前台服务限制。Service 默认也运行在应用主线程,长时间网络或文件操作必须自行调度。

导出的 Service 若没有限制,其他 App 可能启动任务、绑定 Binder、读取返回值或污染内部状态。审计要分别走:

  1. onStartCommand() 收到的 Intent;
  2. onBind() 返回了哪个 Binder;
  3. 每个 Binder 方法在什么时机鉴权;
  4. 是否调用 clearCallingIdentity();
  5. 是否把任务转给线程池后才尝试读取原始调用者身份。

导出组件的调用身份、授权和敏感操作顺序

下面的核心是先在 Binder 调用语境中取 UID 并完成授权,之后才清理身份或排队:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 教学片段:本次未在 Android 设备编译运行
override fun exportReport(reportId: String): ParcelFileDescriptor {
val callingUid = Binder.getCallingUid()
context.enforceCallingPermission(
"com.example.notes.permission.EXPORT_REPORT",
"missing export permission"
)
require(reportId.matches(Regex("[A-Za-z0-9_-]{1,64}")))

val principal = callerDirectory.principalForUid(callingUid)
?: throw SecurityException("unknown caller")
val report = repository.findOwnedBy(reportId, principal.accountId)
?: throw SecurityException("report not owned by caller")

val token = Binder.clearCallingIdentity()
return try {
exporter.openReadOnly(report)
} finally {
Binder.restoreCallingIdentity(token)
}
}

不要信任参数里自报的包名。一个 UID 可能对应需要特殊处理的包集合,旧项目还可能使用 shared UID。准确的版本边界是:Manifest 的 android:sharedUserId 在 API 29 弃用;API 33 又引入 android:sharedUserMaxSdkVersion,让应用可以阻止较新系统上的新安装继续加入 shared UID。两者不是同一项变更,历史 App 的既有安装也仍需兼容分析。Manifest <manifest> 元素列出了这两个属性的 API 条件。signature 级自定义权限适合同一签名合作 App:

1
2
3
4
5
6
7
8
<permission
android:name="com.example.notes.permission.EXPORT_REPORT"
android:protectionLevel="signature" />

<service
android:name=".ReportExportService"
android:exported="true"
android:permission="com.example.notes.permission.EXPORT_REPORT" />

旧文示例里的 android:permission:"android.perrmission.SEND SMS" 既有 XML 语法/拼写问题,也把 dangerous 权限和合作方信任混在了一起,因此这里改成完整的 signature 权限声明。组件权限控制调用入口,不替代用户登录和对象授权。导出组件权限控制给出了同一边界的官方说明。

BroadcastReceiver:发送面和接收面分开看

广播发送者产生 Intent,系统按显式目标或 IntentFilter 分发给接收者。Manifest 静态注册和 Context.registerReceiver() 动态注册都可能出现两类问题:

  • 发送不受限:恶意接收者嗅探敏感 extras,或在有序广播中截断/修改结果。
  • 接收不受限:恶意发送者伪造状态、触发任务、传入畸形数据造成崩溃。

旧文说“动态注册默认导出”,这在现代版本上过于笼统。API 33 引入 RECEIVER_EXPORTED/RECEIVER_NOT_EXPORTED;target 34+ 注册非纯系统广播时必须显式选择。仅在进程内部传播状态时,直接使用回调、Flow/LiveData 等进程内机制。LocalBroadcastManager 已经弃用,因此保留为旧项目识别点,不再推荐给新代码。弃用说明

1
2
3
4
5
6
7
// 教学片段:API/AndroidX 调用需按项目 minSdk 适配
ContextCompat.registerReceiver(
context,
syncReceiver,
IntentFilter(ACTION_SYNC_FINISHED),
ContextCompat.RECEIVER_NOT_EXPORTED
)

需要接收合作 App 广播时,可以要求发送者持有 signature 权限,同时对 action、extras 和业务状态做校验。发送给明确合作方时使用显式 component 或 setPackage();敏感数据尽量走受权限保护的 Binder/API,而不是全局广播。

ContentProvider:URI、SQL 与文件是三道门

ContentProvider 负责跨进程共享结构化数据或文件。旧文示意图准确表达了“调用进程—Provider—数据源”的关系:

ContentProvider 在进程和数据源之间传递数据

原图地址。

原文列出的三类问题都要保留,并且要分别复现:

  1. 信息泄露:Provider 导出或 URI grant 过宽,外部调用者能读取敏感行/文件。
  2. SQL 注入:调用者控制的 selection、列、排序等被拼进 SQL,越过表或权限边界。
  3. 目录遍历:openFile() 将 URI 片段直接拼成文件路径,第三方 App 读取或覆盖允许目录之外的文件。

ContentProvider 查询和文件接口的完整检查链

入口与权限

先看合并后的 Provider 声明:

1
2
3
4
5
6
7
<provider
android:name=".ReportsProvider"
android:authorities="com.example.notes.reports"
android:exported="true"
android:readPermission="com.example.notes.permission.READ_REPORTS"
android:writePermission="com.example.notes.permission.WRITE_REPORTS"
android:grantUriPermissions="false" />

readPermission 和 writePermission 应按能力拆开;若只需要临时共享单个文件,通常让内部 Provider 保持不导出,通过精确 URI grant 或 FileProvider 分享更容易收口。还要检查 <path-permission>、grantUriPermissions、FLAG_GRANT_PREFIX_URI_PERMISSION、持久 grant 以及撤销时机。

旧文提醒“API level 低于 8 时即使写 exported=false 仍可能被访问”。这条保留作历史线索,但现代审计不能从它推出当前设备上 exported=false 无效。实际支持范围应由 APK 的 min/target、Manifest 合并结果和目标设备验证;对现代项目直接显式声明,避免依赖不同年代的默认值。

SQL:绑定值、限制结构、追加归属

危险实现通常类似:

1
2
// 反例:selection 的 SQL 结构完全由外部调用者决定
db.rawQuery("SELECT * FROM reports WHERE $selection", selectionArgs)

只把 selectionArgs 分开,不代表任意 selection 就安全。对不可信调用者,Provider 自己定义 URI 语义和 WHERE 结构;外部只提供值。列名、表名、排序方向无法作为普通值参数绑定,要映射到允许集合:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// 教学片段:本次未在 Android 设备编译运行
private val allowedProjection = mapOf(
"_id" to "_id",
"title" to "title",
"updated_at" to "updated_at"
)

override fun query(
uri: Uri,
projection: Array<out String>?,
selection: String?,
selectionArgs: Array<out String>?,
sortOrder: String?
): Cursor {
val caller = authenticatedCaller(Binder.getCallingUid())
val id = ContentUris.parseId(uri)

val columns = (projection ?: arrayOf("_id", "title"))
.map { allowedProjection[it] ?: throw IllegalArgumentException("bad column") }
.toTypedArray()
val order = when (sortOrder) {
null, "newest" -> "updated_at DESC"
"oldest" -> "updated_at ASC"
else -> throw IllegalArgumentException("bad sort")
}

return db.query(
"reports",
columns,
"_id = ? AND owner_id = ?",
arrayOf(id.toString(), caller.accountId),
null,
null,
order
)
}

这里的 owner_id = ? 才是对象级授权。参数化查询阻止值变成 SQL 语法,却不会自动判断 Alice 能否读 Bob 的记录。官方 SQL 注入说明也把 replaceable parameter 作为值绑定方案;Provider 还要根据自身共享模型收紧 projection、selection 与表边界。

本机实验用内存 SQLite 实际跑了同一差异:

1
2
3
4
payload: alice' OR 1=1 --
字符串拼接: 返回 alice、bob 两行
参数绑定: 返回 0 行
id=2 + authenticated_owner=alice: 返回 0 行

实验脚本见 android_boundary_lab.py。这是 SQLite 后端模型,不是 Android ContentResolver 实机结果。

openFile():不要让 URI 直接变成路径

更稳妥的设计是把 URI 中的业务 ID 查询成应用自己保存的相对路径,而不是接受外部文件名。即便如此,也要限制打开模式并验证最终规范路径:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 教学片段:本次未在 Android 设备编译运行
override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor {
if (mode != "r") throw FileNotFoundException("read only")

val caller = authenticatedCaller(Binder.getCallingUid())
val exportId = uri.lastPathSegment
?.takeIf { it.matches(Regex("[A-Za-z0-9_-]{1,64}")) }
?: throw FileNotFoundException("bad id")
val relative = repository.ownedExportPath(exportId, caller.accountId)
?: throw FileNotFoundException("not found")

val root = File(requireContext().filesDir, "exports").canonicalFile
val target = File(root, relative).canonicalFile
val prefix = root.path + File.separator
if (!target.path.startsWith(prefix) || !target.isFile) {
throw FileNotFoundException("outside export root")
}
return ParcelFileDescriptor.open(target, ParcelFileDescriptor.MODE_READ_ONLY)
}

目录前缀必须带分隔符,否则 /safe-other 可能被误认成 /safe 的子目录。目标目录还应由应用独占,避免符号链接和并发替换;调用权限、URI grant、行级归属、文件模式和路径边界缺一不可。

可以在授权测试设备上用下面的命令观察 Provider 行为:

1
2
3
4
5
6
adb shell content query \
--uri content://com.example.notes.reports/reports/7 \
--projection _id:title:updated_at

adb shell content read \
--uri content://com.example.notes.reports/exports/demo-7

adb shell 仍不是普通第三方 App;权限拒绝、临时 grant 和 Binder UID 的结论必须用对应身份复测。

原文四大组件防护清单,按现代边界校正

原文的要点没有删,整理后可以作为 review checklist:

  • Activity:谨慎处理 Intent 和返回数据;私有页面不导出;目标明确时使用显式 Intent;不要发送不必要的敏感 extras;合作方访问用 signature 权限或可验证的信任关系;对外页面仍做会话、对象归属和参数检查。
  • Service:内部 Service 不设无用的 intent-filter 并显式不导出;显式启动;合作 Binder 校验权限/UID;在每次 onStartCommand、onBind 和 Binder 方法入口鉴权;限制返回数据;不要只在 onCreate 检查一次。
  • ContentProvider:无需共享就不导出;需要共享时拆分读写权限;所有外部参数均视为不可信;SQL 值绑定、结构白名单、行级授权分别落实;避免把外部文本交给 execSQL();文件接口单独检查 URI grant、模式和规范路径。
  • Receiver:私有 Receiver 设为不导出;动态注册选择导出标志;公开 Receiver 限制发送者并验证消息;敏感广播指定接收者;不再把已弃用的 LocalBroadcastManager 当成新项目方案。

Intent 转发和 PendingIntent

嵌套 Intent 转发

常见危险入口是导出的 Activity/Service 从 extras 取出另一个 Intent,然后直接 startActivity()、startService() 或 sendBroadcast():

1
2
3
// 反例:外部调用者控制目标、data、clipData 和 URI grant flags
val forward = intent.getParcelableExtra<Intent>("next")
startActivity(forward)

攻击者可能借应用身份访问本来不导出的组件,或把读写 URI 权限带到非预期目标。修复时先判断业务是否真的需要通用转发;如果只允许两个页面,直接把外部枚举映射到内部显式 Intent 最清楚:

1
2
3
4
5
6
7
8
9
// 教学片段:本次未在 Android 设备编译运行
val destination = when (intent.getStringExtra("destination")) {
"help" -> Intent(this, HelpActivity::class.java)
"settings" -> Intent(this, PublicSettingsActivity::class.java)
else -> return finish()
}
destination.replaceExtras(Bundle())
destination.flags = 0
startActivity(destination)

确需接收嵌套 Intent 时,用固定 component/package 允许集合,清理 FLAG_GRANT_READ_URI_PERMISSION、FLAG_GRANT_WRITE_URI_PERMISSION、FLAG_GRANT_PERSISTABLE_URI_PERMISSION、FLAG_GRANT_PREFIX_URI_PERMISSION,限制 data/type/ClipData/extras,并考虑 AndroidX IntentSanitizer。Android 16 的默认加固降低部分风险,但不能替代应用自己的目标允许集合和业务授权。Intent Redirection列出了 flags 和 IntentSanitizer 的处理方式。

修复验证至少包含:

  1. 允许目标仍能正常打开;
  2. 指向私有 Activity 的嵌套 Intent 被拒绝;
  3. 带 URI grant、ClipData 和异常 flags 的输入被清理/拒绝;
  4. Android 16 与较低测试版本都执行应用自身策略。

PendingIntent:受托能力、可变字段和重放

PendingIntent 是系统维护的 token,接收者可以让创建者身份执行预定义动作。风险通常来自三处:

  • 内层 Intent 隐式或字段未填完整,同时 PendingIntent 可变,接收方可以 fillIn() 改目标或 data。
  • 多个语义不同的 PendingIntent 因 requestCode/action/data 相同而被系统视为同一份,旧 extras 被复用或覆盖。
  • 本应一次性的能力可被重复触发。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 教学片段:本次未在 Android 设备编译运行
val inner = Intent(context, ConfirmPaymentActivity::class.java).apply {
action = "com.example.notes.CONFIRM_PAYMENT"
data = Uri.parse("app://payment/$paymentId") // 参与 Intent identity
putExtra("payment_id", paymentId)
}

val pending = PendingIntent.getActivity(
context,
paymentId.hashCode(),
inner,
PendingIntent.FLAG_IMMUTABLE or
PendingIntent.FLAG_ONE_SHOT or
PendingIntent.FLAG_UPDATE_CURRENT
)

FLAG_IMMUTABLE 阻止接收方填充字段,但创建者仍可通过 FLAG_UPDATE_CURRENT 更新自己创建 token 的 extras;它不是“对象永远不变”。FLAG_ONE_SHOT 适合只允许触发一次的能力,但最终页面仍应检查 payment 当前状态、账户归属和服务端幂等,不能把 token 本身当成完成支付的唯一授权。官方 PendingIntent 风险说明也单独列出了 mutable 与 replay 两类问题。

默认设置:以发布 APK 为准

合并后的 Manifest

库 Manifest、product flavor、构建占位符和 manifest merger 都可能改变最终声明。审计源码时同步检查 release APK:

1
2
3
4
5
6
7
8
# Android SDK build-tools
aapt2 dump xmltree app-release.apk --file AndroidManifest.xml

# Android Studio command-line tools
apkanalyzer manifest print app-release.apk

# 记录样本身份
sha256sum app-release.apk

重点搜 debuggable、testOnly、allowBackup、dataExtractionRules、usesCleartextTraffic、networkSecurityConfig、组件 exported、组件权限、Provider authority 与 URI grant。android:debuggable="true" 会扩大调试与数据观察面;结论应来自最终发布包,而不是 Gradle 文件中的期望值。

备份不是一条 allowBackup 就结束

旧文记录了 Android 2.1 以后 allowBackup 默认风险,并用 adb backup/restore 解释数据导出。这是早期 Android 上重要的检查点,但现在需要拆成:

  1. 旧设备上的 adb backup;
  2. Auto Backup 云备份;
  3. 设备到设备迁移(D2D);
  4. 自定义 BackupAgent;
  5. 恢复后的 token、设备密钥和账户状态是否仍然有效。

Android 官方说明:Auto Backup 的 allowBackup 默认值仍是 true;target 31+ 在 Android 12+ 上使用 data-extraction-rules,同时还要为 Android 11 及以下保留旧规则。部分厂商设备上,allowBackup="false" 可能关闭云备份却不关闭 D2D,因此敏感数据应通过明确的 include/exclude 规则和恢复后失效策略一起处理。Auto Backup 文档

1
2
3
4
5
<!-- AndroidManifest.xml,教学配置片段 -->
<application
android:allowBackup="true"
android:fullBackupContent="@xml/backup_rules_legacy"
android:dataExtractionRules="@xml/data_extraction_rules" />
1
2
3
4
5
6
7
8
9
10
11
12
<!-- res/xml/data_extraction_rules.xml,Android 12+ 示例 -->
<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<cloud-backup disableIfNoEncryptionCapabilities="true">
<exclude domain="sharedpref" path="session.xml" />
<exclude domain="database" path="device_keys.db" />
</cloud-backup>
<device-transfer>
<exclude domain="sharedpref" path="session.xml" />
<exclude domain="database" path="device_keys.db" />
</device-transfer>
</data-extraction-rules>

是否允许备份是产品决策。更重要的是,长期 refresh token、设备绑定私钥和防重放状态不要随着普通偏好一起迁移;恢复后服务端应能识别新设备状态并要求重新认证。

网络:TLS、调用认证和对象授权逐层验证

TLS 证书链与主机名校验

旧文列出的风险仍然成立:

  • 自定义 X509TrustManager.checkServerTrusted() 为空,或无条件接受证书;
  • HostnameVerifier.verify() 总返回 true,或使用历史上的 ALLOW_ALL_HOSTNAME_VERIFIER;
  • WebView 的 onReceivedSslError() 直接调用 proceed();
  • 明文 HTTP 或调试 CA 配置进入 release;
  • 证书颁发机构或应用自管密钥失陷。

这些问题会让中间人读取或修改账号密码、聊天内容、地址、电话、支付信息,也可能把升级地址和页面内容换成恶意版本。正常业务优先使用平台/成熟网络库的默认验证,不要为了抓包长期保留空 TrustManager。WebView 遇到证书错误应取消加载,而不是静默继续。

Network Security Config 能把系统 CA、调试 CA 和明文策略分开:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!-- res/xml/network_security_config.xml,教学配置片段 -->
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="false">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
<debug-overrides>
<trust-anchors>
<certificates src="@raw/debug_ca" />
</trust-anchors>
</debug-overrides>
</network-security-config>
1
2
3
<application
android:networkSecurityConfig="@xml/network_security_config"
android:usesCleartextTraffic="false" />

官方 Network Security Configuration说明了三个容易忽略的条件:target 28+ 的平台默认禁止明文;target 23 及以下默认还信任用户安装 CA;debug-overrides 只在 debuggable=true 时生效。使用 native socket、自带 TLS 栈或忽略平台策略的第三方库时,还要验证具体实现是否遵守配置。

证书 pinning 不是默认必选项。若业务确实需要,至少准备 backup pin、密钥轮换和失效恢复方案;否则一次证书/CA 变更可能让旧客户端永久离线。pinning 也不能替代主机名校验、业务登录和服务端授权。

怎样复测修复

在授权测试环境中准备 release 等价构建和 debug 构建:

  1. 正常证书、正确主机名应成功;
  2. 自签证书、错误主机名、过期证书应失败;
  3. debug CA 仅 debug 构建成功,release 构建失败;
  4. HTTP URL 和 HTTPS→HTTP 重定向应按策略拒绝;
  5. WebView SSL 错误不应出现 proceed() 后继续展示的页面。

不要只根据代理“抓不到包”判断安全;应用可能使用 pinning、QUIC、native 库,也可能根本没有发出请求。记录 Logcat、网络错误、目标地址和服务端日志,说明实际发生了什么。

HTTPS 之后仍有对象级越权

HTTPS 保护传输,但订单归属仍由服务端判断

旧文“业务接口是否存在任意权限调用”指的就是这一层:敏感接口如果只相信请求里的 user_id、订单号或设备号,攻击者即使无法窃听 TLS,也可以用自己的合法会话请求别人的资源。

最清楚的验证方法是准备两个测试账户 A、B:

  1. A、B 分别创建一条对象并记录 ID;
  2. 带 A 的认证信息读取、修改、删除、导出 B 的对象;
  3. 再测批量接口、子资源、分享链接和历史版本;
  4. 把 ID 换成随机 UUID 后重复;
  5. 修复后确认 A→B 全部拒绝,A→A 仍正常。

服务端查询把认证主体放进条件:

1
2
3
4
SELECT id, title, status
FROM reports
WHERE id = :requested_id
AND owner_id = :authenticated_user_id;

:authenticated_user_id 来自验证过的会话或 token,而不是请求体自报字段。随机 ID 降低枚举概率,HTTPS 保护传输;两者都不是授权。

Socket 远程连接与本地监听

旧文的结论是:App 开放端口但没有发送者认证或权限控制时,攻击者可能获得该端口背后的全部功能。这个检查仍然有价值,但“开放”要精确到监听地址和命名空间:

1
2
3
adb shell ss -lntup
adb shell ss -lnup
adb shell cat /proc/net/unix

不同 Android 版本、SELinux 策略和 shell 权限可能隐藏进程信息。127.0.0.1:port 不对局域网开放,但设备上的其他 App 通常仍能访问 loopback;0.0.0.0:port 还要结合网络路由、防火墙与权限验证。Unix domain socket 要看文件权限或 abstract namespace 名称,不要只检查 TCP。

沿服务端代码继续确认:

  • 每条连接如何认证,token 是否可重放;
  • 长度字段、帧边界、字符编码和状态机是否有上限;
  • 并发连接、空闲超时、单连接内存和文件写入是否受限;
  • 管理命令是否与普通数据通道隔离;
  • 身份校验失败后是否仍执行了一部分操作;
  • C/C++ 解析器是否对整数溢出、越界和生命周期做了防护。

“系统版本不影响此漏洞”的旧说法太绝对。应用协议缺陷由 App 引入,但网络命名空间、后台限制、SELinux 与平台 API 会影响可达性和利用条件,报告中要写实测环境。

APK 升级、签名与动态加载

原升级链路的三个风险点

旧文的表格保留,并补上最终验证:

阶段 原文隐患 可能结果 现在还要验证
升级 API API 未加密 返回恶意下载地址 TLS、服务端认证、元数据签名、版本与通道
下载 API 下载未加密 APK 内容或路径被替换 私有临时文件、大小、可信 SHA-256、完整写入
程序安装 本地路径可篡改 安装错误 APK 包名、签名证书沿革、versionCode、防回滚、PackageInstaller 结果

APK 升级链和业务请求防重放检查

如果 APK 和“正确 hash”来自同一个可篡改 HTTP 响应,攻击者可以同时替换两者。hash 必须由已经认证的元数据绑定,或者依赖平台安装时对包签名身份的校验。下载完成后先在应用私有目录验证,安装成功前不要覆盖当前可用包或插件。

从 v1 的 META-INF 到现代签名方案

旧文详细记录了 v1/JAR 签名结构,这部分对分析老 APK 仍有用:

  1. META-INF/MANIFEST.MF 保存各文件的 SHA-1 或 SHA-256 摘要及 Base64 编码;
  2. CERT.SF 保存 Manifest 整体和各 section 的摘要;
  3. CERT.RSA、CERT.DSA 或 CERT.EC 是 PKCS#7 数据,包含对 CERT.SF 的签名以及证书、公钥、算法、有效期等信息;
  4. ZIP 中可以出现多个签名文件,因此“看到了一个 CERT.RSA”不能单独证明 APK 身份。

这只描述 v1。Android 7.0 引入 v2,Android 9 引入 v3;v2/v3 把签名块放在 APK 中并覆盖更完整的文件内容,不能再只盯着 META-INF。AOSP 的 APK Signing说明了 v1/v2/v3 的版本与覆盖范围。v4 用于增量安装,签名存放在 APK 旁边的 .idsig 文件;它要求 APK 已有相同 key 的 v2/v3 签名,不能替代包内签名。

1
apksigner verify --verbose --print-certs app-release.apk

记录每个包内 scheme 的验证结果、Signer certificate DN、SHA-256 digest、包名和 versionCode;交付链使用 v4 时再连同 .idsig 核对。apksigner 的 命令文档还区分了 v3.1 key rotation:默认的轮换 signing key 面向 Android 13/API 33+,较低版本继续使用旧 key,除非构建时明确调整 rotation min SDK。知道证书或公钥指纹不等于拥有私钥;真正的升级关系由安装系统根据已有包的签名身份/证书沿革判断。Google Play App Signing 的 signing key、upload key 和 key upgrade 也不能混为一谈。App signing 文档说明:系统升级时会比较新旧包证书;Play 的 signing key 升级在不同 Android 版本上还可能使用不同签名密钥。

对自定义插件、DEX/JAR/SO 热更新,系统 PackageInstaller 的包级校验不再自动保护每个文件。应用必须把插件版本、允许的宿主版本、hash/签名、来源和防回滚状态放进同一可信策略;校验完成前不得加载。仅检查文件后缀或下载 URL 白名单没有意义。

加密、密钥和重放

原文中的弱算法清单如何使用

原文列出 MD5、DES、RC2、TripleDES、SHA-1、RIPEMD-160。保留这些名字,但判断时要先问“算法用来做什么”:

  • MD5、SHA-1 的碰撞弱点使其不适合安全完整性和数字签名;用于非对抗性的缓存键,与用于验证更新包不是同一风险。
  • DES 密钥空间过小;RC2、TripleDES 都不应继续用于新设计。
  • 普通 SHA-256 不能替代密码哈希/KDF,也不能提供消息来源认证;认证消息用 HMAC 或数字签名。
  • Base64 是编码,不是加密;把固定 AES key 写进 APK 再混淆,也不等于密钥安全。

Android 官方 Cryptography推荐的现代原语包括 SHA-2、HMAC-SHA-2 和 AES/GCM;具体协议仍要处理 nonce、密钥轮换、错误返回与数据格式。

AES-GCM 与 Android Keystore

下面演示本地加密记录的基本形状:密钥生成在 Android Keystore,GCM IV 由 Cipher.init(ENCRYPT_MODE, key) 生成并和密文一起保存,解密时验证 tag。代码是教学片段,本次未构建或在 Android 设备运行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
private const val KEY_ALIAS = "local-records-v1"

fun createKeyIfMissing() {
val store = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
if (store.containsAlias(KEY_ALIAS)) return

val generator = KeyGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_AES,
"AndroidKeyStore"
)
generator.init(
KeyGenParameterSpec.Builder(
KEY_ALIAS,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setRandomizedEncryptionRequired(true)
.build()
)
generator.generateKey()
}

fun encrypt(plain: ByteArray, aad: ByteArray): ByteArray {
val store = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
val key = store.getKey(KEY_ALIAS, null) as SecretKey
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE, key)
cipher.updateAAD(aad)
val ciphertextAndTag = cipher.doFinal(plain)
return byteArrayOf(cipher.iv.size.toByte()) + cipher.iv + ciphertextAndTag
}

真实格式要限制 IV 长度、版本化 envelope,并在解密前验证长度;AAD 可以绑定记录类型、账户或 schema 版本。不要重用同一密钥下的 GCM nonce。Keystore 能让 key material 保持不可导出,并可限制密钥用途;进程被控制时,攻击者仍可能调用应用已有的解密功能,因此业务授权和最小数据暴露依然必要。Android Keystore

加密不阻止重放

攻击者不需要解密,只需再次发送一份先前合法的密文或签名请求。单独加时间戳也挡不住时间窗口内重放。一个可审计的请求认证格式至少绑定:

1
2
3
4
5
6
HTTP method
canonical path + query
timestamp
随机 nonce
SHA-256(body)
认证主体 / key id

服务端先验证认证标签与时间窗口,再把 (subject, nonce) 以原子唯一约束写入带 TTL 的共享存储,最后在事务中检查对象归属和状态并执行一次。多实例服务只在单机内存放 Set,切换节点就能绕过。幂等键可以让客户端安全重试,但必须绑定账户、操作和响应,不能成为跨账户通用通行证。

本机实验实际执行的结果:

1
2
3
4
第一次提交 nonce-001: accepted
原样再次提交: replay
改 path 但沿用旧 tag: bad-authenticator
超过 60 秒窗口: stale

脚本以 HMAC-SHA256 模拟请求认证,不代表特定生产协议;测试 key 是固定 fixture,nonce 存储也只是内存模型。完整代码和运行方法见 android_boundary_lab.py 与 android-labs-README.md。

WebView:URL、origin 和 Native Bridge

旧文把 WebView 风险分成远程代码执行、UXSS、设置错误、忽略证书错误和 file 域同源绕过。这五类都保留,但分析顺序改成“谁能控制 URL—最终加载哪个 origin—页面能执行什么脚本—脚本能调用什么原生能力”。

WebView 导航、重定向和 Native Bridge 的信任边界

设置项与准确的历史条件

设置 旧文关注点 现在的判断方式
setJavaScriptEnabled() JS 使页面能执行脚本,默认 false 仅业务需要时开启;同时审计内容来源和 bridge
setPluginState() ON / ON_DEMAND / OFF,旧 WebView 默认 OFF API 18 已弃用,作为老项目线索,不用于现代修复建议
setAllowFileAccess() 允许 file 访问 target ≤29 默认 true,target ≥30 默认 false;仍建议按业务显式配置
setAllowContentAccess() 允许 content:// 默认 true;不需要就关闭,需使用时约束 URI 来源
setAllowFileAccessFromFileURLs() file 页面读其他 file target ≤15 默认 true,target ≥16 默认 false;API 30 弃用
setAllowUniversalAccessFromFileURLs() file 页面跨到任意 origin 同上;API 30 弃用,官方建议 WebViewAssetLoader
setSavePassword() 早期 WebView 将密码保存到 webview.db API 18 已弃用;旧设备/旧引擎仍可作为历史检查点,不能当现代统一默认
mixed content HTTPS 页面加载 HTTP 子资源 target Lollipop+ 默认 MIXED_CONTENT_NEVER_ALLOW,检查代码是否放宽

旧文提到密码可能明文保存在 /data/data/<package>/databases/webview.db,root 后被读取。这是早期 WebView 的典型风险,因此保留并标明历史范围;现代审计应直接查看实际 WebView 版本、应用自己的 credential storage 和页面 autofill 行为,而不是只搜索一个旧数据库名。

一个只展示静态可信内容且不需要脚本的页面可以明确收紧:

1
2
3
4
5
6
7
// 教学片段:本次未在 Android 设备编译运行
webView.settings.apply {
javaScriptEnabled = false
allowFileAccess = false
allowContentAccess = false
mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW
}

业务确实依赖 JS 时,不应为了通过扫描器直接关闭功能;要继续限制内容来源、重定向和 bridge 权限。

File 域与同源策略绕过

旧文描述的历史利用链是:WebView 启用 JavaScript 和 file 访问,外部可控 HTML 借延时与符号链接切换读取应用私有文件,再把结果送出。早期版本里 setAllowFileAccessFromFileURLs(true) 或 setAllowUniversalAccessFromFileURLs(true) 会显著扩大后果。

修复时同时处理入口和能力:

  1. 含 WebView 的 Activity 不必要就不导出;
  2. 不加载外部可写目录中的 HTML;
  3. 不需要 file/content scheme 就显式关闭;
  4. 本地静态资源使用 WebViewAssetLoader 映射到受控 HTTPS origin;
  5. 不可信内容加载前移除 JavaScript interface;
  6. 更新系统 WebView,并验证厂商定制接口。

旧文只写 setAllowFileAccess(false) 或关闭 JS 是必要但不总是充分的:应用若把私有内容先复制到共享目录,或 bridge 本身提供通用 readFile(path),攻击面仍然存在。

URL 白名单:解析完整 origin

旧文保留了一个历史解析差异案例:

1
http://192.168.0.11\.163.com/2.html

当时某些 Android URL 解析/WebView 组合可能对反斜杠作不同解释,导致 getHost() 判断结果与实际导航目标不一致。原文还给过基于站内跳转参数的示例:

1
http://mbs.hao.163.cn/?c=redirect&ur=http://tu.623.cn/7vNo

这些地址只作为旧案例保留,不表示今天仍可访问或所有 Android/WebView 都有相同解析结果。现代测试要对当前应用实际使用的解析器和 WebView 版本构造用例,不能把“getHost 本身有漏洞”当作普遍结论。

策略上至少解析并完整比较 scheme、host、port 和 userinfo,拒绝控制字符与反斜杠,对每次重定向重新判断:

1
2
3
4
5
6
7
8
9
10
// 教学片段:本次未在 Android 设备编译运行
private fun allowedDocsUrl(raw: String): Boolean {
if (raw.any { it.code <= 0x20 || it.code == 0x7f } || '\\' in raw) return false
val uri = runCatching { Uri.parse(raw) }.getOrNull() ?: return false
val port = uri.port
return uri.scheme.equals("https", ignoreCase = true) &&
uri.host.equals("docs.example.com", ignoreCase = true) &&
uri.userInfo == null &&
(port == -1 || port == 443)
}
输入 该策略结果 原因
https://docs.example.com/help 允许 origin 精确匹配
https://DOCS.EXAMPLE.COM:443/help 允许 host 大小写不敏感、标准 TLS 端口
https://docs.example.com.evil.test/ 拒绝 host 不相等
https://docs.example.com@evil.test/ 拒绝 实际 host 是 evil.test,且有 userinfo
http://docs.example.com/ 拒绝 scheme 错误
https://docs.example.com:444/ 拒绝 端口不在允许集合
file:///data/user/0/... 拒绝 scheme 错误

本机 Python fixture 对上述 host、userinfo、端口、反斜杠和逐跳重定向实际执行了 13 个相关断言,其中显式端口 0 和越界端口 65536 都必须拒绝;这验证策略模型,不证明 android.net.Uri 与 Python 对所有畸形输入解析一致。官方 Unsafe URI Loading要求同时校验 scheme 与完整 host,并提醒不正确的 startsWith/endsWith/contains 会扩大信任范围。

Native Bridge:网页进入 App 权限范围的门

addJavascriptInterface、postWebMessage 和 message channel 都可能让页面调用原生代码。API 17 以前还存在著名的反射调用面;现代系统即便使用 @JavascriptInterface,如果不可信页面能到达读取 token、文件、联系人或设备控制方法,仍会以宿主 App 权限执行。

检查时画出三件事:

1
2
3
哪些顶层页面/iframe/注入脚本能执行?
它们能看见哪些 bridge 名称和方法?
每个方法最终到达哪个文件、Provider、Binder 或后端 API?

不要假定“顶层 URL 在白名单”就保证所有 frame 都可信。把接口缩成具体业务动作,不暴露通用 readFile(path)、exec(cmd)、getToken();原生方法里仍校验当前用户、对象和状态。加载不可信内容前调用 removeJavascriptInterface(),或更直接地让不可信内容进入没有 bridge 的独立 WebView/外部浏览器。官方 WebView Native Bridge 风险说明明确建议不需要 JS 时关闭,并在不可信内容加载前移除接口。

WebView 修复回归矩阵

用例 预期
允许 HTTPS 页面 正常加载,合法业务动作可用
允许页 302 到非允许 host WebView 停止或转系统浏览器
http/file/content/javascript URL 拒绝或按明确外部策略处理
错误证书/主机名 cancel,页面不继续
非允许页面尝试 bridge 接口不可见或动作拒绝
允许页面的恶意 iframe/脚本 不能借 bridge 取得通用 App 权限
旧 target 与新 target 构建 显式设置使结果一致,差异有记录

手机厂商、浏览器厂商、App 开发者和用户都需要及时更新 WebView/浏览器;这是旧文的防护建议之一。对 App 团队而言,更新不能替代自身 URL 和 bridge 边界,但能缩短已知 Chromium/WebView 漏洞暴露时间。

敏感数据泄露

原文列出的六类泄露全部保留:

  • Logcat 输出 token、手机号、VIN、定位、请求/响应正文;
  • 敏感数据明文写入 sdcard/共享外部存储;
  • 数据库敏感字段明文保存;
  • SharedPreferences 被错误设为全局可读写,或通过备份/调试环境泄露;
  • API key、私钥、固定加密 key 等硬编码进 APK;
  • HTTP 明文传输敏感信息。

现代 Android 的应用沙箱、scoped storage 和 SELinux 降低了部分默认暴露,但不能把“私有目录”写成绝对安全:debuggable 构建、root/设备失陷、备份、导出 Provider、WebView bridge 和应用自身越权接口都可能把数据带出来。

逐项审计生命周期比只看“是否加密”更有用:

数据 产生 存储 使用 清理/失效
access token 登录响应 内存/受控持久层 Authorization header 过期、登出、设备解绑
refresh token 服务端签发 不进入普通备份 换取短期 token 服务端撤销、轮换
业务导出文件 用户操作 私有临时目录 FileProvider/分享 超时删除、撤销 URI grant
日志事件 各模块 Logcat/本地文件/SDK 排障与审计 脱敏、采样、保留期限

硬编码的公开 API identifier 不一定是秘密;真正高风险的是能代表服务端身份的 secret、可签名私钥、长期通用 token。把它们加密后连同可解密 key 一起放进 APK,只增加逆向步骤,没有建立服务器侧信任边界。

还应检查通知、最近任务缩略图、剪贴板、截图/录屏、键盘与第三方 SDK。FLAG_SECURE 可以降低特定敏感页面被普通截图/投屏捕获的概率,但会影响可用性,也不能防住被控制的进程或所有外部相机/系统场景。

ZIP 解压缩漏洞

ZIP entry 名称可以包含 ../、绝对路径等特殊形式。若应用简单执行 File(destination, entry.name),攻击者可能把文件写到目标目录之外,覆盖配置、数据库、DEX 或 SO,造成拒绝服务,特定条件下甚至代码执行。旧文提到“寄生兽”、海豚浏览器和三星输入法相关历史事件,保留作这一漏洞影响的背景;具体成因与版本应分别查对应事件资料,不能把它们当作同一利用链。

ZIP 解压路径经过规范化、目录边界判断与资源限制

最小路径检查如下:

1
2
3
4
5
6
7
8
9
10
// 教学片段:本次未在 Android 设备编译运行
static File checkedTarget(File destination, ZipEntry entry) throws IOException {
File root = destination.getCanonicalFile();
File target = new File(root, entry.getName()).getCanonicalFile();
String prefix = root.getPath() + File.separator;
if (!target.getPath().startsWith(prefix)) {
throw new ZipException("entry escapes destination: " + entry.getName());
}
return target;
}

这段只解决规范路径边界,完整实现还要做:

  1. 目标目录由应用独占,解压前为空或采用明确的覆盖策略;
  2. 拒绝符号链接 entry,并防止检查后到打开前的 symlink/rename 竞态;
  3. 限制 entry 数、单文件大小、总解压大小和压缩比,防 ZIP bomb;
  4. 拒绝 NUL、绝对路径、盘符、反斜杠歧义与异常 Unicode 规范化;
  5. 使用独占创建,不静默覆盖现有文件;
  6. 写入失败时清理临时目录,验证完成后再原子移动;
  7. 对 TAR/RAR/7z 和第三方库执行同类检查。

Android 官方 Zip Path Traversal明确指出 java.util.zip 默认不会替调用者过滤 ../,并要求在每个 entry 写入前确认目标是 destination 的子路径。不要依赖“新 Android 会自动拦”这类没有覆盖到自定义/第三方解压器的假设。

本机实验构造了真实内存 ZIP,实际验证:

1
2
3
4
5
6
7
images/a.txt            -> 写入允许目录
../escape.txt -> 拒绝,目录外没有生成文件
a/../../escape.txt -> 拒绝
/absolute.txt -> 拒绝
..\escape.txt -> 拒绝
Unix symlink entry -> 拒绝
声明解压大小超上限 -> 拒绝

实验脚本还要求目标目录为空并使用独占创建。它运行在 Windows/Python 3.11,不是 Android Java 解压器的实机结果;价值在于让路径和资源策略有可重复的正反断言。

Android APP 审计系统:从清单变成证据

旧文最后保存了一张静态/动态扫描脑图,覆盖文件信息、证书、权限、SO、第三方 SDK、组件、WebView、SQLite、网络、加密、敏感数据、动态 Hook、拒绝服务与注入等检查项:

旧文 Android APP 审计系统脑图

原图地址。图中包含 Wormhole、XG SDK、旧 WebView/File 域、adb backup 等当年的检查项,适合作历史索引;工具名称和红绿状态不代表当前版本仍适用。

一套可复核的流程可以这样走。

1. 固定样本与运行条件

1
2
3
sha256sum app-release.apk
apksigner verify --verbose --print-certs app-release.apk
apkanalyzer manifest print app-release.apk > manifest-final.xml

记录 APK 来源、版本、签名摘要、设备/API/补丁、target SDK、WebView provider、账户和测试网络。只有这样,同事才能解释“我这里复现不了”到底是代码、版本还是身份差异。

2. 枚举真正入口

  • 合并 Manifest 中的 exported Activity/Service/Receiver/Provider;
  • 动态 Receiver、Deep Link/App Link、WebView loadUrl;
  • Binder/AIDL、ContentProvider URI、FileProvider paths;
  • 本地 TCP/UDP/Unix socket;
  • 通知/PendingIntent、分享与文件导入;
  • 网络 API、升级/插件下载和压缩包解析。

每个入口写明调用者身份。adb shell、root、同签名 App、普通第三方 App 和远程未登录用户的影响不能混写。

3. 从入口追到 sink

用数据流而不是关键字作结论:

1
2
3
4
5
6
7
8
Intent.getStringExtra("url")
→ normalize/allowlist?
→ redirect?
→ WebView.loadUrl
→ JavaScript enabled?
→ addJavascriptInterface
→ NativeBridge.exportReport(id)
→ repository.openFile

SQL、文件路径、反射、动态加载、系统 Intent、设备控制、日志和网络请求都可以是 sink。遇到“已校验”继续问:校验的是字符串格式、调用身份、对象归属,还是全部?校验发生在 clearCallingIdentity 或异步切换之前吗?

4. 写最小复现和对照组

一个好的复现同时有正例与反例:

组别 输入 调用者 预期/实际
合法 A 读取 A 的 report 普通合作 App A 成功
越权 A 读取 B 的 report 普通合作 App A 修复前成功、修复后拒绝
无权限 无 signature 权限调用 Service 普通第三方 App SecurityException
畸形 错类型/超长 extras 普通第三方 App 拒绝且进程不崩溃
重放 同 nonce 再发一次 已认证账户 A 第二次拒绝/返回同一幂等结果

截图用于展示 UI、代理请求和系统行为;关键参数、命令、日志和哈希仍要以文本保存,避免证据只剩一张难搜索的图。

5. 修复验证要回到敏感操作

“页面打不开了”不一定等于漏洞修复,可能只是测试路径失效。确认最终 sink 没有发生:数据库无越权行、文件未写出允许目录、WebView 未加载恶意 origin、服务端状态只变化一次。同时确认合法调用仍可用,避免以破坏功能换取扫描器不报警。

本机可复现实验

下载:

运行:

1
2
python android_boundary_lab.py --demo
python android_boundary_lab.py --test -v

本次在 Python 3.11.5 上实际执行 13 个 unittest,结果全部通过,覆盖:

  • 9 个 URL 输入和两条 redirect chain;
  • SQL 字符串拼接、参数绑定、行归属和排序字段允许集合;
  • 正常 ZIP、4 种路径逃逸、symlink 与大小限制;
  • 请求首次接受、重复 nonce、篡改 path 与过期时间戳。

这些 fixture 是可重复的主机侧模型。本文没有声称以下内容已经实测:Android 组件导出、Binder UID、URI grant、PendingIntent、WebView bridge、Network Security Config、Keystore、PackageInstaller。把代码带进真实项目时,应按项目的 minSdk/targetSdk、设备矩阵和网络栈补上 instrumentation/集成测试。

参考资料

Android 官方资料

旧文参考链接

以下链接是原文的资料来源,继续保留;其中部分文章较早,涉及默认值和防护方式时以上面的官方版本条件为准。


Android APP常见安全漏洞
https://g1at.github.io/2024/02/18/Android-App-Security-Vulnerabilities/
作者
g0at
发布于
2024年2月18日
许可协议