吾爱破解 - 52pojie.cn

 找回密码
 注册[Register]

QQ登录

只需一步,快速开始

查看: 1230|回复: 18
上一主题 下一主题
收起左侧

[Web逆向] 手把手教你给某讯滑块的JSVMP写反编译器 (如宝宝辅食一样易懂)

  [复制链接]
跳转到指定楼层
楼主
Command 发表于 2026-1-27 17:49 回帖奖励

前言

文章中所有内容仅供学习交流使用,不用于其他任何目的,禁止用于商业和非法用途,否则由此产生的一切后果与作者本人无关。

本反编译器针对防水墙TDC.js的JSVMP, 先给大家上个图片看一下反编译后的效果 (恕我不能全放出来, 全部反编译完有2500多行, 太多了)


预备知识

  • JSVMP简单说就是将JS代码通过自己实现的编译器编译到字节码形式, 使用时通过自己实现的VM(虚拟机)运行字节码
  • JSVMP的代码里通常没有具体逻辑, 就是一个大数组然后不断跑循环, 一般的虚拟机都是基于栈/堆栈的
  • 一般来说, 目前逆向JSVMP都是通过插桩打日志然后硬看 (大概是吧)
  • 注意: TDC的指令会进行乱序处理, 每一次的指令数组会被打乱重排

虚拟机分析

解密字节码

直接把这个自执行函数和上面用到的的函数扣下来即可, 放到控制台就能得到指令字节码(一个大数组)

VM简单看一眼

function __TENCENT_CHAOS_VM(PC, Codes, Stack) {
  var Z = !Stack,
    ErrCB = [], // Try (异常时会从中获取设置)
    Stack = Stack || [[this], [{}]], // VM栈
    G = null; // 异常变量

  for (
    var B = [func1, func2, func3...... /* 指令这会先不看 */]; ;
  )
    try {
      for (var A = !1; !A; ) A = B[Codes[PC++]](); // 从指令表中读取并执行, 返回True时停止执行(return或throw)
      if (G) throw G;
      return Z
        ? (Stack.pop(), Stack.slice(3 + __TENCENT_CHAOS_VM.v))
        : Stack.pop(); // 返回值, 一般执行完后直接从栈中pop
    } catch (X) { // 异常处理
      var o = ErrCB.pop(); // 从异常处理栈中获取 [跳转的位置, 栈高度, 异常变量存放位置(可选)] 并设置
      if (o === undefined) throw X; // 如果没有try catch处理该异常, 则继续抛
      ((G = X), // 保存异常变量
        (PC = o[0]),
        (Stack.length = o[1]),
        o[2] && (Stack[o[2]][0] = G));
    }
}

从这里我们可以看到, 一共有两个栈 (混合栈Stack, 异常栈ErrCB), 并且没有其他的寄存器, 堆等;

混合栈Stack在此既充当了作用域内存(存储变量), 又充当了操作数栈(用于计算)

VM指令分析

让函数数组更易读

var D = [function() {w[w.length - 2] = w[w.length - 2] == w.pop()}
        ,,function() {w[w.length - 1] = E[w[w.length - 1]]},
        function() {w.push(w[j[I++]][0])}
        /*......*/ ]

原先的数组中会出现,,在数组中插入空白干扰, 且数组形式不方便知道当前指令的索引

不妨写代码使用Babel进行处理 (需提前NPM安装依赖)

const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const generator = require("@babel/generator").default;
const t = require("@babel/types");

const code = ``; // 将函数数组代码放在这里

const ast = parser.parse(code, {
  sourceType: "unambiguous",
});

traverse(ast, {
  ArrayExpression(path) {
    const elements = path.node.elements;
    if (path.node.elements.length > 10) {
        // console.log(elements)
        const properties = elements.map((el, idx) =>{
            if (el !== null) {
                return t.objectProperty(
                    t.numericLiteral(idx),
                    el
                )
            } else {
                return undefined
            }
            }        
        ).filter((X) => X);
        const obj = t.objectExpression(properties);
        path.replaceWith(obj);
    }

  }
});

const output = generator(ast, {
  minified: false,
  comments: false,
}).code;

console.log(output);

经处理后即可得到

var D = {
  0: function () {
    w[w.length - 2] = w[w.length - 2] == w.pop();
  },
  2: function () {
    w[w.length - 1] = E[w[w.length - 1]];
  },
  3: function () {
    w.push(w[j[I++]][0]);
  }
  /* ...... */
}

栈中变量存储&引用

个人认为此部分是这个VM比较难理解的地方, 理解这里之后基本上写反编译器就没啥问题了.

他难理解就难理解在, 变量不是直接以值的形式存储在栈的索引上, 而是被包裹在一个数组中(BOX), 就像这样

Stack[index] = [ value ] // 实际值被包裹住, 应该是为了引用传递 (函数中会复制栈), 避免内部修改变量后无法从外部获取更新的值

以下是与变量&引用有关的指令(由于字节码会改变, 故不再展示字节码):

变量声明 (相当于var XXX;, 提前占位):

function () {
    var Z = Codes[PC++]; // 操作数: 变量在栈上的索引
    Stack[Z] = Stack[Z] === undefined ? [] : Stack[Z];
}

创建变量引用 (注: 这里不会创建一个值为数字的变量):

function () {
    // 引用栈上变量
    Stack.push([Codes[PC++]]); // 将变量索引包装后PUSH
}

根据引用赋值:

function () {
    // 设置变量 (数字引用找变量, 然后复制)
    // Stack.length-2 是上文创建的变量引用, Stack.length-1 是要赋的值 (复制后并不会删除, 与AssignmentExpression行为一致)
    Stack[Stack[Stack.length - 2][0]][0] = Stack[Stack.length - 1];
}

变量读取:

function () {
    // 读变量
    // 操作数: 变量在栈上的索引
    Stack.push(Stack[Codes[PC++]][0]);
},

结合函数定义(指令):

function () {
    // ...
    Stack.push(function Q() {
        var Z = W.slice(0); // 复制栈
        ((Z[0] = [this]),   // this变量
        (Z[1] = [arguments]), // arguments变量
        (Z[2] = [Q]));      // 函数自身 变量 (在大多数情况下会被直接覆盖占用)
        // ...
        return __TENCENT_CHAOS_VM(G, Codes, Z);
    });
},

不难得到如下栈布局:

(诶你说是哪个天才想到这么整的)

成员引用/访问 (MemberExpression)

(这里也挺抽象的, 第一次接触很容易就一脸懵)

当VM执行类似 obj.prop 的操作时, 它不会直接把 prop 的值压栈并读取, 而是把一个数组压栈(创建一个引用), 直接按MemberExpression就好, 此数组不会被存储为变量:

[Object /* 目标对象 */, PropertyName /* 属性名称 */]

以下是与此有关的指令(由于字节码会改变, 故不再展示字节码):

创建成员引用:

function () {
    // MemberExpression, 成员引用 [Object, Property]
    Stack.push([Stack.pop(), Stack.pop()].reverse());
}

读取变量然后创建成员引用:

function () {
    // 栈布局: [var1, var2.., 变量引用([n]), 属性名]
    var Z = Stack.pop(); // 属性名
    // 下方Stack.pop() 获取变量引用,Stack[...] 从变量引用读取变量
    Stack.push([Stack[Stack.pop()][0], Z]); // 压入 成员引用[变量值, 属性名]
}

读成员引用:

function () {
    var Z = Stack.pop(); // Z 是 [Object, Key]
    Stack.push(Z[0][Z[1]]); // 执行 Object[Key] 并将结果值压栈
}

赋值成员引用:

function () {
    // 从引用设置Property, 不会pop (与AssignmentExpression相符)
    var Z = Stack[Stack.length - 2]; // 获取栈顶下方的引用 [Object, Key]
    Z[0][Z[1]] = Stack[Stack.length - 1]; // 将栈顶的值赋给 Object[Key]
}

函数调用:

function () {
    // 调用函数, 但是MemberExpression -> CallExpression
    var Z = Codes[PC++], // 参数个数
        l = Z ? Stack.slice(-Z) : [], // 获取参数
        Z = ((Stack.length -= Z), Stack.pop()); // 弹出成员引用

    // Z[0] 是 Object, Z[1] 是 Key
    Stack.push(Z[0][Z[1]].apply(Z[0], l)); // 以Z[0]为this调用Z[0][Z[1]](l...)
}

链式访问:

function () {
    // 读MemberExpression并再MemberExpression (例 a.b .c)
    var Z = Stack.pop(), // 新的属性 (c)
        l = Stack.pop(); // 原引用 [a, b]
    // l[0][l[1]] 读a.b
    // 压入新的引用 [a.b(值), c]
    Stack.push([l[0][l[1]], Z]); 
}

OK, 对虚拟机的分析到这里就结束了, 接下来直接开干!

反编译器编写

思路

使用AST表示所有值:不再PUSH具体的字面量, 而是使用 AST 节点 (如 {type: 'Literal', value: 1}),

符号执行:当虚拟机执行具体的操作时, 不计算结果, 而是生成与操作相对应的AST并正常放入栈中

(上面那俩是不是重复了, 说白了就是全套一层AST)

分支探索: 遇到有条件跳转时, 复制现场环境并递归探索另一条分支

使用标记: 对于被使用过的AST, 将它们从列表中隐藏, 用于区分中间产物Expression与结果Expression, Statement   (我知道你看不懂这个, 你往下看就知道了)

警告, 下文假设你足够了解Javascript AST

状态管理

在遇到分支/进入函数时, 需要保存现场, 并复制一份(用于递归调用, 走入另一条分支); 还需要留存AST, 用于最后生成代码

这时就要引入一个状态类

class VMState {
    // 位置, 栈, 异常栈, 变量数
    constructor(PC, Stack, ErrCB, VARCount = 0) {
        this.PC = PC
        this.Stack = Stack
        this.ErrCB = ErrCB
        this.VARCount = VARCount
        this.ASTs = [] // 存储AST (未被当做值引用的Expression和Statement, 这样可以分行)
    }

    // 复制一份
    Copy() {
        return new VMState(this.PC, [...this.Stack], [...this.ErrCB], this.VARCount)
    }

    // 获取临时变量名
    GetVARName() {
        return 'v' + this.VARCount++
    }
}

基础架构

符号执行, 启动!

class Symbolic {
    // 传字节码
    constructor(Codes) {
        this.Codes = Codes
        this.Visited = new Set() // 存储已经走过的PC, 防止重复执行
    }

    // Run
    Run() {
        let AST = this.ProcessBlock(new VMState(0, [[{type: 'ThisExpression'}], [{type: 'ObjectExpression'}]], []))
        // console.log(JSON.stringify(AST))
        console.log(escodegen.generate({type: 'Program', body: AST}))
    }

    // 一开始我是想分块然后做队列的, 结果好像也没分成... 直接递归了
    ProcessBlock(State) {
        let i = State.PC
        let Stack = State.Stack
        let Codes = this.Codes
        var Z;
        var l;
        let Expr;
        let LastI = 0 // 存储上一个PC
        Outside: while (i < this.Codes.length) {
            if (this.Visited.has(i)) {
                if (LastI > i) State.ASTs.push({type: 'Identifier', name: 'GOTO_' + i}); // 向上跳转的我没处理
                // 但是如果是向下跳转的可以不管, 因为另一条分支应该已经走到了
                break
            } else {
                this.Visited.add(i)
            }
            LastI = i
            switch (this.Codes[i++]) {
                // 指令处理
                // 我知道你最想要的是这部分, 但是你先别想
                default:
                    throw new Error('UNKNOWN ' + Codes[i-1])
                    console.log('UNKNOWN ',Codes[i-1])
                    break
            }
        }
        // 我下面会解释这里
        return State.ASTs.filter(X => !X.used).map(X => {
            if (X.type.endsWith('Statement') || X.type.endsWith('Declaration')) {
                return X
            }
            return {type: 'ExpressionStatement', expression: X}
        }) 
    }
}

解释一下最后return那里: 简而言之, 我将所有可能是最终语句的AST节点添加到State.ASTs中(节点与栈中是同一个Object), 如果执行过程中使用了此节点, 就将其标为used, 最后返回所有未被使用的节点, 他们就是每一行的根节点. 更形象的说:

ThisIsAVar = ThisIsAFun() // 先向栈中PUSH CallExpression, 再PUSH AssignementExpression
// 由于并没有办法区分该二者是否是单独的语句, 他会被反编译成这样:
ThisIsAFun()
ThisIsAVar = ThisIsAFun()
// 而当加入used标记后, 栈中的CallExpression在赋值被使用后会被标记为used, 然后会从State.ASTs中被清理掉, 而AssignementExpression未被使用, 因此可以确认AssignementExpression是单独的一个语句

接下来的都是示例, 其他的你们留作课后练习, 自己写去!!!

表达式还原

注: 引入 used 标记. 因为栈中的节点可能是计算中间产物 (比如没人单独算加法然后不用他的值), 只有那些从未被使用的节点, 才是最终的“语句”

switch (this.Codes[i++]) {
    /* ... */
    // 这些指令数字会变, 你们根据自身情况填充
    case OPCodes.ADD: case OPCodes.SUB: case OPCodes.MULT: case OPCodes.DIV: 
    case OPCodes.MOD: case OPCodes.AND: case OPCodes.OR: case OPCodes.XOR: case OPCodes.LSH: 
    case OPCodes.RSH: case OPCodes.URSH: case OPCodes.SEQUAL: 
    case OPCodes.EQUAL: case OPCodes.GE: case OPCodes.GEQ:
    {
        let right = Stack.pop();
        let left = Stack.pop();
        // 将栈中取下来的节点标记为已使用
        left.used = 1
        right.used = 1
        const opMap = { 
            [OPCodes.ADD] :'+', [OPCodes.SUB]:'-', [OPCodes.MULT]:'*', [OPCodes.DIV]: '/', [OPCodes.MOD]: '%', 
            [OPCodes.AND]: '&', [OPCodes.OR]:'|', [OPCodes.XOR]: '^', [OPCodes.LSH]:'<<', [OPCodes.RSH]:'>>', [OPCodes.URSH]:'>>>',
            [OPCodes.SEQUAL]: '===', [OPCodes.EQUAL]:'==', [OPCodes.GE]:'>', [OPCodes.GEQ]:'>=' 
        };
        const node = {type: 'BinaryExpression', left, right, operator: opMap[Codes[i-1]]};
        Stack.push(node); // 没有人会直接把比较结果扔在这不管, 所以不需要State.ASTs.push(Expr), Expr变量的作用是为了保证node一致性
    }
    break
}

变量与变量引用还原

switch (this.Codes[i++]) {
    /* ... */
    case OPCodes.VAR:
        Z = Codes[i++];
        l = {type: 'VariableDeclaration', declarations: [{type: 'VariableDeclarator', id: {type: 'Identifier', name: State.GetVARName()}, init: null}], kind: 'var'};
        Stack[Z] === undefined ? State.ASTs.push(l): 0;
        Stack[Z] = Stack[Z] === undefined ? l.declarations[0].id: Stack[Z];
        break
    case OPCodes.VAR_FROM_INTREF:
        l = Stack.pop()[0]  // 引用Index
        Z = Stack[l] || {type: 'Identifier', name: 'UNDEF_' + l} // 不知道为啥, 有时候会爆
        Z.used = 1
        Stack.push(Z)
        break
    case OPCodes.MAKE_INTREF: // 变量引用, 这里不需要当AST处理 (同样其他pop也是)
        Stack.push([Codes[i++]]);
        break
}

函数还原

switch (this.Codes[i++]) {
    case OPCodes.FUNCTION:
        {
            for (var G = Codes[i++], W = [], Z = Codes[i++], l = Codes[i++], B = [], A = 0; A < Z; A++) W[Codes[i++]] = Stack[Codes[i++]];
            for (A = 0; A < l; A++) B[A] = Codes[i++];
            var Z = W.slice(0); // Stack
            // 如果你眼睛好的话应该能看出来上面三行我是直接从VMP复制过来的
            var nm = State.GetVARName() // 函数名
            Z[0] = {type: 'ThisExpression'}, 
            Z[1] = {type: 'Identifier', name: 'arguments'}, 
            Z[2] = {type: 'Identifier', name: nm};
            var co = 0;
            for (var l = 0; l < B.length; l++) // 原先这里还有< arguments.length, 我这里默认按照最多参数处理
                0 < B[l] && (Z[B[l]] = {type: 'Identifier', name: 'a' + ++co}); // 这个改了一下
            var S1 = State.Copy()
            S1.Stack = Z
            S1.PC = G

            var cnmb = [] /* 嗯对求我当时的精神状态 */
            for (var awa = 0; awa < co; awa++) {
                cnmb.push({type: 'Identifier', name: 'a' + (awa+1)})
            }
            if (!this.Visited.has(i)) { // 考虑边界情况
                this.Visited.add(i)
                State.ASTs.push({type: 'FunctionDeclaration', id: {type: 'Identifier', name: nm}, body: {type: 'BlockStatement', body: this.ProcessBlock(S1) /* 递归 */}, params: cnmb});
                this.Visited.delete(i)
            } else {
                State.ASTs.push({type: 'FunctionDeclaration', id: {type: 'Identifier', name: nm}, body: {type: 'BlockStatement', body: this.ProcessBlock(S1)}, params: cnmb});
            }

            Stack.push({type: 'Identifier', name: nm}) // 函数实际作为Identifier PUSH
        }
        break
}

控制流还原

终于到了万众瞩目的控制流部分!

我在这里假定所有的WhileStatement(While循环)被编译出来都是(事实上我没考虑Break, Continue, 还有欠缺):

Loop:
; ... 条件代码 ...
JZ  LoopEnd(不管是JZ还是JNZ吧)
; ... 循环中的代码 ...
JMP Loop
LoopEnd:
......

代码如下:

switch (this.Codes[i++]) {
    case OPCodes.JZ: // 嘶, 其实我也不知道是JZ还是JNZ, 反正是个有条件跳转就是了
        var S1 = State.Copy()
        S1.PC = Codes[i++]
        Stack[Stack.length - 1].used = 1
        Expr = {type: 'IfStatement', test: Stack[Stack.length - 1], consequent: {type: 'BlockStatement', body: this.ProcessBlock(S1)}, alternate: null} // 满足条件跳走
        State.ASTs.push(Expr)
        break
    case OPCodes.JMP:
        Z = i
        i = Codes[i++];
        if (i < Z && this.Visited.has(i) && State.ASTs.some(X => X.type === 'IfStatement')) { // JMP上跳为循环
            Expr = State.ASTs.pop()
            var Exprs = [Expr]
            while (Expr.type !== 'IfStatement') {
                Expr = State.ASTs.pop()
                Exprs.push(Expr)
            }
            Expr = Exprs.pop()
            State.ASTs.push({type: 'WhileStatement', test: Expr.test, body: {type: 'BlockStatement', body: Exprs.reverse()}}) // 直接处理为WhileStatement
            State.ASTs = State.ASTs.concat(Expr.consequent.body)
        }
        break
}

碎碎念

2026, 又是新的一年, 新能力GET! 没想到我不仅能写JSVMP, 我还能写反编译器, 嘿嘿!

话说, 以我目前的能力, 能上班吗 ( (

马上就是高中阶段第一个寒假, 难得能抽出时间 :) :)

免费评分

参与人数 12吾爱币 +17 热心值 +12 收起 理由
qq3bot + 1 + 1 谢谢@Thanks!
tantanxin147 + 1 + 1 牛逼,看不懂
allspark + 1 + 1 用心讨论,共获提升!
zerglurker + 1 + 1 谢谢@Thanks!
hzpeng + 1 + 1 牛波一
JettyCatR3C0 + 1 + 1 用心讨论,共获提升!
ipylei + 1 + 1 我很赞同!
buluo533 + 1 + 1 用心讨论,共获提升!
timeslover + 3 + 1 用心讨论,共获提升!
放手一搏09 + 1 + 1 感谢发布原创作品,吾爱破解论坛因你更精彩!
漁滒 + 4 + 1 我很赞同!
小傲宇 + 1 + 1 真厉害,才高中,这就是天赋吗

查看全部评分

发帖前要善用论坛搜索功能,那里可能会有你要找的答案或者已经有人发布过相同内容了,请勿重复发帖。

推荐
imxz 发表于 2026-1-28 15:03
天赋也太强了
推荐
JettyCatR3C0 发表于 2026-1-28 14:54
推荐
悦来客栈的老板 发表于 2026-1-28 14:39
警告, 下文假设你足够了解Javascript AST。 看到这句话,我已经放弃了
推荐
炫迈 发表于 2026-1-29 09:13
JSVMP逆向核心就两点,一是搞懂VM的栈结构和指令集,二是用符号执行把字节码还原成AST,这哥们思路很正。
不过实际搞的时候会遇到几个坑,一是指令会混淆乱序,每次加载都不一样,二是有些VM会加反调试和环境检测,直接扣代码跑不起来。
我一般的做法是先静态分析VM框架,找到指令分发的地方,然后动态调试记录执行轨迹,最后用Python或者Node写个反编译器,比手动插桩效率高多了。
还有个技巧,遇到复杂的VM可以先写个简单的解释器跑一遍,看看每条指令实际做了啥,比纯静态分析快。
推荐
kldbs 发表于 2026-1-28 21:18
这是高中生干的活?这不科学啊
推荐
hzpeng 发表于 2026-1-28 21:20
我插桩分析 卡在TEA算法动态key了  反编译又看不懂 唉  能否向佬寻求一份代码学习
沙发
泽哥 发表于 2026-1-28 12:55
马上就是高中阶段第一个寒假, 难得能抽出时间
3#
timeslover 发表于 2026-1-28 14:32
我只想对你说,保护好自己的头发
7#
三十二变 发表于 2026-1-28 16:48
天赋型选手啊,牛
8#
hello88 发表于 2026-1-28 19:47
没想到现在的高中生都这么牛,respect
您需要登录后才可以回帖 登录 | 注册[Register]

本版积分规则

返回列表

RSS订阅|小黑屋|处罚记录|联系我们|吾爱破解 - 52pojie.cn ( 京ICP备16042023号 | 京公网安备 11010502030087号 )

GMT+8, 2026-1-30 11:57

Powered by Discuz!

Copyright © 2001-2020, Tencent Cloud.

快速回复 返回顶部 返回列表