1 前言
网上已经有大量的 web 端接口解析的方法了, 但是对客户端的接口解析基本上找不到什么资料, 本文主要分析网易云音乐 PC 客户端的 API 接口交互方式
通过内部的代理设置, 使用 fiddler 作为代理工具, 即可查看交互流程:
可以大致看一下交互方式, 通过 HTTPS POST 交互, POST 了一串 params 的内容, 内容加密, 返回 JSON 内容, 我要做的重点就在于解析 params 的生成方式, 用于模拟这次交互
(Tan1993: 这是后续编写的内容, 截图很多都是后补的, 所以可能会出现使用不同的调试工具, 不同的环境, 不同的时间等, 不影响阅读另外本人工作主要是 linux 网络方向的, 像是这次只是我的一点业余爱好, 也很少会去逆向东西, 如果出现一些比较业余的操作或想法时, 还望指出)
2 初步了解
下载最新版 PC 版网易云安装 (目前是 2.3.0.196231 版本), 分析在程序所在目录下的文件
动态链接库与可执行文件 :
第一个最让我注意的时 libcurl, 这个网络库可以用于 HTTP 协议交互, 如果通过该库与服务器交互, od 断点到 curl_easy_perform 再往回推就可以判断转换算法位置了, 然而事实比我想象的复杂多了, 这个库仅在程序刚运行时用于一些无关的网络交互 (Tan1993: 记不清了, 好像是版本还是客户端信息相关的请求)
第二个是 libcef, 这个是个基于 C/C++ 的 Web browser 控件, 可以简单理解为就是个浏览器的壳子 (Tan: 为什么说关键 API 没用到 libcurl 库, 因为除了开始时 cef 框架还没初始化前网络交互用到那个库而已, 一点 cef 环境起来了, 都是通过 JS ajax 交互了)
其他的除了 cef 依赖的 dll 外, 两个主程序和 cloudmusic.dll 都比较值得关注
资源文件 :
除了在 package 下的其他都是 cef 库依赖的资源文件
都是未知的格式, 一般看到未知格式的文件, 我都会用 7z 尝试打开看看, 是不是某种归档格式文件, 这个一下就蒙中了, 是 zip 格式的
除了几个通过后缀就能看出来的皮肤文件, 还有两个比较可疑的文件, 翻一翻比较大的 orpheus.ntpk 文件, 里面可以看到都是网页相关的资源文件, 看到那个 core.js, 就让我联想到网页版 API 提取时用到的那个 core.js 文件了, 脑海里就想着替换然后对转换流程动态分析了, 事实有点不尽人意, 该 zip 文件加密了
OK, 调研阶段结束, 在不进行逆向解析前, 能了解到的也就止步于此了
3 第一轮尝试
其实一开始我是把目光放在 libcurl 上面的, 在断点到 curl 库的函数上时发现只有程序刚运行时触发过几次, 后面所有网络交互都不用这个库了, 就转战到 cef 上而 cef 的重点在于内部的 JS 文件, 能提取到该文件才是关键的
0×2712 即 CURLOPT_URL 宏, eax 中存放着 url 的字符串指针, 基本上都是无关的 url
第一个任务来了, 逆向寻找特征串, 也就是密码 , 这里断点到系统文件操作 API 上, 断到 CreateFileW , 一顿的 F9 后可以看到加载到 default.skin 文件了 (图中是 native.ntpk, 同类型的加密 ZIP 文件), 后续就单步调试下去
然后看到一个比较特别的内存块, 一看就是 PNG 格式的文件头, 就可以判断这一步资源已经解压缩到内存了
往上推几步, 断点, 缩小范围, 再跟下来, 看看哪里做了解压操作, 再一步步跟函数 (Tan1993: 可能比较业余, 但我也只能一点点缩小范围在一点点看流程, 凭经验判断可能会做什么操作, 缩短到比较短的范围, 不然一堆汇编码真的会受不了, 感谢世界上程序员的思想都是接近的吧)
得知密码后, 就可以解压出 core.js 文件了 (Tan1993: 这里仅提供思路, 不提供便民服务哈)
又是这一堆让人窒息的混淆, 卡得怀疑人生, 先解压缩再看吧
解压后, 搜几个关键字, 比如 params,eapi,batch 等最上面 HTTP 交互时的一些特征
关键代码, 像这样混淆的 JS 代码, 如果不通过调试器跟踪, 很难看懂, 目前能可以看出也只有 channel.serialData 应该时比较关键的转换函数, 但是搜索了整个 JS 文件都找不到函数定义, 不知道是不是混淆到哪个奇怪的地方了
虽然 cef 自带 DevTools, 但是已经被屏蔽掉了也无法在程序里调出来, 所以我想在 JS 文件中加上 alert 调试关键参数然后我修改了 core.js 文件, 按原来的密码压缩回去但程序根本就起不来, 为什么呢, 看看原版的. ntpk 文件, 很明显还有一些奇怪的东西和 zip 文件一起合成了这个 ntpk 文件格式根据经验判断很可能时类似于数字签名的东西 (Tan1993: 之前我也会对一些可能被篡改的档案末尾对整个文件加盐生成一个 hash 值用于校验, 但是后续跟完网易云的数字签名方式让我又学习了不少)
4 第二轮尝试
为了方便调试, 我需要替换掉资源文件中的 core.js 文件, 但是该资源文件不仅仅加密压缩了, 还有一些其他内容存在, 所以这次跟代码就是为了了解除了 zip 文件本身以外其他部分内容的作用
还是断到 CreateFileW 函数上, 其实第一轮跟代码的时候我就已经发现了部分调用系统 加密服务提供程序 (CSP) 库的函数
一步步跟过来, 发现用的是 SHA1 数字签名算法 (Tan1993: 不是很了解 CSP 库, 但这个是为 Windows 系列操作系统制订的底层加密接口, 和我理解的 SHA 不太一样, 我姑且将程序内部的那部分称为公钥, 与文件头部的校验数据进行校验)
文件头 NTPK, 文件长度 0x0D5C5B, 校验串长度 0×100
刚好差了 0×110 长度, 除了 0×100 用于校验的数据, 还有 0×10 的头部
由于我是无法在不知道私钥的情况下, 再次对该文件进行签名的, 所以我只能把程序内部的用于校验的公钥一并替换, 再生成一个对应的检验数据, 从而通过系统验证, 或者直接把验证部分的代码跳转逻辑修改掉 (Tan1993: 其实可能改分支流程修改会更简单也说不定, 但我一开始选择的是替换公钥重新生成校验数据)
- int GenKey(HCRYPTPROV hProv)
- {
- HCRYPTKEY hKey;
- HANDLE hFile = NULL, hOutFile = NULL;
- DWORD dwSize = 0, dwRead = 0, dwWrite = 0, dwBlobLen = sizeof(bRsaKey);
- BYTE *pbFileData = NULL;
- int ret = -1;
- // 先读取原版的 dll, 加载到内存中
- hFile = CreateFileW(L"cloudmusic_src.dll",
- GENERIC_READ, FILE_SHARE_READ, NULL,
- OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);
- dwSize = GetFileSize(hFile, NULL);
- pbFileData = new BYTE[dwSize];
- ReadFile(hFile, pbFileData, dwSize, &dwRead, NULL);
- CloseHandle(hFile);
- if (!memcmp(pbFileData + 0x7C3438, bRsaKey, sizeof(bRsaKey)))
- {
- // 重新生成密钥对
- CryptGenKey(hProv, AT_SIGNATURE, CRYPT_EXPORTABLE, &hKey);
- memset(bRsaKey, 0, sizeof(bRsaKey));
- CryptExportKey(hKey, NULL, PUBLICKEYBLOB, 0, bRsaKey, &dwBlobLen);
- // 将新生成的公钥覆盖原本 dll 中的公钥
- memcpy(pbFileData + 0x7C3438, bRsaKey, sizeof(bRsaKey));
- // 随带把 debug 端口开了 (后续再解释)
- SetDebugPort(pbFileData);
- hOutFile = CreateFileW(L"cloudmusic.dll",
- GENERIC_WRITE, FILE_SHARE_READ, NULL,
- CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL);
- // 写回到 dll 中
- WriteFile(hOutFile, pbFileData, dwSize, &dwWrite, NULL);
- CloseHandle(hOutFile);
- ret = 0;
- }
- delete[] pbFileData;
- CryptDestroyKey(hKey);
- return ret;
- }
- int EncFile(HCRYPTPROV hProv, LPCWCHAR wstrInFile, LPCWCHAR wstrOutFile)
- {
- HCRYPTHASH hHash;
- DWORD dwSize = 0, dwRead = 0, dwWrite = 0, dwOutSignSize = 0;
- HANDLE hFile = NULL, hOutFile = NULL;
- BYTE *pbFileData = NULL, *pbSignData = NULL;
- // 打开带密码的压缩文件
- hFile = CreateFileW(wstrInFile,
- GENERIC_READ, FILE_SHARE_READ, NULL,
- OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);
- dwSize = GetFileSize(hFile, NULL);
- pbFileData = new BYTE[dwSize];
- ReadFile(hFile, pbFileData, dwSize, &dwRead, NULL);
- CloseHandle(hFile);
- // 打开输出文件
- hOutFile = CreateFileW(wstrOutFile,
- GENERIC_WRITE, FILE_SHARE_READ, NULL,
- CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL);
- // 写入文件头
- WriteFile(hOutFile, bHead, sizeof(bHead), &dwWrite, NULL);
- // 写入原压缩文件长度
- WriteFile(hOutFile, &dwSize, sizeof(int), &dwWrite, NULL);
- // 创建并计算 Hash 值
- CryptCreateHash(hProv, CALG_SHA, 0, 0, &hHash);
- CryptHashData(hHash, pbFileData, dwSize, 0);
- CryptSignHash(hHash, AT_SIGNATURE, NULL, 0, NULL, &dwOutSignSize);
- pbSignData = new BYTE[dwOutSignSize];
- CryptSignHash(hHash, AT_SIGNATURE, NULL, 0, pbSignData, &dwOutSignSize);
- // 写入 Hash 值大小
- WriteFile(hOutFile, &dwOutSignSize, sizeof(int), &dwWrite, NULL);
- // 写入 Hash 值 (校验数据)
- WriteFile(hOutFile, pbSignData, dwOutSignSize, &dwWrite, NULL);
- // 写入原压缩文件
- WriteFile(hOutFile, pbFileData, dwSize, &dwWrite, NULL);
- CloseHandle(hOutFile);
- delete[] pbSignData;
- delete[] pbFileData;
- CryptDestroyHash(hHash);
- return 0;
- }
截了一部分代码, 用于修改 cloudmusic.dll 中的二进制数据, 偏移是根据内存加载地址与基址算的, 直接固定偏移修改即可
到这一步其实我已经可以替换掉 core.js 文件并且可以 alert 弹出对话框, 显示一些 JS 运行时数据了, 虽然 alert 弹框并不是那么好用
通过 alert 我可以看到加密前的内容, 也就是具体发了哪些数据, 以及加密后是什么样子的, 很可惜的是当我尝试 alert(channel.serialData) 时发现是 [native code] , 按我个人理解应该是系统二进制函数才会显示这个的吧 (对 JS 并不是非常了解), 怀疑是库函数, 但查询无果, 后来想了想会不会是 JS 调用了 C++ 代码 (凭我对 cef 粗糙的理解), 我尝试去查了一下, 果然是可以的, 那么很有可能这部分加密转换的代码还是在主程序中, 这就很头疼了, 刚从主程序逆向脱离出来到 JS 这个自由的世界, 又要回到看汇编码的环境了
5 第三轮尝试
这一轮主要目的是找到 channel.serialData 在主程序的位置, 根据我对 cef 的理解, 应该是在程序启动时, 注册了一部分回调函数, 可以从注册的时候找到回调函数入口, 然后等触发 channel.serialData 动作时, 从回调函数跟代码跟下来
根据 DLL 版本, 我找到了对应的 cef 源码版本, cef 注册回调时是整个结构体的, 必须找到对应的版本避免新版本结构体不一样导致偏移位置有差异
在看源码的过程中发现结构体里有个很有意思的字段, 一个 debug 端口, 调研了一下, 这个端口很有用了, 可以远程 DevTools, 这样还用什么 alert
如果要在调用初始化前把结构体改掉, 要么 API Hook 修改, 要么静态文件修改, 文件修改的话只能舍弃一些无用代码来改这个结构体了, 我选了一个不影响的赋值语句, 改成给这个地址赋 9222
对照源码中结构体计算偏移值
原本修改 cloudmusic.dll 的代码中增加个代码段修改的方法
- // 修改 Debug Port 为 9222
- void SetDebugPort(BYTE *pbFileData)
- {
- if (!memcmp(pbFileData + 0x14EED, bSettingAsm, sizeof(bSettingAsm)))
- {
- bSettingAsm[2] = 0x94; // 结构体偏移
- bSettingAsm[6] = 0x06; // 0x2406 也就是 9222 端口
- bSettingAsm[7] = 0x24;
- memcpy(pbFileData + 0x14EED, bSettingAsm, sizeof(bSettingAsm));
- }
- }
现在我就可以通过 http://127.0.0.1:9222 远程访问 DevTools 了可当我打开网页时一片空白, 这时候又凭借我对 cef 粗略的了解, 在程序目录下, 并没有 devtools 相关的资源, 其实只要把资源文件补上就可以了 (官网已经没有这么老的资源文件档案了, 这个还是我网上找的 3.1916 版本的 devtools 资源文件)
这时候所有 JS 调试命令都可以改成 console.log 来进行了, 方便了好多
回到正题, 从注册来跟代码实在是太痛苦了一个是注册的内容比较多, 一层叠一层的, 而且程序用的是 C++ warp 的 C 语言版本的 cef 库, 和源码对照跟的时候还是有点差别的这时候我想到一个非常好的方法, 那就是制造一个死循环
6 第四轮尝试
上面就提到了, 我放弃了从注册一步步跟踪回调函数的麻烦方案, 而是在 JS 中知道一个死循环, 不停的调用 channel.serialData 函数, 等程序单核满载时, 只需要将调试器附加程序, 点一点暂停, 基本上就是这个函数相关业务流程的代码了 (JS 到机器码代码按我理解应该在堆上, 而加密的代码应该在程序代码段上, 所以我定位的时候可以忽略掉很多 JS 的代码, 找到真正相关的代码位置)
实际上, channel.serialData 的汇编码也非常多, 流程也分了好多部分, 这部分工作量实在是降不下来, 但是很多可能是为了防止静态分析的代码, 部分特征串是运行时生成的, 但是因为这部分特征串都是固定的, 所以是可以不用去仔细琢磨的 (然而我花了一两天来看那一堆汇编码来算出特征串, 非常郁闷, 早知道就逆推就好, 但说实话, 光逆推也会很难, 主要是要有一定理解)
简单说明一下转换流程
1 输入 url(请求部分) 和 data(提交的 json 数据)
2 拼成 nobody + url + use + data + md5forencrypt 字符串
3 对字符串计算 MD5
4 二次拼接 url + -36cd479b6b5- + data + -36cd479b6b5- + md5
5 0×10 对齐, 缺少的部分会以缺少的位数来填充
6 私有转换方法 (也许是我不知道的一种加密方式?)
附上一部分分析的图
待加密数据, 0×10 字节对齐, 每次处理 0×10 字节的数据
辅助加密数据 (动态生成, 但是是固定的, 我还傻傻去复现了一遍生成流程)
开始对 0×10 进行转换
一堆异或和位移计算, 这个还是很好复现到 C 的代码中的, 这个比较长就不全粘贴了
循环转换完后再按照 %02X 格式 snprintf 到字符串即可我没有过多去理解这个加密算法究竟是什么原理, 只是直译汇编码
后来尝试反过来解析, 看了一早上没看出来, 简单描述一下为什么难以逆转的问题
内存块 mem
- a1b1b1c1a1b1b1c1 a2b2b2c2a2b2b2c2
- eax = a1a2a3a4
- ebx = b1b2b3b4
- ecx = c1c2c3c4
- edx = d1d2d3d4
- eax = mem[a4 * 8] ^ mem[b3 * 8 + 3] ^ mem[c2 * 8 + 2] ^ mem[d1* 8 + 1]
- ebx = mem[a3 * 8] ^ mem[b2 * 8 + 3] ^ mem[c1 * 8 + 2] ^mem[d4 * 8 + 1]
- ecx = mem[a2 * 8] ^ mem[b1 * 8 + 3] ^ mem [c4 * 8 + 2] ^ mem[d3* 8 + 1]
- edx = mem[a1 * 8] ^ mem[b4 * 8 + 3] ^ mem[c3 * 8 + 2] ^mem[d2 * 8 + 1]
然后在得知后面的 eax,ebx,ecx,edx 逆推原来的, 感觉不太可能, 但是 mem 并不是没有规律的一个内存块, 而且数组索引时也做了些巧妙的偏移, 事实上内存块确实有不少规律 (比如 a1 是偶数时 b1 是 a1 的一半, c1 是 a1 ^ b1), 而且和索引时的偏移可能会相得益彰, 如果能看出窍门说不定还是能解的, 有兴趣的小伙伴也可以研究一下 (Tan1993: 个人没学过加密学, 只略懂一部分概念)
7 汇总
其实到这一步, 我可以通过远程 devtools 来看发送前未加密的内容以及结构, 同时我也可以通过已经复现的加密方法, 对不同业务数据加密发送出去我发现有一部分请求数据返回内容也是加密的, 但这个是可以在客户端控制 e_r 的值来控制是否需要返回加密内容的
写个模拟客户端下载歌曲的小 Demo, 本来发送和接收都是加密的数据的下载接口, 就可以通过服务器验证实现下载了, 解析到此告一段落, 虽然过程中还有很多内容值得研究, 如果有机会以后会继续挖掘
8 总结
由于并没有找到任何的参考资料, 断断续续也研究了一周时间除了实现了目标以外, 还是有不少收获的, 比如比较有趣的加密算法, 数字签名方法, cef 库, 还有一些逆向的思路
比较遗憾的是没有把解密的算法也解析出来, 同时在客户端控制 e_r 的值来控制返回数据是否加密显然不是好方法, 官方只需要忽略这个参数强制对部分 API 返回加密数据, 正常的客户端也没有任何影响 (难道有平台相关性所以才把这个参数放到客户端的吗?)
( Tan1993: 视情况考虑是否在 github 提供源码)
将一件有趣的事, 当时我尝试在一台国外 IP 的服务器上调用 web 的 api 接口时发现不能适用, 获取不到数据, 然后我又跟了一便 JS 代码发现逻辑不一样, 其中发现了一个很有意思的特征串 (在你们看不到的地方, 总有调皮的程序员):
来源: http://www.tuicool.com/articles/iM3uAzz