*本文作者:Tan993,本文属 FreeBuf 原创奖励计划,未经许可禁止转载。
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文件,能提取到该文件才是关键的。
0x2712即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,校验串长度0x100
刚好差了0x110长度,除了0x100用于校验的数据,还有0x10的头部。
由于我是无法在不知道私钥的情况下,再次对该文件进行签名的,所以我只能把程序内部的用于校验的公钥一并替换,再生成一个对应的检验数据,从而通过系统验证,或者直接把验证部分的代码跳转逻辑修改掉(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、 0x10对齐,缺少的部分会以缺少的位数来填充
6、 私有转换方法(也许是我不知道的一种加密方式?)
附上一部分分析的图
待加密数据,0x10字节对齐,每次处理0x10字节的数据
辅助加密数据(动态生成,但是是固定的,我还傻傻去复现了一遍生成流程)
开始对0x10进行转换
一堆异或和位移计算,这个还是很好复现到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提供源码)
9、彩蛋
将一件有趣的事,当时我尝试在一台国外IP的服务器上调用web的api接口时发现不能适用,获取不到数据,然后我又跟了一便JS代码发现逻辑不一样,其中发现了一个很有意思的特征串(在你们看不到的地方,总有调皮的程序员):
*本文作者:Tan993,本文属 FreeBuf 原创奖励计划,未经许可禁止转载。