文章目录
- [1. 写在前面](#1. 写在前面)
- [2. 接口分析](#2. 接口分析)
- [3. 日志分析](#3. 日志分析)
- [4. 算法还原](#4. 算法还原)
【🏠作者主页】:吴秋霖
【💼作者介绍】:擅长爬虫与JS加密逆向分析!Python领域优质创作者、CSDN博客专家、阿里云博客专家、华为云享专家。一路走来长期坚守并致力于Python与爬虫领域研究与开发工作!
【🌟作者推荐】:对爬虫领域以及JS逆向分析感兴趣的朋友可以关注《爬虫JS逆向实战》《深耕爬虫领域》
未来作者会持续更新所用到、学到、看到的技术知识!包括但不限于:各类验证码突防、爬虫APP与JS逆向分析、RPA自动化、分布式爬虫、Python领域等相关文章
作者声明:文章仅供学习交流与参考!严禁用于任何商业与非法用途!否则由此产生的一切后果均与作者无关!如有侵权,请联系作者本人进行删除!
1. 写在前面
马上过年了,抽时间再更新两篇文章就收工了。在一个多月前新版本推出后作者抽时间看了一下最新的加密流程做了一下纯算的分析与还原。此前几个版本的变动其实并不是太大,如果深度去研究过15
或17
版本(非补环境方式)的基本是没有难度的
之前版本中同样先是使用了sm3
的国密算法进行哈希运算,再通过实现一个基于S-
盒的加密算法。类似于RC4
,携带UA
以及Salt
进行加密混淆操作。再构建数组进行字节操作然后位运算做数据的运算操作,最终生成完整的参数长串基本都差不多(使用自定义字符集索引
)
填充不一样体现在处理字符不足时、新的长ab多了一轮循环且相较于之前版本更加复杂新增了额外判断与B64格式处理
其实真正去分析还原的时候,做起来感觉并不是太难!但是要复盘一下,再以文字的形式去描述出来的时候,发现这个东西还是有一点点难度的,至少对于一个有经验的逆向分析人员来说,如果没有之前的经验累计。是无法快速还原出来的~
本文该细的地方尽量多说了一些,然后根据本文的一些重点描述、流程述说以及核心的算法讲解相信能够给学习的人提供到帮助(内容仅供大家学习与参考)
一旦任何涉及到vmp
的,逆向分析需要做的就是确认执行的指令
对应到的一系列操作是哪些。涉及到的一些字符拼接、函数调用、运算操作的信息都尽量通过不同的条件断点让日志吐的更加完善才能够有效的帮助我们去分析、比对与还原
2. 接口分析
目前192的百应这边跟视频端加密流程是一样的!你还原了一处相当于都还原了。这里作者以商品详情的接口为例,发包如下所示:
它这个百应的话发现算法如果还原有问题是过不了校验
的,请求接口的话大概会直接出现异常,具体信息如下所示:
python
{"code":"10001010A","data":null,"msg":"当前环境存在风险,请稍后重试"}
3. 日志分析
对于JSVMP的逆向分析公开的站点
跟资料也有多,有的可能涉及到的指令集简单而且算法也没有经过魔改,那么分析还原起来会简单的多!从堆栈进去后把关键的位置都打上日志桩位(.apply、运算都打上),说实话一些耗费体力时间的活免不了,桩配断
走一望三
整个的日志内容是比较多的,如果你在控制台直接输出(除某些单独过滤的日志
)是会炸掉的,而且会很卡(新手朋友注意~
)建议日志导出到本地来进行分析(对于分析过程中不全或者单独要深度分析的可以再加条件吐出来
)
通过入口的日志可以看到请求参数拼接dhzx的盐字符串加请求数据(GET请求则直接空字符串)同样拼接dhzx分别进行了sum函数计算得到了32位数组(调用了两次sm3的加密)。如下所示:
javascript
const params = `${url.slice(url.indexOf("?") + 1)}dhzx`;
const extendedData = `${data}dhzx`;
这里可以直接跳转到该算法内,动静态分析调试一下可以发现其返回的32位数组。跟之前版本一样使用的国密sm3加密!之前版本中对params+cus做的哈希字节数组。然而新版的这里我们是可以直接扣这块的代码的,如下所示:
接着往下分析日志,可以看到对Ua以及一个盐进行了运算加密操作!使用到了RC4的流加密算法,但是通过与标准的算法进行比对。虽然说都基于KSA和PRGA两个阶段生成密钥流并与明文字符进行异或加密
不同的地方在于我们在分析确定加密算法是否存在魔改行为的时候,都会基于标准的算法去分析比对的。标准的RC4只需要使用它给的密钥来生成256字节的密钥流!而这个算法的逻辑使用了盐(可固定)跟0.00390625来参与了扰动从而导致不同的密钥流生成,并使用了额外的计算跟交换操作,增加了加密过程的复杂性!如下所示:
4. 算法还原
这里根据上面的分析对这部分Ua加盐的RC4魔改加密进行算法还原,实现代码如下所示:
javascript
// 加密生成混淆的字符串
function encodeUserAgentWithSalt(userAgent, uaSalt) {
const arr256 = Arr256(uaSalt); // 假设这是预先定义的函数
let n4 = 0;
let result = "";
for (let i = 0; i < userAgent.length; i++) {
const n2 = (i + 1) % 256;
const n3 = (n4 + arr256[n2]) % 256;
n4 = n3;
// 交换 arr256[n2] 和 arr256[n4],避免重复计算
[arr256[n2], arr256[n4]] = [arr256[n4], arr256[n2]];
const charCode = userAgent.charCodeAt(i);
const n6 = (arr256[n2] + arr256[n4]) % 256;
const n7 = n6 % 256;
const n8 = charCode ^ arr256[n7];
result += String.fromCharCode(n8);
}
return result;
}
// 根据输字符串生成一个256数组,并进行盐值加密操作
function Arr256(Salt) {
const shufflingArray = Array.from({ length: 256 }, (_, i) => 255 - i);
let swapIndex = 0;
// 预计算盐值字符的字符码数组
const saltChars = [0.00390625, 1, Salt].map(String.fromCharCode).map(c => c.charCodeAt(0));
for (let i = 0; i < shufflingArray.length; i++) {
const product = swapIndex * shufflingArray[i];
// 使用位与运算代替 % 256
swapIndex = (product + swapIndex + saltChars[i % 3]) & 255;
// 交换操作
[shufflingArray[i], shufflingArray[swapIndex]] = [shufflingArray[swapIndex], shufflingArray[i]];
}
return shufflingArray;
}
接下来的部分有点绕!尤其对魔改算法要尝试进行还原出来,纯纯的体力活!我们继续根据日志来分析,RC4完了后的结果后被再次进行了一个B64二次编码!(这里我们在运算日志中看特征即可),还经过了之前我们分析所看到的sm3加密。其中B64所使用的字符编码是自定义的、然后运算的掩码不同、最后就是填充规则也不同!
在这一步我们先对魔改的B64进行还原,再去调用之前扣取的sm3算法对一堆加密的乱码字符进行数组计算,如下所示:
javascript
function B64UerAgent(inputString) { // 这个是RC4加密后的那个UA
// 自定义字符集,用于替代标准Base64的字符集
const charset = "ckdp1h4ZKsUB80/Mfvw36XIgR25+WQAlEi7NLboqYTOPuzmFjJnryx9HVGDaStCe";
// 初始化最终加密结果的字符串
let encryptedString = "";
// 初始化处理的起始位置
let startIndex = 0;
// 按照每3个字符处理一次输入字符串
while (startIndex < inputString.length) {
// 计算剩余字符的数量
let remainingLength = inputString.length - startIndex;
// 处理剩余字符数量大于或等于3的情况(标准3字节处理)
if (remainingLength >= 3) {
// 提取当前3个字符的UTF-16字符编码并拼接
let number = ((inputString.charCodeAt(startIndex) & 255) << 16) |
((inputString.charCodeAt(startIndex + 1) & 255) << 8) |
(inputString.charCodeAt(startIndex + 2) & 255);
// 通过自定义字符集进行编码
encryptedString += charset.charAt((number & 16515072) >> 18); // 提取高6位
encryptedString += charset.charAt((number & 258048) >> 12); // 提取次高6位
encryptedString += charset.charAt((number & 4032)); // 提取次低6位
encryptedString += charset.charAt((number & 63)); // 提取低6位
}
// 处理剩余字符数为2的情况(需要加一个`=`填充)
else if (remainingLength === 2) {
let firstCharCode = inputString.charCodeAt(startIndex);
let secondCharCode = inputString.charCodeAt(startIndex + 1);
// 将两个字符合并为一个数字
let baseNum = (secondCharCode << 8) | (firstCharCode << 16);
// 通过自定义字符集进行编码并加上`=`
encryptedString += charset[(baseNum & 16515072) >> 18];
encryptedString += charset[(baseNum & 258048) >> 12];
encryptedString += charset[(baseNum & 4032) >> 6];
encryptedString += '=';
}
// 处理剩余字符数为1的情况(需要加上`==`填充)
else if (remainingLength === 1) {
let firstCharCode = inputString.charCodeAt(startIndex);
// 将一个字符合并为一个数字
let baseNum = firstCharCode << 16;
// 通过自定义字符集进行编码并加上`==`
encryptedString += charset[(baseNum & 16515072) >> 18];
encryptedString += charset[(baseNum & 258048) >> 12];
encryptedString += '==';
}
// 移动到下一个处理的起始位置,3个字符为一个单位
startIndex += 3;
}
// 返回最终的加密结果
return encryptedString;
}
接下来我们继续分析日志可以看到对Ua做了一次sum
操作,得到一个数组。主要实现的就是根据操作系统来选择一组固定的数值,然后进行charCodeAt
将数值字符串的每一个字符转成其对应的Unicode
字符码,最后添加到数组中,如下所示:
javascript
// 根据Ua来生成对应的字符数组并映射为Unicode值
function generateArr(userAgent){
const platformData = userAgent.includes('Win')
? '2056|1080|2056|1201|2056|1201|2056|1329|Win32'
: '2056|1080|2056|1201|2056|1201|2056|1329|MacIntel';
return [...platformData].map(char => char.charCodeAt(0));
}
接下来就是一堆数组操作了,它会将上面的几组数组并携带着时间戳、盐(一些固定数值)开始进行一系列的数组运算操作!得到一个50位的数组,这里算法流程的实现如下所示:
javascript
const timestamp1 = Date.now();
const timestamp2 = timestamp1 - Math.floor(Math.random() * 10);
function calculateDateTime3() {
return (parseFloat(Date.now()) - 1721836800000) / 1000 / 60 / 60 / 24 / 14 >> 0;
}
在此时间戳出来之前我们上面params
、data
以及经过RC4/魔改B64/SM3
加密出来的数组就不再放了,还有一个8位数组的生成参与到后续的运算操作(定义生成即可
),完整的实现算法如下所示:
javascript
function generateRandomGarbledArrays() {
// 通用函数来生成乱序字符数组
function generateRandomArray(num1, num2) {
return [
(num1 & 170) | (1 & 85),
(num1 & 85) | (1 & 170),
(num2 & 170) | (20 & 85),
(num2 & 85) | (20 & 170)
];
}
// 生成第一个数组
const arr1 = generateRandomArray(Math.random() * 65535 & 255, Math.random() * 65535 >> 8 & 255);
// 生成第二个数组
const arr2 = generateRandomArray(Math.random() * 240 >> 0, (Math.random() * 255 >> 0) & 77 | 2 | 16 | 32 | 128);
// 返回合并后的数组
return [...arr1, ...arr2];
}
然后基于前面的先决条件,我们开始通过日志来定义一个55
长度的数组,再开始实现完整的数组操作算法,完整代码实现如下所示:
javascript
function buildInitialArray(timestamp1, timestamp2, uaSalt, calculateDateTime3) {
const arr = Array(55).fill(0);
arr[0] = 41;
arr[1] = timestamp1;
arr[2] = 5;
arr[3] = timestamp2;
assignDateTimeValues(arr, timestamp1, 4);
assignDateTimeValues(arr, timestamp2, 34);
arr[10] = 1 & 255;
arr[11] = 1 / 256 & 255;
arr[12] = 1 & 255;
arr[13] = 1 >> 8 & 255;
arr[14] = 1;
arr[15] = 0 % 101 % 256 & 255;
arr[16] = 0 % 201 % 256 & 255;
arr[17] = 0 % 101 % 256 & 255;
assignUaSaltValues(arr, uaSalt, 18);
assignParamsData(arr, parArr, dataArr, browserArr);
assignFixedValues(arr);
return arr;
}
// 为日期时间赋值到数组
function assignDateTimeValues(arr, Time, index) {
arr[index] = Time >> 0 & 255;
arr[index + 1] = Time >> 8 & 255;
arr[index + 2] = Time >> 16 & 255;
arr[index + 3] = Time >> 24 & 255;
arr[index + 4] = Time / 256 / 256 / 256 / 256 & 255;
arr[index + 5] = Time / 256 / 256 / 256 / 256 / 256 & 255;
}
// 赋值到数组
function assignUaSaltValues(arr, uaSalt, startIndex) {
arr[startIndex] = uaSalt & 255;
arr[startIndex + 1] = uaSalt >> 8 & 255;
arr[startIndex + 2] = uaSalt >> 16 & 255;
arr[startIndex + 3] = uaSalt >> 24 & 255;
}
// 为参数和数据赋值到数组
function assignParamsData(arr, parArr, dataArr, browserArr) {
arr[22] = parArr[9];
arr[23] = parArr[18];
arr[24] = 3;
arr[25] = parArr[3];
arr[26] = dataArr[10];
arr[27] = dataArr[19];
arr[28] = 4;
arr[29] = dataArr[4];
arr[30] = browserArr[11];
arr[31] = browserArr[21];
arr[32] = 5;
arr[33] = browserArr[5];
}
// 为固定值赋值到数组
function assignFixedValues(arr) {
const pageId = 22740;
const aid = 2631;
arr[41] = pageId & 255;
arr[42] = pageId >> 8 & 255;
arr[43] = pageId >> 16 & 255;
arr[44] = pageId >> 24 & 255;
arr[45] = aid & 255;
arr[46] = aid >> 8 & 255;
arr[47] = aid >> 16 & 255;
arr[48] = aid >> 24 & 255;
}
然后还要还原一个算法,生成日志中刷出来的50
位数组,实现算法如下所示:
javascript
function buildSecondaryArray(arr) {
const indices = [9, 18, 30, 35, 47, 4, 44, 19, 10, 23, 12, 40, 25, 42, 3, 22, 38, 21, 5, 45, 1, 29, 6, 43, 33, 14, 36, 37, 2, 46, 15, 48, 31, 26, 16, 13, 8, 41, 27, 17, 39, 20, 11, 0, 34, 7, 50, 51, 53, 54];
return indices.map(i => arr[i]);
}
接着对13
的一个时间戳进行字符编码数组操作!实现算法如下所示:
javascript
function getLastNum(){
let intStr = parseFloat(1737455333787) + 3;
let byteValues = [];
let timestampStr = `${intStr & 255},`; // 将结果转换为字符串形式
for (const char of timestampStr) {
byteValues.push(char.charCodeAt(0)); // 将每个字符的Unicode值推入数组
}
return byteValues;
}
然后将上面的8
跟55
位数组进行新的异或
运算操作,实现算法如下所示:
javascript
// getLastNum: 8位数组
// buildInitialArray: 55位数组(新)
function indexTwoArr(arr8, arr55){
const xorResult = arr1[0]^arr1[1]^arr1[2]^arr1[3]^arr1[4]^arr1[5]^arr1[6]^arr1[7]^arr[0] ^arr[1]^arr[2] ^arr[3]^arr[4]^arr[5]^arr[6]^arr[7]^arr[8]^arr[9]^arr[10]^arr[11]^arr[12]^arr[13]^arr[14]^arr[15]^arr[16]^arr[17]^arr[18]^arr[19]^arr[20]^arr[21]^arr[22]^arr[23]^arr[25]^arr[26]^arr[27]^arr[29]^arr[30]^arr[31]^arr[33]^arr[34]^arr[35]^arr[36]^arr[37]^arr[38]^arr[39]^arr[40]^arr[41]^arr[42]^arr[43]^arr[44]^arr[45]^arr[46]^arr[47]^arr[48]^arr[50]^arr[51]^arr[53]^arr[54]
return xorResult
}
最后就是我们在日志看到的数组push
不断的,完整的算法实现如下所示:
javascript
function GenArrObj(){
const arr0 = generateRandomGarbledArrays(); //8位数组
// 从前往后分别是50位数组、Ua映射字符数组、时间戳计算数组、异或运算函数indexTwoArr
const arrAr = [...arr_50, ...generateArr2(userAgent), ...lastNumOne, lastNum];
const numList = [];
const len = arrAr.length;
// 循环遍历 arrAr,步长为 3
for (let i = 0; i < len; i += 3) {
const num1 = arrAr[i];
const num2 = i + 1 < len ? arrAr[i + 1] : undefined;
const num3 = i + 2 < len ? arrAr[i + 2] : undefined;
// 如果有 num2 和 num3,则按位运算处理
if (num2 !== undefined && num3 !== undefined) {
const random = Math.random() * 1000 & 255;
numList.push(
(random & 145) | (num1 & 110),
(random & 66) | (num2 & 189),
(random & 44) | (num3 & 211),
((num1 & 145) | (num2 & 66)) | (num3 & 44)
);
} else if (num2 !== undefined) {
// 只有 num1 和 num2
numList.push(num1, num2);
} else {
// 只有 num1
numList.push(num1);
}
}
return [...arr0, ...numList];
}
最终得到一个100
多位的数组,到了拼接最终参数的环节!把上面的数组丢给RC4
加密算法,得到下面这样子的:
úJaßèu<<3ü"€@òsT{ø¾ØVf...
最后基于上面对完整参数的拼接,对上面流程加密产出的最终乱码进行一个字节分组。3个一组经过编码得到一个整数。然后通过位操作的方式将三个字符的ASCII码组合成一个24位整数,其后通过按位与(&)操作和右移(>>)的操作来得到一个下标,最后从自定义的字符串中索引取值拼接得到完整的参数值!如下所示:
这里我们可以根据运算日志中的输出在本地进行字符的验证,确保规则是没问题的,如下:
那么现在我们准备对算法开始还原,首先对密文乱码按照每3个字符进行切割一下!像£¢RÕVfmÌ---4ž¸Jc³©õ÷†æwéc"ä
这样的火星文...并创建一个长度为3的新数组,代码实现如下:
javascript
Array.from({ length: Math.ceil(garbledString.length / 3) }, (_, i) => {
const chunk = garbledString.slice(i * 3, i * 3 + 3);}
然后对分割出来的字符进行编码(使用自定义的字符集)再拼接成一个完整的长字符串,这里完整的算法实现如下所示:
javascript
function generateABogus(url, ua, data) {
// 经过上面一系列加密得到的加密火星文
const garbledString = "BX× ªs8¨7ù£¢RÕVfmÌ---4ž¸Jc³©õ÷†æwéc\"ä‚Ô³Æym'½¼|Áóô÷\\/\\- ;)`ð3ì¼€ÑðXu---šÙÃd=yAãÚMƒtúK!Ûyw£`7<<°ÅwTÓ:Z"8á";
// 插桩日志能看到最后取值检索的字符集
const char = "Dkdpgh2ZmsQB80/MfvV36XI1R45-WUAlEixNLwoqYTOPuzKFjJnry79HbGcaStCe";
// 生成最终的Abogus字符串
return Array.from({ length: Math.ceil(garbledString.length / 3) }, (_, i) => {
const chunk = garbledString.slice(i * 3, i * 3 + 3);
let Num = 0;
for (let j = 0; j < chunk.length; j++) {
Num |= chunk.charCodeAt(j) << ((2 - j) * 8);
}
const encodedChars = [
charSet[(Num & 16515072) >> 18],
charSet[(Num & 258048) >> 12],
charSet[(Num & 4032) >> 6],
charSet[Num & 63]
];
const padding = 3 - chunk.length;
return encodedChars.slice(0, 4 - padding).join('') + '='.repeat(padding);
}).join('');
}
Num
就是上面提到保存那个24整数的,然后chunk
火星文字符使用charCodeAt
直接Unicode
编码,那24整数怎么来的,注意上面的算法中|=
按位或操作将字符编码值移动到正确的字节位置!这么得到的,如下所示:
然后下面就是位运算了,这里作者拿一个例子来说一下原理吧!方便新手朋友对进制之间的转换、位移的操作,如下所示:
这里作者拿的上面日志中的一个24整数,这里将其先转为二进制来看,分别如上!然后按位与
得到了一个新的二进制串!把它转换为十进制的结果就是16252928
在这个结果的基础>>
18右移的操作,相当于将数字除以2^18
取整。所以先将16252928
转换的二进制进行右移得到了一个0b111...
得到结果111110
然后转十进制就是计算出来的结果,如下所示:
python
# 根据手动计算的方式是这样的
binary_number = '111110'
decimal_number = int(binary_number, 2)
print(decimal_number)
32+16+8+4+2+0=62
# 结果就是: 62
最后整个算法的流程还原之后,找一个接口验证一下算法是否有效!这篇文章也是前前后后有时间码一点(因为最近公司业务涉及到的有一个5S刚今天弄完
),好多天了。过程也是能够细的地方尽量细,测试结果如下所示:
开头的时候说了一下通用
是没错的,因为之后过了两天作者又顺便去瞅了一眼视频端的日志信息,流程是一样的,测试如下:
至此,明天作者就要回家过年了~然后抽时间快速复盘来更完本文!其实中间数组运算、加密环境可以写的更加细节。然后运算操作的日志部分(因为很多经验不足或者对算法的运算原理不是很了解的话对于魔改的算法还原可能会绕进去浪费一些时间
)这个作者会在假期结束以后再次进行细化,这篇文章在一定程度上截止目前。应该是可以为学习研究分析的小伙伴提供一些思路的!最后,在这里提前祝大家新年快乐