- JavaScript 72.4%
- Java 9.8%
- Python 8.3%
- HTML 8%
- Shell 1.5%
| .pi | ||
| electron | ||
| gui | ||
| hongguo | ||
| signsvc | ||
| .gitignore | ||
| hongguo-gui.desktop | ||
| README.md | ||
| run.py | ||
| start.sh | ||
| waydroid_setup.sh | ||
红果免费短剧 · GNU/Linux 客户端
对 base.apk(红果免费短剧 com.phoenix.read 7.3.9.32)逆向实现的 Linux 桌面客户端。
Electron 界面 + unidbg 签名服务,纯 Node 数据层。
运行
cd electron
npm install # 首次;electron 二进制若未下载见下方说明
npm start # 或: ./node_modules/electron/dist/electron . --no-sandbox
依赖:Node ≥ 18、JDK 21(签名)、网络。签名器 jar(signsvc/lib/)与编译产物
(signsvc/hg/)均不入库——首次检出或缺失时 api.js 会在首次 boot 前自动执行
signsvc/setup.sh 从 Maven Central 下载并重编译(HG_SKIP_SETUP=1 可跳过,供测试打桩环境),
失败时报错并提示手动运行;原生 SO/证书在
~/.cache/hongguo/hg/(HG_ASSETS 可覆盖;不再放 /tmp,曾因被清理导致签名全败)。
electron 二进制下载慢时:
curl -L -o e.zip https://npmmirror.com/mirrors/electron/v33.4.11/electron-v33.4.11-linux-x64.zip
python3 -c "import zipfile; zipfile.ZipFile('e.zip').extractall('node_modules/electron/dist')"
echo electron > node_modules/electron/path.txt && chmod +x node_modules/electron/dist/electron
功能
- 🏠 首页:热门搜索词 + 为你推荐(分类推荐流分页拉满,服务端每页仅返 12 条;
失败自动回退热搜词搜索;2026-10 起 landing 改返推荐流形态
video_info,解析已双形态兼容) - 🎭 剧场:短剧分类浏览(
new_category/landing/v,genre_type 130),可翻页 - 🧭 发现(免签,不占用签名后端):今日上新(短剧标签簇语义 + 漫剧/AI 官方 7 天筛选)、榜单(漫剧热播/推荐/新剧 TOP50,带名次角标)、筛选浏览 (体裁 × 排序 × 时间 × 受众多维组合,可翻页)
- 👤 我的:收藏 / 观看历史(localStorage 本地持久化);观看历史行封面由卡片点击时 记录的封面缓存 + 后台详情回填补齐(旧条目无封面也可恢复)
- 🔍 短剧搜索:
search/tab/v(tab_name=store,App 同源新目录)+ 「加载更多」分页 (翻页三件套游标 passback/search_id/next_offset,与 App 同源,实测翻页零重复) - 📖 详情页:封面/简介/分类/剧集目录(
video_tab/multi_video_detail/v为准), 长剧支持输入集数定位 - ▶ 内嵌播放器(不弹出外部窗口):HEVC/bytevc1 源在本机(NVIDIA 桌面)无法原生
内嵌解码 —— Electron 全系编译期裁除 HEVC 解析(libffmpeg 无 ff_hevc_parser,v33~v44
实测一致),且上游 Chromium 默认跳过 NVIDIA VA-API 设备(
VaapiOnNvidiaGPUs可解除 跳过,但 NVIDIA 私有驱动 × xwayland-satellite 的 EGL 栈不健康,解码器初始化仍不通; Intel/AMD 核显设备 VA-API 原生可用,渲染进程 canPlayType 探测自动走原生)。为此 bytevc1-only 内容走 ffmpeg 转码代理(NVENC 硬编,详见 electron/transcode.js), 内嵌全功能保留;mpv 兜底--hwdec=auto-safe(经 libva-nvidia-driver 桥接 NVDEC 硬解)。 未加密流直连 CDN;CENC 加密流走 TT 播放器 密钥派生 + 本地解密代理(见下文「TT DRM 密钥派生」),支持整流加密 (cbc/cbc512/ctr 自动探测)与逐样本 CENC(渐进式 MP4,stbl 内 senc 携带逐样本 8B IV、整样本 AES-CTR;代理按样本解密并同尺寸改写盒类型,输出标准明文 MP4), 解密后的明文流经127.0.0.1HTTP 服务喂给窗口内<video>;解码失败自动降级:备用线路 → Web 源(hongguoduanju.com,每剧前 3 集)→ 外部 mpv 兑底。播放器功能: 自动连播(可关)、音量/静音记忆、0.5–3x 倍速、进度拖动与缓冲显示、 断点续播(按剧记忆,删历史时同步清除)、剧集抽屉、跳集、查找其他季、 停滞约 20s 显示手动「重试当前集」、App 密度预取(接下来 4 集一次签名批量拉取, 连播/切集秒开)、全屏、老板键Ctrl+Shift+B(隐藏窗口自动暂停,再次启动应用唤回续播, 手动暂停保持)、键盘快捷键(空格 / ←→ / ↑↓ / M / N / P / F / Esc)。 选源策略:可解码编码优先(排除 bytevc2)→ 分辨率优先 → 同分辨率比大小 - ⌨️ 中文输入(fcitx5):Wayland 会话自动切原生 ozone 后端并启用
--enable-wayland-ime --wayland-text-input-version=3(text-input-v3 → niri/sway/KDE → fcitx5);X11 会话自动设置GTK_IM_MODULE=fcitx5、XMODIFIERS=@im=fcitx兑底
签名器为常驻进程 + 开机探针(2 槽位并发、nice 10):实测一个 JVM 能否签名在
初始化时就已注定(collector 竞态,约 40–50% 概率)——「好 JVM」每个请求第 0 轮即成功
(跨 URL、带 gzip body 均可),「坏 JVM」多轮重试也救不活。因此进程起来后先用 3 轮
探针试签:好 JVM 留用终生(此后签名 0.1s 级,不再逐次冷启动),坏 JVM 立即更换重抽。
请求走 stdin JSON 行协议({"url", "body_b64", "rounds"}),每个请求结束后清空
msdata(状态文件绑定 (url, body),但绝不能在首个请求前清——会毁掉 initFull 刚建好的
collector 状态,HG_WIPE=none|after|before 可对照调试)。单 URL 最多 12 轮重试,
连续 2 次失败或崩溃时 Java 侧 exit(3) 由 Node 侧自动重建;结果按 (URL+请求体)
永久磁盘缓存(~/.cache/hongguo/signs.json)——同一资源只需签一次;搜索的
app_launch_time 按会话冻结。播放地址另有 10 分钟响应缓存 + App 密度预取:
preload 端点原生支持 mixed_video_id_map 多 vid,接下来 4 集一次签名批量拉取,
连播与点播秒开(预取链路永不抛错)。
实测:签名器启动(含抽签换 JVM)约 2.7–4.5s 一次,之后单签名 30–65ms(dynarmic 后端);
启动预热:require('./api.js') 即后台拉起签名槽并预签首屏两条 GET(首页
landing + 剧场分类树,与真实请求同源逐字节一致),UI 首屏签名命中磁盘缓存后
只剩纯网络耗时(实测 landing ~1s),冷启动移出感知路径(单测 warmup.test.js);
修复:kill("KILL") 在 Node ≥26 与 Electron 33 内置 Node 20.18 均抛 ERR_UNKNOWN_SIGNAL
且被空 catch 吞掉——_kill 一直是无声空操作,坏 JVM 更换/启动放弃全在漏 JVM
(靠父进程退出 stdin EOF 才收敛),改用 SIGKILL 并区分外部终止 stop()/内部清理 _kill();
批量预取 4 集仅 1 次签名 + 1 次 HTTP(~1s);常驻 JVM 空闲 CPU 0%、RSS ~140MB。
失败不再致命:签名器崩溃/失败自动重建并重试(请求级 150s 硬死线,到点强制 击毙重建);device_register 失败退化为本地随机设备(下次启动重试注册);前台取数 批量失败自动退化为逐集请求、再失败才走 UI 降级链(备用线路 → Web 源 → mpv); 预取链路失败静默放弃(不重试、不拖慢前台),全程不抛错。
详情页以 video_tab/multi_video_detail/v/(剧集目录,含标题/简介/封面/时长)为准,
detail/v/ 仅为可选增强(2026-10 起该端点对本设备偶发返回 200 空体,疑似风控;
device_register 亦被拒——同一 IP 大量调用所致)。
TT DRM 密钥派生(2026-10 重写,electron/ttdrm.js)
对 libttmplayer.so(7.3.9.32 / ttplayer 2.10.252.980-novel)+ libttffmpeg.so 的
完整逆向还原,替代原先「遇加密即放弃」的占位逻辑:
[Java] TTVideoEngineImpl._doParseIPAddress()
→ mMediaPlayer.setStringOption(144, videoInfo.getValueStr(5)) // spadea
[native] ff_demuxer_android.cpp setOptions() (libttmplayer+0x1f457c)
key = derive(属性0x7531=spadea, 属性0x7541=authKey(Java opt 462, 本App未设),
配置34004=authIdx(Java opt 471), 配置151=flag(默认0))
av_dict_set(opts, "decryption_key", key, 0)
[native] derive = 0x175804 → 0x175a80(b64解码+取flag) → 0xc7120(解析+变换):
v = b0^b1^b2; headLen = v-'0'; tailLen = len-v+47
k = spade[len-headLen-2] ^ spade[len-headLen-1]
head = 末尾 headLen 字节 ^ k → "app_v2"/"web_v2" ⇒ V2
tail = spade[1 .. 1+tailLen)
V2: 先交换相邻字节对,再 out[i] = (b[i]^b[i-2]) - b[i-1] + δ(i)
V1: out[i] = (b[i]^b[i-2]) + δ(i) (b[-2]=0xfa, b[-1]=0x55)
δ(i) = flag ? +(21+popcount(i)) : -(21+popcount(i))
tail[0] = out2 长度(十六进制数字);out1 = tail[1..keyLen),out2 = 版本标记
out2 == "2" 且 authKey 非空 ⇒ AES-256-GCM:
key = ASCII(md5hex(authKey+str(authIdx))) ← 32 字符作 32 字节密钥(非16B摘要!)
iv = 4b68454b9cabee47ca04f404, tag = ct 末 16B, 明文即十六进制 decryption_key
否则 out1 本身即十六进制 decryption_key(本 App 实际路径)
[native] 流为**逐样本 CENC**(已用真实流验证):非分片 MP4,stbl 内非标准位置的
senc 盒携带逐样本 8B IV(整样本加密,无子样本),CTR 计数块 =
IV 左对齐补零到 16B;mpv/ffmpeg 的 mov demuxer 用 decryption_key
原生解密(api:mpv 已集成 --demuxer-lavf-o=decryption_key=<hex>)。
check_info "c:<s>-<e>-<crc>" = mdat 之前明文区的 CRC16-CCITT(init 0);
"e:<s>-<e>-<crc>,…" = 文件绝对窗口的探测 CRC(前 1024B 拆两半,防劫持)
electron/ttdrm.js 实现:parseSpadea / deriveKey / parseCheckInfo /
crc16Ccitt / parseCencMp4(TtDrmProxy 为无 mpv-CENC 环境的本地解密代理,
支持整流 cbc/cbc512/ctr 与逐样本 CENC 模式)。
单测:node electron/test/ttdrm.test.js(正向构造 + 逆向派生往返、GCM、代理端到端)、
node electron/test/cenc.test.js(合成渐进式 CENC 流的探测/解密/盒改写/Range)、
node electron/test/warmup.test.js(启动预热:HOME 隔离 + URL 同源断言;会真实启动 JVM,~10s)、
node electron/test/discover.test.js(免签发现面 + 假签名器 JVM + fetch 打桩,离线快跑)。
逆向结论
| 项目 | 值 |
|---|---|
| 包名 / 版本 | com.phoenix.read 7.3.9.32 (73932),番茄同源代码库 |
| aid | 8662(AppProperty.getAppIdString()) |
| API 网关 | api5-normal-{lf,hl}.fqnovel.com |
| 请求签名 | 必需。mssdk libmetasec_ml.so v4.10.3,JNI 入口 ms.bd.c.m.a(IIJ...) @ so+0x297890 |
| 签名命令 | 0x2000006 → X-Argus/Gorgon/Helios/Khronos/Ladon/Medusa(绑定完整 URL 的 query) |
| 设备注册 | applog device_register(log.snssdk.com/service/2/)。必须用信封格式 {magic_tag:"ss_app_log", header:{…}, _gen_time}(与 hongguo/device.py 一致);扁平 payload 会被拒(device_id=0)。header 用 display_name/app_name: "phoenix_read"、device_model(非 device_type),建议带 X-SS-REQ-TICKET;连发会限频,需重试(api.js registerDevice 已实现) |
| 签名稳定性 | JVM 可签名性在初始化时即注定(collector 竞态,~40–50% 好 JVM;好 JVM 跨 URL/带 body 均可第 0 轮签成)→ 常驻进程 + 开机 3 轮探针,坏 JVM 即弃,好 JVM 终生留用 + 失败自动重启 |
| 模拟后端 | 默认 dynarmic(2026-10 实测:好 JVM 单签中位 40ms vs unicorn2 235ms,约 5x;启动多 ~0.7s 被启动画面掩盖;好坏 JVM 抽签比例与后端无关,5/6 vs 5/6)。HG_BACKEND=unicorn2 可回退 |
| 关键修复 | mssdk 状态文件路径是斜杠包名 /data/user/0/com/phoenix/read/files/.msdata(native 自行把包名转路径,MS.b(65539) 的返回值被忽略),路径映射错误会导致 collector 永远无法完成 |
| 短剧判别 | 搜索结果 book_data.platform: "1"=短剧, "2"=小说(genre 都是 0,不能靠 genre 区分) |
已打通的 API
GET /reading/bookapi/search/cue/v/ 热搜词(免签名)
GET /reading/bookapi/search/page/v/ 搜索
GET /reading/bookapi/detail/v/?book_id= 详情
GET /reading/bookapi/directory/all_items/v/ 目录(item_data_list)
GET /reading/bookapi/video_tab/multi_video_detail/v/?video_series_id=&vs_id_type=1
剧集元数据(vid/时长/集数)★
POST /novel/player/video_detail/v1/ 播放页详情(596KB,含 vid)★
GET /reading/bookapi/new_category/video/info/v/ 短剧分类树
GET /reading/bookapi/bookstore/homepage/v/ 书城首页
POST /reading/bookapi/audio/playurl/ 音频播放(对短剧返回 NO_THIS_TONE)
★ 标记为本次会话新打通。multi_video_detail 确认 vid == item_id。
免签发现面(2026-10-08 实测:不校验 X-Argus,游客态并发均 code=0)
逆向「渠道有数」红果 Windows 桌面版(Tauri+Python+unidbg,其明文后端随安装包分发) 获得线索后,用本项目设备/网关复核成立;高频发现面全部普通 fetch,不触碰签名后端:
GET /reading/bookapi/bookmall/cell/change/v/ 榜单(tab_type=26 漫剧聚合位,
sub_selected_items 区分热播/推荐/新剧)
POST /reading/distribution/category/landpage/v/ 多维筛选/今日上新/最新上架
(仅在 api5-normal-sinfonlinea 网关存活;
need_selector_panel=true 返回实时筛选面板;
select_items: genre/sort/online_time/gender/…;
filter_ids=已见 series_id 供翻页去重)
同源结论(对方实现佐证):搜索/multi_video_model 对游客开放且不校验 device_id 是否注册
——随机设备 + 签名即可 code=0(本项目仍走注册,失败已自动退化为本地随机设备);
列表类响应中 main_url/backup_url 可能是 base64 形态(_toHttpUrl 已容错);
CDN 直链 url_expire≈6h(播放缓存 TTL 已放宽至 4h)。
封面 heic 问题:免签来源(landpage/bookmall)封面为 image/heic(Linux Chromium
不解码),landing/书卡来源为 jpeg。主进程本地封面代理(固定端口 21840,被占时退回
动态端口):heic 用 heic-decode(wasm)+jpeg-js 转 jpeg,全部封面落盘
~/.cache/hongguo/thumbs/(哈希寻址 + LRU 清理),img-src * 天然放行;
依赖缺失时透传原图优雅降级。剧场分类推荐流未覆盖的分类(如「女婿」),
按分类名精确匹配免签筛选面板降级 landBrowse,仍无则显示准确空态。
RPC 框架编码规则(逆向得出)
- Java RPC(
com.bytedance.rpc/ff1):GET 请求对象经kf1.g.f()展平为 query —— 枚举转数字 (WireEnum.getValue)、List 转逗号分隔、嵌套 POJO 转 Java toString(服务端忽略); POST 走 Gson JSON body。 - KMP RPC(
multi.rpc):POST body 为 kotlinx JSON,枚举经 EnumAsOrdinal 序列化器转数字。
播放链路(未完成,下一步方向)
剧集 → vid(已确认 = item_id)→ 播放地址。卡点:
POST /novel/player/video_model/v1/(正确端点,请求形状已还原)→101000 服务异常。 疑似需要NovelCommonParam的Sign-Env/Sign-Res安全校验字段 (cq6/d3.java),其取值来自 mssdk 运行时。POST /reading/media/video/info/(thrift, psmtoutiao.reading.mediaapi)→1303 thrift parse status failed,TBinaryProtocol 编码{1:list<string> vids, 2:bool is_encrypt}未被接受,可能需要 TTNet 私有 thrift 封套。POST /reading/distribution/video_model_lite/v1/→101000 get audio/video model failed。- 备选:
registerkey+reader/full正文解密链(content 构造已完全逆向: AES-CBC,key=hexac25c67ddd8f38c1b37a2348828e222e,IV=随机16字符, 明文=device_id/iid 各 8 字节大端,见tg7/i.java)。
目录结构
electron/ Electron 应用(main/preload/renderer + api.js 数据层 + ttdrm.js DRM)
electron/test/ ttdrm 单元测试(node electron/test/ttdrm.test.js)
signsvc/ Java 签名器(HgSign.java + unidbg;常驻 --serve 模式,由 api.js 驱动)
hongguo/ Python 参考实现(device/signer/api,与 electron/api.js 等价)
base.apk 原始 APK
签名器运行依赖(/tmp 重启后需重建;全部可从 Maven Central 拉取):
mkdir -p /tmp/hg/lib/arm64-v8a /tmp/refso/data /tmp/unidbg_jars
unzip -o -j base.apk "lib/arm64-v8a/libmetasec_ml.so" "lib/arm64-v8a/libc++_shared.so" \
-d /tmp/hg/lib/arm64-v8a/
cp base.apk /tmp/hg/base.apk
M=https://repo1.maven.org/maven2
for a in \
com/github/zhkl0228/unidbg-android/0.9.8/unidbg-android-0.9.8.jar \
com/github/zhkl0228/unidbg-api/0.9.8/unidbg-api-0.9.8.jar \
com/github/zhkl0228/unidbg-unicorn2/0.9.8/unidbg-unicorn2-0.9.8.jar \
com/github/zhkl0228/unicorn/1.0.14/unicorn-1.0.14.jar \
com/github/zhkl0228/keystone/0.9.7/keystone-0.9.7.jar \
net/java/dev/jna/jna/5.10.0/jna-5.10.0.jar \
org/scijava/native-lib-loader/2.3.5/native-lib-loader-2.3.5.jar \
net/dongliu/apk-parser/2.6.10/apk-parser-2.6.10.jar \
org/slf4j/slf4j-api/1.7.36/slf4j-api-1.7.36.jar \
org/slf4j/slf4j-simple/1.7.36/slf4j-simple-1.7.36.jar \
com/alibaba/fastjson/1.2.83/fastjson-1.2.83.jar \
commons-codec/commons-codec/1.15/commons-codec-1.15.jar \
org/apache/commons/commons-collections4/4.4/commons-collections4-4.4.jar \
commons-io/commons-io/2.11.0/commons-io-2.11.0.jar \
commons-logging/commons-logging/1.2/commons-logging-1.2.jar ; do
curl -sfL -o /tmp/unidbg_jars/$(basename $a) $M/$a || echo "FAIL $a"
done
cd signsvc && javac -cp "$(cat cp.txt)" -d . HgSign.java
注意:cp.txt 允许尾部换行(setup.sh 的 paste 天然产生,api.js 读取时会 trim);
签名器 .class 不入库(编译产物易陈旧干扰 review),检出后跑 setup.sh 重建;
/tmp/refso/data/ms_16777218.bin(mssdk 证书,真机拉取)非必需——无证书也能完成 init 与签名。
播放链路(2026-10-08 更新:已完全打通,端到端验证)
通过 waydroid 真机 + mitmproxy TLS 解密 + 请求重放差分,已完全打通 pack 服务; 密钥派生已重写实现并用真实流验证(见「TT DRM 密钥派生」):
已攻破:
- 播放端点:
POST https://reading.snssdk.com/novel/player/multi_video_model/preload/v1?<公共参数> - 请求体:gzip JSON
{biz_param{video_platform:1024, 不带 device_level, ...}, dr_scene:"preload", mixed_video_id_map:{"1":["vid"]}} - 签名必须覆盖 body:mssdk 第 5 参 = gzip body 字节;头
x-ss-stub: MD5(gzip)大写 +Content-Encoding: gzip - 设备身份:applog device_register 信封格式即可(无需真机;见「逆向结论」表)
- 搜索换
search/tab/v/(tab_name=store)→ 新目录可播剧 - 响应
data.<vid>.video_model.video_list[]含video_meta.codec_type:bytevc2=私有编码(不可解)、bytevc1=HEVC/hvc1(可解) - 密钥派生:
encrypt_info.spade_a→ttdrm.deriveKey()→ 32 字符十六进制密钥 - 流结构(真实流验证):非分片 MP4,
moov后紧跟mdat;视频轨encv(sinf/tenc ivSize=8)+ stbl 内非标准位置的 senc 盒(逐样本 8B IV,整样本加密,无子样本);CTR 计数块 = IV 左对齐补零到 16B。 mpv/ffmpeg 原生支持:mpv --demuxer-lavf-o=decryption_key=<hex> <url>(api:mpv 已集成) - 端到端验证:派生密钥解密真实流前 200 个视频样本,HEVC NAL 长度链全部自洽(首样本为 VPS)
- check_info 语义(已验证):
c:<s>-<e>-<crc>= mdat 之前明文区的 CRC16-CCITT(init 0);e:<s>-<e>-<crc>,…= 文件绝对窗口的探测 CRC(前 1024B 拆两半,防 CDN 劫持;旧「512B 加密块」理解有误)
遗留:无。App 目录加密流可直接播;Web 源(hongguoduanju.com,每剧 3 集)保留为兜底。