写安卓系统定制或者企业设备项目的时候,经常遇到一个看似简单却处处是坑的需求:MTP连接电脑后,只允许电脑访问指定的目录,而不是把整个内置存储卡的内容全晾出来。尤其是涉及Android Framework层定制、需要保护sdcard上应用私有数据或企业内部文档的场景,这类“限定访问目录”的诉求几乎成了ROM开发的标准配置。这篇文章从Android Framework MTP的实现原理讲起,梳理一套实际落地的改造思路,并给出源码级的关键改动点和排查经验。不管你是做系统定制的厂商工程师,还是研究Framework源码的开发者,都可以直接当作参考。
1. 需求场景与整体思路
1.1 为什么需要限定MTP可见目录
MTP(Media Transfer Protocol)是Android设备通过USB连接电脑时最常用的文件传输协议。用户插上数据线,电脑端就能以“便携设备”的形式浏览手机存储,复制照片、视频、文档都靠它。但问题也出在这里:默认实现下,MTP会把内置存储的大部分内容暴露给电脑,包括/Android/data、/Android/obb这类应用私有目录,甚至一些企业不希望被随意导出的资料目录。
原生系统其实已经意识到了这一点,Android 11开始强制分区存储,并限制了电脑端对Android/data的访问。但限制Android/data只是“默认安全”的一部分,远远不能满足真正的业务需求。实际项目里经常只有两种诉求:要么是只允许电脑访问DCIM、Download这些特定目录,其余一律不可见;要么是把某个内部业务目录映射出去,让电脑只看到这一个“虚拟根目录”。这两种诉求靠原生开关都做不到,必须从Android Framework的MTP实现层动手。
1.2 MTP在Android Framework中的运行链路
要改MTP,先得搞清楚它在系统里是怎么跑起来的。Android的MTP服务主体位于MediaProvider(旧版本在MTP服务独立应用)中,核心类是MtpService、MtpDatabase,以及Android 8之后引入的FileSystemHelper。整体链路可以简化成三步:
- MtpService在系统启动时注册存储区,把内部存储、SD卡等Volume注册到MTP框架里;
- 电脑端通过USB发来MTP命令,比如枚举对象(GetObjectHandles)、查询对象信息(GetObjectInfo)、取文件内容(GetObject)等;
- MtpDatabase或FileSystemHelper根据这些命令,在文件系统上枚举目录、打开文件,再把结果翻译成MTP对象句柄返回给电脑。
所以,MTP的暴露范围实际上由两个层级决定:一是MtpService注册了哪些存储Volume,二是MtpDatabase/FileSystemHelper在枚举目录时把哪些路径映射成了“可见对象”。限定可见目录这件事,其实就是在这两层各自做白名单过滤。
1.3 三条实现路线怎么选
针对“MTP不暴露sdcard所有内容”的目标,我在实际项目里评估过三条路线,各有适用场景。
- 路线A:改Framework层的MTP枚举逻辑,在MtpDatabase或FileSystemHelper中按白名单过滤,只让指定目录参与MTP对象树构建。这条路最正统,改动点清晰,权限控制可靠,适合对源码有完整掌控的定制ROM项目。
- 路线B:用Linux的bind mount把指定目录重挂载到一个“干净”的虚拟路径,然后让MTP暴露这个虚拟路径。这条路不用改MTP源码,对MediaProvider透明,但需要系统root权限,并且对多用户、多存储区的适配要格外小心。
- 路线C:在更底层的文件系统/FUSE层做访问控制,统一拦截所有对存储卡敏感路径的读取操作。这属于大动干戈的方案,适合连同其他安全需求一起做,如果只是为了MTP一个场景,成本和风险都偏高。
综合评估,绝大多数场景我会优先选路线A,因为它直接、可控,后续升级版本时也方便移植。路线B可以作为A的补充,解决某些厂商平台MTP改动困难的问题。下面重点展开路线A的实现细节,路线B单独作为备选章节。
2. 核心原理:MTP对象树与句柄映射
2.1 MTP协议的对象模型
MTP协议本身有一套“对象”模型:设备上有若干个Storage(存储区),每个Storage下面挂着一棵由Object组成的树。Object可以是目录,也可以是文件,每个Object都有一个唯一的ObjectHandle(对象句柄)。电脑端浏览设备时,先枚举Storage,再从Storage根节点开始逐层展开目录树,点击某个文件时再通过句柄发起读取。
这套模型有个关键特点:电脑端能“看到什么”,完全取决于设备端在响应枚举命令时返回了哪些对象。如果设备端在枚举某个父目录时,只返回白名单内的子项,那电脑端根本感知不到那些被过滤的目录和文件存在。这就是限定访问目录最核心的抓手——不是拦截传输,而是从源头控制枚举结果。
句柄映射是另一个关键点。MTP服务维护了一个“句柄到真实路径”的对应关系,每次枚举目录时生成句柄,每次读取文件时根据句柄反查路径。任何过滤逻辑都必须同时作用在“枚举生成句柄”和“句柄反查路径”这两个方向,否则就会出现“文件列表看不到,但按句柄硬猜却能打开”的安全漏洞。
2.2 Android端对象索引的实现位置
Android 8之后,MTP的实现主要靠FileSystemHelper直接操作底层文件系统,不再像早期版本那样从MediaStore数据库读索引。这意味着MTP的目录枚举和文件打开,走的是独立的文件扫描路径,跟系统相册、文件管理器看到的内容并不完全一致。
在AOSP里,关键的类有这些:
- MtpService:负责初始化和生命周期管理,把StorageVolume注册到MTP Native层;
- MtpDatabase:早期版本的数据库索引实现,负责把MTP对象映射到文件路径;
- FileSystemHelper:Android 8+的文件系统封装,负责枚举目录、获取文件信息、打开文件;
- MtpServerJNI:衔接上层Java逻辑和底层USB MTP协议栈。
标定漏洞点的时候,优先看FileSystemHelper和MtpDatabase里的getObjectList、getObjectInfo、getObjectFilePath这几个方法。它们分别是“枚举子项”“查询对象属性”“根据句柄取路径”的核心入口,也是白名单过滤必须插入的位置。
2.3 白名单过滤的最小改动点
从“最小可用”的角度,白名单过滤有三个必改位置:
- 存储注册阶段:在MtpService注册Volume时,可以只注册白名单目录所在的Volume,但这一步通常决定“显示哪些存储区”,而不是“存储区下显示哪些目录”;
- 对象枚举阶段:FileSystemHelper/MtpDatabase在枚举目录时,对返回的子对象做白名单过滤,这是决定电脑端文件树可见性的关键;
- 句柄反查阶段:getObjectFilePath在根据句柄返回路径时,必须校验该路径是否落在白名单内,防止绕过列表直接读取。
如果只改了第二个位置,电脑端确实看不到被过滤的内容,但理论上还是可以构造MTP命令请求某个句柄来读取文件。所以在做安全要求高的项目时,三个位置必须一起改。
3. 实操:基于AOSP的目录白名单改造
3.1 环境准备与源码定位
实操基于AOSP的MediaProvider模块,先确认本机源码的版本分支。不同Android版本里,MTP相关代码的位置略有差异:
| Android版本 | 关键路径 |
|---|---|
| Android 7及更早 | packages/providers/MediaProvider/src/com/android/providers/media/MtpService.java |
| Android 8-12 | packages/providers/MediaProvider/src/com/android/providers/media/MtpService.java |
| Android 13及以上 | 部分逻辑迁移至 packages/providers/MediaProvider 内部,类名仍以Mtp开头 |
我建议先在源码目录里搜一下“class MtpService”和“class FileSystemHelper”,确认实际位置再动手。编译环境用标准的source build/envsetup.sh、lunch命令即可,真机调测时最好准备一台可以刷userdebug或eng版本的系统设备,因为正式user版本对usb调试和日志输出限制较多。
白名单的配置入口,我习惯用系统属性来做,比如定义persist.sys.mtp.allowed_dirs,用分号分隔多个路径。选择系统属性而不是硬编码,是因为它可以在生产环境动态调整,不用每次改代码都要重新编译。属性值在init脚本里设置,也可以运行中通过setprop临时修改(部分属性需要root权限)。
3.2 MtpService层的存储注册过滤
MtpService的改动主要在于存储区注册阶段。原生的注册逻辑会遍历系统里所有可用的StorageVolume(内置存储、外置SD卡、OTG设备等),把所有存储区都交给MTP框架。要做白名单,可以在这里做第一层控制:如果配置了允许目录,就只注册包含了允许目录的存储区,并且把允许目录的存储归属关系记录下来。
下面是一段裁剪后的示意代码,逻辑重点是拦截addStorage:
// MtpService.java 关键片段(示意) public final class MtpService extends IMtpService.Stub { private static final String PROP_ALLOW_DIRS = "persist.sys.mtp.allowed_dirs"; private List<String> mAllowedDirs = new ArrayList<>(); @Override public void onCreate() { super.onCreate(); String dirs = SystemProperties.get(PROP_ALLOW_DIRS, ""); if (!TextUtils.isEmpty(dirs)) { mAllowedDirs.addAll(Arrays.asList(dirs.split(";"))); } mBatteryStats.updateMtpState(...); } private boolean isStorageAllowed(StorageVolume volume) { if (mAllowedDirs.isEmpty()) { return true; // 未配置白名单,保持原生行为 } for (String dir : mAllowedDirs) { if (isPathUnderStorage(dir, volume)) { return true; } } return false; } // 在MTP启动、挂载存储时调用 private void addStorage(StorageVolume volume) { if (!isStorageAllowed(volume)) { return; } // 原有注册逻辑 } }这段代码主要解决的是“多个存储区场景下只暴露相关存储区”。如果内置存储和外置SD卡都有,而白名单只放在内置存储上,那注册阶段就把外置SD卡过滤掉,电脑端根本看不到第二个存储区,干净利落。
注意:不要以为这一步做完就完事了。注册存储区只解决“显示哪个盘”,解决不了“盘里显示什么东西”。真正的目录级过滤,必须配合枚举和句柄反查,不然默认情况下电脑还是能看到存储根目录下的绝大多数文件夹。
3.3 MtpDatabase/FileSystemHelper层的白名单枚举
目录级过滤是整个改造的核心。以Android 12为例,MTP在枚举目录时走的是FileSystemHelper,核心方法是getObjectList。改造思路是:在返回子对象列表之前,对每一个子对象做一次“是否位于白名单目录内”的判断,把目录结构当成一棵以白名单目录为根的子树。
具体做法上,我选择封装一个白名单判定工具类,比如MtpPathPolicy,然后在FileSystemHelper的枚举入口加一个分支。这样既不打乱原有代码路径,又能让判定逻辑统一维护。
// FileSystemHelper.java 简化示意 public class FileSystemHelper implements MtpDatabase { private final MtpPathPolicy mPathPolicy; @Override public int[] getObjectList(int storageID, int parent, int offset, int limit) { if (mPathPolicy.isWhiteListEnabled()) { return getObjectListWithWhitelist(storageID, parent, offset, limit); } // 原有逻辑 } private int[] getObjectListWithWhitelist(int storageID, int parent, int offset, int limit) { // 先拿到原生的对象列表 int[] original = getObjectListInternal(storageID, parent, offset, limit); List<Integer> filtered = new ArrayList<>(); for (int handle : original) { String path = getObjectFilePath(handle); // 句柄反查真实路径 if (mPathPolicy.isAllowed(path)) { filtered.add(handle); } } // 按offset/limit重新分页 return filtered.subList(offset, Math.min(filtered.size(), offset + limit)); } }这里有个细节值得注意:MTP枚举是支持分页的(offset和limit),所以过滤后必须重新计算分页,不能直接把原生结果截断。否则电脑端的文件浏览器会显示错乱的列表——比如第一页返回了9个文件,第二页本来应该返回第10到第18个,但过滤后实际只剩5个,分页就会漏掉或者重复。
当一个目录本身就在白名单之外时,还可以做一层短路优化:如果父目录路径不在白名单内,直接返回空列表,不再逐个枚举子项。因为白名单目录的父级,除了白名单目录自身路径上的父目录外,都不应该被展开。举个例子,允许目录是/storage/emulated/0/Work,那枚举/storage/emulated/0根目录时,只能在返回结果里保留Work这一个子项;枚举/storage/emulated/0/DCIM时,无论DCIM下有多少文件,都不应该返回任何内容。
3.4 越界访问防护与句柄校验
枚举过滤只是界面层的“看不见”,要防止按句柄硬读,必须在句柄反查路径时再次校验。MTP客户端如果拿到某个对象的句柄,可以直接发GetObject命令取文件内容。如果句柄反查只做了路径拼接,没有做边界校验,黑客客户端完全可以枚举出真实句柄范围,然后逐号请求试探文件内容。
所以getObjectFilePath这个入口一定要加校验,逻辑很简单:根据句柄解析出真实路径后,判断该路径是否在白名单目录内,不是就返回无效句柄。
// 句柄反查时的校验(示意) @Override public String getObjectFilePath(int handle) { String path = resolvePathByHandle(handle); if (mPathPolicy.isWhiteListEnabled() && !mPathPolicy.isAllowed(path)) { return null; // 对外表现为对象不存在 } return path; }除此之外,还要注意文件类型相关的MTP命令,比如GetThumb(缩略图)、GetObjectInfo。有些设备端实现会把缩略图读取单独走一条路径,漏了校验。我在做安全加固时是统一在“路径获取”这一层拦,只要最终都要调resolvePathByHandle,就能覆盖大部分泄露风险。
3.5 编译、刷机与验证
改完代码以后,正常编译MediaProvider模块,然后刷入系统:
source build/envsetup.sh lunch <你的产品> m mediaprovider adb root adb remount adb push out/target/product/<产品>/system/priv-app/MediaProvider/MediaProvider.apk /system/priv-app/MediaProvider/ adb reboot验证白名单效果时,我强烈建议准备一台Windows电脑和一台Linux电脑分别测试。Windows的资源管理器对MTP有缓存,有时过滤已经生效了,但电脑端还显示着旧的文件列表,这个时候要断开连接重插,或者在设备端重启一下MTP服务再连。
验证步骤可以按这个清单来:
- 插上USB线,选择MTP模式(传输文件),确认电脑端是否只看到白名单目录及其内容;
- 展开每个目录,确认后代目录、文件都能正常访问,目录层级和白名单配置一致;
- 尝试在电脑端地址栏手动输入设备路径,比如“计算机\设备名\内部存储\Android\data”,确认这些路径无法访问或不显示;
- 在设备端用adb shell运行dumpsys media.mtp或dumpsys mtp,确认MTP枚举的对象句柄范围和过滤后的目录一致;
- 反复插拔USB线、重启设备,确认白名单配置在每次启动后都稳定生效。
这里有一个我踩过的坑:修改后如果发现电脑端完全看不到任何文件,大概率是白名单目录的权限问题。MTP服务进程是MediaProvider,它是系统进程,但文件读取还要经过SELinux策略。白名单目录如果是新建的、没有正确设置SELinux label,MediaProvider进程可能没有读取权限,枚举结果就会为空。排查时用adb logcat看avc denied日志,只要有avc拒绝基本就是这个方向。
4. 备选方案:bind mount虚拟重定向
4.1 bind mount的原理
bind mount是Linux的一项经典能力,它允许把一个目录“挂载”到另一个挂载点上,两个路径指向同一份真实数据,但对外呈现的是目标挂载点的路径。利用这个特性,可以把/storage/emulated/0/Work这个业务目录,bind到一个新的挂载点,比如/mnt/mtp_safe_root。
之后让MTP暴露/mnt/mtp_safe_root这个“虚拟根”,而不是真实的/storage/emulated/0。这样电脑端看到的所有内容,实际上都是被重定向过的目录树,天然就看不到Work以外的任何目录。
4.2 配置示例
在init脚本或系统服务里,可以这样执行:
# 创建虚拟挂载根目录 mkdir -p /mnt/mtp_safe_root # 将允许的业务目录bind mount到虚拟根下 mount --bind /storage/emulated/0/Work /mnt/mtp_safe_root # 如果还需要其他目录,可以继续bind到不同的子目录 mkdir -p /mnt/mtp_safe_root/DCIM mount --bind /storage/emulated/0/DCIM /mnt/mtp_safe_root/DCIM然后修改MtpService,让它在注册存储区时,把/mnt/mtp_safe_root作为存储区的根路径,而不是默认的/storage/emulated/0。这个思路能大幅减少Java层代码改动,因为MTP逻辑本身不用变,变的只是它看到的根目录指向哪儿。
4.3 与Framework方案的对比
bind mount方案的好处是改造量小、可以应急,尤其适合不方便重新编译整个MediaProvider模块的场合。但它也有几个明显的坑:
- 挂载时机必须晚于存储挂载、早于MTP服务启动,否则虚根目录里可能什么都没有;
- 需要在系统服务里持久化,重启后要重新挂载,不能只靠临时命令;
- 多用户场景下,/storage/emulated/0其实只是对应用户的映射,不同用户有不同的真实路径,bind逻辑要按用户区分;
- 对上层文件系统的状态影响需要测试,比如媒体扫描、备份恢复等模块,如果它们也访问了这个虚拟根,可能产生冲突。
所以我的经验是:bind mount适合快速验证、临时交付,要作为正式版本方案,稳定性不如直接在Framework层做白名单过滤。
5. 常见问题与排查实录
5.1 修改后电脑端仍能看到全部目录
这是最常遇到的“假失败”。优先排查三件事:
- 系统属性persist.sys.mtp.allowed_dirs是否真的set成功,部分版本persist属性需要重启后才稳定生效;
- MTP服务是否真的重启了,改了代码不重启服务,内存里还是旧逻辑;断开重连USB、重启系统都可以试试;
- 有没有把代码push到错误的路径,MTK、高通平台上,MediaProvider可能在system/system_ext或system/product分区,push错地方了自然没效果。
用adb shell dumpsys media.mtp确认当前MTP存储注册路径,能直接看出MTP发布根目录是哪一个,这条命令在排查时最好用。
5.2 句柄失效、文件打不开、复制报错
句柄失效通常表现为:文件列表能显示,但双击打开报错,或者复制到一半中断。原因集中在两个地方:
- 修改枚举逻辑后,句柄生成规则和句柄反查规则不一致,比如枚举时用的是修改后的白名单句柄,反查时却走了旧路径;
- 分页重新计算时,offset和limit与过滤后的列表没有对齐,客户端拿到的列表和实际文件句柄错位。
排查思路是把MTP日志打开,重点看GetObjectInfo和GetObjectFilePath对应的handle是否都落在预期文件的路径上。如果handle对不上路径,九成是句柄映射处改漏了。
5.3 可移动SD卡与多存储区适配
只考虑内置存储的项目,通常不会踩到这个问题。但很多设备是支持SD卡扩展的,MTP会把SD卡注册成第二个Storage。白名单只配置了内置存储路径时,外置SD卡默认还是会全部暴露出来,这一点很隐蔽。
为了安全,需要在MtpService的存储注册阶段做双重判定:如果这个Volume不是内置存储,同时又不包含任何白名单路径,就整个不注册。否则就会出现“内置存储只能看到Work目录,切到SD卡却能看到全部文件”的诡异局面。
另外,如果允许目录真的涉及外置SD卡,那么白名单路径的匹配要做前缀归一化,SD卡的挂载点在Framework层可能是/storage/XXXX-XXXX,但MTP层拿到的Volume路径可能是/mnt/media_rw/XXXX-XXXX,两套路径要映射上,不然isPathUnderStorage永远返回false。
5.4 厂商平台差异与后续系统升级
不同SoC平台对MTP的改造程度差异很大。高通的平台大体沿用AOSP的MediaProvider实现,改动移植相对顺利;MTK早期有些方案把MTP做进了自己的中间件或底层库,Framework层的MtpService只是个壳,改了Java层不生效,这时候可能需要看厂商提供的扩展接口,或者考虑bind mount方案。
还有一点要牢记:MTP的代码在系统升级时经常变动,Android 13、14里MediaProvider持续在优化存储访问逻辑。定制的白名单代码建议做一个独立的类库,集中承载路径策略逻辑,不要散落到MtpService各个方法里。这样升级版本时只需要适配接口调用,不用重写核心策略。
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 电脑端还能看到全部目录 | 属性未生效、服务未重启、代码push错分区 | 检查persist属性、dumpsys media.mtp、确认分区 |
| 列表错乱或文件打不开 | 分页未按过滤后列表重算、句柄映射不一致 | 看MTP日志里handle和路径是否对应 |
| 只改内置存储没改SD卡 | 多存储区只部分注册 | 检查所有Volume的注册逻辑 |
| 完全看不到文件 | SELinux权限、目录label错误 | logcat查avc denied |
| 重启后配置丢失 | 属性没有持久化、bind mount未重建 | 检查init脚本和服务启动顺序 |
结尾
按我个人在几个定制项目里踩坑的体会,MTP限定目录这件事,最关键的其实不是“怎么写过滤代码”,而是“在哪儿写过滤代码”。枚举和句柄反查一起改,才不会有列表和读取之间的缝隙;分页重算不落地,电脑端列表就会错乱到让人怀疑人生;多存储区不处理,SD卡就是最大的后门。如果只想快速验证,bind mount可以救急;想正式交付,Framework层的白名单过滤才是最稳的路。真到了要排障的时候,记住先看dumpsys media.mtp的输出,再看logcat里的avc denied,这两个地方能解决八成以上的MTP疑难杂症。