做 Android 逆向或者做兼容性排查的时候,卡住人的第一步往往不是反编译、不是脱壳,而是一个更朴素的问题:这个应用到底被装到了机器上的哪个目录里?包名叫什么?正在跑的那个进程对应的又是谁?这几个问题看着简单,可真到命令行里敲的时候,路径拼错了、查出来是软链接、拿到的是 content:// 开头的 URI、高版本系统上进程列表一片空白,各种情况都能遇到。这篇就把"应用安装目录""包名""安装路径"这条链路完整走一遍,从 adb 命令行到 PackageManager 的 API 层,从 APK 落盘位置到外部存储的数据目录,把每一步的来龙去脉和踩坑点都说清楚。
内容偏实战,命令都是可以直接复制粘贴跑的那种。适合两类人:一类是刚开始接触 Android 应用结构、想搞明白"文件都去哪了"的开发者;另一类是做逆向分析、需要在设备上定位目标 APK 和它的运行痕迹的同学。前置要求不高,会开 adb、能用终端就行,root 不是必须的,但我会标出来哪些操作绕不开 root。
1. 先搞清楚"安装目录"这四个字在 Android 里到底指什么
很多人第一次搜"Android 应用安装目录",期望的答案是"一个路径"。但 Android 的设计里,一个应用从装上到跑起来,至少会牵扯到五个不同的物理位置,随便拿一个出来说"这就是安装目录",后面一定会出问题。所以动手之前,先把这几个位置的名字和职责对齐,比背命令重要得多。
1.1 一个应用至少对应五个物理位置
我把它们列成一张表,这张表我在实际排查里几乎是当字典用的:
| 位置 | 典型路径 | 里面装的是什么 |
|---|---|---|
| APK 本体目录 | /data/app/~~随机串==/包名-随机串==/ | base.apk、split 分包、lib 目录 |
| 私有数据目录 | /data/user/0/包名/ | shared_prefs、databases、files、cache |
| 设备加密数据目录 | /data/user_de/0/包名/ | 开机即需访问的那部分数据 |
| 外部数据目录 | /storage/emulated/0/Android/data/包名/ | 应用自己往公共存储写的文件 |
| 外部媒体与 OBB | /storage/emulated/0/Android/media/包名/、/storage/emulated/0/Android/obb/包名/ | 媒体文件、游戏资源包 |
系统预装应用则是另一套逻辑,它们不在/data/app下,而是躺在/system/app、/system/priv-app、/product/app、/vendor/app这些只读分区里。你用pm path去查一个系统应用,返回的可能是/system/priv-app/XXX/XXX.apk,也可能是被 OTA 更新过后的/data/app版本——这就涉及同一个包名在设备上存在两份安装体的问题,后面第 6 节会专门讲。
一句话总结:APK 本体管"程序从哪加载",/data/user/0/包名管"程序的数据写哪去",外部目录管"用户看得见的文件放哪"。你要定位的是哪一种,决定了该敲哪条命令。
1.2 为什么 Android 8.0 之后路径变成了一串随机字符
如果你在 Android 7 及以前的机器上执行pm path com.example.app,得到的大概是这样,清晰好认:
package:/data/app/com.example.app-1/base.apk但从 Android 8.0 开始,同样的命令返回的东西会变得很"丑":
package:/data/app/~~kR3n7QxLm9vA2bCdEfGh==/com.example.app-xY8zWvUtSrQp==/base.apk这两个~~包裹的随机串不是乱码,也不是加密,而是系统在安装时为每个应用生成的唯一目录名。为什么要这么干?核心原因是防止应用之间互相干扰和预置攻击。旧方案里路径直接由包名推导,攻击者只要拿到包名就能预测路径,进而做目录抢占、符号链接替换这类操作;同时包名相同就必然路径相同,多个版本共存时会互相覆盖。改成随机串之后,路径不可预测,同名包也能安全共存(比如企业工作资料里的克隆应用)。
这个变化对逆向分析最直接的影响是:你不能再用包名去拼 APK 路径了。写脚本的时候一定要走pm path或者 PackageManager 查询,把结果当作不透明字符串来用。我见过不止一个人写了/data/app/+ 包名 +-1/base.apk这种拼接,在 Android 9 以上的机器上全军覆没。
1.3 符号链接把 /data/data 藏到了 /data/user/0 后面
还有一个特别容易把人绕晕的设计。你在老资料里看到的数据目录是/data/data/包名,可现在查出来却是/data/user/0/包名。这俩其实是同一个地方:
ls -l /data/data # lrwxrwxrwx 1 root root 7 ... /data/data -> /data/user/0/data/data是个符号链接,指向/data/user/0,而0就是主用户的 user id。Android 从 4.2 引入多用户之后,每个用户的数据目录是/data/user/<userId>/包名,主用户是 0,第二个用户就是 10、11 这样递进。
这里有个坑:ApplicationInfo.dataDir这个字段在不同版本、不同调用方返回的字符串不完全一致,有时给/data/data/包名,有时给/data/user/0/包名。如果你拿它去做字符串比较或者当 Map 的 key,很容易因为两种写法对不上而出错。稳妥的做法是用它做展示,但比较时统一用File.getCanonicalPath()归一化之后再比。
顺带说一句/data/user_de/0/包名。de是 device encrypted 的缩写,直连加密。这部分数据在用户第一次解锁屏幕之前就能被访问,所以系统级组件、闹钟、短信这类需要在开机早期工作的应用会把关键数据放这儿。逆向时如果只在/data/user/0里翻,可能会漏东西。
2. 只用 adb 和 shell:最省事的路径查询链路
搞清楚目录结构之后,接下来就是"怎么查"。我的习惯是:能用 adb 一条命令解决的,绝不先写代码。因为命令行反馈快、可组合,而且不需要装环境。这一节把常用命令按使用频次排一下。
2.1 pm path:一行命令拿到 APK 落盘位置
这是最核心的一条命令,没有之一:
adb shell pm path com.example.app返回内容可能是单行,也可能是多行——多行说明这个应用用了 Split APK(App Bundle 分发出来的应用基本都是这种),base.apk 加上若干个 config 分包:
package:/data/app/~~kR3n7QxLm9vA2bCdEfGh==/com.example.app-xY8zWvUtSrQp==/base.apk package:/data/app/~~kR3n7QxLm9vA2bCdEfGh==/com.example.app-xY8zWvUtSrQp==/split_config.arm64_v8a.apk package:/data/app/~~kR3n7QxLm9vA2bCdEfGh==/com.example.app-xY8zWvUtSrQp==/split_config.xxhdpi.apk想把整套 APK 拉下来做分析,就在宿主机的 shell 里写个循环:
for p in $(adb shell pm path com.example.app | sed 's/package://'); do adb pull "$p" done这里有个细节值得注意:pm path的输出带package:前缀,而且行尾可能有\r。在 Windows 上做脚本处理时,如果不处理这个回车符,拼出来的路径会带一个不可见字符,adb pull会报文件不存在。用tr -d '\r'清一下最省心。
另外,Android 10 以后也支持cmd package path com.example.app,输出格式略有不同,但功能等价。如果你的设备上pm有点异常(有些定制 ROM 会裁剪),可以拿cmd package试试。
2.2 pm list packages 的几个过滤开关
当你不知道确切包名,需要"先看有哪些应用"时,用这几个开关组合:
adb shell pm list packages -f # 带 APK 路径的完整列表 adb shell pm list packages -3 # 只看第三方应用 adb shell pm list packages -s # 只看系统应用 adb shell pm list packages -d # 只看被禁用的 adb shell pm list packages -e # 只看启用的 adb shell pm list packages -u # 包含已卸载但保留数据的 adb shell pm list packages | grep 关键词 # 模糊匹配-f的输出格式是package:/data/app/xxx/base.apk=com.example.app,路径和包名用等号连接。这个格式很好用,一次就能同时拿到路径和包名,不用二次查询:
adb shell pm list packages -f -3 | sed 's/package://' | awk -F'=' '{print $2"\t"$1}'要提醒的是,pm list packages的列表是系统视角的完整列表,但它有个坑:被"暂停"/"挂起"状态的包、工作资料里的包,在不同 ROM 上表现不一致。有些国产 ROM 会把自家的应用商店、推送服务隐藏在标准列表之外。如果你是在做安全排查,pm list packages的结果不能当作全集,还得去/data/app目录本身列一遍做交叉验证(这一步需要 root)。
2.3 不安装也能查包名:aapt 与 apkanalyzer
手里有一个 APK 文件,但还没装到设备上,怎么知道它的包名?这是逆向流程里最先要做的一步。三个方法,我按推荐度排:
第一种,用aapt(Android SDK build-tools 里自带):
aapt dump badging app.apk | grep "^package" # package: name='com.example.app' versionCode='1024' versionName='3.2.1'第二种,用aapt2,更简洁:
aapt2 dump packagename app.apk第三种,用apkanalyzer(SDK cmdline-tools 里):
apkanalyzer manifest print app.apk | head -20它会把 AndroidManifest 解析成可读 XML,package属性一眼就能看到,顺便还能看到applicationId和权限声明。
这里顺便解释一个很多人遇到的报错:"您上传的 APK 包名已存在"。这通常发生在往应用市场提包的时候。原因不是包名重复了,而是你上传的这个包名,在平台上已经被另一个开发者账号占用,或者你自己之前提过同包名的应用。解决办法是改build.gradle里的applicationId,改完重新签名。要注意applicationId和namespace是两回事,改的时候别只改一个。而如果你的应用已经上线、有存量用户,改applicationId等于做了一款新应用,老用户不会收到更新,这个代价要先想清楚。
2.4 dumpsys package 里值得逐行看的字段
pm path只给你一个路径,想知道更全的信息就得靠dumpsys package:
adb shell dumpsys package com.example.app输出很长,几百上千行,但真正值得关注的字段就那么几个。我一般这样过滤:
adb shell dumpsys package com.example.app | grep -E "codePath|resourcePath|dataDir|primaryCpuAbi|versionName|firstInstallTime|lastUpdateTime|flags="各字段含义如下:
codePath:代码(也就是 APK)加载路径,和pm path一致resourcePath:资源路径,正常和 codePath 相同,做过插件化/资源热修复的可能不同dataDir:私有数据目录,一般形如/data/user/0/包名primaryCpuAbi:主 ABI,arm64-v8a、armeabi-v7a这类,决定了它加载哪一套 native 库flags=:一大串十六进制位标记,能看到是不是 debug 包、是不是系统应用、能不能被其他应用查询等等
flags这块信息量其实很大,但文档分散、不同版本位定义还会变,我一般只挑几个稳定的看,比如是否带DEBUGGABLE。判断一个应用是不是可调试,还有个更直接的办法:
adb shell dumpsys package com.example.app | grep -i "flags" | tr ' ' '\n' | grep -i debug或者干脆用run-as试一把,能进去就是可调试的,这个下一节会细说。
3. 查当前正在跑的包名:进程、窗口、Activity 三条线
装在哪查完了,接下来是"现在正在跑的是谁"。这个需求在做自动化、做抓包、做界面分析的时候特别常见。方法不止一种,各有各的适用边界。
3.1 ps、pidof、top:还在用但要看版本
最传统的方式是列进程:
adb shell ps -A | grep com.example adb shell ps | grep com.example # Android 8 以前用这个 adb shell pidof com.example.app # 直接拿 PID,最干净pidof是 Android 6 以后引入的,输出就是纯数字 PID,特别适合塞进脚本。拿到 PID 之后,很多后续操作就打开了,比如查这个进程打开了哪些文件、占用了哪些端口:
adb shell ls -l /proc/<pid>/fd adb shell cat /proc/<pid>/maps | head -40top也能用,但要注意 Android 上的top和 Linux 上的参数不一样:
adb shell top -n 1 | grep com.example这里的坑在于不同 Android 版本、不同 ROM 上ps的参数支持差异很大。Android 8 是一个分水岭,ps换成了 toybox 实现,-A才表示"全部进程",不加的话只显示当前用户的。我在 Android 7 的机器上敲ps -A直接被提示参数非法。写脚本的时候最好先探一次版本,或者干脆优先用pidof,兼容性最好。
3.2 dumpsys activity 与 dumpsys window 的分工
如果你要的不是"这个包有没有在跑",而是"当前前台是哪个页面",那就得换工具。这两条命令我都常用:
adb shell dumpsys activity activities | grep -E "mResumedActivity|mFocusedApp|topResumedActivity" adb shell dumpsys window | grep -E "mCurrentFocus|mFocusedApp"它们的区别值得说清楚:
dumpsys activity activities关注的是Activity 栈,能告诉你哪个 Activity 处于 resumed 状态,输出里有完整包名 + Activity 类名dumpsys window关注的是窗口焦点,mCurrentFocus通常是当前拿到输入焦点的窗口,弹窗、输入法弹出时会变
实际用的时候我一般两个都跑,互相印证。比如用户正在一个弹窗上打字,mCurrentFocus可能显示的是输入法进程或者系统弹窗,而mResumedActivity才是底下的宿主应用。只看一个很容易判断错。
还有一个更早的写法dumpsys activity top,输出里有个ACTIVITY行,格式是ACTIVITY com.example.app/.MainActivity pid=12345,一行同时给了包名、类名和 PID,做自动化的时候特别顺手。不过在新版本上它的输出结构有调整,索引位置会变,写脚本解析时要留个容错。
3.3 高版本上 getRunningAppProcesses 返回空名单的原因
从命令行切到代码里,很多人的第一反应是:
ActivityManager am = (ActivityManager) getSystemService(Context.ACTIVITY_SERVICE); List<ActivityManager.RunningAppProcessInfo> list = am.getRunningAppProcesses();然后发现list里只有自己,或者干脆是空的。这不是你写错了,而是系统从 Android 5.0 开始收紧了权限,第三方应用调用这个接口只能拿到自己进程的信息,要拿全局列表必须有系统级签名权限。
那应用市场上那些"进程管理"功能是怎么做的?一般两条路:
- UsageStatsManager:需要用户手动去设置里授予"使用情况访问"权限(这是一项特殊权限,不能通过运行时弹窗申请,只能跳设置页)。拿到之后可以按时间区间查询哪些包被放到过前台、前台停留了多久。
- 无障碍服务(AccessibilityService):监听窗口变化事件,通过
TYPE_WINDOW_STATE_CHANGED事件里的包名判断前台应用。这条路权限门槛相对低,但需要用户主动开启无障碍,且功耗和隐私合规上都要谨慎处理。
// UsageStatsManager 查询示例 UsageStatsManager usm = (UsageStatsManager) getSystemService(Context.USAGE_STATS_SERVICE); long now = System.currentTimeMillis(); List<UsageStats> stats = usm.queryUsageStats( UsageStatsManager.INTERVAL_DAILY, now - 60_000, now); for (UsageStats stats1 : stats) { if (stats1.getLastTimeUsed() > now - 30_000) { Log.d("TAG", "最近活跃: " + stats1.getPackageName()); } }这两条路我都用过。UsageStatsManager的问题是时间粒度粗、有延迟,不适合要求毫秒级响应的场景;无障碍服务的实时性好,但要处理大量事件过滤,而且用户随时可能关掉开关。选哪个取决于你的场景对实时性和权限侵入性的取舍。
4. 把查询逻辑写进代码:PackageManager 侧的 API 组合
命令行能解决的是一次性排查,但如果要做成一个工具、一个检测模块,就得落到代码上。这一节讲 PackageManager 这块几组 API 的差别,这几组 API 名字长得像,用错了结果差很远。
4.1 sourceDir、publicSourceDir 和 dataDir 的区别
ApplicationInfo里有这么几个长得像的字段,我第一次用的时候也搞混过:
| 字段 | 含义 | 典型值 |
|---|---|---|
sourceDir | 主 APK 的绝对路径 | /data/app/~~xxx==/包名-yyy==/base.apk |
publicSourceDir | 对外可见的 APK 路径,未做拆分时与 sourceDir 相同 | 同上 |
splitSourceDirs | Split APK 的路径数组,可能为 null | [split_config.arm64_v8a.apk, ...] |
splitPublicSourceDirs | 同上,对外版本 | 同上 |
dataDir | 私有数据目录 | /data/user/0/包名 |
nativeLibraryDir | native 库解压/加载目录 | /data/app/~~xxx==/包名-yyy==/lib/arm64 |
关键点在于:做 APK 备份或者拷贝的时候,不能只拷sourceDir。如果目标应用是 Split APK 分发的,只拿 base.apk 会导致装回去之后缺资源、缺 so 库,一运行就崩。正确做法是把splitSourceDirs一起收集:
PackageInfo pi = pm.getPackageInfo(pkgName, 0); ApplicationInfo ai = pi.applicationInfo; List<String> apkPaths = new ArrayList<>(); apkPaths.add(ai.sourceDir); if (ai.splitSourceDirs != null) { apkPaths.addAll(Arrays.asList(ai.splitSourceDirs)); }至于dataDir,要有个心理预期:第三方应用默认是无权读取它的内容的。你能拿到路径字符串,但new File(ai.dataDir).listFiles()会返回 null。这是正常的沙箱隔离,不是你代码写错了。要真正读到内容,要么应用自己debuggable=true配合run-as,要么设备 root。
4.2 包可见性过滤:为什么你的列表里少了半屏应用
Android 11 引入了一套叫"包可见性"的机制。简单说就是:你的应用默认只能看到自己和少量系统包,其他应用必须在清单里声明"我想看到谁",否则查不到。
症状很典型:一段在 Android 10 上跑得好好的代码,到了 Android 11 的机器上,getInstalledApplications返回的列表突然少了一大半。很多人第一反应是权限问题,去加QUERY_ALL_PACKAGES,结果发现有的渠道还上不了架。
正确的做法是精细化声明。在当前工程的AndroidManifest.xml里加<queries>节点:
<queries> <!-- 声明想查询的具体包名 --> <package android:name="com.example.target" /> <!-- 或者按 intent 特征声明 --> <intent> <action android:name="android.intent.action.SEND" /> <data android:mimeType="text/plain" /> </intent> <!-- 按 provider authority 声明 --> <provider android:authorities="com.example.target.fileprovider" /> </queries>如果你的工具确实需要枚举全部应用(比如设备管理类、安全检测类),那就得申请QUERY_ALL_PACKAGES权限,同时在应用市场提交时说明用途,审核会比较严格。
我的经验是:先按<queries>精细声明,真的不够用了再考虑全量权限。因为全量权限除了审核问题,在部分 ROM 上还会触发额外的权限提示,用户体验会变差。
4.3 外部存储路径不要在代码里拼字符串
查外部数据目录,我看到过这样的写法:
// 千万别这么写 String path = "/sdcard/Android/data/" + getPackageName() + "/files";这段代码在大部分机型上"看起来"能跑,但它依赖了三个不成立的假设:外部存储一定挂在/sdcard、一定存在、一定可写。实际情况是——有些设备有内置存储加实体卡两套外部存储,/sdcard可能是个软链或者根本不存在;应用被装到 adopted storage(合并后的存储卡)上时路径完全不同;Android 11 之后/sdcard/Android/data/包名对第三方应用和 adb shell 基本都关上了门。
标准写法就一行:
File externalFiles = getExternalFilesDir(null); // 典型结果: /storage/emulated/0/Android/data/包名/files File externalCache = getExternalCacheDir(); // 典型结果: /storage/emulated/0/Android/data/包名/cache这两个方法的好处是系统帮你算好了路径,返回的 File 对象一定落在应用有权读写的范围内。返回值可能为 null(外部存储不可用时),所以拿到之后要做判空,别直接.mkdirs()。
如果你就是要访问那个"用户能看见的公共目录",那得走MediaStore或者 SAF(存储访问框架),让用户主动选一个目录并授权。Android 10 之后引入了分区存储,直接写公共目录的路子基本被堵死了,硬闯只会拿到EACCES或者EROFS。
5. content:// 开头的 URI 为什么会把人带进沟里
做逆向的时候经常会截到一些字符串,长这样:
content://com.tencent.mobileqq.sharefileprovider/external_files/Android/data/com.xxx/... content://com.baidu.searchbox.fileprovider/baiddpath/Android/data/com.baidu... content://com.ss.android.uri.key/external_root/Android/data/com.ss.andro...第一眼看上去,路径信息全在里面,好像把前缀去掉就能得到真实文件路径。但这是一个非常经典的误解,必须单独拿出来讲。
5.1 FileProvider 的 external_path 并不是磁盘路径
content://是 Android 的内容 URI,它的格式是:
content://<authority>/<path-segment>/<相对路径>第二段(比如external_files、baiddpath、external_root)只是这个应用在 FileProvider 配置里给自己起的名字,是一个映射标签,不是目录名。真正的映射关系写在应用内部的 XML 配置里,形如:
<paths> <external-files-path name="external_files" path="." /> <external-path name="external_root" path="." /> </paths>这几个标签对应的真实根目录是固定的:
<files-path>→Context.getFilesDir(),即/data/user/0/包名/files<cache-path>→Context.getCacheDir()<external-files-path>→Context.getExternalFilesDir(null),即/storage/emulated/0/Android/data/包名/files<external-path>→Environment.getExternalStorageDirectory(),即/storage/emulated/0<external-cache-path>→Context.getExternalCacheDir()
即使你知道了标签对应的根目录,相对路径部分也未必是原样透传的。有些应用会在重写 URI 的时候做一层转义、拼接或者 base64 编码,甚至干脆用一张内存映射表把长路径映射成一个短 key。这种情况下,光靠字符串是还原不出来的。
5.2 从 content URI 反推真实路径的可行与不可行
那到底能不能还原?分情况:
可以还原的情况:应用用了默认的 FileProvider 实现,配置里就是简单的path="."透传,没有额外编码。这时候你在反编译工具里找到AndroidManifest.xml里的<provider android:authorities="xxx">节点,顺藤摸瓜找到它引用的res/xml/*.xml文件,把<paths>里的映射读出来,再和 URI 逐段对照,基本就能拼出真实路径。
不能还原的情况:应用自己继承了ContentProvider或者重写了getUriForFile,中间做了编码。这种就得去看反编译出来的 smali 或者用动态调试跟一遍调用栈,纯静态分析很难一步到位。
还有一个更省事的角度——如果目标应用本身拥有这个文件的读写权限,那么通过 URI 是可以直接读的:
adb shell content read --uri "content://com.example.provider/external_files/test.txt"或者用content query查数据库型的内容提供者。这条路绕开了路径还原的问题,前提是你知道 URI 并且没有被权限校验拦住(很多 provider 带android:exported="false"或者grantUriPermissions校验)。
我个人的判断标准是:如果只是为了读一份文件,走 URI 比还原路径省事得多;如果是为了理解应用的数据组织方式,那还原路径这件事本身才是有价值的。前者是手段,后者是目的,别搞反。
6. 实操中最容易翻车的几类路径误判
前面讲的都是"怎么查",这一节讲"查出来的东西为什么可能是错的"。这些坑基本都是我自己踩过或者看别人踩过之后记下来的,比正着讲命令更有用。
6.1 查到的是 /data/app/... 却不是唯一一份
同一个包名在设备上存在两份安装体,这在今天非常常见。最典型的就是系统预装应用被应用商店更新过:
adb shell pm path com.example.prebundled # package:/data/app/~~Abc==/com.example.prebundled-Xyz==/base.apk adb shell pm list packages -s | grep prebundled # package:com.example.prebundled ← 它同时还被标记为系统应用系统分区里的那份旧版本还在/system/app/...躺着,只是被/data里的新版本"覆盖"了。所以你查到的路径是新的,但安装包来源标记仍然是系统的。
这个现象带来的直接后果是:卸载更新(Uninstall updates)之后,应用会回退到系统分区里的老版本,路径变了、版本号变了、行为也可能变了。做逆向分析的时候,如果目标设备被卸载过更新,你分析的可能是个几年前的版本,跟线上跑的根本不是一套代码。
验证方法很简单,dumpsys package里的codePath、resourcePath和firstInstallTime、lastUpdateTime一起看。如果firstInstallTime很早但lastUpdateTime很近,基本可以确定是被更新过的。
6.2 多用户、工作资料与克隆应用带来的路径分叉
再来看另一个高频误判。很多国产 ROM 支持"应用分身"、"平行空间",企业设备还会配工作资料(Work Profile)。这些功能背后的实现基本都基于 Android 的多用户机制——每个分身其实是一个独立的 user id,拥有独立的数据目录。
adb shell pm list users # Users: # UserInfo{0:机主:13} running # UserInfo{10:工作:1030} running # UserInfo{999:分身空间:1030} running主用户是 0,分身一般是 10、11、999 这类。对应的数据路径就分叉了:
/data/user/0/包名 /data/user/10/包名 /data/user/999/包名如果你在主页面上操作应用、却在/data/user/0/包名里翻数据,很可能翻到的是主用户那份"没怎么用"的数据,真正活跃的是分身那一份。
还有一个并行的机制叫isolated process(隔离进程)和app clone,部分系统对每个用户的数据目录还会再加一层混淆路径。这种情况下,/data/user/<uid>/包名不再是简单拼接,得用ApplicationInfo实际查询。
排查建议:先用pm list users确认有几个用户,然后在每个用户上下文里分别查询。跨用户查包可以用:
adb shell pm list packages --user 10 adb shell pm path --user 10 com.example.app--user这个参数很多人不知道,但处理多用户场景时是刚需。
6.3 run-as 只对 debuggable 应用有效这件事
run-as是个很好用的命令,它让你以目标应用的 uid 身份执行命令,从而绕过沙箱读到/data/user/0/包名里的内容:
adb shell run-as com.example.app ls -l adb shell run-as com.example.app cat files/config.xml但它有个硬性门槛:目标应用必须在 AndroidManifest 里声明了android:debuggable="true",否则会直接报package not debuggable。发布版的正式包几乎都是debuggable=false,所以run-as对它们是无效的。
怎么快速判断一个包能不能run-as?直接试一次最快:
adb shell run-as com.example.app id # 成功: uid=10123(u0_a123) gid=10123(u0_a123) ... # 失败: run-as: package not debuggable: com.example.app如果真的需要读正式包的数据,路径只有两条:设备 root 后以 root 身份访问,或者应用自己做了数据导出/备份功能。还有一条历史遗留的路子——adb backup,但从 Android 12 开始,系统默认对allowBackup做了收紧,很多应用也主动关闭了备份,这条路能走通的场景越来越少,不建议作为主要方案。
顺带提醒一句,如果你在做的分析涉及真实用户的设备数据,务必先确认授权范围。技术手段是中性的,用在哪、谁来用,性质完全不同。只分析自己开发的应用、或者有明确书面授权的目标,这是底线。
6.4 路径里带 "==" 的随机串别拿去做字符串匹配
最后说个小但烦人的点。前面提到 Android 8.0 之后/data/app下的目录名带随机串,形如~~kR3n7QxLm9vA2bCdEfGh==。这个串是 Base64 风格的,可能包含=、-、_这些字符,而且每次重新安装都会变。
这意味着两件事:
第一,别把它写进任何持久化的配置或白名单里。你今天记下来的路径,明天卸载重装就失效了。
第二,如果你要"判断两个路径是不是同一个应用",不要比完整路径,要比包名。从路径里反推包名的正则大概是:
echo "/data/app/~~kR3==/com.example.app-xY8==/base.apk" | sed -E 's#^.*/([^/]+)-[^/]+/.*#\1#' # com.example.app不过这个正则在包名本身带横线的情况下会出问题(Android 包名里横线其实是非法字符,但有些工具会产生奇怪的中间态)。所以更稳的做法还是走pm path拿路径、走pm list packages拿包名,把两者的对应关系交给系统去维护,别自己写解析。
我在实际工具里是这么处理的:维护一个"路径 → 包名"的映射表,但每次工具启动时重新扫描一次,不做持久化缓存。多花几十毫秒,省掉一堆"为什么昨天还好好的今天就不行了"的排查时间。这个习惯是从一次线上事故里养成——当时缓存了一份路径映射,用户升级应用之后路径变了,工具一直指着一个已经不存在的目录,报的错还特别隐晦。
再补一个我常用的兜底命令。当你实在搞不清一个包里到底有哪些路径信息时,把dumpsys package的完整输出存成文件慢慢翻,比在终端里 grep 高效得多:
adb shell dumpsys package com.example.app > /tmp/pkg_dump.txt adb shell pm list packages -f -u > /tmp/all_pkgs.txt adb shell pm list users > /tmp/users.txt这三份文件基本覆盖了"包名、路径、多用户"三个维度的全部信息,后面不管是写分析报告还是做交叉比对,都不用再反复连设备了。这也是我在做长期分析项目时的固定开场动作——先把快照拉全,再去慢慢啃。