2026/10/11 7:04:13

Android Framework MTP定制:限定可见目录,隐藏存储内容

Android Framework MTP定制:限定可见目录,隐藏存储内容 做Android Framework定制的人大概都遇到过这种尴尬设备交付给客户客户用USB线连上电脑MTP目录里瞬间把内部存储翻了个底朝天——apk备份、log、cache、测试照片全都一览无余。MTP的初衷是像U盘一样方便但对行业设备、会议平板、教育终端这类定制系统来说这恰恰是最头疼的暴露面。这篇文章要解决的就是这个问题在不改硬件、不换协议的前提下在Android Framework的MTP服务里把可见目录锁死到一个指定目录让电脑端永远看不到sdcard的其他内容。适合正在做AOSP定制、行业终端整机方案或者被领导要求“把MTP收一收”的Framework工程师参考。1. MTP的“全盘可见”到底是怎么来的一条数据线背后的四层翻译1.1 从USB线到目录树MTP不是“U盘挂载”很多刚接触MTP定制的人会误以为MTP模式下电脑看到的目录就是文件系统直接暴露出来的像U盘一样。其实不是。MTP全称Media Transfer Protocol是一种基于对象Object的传输协议和U盘的SCSI Mass Storage完全是两套东西。MTP模式下设备端的文件系统并不会直接暴露给电脑真正干活的是设备端一个名为MtpServer的服务进程它按MTP协议把文件和目录抽象成“对象”再通过网络传输层提供给电脑。完整的链路大致是这样的USB数据线插入电脑设备端的USB Gadget驱动把物理链路伪装成一个MTP设备内核收到连接事件后通知Framework层的UsbServiceUsbService再通知MtpService开始工作MtpService启动MtpServer并向StorageManagerService查询当前系统里有哪些存储卷MtpServer把卷的根路径作为MTP对象树的根逐级枚举目录和文件回复电脑端的列表请求。也就是说电脑端看到的目录树根本不是文件系统的实时快照而是MtpServer根据协议查询后返回的“对象列表”。这就给了我们在Framework层做手脚的空间——只要控制MtpServer枚举对象时的起始路径和过滤规则电脑端就看到什么。1.2 StorageManagerService在MTP里扮演的角色要控制MTP暴露范围必须先知道MtpServer是从哪里拿到“有哪些盘”这个信息的。Android系统的存储抽象核心是StorageManagerService它管理着一组VolumeInfo。每个VolumeInfo代表一个存储卷里面包含volume的挂载状态比如挂载在/storage/emulated/0volume的类型内部共享存储、SD卡、USB OTG盘等volume的读写权限、标签、UUID等元数据。MtpService在创建MtpServer时会把StorageManagerService的卷列表原样传进去。MtpServer把这些卷逐个注册成MTP协议层面的Storage电脑端每看到一个“盘符”其实对应的就是MtpServer注册的一个Storage卷。这里有一个关键认知MTP的盘符数量完全取决于Framework层给了MtpServer多少个卷。理论上如果你在传给MtpServer之前把卷列表过滤一遍电脑端能看到的盘就只剩过滤后的那几个。这是整个定制方案最省事、也是最容易被忽视的切入面。1.3 目录暴露的本质卷根路径就是对象树的根当MtpServer注册了一个卷它会把卷的根路径作为MTP对象树的根节点。电脑端第一次枚举时会收到这个根节点下的所有子目录然后一层一层往下刷。如果你的内部存储卷根路径是/storage/emulated/0那么MTP默认会把该路径下的log、Download、Android、DCIM、备份目录全部展示出来。所以“限定访问目录”这件事本质就是回答一个问题MTP对象树的根节点应该从哪里开始默认答案是卷根路径定制答案是你指定的限定目录。剩下的工作就是把代码里所有使用卷根路径的地方统一替换成限定目录确保任何一层目录枚举都不会越过这个边界。2. 动手前先想清楚四种改造路径的成本和适用场景2.1 方案A在StorageManager层过滤卷适合多存储设备最简单的做法是在MtpService启动MtpServer之前遍历StorageManagerService返回的卷列表只保留你想暴露的卷其余的一律丢弃。这种做法适合设备里有多个存储卷的场景比如既有内部存储又有SD卡你想让MTP只显示SD卡或者只显示内部存储把另一个藏起来。实现上几乎就是一段遍历和判断ListVolumeInfo volumes storageManager.getVolumes(); ListVolumeInfo allowedVolumes new ArrayList(); for (VolumeInfo volume : volumes) { if (volume.isMountedReadable() volume.getType() VolumeInfo.TYPE_EMULATED) { if (volume.isPrimary()) { allowedVolumes.add(volume); } } }这个方案的优点是改动量极小基本不会引入新的bug。缺点是粒度太粗只能管到“卷”这一层没法管到卷下面的子目录。如果你的设备只有一个内部存储卷这个方案等于什么都没做。它更适合作为方案B的辅助手段先把乱七八糟的卷过滤掉再进一步限定目录。2.2 方案B替换卷根路径最常规的做法方案B的核心思想是保留MtpServer原有的注册流程但在注册卷时把卷的路径替换成你指定的限定目录。这样MtpServer以为暴露的是一个“卷”实际上这个卷的根路径已经被换成了你指定的目录。具体来说就是把原先来自StorageManagerService的/storage/emulated/0替换成/storage/emulated/0/Work这样的子目录然后再传给MtpServer。这个方法最大的优点是电脑端完全无感它只是看到一个普通的存储设备但往里一刷只能看到Work目录下的内容。方案B的难点在于要处理MtpServer内部的路径翻译逻辑不能让某个查询请求拿到原始卷根路径后直接绕过渡换逻辑。所以一旦决定用方案B就要在MtpServer的存储注册入口、Native层数据库查询入口两端同时做替换保证链路内所有代码拿到的都是替换后的路径。2.3 方案C在对象查询层做白名单过滤最灵活但最重方案C是在MTP协议请求到达对象查询层时做一个二次校验。比如电脑端请求枚举某个目录下的子对象MtpServer先获取真实路径然后判断这个真实路径是否位于限定目录范围内不在就直接返回空列表。相比方案B方案C更贴近“安全策略”的直觉因为你是在实际输出对象之前拦截的逻辑最直观。但方案C的问题也很突出MTP协议有大量查询请求比如枚举目录、获取对象信息、获取对象属性、拷贝文件、删除文件每类请求都要做一遍路径范围判断代码侵入面非常大。如果判断逻辑不够完善很容易出现“某个接口漏了判断导致绕过”的漏洞。我一般只在方案B无法满足动态需求时才会叠加方案C作为补充。2.4 方案DSELinux兜底防绕过的最佳搭档SELinux不能改变MTP能“看到”哪些目录但它能决定MTP进程能不能“访问”某些目录。如果MtpServer进程的SELinux domain没有对限定目录之外的文件读取权限那么即使方案B或方案C因为某个bug漏了一个查询电脑端最终拿到的也是一个权限错误而不是文件内容。方案D适合做兜底不适合做唯一方案。原因是MTP客户端在电脑端的体验很敏感一旦访问被拒绝Windows会弹出“无法访问指定设备”之类的提示用户会认为设备坏了。我在实际项目中通常是方案B做主逻辑方案D做安全网两者同时启用。这样即便哪天代码被改出了漏洞SELinux仍然能把数据挡在最后一道门外。2.5 我的选型判断如果你的设备只是要求“MTP默认只显示一个工作目录”我建议直接走方案B把限定目录做成一个可配置常量集中放在MtpService的配置区域里。如果还要求“不同用户登录时显示不同目录”那就在方案B基础上做成动态获取限定目录的接口。方案C除非你有动态白名单需求否则不建议一开始就上因为复杂度会随着需求增加迅速膨胀。方案D则无论怎么选型都建议加上成本低收益大。3. 实操在MtpServer里固化一个“可见根目录”3.1 先定位MtpServer的启动与存储注册流程以Android 12/13的AOSP代码为例MTP服务端主要在packages/services/Mtp目录下核心类是MtpService和MtpServer底层Native实现则在frameworks/av/media/mtp。不同厂商的ROM可能会把代码挪到vendor目录但总体结构差不了太多。MtpService作为系统服务启动时会接收USB模式切换的广播然后创建MtpServer并传入一个MtpDatabase对象。MtpServer向Native层注册存储卷时会遍历StorageManagerService返回的卷列表。我们要替换路径最干净的位置就是在遍历卷列表、准备把路径传给MtpServer之前。这里的操作思路不是直接在MtpService里硬编码一个路径而是先定义好“暴露根目录”的常量再在传入前把卷路径统一替换。这样才能让MTP对象树的根节点一开始就落在限定目录上而不是等到查询时才临时过滤。3.2 用“换根”的方式让MTP只看到一个目录假设我们要暴露的目录是/storage/emulated/0/Work代码如下示意按AOSP代码风格写private static final String CUSTOM_MTP_ROOT /storage/emulated/0/Work; private void addCustomVolumeToMtpServer(MtpServer server, StorageManager storageManager) { ListVolumeInfo volumes storageManager.getVolumes(); for (VolumeInfo volume : volumes) { if (volume.isMountedReadable() volume.isPrimary() volume.getType() VolumeInfo.TYPE_EMULATED) { File root new File(CUSTOM_MTP_ROOT); if (!root.exists()) { root.mkdirs(); } // 关键点不传volume.getPath()传自定义限定目录 server.addStorage(volume.getDescription(), CUSTOM_MTP_ROOT, volume.getMountUserId(), volume.getStorageId()); break; } } }要注意的是server.addStorage的参数签名在不同Android版本里可能不一样但语义是一样的告诉MtpServer有一个存储卷它的路径是哪里。只要我们在这里传入CUSTOM_MTP_ROOTMtpServer后续的所有枚举就会以这个目录为根。这里有一个很容易踩的细节如果限定目录是/storage/emulated/0/Work但这个目录在设备端对MtpServer进程有SELinux访问限制就会出现“根目录已经换过去了但电脑端打开后目录是空的”的情况。所以创建目录后第一步要检查设备端能不能访问到这个目录而不是急着连电脑验证。3.3 Native层的路径翻译也不能漏Java层换根只是第一步。MtpServer收到MTP协议请求后很多对象枚举逻辑是在Native层执行的。如果在Native层的MtpDatabase里仍然使用原始卷根路径做拼接就会出现一个问题电脑端枚举出来的对象路径可能是/storage/emulated/0/xxxx而不是限定目录下的路径。所以Native层的路径翻译逻辑也需要同步修改。核心原则只有一条MtpDatabase在收到任何对象ID或者路径信息时只能用限定目录作为根不能拿原始卷根路径出来拼。具体操作上需要定位Native层保存卷路径的入口看它是从哪个变量里读出卷根路径的然后把这个变量初始化时赋的值换成限定目录。例如在注册存储时卷路径保存到一个结构体里后续枚举都从这个结构体取值那只要在注册时传入限定目录后续逻辑就自然跟着变了。如果Native层某个地方写死了/storage/emulated/0这个路径不要犹豫找到并替换它。这种写死在老版本代码里很常见尤其是厂商自己加的补丁里。3.4 别忽略卷名的显示把路径换掉之后电脑端看到的盘符名称可能还是会显示成/storage/emulated/0/Work看起来很不专业。大部分电脑客户端会优先使用MTP协议里设备的存储卷描述字段来显示盘符名称所以我们可以在传入addStorage时把卷描述改成一个干净的名字比如“工作空间”。这样做的好处是双重的一是电脑端的展示更干净二是能避免用户通过盘符名反推出真实路径结构减少不必要的猜测和尝试。对于定制项目来说细节往往是客户验收时最容易关注的加分项。3.5 留意Android 11以后的MediaProvider参与Android 11开始系统强制了分区存储媒体文件的扫描和访问大多通过MediaProvider完成。MTP的一部分对象枚举在部分版本里也会委托给MediaProvider。如果你的定制设备用的Android 12或13仅仅改了MtpService这边的路径可能还会发现某些多媒体目录比如照片、视频仍然能通过MediaProvider的数据库查询暴露出去。这种情况需要额外处理MediaProvider侧的MTP权限。通常做法是在MediaProvider的外部存储访问策略里把限定目录之外的路径排除掉。因为不同版本MediaProvider的代码差别很大我这里不写死代码只提醒一点不要只顾MTP服务端MTP查询数据源也要一并收口。3.6 一个更省事的变体干脆不注册内部存储卷如果你的定制场景完全不需要显示内部存储只允许访问一个独立的工作目录可以考虑更彻底的方案MtpServer一个内部存储卷都不注册只注册一个指向/data/workdir的自定义卷。这样做的风险在于/data/workdir通常不在外部存储域内MtpServer进程访问它会遇到SELinux限制需要在SELinux策略里给mtp进程放行该路径。但好处也很明显目录干净、路径可控性强电脑端完全看不到任何内部存储的影子安全性最高。适合对数据隔离要求比较严格的行业终端。4. 交叉验证与避坑别让修改被“缓存”骗了4.1 验证清单连电脑后逐项确认改完代码、编译刷机后验证不能只看“目录少了没”。我的标准清单是这样的设备连电脑后确认电脑端能正常识别MTP设备且没有报错打开MTP目录确认根目录下只剩限定目录里的内容逐级进入子目录确认深层目录也没有越界路径从电脑端拷贝一个文件到限定目录确认文件能正常写入且不会跑到限定目录之外从电脑端删除限定目录里的一个文件确认删除操作正常返回尝试从电脑端打开一个限定目录外的已知路径比如用资源管理器的手动输入路径确认访问被拒绝或返回空。不要省略第6步。我见过很多只验证了目录数减少、却漏了路径穿越验证的项目最后被别人用真实路径直接绕过去那才是真正的翻车。4.2 Windows端MTP缓存的坑Windows会缓存MTP设备的结构信息。你改了MTP暴露目录后第一次连接电脑可能仍然看到旧的完整目录。这不是代码没生效而是Windows的MTP驱动缓存了之前会话的目录树。处理办法很粗暴在“设备和打印机”里找到设备删掉记录重新插拔或者在“设备管理器”里卸载MTP设备驱动再重装。这一步一定要记录到交付文档里否则客户第一次连电脑时看到旧目录会认为你们根本没做修改。4.3 system_server里的存储状态没有刷新MtpService运行在system_server进程内。如果你只是在应用的存储配置里改了限定目录但system_server里的StorageManagerService仍然持有旧的卷信息MTP可能还是按旧逻辑走。这也是我建议把“限定目录”做成MtpService内部常量而不是依赖动态配置文件的原因之一——避免因为配置文件更新不及时导致行为不一致。编译替换镜像后建议先拔掉USB线重启设备让system_server彻底用新代码重新拉起MtpService再连电脑验证。不要图省事在运行时用am restart之类的方式重启MtpService除非你确认整个USB Gadget链路都干净地重启了。4.4 SELinux拒绝访问限定目录定制过程中最常遇到的现象是电脑端能看到根目录下的内容但点击进入某个子目录时返回空或者文件列表一直是加载状态。这时候第一反应不是去看MTP协议而是去看设备日志里的SELinux拒绝记录adb logcat -b all | grep avc: denied如果看到mtp进程或mediaprovider进程访问限定目录时被denied那就是SELinux策略没放行。解决办法是给限定目录打上media_rw_data_file标签并确保mtp domain对该标签有读和搜索的权限。改完SELinux策略后需要用setenforce 0临时验证是否为SELinux问题确认后再把策略正式放进去不要为了省事直接关SELinux。4.5 分区存储与多用户的影响Android 11以后的分区存储让MTP默认暴露范围已经有了收紧但加上自定义限定目录后反而可能引入新问题。比如内部存储是emulated存储在实际文件系统里的路径可能是/data/media/0而MtpServer用户空间看到的路径是/storage/emulated/0当你在MTP层自定义路径时一旦卷的mount user id对不上电脑端可能什么都看不到。我调整这类问题时的经验是始终以限定目录的实际挂载路径为准不要在代码里混用/storage/emulated/0和/data/media/0。哪一层用哪个路径要在一开始就统一好否则排查起来非常痛苦。5. 更进一步虚拟卷、多目录合并与动态策略5.1 虚拟卷让电脑端看到一个独立的“工作盘”如果你连限定目录都不想让电脑端看到/storage/emulated/0/Work这种嵌套路径可以做一个虚拟卷注册一个不存在的卷路径在MtpDatabase内部维护“虚拟路径到真实路径”的映射。电脑端看到的卷名是“工作盘”里面直接就是Work目录下的文件完全感知不到设备端的真实目录结构。实现虚拟卷的核心是维护一张映射表。比如虚拟根路径/对应真实路径/storage/emulated/0/Work虚拟路径/Document对应真实路径/storage/emulated/0/Work/Document每次收到MTP查询请求时先把虚拟路径转换成真实路径再去做文件枚举。电脑端所有可见信息都停留在虚拟路径层真实路径永远不外传。5.2 多目录合并成一个MTP卷行业设备经常会有这样的需求把内部存储的Projects和SD卡的Backup合并成一个逻辑卷。从用户角度看这是一个目录但文件实际上分散在两个物理设备上。这时虚拟路径映射表就派上用场了。虚拟根目录下可以定义两个子目录虚拟路径/Projects对应/storage/emulated/0/Projects虚拟路径/Backup对应/storage/XXXX/BackupMtpDatabase在枚举时需要把虚拟根目录下的子对象列表从两条真实路径合并然后再按虚拟路径返回给电脑端。这个方案实现难度比单一目录高不少但还是可控的关键是对象ID的管理要和路径映射严格对应否则会出现“能浏览但无法复制文件”的诡异问题。5.3 与设备管理策略联动动态切换MTP可见范围把限定目录做成硬编码常量只是起步。做成可动态配置之后能玩的花样就多了。比如企业设备可以在DeviceOwner里下发一个配置指定MTP只暴露某个工作目录访客模式登录时MTP暴露目录自动切换到一个临时目录某应用进入保密模式后MTP直接不注册任何存储卷。实现上只需要在MtpService里暴露一个自定义接口比如setMtpExposedPath(String path)内部立刻重新配置MtpServer的存储卷并重启MTP会话。需要注意的是切换时要把USB连接断开重连否则电脑端缓存的目录树不会更新。从我个人的实操体会来说做MTP限定目录最忌讳一上来就想把方案做得“完美”。正确路径是先做方案B把主逻辑跑通验证确实只能看到限定目录再按需叠加SELinux兜底、虚拟卷映射和动态切换。等这套东西跑顺了你会发现它不仅解决了“不暴露sdcard所有内容”的诉求还顺带把整机的数据隔离能力都提升了一个档次——后面不管客户提多离谱的需求你都有一个稳定的技术底座去接。