最近出去旅游,住酒店时打开电视看了看,发现用的是一套 Android TV 桌面。「影视娱乐」下面摆着几个应用入口,前三个都是视频应用,最后一个却是 QQ 音乐。
本来只是想找点东西看,看到这里又有点好奇:这些入口是写死在 APK 里的,还是通过配置加载的?
起初想把 QQ 音乐换成 Termux,转念一想,在一排视频应用中间放个终端模拟器好像有点太招摇了。那就换成 CIBN 酷喵好了,放在这里不显得奇怪,也挺好玩。先不改 APK,看看只动本地数据能做到哪一步。
先看一眼原来的页面,目标是最右侧的「音乐欣赏」:

找配置
前台应用的包名是 com.smalls.smallhotel3,先看看包信息:
1 | adb shell pm path com.smalls.smallhotel3 |
1 | codePath=/system/priv-app/com_smalls_smallhotel3 |
是个 priv-app,而且用了 system UID。本来想通过 run-as 看看私有目录,结果直接报错:
1 | run-as: package not an application: com.smalls.smallhotel3 |
查一下 run-as 的实现,它会先检查目标 UID,拒绝普通应用 UID 范围之外的包,之后才检查 debuggable。所以这里即使打开调试标志也没用,UID 1000 在前面就被拦下来了。
那就先把 APK 拉下来,交给 JADX。
FilmFragment
「影视娱乐」对应的是 FilmFragment,部分方法和字段名被混淆了,不过显示图片的地方很好找:
1 | for (int i = 0; i < this.l.size() && i < 4; i++) { |
最多取四项,图片用 File 加载,下面的文字则另外设置。看来海报已经保存在本地了,接下来找 this.l 的来源。
顺着几个混淆方法往下走,最终找到数据库查询:菜单读 h_menu,按照 morder 排序,再去 h_language 查对应语言的名称;图片则通过语言记录关联到 h_pic,取出其中的 picadd。
原来是 SQLite,并不是一份直接写在 SP 里的配置。
再看数据库在哪里打开。MyApplication 中有一段路径拼接,省略默认值和末尾 / 的处理后,大致是这样:
1 | String databasePath = downloadPath + dbPath + "SmallHotel2.db"; |
这两个路径倒是来自 smallconfig SP,不过读取它的方式有点意思:
1 | createPackageContext("com.smalls.smallhotel", 2) |
等等,包名少了一个 3?
检查设备,发现确实装着两个 APK:com.smalls.smallhotel3 是当前的展示端,版本 3.22;com.smalls.smallhotel 则是另一个版本号为 36 的配套应用。两者都使用 android.uid.system,这也解释了为什么能跨包读取这份私有配置。
另一个包
既然路径配置来自 com.smalls.smallhotel,数据库会不会也是它下载的?把这个包也反编译出来看看。
很快找到 HeartBeatService。它会解析服务端返回的 type,其中有一条分支:
1 | if (var3.equals(Constant.GET_UPDATE_INFO_URL)) { |
GET_UPDATE_INFO_URL 的值是 getVersionContent。继续追到 UpdateModel#getUpdateInfo,可以看到请求里带着当前版本号;后面则会复制数据库,在副本上应用下载的 SQL,并下载对应资源,最后更新 SP 里的数据库路径和版本信息。
所以最初那个「服务端下发还是本地配置」的问题,答案其实是两者都有:
1 | 服务端 SQL、图片等更新 |
这台设备当前使用的文件是 /data/SmallHotel2/dbFile/SmallHotel2.db。只是换个入口的话,似乎直接改它就够了。
读写数据库
路径是找到了,但目录属于 system:system,权限为 0700,普通 shell 还是进不去。run-as 又不能用,只能继续看看应用自己有没有相关的文件操作。
然后就发现了这个 ChangeModeService:
1 |
|
名字已经很直白了。g.b() 读取 SP 中的 downloadPath,四个常量分别为:
1 | /SmallHotel2 |
再看 chmodDir777,确认目录存在后执行:
1 | String command = "chmod -R 777 " + dir; |
第一条就会递归处理整个资源目录,dbFile 自然也在其中,后面三条甚至还有些重复。
这个服务没有使用传进来的 Intent,也就不能通过 extras 指定路径或权限,只会按已有配置执行这四次 chmod。不过对眼前这份数据库来说,已经足够了。
再看看能不能从外面调用:
1 | <service android:name="com.smalls.smallhotel.service.ChangeModeService"> |
没有显式设置 exported,也没有在 service 或 application 上声明调用权限。这个包的 target SDK 是 28,按当时的默认规则,带有 Intent Filter 的 Service 会被导出。
于是通过 ADB 启动它:
1 | adb shell am startservice \ |
这里直接指定了组件。反编译出的 action 和应用代码使用的字符串还不太一样,因此没有依赖 Intent Filter 来定位服务。
执行后检查目录:
1 | drwxrwxrwx ... system system ... /data/SmallHotel2 |
确实变成了 0777,现在可以直接 adb pull 了。
这里 shell 并没有拿到 system 身份,只是借应用自己的服务执行了 chmod。文件属主也没变,因此 shell 虽然能写数据库,却不能据此把权限改回去。这次没有恢复原权限,整个资源目录仍然是开放的;这个服务的导出方式显然不太合适。
改入口
先保留原始数据库备份,在副本上查看菜单关系。「音乐欣赏」对应的数据如下:
1 | SELECT m.id, m.pid, m.attr, m.bljlm, m.nowurl, l.content |
| id | pid | attr | bljlm | nowurl | content |
|---|---|---|---|---|---|
| 96669 | 96657 | 3 | 0 | 0 | 音乐欣赏 |
| 96679 | 96669 | 99 | com.tencent.qqmusictv | 1 | 启动音乐APP |
居然还有一层:显示在页面上的是 96669,保存 QQ 音乐包名的却是它的子菜单 96679。
回头看 FilmFragment 的点击事件,它会调用数据库方法,再取列表中的第一项交给动作分发逻辑。这个方法虽然传入了 "id",内部却又查了一次子菜单:
1 | public List<c> a(String str, String str2, String str3) { |
也就是先通过 ID 找到当前记录,再以它的 ID 作为 pid 查询下一层。难怪名称和启动配置没有放在同一条记录里。
动作分发中,attr = 99 会进入外部应用分支,nowurl = 1 时继续解析 bljlm。后者除了包名,还支持指定 Activity,这次只需要一个 com.cibn.tv。
对应修改如下,ID 均来自这台设备的数据库:
1 | BEGIN IMMEDIATE; |
完整性检查返回 ok,再查一遍记录,名称和包名也都改好了。将副本推回设备,停止展示端,下次启动时重新加载:
1 | adb push SmallHotel2.cibn.db /data/SmallHotel2/dbFile/SmallHotel2.db |
替换数据库前需要停止相关读写。如果启用了 WAL,还要处理一致性备份或 checkpoint,不能只复制主文件、忽略尚未合入的数据。
还有一张图
重新打开页面,名字已经变成 CIBN 酷喵了,图片怎么还是 QQ 音乐?
因为刚才只改了名称和启动项,海报是另一份数据。顺着 h_pic 查一下:
1 | SELECT p.id, p.picadd |
关联关系是:
1 | h_menu.id = 96669 |
把原图拉下来,是一张 390 × 640 的竖版海报。照着原来的青绿色背景和花草生成一张酷喵素材,备份原图后直接覆盖同名文件,就不用再动数据库了。
这里还改了好几轮。最开始以为底部那层深色遮罩是布局画的,结果它居然就在图片里,只有上面的「音乐欣赏」是单独的 TextView。
所以新图也得带上这层遮罩,还不能把下面的花草去掉。最后把它调到从 y = 505 开始,一直延伸到底部,共 135 像素,文字仍然由页面叠加。这样才和旁边几张卡片对得上:

到这里,入口名称、启动包和海报都换好了。不过还有个问题:管理端的同步逻辑还在,下次云端更新,会不会又把它改回去?
再改一个版本号
前面追更新流程时,已经看到请求里带着 version。如果把它改成一个足够大的值,是不是就能让服务端暂时不再下发新配置?
先看看这个版本号到底怎么用。smallconfig 中保存了 version 和 preVersion,分别对应当前配置版本和上一个配置版本,和 APK 的 versionCode、versionName 没有关系。
UpdateModel#getUpdateInfo 发出的参数大致如下,房间号在这里省略:
1 | { |
解析响应时,只有 result_msg 为 "true" 才继续读取 result_version 和更新内容。客户端这条路径里没有再比较一次「云端版本是否大于本地版本」,是否下发主要由服务端决定。
所以这个办法有个前提:服务端确实按照客户端版本筛选更新。如果它忽略版本号直接下发,客户端仍然会照常处理。手里只有客户端代码,还不能保证抬高版本就一定有效。
这次选了 100000,没有直接写 Integer.MAX_VALUE。另外,U 盘更新倒是有明确的大小比较:
1 | int version = g.q(); |
因此抬高版本也会影响正常的 U 盘更新,之后恢复更新时还得把它改回来。
SP 又写不进去了
数据库已经可写,但这次要动的是应用私有目录里的 smallconfig.xml。前面的 ChangeModeService 只处理资源目录,帮不上忙;设备也没有 su。
设置页上倒是有个版本框,能不能直接在那里改?看了一下 SetUpActivity,它只负责显示当前版本,保存逻辑并不读取这个控件。也没找到接受版本参数的导出组件。
不过重新检查管理端的包信息时,看到一个前面没用上的标志:
1 | flags=[ SYSTEM DEBUGGABLE HAS_CODE ALLOW_CLEAR_USER_DATA ALLOW_BACKUP ] |
run-as 因为 system UID 拒绝了这个包,但它本身还是可调试的。既然能附加调试器,那就让应用自己写 SP!
通过 JDWP 调用应用方法
之前为了运行 JADX,已经在本地下载了一份 JDK,正好可以用其中的 jdb。
先让管理端等待调试器,再启动设置页:
1 | adb shell am force-stop com.smalls.smallhotel |
拿到 PID 后,通过 ADB 转发 JDWP 连接。下面的 6722 是当时的进程号,每次重启都需要重新获取:
1 | adb forward tcp:8700 jdwp:6722 |
想调用的方法也不用现写,SP 工具类 c.g.a.i.g 里已经有了。两个 setter 的实现分别是:
1 | public static void b(int version) { |
但附加成功并不代表马上就能求值。第一次停在类加载相关的线程上,遇到了 ClassNotFoundException;后来手动 suspend,切到主线程再调用,又报 IncompatibleThreadStateException。
查了 JDI 的方法调用说明,执行方法需要线程因断点等事件挂起,单纯调用 suspend 不满足这个条件。
于是设置应用侧断点,再通过重新进入设置页触发执行。调试时设置过 SetUpActivity.onResume 和版本读取方法 g.q() 的断点,实际执行写入时停在了主线程的 g.q() 中:
1 | 断点命中: "线程=main", c.g.a.i.g.q() |
这时逐条求值,每条等返回后再继续:
1 | eval c.g.a.i.g.b(100000) |
两个 setter 都曾返回 <void value>。不过后面的回读并不顺利,JDB 多次报 Cannot access field of primitive type,尝试直接调用 SharedPreferences.Editor.putInt(...).commit() 也遇到了求值异常,没能从调试器拿到可靠的回读结果。
那就换个地方验证。清除调试等待状态,停止管理端后重新打开设置页,再用 uiautomator 导出界面树,版本框里已经是:
1 | <node |
上面只保留了版本控件的几个属性。重新启动后仍能读到 100000,说明当前 version 已经写入持久化配置。preVersion 的 setter 也执行并返回了,但这次没有拿到它单独的持久化回读结果,不能靠这个版本框一起验证。
最后清除调试等待状态,并停止管理端和展示端,pidof 确认两个进程都已退出:
1 | adb shell am clear-debug-app |
这次修改发生在应用自己的进程里,没有 root,也没有再次放宽目录权限。前面 run-as 走不通的地方,最后反而通过调试器完成了。
至于这个高版本能不能挡住云端更新,还要观察服务端实际怎么响应。这次没有主动触发更新检查,因此目前确认的是版本写入成功,还不能说同步已经被冻结;服务端强制下发、重置配置等路径也不受这个推测保证。