最近出去旅游,住酒店时打开电视看了看,发现用的是一套 Android TV 桌面。「影视娱乐」下面摆着几个应用入口,前三个都是视频应用,最后一个却是 QQ 音乐。

本来只是想找点东西看,看到这里又有点好奇:这些入口是写死在 APK 里的,还是通过配置加载的?

起初想把 QQ 音乐换成 Termux,转念一想,在一排视频应用中间放个终端模拟器好像有点太招摇了。那就换成 CIBN 酷喵好了,放在这里不显得奇怪,也挺好玩。先不改 APK,看看只动本地数据能做到哪一步。

先看一眼原来的页面,目标是最右侧的「音乐欣赏」:

修改前的影视娱乐页面,第四个坑位为 QQ 音乐;酒店及网络信息已脱敏

找配置

前台应用的包名是 com.smalls.smallhotel3,先看看包信息:

1
2
adb shell pm path com.smalls.smallhotel3
adb shell dumpsys package com.smalls.smallhotel3
1
2
3
4
codePath=/system/priv-app/com_smalls_smallhotel3
userId=1000
sharedUser=SharedUserSetting{... android.uid.system/1000}
versionName=3.22

是个 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
2
3
4
5
6
7
8
9
10
11
for (int i = 0; i < this.l.size() && i < 4; i++) {
b bVar = this.l.get(i);
File file = new File(bVar.f1255c.get(0));
if (file.exists()) {
Picasso.get().load(file)
.config(Bitmap.Config.ARGB_8888)
.memoryPolicy(MemoryPolicy.NO_CACHE, MemoryPolicy.NO_STORE)
.into(this.g.get(i));
}
this.i.get(i).setText(bVar.f1253a.f1233d);
}

最多取四项,图片用 File 加载,下面的文字则另外设置。看来海报已经保存在本地了,接下来找 this.l 的来源。

顺着几个混淆方法往下走,最终找到数据库查询:菜单读 h_menu,按照 morder 排序,再去 h_language 查对应语言的名称;图片则通过语言记录关联到 h_pic,取出其中的 picadd。

原来是 SQLite,并不是一份直接写在 SP 里的配置。

再看数据库在哪里打开。MyApplication 中有一段路径拼接,省略默认值和末尾 / 的处理后,大致是这样:

1
String databasePath = downloadPath + dbPath + "SmallHotel2.db";

这两个路径倒是来自 smallconfig SP,不过读取它的方式有点意思:

1
2
createPackageContext("com.smalls.smallhotel", 2)
.getSharedPreferences("smallconfig", 0);

等等,包名少了一个 3?

检查设备,发现确实装着两个 APK:com.smalls.smallhotel3 是当前的展示端,版本 3.22;com.smalls.smallhotel 则是另一个版本号为 36 的配套应用。两者都使用 android.uid.system,这也解释了为什么能跨包读取这份私有配置。

另一个包

既然路径配置来自 com.smalls.smallhotel,数据库会不会也是它下载的?把这个包也反编译出来看看。

很快找到 HeartBeatService。它会解析服务端返回的 type,其中有一条分支:

1
2
3
4
if (var3.equals(Constant.GET_UPDATE_INFO_URL)) {
startService(new Intent(this, (Class<?>) UpdateService.class));
return;
}

GET_UPDATE_INFO_URL 的值是 getVersionContent。继续追到 UpdateModel#getUpdateInfo,可以看到请求里带着当前版本号;后面则会复制数据库,在副本上应用下载的 SQL,并下载对应资源,最后更新 SP 里的数据库路径和版本信息。

所以最初那个「服务端下发还是本地配置」的问题,答案其实是两者都有:

1
2
3
4
5
6
7
服务端 SQL、图片等更新
↓
com.smalls.smallhotel 下载并更新本地数据
↓
smallconfig 保存当前数据库路径
↓
com.smalls.smallhotel3 读取数据库,展示页面

这台设备当前使用的文件是 /data/SmallHotel2/dbFile/SmallHotel2.db。只是换个入口的话,似乎直接改它就够了。

读写数据库

路径是找到了,但目录属于 system:system,权限为 0700,普通 shell 还是进不去。run-as 又不能用,只能继续看看应用自己有没有相关的文件操作。

然后就发现了这个 ChangeModeService:

1
2
3
4
5
6
7
@Override
public void onHandleIntent(Intent intent) {
Util.chmodDir777(g.b() + Constant.totalPath);
Util.chmodDir777(g.b() + Constant.resPath);
Util.chmodDir777(g.b() + Constant.apkPath);
Util.chmodDir777(g.b() + Constant.filePath);
}

名字已经很直白了。g.b() 读取 SP 中的 downloadPath,四个常量分别为:

1
2
3
4
/SmallHotel2
/SmallHotel2/Res
/SmallHotel2/Apk
/SmallHotel2/File

再看 chmodDir777,确认目录存在后执行:

1
2
String command = "chmod -R 777 " + dir;
int exitCode = Runtime.getRuntime().exec(command).waitFor();

第一条就会递归处理整个资源目录,dbFile 自然也在其中,后面三条甚至还有些重复。

这个服务没有使用传进来的 Intent,也就不能通过 extras 指定路径或权限,只会按已有配置执行这四次 chmod。不过对眼前这份数据库来说,已经足够了。

再看看能不能从外面调用:

1
2
3
4
5
<service android:name="com.smalls.smallhotel.service.ChangeModeService">
<intent-filter>
<action android:name=".ACTION.CHMODSERVICE"/>
</intent-filter>
</service>

没有显式设置 exported,也没有在 service 或 application 上声明调用权限。这个包的 target SDK 是 28,按当时的默认规则,带有 Intent Filter 的 Service 会被导出。

于是通过 ADB 启动它:

1
2
3
adb shell am startservice \
-n com.smalls.smallhotel/.service.ChangeModeService \
-a com.smalls.smallhotel.ACTION.CHMODSERVICE

这里直接指定了组件。反编译出的 action 和应用代码使用的字符串还不太一样,因此没有依赖 Intent Filter 来定位服务。

执行后检查目录:

1
2
3
drwxrwxrwx ... system system ... /data/SmallHotel2
drwxrwxrwx ... system system ... /data/SmallHotel2/dbFile
-rwxrwxrwx ... system system ... /data/SmallHotel2/dbFile/SmallHotel2.db

确实变成了 0777,现在可以直接 adb pull 了。

这里 shell 并没有拿到 system 身份,只是借应用自己的服务执行了 chmod。文件属主也没变,因此 shell 虽然能写数据库,却不能据此把权限改回去。这次没有恢复原权限,整个资源目录仍然是开放的;这个服务的导出方式显然不太合适。

改入口

先保留原始数据库备份,在副本上查看菜单关系。「音乐欣赏」对应的数据如下:

1
2
3
4
5
SELECT m.id, m.pid, m.attr, m.bljlm, m.nowurl, l.content
FROM h_menu AS m
LEFT JOIN h_language AS l ON l.mid = m.id AND l.type = 1
WHERE m.id IN (96669, 96679)
ORDER BY m.id;
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
2
3
public List<c> a(String str, String str2, String str3) {
return f("pid", ((c) ((ArrayList) f(str, str2, str3)).get(0)).f1232c, str3);
}

也就是先通过 ID 找到当前记录,再以它的 ID 作为 pid 查询下一层。难怪名称和启动配置没有放在同一条记录里。

动作分发中,attr = 99 会进入外部应用分支,nowurl = 1 时继续解析 bljlm。后者除了包名,还支持指定 Activity,这次只需要一个 com.cibn.tv。

对应修改如下,ID 均来自这台设备的数据库:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
BEGIN IMMEDIATE;

UPDATE h_menu
SET attr = 99, nowurl = '1', bljlm = 'com.cibn.tv'
WHERE id = 96679 AND bljlm = 'com.tencent.qqmusictv';

UPDATE h_language
SET content = 'CIBN酷喵'
WHERE mid = 96669 AND type = 1 AND content = '音乐欣赏';

UPDATE h_language
SET content = '启动CIBN酷喵'
WHERE mid = 96679 AND type = 1;

COMMIT;

PRAGMA integrity_check;

完整性检查返回 ok,再查一遍记录,名称和包名也都改好了。将副本推回设备,停止展示端,下次启动时重新加载:

1
2
adb push SmallHotel2.cibn.db /data/SmallHotel2/dbFile/SmallHotel2.db
adb shell am force-stop com.smalls.smallhotel3

替换数据库前需要停止相关读写。如果启用了 WAL,还要处理一致性备份或 checkpoint,不能只复制主文件、忽略尚未合入的数据。

还有一张图

重新打开页面,名字已经变成 CIBN 酷喵了,图片怎么还是 QQ 音乐?

因为刚才只改了名称和启动项,海报是另一份数据。顺着 h_pic 查一下:

1
2
3
4
5
SELECT p.id, p.picadd
FROM h_language AS l
JOIN h_pic AS p ON p.lid = l.id
WHERE l.mid = 96669 AND l.type = 1
ORDER BY p.picorder;

关联关系是:

1
2
3
4
5
h_menu.id = 96669
→ h_language.mid = 96669,type = 1
→ h_language.id = 373915
→ h_pic.lid = 373915
→ /data/SmallHotel2/Res/20241225221702720.png

把原图拉下来,是一张 390 × 640 的竖版海报。照着原来的青绿色背景和花草生成一张酷喵素材,备份原图后直接覆盖同名文件,就不用再动数据库了。

替换后的酷喵海报,底部深色遮罩位于图片内部

这里还改了好几轮。最开始以为底部那层深色遮罩是布局画的,结果它居然就在图片里,只有上面的「音乐欣赏」是单独的 TextView。

所以新图也得带上这层遮罩,还不能把下面的花草去掉。最后把它调到从 y = 505 开始,一直延伸到底部,共 135 像素,文字仍然由页面叠加。这样才和旁边几张卡片对得上:

修改后的影视娱乐页面,第四个坑位已替换为 CIBN 酷喵;酒店及网络信息已脱敏

到这里,入口名称、启动包和海报都换好了。不过还有个问题:管理端的同步逻辑还在,下次云端更新,会不会又把它改回去?

再改一个版本号

前面追更新流程时,已经看到请求里带着 version。如果把它改成一个足够大的值,是不是就能让服务端暂时不再下发新配置?

先看看这个版本号到底怎么用。smallconfig 中保存了 version 和 preVersion,分别对应当前配置版本和上一个配置版本,和 APK 的 versionCode、versionName 没有关系。

UpdateModel#getUpdateInfo 发出的参数大致如下,房间号在这里省略:

1
2
3
4
5
{
"portName": "getVersionContent",
"roomNo": "...",
"version": "当前本地配置版本"
}

解析响应时,只有 result_msg 为 "true" 才继续读取 result_version 和更新内容。客户端这条路径里没有再比较一次「云端版本是否大于本地版本」,是否下发主要由服务端决定。

所以这个办法有个前提:服务端确实按照客户端版本筛选更新。如果它忽略版本号直接下发,客户端仍然会照常处理。手里只有客户端代码,还不能保证抬高版本就一定有效。

这次选了 100000,没有直接写 Integer.MAX_VALUE。另外,U 盘更新倒是有明确的大小比较:

1
2
3
4
5
int version = g.q();
File fileTemp = new File(String.valueOf(usbPath) + Constant.totalPath);
if (mConfigInfoBean.h() > version && fileTemp.exists()) {
// 进入 U 盘更新流程,后续代码省略
}

因此抬高版本也会影响正常的 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
2
3
4
adb shell am force-stop com.smalls.smallhotel
adb shell am set-debug-app -w com.smalls.smallhotel
adb shell am start -n com.smalls.smallhotel/.view.SetUpActivity
adb shell pidof com.smalls.smallhotel

拿到 PID 后,通过 ADB 转发 JDWP 连接。下面的 6722 是当时的进程号,每次重启都需要重新获取:

1
2
adb forward tcp:8700 jdwp:6722
jdb -attach localhost:8700

想调用的方法也不用现写,SP 工具类 c.g.a.i.g 里已经有了。两个 setter 的实现分别是:

1
2
3
4
5
6
7
8
9
10
11
public static void b(int version) {
SharedPreferences.Editor editor = l().edit();
editor.putInt("version", version);
editor.commit();
}

public static void a(int version) {
SharedPreferences.Editor editor = l().edit();
editor.putInt("preVersion", version);
editor.commit();
}

但附加成功并不代表马上就能求值。第一次停在类加载相关的线程上,遇到了 ClassNotFoundException;后来手动 suspend,切到主线程再调用,又报 IncompatibleThreadStateException。

查了 JDI 的方法调用说明,执行方法需要线程因断点等事件挂起,单纯调用 suspend 不满足这个条件。

于是设置应用侧断点,再通过重新进入设置页触发执行。调试时设置过 SetUpActivity.onResume 和版本读取方法 g.q() 的断点,实际执行写入时停在了主线程的 g.q() 中:

1
断点命中: "线程=main", c.g.a.i.g.q()

这时逐条求值,每条等返回后再继续:

1
2
eval c.g.a.i.g.b(100000)
eval c.g.a.i.g.a(100000)

两个 setter 都曾返回 <void value>。不过后面的回读并不顺利,JDB 多次报 Cannot access field of primitive type,尝试直接调用 SharedPreferences.Editor.putInt(...).commit() 也遇到了求值异常,没能从调试器拿到可靠的回读结果。

那就换个地方验证。清除调试等待状态,停止管理端后重新打开设置页,再用 uiautomator 导出界面树,版本框里已经是:

1
2
3
4
5
<node
text="100000"
resource-id="com.smalls.smallhotel:id/version_edit"
class="android.widget.EditText"
enabled="false" />

上面只保留了版本控件的几个属性。重新启动后仍能读到 100000,说明当前 version 已经写入持久化配置。preVersion 的 setter 也执行并返回了,但这次没有拿到它单独的持久化回读结果,不能靠这个版本框一起验证。

最后清除调试等待状态,并停止管理端和展示端,pidof 确认两个进程都已退出:

1
2
3
4
adb shell am clear-debug-app
adb shell am force-stop com.smalls.smallhotel
adb shell am force-stop com.smalls.smallhotel3
adb shell pidof com.smalls.smallhotel com.smalls.smallhotel3

这次修改发生在应用自己的进程里,没有 root,也没有再次放宽目录权限。前面 run-as 走不通的地方,最后反而通过调试器完成了。

至于这个高版本能不能挡住云端更新,还要观察服务端实际怎么响应。这次没有主动触发更新检查,因此目前确认的是版本写入成功,还不能说同步已经被冻结;服务端强制下发、重置配置等路径也不受这个推测保证。