JSVMP 反编译
将 VMP 中的字节码,重新构建成 AST 节点,实现对 JSVMP 代码进行还原
参考内容 BV1bFXPBWEgz BV1vz4y1v7Wm
预处理
基础准备: 使用 AST 去除 OB 混淆、编码混淆等基础混淆内容
补环境(复杂代码需要此步骤): 保证代码顺利运行,方便后续VMP指令还原时的调试与打印
常量还原: 代码中读取的常量数组内的常量、与其他确定的常量(
var glb = this) 转换成 AST 节点(this转换成节点types.ThisExpression)定位虚拟机与关键参数: 明确 索引(PC),堆栈,作用域数组 等变量
保持整体架构不变,仅指令解析时需要手动处理(
switch外的整体内容保持不变,指令中保护的代码才是关键的代码)手动处理指令的准备
const types = require('@babel/types') const parser = require('@babel/parser') const traverse = require('@babel/traverse').default const generator = require('@babel/generator').default const { scope } = require('@babel/traverse/lib/cache.js')定义一些初始化值
// 函数计数器,还原函数时函数名使用 let functionCounter = 1 // 函数返回值计数器 let funcRetValCounter = 1 // 函数声明数组 - 当遇到函数声明时存入该数组 const funcSaver = [] // 函数作用域名,还原函数用于存储与判断当前作用域。该数组存放还原节点 const programScopeList = [] // 全局作用域 - 可用于得到还原后的代码 programScopeList.name = 'programScopList' programScopeList.parentScope = null // 父作用域,父为全局作用域时设置为 null复制一个栈传入VM。执行到分支时,栈会选择一个分支执行并修改。通过读取原始栈,可将没有走的分支逻辑还原出来
stack = Array.from(<原始栈>)生成还原代码: 代码最后一行进行还原代码的输出
fs.writeFileSync('vmp-reverse-output.js', generator(types.program(programScopeList)).code)
虚拟机分析
- 定位虚拟机函数。分析 作用域、栈、SP、PC 等内容的标识符
- 修改虚拟机函数。增加参数,以使虚拟机使用额外传入的作用域、栈、SP
function xxx(..., scopeListm stackArray, stackPosition) { // 修改 作用域、栈、SP的初始化 _0xXXX = stackArray ? stackArray : [] // 正常初始值是空数组,修改成优先接收传入内容 _0xYYY = stackPosition ? stackPostion : 0 } - 在switch判断指令前,增加数组记录已处理的 case 编号。每处理一个,在数组内记录
const caseHandledArray = [] if (!caseHandledArray.includes(_0xXXX))) { console.log("指令未处理:", _0xXXX) process.exit() } switch (_0xXXX) { .... }
分支还原
不同分支在虚拟机中的表现不同,根据具体情况而定。此处结合行号查看
if无else// if前的其他语句 if (test) { pc = 4 // 这里可以省略 // 满足条件的语句 } else { pc = 7 } // if 后的其他语句if+else// if前的其他语句 if (test) { // 满足条件的语句 pc = 9 } else { // 不满足条件的语句 pc = 4 // 这里可以直接 9 也可以到 4 后,再修改成 9 } // if 后的其他语句while循环pc = 3 // 循环体 if (test) { // 循环条件判断 pc = 2 } else { pc = 8 }
CASE处理入门
所用代码,其中 src.js 是VMP后的代码,pmvsjyc.js 是VMP反编译代码
运行代码,查看待解析 case 编号,同时分析 case 的功能。如果 case 在构造语句,则需手动使用 AST 还原出对应的语句
非语句CASE
入常量入栈、变量名入栈等操作。方便后续构造语句涉及到常量时,可从栈中获取
通过源代码在待解析的
case设置断点,将case中执行的代码放回case。保证涉及到常量都已被处理为 AST 节点case 1: a8 = duei.length // 循环未执行 - 循环用于声明函数 for (a1 = 0; a1 < a8; a1++) { a7 = duei.pop() if (a7 === "cbbiyhh.online") { break ... break // 解析代码 case 1: a8 = duei.length break将常量压入堆栈供后续的真实代码获取
case 23: // 此处 cbbb 是虚拟机函数的入参。而向该函数参数传入的变量,其值为 this,需确保 this 已被转换为节点 all = cbbb duei.push(cbbb) break // 外部没进行转换,则此时需要进行转换 cywindow = types.identifier("window") // cywindow 就是外部传入的 cbbb
语句构造CASE
经过断点调试分析, case 是语句时: 构建语句,存放时分析该语句的内容,根据内容考虑两种情况
case中生成的语句是一个功能完整语句,如console.log(a)。该语句已还原完成,放入预先声明的,用于存放还原节点的 AST 中resultAst.body.push(exp)case中生成的语句是别的语句的子语句,如console.log(a())中的a()。该语句是完整语句的一部分,放回堆栈,从而让最终语句从堆栈中读取到该子语句节点,并组装出完成语句节点后方可放入还原树中
即“子语句(表达式)入栈,完整语句(语句)入树”。可观察原 case 中,操作完成后是否进行压栈操作,判断该操作是否为子语句
完整语句
- 声明变量并赋予初始值
case 22: a1 = shuz[start++] a2 = duei.pop() // a2 是栈中取出的全局对象 a3 = duei.pop() // a3 是栈中取出的常量节点 a2[constantPool[a1]] = a3 // 全局对象的属性是一个常量节点,即在全局中创建变量语句 break // 解析代码 case 22: a1 = shuz[start++] a2 = duei.pop() a3 = duei.pop() // 构建语句 const exp = types.assignmentExpression("=", types.memberExpression(a2, types.identifier(constantPool[a1])), a3 ); // 放入树中 resultAst.body.push(exp) break
子语句
子语句由于需要参与完整语句的构建,往往会将值放回栈内供后续内容读取,case 中存在计算后的压栈操作
- 读取属性: 该case中读取了一个对象的属性,并将对应值压回栈内。说明后续内容需要读取这个属性,极有可能是一个子语句
case 181: a1 = duei.pop(); // 取出内容 a2 = duei.pop(); // 获取一个对象的属性 try { a1 = a2[a1] } catch (e) { a1 = window[a1] } all = a2 duei.push(a1) // 属性值压回栈 break // 解析后 case 181: a1 = duei.pop(); a2 = duei.pop(); try { a1 = types.memberExpression(a2,a1,true) } catch (e) { a1 = types.memberExpression(types.identifier("window"),a1,true) } all = a2 // 获取对象属性(memberExpression),通常不放入还原树,而是放入堆中正常执行 // 保持压栈操作 duei.push(a1) break - 计算: 该 case 中从栈内获取了内容,并计算,随后将结果放回栈中供后续内容读取
case 20: a1 = duei.pop() a2 = duei.pop() a1 = a2 + a1; duei.push(a1) break // 解析代码 case 20: a1 = duei.pop() a2 = duei.pop() a1 = types.binaryExpression('+', a2, a1) // 计算通常不放入还原树,而是放入堆栈正常执行 duei.push(a1) break - 函数调用: 需要分析 case 逻辑。这里的逻辑是进行函数调用,并将结果返回
case 150: a1 = shuz[start++] a3 = duei.pop() args = [] for (a2 = 0; a2 < a1; a2++) { args.splice(0, 0, duei.pop()) } if (a3 == window.setTimeout) { a4 = setTimeout(...args) } else if (a3 == window.atob) { a4 = atob(...args) } else if (a3 == window.clearInterval) { a4 = clearInterval(...args) } else if (a3 == window.setInterval) { a4 = setInterval(...args) } else if (a3 == window.RegExp) { a4 = RegExp(...args) } else if (a3 == window.alert) { a4 = alert(...args) } else { a4 = a3.apply(all, args) // 根据调试,只执行了 apply, 且 all 是全局。原版在此处已经执行了该函数 } duei.push(a4) // 将函数执行结果压栈 break // 解析代码 case 150: a1 = shuz[start++] a3 = duei.pop() args = [] for (a2 = 0; a2 < a1; a2++) { args.splice(0, 0, duei.pop()) } // 由于调试时,整个 vmp 过程中此 case 只执行了 apply。此处移除其他情况判断 // 由于 all 是全局,相当于直接调用一个全局函数,直接使用 callExpression // 方案 1 a4 = types.callExpression(a3, args) // 构建函数调用节点 duei.push(a4) // 直接将语句压栈,若该语句不是子语句,在结束虚拟机或 pop 时(该语句结束了生命周期),将其放入还原树 // 优点: 使代码更简洁(当函数调用作为子语句时),缺点: 执行时机可能异常(当该函数调用就是一个没有返回值的语句,后续没有读取该返回值,则该语句一直在栈内) // 方案 2 (标准做法) // 构造标识符接收函数执行返回值的语句,将该语句放入还原树 // 将返回值标识符压栈 // 优点:保证了执行顺序完全与原逻辑一致,缺点:代码冗长 break
处理完成
在 while 循环结束时(或者 default 的退出分支),把堆栈里剩下的没有被消耗掉的节点当作独立语句放入树中:
case 200:
return
default:
return "-90_cbb"
// 解析代码
case 200: // 原版 VM 结束指令
// 【关键操作】:VM 要退出了,把堆栈里残留的节点(比如 console.log)全部当作独立语句排队放入树中
while (duei.length > 0) {
let leftNode = duei.shift(); // 从栈底往栈顶拿,保证执行顺序
result.body.push(types.expressionStatement(leftNode));
}
console.log("解析遇到 case 200,正常结束!");
return;
default:
// 处理原版的 default 异常情况
if (s_cbb === undefined) {
console.log("指令数组越界结束");
return;
}
console.log("指令没有解析:", s_cbb);
return;
复杂CASE处理
在真实代码中,存在着构建函数,流程控制等内容。这些内容的处理方式往往较为复杂
代码块还原思路
在还原代码时,我们需要还原函数、流程控制等内容,这些内容往往存在嵌套关系。如函数的函数体是代码块,代码块中存在流程控制(循环,条件判断),而循环体或分支也是代码块。
因此还原时需要递归进行还原。
- 作用域: 每个代码块都有其内部作用域与父作用域,需要进行判断
- 函数若是全局函数,其作用域是函数,父作用域是全局
- 函数内的流程控制块,其作用域是块,父作用域是所在函数
- ...
还原时,读取要是代码块,则将当前代码块的字节码、作用域 与 堆栈情况 递归调用虚拟机函数,进行还原。使用返回值接收还原的代码块,从而可用于还原父层代码块
因此虚拟机函数需要改造成可以接收传入 堆栈、作用域
作用域数组用于接收当前代码块已经生成并需要存放的 AST 节点,如之前提及的
- 函数调用。创建变量接收调用结果,将该变量标识符压栈,但变量接收调用表达式的语句需要生成并存放在结果数组中
- 创建变量
- 读取对象属性。创建变量接收结果,将该变量标识符压栈
- ... 只要这个操作具有“副作用(Side Effects)”,就必须构造临时变量放入 ScopeList。 在反编译中,没有副作用的纯计算操作(如 1 + 1、a === b、读取 obj.name)可以直接把 AST 节点压栈,等待后续组合。 但如果操作会改变程序的执行状态,如果直接压栈可能会导致执行时机错乱或重复执行,这些情况必须立即执行并用变量接收(放入 ScopeList):
- 函数调用 (CallExpression):比如 console.log() 或修改了全局变量的函数。
- 赋值操作 (AssignmentExpression):比如 a = 2 或 obj.b = 3。
- 对象实例化 (NewExpression):比如 new Date(),每次 new 都是新对象,万一被 VM 复制了栈顶,会导致 new 两次。
- 自增/自减 (UpdateExpression):比如 i++。
- etc.
虚拟机分析
阅读源码,分析变量含义 虚拟机外共两层
最外层
// _0x1f8d7a 字节码字符串,包含常量池,指令。函数起始地址、长度等。后续切分并 parseInt 从而得到 int 数组,进行 case 循环 // _0x4ec77a 函数起始地址。VMP声明函数时使用 // _0x3596f9 / 2 函数长度 / 函数的指令个数 - 每个指令2字节 // [] 函数参数数组。VMP声明函数时使用 // _0x4ede15 外部作用域(全局作用域)数组: 全局变量 // _0xb2668e 传入 undefined 且没有使用,忽略此参数 return _0x31ad27(_0x1f8d7a, _0x4ec77a, _0x3596f9 / 2, [], _0x4ede15, _0xb2668e) // 进入虚拟机外层调用虚拟机层 - 虚拟机外层
funcion _0x31ad27(_0x541033, _0x534897, _0x12161e, _0x1e583c, _0xaf8777, _0x40f090, _0x19443e) { // 入参说明 // _0x541033 字节码字符串 // _0x534897 函数起始地址 // _0x1e583c 函数参数数组。VMP声明函数时使用 // _0x12161e 函数长度 / 函数的指令个数 // _0xaf8777 外部作用域(全局作用域)数组: 全局变量 // _0x31bb79 赋值为 window _0x31bb79 = this // 声明一个对象 var _0x5f1d55 = {} // 获取外部作用域长度 _0x31ff41 = _0x5f1d55['d'] = _0xaf8777 ? _0xaf8777['d'] + 1 : 0 // 外部作用域放入该对象 - 作用域数组转换成作用域对象 for (__0x5f1d55['$' + _0x31ff41] = __0x5f1d55, _0x219876 = 0; _0x219876 < _0x31ff41; _0x219876++) { _0x5f1d55[_0x13a1af = '$' + _0x219876] = _0xaf8777[_0x13a1af] } // 参数赋值 VMP声明函数时使用,函数内遍历参数列表获取参数值 for (_0x219876 = 0, _0x31ff41 = _0x5f1d55['length'] = _0x1e583c['length']; _0x31ff41; _0x219876++) { _0x5f1d55[_0x219876] = _0x1e583c[_0x219876] } // 对象构造完成 - 存储外部作用域和参数列表 // 调用虚拟机函数 return _0x19443e && _0x509192[_0x534897], _0x509192[_0x534897], // 传入参数: 字节码字符串、 函数起始地址、 函数长度 / 函数的指令个数、存储作用域与参数的对象,全局对象 _0x1233dd(_0x541033, _0x534897, _0x12161e, 0, _0x5f1d55, _0x31bb79, null)[1] }虚拟机函数
function _0x1233dd(_0x30754b, _0x1019bd, _0x3064f2, _0x26e9ab, _0x5d7e17, _0x2c01e5, _0x469ec1, _0x1e438f) { // 入参说明 // _0x30754b 字节码字符串 // _0x1019bd 函数起始地址 // _0x3064f2 函数长度 / 函数的指令个数 // _0x5d7e17 存储作用域与参数的对象 // _0x2c01e5 全局对象 // 虚拟机初始化部分代码 // _0x2c01e5: 全局对象 window 此变量为空时再次赋值为 this _0x2c01e5 = this // 注意 nodejs 中的 this 指向全局时与浏览器不同,需要处理成 identifier('window') // _0x795c5c: 堆栈 - 接收外部处理时复制的堆栈 `stack` _0x795c5c = [] // _0x17e91c: SP 栈指针 / 栈的长度 _0x17e91c = 0 // _0x415185: PC / 指令索引(当前指令在字符串的索引) - 接收外部传入函数起始地址作为初始值,循环读取一个指令时,自 +2 _0x415185 += 2 // _0x7ea922: 函数的结束地址(起始地址 + 2 * 指令个数 ,每个指令 2 字节) _0x7ea922 = _0x415185 + 2 * _0x3064f2 // 循环获取每个指令码 - 读取字符串两个字符,作为 16 进制字符串,转换成整形,得到当前指令的 case 编号 _0x4ac770 = parseInt('' + _0x30754b[_0x415185] + _0x30754b[_0x415185+1], 16) ... }
代码输出
移除原本的调用虚拟机函数处
// 移除此处的调用并返回虚拟机函数部分
// return _0x19443e && _0x509192[_0x534897],
//
自己编写额外的调用虚拟机函数,传入存放节点空数组,进行输出。覆盖原本的虚拟机调用
var result = _0x31ad27(_0x1f8d7a, _0x4ec77a, _0x3596f9 / 2, [], _0x4ede15, _0xb2668e, undefined, undefined, programScopeList)
return result
数值压栈
将数值压栈。还原时需要使压栈的内容都是 AST 节点
字节码整型压栈: 字节码内指定字符的整型(这里以读取 2 字节的字节码转换的值)压入堆栈
- 源代码
// 预定义函数 // 字符串指定位置开始,连续读取 2 字节转换成有符号整型 _0x262766 = function (_0x24c906, _0x2b76de) { // 字符串指定位置开始,连续读取 2 字节转换成整型 var _0x4dfc08 = parseInt('' + _0x24c906[_0x2b76de] + _0x24c906[_0x2b76de + 1], 16); // 把 0~255 的无符号数,转换成 -128~127 的有符号数(8位补码)。模拟 C 语言的 int8_t,等效为 (x << 24) >> 24 return _0x4dfc08 = _0x4dfc08 > 127 ? -256 + _0x4dfc08 : _0x4dfc08; } // case // 压栈 (SP+1) 字节码中读取的整数 _0x795c5c[++_0x17e91c] = _0x262766(_0x30754b, _0x415185), // 指令索引 / PC _0x415185 前进2个字节(跳过已处理的字节码 - 2 字节的整型) _0x415185 += 2; break; - 还原: 将待压栈的内容转换为 AST 节点再压栈
_0x795c5c[++_0x17e91c] = types.valueToNode(_0x262766(_0x30754b, _0x415185)) _0x415185 += 2 break
- 源代码
固定常量压栈
- 源代码: 将
true压栈_0x797c7c[++_0x17e91c] = !0; break; _0x797c7c[++_0x17e91c] = void 0; break - 还原
_0x797c7c[++_0x17e91c] = types.valueToNode(!0) break _0x797c7c[++_0x17e91c] = types.unaryExpression('void', types.valueToNode(0)) break
- 源代码: 将
全局对象压栈
- 源代码
_0x797c7c[++_0x17e91c] = _0x210b4b break - 还原:
_0x210b4b在预定义处赋值为全局this,预处理阶段已经处理成 ast 节点。此处无需额外操作
- 源代码
内置函数调用压栈
- 源代码
//栈内取值 _0xfd8a61 = _0x797c7c[_0x17e91c--], // 将该值的 typeof 结果压栈 _0x797c7c[_0x17e91c] = typeof _0xfd8a61; break; - 还原
_0xfd8a61 = _0x797c7c[_0x17e91c--] // 将 typeof 语句构造成 AST 节点并压栈 _0x797c7c[_0x17e91c] = types.unaryExpression('typeof', _0xfd8a61)
- 源代码
栈内数据二元计算后压栈
- 源代码
// 弹出一个数据 _0xfd8a61 = _0x797c7c[_0x17e91c--], // 栈顶元素与之前数据计算结果压栈(操作数1 被 计算结果覆盖,实现操作数1的出栈与结果入栈) _0x797c7c[_0x17e91c] = _0x797c7c[_0x17e91c] == _0xfd8a61; break; - 还原
_0xfd8a61 = _0x797c7c[_0x17e91c--], // 构造计算表达式 _0x797c7c[_0x17e91c] = types.binaryExpression('==', _0x797c7c[_0x17e91c], _0xfd8a61) break
- 源代码
取栈中对象的属性值压栈: 堆栈中取出对象,字节码中取出属性,访问对应值,进行压栈
- 源代码
_0x425731 = _0x2ec1e9(_0x30754b, _0x415185); _0x415185 += 4; _0x795c5c[_0x17e91c] = _0x795c5c[_0x17e91c][_0x425731]; break; - 还原: 栈中对象已经是 AST 节点,取出来的属性也是 AST 节点,直接压栈,无需额外操作
- 源代码
取栈中对象的属性值压栈: 栈中获取对象,栈中获取属性名,访问对应值,进行压栈
- 源代码
- 还原: 构建一个标识符接收取出的内容,将该标识符压栈
_0x17e91c -= 1 // SP 下移 // 构建一个接收对象属性值的标识符名称 var getFromObjectName = `${scopeList.name}_getFromObject${functionCounter++}` // 构建赋值表达式语句放入当前作用域的语句数组 scopeList.push(types.expressionStatement(types.assignmentExpression( '=', types.identifier(getFromObjectName), types.memberExpression(memberExpression(_0x795c5c[_0x17e91c], _0x795c5c[_0x17e91c + 1], true)) ))) // 标识符入栈用于后续语句访问该属性值 _0x795c5c[_0x17e91c] = types.identifier(getFromObjectName)
取局部变量压栈: 从存放函数作用域内变量的对象中取值并压栈
- 源代码
// 预定义函数 // 字符串指定位置开始,连续读取 4 字节转换成整型 _0x2ec1e9 = function (_0x345827, _0xb025b6) { return parseInt((('' + _0x345827[_0xb025b6]) + _0x345827[_0xb025b6 + 1] + _0x345827[_0xb025b6 + 2]) + _0x345827[_0xb025b6 + 3], 16);} // case // 通过函数 _0x2ec1e9,解析 4 位字节码的数字 _0x425731 作为索引值 _0x425731 = _0x2ec1e9(_0x30754b, _0x415185), // 指令索引 / PC _0x415185 前进4个字节(跳过已处理的字节码: 4 字节的整型索引值) _0x415185 += 4, // 获取 局部变量对象 _0x5d7e17 的指定索引 _0x425731 位置的值 _0xfd8a61 _0xfd8a61 = _0x5d7e17[_0x425731], // _0x5d7e17 - 虚拟机外层传入的函数作用域与参数的对象 // 将获取到的局部变量值压栈 _0x795c5c[++_0x17e91c] = _0xfd8a61; break; // case // 通过函数 _0x4d989d,解析 2 位字节码的数字 _0x425731 作为索引值 _0x425731 = _0x425731(_0x30754b, _0x415185), // 指令索引 / PC _0x415185 前进4个字节(跳过已处理的字节码: 2 字节的整型索引值) _0x415185 += 2, // 获取 局部变量对象 _0x5d7e17 的指定 key 的内容压栈(根据虚拟机外层,$ 开头的 key 是函数的外层作用域变量) _0x795c5c[++_0x17e91c] = _0x5d7e17['$' + _0x425731]; - 还原: 放入局部变量数组内的值也会被预先处理成 AST 节点。因此取出并压栈的内容也会是 AST 节点,无需额外操作
- 源代码
取全局变量压栈: 从常量池或预定义的内容中取值。此处示例是从预定义的数据表取值
- 源代码
// 预定义函数 // 字符串指定位置开始,连续读取 4 字节转换成整型 _0x2ec1e9 = function (_0x345827, _0xb025b6) { return parseInt((('' + _0x345827[_0xb025b6]) + _0x345827[_0xb025b6 + 1] + _0x345827[_0xb025b6 + 2]) + _0x345827[_0xb025b6 + 3], 16);} // case // 通过函数 _0x2ec1e9,解析 4 位字节码的数字 _0x425731 作为索引值 // 遍历预定义数据表 _0x390b16['q'][_0x425731] 中的一个范围 // 将数据表中的每个字符与 _0x482e39 进行异或后转换为字符 // 将这些字符连接成一个字符串 _0xfd8a61 // 将解密后的字符串压入堆栈 _0x795c5c,实现代码 _0x795c5c[++_0x17e91c] // 指令索引 / PC _0x415185 前进 4 个字节(跳过已处理的字节码 - 4 字节的整型索引值) for ( _0x425731 = _0x2ec1e9(_0x30754b, _0x415185), _0xfd8a61 = '', _0x16b085 = _0x390b16['q'][_0x425731][0]; _0x16b085 < _0x390b16['q'][_0x425731][1]; _0x16b085++ ) _0xfd8a61 += String['fromCharCode'](_0x3191ea ^ _0x390b16['p'][_0x16b085]); _0x795c5c[++_0x17e91c] = _0xfd8a61, _0x415185 += 4; break; - 还原: 将取出的内容转换成 AST 节点即可
for (_0x425731 = _0x2ec1e9(_0x30754b, _0x415185), _0xfd8a61 = '', _0x16b085 = _0x390b16['q'][_0x425731][0]; _0x16b085 < _0x390b16['q'][_0x425731][1]; _0x16b085++) _0xfd8a61 += String['fromCharCode'](_0x3191ea ^ _0x390b16['p'][_0x16b085]); _0x795c5c[++_0x17e91c] = types.valueToNode(_0xfd8a61), _0x415185 += 4; break;
- 源代码
将栈顶多个元素组成一个数组压栈: 字节码内指定索引开始到栈顶的栈内元素组成一个数组,压入堆栈
- 源码
// 预定义函数 // 字符串指定位置开始,连续读取 4 字节转换成整型 _0x2ec1e9 = function (_0x345827, _0xb025b6) { return parseInt((('' + _0x345827[_0xb025b6]) + _0x345827[_0xb025b6 + 1] + _0x345827[_0xb025b6 + 2]) + _0x345827[_0xb025b6 + 3], 16);} // case // 字节码中取整型 - 作为需要压栈的数组长度 _0x2e5381 = _0x2ec1e9(_0x30754b, _0x415185), // 跳过已处理的字节码 _0x415185 += 4, // 计算 slice 索引(左闭右开,末尾索引要 +1) _0x491a16 = _0x17e91c + 1, // slice复制 指定索引(字节码中获取的整型)到栈顶的数组 // _0x17e91c(SP) 通过数组长度(即 SP) - 待取长度,计算出入栈数组的在栈中开始位置的索引作为 SP // _0x795c5c[SP] 存放 slice 得到的数组 _0x795c5c[_0x17e91c -= _0x2e5381 - 1] = _0x2e5381 ? _0x795c5c['slice'](_0x17e91c, _0x491a16) : []; break; - 还原
const elementCount = _0x2ec1e9(_0x30754b, _0x415185) _0x415185 += 4 const arrayEndIndex = _0x17e91c + 1 _0x17e91c -= elementCount - 1 // 检查是否是空数组 if (elementCount > 0) { const arrayElements = _0x795c5c.slice(_0x17e91c, arrayEndIndex) // 构造一个数组变量 var arrayName = `${scopeList.name}_array${functionCounter++}` var arrayIdentifier = types.identifier(arrayName) arrayIdentifier['length'] = arrayElements.length // 将该变量的声明放入作用域 scopeList.push(types.expressionStatement(types.assignmentExpression('=', arrayIdentifier, types.arrayExpression(arrayElements)))) // 将该变量压栈 _0x795c5c[_0x17e91c] = arrayIdentifier // 或者不构建数组变量,而是将数组节点直接压栈 // _0x795c5c[_0x17e91c] = types.arrayExpression(arrayElements) } else { // 构造一个数组变量。将该变量压栈 var arrayName = `${scopeList.name}_array${functionCounter++}` var arrayIdentifier = types.identifier(arrayName) arrayIdentifier['length'] = 0 scopeList.push(types.expressionStatement(types.assignmentExpression('=', arrayIdentifier, types.arrayExpression([])))) // 或者不构建数组变量,而是将数组节点直接压栈 // _0x795c5c[_0x17e91c] = types.arrayExpression([]) } break
- 源码
构建新对象压栈:
new一个新对象,并将该对象压栈- 源代码
// 字节码中读取一个整数,表示需要从栈中弹出元素的个数,作为 new 对象是的参数个数 _0x425731 = _0x4d989d(_0x30754b, _0x415185), _0x415185 += 2, // 压栈操作,计算 SP 指针,进行压栈 判断参数个数是否为 0 _0x795c5c[_0x17e91c -= _0x425731] = 0 === _0x425731 ? // 为 0, new 对象没有参数 new _0x795c5c[_0x17e91c]() : // 不为 0 ,new 一个对象,参数是栈中取出的元素 _0x111479(_0x795c5c[_0x17e91c], _0x493880(_0x795c5c["slice"](_0x17e91c + 1, _0x17e91c + _0x425731 + 1))); break; - 还原
// 字节码中读取一个整数 _0x425731 = _0x4d989d(_0x30754b, _0x415185) _0x415185 += 2 // 堆栈弹出该整数的元素个数,作为新建对象的参数 _0x17e91c -= _0x425731 // 弹出的元素 if (_0x425731 === 0) { // 构建一个标识符接收 new 出来的对象,将此标识符压栈 var newObjectName = `newObj_${functionCounter++}` scopeList.push(types.expressionStatement(types.assignmentExpression( '=', types.identifier(newObjectName), types.newExpression(_0x795c5c[_0x17e91c]) ))) _0x795c5c[_0x17e91c] = types.identifier(newObjectName[_0x17e91c], []) // 或者直接将构建出来的对象压栈 // _0x795c5c[_0x17e91c] = types.newExpression(_0x795c5c[_0x17e91c], []) } else { // 构建一个标识符接收 new 出来的对象,将此标识符压栈 var newObjectName = `newObj_${functionCounter++}` scopeList.push(types.expressionStatement(types.assignmentExpression( '=', types.identifier(newObjectName), types.newExpression( _0x795c5c[_0x17e91c], // 参数数组 _0x493880(_0x795c5c["slice"](_0x17e91c + 1, _0x17e91c + _0x425731 + 1))) ))) _0x795c5c[_0x17e91c] = types.identifier(newObjectName) // 或者直接将构建出来的对象压栈 // _0x795c5c[_0x17e91c] = types.newExpression( // _0x795c5c[_0x17e91c], // _0x493880(_0x795c5c["slice"](_0x17e91c + 1, _0x17e91c + _0x425731 + 1)) // ) } break
- 源代码
堆栈取值
- 读取函数作用域与参数(函数作用域内的局部变量): 堆栈内取出的值,存入函数作用域与参数的对象(局部变量)的指定位置
- 源代码
// 预定义函数 // 字符串指定位置开始,连续读取 4 字节转换成整型 _0x2ec1e9 = function (_0x345827, _0xb025b6) { return parseInt((('' + _0x345827[_0xb025b6]) + _0x345827[_0xb025b6 + 1] + _0x345827[_0xb025b6 + 2]) + _0x345827[_0xb025b6 + 3], 16);} // case // 通过函数 _0x2ec1e9,解析字节码 _0x30754b 内的数字 _0x425731 作为索引值 - 字节码转换成一个索引 _0x425731 _0x425731 = _0x2ec1e9(_0x30754b, _0x415185); // 指令索引 / PC _0x415185 前进4个字节(跳过已处理的字节码 - 4 字节的整型索引值) _0x415185 += 4; // 从堆栈获取值 _0xfd8a61 _0xfd8a61 = _0x795c5c[_0x17e91c--]; // 将该值存入局部变量数组 _0x5d7e17 的指定索引 _0x425731 内 _0x5d7e17[_0x425731] = _0xfd8a61; // _0x5d7e17 - 虚拟机外层传入的函数作用域与参数的对象 break - 还原: 在压栈时,已经使压栈的内容是 AST 节点。此处从栈内取到的值已经是 AST 节点,最终放入局部变量数据的也是 AST 节点。因此无需额外处理
- 源代码
对象属性
- 对象属性赋值: 字节码中取出值,将堆栈中的对象属性赋值为这个值
- 源代码
// 预定义函数 // 字符串指定位置开始,连续读取 4 字节转换成整型 _0x2ec1e9 = function (_0x345827, _0xb025b6) { return parseInt((('' + _0x345827[_0xb025b6]) + _0x345827[_0xb025b6 + 1] + _0x345827[_0xb025b6 + 2]) + _0x345827[_0xb025b6 + 3], 16);} // case for ( // 堆栈取出一个值 从字节码取出一个整型 _0xfd8a61 = _0x795c5c[_0x17e91c--], _0x425731 = _0x2ec1e9(_0x30754b, _0x415185), _0x58ef42 = '', _0x16b085 = _0x390b16['q'][_0x425731][0]; _0x16b085 < _0x390b16['q'][_0x425731][1]; _0x16b085++) _0x58ef42 += String["fromCharCode"](_0x46ff72 ^ _0x390b16['p'][_0x16b085]); // 跳过字节码中已处理的 4 个字节, 栈顶元素的 _0x58ef42 属性赋值为 _0xfd8a61 _0x415185 += 4, _0x795c5c[_0x17e91c--][_0x58ef42] = _0xfd8a61; break; - 还原: 仅将对象赋值语句处理为对应的 AST 节点,其余不进行变动
for(...)... // 跳过已处理的 4 个字节 _0x415185 += 4 // 判断堆栈取出的值是否是一个节点 - 也可能是局部作用域,局部变量数组 if (_0x795c5c[_0x17e91c].type) { // 构建赋值语句 放入作用域 scopeList.push( types.expressionStatement(types.assignmentExpression('=', types.memberExpression(_0x795c5c[_0x17e91c], types.valueToNode(_0x58ef42), true), _0xfd8a61)) ) } else { // 保持原本的运行逻辑 局部变量数组重构 _0x795c5c[_0x17e91c--][_0x58ef42] = _0xfd8a61 } _0x17e91c-- break
- 源代码
- 对象属性赋值: 将堆栈中的对象设置从堆栈中获取的属性名和对应值(三者均来自堆栈)
- 源代码
// SP下移两位(弹出两个元素) , _0x27df96 为第二个弹出的元素是属性名 var _0x27df96 = _0x795c5c[(_0x17e91c -= 2) + 1]; // 堆栈栈顶元素 _0x795c5c[_0x17e91c] 赋值属性名 _0x27df96 值为弹出的第一个元素 _0xfd8a61 = _0x795c5c[_0x17e91c][_0x27df96] = _0x795c5c[_0x17e91c + 2]; // 特殊情况处理,如果属性名是 5742 的额外处理 5742 === _0x27df96 && (_0xfd8a61 = _0x795c5c[_0x17e91c][_0x27df96 - 1] = !_0x795c5c[_0x17e91c + 2]), _0x17e91c--; break; - 还原
_0x17e91c -= 2 const propertyName = _0x795c5c[_0x17e91c + 1] const targetObject = _0x795c5c[_0x17e91c] const propertyValue = _0x795c5c[_0x17e91c + 2] scopeList.push(types.expressionStatement(types.assignmentExpression( '=', types.memberExpression(targetObject, propertyName, true), propertyValue ))) _0xfd8a61 = types.memberExpression(targetObject, propertyName, true) if (propertyName.value === 5742) { scopeList.push(types.expressionStatement(types.assignmentExpression( '=', types.memberExpression(targetObject, types.valueToNode(propertyName.value - 1), true), types.unaryExpression(propertyValue) ))) _0xfd8a61 = types.memberExpression(targetObject, types.valueToNode(propertyName.value - 1), true) } _0x17e91c-- break
- 源代码
函数定义
在虚拟机首次执行时,会记录包含函数与其指令,在函数被调用时,再获取该函数的指令并调用虚拟机执行该函数的指令
还原时。遇到函数定义先构造 window.func_* = function () {} 的语句。其中函数体是空的,在函数指令传入虚拟机时,将函数体的指令转换成 AST 节点,从而进行还原
- case
- 源码
// 从字节码读取2个字节的内容,转换成有符号整数,作为函数指令个数 / 函数长度 _0x425731 = _0x254e53(_0x30754b, _0x415185); // _0x57c9e7 为构造出来的函数本身 var _0x57c9e7 = function _0x30ce91() { var _0x50f290 = arguments; return _0x30ce91['\u0399II'] > 0 || _0x30ce91["\u0399II"]++, _0x31ad27(_0x30754b, _0x30ce91["II\u0399"], _0x30ce91["I\u0399I"], _0x50f290, _0x30ce91["\u0399I\u0399"], this, null, 0); }; // 函数对象记录自身的信息 // 字节码地址 _0x57c9e7["II\u0399"] = _0x415185 + 4, // 字节码长度 _0x57c9e7["I\u0399I"] = _0x425731 - 2, // 内部函数标识 - 原生函数还是需要字节码 vmp 还原 _0x57c9e7["I\u0399\u0399"] = _0x1233dd, // 调用计数 _0x57c9e7["\u0399II"] = 0, // 外部作用域 _0x57c9e7["\u0399I\u0399"] = _0x5d7e17, _0x47fda0[_0x4cd76e] = _0x57c9e7, // 指令索引 / PC 跳过函数字节码,从而循环进行后续 case 解析 _0x415185 += (2 * _0x425731) - 2; // 函数 break; - 还原:
_0x425731 = _0x254e53(_0x30754b, _0x415185) // 函数名,通过计数器防止重名 funcName = `func_${functionCounter++}` // 定义一个标识符节点 var _0x57c9e7 = types.identifier(funcName) _0x57c9e7["II\u0399"] = _0x415185 + 4 _0x57c9e7["I\u0399I"] = _0x425731 - 2 _0x57c9e7["I\u0399\u0399"] = _0x1233dd _0x57c9e7["\u0399II"] = 0 _0x57c9e7["\u0399I\u0399"] = _0x5d7e17 _0x57c9e7['funcCall'] = function _0x30ce91() { var _0x50f290 = arguments return _0x30ce91['\u0399II'] > 0 || _0x30ce91["\u0399II"]++, _0x31ad27(_0x30754b, _0x30ce91["II\u0399"], _0x30ce91["I\u0399I"], _0x50f290, _0x30ce91["\u0399I\u0399"], this, null, 0) } var funcBody = [] funcBody.name = funcName _0x57c9e7['funcBody'] = funcBody globalThis[funcName] = _0x57c9e7 _0x795c5c[_0x17e91c] = globalThis[funcName] funcSaver.push(globalThis[funcName]) _0x415185 += 2 * _0x425731 - 2 scopeList.push( // 构造 `window.func_1 = function () {}` 的表达式 types.expressionStatement( types.assignmentExpression( '=', types.memberExpression(types.identifier('window'), types.identifier(_0x57c9e7['funcBody']['name'])), // 函数表达式中,函数名,参数留空。函数名以标识符为准 types.functionExpression(null, [], types.blockStatement(_0x57c9e7['funcBody'])) ) ) ) break
- 源码
- 修改代码输出的部分,调用虚拟机将未被调用的函数进行处理,将指令还原成 AST 节点
// 虚拟机外层 函数后 ,手动调用 虚拟机外层函数 处 var result = _0x31ad27(_0x1f8d7a, _0x4ec77a, _0x3596f9 / 2, [], _0x4ede15, _0xb2668e, undefined, undefined, programScopeList) // 对已声明但为调用的函数进行调用,从而得到函数内的指令,进行还原 - 否则未调用函数将是空函数 for(var i = 0; i < funcSaver.length; i++) { var _0x57c9e7 = funcSaver[i] // 函数未被调用(该属性记录调用次数,初始值为 0)时,执行一次调用 if (_0x57c9e7['\u0399II'] == 0) { _0x57c9e7['\u0399II']++ // 计数器+1 var selfArgs = [] for (var i = 0; i < 6; i++) { // 构造函数的参数节点数组 selfArgs.push(types.memberExpression(types.identifier('arguments'), types.numericLiteral(i), true)) } // 调用虚拟机,将该函数进行还原 // 字节码 起始地址 长度 参数节点数组 外部作用域 _0x31ad27(_0x1f8d7a, _0x57c9e7['II\u0399'], _0x57c9e7['I\u0399I'], selfArgs, _0x57c9e7['\u0399I\u0399'], types.identifier('window'), null, 0, _0x57c9e7['funcBody'], [], 0) } } return result
分支判断
示例代码: 循环结构与IF结构的条件判断case的还原
源代码: 条件成立 PC + 4, 不成立根据情况跳转
// 堆栈中取出的条件结果进行判断 (二元计算指令 `==`,`!==` 的结果压栈) _0x795c5c[_0x17e91c--] ? // 为真是指令索引 + 4 (跳过false分支,false 分支的 4 字节跳转偏移量) _0x415185 += 4 : // 为假时判断情况 - 使用函数读取指令中保存的 4 字节跳转偏移量(为真时被跳过的4字节) // 通过 <0 判断 false 分支在当前指令的前方还是后方 (_0x425731 = _0x254e53(_0x30754b, _0x415185)) < 0 ? (1, _0x415185 += 2 * _0x425731 - 2) : _0x415185 += 2 * _0x425731 - 2 break条件 case 的还原代码: 由于原虚拟机中入栈的是条件判断的结果,而还原时入栈的是二元表达式 AST 节点,需要额外处理
var test = _0x795c5c[_0x17e91c--] console.log('if判断条件', generator(test).code) // 将识别到的循环跳转进行记录,通过断点计算两个跳转地址,记录到 map 中 var whileMap = { 21178: [21404, 21162], 20660: [20886, 20644], 6802: [6912, 6786], 7060: [7188, 7038], 7336: [7456, 7314], 4934: [5002, 4912], 5498: [5566, 5476], 8098: [8162, 8076], 9148: [9178, 9142], 22666: [22756, 22630] } // 判断是循环结构还是IF结构的条件判断 if (Object.keys(whileMap).includes(_0x415185.toString())) { // ****** 循环结构的条件判断构造 ****** var = [] whileStatementBody.name = 'whileStatementBody' // 复制堆栈,后续栈变动时,用之前复制的栈还原后续将要执行和不会执行的分支 var stackArray3 = Array.from(_0x795c5c) var stackArray4 = Array.from(_0x795c5c) var pc1 = _0x415185 + 4 var pc2 = _0x415185 + (2 * _0x254e53(_0x30754b, _0x415185) - 2) if (types.isBinaryExpression(test)) { var whileTest = types.binaryExpression(test.operator, types.identifier('i'), test.right) scopeList.push(types.expressionStatement(types.assignmentExpression('=', types.identifier('i'), test.left))) } else { var whileTest = test } // 递归调用虚拟机函数构建分支内的代码块语句 - 传入该代码块所处的作用域与代码块所在函数的参数 _0x1233dd(_0x30754b, whileMap[_0x415185][1], (_0x415185 - 2 - whileMap[_0x415185][1]) / 2, _0x26e9ab, _0x5d7e17, _0x2c01e5, _0x4649ec1, _0x1e438f, scopeList, stackArray, stackPosition _0x1233dd(_0x30754b, pc1, (pc2 - pc1) / 2, _0x26e9ab, _0x5d7e17, _0x2c01e5, _0x469ec1, _0x1e438f, whileStatementBody, stackArray4, _0x17e91c) scopeList.push(types.whileStatement(whileTest, types.blockStatement(whileStatementBody))) _0x415185 = whileMap[_0x415185][0] + 4 // debugger break; } else { // ****** IF结构的条件判断构造 ****** var ifStatementBody = [] var elseStatementBody = [] ifStatementBody.name = 'ifStatementBody' ifStatementBody.parentScope = scopeList elseStatementBody.name = 'elseStatementBody' elseStatementBody.parentScope = scopeList var stackArray1 = Array.from(_0x795c5c) var pc1 = _0x415185 + 4 var pc2 = _0x415185 + (2 * _0x254e53(_0x30754b, _0x415185) - 2) // 递归调用虚拟机函数构建分支内的代码块语句 var if Result = _0x1233dd(_0x30754b, pc1, (pc2 - pc1) / 2, _0x26e9ab, _0x5d7e17, _0x2c01e5, _0x469ec1, _0x1e438f, ifStatementBody, stackArray1, _0x17e91c) var stackArray2 = Array.from(_0x795c5c) var elseResult = _0x1233dd(_0x30754b, pc2, (_0x7ea922 - pc2) / 2, _0x26e9ab, _0x5d7e17, _0x2c01e5, _0x469ec1, _0x1e438f, elseStatementBody, stackArray2, _0x17e91c) // if (ifStatementBody.length === 0) { // if (types.isUnaryExpression(test) && test.operator === '!') { // scopeList.push(types.ifStatement(test.argument, types.blockStatement(elseStatementBody), null)) // } else { // scopeList.push(types.ifStatement(types.unaryExpression('!', test), types.blockStatement(elseStatementBody), null)) // } // } // if (elseStatementBody.length === 0){ // scopeList.push(types.ifStatement(test, types.blockStatement(ifStatementBody), null)) // } // if (ifStatementBody.length > 0 && elseStatementBody.length > 0) { // scopeList.push(types.ifStatement(test, types.blockStatement(ifStatementBody), types.blockStatement(elseStatementBody))) // } // scopeList.push(types.ifStatement(test, types.blockStatement(ifStatementBody), types.blockStatement(elseStatementBody))) if (ifStatementBody.length > 0) { scopeList.push(types.ifStatement(test, types.blockStatement(ifStatementBody), null)) } scopeList.push(...elseStatementBody) return [0, null] }
try
- 源代码
_0x425731 = _0x254e53(_0x30754b, _0x415185) try { // 字节码 try 开始 try 结束 if (_0x479d2a[_0x1dcdbe][2] = 1, 1 == (_0xfd8a61 = _0x1233dd(_0x30754d, _0x415185 + 4, _0x425731 - 3, [], _0x5d7e17, _0x2c01e5, null, 0))[0]) return _0xfd8a61 } catch (_0x37f47a) { if (_0x479d2a[_0x1dcdbe] && _0x479d2a[_0x1dcdbe][1] && 1 == (_0xfd8a61 = _0x1233dd(_0x30754d, _0x479d2a[_0x1dcdbe][1][0], _0x479d2a[_0x1dcdbe][1][1], [], _0x5d7e17, _0x2c01e5, _0x37f47a, 0))[0]) return _0xfd8a61; } finally { iF (_0x479d2a[_0x1dcdbe] && _0x479d2a[_0x1dcdbe][0] && 1 == (_0xfd8a61 = _0x1233dd(_0x30754d, _0x479d2a[_0x1dcdbe][0][0], _0x479d2a[_0x1dcdbe][0][1], [], _0x5d7e17, _0x2c01e5, null, 0))[0]) return _0x118a17; _0x479d2a[_0x1dcdbe] = 0; _0x1dcdbe-- } _0x415185 += 2 * _0x425731 - 2 break - 还原思路: 直接通过递归解析出 try 内的块,catch 的块,finally 的块
循环
通过分支判断与无条件跳转配合,实现循环结构。此处是无条件跳转 case 的还原
- 源代码
// 从字节码中读取一个整型表示跳转偏移量。小于 0 时表示 PC 往回跳,否则 PC 往后跳 (_0x425731 = _0x254e53(_0x30754b, _0x415185)) < 0 ? (1, _0x415185 += (2 * _0x425731) - 2) : _0x415185 += 2 * _0x425731 - 2 break - 还原: 调试并记录循环的地址到条件判断case内,统一进行处理
_0x425731 = _0x254e53(_0x30754b, _0x415185) if (_0x425731 < 0) { console.log('循环', _0x415185, _0x415185 + 2 * _0x425731 - 2) return [2, [_0x415185 + 2 * _0x425731 - 2, _0x415185]] } _0x415185 += 2 * _0x425731 - 2 break
迭代器
构建一个迭代器函数压栈
- 源代码: 该 case 经 ai 分析是一个迭代器的
iter()函数// 初始化 i 栈顶元素的长度 栈顶元素 _0x188ee8 = 0, _0x9d4026 = _0x795c5c[_0x17e91c]["length"], _0x4a74c8 = _0x795c5c[_0x17e91c]; // 创建一个迭代器函数压栈(获取下一个元素的函数) _0x795c5c[++_0x17e91c] = function () { // 判断栈顶元素是否有下一个元素 var _0x68079b = _0x188ee8 < _0x9d4026; if (_0x68079b) { // 获取下一个元素 var _0x366b4a = _0x4a74c8[_0x188ee8++]; // 将该元素压栈 - 在函数调用时压栈 _0x795c5c[++_0x17e91c] = _0x366b4a; } // 将是否有下一个元素的标识位压栈 - 在函数调用时压栈 _0x795c5c[++_0x17e91c] = _0x68079b; }; break; - 还原: 由于 ai 分析该函数是迭代器函数,直接生成
obj[Symbol.iterator]()的 AST 节点后压栈// 取出栈顶元素 _0x4a74c8 = _0x795c5c[_0x17e91c] // 构建一个迭代器函数压栈 `xxx['Symbol'['iterator']]` 等效为 `xxx[Symbol.iterator]` _0x795c5c[++_0x17e91c] = types.memberExpression( _0x4a74c8, // 'Symbol'['iterator'] types.memberExpression( types.stringLiteral('Symbol'), types.stringLiteral('iterator'), true ), // 等效写法 Symbol.iterator // types.memberExpression( // types.identifier('Symbol'), // types.identifier('iterator'), // false // Symbol.iterator 不是计算属性,是点运算 // ), true ) break
Tips: 迭代器 arr[Symbol.iterator] 的说明 在遍历结构等数组元素操作时,JS 引擎底层会自动去寻找对象身上的一个特殊属性:Symbol.iterator
- 只要一个对象有 Symbol.iterator 方法,它就是可迭代的(Iterable)。
- 这个属性是一个方法,
xxx.[Symbol.iterator]()执行后,会返回一个迭代器对象(Iterator),这个对象里面必须有一个 next() 方法。 - 每次调用 next(),都会返回类似 { value: 1, done: false } 的结果,直到 done: true。
函数返回
函数返回语句,此 case 直接 return,没有 break
- 源代码
// 将栈顶元素弹出作为返回值 return [1, _0x795c5c[_0x17e91c--]]; - 还原
// 构造一个 return 语句,放入当前函数语句数组 scopeList.push(types.returnStatement(_0x795c5c[_0x17e91c])) return [1, _0x795c5c[_0x17e91c--]]
JSRPC
使用 websocket 创建爬虫代码与浏览器的连接,让爬虫代码远程调用浏览器控制台 为什么使用 websocket 而不是 http 等其他形式的服务?
- 爬虫代码通过请求浏览器,从获取浏览器内的数据。此时爬虫代码是客户端,浏览器是服务端
- 但浏览器只能作为客户端,因此使用 websocket 长连接,即使浏览器作为客户端,也可以在收到请求后立即响应
部分代码参考 BV1mg4y1R7p9
爬虫作为服务端
这里以加密函数为例
爬虫服务端: 依赖包
websockets- 旧版本写法
import asyncio import websockets async def main(websocket): # 发送给浏览器的内容 - 可在此处将加密函数的入参发给浏览器 await websocket.send('加密函数入参') # 接收浏览器的响应 data = await websocket.recv() print(data) if __name__ == '__main__': # 创建 websocket 服务,传入处理参数 server = websockets.serve(main, '127.0.0.1', 7777) asyncio.get_event_loop().run_until_complete(server) asyncio.get_event_loop().run_forever() - 新版本写法
import asyncio import websockets async def handler(websocket): await websocket.send('加密函数入参') data = await websocket.recv() print(data) async def main(): async with websockets.serve(handler, '127.0.0.1', 7777): await asyncio.Future() # run forever if __name__ == '__main__': asyncio.run(main())
- 旧版本写法
浏览器客户端: 将接收到信息直接调用加密函数,将加密函数结果返回
- 注意: 由于控制台在断点状态下无法建立ws链接,需要保证加密函数位置是全局的
- 先定位加密函数位置。例如页面发送请求处调用加密函数的方式如下
this['DataEncrypt']('待传入的请求体') - 在调用加密函数位置下断点,控制台注入以下代码将加密函数全局化,绑定到 window 上(或重写响应文件),方便后续调用
window.DataEncrypt = this['DataEncrypt']; - 刷新页面并取消断点(或直接跳过断点),使浏览器进入可以 ws 连接的状态
- 检查加密函数全局化效果: 在控制台中输入
window.DataEncrypt查看是否全局化成功 - 先启动服务端,然后在控制台注入以下代码来启动客户端
const ws = new WebSocket('ws://127.0.0.1:7777') ws.onmessage = function (event) { // MessageEvent 实例 console.log(event) // MessageEvent.data 取出消息内容字符串 ws.send(window.DataEncrypt(event.data)) }
三方服务端
额外建立一个 websocket 服务,爬虫代码也作为客户端连接该三方服务。此时可通过增加爬虫客户端实现多爬虫进程的运行
websocket服务端
在多客户端连接的情况下,为了减少房间管理等问题,服务端使用广播的方式,客户端自行根据消息判断是否是自己需要处理的消息
- node环境
const ws = require('nodejs-websocket') const server = ws.createServer(conn => { // 收到文本消息 conn.on('text', msg => { const key = conn.key // 将收到的消息广播发送给全部客户端 // 发送方也会收到,需要自行判断是否是自己要接收的消息 server.connections.forEach(client => { client.send(msg) }) }) // close conn.on('close', (code, reason) => { console.log('客户端断开', code, reason) }) conn.on('error', err => { console.error('连接错误:', err) }) }).listen(7777)
websocket客户端
此处使用 js_<python_client_name>_<send_msg> 表示发送给浏览器的消息。
浏览器判断该消息是自己要处理的消息后,响应 <python_client_name>_<resp_msg>
- python环境
- 使用
websocketsimport asyncio import websockets # 每个客户端设置不同的名称 client_name = 'xxx' async def main(): async with websockets.connect('ws://127.0.0.1:7777') as websocket: # 循环采集任务,持续发送消息 for task_id in tasks: await websocket.send(f'js_{client_name}_msg') res = await websocket.recv() # 过滤出浏览器发送给自己的消息 if res.startswith(client_name + '_'): # 打印出浏览器响应结果 print(res) if __name__ == '__main__': asyncio.run(main()) - 使用
websocket-clientimport websocket client_name = 'xxx' ws = websocket.WebSocketApp('ws://127.0.0.1:7777') def on_message(ws, message): # 过滤出浏览器发送给自己的消息 if message.startswith(client_name + '_'): # 打印出浏览器响应结果 print(message) ws.on_message = on_message ws.run_forerver()
- 使用
- 浏览器环境
(function() { ws = new WebSocket('ws://127.0.0.1:7777') ws.onopen = () => {} ws.onclose = () => { console.log('服务端关闭') } ws.onerror = (e) => { console.log('连接错误:' + e) } ws.onmessage = (event) => { const data = event.data.split('_') // 过滤出发送给浏览器的消息 if (data[0] == 'js') { // 打印出收到的消息 console.log(event.data) // 将结果响应给发送人 ws.send(data[1] + '_' + window.DataEncrypt(event.data)) } } })()