音频播放链接逆向工程文档
一、目标
通过逆向 yuetingba.cn 的前端 JS 加密逻辑,实现 PHP 服务端生成音频播放地址(RSS 订阅用)。
二、系统架构
用户请求 → ting-play.php → yuetingba API → 返回加密音频信息 → PHP 解密 → 构造签名 URL → 302 重定向到音频服务器
涉及3层加密/签名:
assl— AES-128-CBC 解密 → 服务器配置(哪些服务器可播放)efi— AES-256-CBC 解密 → 音频文件路径- URL 签名 — MD5 签名防篡改
三、逆向思路与破解过程
第1步:分析页面结构
做了什么:用 Playwright 访问 yuetingba.cn 书籍详情页,分析 DOM 结构。
发现:
- 主页面是一个 iframe 架构:
主页面 → iframe(iframe_tingPlay) → uni-app Vue SPA(/tingpage/index.html) - 播放按钮调用
testFn(tingId),通过iframe.contentWindow.testFun(tingId)传递给子页面 - 音频播放的 JS 在
/tingpage/assets/pages-book-bookplay.DVPVQF4m.js(重度混淆)
你自己做:
- 浏览器打开 yuetingba.cn,F12 查看 Network 标签
- 点击播放按钮,观察 XHR 请求找到 API 端点
- 查看 Elements 面板找到
var assl = '...'和var py = '...'变量
第2步:发现 API 端点
做了什么:分析 Network 请求,找到音频信息 API。
发现的 API:
GET /api/app/docs-listen/{tingId}/ting-with-efi
返回 JSON:
{
"id": "3a154a06-b9c0-ec51-780e-9e1e1c172236",
"bookId": "3a154809-c5d5-c0ae-064d-578f2965bc3d",
"tingNo": 1,
"title": "000_片花",
"efi": "c/YX+dWJU7/x+Q7wE6tuN5w0MXlkkZ0VLPAGhvrd8T...",
"creationTime": "2024-09-28T16:57:58.978376"
}
关键字段:
efi(不是ef)— AES 加密的音频路径creationTime— 用于派生解密密钥
你自己做:
curl "http://www.yuetingba.cn/api/app/docs-listen/3a154a06-b9c0-ec51-780e-9e1e1c172236/ting-with-efi"
第3步:破解 assl 解密(服务器配置)
做了什么:分析混淆 JS,找到 As 类的 AES 解密逻辑。
发现的密钥:
Key: le95G3hnFDJsBE+1/v9eYw== (Base64 → 16 bytes)
IV: IvswQFEUdKYf+d1wKpYLTg== (Base64 → 16 bytes)
解密流程:
1. 清理换行符
2. 去噪: splitIndex = ord(assl[0]), 删除32个干扰字符
3. openssl_decrypt(assl, 'AES-128-CBC', $key, 0, $iv) // flag=0 表示输入是base64
4. JSON 解析 → 服务器配置数组
解密后的结构:
[
{"AsType":"1", "Scheme":"http", "Value":"185.242.232.69:33569", "Port":"33569"},
{"AsType":"1", "Scheme":"http", "Value":"185.242.234.59:34569", "Port":"34569"},
{"AsType":"2", "Scheme":"http", "Value":"60.168.76.18:52001", "Port":"52001"}
]
你自己做:
// 在 yuetingba 主页面源码中找到 assl 值
$html = file_get_contents('http://www.yuetingba.cn/book/detail/3a154809-c5d5-c0ae-064d-578f2965bc3d/0');
preg_match("/var\s+assl\s*=\s*'([^']+)'/", $html, $m);
$assl = $m[1];
// 去噪
$splitIndex = ord($assl[0]);
if (strlen($assl) - 32 > $splitIndex) {
$assl = substr($assl, 0, $splitIndex) . substr($assl, $splitIndex + 32);
}
// AES-128-CBC 解密
$key = base64_decode('le95G3hnFDJsBE+1/v9eYw==');
$iv = base64_decode('IvswQFEUdKYf+d1wKpYLTg==');
$decrypted = openssl_decrypt($assl, 'AES-128-CBC', $key, 0, $iv);
print_r(json_decode($decrypted, true));
第4步:破解 efi 解密(音频路径)
做了什么:分析 _s 类的密钥派生逻辑。
这是最难的一步,因为混淆 JS 里 gk 和 gi 函数被严重混淆。
发现的密钥派生算法:
// tingId 去掉横杠 (32字符)
$tingIdNoDash = str_replace('-', '', $tingId);
// creationTime 清理 (只保留数字,20字符)
$ctClean = preg_replace('/[-:T. ]/', '', $creationTime);
// gk: 派生 AES 密钥 (32字节 → AES-256)
$gk = '';
for ($i = 0; $i < 20; $i++) {
$gk .= chr(ord($tingIdNoDash[$i]) + intval($ctClean[$i]));
}
for ($i = 20; $i < 32; $i++) {
$gk .= chr(ord($tingIdNoDash[$i]) + intval($ctClean[$i - 20]));
}
// gi: 派生 IV (16字节)
$gi = '';
for ($i = 20; $i > 4; $i--) {
$gi .= chr(ord($tingIdNoDash[$i]) + intval($ctClean[$i - 1]));
}
破解踩坑记录:
- ❌ 以为是 AES-128-CBC(因为 key 看起来是16字节)→ 实际
gk产生 32 字节,是 AES-256-CBC - ❌ 用了
OPENSSL_RAW_DATAflag → efi 是 base64 编码,必须用flag=0让 PHP 自动 base64 解码 - ❌
openssl enc命令行测试失败 → PHPopenssl_decrypt和命令行行为不同,只有 PHP 才能正确解密
你自己做:
function decryptAudioPath($efi, $tingId, $creationTime) {
$tingIdNoDash = str_replace('-', '', $tingId);
$ctClean = preg_replace('/[-:T. ]/', '', $creationTime);
$ctClean = str_pad($ctClean, 14, '0');
// gk (32字节密钥)
$gk = '';
$len = strlen($tingIdNoDash);
for ($i = 0; $i < min(20, $len); $i++) {
$gk .= chr(ord($tingIdNoDash[$i]) + intval($ctClean[$i] ?? 0));
}
for ($i = 20; $i < $len; $i++) {
$gk .= chr(ord($tingIdNoDash[$i]) + intval($ctClean[$i - 20] ?? 0));
}
// gi (16字节 IV)
$gi = '';
for ($i = 20; $i > 4; $i--) {
if ($i < $len && ($i - 1) < strlen($ctClean)) {
$gi .= chr(ord($tingIdNoDash[$i]) + intval($ctClean[$i - 1]));
}
}
// AES-256-CBC 解密, flag=0 (base64 输入)
$decrypted = openssl_decrypt($efi, 'AES-256-CBC', $gk, 0, $gi);
return $decrypted; // 返回如: /myfiles/host/listen/听书目录/.../1737f47e...m4a
}
第5步:破解 URL 构造
做了什么:对比正确 URL 和我们生成的 URL,发现路径格式错误。
正确 URL 格式(两种):
格式A(部分服务器):
http://60.168.76.18:35662/myfiles/host/listen/booksdir/SZHX_3a154809.../1737f47e...m4a?sign=xxx&t=xxx
格式B(另一些服务器):
http://185.242.232.69:36512/myfiles/host/listen/听书目录/大奉打更人~卖报小郎君~头陀渊,小桃红/86dabb84...m4a
关键发现:
- 不同服务器支持不同路径格式 — 不是所有服务器都支持
booksdir/前缀 - 解密出的完整路径(含中文目录名)在某些服务器上可以直接使用
- 需要两种格式都试,哪个返回200用哪个
- 签名用
sign=和t=参数名
签名公式:
$timestamp = time() + 600; // 当前时间 + 10分钟
$sign = md5($path . '|' . $timestamp . '|' . $sk);
// $sk = 'xMiP5W1DHBxC5PwQ5oj5QfRn0tsT5UBk'
你自己做:
$hashFile = basename($decryptedPath);
$pathA = '/myfiles/host/listen/booksdir/' . $py . '_' . $bookId . '/' . $hashFile;
$pathB = $decryptedPath; // 解密的完整路径
$timestamp = time() + 600;
$signA = md5($pathA . '|' . $timestamp . '|' . $sk);
$signB = md5($pathB . '|' . $timestamp . '|' . $sk);
// 遍历服务器, 每个服务器试两种格式
foreach ($servers as $s) {
$url = $s['Scheme'] . '://' . $s['Value'];
// 先试格式A
// 再试格式B
// 哪个返回200用哪个
}
四、你自己处理的完整步骤
准备工作
- 确保服务器 PHP 开启了
openssl扩展 - 确保
allow_url_fopen = On(PHP 配置)
实施步骤
Step 1: 获取 API 数据
$tingId = '3a154a06-b9c0-ec51-780e-9e1e1c172236';
$resp = file_get_contents("http://www.yuetingba.cn/api/app/docs-listen/$tingId/ting-with-efi");
$info = json_decode($resp, true);
$efi = $info['efi'];
$bookId = $info['bookId'];
$creationTime = $info['creationTime'];
Step 2: 解密 assl 获取服务器列表
$html = file_get_contents("http://www.yuetingba.cn/book/detail/$bookId/0");
preg_match("/var\s+assl\s*=\s*'([^']+)'/", $html, $m);
preg_match("/var\s+py\s*=\s*'([^']+)'/", $html, $m2);
$assl = $m[1];
$py = $m2[1] ?? 'SZHX';
// 去噪 + AES-128-CBC 解密
$splitIndex = ord($assl[0]);
if (strlen($assl) - 32 > $splitIndex)
$assl = substr($assl, 0, $splitIndex) . substr($assl, $splitIndex + 32);
$key = base64_decode('le95G3hnFDJsBE+1/v9eYw==');
$iv = base64_decode('IvswQFEUdKYf+d1wKpYLTg==');
$servers = json_decode(openssl_decrypt($assl, 'AES-128-CBC', $key, 0, $iv), true);
Step 3: 解密 efi 获取音频路径
// 见上面的 decryptAudioPath() 函数
$decryptedPath = decryptAudioPath($efi, $tingId, $creationTime);
Step 4: 构造签名 URL
$hashFile = basename($decryptedPath);
// 两种路径格式 (不同服务器支持不同格式)
$pathA = '/myfiles/host/listen/booksdir/' . $py . '_' . $bookId . '/' . $hashFile;
$pathB = $decryptedPath; // 解密的完整路径如 /myfiles/host/listen/听书目录/书名/hash.m4a
$timestamp = time() + 600;
$sk = 'xMiP5W1DHBxC5PwQ5oj5QfRn0tsT5UBk';
$signA = md5($pathA . '|' . $timestamp . '|' . $sk);
$signB = md5($pathB . '|' . $timestamp . '|' . $sk);
// 遍历服务器, 每个试两种格式
foreach ($servers as $s) {
$baseUrl = $s['Scheme'] . '://' . $s['Value'];
// 先试格式A (booksdir)
$code = testHead($baseUrl . $pathA . '?sign=' . $signA . '&t=' . $timestamp);
if ($code == 200) { $url = $baseUrl . $pathA . '?sign=' . $signA . '&t=' . $timestamp; break; }
// 再试格式B (完整路径)
$code = testHead($baseUrl . $pathB . '?sign=' . $signB . '&t=' . $timestamp);
if ($code == 200) { $url = $baseUrl . $pathB . '?sign=' . $signB . '&t=' . $timestamp; break; }
}
五、调试技巧
遇到解密失败时
- efi 解密失败 → 检查 flag 是否为 0(不是 OPENSSL_RAW_DATA)
- assl 解密失败 → 检查去噪逻辑,确认
ord(assl[0])位置正确 - URL 返回404 → 检查路径格式是否为
booksdir/{py}_{bookId}/{hash}.m4a - URL 返回403/401 → 检查签名计算,确认 path 变量与实际请求路径一致
打印中间值调试
error_log("efi: $efi");
error_log("tingIdNoDash: " . str_replace('-', '', $tingId));
error_log("ctClean: " . preg_replace('/[-:T. ]/', '', $creationTime));
error_log("gk hex: " . bin2hex($gk));
error_log("gi hex: " . bin2hex($gi));
error_log("decrypted: $decryptedPath");
error_log("hashFile: $hashFile");
error_log("audioPath: $audioPath");
error_log("sign: $sign");
OpenSSL 错误排查
while ($e = openssl_error_string()) {
error_log("OpenSSL: $e");
}
六、所有已知常量
| 名称 | 值 | 用途 |
|---|---|---|
| ASSL_KEY | le95G3hnFDJsBE+1/v9eYw== |
assl 解密 AES-128 密钥 |
| ASSL_IV | IvswQFEUdKYf+d1wKpYLTg== |
assl 解密 AES-128 IV |
| sk | xMiP5W1DHBxC5PwQ5oj5QfRn0tsT5UBk |
MD5 签名密钥 |
| py | 从页面 var py = 'SZHX' 获取 |
播放器类型前缀 |
| booksdir | 固定值 | 音频服务器基础目录 |
| API 端点 | /api/app/docs-listen/{tingId}/ting-with-efi |
获取音频信息 |
七、文件清单
| 文件 | 用途 |
|---|---|
ting-play.php |
生产版:302 重定向到音频 URL |
ting-play-debug.php |
调试版:显示每步中间值 |
ting-rss.php |
RSS 生成器(已正常工作) |
九、反调试绕过与混淆 JS 分析技巧
问题1:网站禁止 F12 / DevTools
很多网站通过 JS 检测开发者工具的打开状态,一旦打开就卡死、跳转或白屏。
绕过方法
方法A:用浏览器启动参数禁用检测
# Chrome: 禁用 debugger 语句
google-chrome --disable-backgrounding-occluded-windows --disable-renderer-backgrounding
# 或用命令行远程调试(不触发页面内的检测)
google-chrome --remote-debugging-port=9222
# 然后用另一个浏览器访问 chrome://inspect
方法B:用 Playwright / Puppeteer 无头模式
# 安装
npx playwright install chromium
# 无头模式访问,完全绕过页面检测
npx playwright-cli open https://www.yuetingba.cn/book/detail/xxx/0
无头浏览器没有 DevTools 面板,页面的 ondevtoolsopened 检测不会触发。
方法C:抓包工具直接拿请求
不需要打开 DevTools,用代理工具抓包:
# mitmproxy (推荐)
pip install mitmproxy
mitmproxy --listen-port 8080
# 然后浏览器设置代理 127.0.0.1:8080
# 所有请求/响应都能看到,包括 API 返回的 JSON
# 或者用 curl 直接调 API(最简单)
curl "http://www.yuetingba.cn/api/app/docs-listen/xxx/ting-with-efi"
curl "http://www.yuetingba.cn/book/detail/xxx/0" | grep -oP "var assl = '[^']+'"
方法D:view-source: 协议
view-source:http://www.yuetingba.cn/book/detail/xxx/0
浏览器的 view-source: 不会执行 JS,直接看 HTML 源码,不受反调试影响。
方法E:保存网页后本地分析
# 用 curl 下载完整页面
curl -o page.html "http://www.yuetingba.cn/book/detail/xxx/0"
# 用 grep 提取关键变量
grep -oP "var assl = '[^']+'" page.html
grep -oP "var py = '[^']+'" page.html
grep -oP "var sk = '[^']+'" page.html
方法F:浏览器扩展注入
安装「Tampermonkey」或「Violentmonkey」扩展,在页面加载前注入脚本:
// ==UserScript==
// @name Bypass DevTools Block
// @match *://*.yuetingba.cn/*
// @run-at document-start
// ==/UserScript==
// 阻止反调试检测
Object.defineProperty(window, 'devtools', { get: () => false });
// 覆盖检测函数
window.__defineGetter__('outerWidth', () => window.innerWidth);
window.__defineGetter__('outerHeight', () => window.innerHeight);
问题2:重度混淆的 JS 找不到常量来源
混淆 JS 用 a1_0x8221(idx) 这样的函数从字符串数组中取值,直接搜 "le95G3hnFDJsBE+1/v9eYw==" 找不到引用位置。
核心原理
混淆 JS 的结构通常是:
// 1. 定义一个巨大的字符串数组
var arr = ['str0', 'str1', 'str2', ...]; // 几百个字符串
// 2. 定义一个解码函数(有旋转/偏移)
function a1_0x8221(idx) {
// idx - offset 再查 arr
return arr[idx - 0x17f];
}
// 3. 代码中用 a1_0x8221(0x3a4) 代替 "le95G3hnFDJsBE+1/v9eYw=="
实用技巧
技巧1:直接搜 Base64 字符串
# 搜 AES 密钥
grep -oP '[A-Za-z0-9+/]{20,}={0,2}' pages-book-bookplay.DVPVQF4m.js | sort -u
# 搜已知常量
grep -n 'le95G3hnFDJsBE' pages-book-bookplay.DVPVQF4m.js
grep -n 'IvswQFEUdKYf' pages-book-bookplay.DVPVQF4m.js
grep -n 'xMiP5W1DHBxC' pages-book-bookplay.DVPVQF4m.js
找到后看上下文,通常附近就有 a1_0x8221(0x???) 的调用。
技巧2:搜已知函数名
# 搜 crypto 相关关键词
grep -n 'CryptoJS\|AES\|decrypt\|encrypt\|md5\|hmac' js文件
# 搜 API 路径
grep -n 'docs-listen\|ting-with-efi\|playsServerUrl'
# 搜页面变量名
grep -n 'assl\|bookId\|tingId\|creationTime'
技巧3:用 Node.js 提取字符串数组
# 把字符串数组单独提取出来执行
node -e "
var arr = [这里粘贴整个数组];
console.log(arr.length + ' strings');
// 搜特定字符串的索引
arr.forEach((s, i) => {
if (s.includes('le95G3hn') || s.includes('xMiP5W1D') || s === 'assl') {
console.log('[' + i + '] ' + s.substring(0, 50));
}
});
"
技巧4:Hook 解密函数
在页面加载前,用 Playwright 注入 hook 脚本:
// Hook CryptoJS.AES.decrypt
const origDecrypt = CryptoJS.AES.decrypt;
CryptoJS.AES.decrypt = function() {
console.log('=== AES DECRYPT ===');
console.log('Key:', arguments[1].toString());
console.log('IV:', arguments[2] ? arguments[2].toString() : 'none');
console.log('Mode:', arguments[3] ? arguments[3].mode : 'default');
const result = origDecrypt.apply(this, arguments);
console.log('Result:', result.toString(CryptoJS.enc.Utf8));
return result;
};
# Playwright 执行
npx playwright-cli eval "粘贴上面的hook代码"
npx playwright-cli snapshot # 然后点击播放按钮
# 控制台会打印所有 AES 解密的 Key/IV/Result
技巧5:用代理拦截响应
# mitmproxy 拦截 API 响应
mitmdump -s interceptor.py
# interceptor.py
from mitmproxy import http
def response(flow: http.HTTPFlow):
if 'ting-with-efi' in flow.request.url:
print("=== API Response ===")
print(flow.response.text)
if 'assl' in flow.response.text:
print("=== Page with assl ===")
# 提取 assl 值
import re
m = re.search(r"var assl = '([^']+)'", flow.response.text)
if m:
print("assl:", m.group(1)[:50], "...")
技巧6:搜索字符串数组中的关键词
把整个 JS 文件下载后:
# 列出所有字符串(搜关键词)
grep -oP "'[^']{5,100}'" pages-book-bookplay.DVPVQF4m.js | grep -i 'aes\|decrypt\|sign\|md5\|server\|plays'
# 找 crypto 库的使用
grep -oP 'CryptoJS\.\w+\.\w+' pages-book-bookplay.DVPVQF4m.js | sort -u
十、推荐的逆向工作流
面对一个有加密的网站,按这个顺序来:
1. curl 抓页面 HTML → 提取 assl / py / sk 等变量
↓
2. curl 调 API → 看返回的 JSON 结构,找 efi 字段
↓
3. 下载混淆 JS → grep 搜已知字符串 → 找到引用位置
↓
4. Node.js 提取字符串数组 → 定位密钥/函数名的索引
↓
5. 用 Playwright hook crypto 函数 → 直接拿到 Key/IV/明文
↓
6. PHP 实现解密 → curl 测试 → 对比正确 URL 修正路径
核心原则:
- 不要硬读混淆代码,用工具提取常量
- 不要猜加密方式,hook 函数直接拿结果
- 不要假设路径格式,curl 测试每种可能
十一、踩过的坑总结
- efi 字段名:API 返回的是
efi不是ef - AES 算法:efi 用 AES-256(不是 128),因为 gk 产生 32 字节密钥
- base64 编码:efi 是 base64 编码,
openssl_decrypt的 flag 必须为 0 - 路径格式不统一:不同服务器用不同路径格式,需要
booksdir/格式和完整中文路径格式都试 - 端口选择:不是所有服务器都可用,需要遍历尝试
- openssl CLI vs PHP:命令行
openssl enc和 PHPopenssl_decrypt行为不同,只有 PHP 能正确解密 - 混淆 JS:不能直接从混淆代码推断逻辑,必须通过实际测试验证