ARTICLE · 1009896
实战不正经APP之黄果短剧

libapp.so一、字符串情报收集
对 libapp.so 的 rodata 做关键词搜索,命中率极高:
付费模型字段:
freeEpisode ← 免费集数
isFreeChapter ← 是否免费章节
chapterPrice ← 章节单价
unlockType ← 解锁类型
Pro|realPlayUrl ← "Pro|" 前缀是它家协议字段命名习惯
核心 API 路径:
/playlet-chapter/list/ ← 拉取剧集章节列表
/playlet-chapter/extra/ ← 章节附加信息
/playlet-permission/unlock ← 付费解锁接口
/api/app/ping/check ← 存活检查
VIP 体系:mine_vip/online_pay_page.dart、VipCorePrivilegeCard、clickAdToVip、doCheckinClaimVip(签到送 VIP)……
到这里业务全貌已经清晰:金币按集解锁 + VIP 包月双轨付费,短剧数据模型围绕 PlayletChapter(章节)构建。
二、还原数据模型
反汇编 PlayletChapter::fromJson(0x64666C,3460 字节)可以看到完整的 JSON 字段到对象偏移的映射。Dart AOT 的 JSON 解析代码特征非常明显——每个字段都是"从 Map 取 key → 类型转换 → StoreField"三连:
0x646D78: ldr x2, [x27, #0xca8] ; "videoUrl"
0x646D88: blr xlr ; map["videoUrl"]
0x646DD0: stur w0, [x3, #0x2f] ; this->field_2f = videoUrl
关键映射结果:
videoUrl | field_2f | 章节真实播放路径 |
field_6b | ||
chapterPrice | ||
id |
VideoModel(剧集聚合对象)侧还有两个关键字段:field_7 = 当前集数下标(int),field_c3 = 章节列表 List<PlayletChapter>。
三、还原播放链路
从播放器控制器入手,反汇编 ShortVideoPlayerLogic::initVideoPlayer(0x92F29C):
0x92F434: bl #0x8d1794 ; ::Pro.realVideoUrl(item)
0x92F444: stur w0, [x2, #0x5b] ; logic->playUrl = url
0x92F464: bl #0x9318e4 ; PlayerFactory::network(url)
顺藤摸瓜:
// 伪代码还原
String realVideoUrl(VideoModel v) {
final path = Pro.currentChapter(v)?.videoUrl ?? ""; // currentChapter = v.chapters[v.episodeIndex]
if (path.isEmpty) return "";
if (path.startsWith("http")) return path;
return "$cdnBase/vid/h5/m3u8/$path?token=$token&c=$c"; // token/c 是全局静态字段
}
注意两个细节:
- URL 组装用的 token 和 c 是全局静态字段
,不是按集签发的——这是一个很弱的授权设计,对客户端锁定方案是重大利好; Pro.videoUrl里有一个被丢弃返回值的 isCanPreview调用(读原始字节09 00 00 14确认是无条件 B 跳过了预览分支)——编译器遗留的痕迹,还原伪代码时要小心别被带偏。
然后我把 realVideoUrl 的全部 13 个调用方枚举了一遍(播放器、长视频页、缓存下载模块):没有任何一处存在"切集/滑动"层面的强制拦截。ShortVideoLogic::onPageChanged 反汇编后确认只做下标更新和预加载。也就是说:
客户端唯一的内容门槛,就是"当前章节的 videoUrl 字段里有没有货"。
ComicBuyAlert::show(购买弹窗)全库只有一个调用者 onBuyTapEvent——用户主动点"解锁"按钮才弹,滑动切集时不会自动弹。
四、还原付费判定链
UI 层的付费墙来自 ShortVideoPlayerLogic 的两个 getter:
bool get needBuyVipBool => longVideoStatusDesc(video).needBuyVip;
bool get needBuyVideoBool => longVideoStatusDesc(video).needBuyVideo;
中央状态函数 ::longVideoStatusDesc(0x9321E4)的反汇编翻译成 Dart:
Status longVideoStatusDesc(VideoModel video) {
final r = Status(needBuyVip: false, needBuyVideo: false, desc: "");
if (video == null) return r;
FreePlayCountManager.instance; globalStore; // 触发懒加载
final ch = Pro.currentChapter(video);
if (ch == null) return r..desc = "预览视频";
if (video.freeArea) return r..desc = "免费视频"; // ← 分水岭
if (!Pro.isCoinVideo(video)) {
if (ch.field_57[0].field_7 == true) return r; // VIP剧已解锁
return r..needBuyVip = true..desc = "跳过预览";
} else {
if (ch.field_57[0].field_7 == true) return r; // 金币剧已解锁
return r..needBuyVideo = true..desc = "跳过预览";
}
}
两个子判定也全部还原:
bool freeArea(VideoModel v) {
if (v.field_83 == null) return true; // 无付费标记 → 免费
return v.currentEpisode < v.freeEpisode; // 当前集 < 免费集数
}
bool isCoinVideo(VideoModel v) => v.payType == VideoPayType.Price; // Price/None/Vip 三态
顺带一提,分析过程中通过返回值指令实测确认了 Dart AOT ARM64 的布尔编码:true = xNULL+0x20,false = xNULL+0x30(xNULL=x22 寄存器)。这个细节在写补丁判断分支极性时非常关键,搞反了整个分析都会错。
五、解锁协议
购买弹窗侧 _ComicBuyAlertState 的几个关键函数还原后,解锁协议非常直白:
POST /playlet-permission/unlock
{
"unlockType": <int>, // 金币/VIP 等解锁方式
"playletChapterId": <章节ID>
}
这是一个纯记账型接口:服务端记录"这集你买过了",并不给客户端签发任何按集的播放凭证(对比第一节的 CDN URL 用全局 token,进一步佐证)。试看体系(FreePlayCountManager)则负责扣减免费试看次数并异步上报观看记录。
六、修改方案
分析到这里,补丁点其实有很多候选:状态 getter、freeArea、isCoinVideo、章节解锁标志……这次选择两个点:
P1 —— longVideoStatusDesc 内的分水岭跳转(0x9322F8)
0x9322F4: bl #0x932ba4 ; 调 freeArea(video)
0x9322F8: tbnz w0, #4, +5 ; false(付费区)→走付费分支 ← 改成 nop
0x9322FC: (true 分支:desc="免费视频",needBuy 标志保持 false)
效果:付费状态计算恒返回"免费视频",所有读取 needBuy 标志和状态文案的 UI(解锁按钮、跳过预览、购买弹窗入口)全部哑火。
P2 —— freeArea 的集数比较(0x932BFC)
0x932BF8: cmp x3, x1 ; currentEpisode vs freeEpisode
0x932BFC: b.ge #0x932c08 ; >= 免费集数 → return false ← 改成 nop
0x932C00: add x0, x22, #0x20 ; return true
效果:数据层所有"免费区"判定恒为 true(含试看额度管理里的引用),从源头把"付费区"抹掉。
最后肯定就是构建验证了,测试完美解锁付费剧集

__________
End.