音频播放链接逆向工程文档

一、目标

通过逆向 yuetingba.cn 的前端 JS 加密逻辑,实现 PHP 服务端生成音频播放地址(RSS 订阅用)。


二、系统架构

用户请求 → ting-play.php → yuetingba API → 返回加密音频信息 → PHP 解密 → 构造签名 URL → 302 重定向到音频服务器

涉及3层加密/签名

  1. assl — AES-128-CBC 解密 → 服务器配置(哪些服务器可播放)
  2. efi — AES-256-CBC 解密 → 音频文件路径
  3. 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 里 gkgi 函数被严重混淆。

发现的密钥派生算法

// 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]));
}

破解踩坑记录

  1. ❌ 以为是 AES-128-CBC(因为 key 看起来是16字节)→ 实际 gk 产生 32 字节,是 AES-256-CBC
  2. ❌ 用了 OPENSSL_RAW_DATA flag → efi 是 base64 编码,必须用 flag=0 让 PHP 自动 base64 解码
  3. openssl enc 命令行测试失败 → PHP openssl_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

关键发现

  1. 不同服务器支持不同路径格式 — 不是所有服务器都支持 booksdir/ 前缀
  2. 解密出的完整路径(含中文目录名)在某些服务器上可以直接使用
  3. 需要两种格式都试,哪个返回200用哪个
  4. 签名用 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用哪个
}

四、你自己处理的完整步骤

准备工作

  1. 确保服务器 PHP 开启了 openssl 扩展
  2. 确保 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; }
}

五、调试技巧

遇到解密失败时

  1. efi 解密失败 → 检查 flag 是否为 0(不是 OPENSSL_RAW_DATA)
  2. assl 解密失败 → 检查去噪逻辑,确认 ord(assl[0]) 位置正确
  3. URL 返回404 → 检查路径格式是否为 booksdir/{py}_{bookId}/{hash}.m4a
  4. 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 测试每种可能

十一、踩过的坑总结

  1. efi 字段名:API 返回的是 efi 不是 ef
  2. AES 算法:efi 用 AES-256(不是 128),因为 gk 产生 32 字节密钥
  3. base64 编码:efi 是 base64 编码,openssl_decrypt 的 flag 必须为 0
  4. 路径格式不统一:不同服务器用不同路径格式,需要 booksdir/ 格式和完整中文路径格式都试
  5. 端口选择:不是所有服务器都可用,需要遍历尝试
  6. openssl CLI vs PHP:命令行 openssl enc 和 PHP openssl_decrypt 行为不同,只有 PHP 能正确解密
  7. 混淆 JS:不能直接从混淆代码推断逻辑,必须通过实际测试验证