文章总结: 这篇文章详细分析了QQ音乐新版VMP的sign生成机制,包括SHA1哈希处理、特定位置字符提取、Base64编码等步骤,并提供了完整的代码实现。同时文章还介绍了请求体使用AES-GCM加密的方式,但key的查找将在后续文章中详述。文章提供了详细的调试过程和代码示例,对从事逆向工程和爬虫开发的技术人员有较高参考价值。
综合评分: 86
文章分类: 逆向分析,爬虫,WEB安全,二进制安全
【网页逆向】QQ-music-新vmp(一期sign)
原创
可乐还是百事好
爬虫逆向小林哥
2025年9月4日 17:28
江苏
介绍
这个不知道啥时候更新了,vmp改版然后请求体和响应体都做了加密
过程
sign
var G = "undefined" !== typeof e ? e : "undefined" !== typeof window ? window : "undefined" !== typeof self ? self : void 0
var P = G._getSecuritySign;
vmpP进入了i
接下来在i for循环上断点,这里是通过call调用函数的
第一遍先把call在的打上, 这里注意我们语句都执行了++,我们在break打断点,需要通过对应的- – 操作,这里有点难理解,正常的执行是等号右边以此进行,但是h++这种是立即执行的操作因此等号左边的++先执行就会小一些
p[n[++h]] = p[n[++h]].call(d, p[n[++h]], p[n[++h]]);
'函数: ', p[n[h-2]], '对象: ', d, '参数1: ', n[h-1], '参数2: ', p[n[h]], '结果: ', p[n[h-3]]
p[n[++h]] = p[n[++h]].call(p[n[++h]]);
'函数: ', p[n[h-1]], '对象: ', p[n[h]], '结果: ', p[n[h-2]]
p[n[++h]] = p[n[++h]].call(p[n[++h]], p[n[++h]]);
'函数: ', p[n[h-2]], '对象: ', p[n[h-1]], '参数1: ', p[n[h]], '结果: ', p[n[h-3]]
p[n[++h]] = p[n[++h]].call(p[n[++h]], p[n[++h]], p[n[++h]]);
'函数: ', p[n[h-3]], '对象: ', p[n[h-2]], '参数1: ', p[n[h-1]], '参数2: ', p[n[h]], '结果: ', p[n[h-4]]
p[n[++h]] = p[n[++h]].call(d, p[n[++h]]);
'函数: ', p[n[h-1]], '对象: ', d, '参数1: ', p[n[h]], '结果: ', p[n[h-2]]
先简单看下日志
第一处
输入data
调用函数 (下面a循环体)
输出 87fc8426c5b144a443ebb30f9a1b691f6dfc6788
40位猜测sha1,特征5个寄存器h0-h4运算
第二处
对象 [23, 14, 6, 36, 16, 40, 7, 19]
调用函数 (下面a循环体)
输出 ['F', 'A', '2', '6', '4', undefined, '6', 'B']
join FA2646B
第三处
对象 [16, 1, 32, 12, 19, 27, 8, 5]
调用函数 (下面a循环体)
输出 ['4', '7', '6', '4', 'B', 'B', 'C', '4']
join 4764BBC4
第四处
[222, 219, 55, 176, 31, 227, 126, 88, 242, 223, 9, 116, 226, 91, 155, 154, 226, 93]
3ts3sBjfljy3wl04lubmuJdHjs
最后结果 zzcfa2646b3ts3sbjfljy3wl04lubmujdhjs4764bbc4
整理下
sign=zzcfa2646b3ts3sbjfljy3wl04lubmujdhjs4764bbc4
zzc + FA2646B + 3ts3sBjfljy3wl04lubmuJdHjs + 4764BBC4
下面为了保证分析入参一直,需要进行override以下然后console里面控制入参
接下来主要看看0A09695 + JW0nxBlWFPQ0S9b7H0w4RtZ7ag + 6C965DCD 这三个部分咋生成
0A09695
看上去像根据列表map,对字符串进行charAt
对象: (8) [23, 14, 6, 36, 16, 40, 7, 19] // 固定
函数: map
参数: 未知
结果: ['0', 'A', '0', '9', '6', undefined, '9', '5']
猜测了下这里的字符串就是上面的sha1,光猜测是提升不了的 后面进行更详细的日志
在case 78上日志就很明显了, 也可以说明为什么我们在call输出不到因为它是通过 字符串[index]来提取的 而不是charAt
console.log('对象: ', p[n[h-1]], '元素: ', p[n[h]], '结果: ', p[n[h-2]])
这里0A09695 和 6C965DCD 就分析出来了
再来看中间的, 数组未知然后后面很明显四组一轮 base64编码,验证了也没毛病,现在主要看这个大数组咋来的
const numericArray = [37, 109, 62, 159, 16, 101, 88, 83, 208, 209, 47, 91, 236, 125, 48, 225, 27, 89, 237, 168]; // 对应 "Hello World"
const numericBuffer = Buffer.from(numericArray);
const numericBase64 = numericBuffer.toString('base64');
console.log('数值数组转 Base64:', numericBase64);
从现有的日志可以看出从sha1的值每两个一组拿出来
然后根据一个对照map
{0: 0, 1: 1, 2: 2, 3: 3, 4: 4, 5: 5, 6: 6, 7: 7, 8: 8, 9: 9, A: 10, B: 11, C: 12, D: 13, E: 14, …}
然后一个长度20的固定数组
[89, 39, 179, 150, 218, 82, 58, 252, 177, 52, 186, 123, 120, 64, 242, 133, 143, 161, 121, 179]
第一次:
map[sha1[0]] map[sha1[1]]
固定数组[0]
第二次:
map[sha1[2]] map[sha1[3]]
固定数组[1]
.......
// 刚好固定数组的长度是sha1字符串长度的一般
function change(sha1Str) {
const aaa = [89, 39, 179, 150, 218, 82, 58, 252, 177, 52, 186, 123, 120, 64, 242, 133, 143, 161, 121, 179];
const bbb = {
"0": 0, "1": 1, "2": 2, "3": 3, "4": 4, "5": 5, "6": 6, "7": 7, "8": 8, "9": 9, "A": 10, "B": 11, "C": 12, "D": 13, "E": 14, "F": 15, "a": 10, "b": 11, "c": 12, "d": 13, "e": 14, "f": 15
};
for (let i = 0; i < 20; i++) {
const hexChar1 = sha1Str[2 * i],
hexChar2 = sha1Str[2 * i + 1];
console.log(hexChar1, hexChar2);
let ccc = bbb[hexChar1],
ddd = bbb[hexChar2],
eee = aaa[i]
console.log(ccc, ddd, eee)
console.log("===================")
}
}
change("7C4A8D09CA3762AF61E59520943DC26494F8941B");
接着后面就除了值,应该是还进行了其它位运算
37
109
62
...
需要在位运算也上日志了
第一轮
7 * 16 = 112 // 7 上面代码生成
0 * 2 = 2
112 + 12 = 124 // 12 和 89 上面代码生成
124 ^ 89 = 37
第二轮
4 * 16 = 64 // 4 上面代码生成
1 * 2 = 2
64 + 10 = 74 // 10 和 89 上面代码生成
74 ^ 39 = 109
。。。。
后面就不用分析了 还是蛮简单的
let result = (bbb[hexChar1] * 16 + bbb[hexChar2]) ^ aaa[i]
function change(sha1Str) {
const data = []
const aaa = [89, 39, 179, 150, 218, 82, 58, 252, 177, 52, 186, 123, 120, 64, 242, 133, 143, 161, 121, 179];
const bbb = {
"0": 0, "1": 1, "2": 2, "3": 3, "4": 4, "5": 5, "6": 6, "7": 7, "8": 8, "9": 9, "A": 10, "B": 11, "C": 12, "D": 13, "E": 14, "F": 15, "a": 10, "b": 11, "c": 12, "d": 13, "e": 14, "f": 15
};
for (let i = 0; i < 20; i++) {
const hexChar1 = sha1Str[2 * i],
hexChar2 = sha1Str[2 * i + 1];
// console.log(hexChar1, hexChar2);
// let ccc = bbb[hexChar1],
// ddd = bbb[hexChar2],
// eee = aaa[i]
// console.log(ccc, ddd, eee)
data.push((bbb[hexChar1] * 16 + bbb[hexChar2]) ^ aaa[i])
}
console.log(data)
}
change("7C4A8D09CA3762AF61E59520943DC26494F8941B");
代码合起来就是
const crypto = require('crypto');
function start(sha1Str) {
const headExtractionIndices = [23, 14, 6, 36, 16, 40, 7, 19];
let signHead = "";
for (const index of headExtractionIndices) {
if (index >= 0 && index < sha1Str.length) {
signHead += sha1Str[index];
}
}
return signHead;
}
function end(sha1Str) {
const tailExtractionIndices = [16, 1, 32, 12, 19, 27, 8, 5];
let signTail = "";
for (const index of tailExtractionIndices) {
if (index >= 0 && index < sha1Str.length) {
signTail += sha1Str[index];
}
}
return signTail;
}
function change(sha1Str) {
const data = []
const aaa = [89, 39, 179, 150, 218, 82, 58, 252, 177, 52, 186, 123, 120, 64, 242, 133, 143, 161, 121, 179];
const bbb = {
"0": 0,
"1": 1,
"2": 2,
"3": 3,
"4": 4,
"5": 5,
"6": 6,
"7": 7,
"8": 8,
"9": 9,
"A": 10,
"B": 11,
"C": 12,
"D": 13,
"E": 14,
"F": 15,
"a": 10,
"b": 11,
"c": 12,
"d": 13,
"e": 14,
"f": 15
};
for (let i = 0; i < 20; i++) {
const hexChar1 = sha1Str[2 * i],
hexChar2 = sha1Str[2 * i + 1];
// console.log(hexChar1, hexChar2);
// let ccc = bbb[hexChar1],
// ddd = bbb[hexChar2],
// eee = aaa[i]
// console.log(ccc, ddd, eee)
data.push((bbb[hexChar1] * 16 + bbb[hexChar2]) ^ aaa[i])
}
console.log(data)
const numericBuffer = Buffer.from(data);
const numericBase64 = numericBuffer.toString('base64');
return numericBase64.replace(/\+/g, '').replace(/=+$/, '');
}
function sha1(input, encoding = 'hex') {
const hash = crypto.createHash('sha1');
hash.update(input);
return hash.digest(encoding).toUpperCase();
}
function sign(data) {
const dataStr = JSON.stringify(data),
shaStr = sha1(dataStr);
return ('zzc' + start(shaStr) + change(shaStr) + end(shaStr)).toLowerCase()
}
let payload = {"comm":{"cv":4747474,"ct":24,"format":"json","inCharset":"utf-8","outCharset":"utf-8","notice":0,"platform":"yqq.json","needNewCode":1,"uin":"1152921504972391331","g_tk_new_20200303":1908872806,"g_tk":1908872806},"req_1":{"method":"get_related_gedan","module":"music.mb_gedan_recommend_svr","param":{"song_id":426716334,"song_type":1,"sin":0,"last_id":0}}}
console.log(sign(payload))
请求体加密
同样跳到了另一个vmp循环体,一样的 先在call打上日志
看上去比sign简单一些
AES-GCM,iv也给出了
key没找到,根据之前xhs aes的经验我们去 ^ 这里下日志看看信息
。。。。。。下期再出吧
广子
❝
有需要用于爬虫、逆向、抓包的测试机又不想花时间折腾Root、刷机、环境配置可以来看下下面联系方式,或者B站视频评论区见
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:爬虫逆向小林哥 可乐还是百事好《【网页逆向】QQ-music-新vmp(一期sign)》