[新人试投]某云音乐前端播放器逆向
本帖最后由 InvFish 于 2022-11-16 17:19 编辑0x00 前言
声明:仅作技术交流,请勿恶意利用
逆向背景:现在我有某第三方音乐接口,需要制作用户脚本,利用此接口将音乐资源注入到某云音乐的前端播放器逻辑中,实现直接播放VIP歌曲的效果
目标网址:https://music.163.com/#/song?id=\d+
以一首VIP歌曲为例:https://music.163.com/#/song?id=18794244
对照组:一首可以直接播放的普通单曲:https://music.163.com/#/song?id=1470919891
逆向目标:播放按钮点击直接播放
环境/工具:Chrome,Tampermonkey
目标页面截图:
对照页面截图:
PS:题外话,这是我第一次写文,各个方面把握不当,还请大家指导。如有错误之处,还请大家指出。
0x01 初探
0x010第一次尝试:从事件监听器入手
首先想到的第一个思路是从页面本身的事件监听器入手,看一看点击播放以后的代码逻辑是什么
控制台定位到播放按钮,查看事件监听器,发现按钮上没有事件监听器;又发现播放和收藏等按钮都直接有Attribute写明res-id等等信息,怀疑是把事件监听器放在了某级祖先元素上;向上定位到这个事件监听器,就在父元素#content-operation上。
定位到相关源代码,格式化后下断点,点击播放
很好,断下来了,跟进
很可惜的是,跟踪了好几个堆栈,一直是这样的前端事件分发相关的代码,没有看到有关播放的逻辑部分。当然,一直跟踪下去肯定是能看到有关播放的代码的,但是可以预见到相当的麻烦,是时候换个思路了。
0x011 第二次尝试:从网络请求入手
0x0110 歌曲信息
从网络请求入手,看一看前端是怎么从服务器获取歌曲信息的,又是如何播放的
打开控制台-网络,点击播放按钮,可惜没有看到有关歌曲信息的请求
这种情况不要着急,前端要播放歌曲,要么通过xhr从服务器请求歌曲,要么就是歌曲信息已经写在了js/html等等预加载的资源里,出于性能和资源安全性等考虑,前者可能性更大;点击按钮没有请求,很可能是在之前一次点击按钮时就已经请求过了,被js前端缓存了。刷新页面再播放试试看:
很好,看到我们需要的请求(weapi/v3/song/detail)了,展开看了一下响应,确定就是这首歌的信息。但是,在这些信息里,并没有看到播放的音乐本身的url。两种可能:
[*]因为是vip单曲,无权播放,所以不返回url
[*]播放歌曲的url信息有一个单独的api,在detail请求以后判断是否有播放权限,有播放权限再请求这个播放api
打开对照单曲页面,点击播放,查看网络请求
确定是2(weapi/song/enhance/player/url/v1)
自此明确请求逻辑:请求detail -> 判断播放权限 ->
[*]有播放权限,请求url/v1,播放
[*]没有播放权限,不请求,弹出提示
注意:由于发现前端js有请求缓存机制,以后每次对请求进行抓包/调试时要记得先刷新页面。
0x02 请求分析
0x020 detail
响应为json,格式如下:
{
"code": 200,
"privileges": [{...}],
"songs": [{...}]
}
对比响应体,不难发现其中关键字段json.privileges.dl, json.privileges.dlLevel, json.privileges.pl, json.privileges.plLevel,根据名字不难猜测其直接控制了下载和播放的音质等级(其实还有json.privileges.fl, json.privileges.flLevel,但是这个看不出来有什么用,事实证明到后面也用不到),其分别与响应体的json.privileges.downloadMaxbr, json.privileges.downloadMaxBrLevel, json.privileges.playMaxbr, json.privileges.playMaxBrLevel对应。前者表示当前用户拥有的音质等级,后者表示该歌曲最高的音质等级
0x021 url/v1
响应为json,格式如下:
目测关键键值为json.data.url,可能有关的键值有json.data.md5、json.data.fee、json.data.payed等等,但是因为detail显示权限不足,前端页面没有请求VIP歌曲的url/v1接口,所以可能还有其他关键键值需要通过对照才能得到
0x03 调试请求
0x030 hook函数
有了前面的分析,(理论上)现在我们只需更改请求的响应数据,将音乐接口获取的音乐资源注入到响应即可实现点击播放。
容易想到hook掉window.XMLHttpRequest来拦截请求,关键在于如何hook。
hook的目标为:
[*]可以拦截、断点调试每一次请求
[*]可以根据请求url筛选请求
想到的几种方案:
[*]直接hook XMLHttpRequest本身
[*]hook XMLHttpRequest.prototype.open
[*]hook XMLHttpRequest.prototype.send
[*]同时hook open和send
首先,XMLHttpRequest本身是一个构造函数,直接hook XMLHttpRequest本身是不可行的。
仅仅hook XMLHttpRequest.prototype.open可以筛选url,但是不能获取请求体(虽然用不到),不方便添加事件监听器(实践中发现页面前端代码本身就有对open和send的hook,会对事件监听器有拦截和包装);仅仅hook XMLHttpRequest.prototype.send不可筛选url;所以选择方案4
利用到我自己写的一个Hooker(只能hook函数)
(function () {
window.hooker = new Hooker();
function Hooker() {
const H = this;
const makeid = idmaker();
const map = H.map = {};
H.hook = hook;
H.unhook = unhook;
function hook(base, path, log=false, apply_debugger=false, hook_return=false) {
// target
path = arrPath(path);
let parent = base;
for (let i = 0; i < path.length - 1; i++) {
const prop = path;
parent = parent;
}
const prop = path;
const target = parent;
// Only hook functions
if (typeof target !== 'function') {
throw new TypeError('hooker.hook: Hook functions only');
}
// Check args valid
if (hook_return) {
if (typeof hook_return !== 'object' || hook_return === null) {
throw new TypeError('hooker.hook: Argument hook_return should be false or an object');
}
if (!hook_return.hasOwnProperty('value') && typeof hook_return.dealer !== 'function') {
throw new TypeError('hooker.hook: Argument hook_return should contain one of following properties: value, dealer');
}
if (hook_return.hasOwnProperty('value') && typeof hook_return.dealer === 'function') {
throw new TypeError('hooker.hook: Argument hook_return should not contain both of following properties: value, dealer');
}
}
// hooker function
const hooker = function hooker() {
let _this = this === H ? null : this;
let args = Array.from(arguments);
const config = map.config;
const hook_return = config.hook_return;
// hook functions
config.log && console.log(, _this, args);
if (config.apply_debugger) {debugger;}
if (hook_return && typeof hook_return.dealer === 'function') {
= hook_return.dealer(_this, args);
}
// continue stack
return hook_return && hook_return.hasOwnProperty('value') ? hook_return.value : target.apply(_this, args);
}
parent = hooker;
// Id
const id = makeid();
map = {
id: id,
prop: prop,
parent: parent,
target: target,
hooker: hooker,
config: {
log: log,
apply_debugger: apply_debugger,
hook_return: hook_return
}
};
return map;
}
function unhook(id) {
// unhook
try {
const hookObj = map;
hookObj.parent = hookObj.target;
delete map;
} catch(err) {
console.error(err);
DoLog(LogLevel.Error, 'unhook error');
}
}
function arrPath(path) {
return Array.isArray(path) ? path : path.split('.')
}
function idmaker() {
let i = 0;
return function() {
return i++;
}
}
}}) ();
用以下代码进行hook
hooker.hook(window.XMLHttpRequest.prototype, 'open', true, true);
hooker.hook(window.XMLHttpRequest.prototype, 'send', true, true);
第一个参数是hook目标的父object,第二个参数是hook目标函数在父对象上的属性名(就是函数名),第三个参数为是否在hook函数被调用后在console log一条消息,第四个参数为是否在hook函数被调用后触发debugger进入调试,第五个参数用于替换目标函数(此处只需要下断点,第五个参数未使用)
刷新页面,控制台执行,点击播放按钮:
断下来了,诶,还没断下来呢,咋回事?查看页面DOM结构:
原来还有一个iframe,所以我们hook不止要hook top window,还要hook iframe.contentWindow
所以代码改进成这样:
// hook top window
hooker.hook(window.XMLHttpRequest.prototype, 'open', true, true);
hooker.hook(window.XMLHttpRequest.prototype, 'send', true, true);
// iframe.contentWindow
hooker.hook(document.querySelector('#g_iframe').contentWindow.XMLHttpRequest.prototype, 'open', true, true);
hooker.hook(document.querySelector('#g_iframe').contentWindow.XMLHttpRequest.prototype, 'send', true, true);
刷新,控制台执行,点击播放:
这次断下来了
可以看到open的第二个参数args === "https://music.163.com/weapi/v3/song/detail?csrf_token=",就是detail api
这里先用全局变量(temp1)把xhr保存下来,然后f8继续执行,等到send函数断下来
比对send的xhr和之前保存的temp1,定位到detail api的xhr的send调用,然后再注册load事件监听器,监听load事件
这里有个问题:这里我直接调用temp2.addEventListener一直调用不成功,后来发现前端页面js已经将XMLHttpRequest.prototype.addEventListener提前hook掉了
解决方法:创建一个iframe,取出其contentWindow.XMLHttpRequest.prototype.addEventListener,再用call或者apply方法给当前xhr调用
这样就绕过了hook的前端函数。但是很可惜,这样添加的load事件的监听器还是断不下来,到网络面板一看
已经被abort掉了,怀疑是调试用时太长,前端页面以为xhr timeout就abort了
把之前的hook代码结合起来一次执行完毕:
(function() {
let xhr;
const AEL = getPureAEL();
// hook top window
hooker.hook(window.XMLHttpRequest.prototype, 'open', false, false, {
dealer: onOpen
});
hooker.hook(window.XMLHttpRequest.prototype, 'send', false, false, {
dealer: onSend
});
// iframe.contentWindow
hooker.hook(document.querySelector('#g_iframe').contentWindow.XMLHttpRequest.prototype, 'open', false, false, {
dealer: onOpen
});
hooker.hook(document.querySelector('#g_iframe').contentWindow.XMLHttpRequest.prototype, 'send', false, false, {
dealer: onSend
});
function onOpen(_this, args) {
if (args.includes('/weapi/v3/song/detail')) {
// 记录detail api的xhr
xhr = _this;
}
return ;
}
function onSend(_this, args) {
if (xhr === _this) {
AEL.call(_this, 'load', function(e) {
debugger;
});
}
return ;
}
// Get unpolluted addEventListener
function getPureAEL(parentDocument=document) {
const ifr = makeIfr(parentDocument);
const oWin = ifr.contentWindow;
const oDoc = ifr.contentDocument;
const AEL = oWin.XMLHttpRequest.prototype.addEventListener;
return AEL;
}
// Get unpolluted removeEventListener
function getPureREL(parentDocument=document) {
const ifr = makeIfr(parentDocument);
const oWin = ifr.contentWindow;
const oDoc = ifr.contentDocument;
const REL = oWin.XMLHttpRequest.prototype.removeEventListener;
return REL;
}
function makeIfr(parentDocument=document) {
const ifr = parentDocument.createElement('iframe');
ifr.srcdoc = '<html></html>';
ifr.style.width = ifr.style.height = ifr.style.border = ifr.style.padding = ifr.style.margin = '0';
parentDocument.body.appendChild(ifr);
return ifr;
}
}) ();
点击播放:
很可惜,还是没有在load事件成功断下来,而看网络面板
detail api的请求确实已经发送出去并收到了响应
到这里我的内心os已经开始大喊mmp了,但其实越是到这种时候越是不能慌不能急。很喜欢维术的一句话:“世界上没有鬼,如果你见了鬼,那是你认为你看见了鬼;写代码不要相信自己的眼睛,要相信代码”,还有福尔摩斯的一句话:“如果所有其他可能都已经排除,那么剩下的这种可能,就算再离谱,也一定是真相”。到这里我反复查了好几遍我的代码,是没有问题的,那么就只有一种可能:在我的事件监听器触发前,有其他的事件监听器先一步触发执行了,并且调用了e.stopImmediatePropagation阻止了我的事件监听器触发。后面我又自己尝试了在open里面注册监听器等方法试图提前事件监听器的次序,但均以失败告终。没办法,只能单步跟踪看看被前端页面hook的open和send函数干了些什么了。
0x031 前端跟踪
open和send都有可能导致我的eventListener失效的情况下,我盲选先跟踪send函数,因为我感觉send函数在我的onSend后执行,更改xhr导致onload无法触发的可能性更大(事实证明我蒙对了,不过这都是后话)
在刚才的onSend函数里添加debugger:
function onSend(_this, args) {
if (xhr === _this) {
AEL.call(_this, 'load', function(e) {
debugger;
});
debugger;
}
return ;
}
刷新执行,点击播放:
单步跟踪到js前端hook send的代码里
单步跟踪可以厘清这段代码中的结构:
蓝色部分检查所有onload, onerror, onprogress属性所对应的函数(如果此属性已设置),并进行了一系列处理。关键在于蓝色下半部分,将onreadystatechange设置为橙色部分的e函数
在e函数中,判断如果xhr.readState === 4(即xhr加载完毕),则进一步分发事件到各个之前设置的事件处理器;如果不是xhr.readState === 4,就什么都不做。我并不知道分发事件的内部逻辑是什么样的,但是也没有必要继续跟踪了,直接改为监听readystatechange事件,在xhr.readState < 4时,将onreadystatechange也hook掉就可以提前拦截到xhr响应了。
hook代码:
function onSend(_this, args) {
if (xhr === _this) {
AEL.call(_this, 'readystatechange', function(e) {
_onreadystatechange = _this.onreadystatechange;
_this.onreadystatechange = onProgress;
debugger;
}, {once: true}); // once参数指定事件监听器仅仅触发一次
}
return ;
}
function onProgress(e) {
debugger;
}
控制台执行,点击播放:
这次终于成功了,在前端代码执行前断了下来,并且可以获取到返回值
改一下xhr返回值试试(因为不知道页面前端代码中用的是xhr.response还是xhr.responseText,就一次都改了)
function onProgress(e) {
const xhr = e.target;
if (xhr.readyState === 4) {
const RATES = {
'none': 0,
'standard': 128000,
'exhigh': 320000,'lossless': 999000,
}; // br-level 对照表,可从普通单曲的请求中收集到
const json = JSON.parse(xhr.response);
const privilege = json['privileges'];
dlLevel = privilege['downloadMaxBrLevel'];
dlRate = RATES;
plLevel = privilege['playMaxBrLevel'];
plRate = RATES;
privilege['dlLevel'] = dlLevel; // Download
privilege['dl'] = dlRate; // Download
privilege['plLevel'] = plLevel; // Play
privilege['pl'] = plRate; // Play
const response = JSON.stringify(json)
const propDesc = {
value: response,
writable: false,
configurable: false,
enumerable: true
}
Object.defineProperties(xhr, {
'response': propDesc,
'responseText': propDesc
});
}
// 别忘了继续执行页面前端js
_onreadystatechange(e);
}
可以看到,detail api后,url/v1 api也紧接着请求了,同时页面也没有再弹出VIP提示弹窗,说明我们已经成功地“骗过”了页面前端js,更改了页面前端js收到的响应,接下来只需要再对url/v1 api如法炮制即可,需要注意的是,这次需要将第三方音乐接口嵌进来。
0x032 url/v1
hook的方法和上面的detail接口一样,嵌入第三方接口的方法也很简单:在onProgress里对第三方接口进行请求,请求到播放地址后填入json.data.url即可,在此不再赘述。
这里简单说一下可能遇到的几个坑:
[*]不止需要填入url:json.data.code, json.data.br, json.data.level, json.data.type也都是需要的,分别代表请求状态(实测填200就行)、音质数值、音质字符串名称、音频编码(实测填"mp3"就行)
[*]第三方接口返回的音频url的跨域问题:第三方接口如果返回的音频url不是某云音乐自身的域名下的url,又没有cors头信息,则可能会因为跨域而无法播放;这时候充分发挥用户脚本的跨域能力,使用GM_xmlhttpRequest访问后,再用URL.createObjectURL转换成可以播放的链接即可,但在实测中我用的第三方接口返回的链接虽然不是某云音乐自身的域名下的url,但是经过一串302跳转以后最终地址还是转回了某云音乐自身的域名下,所以只需要GM_xmlhttpRequest({
method: "GET",
url: api_got_url, // 第三方接口返回的url
onprogress: function(e) {
json.data.url = e.finalUrl;
// 填入其他属性
onreadystatechange.apply(_this, args); // 继续执行页面前端代码
}
})这样获取到最终url即可
0x04 编写userscript
这里就用户脚本把刚才在控制台手动做过的步骤自动化一下就可以了。
本来想在这里贴一下代码或者链接的,但是怕违反版规(同时也是我写累了,偷个懒),就不贴了。完整的优化过的代码已经开源在GreasyFork了,欢迎各位大佬前来交流。
最终效果:
PS:新人第一次写文,个人感觉还是用了不少心思的,如有不当一定改正,如有可以改进处请务必提出。 学习了学习了,楼主牛批 Hmily 发表于 2022-11-15 11:40
我看了下图片插入应该是对的,但没有是不是没上传成功?重新上传一下,别用附件,用图片那个上传试下?
感谢,已经重新上传了,Mac系统截图拖动到网页以后不会再保存在本地,所以图片丢失以后好多图片都得重新截图
论坛上编辑的时候如果不小心刷新了页面,好像是恢复不了已经插入的图片的,昨天我就是被这个机制坑了,唉(也有可能是我不会用)
编辑的时候能不能加一个页面unload确认呢? 第一次写文,不知道为什么插入的代码缩进会消失,为什么审核中我自己也看不到图片(是审核中看不到还是图片就丢失了?),也不知道要审核多久… InvFish 发表于 2022-11-14 23:19
第一次写文,不知道为什么插入的代码缩进会消失,为什么审核中我自己也看不到图片(是审核中看不到还是图片 ...
我看了下图片插入应该是对的,但没有是不是没上传成功?重新上传一下,别用附件,用图片那个上传试下? 太强了大佬,我始终学不会hook的精髓 进来的不只萌新,还有不少大佬啊 学习学习咯📚🙇 谢谢大佬分享,可以深究一下 代码缩进已经重新手动添加。 InvFish 发表于 2022-11-15 14:19
感谢,已经重新上传了,Mac系统截图拖动到网页以后不会再保存在本地,所以图片丢失以后好多图片都得重新 ...
好建议,@Takitooru 大神帮忙看看是否可以在主题发布页面增加一个刷新提醒?