ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Android MTP目录限定访问:Framework层白名单方案与实现

Android MTP目录限定访问:Framework层白名单方案与实现 接手过不少Android系统定制项目这个算比较典型的“行业终端数据管控”诉求电脑用USB线连上设备后只能看到我们指定的目录其余文件一概不可见。Android默认的MTPMedia Transfer Protocol服务一开整个共享存储大家叫惯了的sdcard都会被端到PC端DCIM、Download、Documents以及各种应用落在公共区域的文件电脑上一拉一个准。真正要在Framework层做的事就是给MTP服务加上“目录限定访问”把USB文件传输这个口子收窄只暴露白名单内的那棵树其余目录保持不可见、不可读、不可写。这篇文章把我实际改过的方案、选型过程、关键代码思路和踩过的坑完整写出来。项目背景是行业平板和教育/企业终端核心场景就是防数据外带和合规审计。做Android Framework定制、系统集成或者在为设备数据防外泄方案发愁的同学可以直接参考。1. 先捋清楚MTP这条访问链路1.1 MTP协议短课Storage、Root、Object、HandleMTP是从PTPPicture Transfer Protocol扩展而来的传输协议大量用在数码相机、Android手机连接电脑的场景。它和U盘LUNUMS有一个本质区别UMS是PC直接挂着块设备读写MTP则在应用层把文件抽象成“对象”PC端通过协议命令来浏览、读取、写入从不直接操作文件系统。协议里几个关键概念容易混淆先理清楚Storage一个存储区。MTP设备可以有多个StoragePC客户端Windows资源管理器会把每个Storage显示成一个存储库。Root / PARENT_ROOT每个Storage有一个根对象。枚举根对象的子对象相当于看这个存储的根目录内容。Object一个文件或目录。每个Object有ObjectHandle对象句柄、ParentHandle父句柄、Format对象格式类型、ProtectionStatus保护属性等元数据。Handle会话期间对对象的引用。PC拿到一个ObjectHandle后才能继续请求对象的属性、内容、缩略图等。Android设备一个常见特殊情况是通常把内部共享存储作为一个Storage暴露路径就是/storage/emulated/0由FUSE挂载。标题里说的“sdcard”在现代Android设备上其实就是指这个内部共享存储而不是物理SD卡。MTP默认端出去的是这一整棵树。1.2 Android Framework里的MTP组件和各自职责Android系统实现MTP涉及一批Framework层组件各自职责如下组件位置职责MtpServicepackages/services/MtpMTP后台服务管理生命周期和USB连接状态MtpServerpackages/services/MtpMTP协议主处理接收协议命令并分发MtpStorageManagerpackages/services/Mtp管理存储卷Storage注册和Root对象MtpDatabasepackages/providers/MediaProviderMTP与MediaProvider之间的数据桥提供对象枚举、句柄解析MediaProvider / MediaStorepackages/providers/MediaProvider系统媒体数据中枢实际查询底层文件和媒体数据库FUSE / ExternalStorageProvidersystem/vold共享存储的用户空间文件系统挂载平时大家说“改MTP”大多数情况改的是MtpServer和MtpDatabase这两层。MtpServer是协议逻辑层它翻译PC发来的MTP命令MtpDatabase是数据访问层它负责把/storage/emulated/0下的文件树映射成MTP对象树。1.3 一次文件列表请求在链路里走多远以Windows资源管理器打开MTP设备根目录为例完整链路是PC发送GetObjectList(PARENT_ROOT)请求 →MtpServer.handleGetObjectList→MtpDatabase.getObjectList→ MediaProvider查询数据库/FUSE → 返回对象列表 →MtpServer.sendObjectList回复PC。读文件也类似PC先GetObjectInfo拿对象属性再GetObject拿到文件内容中间会经过MtpDatabase.getObjectFilePath解析真实路径然后打开文件流。这个链路里看似每一环都能做限制但实际效果和改动成本差别巨大。理解了链路才能理解下面选型为什么这样做。2. 限定访问的落点选在哪里四个候选位置2.1 Root/存储卷层直接把白名单目录注册成存储根最直觉的做法是在MtpStorageManager创建Storage时把白名单目录路径直接当成Storage的根路径。这样PC端看到的存储卷根就是我们的共享目录。这个方案实现简单一个目录替换就能生效。但问题也很明显MTP的对象句柄在会话内是全局的如果底层存储卷的注册没有彻底收口PC仍然可能通过已知句柄直接请求其他对象。而且这种方式只影响“PC看到什么根”并没有影响“MTP能解析哪些句柄”。所以只改Root层不够适合作为体验优化的一部分。2.2 数据库查询层给MediaStore查询加白名单条件MTP的对象列表最终来自MediaProvider的数据库查询。如果能在MtpDatabase.getObjectList等方法内把查询条件加上_data 白名单目录 或 _data LIKE 白名单目录/%那PC无论怎么枚举都只能拿到白名单目录内的对象。这是真正的“源头切断”性能也最好SQLite的LIKE base/%能走_data字段的前缀匹配大数据量下依然可接受。这条还有一个隐藏优势MtpDatabase实现只服务于MTP模块不会误伤其他内容提供者的MediaStore查询。2.3 MTP协议层句柄与路径的全面校验在MtpServer的各个操作码入口对目标句柄做路径白名单校验。比如PC发来GetObjectInfo(handle)时先调getObjectFilePath(handle)解析出真实路径判断路径是否在白名单内。这是覆盖面最全的兜底方案能覆盖所有协议命令包括GetObject、DeleteObject、MoveObject、SendObject等。缺点也明显如果每个对象列表里的元素都去回查数据库会产生大量Binder IPC和SQLite查询性能很差。所以协议层适合用在校验读写入口而不是列表枚举。2.4 文件读写层openFile时的最终检查最后还有一道防线是openFile即真正要打开文件流时再做一次路径校验。这个层面更多是防“穿透”比如某个对象曾经是合法句柄但文件被替换或目录被重命名导致路径变化。做了这一层哪怕前面漏了最终打开文件时也能拦住。四个位置对比下来我的结论是查询层做源头切断协议层做关键入口兜底Root层做PC端体验优化读写层做最终防线。四层组合起来才能把“限定目录”这件事做扎实。3. 我的最终方案查询层过滤加协议层兜底3.1 准备一份“暴露路径白名单”首先要解决一个问题白名单到底怎么写。很多项目会用系统属性持久化方便工厂和运维改配置比如ro.mtp.allowed.root。也可以写成配置文件但行业设备尤其是离线部署场景系统属性更稳Framework层读取时机也好控制。我在这个项目里用系统属性做了个默认值同时兼容配置读取public final class MtpPathGuard { // 白名单目录示例/storage/emulated/0/SharedBox public static final String DEFAULT_ALLOWED_DIR /storage/emulated/0/SharedBox; public static String getAllowedDir() { String dir SystemProperties.get(ro.mtp.allowed.root); if (dir null || dir.isEmpty()) { dir DEFAULT_ALLOWED_DIR; } // 统一去掉末尾斜杠方便后续拼接判断 while (dir.endsWith(/) dir.length() 1) { dir dir.substring(0, dir.length() - 1); } return dir; } }路径写死/storage/emulated/0前缀有个隐患/data/media/0和/storage/emulated/0可能是同一个FUSE视图的不同路径表达。正常MTP会话拿到的路径都是/storage/emulated/0开头所以实际项目里可以按这个前缀约定来但如果设备启用了多用户或多存储卷最好动态取Environment.getExternalStorageDirectory()再拼白名单目录。另外启动MTP服务时最好确认目录存在不存在就创建private void ensureExposedDirExists() { File dir new File(MtpPathGuard.getAllowedDir()); if (!dir.exists() !dir.mkdirs()) { Log.w(TAG, create exposed dir failed: dir); } }3.2 拦截点一getObjectList 从源头切断目录树getObjectList是MTP枚举目录的核心方法。以AOSP中MtpDatabase的实现为基础思路是当PC枚举根对象parentHandle为0xFFFFFFFF时不再返回存储根下的真实内容而是返回白名单目录这一个对象作为根“子”节点。这样从根开始PC能枚举到的整棵树都在白名单目录内部。关键代码示意如下// 基于AOSP MtpDatabase实现的改动示例 public int getObjectList(int storageId, int parentHandle, int format, Object[] out) { if (parentHandle 0xFFFFFFFF) { // 根枚举只返回白名单目录对象 return queryExposedRoot(storageId, out); } // 子节点枚举先验证父句柄是否在白名单树内 if (!isHandleInAllowedTree(parentHandle)) { out[0] new MtpObjectInfo[0]; return 0; } return doQueryChildren(storageId, parentHandle, format, out); }queryExposedRoot的实现我建议直接查数据库里_data 白名单目录的对象记录。如果MediaStore已经有这个目录的扫描记录那返回的MtpObjectInfo里天然带正确句柄后续getObjectFilePath也能解析如果数据库里还没收录需要手动构造一个MtpObjectInfo并且维护一套“句柄到路径”的映射表否则PC拿到句柄后无法继续访问。这就是为什么我强调开机后要确保白名单目录被MediaStore扫描收录。我在ensureExposedDirExists之后会主动触发一次扫描private void triggerScanForExposedDir(Context context) { MediaScannerConnection.scanFile(context, new String[]{MtpPathGuard.getAllowedDir()}, new String[]{*/directory}, null); }3.3 拦截点二getObjectInfo 与 getObjectFilePath 双重校验目录树虽然从源头收窄了但MTP协议允许PC直接按已知句柄请求对象信息比如GetObjectInfo(0x10001)。要防止PC端绕过枚举逻辑getObjectInfo和getObjectFilePath必须做路径校验。核心校验函数public static boolean isAllowedPath(String path) { if (path null) return false; try { String target new File(path).getCanonicalPath(); String base new File(getAllowedDir()).getCanonicalPath(); if (target.equals(base)) return true; // 必须带斜杠判断防止 /SharedBoxEvil 这类目录绕过 String prefix base.endsWith(/) ? base : base /; return target.startsWith(prefix); } catch (IOException e) { return false; } }getObjectFilePath(handle, path)是MTP服务从数据库拿真实路径的通道在返回路径前再校验一次public int getObjectFilePath(int handle, String[] outFilePath) { // 原逻辑先解析出路径 int result doGetObjectFilePath(handle, outFilePath); if (result ! 0 || !MtpPathGuard.isAllowedPath(outFilePath[0])) { return -1; } return 0; }getObjectInfo也一样要么返回对象在数据库里的真实属性要么直接返回内部错误。我实测中遇到过一种情况PC端一次性通过枚举拿到一批句柄然后逐个GetObjectInfo。如果这些对象里混入了一条白名单外的处理方式不是“跳过这条”而是整个请求返回错误或者跳过该对象否则PC端会报错卡界面。3.4 拦截点三写操作收口MTP不只读还有写。SendObjectInfo、SendObject对应PC拖文件进设备DeleteObject对应删除MoveObject对应移动。这些操作如果只查根目录树不校验目标路径会出现“白名单外的目录里有文件但因为句柄解析漏洞被写入”的情况。写操作校验重点是目标父句柄public int beginSendObject(int storageId, int parentHandle, int format, String name, long size, long modified) { // 父句柄必须存在于白名单树内 if (!isHandleInAllowedTree(parentHandle)) { return -1; } return doBeginSendObject(storageId, parentHandle, format, name, size, modified); }DeleteObject、MoveObject类似校验目标句柄即可。值得一提的是这些方法里如果只校验了“父句柄在白名单内”还得防止父目录被换过比如父目录本身在白名单内但它下面通过符号链接把目标文件链到了外部路径。FUSE挂载的共享存储默认不开放任意symlink所以这个风险很低但做严格的系统定制时getCanonicalPath的规范化已经够用。3.5 一个容易被忽略的根Root处理根处理是整个方案的体验关键。我当时花了点时间想清楚要让PC双击设备后直接看到共享目录的内容最好的做法是把Storage的根路径直接设置为白名单目录而不是保留存储根再返回一个“虚拟子目录”。第一种做法推荐在MtpStorageManager创建MtpStorage时直接指定根路径MtpStorage baseStorage new MtpStorage( STORAGE_ID_INTERNAL, MtpPathGuard.getAllowedDir(), // 根路径 白名单目录 SharedBox);这样做之后getObjectList(根)枚举的就是白名单目录下的直接子项PC看到的是“一进去就是共享目录内容”逻辑最简单也完全不需要合成虚拟句柄。第二种做法是保留原存储根在根枚举时只返回一个“SharedBox”子目录。这样PC能看到一个“存储库 SharedBox 子内容”的结构。听起来更“专业”但要合成为一个MtpObjectInfo得自己管理一个句柄映射表否则GetObjectInfo、GetObjectFilePath都没法从MediaStore查到这个虚构根对象。除非客户明确要求“根下要有一层文件夹”否则别选这个维护成本高容易出诡异Bug。4. 必踩的坑和排查经验4.1 PC端目录缓存怎么都刷新不出来Windows资源管理器对MTP设备是有缓存机制的。改完MTP白名单PC上可能还显示旧的目录树或者明明已经过滤掉了某个目录PC侧依然能看到“幽灵目录”点进去才报错。排查思路先在PC端把“便携设备”从设备管理器里卸载或者直接重启电脑再试这能排除90%的缓存问题。另外MTP会话内的对象句柄缓存也要注意重启MTP服务相应地会清掉句柄映射。实测定制机上断开USB重连比只刷新资源管理器更可靠。4.2 目录里有文件但MTP列表是空的这是很典型的场景白名单目录是应用运行时创建的MediaStore还没来得及收录MTP枚举时数据库里查不到这个目录于是PC端看到的是一个空白的存储卷。解决办法就是上面说的创建目录后主动触发MediaScannerConnection.scanFile。如果项目里无法用这个类可以直接发起一条数据库插入事件的广播或者调用MediaProvider的scanFile服务。我这里遇到过一次更深的坑scanFile只扫描了目录本身没有递归扫描子目录。对于运行期动态生成的内容子目录的文件还是在列表里时有时无。最终做法是在目录创建时就规划好固定子目录结构并在创建完成后统一扫描一遍整棵白名单树。4.3 路径规范化别让相对路径绕过白名单路径判断最忌讳用path.contains(/SharedBox/)这种字符串判断。路径里出现..、双斜杠、软链接时就会漏。比如/storage/emulated/0/SharedBox/../Downloads/secret.txt字符串层面上它是从SharedBox开头的但规范化后实际指向的是Downloads。所以校验必须走getCanonicalPath()并且判断前缀是base /而不是base本身否则/SharedBoxEvil这种目录也会被误判成白名单内目录。4.4 性能别在MtpServer层逐对象回查数据库我最早版本的方案是在MtpServer的列表枚举里对每个对象都调getObjectFilePath校验一次。结果一个几千文件的目录在Windows上枚举要卡几秒因为每个对象都要走一次Binder IPC到MediaProvider。正确做法是“查询层过滤优先”在MtpDatabase.getObjectList阶段直接把白名单条件拼进数据库查询比如追加(_data LIKE /storage/emulated/0/SharedBox/%)让SQLite一次性过滤。这样MtpServer拿到的对象列表本来就是合法的不需要逐对象回查。协议层兜底校验只放在GetObjectInfo、GetObject、Delete、Move这类单个对象操作上数量不大性能影响可以忽略。优化前枚举一个5000文件的目录约3秒优化后基本在1秒内。这个差别在客户现场是能明显感受到的值得专门强调。4.5 句柄与路线一致性重命名、移动后的“幽灵项”MTP对象句柄在很多实现里来自MediaStore数据库的_id。如果白名单目录本身被重命名或者内部文件被移动数据库里的路径变了但PC会话里缓存的句柄可能还是旧的。此时如果再请求这些句柄getObjectFilePath能拿到新的路径校验可能通过也可能失败但文件内容对不上PC会显示损坏或无法访问。我的处理方式很实际在定制系统里白名单目录及其根级别的子目录不允许通过MTP改名或移动。另外在MTP服务启动时手动清空句柄映射缓存保证每次新会话从最新数据库状态开始。如果业务上确实需要动态更新目录结构就在更新完成后强制重启MTP服务让PC端重连。5. 上线前验证清单与安全复盘5.1 多平台MTP客户端验证改完框架层不能只看Windows资源管理器正常就完事。不同MTP客户端的差异很大macOS的Android File Transfer走的一套协议Linux的GVFS又是另一套。我整理了下面这张验证清单直接在定制机上过一遍用例操作预期结果根浏览打开MTP设备根只能看到白名单目录内容子目录深度访问逐级进入子目录所有子目录可见并可读文件复制到PC从设备拖文件到电脑复制正常文件从PC写入拖文件到白名单目录写入正常写入白名单外手动输入地址访问其他目录拒绝或目录不可见删除操作删除白名单内文件删除成功删除白名单外尝试删除其他目录文件失败或无权限直接句柄访问用库工具按已知句柄请求返回拒绝或错误相对路径模拟../等路径访问无法越出白名单Windows、macOS、Linux三个平台都跑一遍才算完整验证。我自己的经验是Windows对协议错误比较宽容某些非法句柄它会静默跳过macOS出现异常时反而会整个设备弹错。两边通过的方案基本可以放心。5.2 常见MTP错误码补充调试时logcat里常看到MTP返回的错误码列出来方便快速定位0x2001General Error内部异常通常是因为路径解析失败或数据库查询返回异常。0x2009Invalid ObjectHandle句柄无效常见于白名单目录被重建后旧句柄失效。0x200FAccess Denied访问拒绝路径校验没通过时会返回这个正好作为白名单拦截的响应。0x2010No Such Object对象不存在数据库里没有对应记录。我在框架层过滤时对不在白名单内的对象更倾向于直接返回0x200F而不是0x2001。因为0x2001容易被误报成系统错误0x200F语义明确PC端也更好处理日志里也方便区分是“真故障”还是“被拦截”。5.3 别忘了MTP不是唯一外传通道最后想提醒一句MTP只是USB口上一个功能模块。如果设备上开着USB调试ADBadb pull可以绕过MTP白名单直接拉取共享存储内容如果开了FTP服务或蓝牙文件传输也有同样问题。行业终端做数据防泄露通常是组合拳USB接口功能裁剪到最小集行业设备常见做法是关闭ADB、关闭UMS大容量存储只留MTP白名单模式。系统层做应用级文件访问审计白名单目录的读写事件记日志。若设备面向儿童或教育场景还要同步限制蓝牙、WiFi直连和第三方网盘应用的可用性。我见过不少项目只封堵了MTP结果客户现场用ADB一拉数据就穿了。所以MTP限定访问是“USB传输”这个场景的正解但它不能替代整体数据安全方案。我做这类定制时都会在交付文档里专门列出“其他外传通道”的关闭建议。最终小结这套方案目前在实际定制机上运行得比较稳定核心就三点MtpDatabase查询层从根上限制对象树MtpServer协议层对单对象操作做路径兜底MtpStorageManager把存储根直接改成白名单目录。实现上不难真正花时间的是把PC端各种MTP客户端的异常行为和数据库收录细节调顺。如果你正在做类似功能建议先从MtpDatabase.getObjectList的根枚举下手把一个目录过滤做通再逐步补上写操作和协议层校验。遇到PC端“看到了却打不开”、目录空白这类问题先查MediaStore有没有收录再查路径校验是否被相对路径绕过基本都能定位。这个功能做完之后客户那边再也没提过“电脑上能看到所有文件”的问题算是Framework定制里性价比很高的一次改动。
返回列表