首页
社区
课程
招聘
[原创]使用 AST 还原某大厂JS控制流平坦化与VMP保护
发表于: 2天前 268

[原创]使用 AST 还原某大厂JS控制流平坦化与VMP保护

2天前
268

声明:本文仅供学习交流,仅分享分析思路,不提供相关工具或还原代码。严禁将本文内容用于商业用途或任何违法违规活动,由此产生的一切后果由使用者自行承担,与作者无关!

做安全 SDK 的算法分析,绕不开的不只是算法本身,更重要的就是代码保护。再复杂的算法,脱离代码保护也容易分析;即使是简单的算法,使用强大的代码保护,依然会对攻击者造成很大的困扰。目前大厂较为成熟的保护方式就是 OLLVM 控制流平坦化 + VMP + 标准算法自定义 。接下来就用 AST(抽象语法树)一点点拆开,看看能把代码还原到什么程度。同时讲清楚中间遇到的问题、为什么这样处理,以及处理前后有什么变化。

一、控制流平坦化与 JSVMP

前端保护并不是让代码无法执行,而是让执行逻辑不容易被直接理解。相比变量改名、压缩和简单字符串替换,控制流平坦化与虚拟化会进一步改变程序的表达方式。

1.1 控制流平坦化

正常程序中,一段代码执行完之后,直接进入下一段;遇到条件时选择分支,满足循环条件时重复执行循环体。这些连接关系通常能从语句的排列和嵌套中看出来。

例如,一个条件块 A 根据判断进入 B 或 C,两支处理后汇合到 D,最后结束。

控制流平坦化改变的是这些代码块之间的连接方式:把执行片段交给一个统一的分派器,再用一个状态值记录下一片段的位置。分派器通常由循环和 switch 组成,但也可以使用条件树等其他表达。

图片描述

1.2 JavaScript 虚拟化保护

JavaScript 虚拟化保护(JSVMP) 则是将 JavaScript 逻辑转换成自定义字节码,并随代码提供能够执行这套字节码的解释器。

运行时,宿主 JavaScript 调用解释器;解释器读取指令、选择处理逻辑、操作执行状态,再决定下一条指令。

图片描述

二、AST

AST(Abstract Syntax Tree,抽象语法树) 把源码表示成语句、表达式、函数等节点及其组成关系。

function add(a, b) {
  return a + b;
}

它可以用一棵简化的树表示:

程序 Program
└─ 函数声明 FunctionDeclaration:add
   ├─ 参数:a、b
   └─ 函数体 BlockStatement
      └─ 返回语句 ReturnStatement
         └─ 二元表达式 BinaryExpression:+
            ├─ 左操作数:a
            └─ 右操作数:b

树中能明确区分函数参数、返回语句和加法表达式。空格、换行以及括号外观不是这里的主要信息。

字符串匹配无法获取作用域等信息,容易误改代码。AST 能分清语句结构,配合作用域分析,让我们按代码的实际含义分析和改写,而不是对着字符硬替换。

三、使用 AST 还原

下图是一段经过混淆与保护的 JavaScript。它仍然可以执行,但很难从表面的代码直接看出原有算法的步骤、关键参数如何产生,以及各段代码按什么顺序运行。

图片描述

接下来先从预处理开始,把表达式里的操作展开,方便后面分析执行顺序。

3.1 预处理

3.1.1 逗号表达式

3.1.1.1 非 return:普通表达式语句按顺序拆开

下面这条语句不使用逗号表达式的整体结果,只需要保留三次赋值的顺序:

处理前:

this.type = l, this.value = i, this.target = n;

处理后:

this.type = l;
this.value = i;
this.target = n;

逗号表达式如果在赋值右侧或调用参数里,不能直接按普通语句拆开。

3.1.1.2 return:前面的项展开,最后一项留给返回值

处理前:

function Ni(l, i) {
  return l[49]++, "";
}

处理后:

function Ni(l, i) {
  l[49]++;
  return "";
}

最后的空字符串仍作为返回值。也就是前面的项展开成语句,最后一项继续交给 return。

3.1.2 逻辑表达式

&& 是左边为真才执行右边:

处理前:

(n = a) && (e = typeof Symbol, o = l[51], n = e === o);

处理后:

if (n = a) {
  e = typeof Symbol;
  o = l[51];
  n = e === o;
}

|| 正好相反:左边为假才执行右边。样本中的下面一条 return 还同时包含三目,我们可以先只看最前面的 (t = e) || (t = isNaN(c)):

处理前:

return (t = e) || (t = isNaN(c)), (e = t) ? e = null : ((t = {}).r = a, t.g = s, t.b = c, e = t), a = e;

处理后:

if (!(t = e)) {
  t = isNaN(c);
}

这里先执行 t = e,只有结果为假,才调用 isNaN(c) 并更新 t。

3.1.3 三目表达式

三目表达式要它展开,重点不只是消掉问号,还要把条件、写入和求值顺序保留下来。

处理前:

r = (n = e) ? 0 : 2;

处理中:把写入操作放进两条分支:

(n = e) ? r = 0 : r = 2;

处理后:分支转换成if else语句:

if (n = e) {
  r = 0;
} else {
  r = 2;
}

先用 n = e 的结果选支,再把 r 写成 0 或 2。n = e 这个赋值没有被删掉,也没有同时执行两次对 r 的写入。

通过上面的规则,就能处理 fi 此函数里很长的一条 return。这里用的是前面几条规则的组合:逗号表达式,逻辑表达式,三目表达式,最后一项保留返回值。

处理前:

图片描述

处理后:

图片描述

3.1.4 统一条件处理的方向

混淆代码会把常量放在左边,比如 0 == o。可以统一为“变量在左、常量在右”这样的格式:

处理前:

if (0 == o) {
  ql = function t(a) {

处理后:

if (o == 0) {
  ql = function t(a) {

这里判断的仍是 o 是否等于 0。遇到大小比较时,交换两边还要反转比较符号,不能只交换操作数:

处理前:

if (1 < o) {
  // ...
}

处理后:

if (o > 1) {
  // ...
}

同样的整理也用于 switch 的简单按位表达式:

处理前:

switch (3 & n) {
  case 0:

处理后:

switch (n & 3) {
  case 0:

3.1.5 处理单句分支

这一步把单句 if/else 分支统一写成代码块,让每条语句更清楚表示属于哪个分支。

处理前:

if ("undefined" != typeof globalThis) m = globalThis;else if ("undefined" != typeof global) m = global;else if ("undefined" != typeof self) m = self;else {
  if ("undefined" == typeof window) throw new Error("Unable to detect global object.");
  m = window;
}

处理后:

if ("undefined" != typeof globalThis) {
  m = globalThis;
} else {
  if ("undefined" != typeof global) {
    m = global;
  } else {
    if ("undefined" != typeof self) {
      m = self;
    } else {
      if ("undefined" == typeof window) {
        throw new Error("Unable to detect global object.");
      }
      m = window;
    }
  }
}

3.1.6 去掉标签跳转

break l 与普通 break 不一定跳到同一个地方。所以需要使用do ... while (false) 包裹:

处理前:

l: try {
  (r = b.CSS) && (o = b.CSS, e = (n = l[3][175])[0], c = i(l[4], null, 13, e), e = l[3][n[1]], r = o[e = c + (n = i(l[8], null, 8, e))]);
  (v = r) && (r = b.CSS, o = l[3][175], n = l[3][o[0]], e = i(l[4], null, 16, n), n = o[1], v = r[n = e + (o = i(l[8], null, 17, n))](d, h));
  f = v;
  t = 39;
  break l;
} catch (l) {
  t = 40;
  break l;
}

处理后:

do {
  try {
    (r = b.CSS) && (o = b.CSS, e = (n = l[3][175])[0], c = i(l[4], null, 13, e), e = l[3][n[1]], r = o[e = c + (n = i(l[8], null, 8, e))]);
    (v = r) && (r = b.CSS, o = l[3][175], n = l[3][o[0]], e = i(l[4], null, 16, n), n = o[1], v = r[n = e + (o = i(l[8], null, 17, n))](d, h));
    f = v;
    t = 39;
    break;
  } catch (l) {
    t = 40;
    break;
  }
} while (false);

do ... while (false) 只是一个单次执行、可以被 break 退出的外壳。如果跳转与标签之间还夹着别的循环或 switch,就不能直接去标签,否则 break 可能跳错层级。所以会检查如果存在层级则保留对应的结构。

3.1.7 处理立即执行函数

样本里有两种立即执行函数的写法:一种把函数交给执行器调用,另一种在定义后直接调用。两者都可以去掉调用外壳,让内部操作直接出现在执行位置。

3.1.7.1 通过执行器立即调用

有些 case 并不直接写业务,而是先定义一个函数,再交给执行器立即调用。下面的操作被放进了 u(function () { ... }, this):

处理前:

先看 u 的定义。这里只摘出执行器相关的两个变量;定义和下面的调用位于源码的不同位置:

var gi = Function.prototype.call,
    u = gi.bind(gi);

这里的 u(fn, this) 相当于 fn.call(this):立即执行 fn,并把当前的 this 作为函数调用时的接收对象。确认这一点后,再看调用处,就知道这是一个立即执行的回调:

u(function () {
  e = Symbol[l[52]];
  n = a !== e;
  r = 2;
}, this);

处理后:

e = Symbol[_l20[52]];
n = a !== e;
r = 2;

3.1.7.2 直接自调用

另一种包装没有辅助执行器,而是函数定义后直接调用。IIFE 就是这种立即调用的函数表达式。样本最外层的 !function () { ... }() 属于这一类。看这一层展开前后的开头:

处理前:

!function () {
  var Bi = void 0,
    n = parseInt,
    z = Uint8Array,
    b = String,

处理后:

var _Bi = void 0,
  _n9 = parseInt,
  _z18 = Uint8Array,
  _b = String,

3.1.8 识别状态变量

经过前面的表达式展开、语法整理和立即执行函数展开,代码已经变成下面这样。以 _q55 为例,具体操作已经展开成语句,但代码执行的顺序流由 switch (Z & 7) 和派生值 N 下的多层 if 共同决定。

图片描述

接下来,我们的目标是把同一状态变量及其派生值控制的多层结构,整理成一个直接按完整状态分派的 switch。就是把 switch (Z & 7) 和针对 N 的分派 if 统一成 switch (Z),首先先得认清哪个是状态变量,哪些值是由它派生出来的。这里的思路是:从 for 的循环条件找到候选,再看 switch 是否直接使用它,或者使用由它计算出来的派生值;

继续看截图中的 _q55(ll),它的入口是:

function _q55(ll) {
  for (var Z = 2; void 0 !== Z;) {
    var el,
      rl,
      N = 7 & Z >> 3;
    switch (Z & 7) {

void 0 !== Z 表示 Z 不是 undefined 就继续循环,因此先选 Z 作为候选;switch (Z & 7) 又使用 Z 的低三位选 case,确认它参与分派。回到初始化,Z = 2 给出入口状态。N = 7 & Z >> 3 则从 Z 计算出派生值 N,最终识别的是:状态变量 Z、入口状态 2,以及派生变量 N。

3.1.9 把状态变量的if转switch

上一节已经确认 _q55(ll) 的状态变量是 Z,N 是由 Z 计算出来的派生值。思路是:把原代码段与临时编号建立映射,用编号写入替换代码段,只留下 N 的判断;再给 N 代入不同整数,模拟执行,记录每个 N 选中的编号,最后生成 switch,并把原代码段放回对应 case。

处理前:

case 0:
  if (N == 3) {
    if (C == 309) {
      Z = 1;
    } else {
      Z = 0;
    }
  } else {
    if (N < 3) {
      if (N == 1) {
        if (K) {
          _v35 += 1;
        }
        K = 1;
        R = _y38[b = 4 * _v35];
        _ = (n = R) & (u = 1);
        L = (n = R) & (u = 2);
        n = R;
        Z = 48;
      } else {
        if (N < 1) {
          Z = 8;
        } else {
          if (C == 311) {
            Z = 3;
          } else {
            Z = 40;
          }
        }
      }
    } else {
      if (N == 5) {
        if (C == 310) {
          Z = 4;
        } else {
          Z = 24;
        }
      } else {
        if (N < 5) {
          Z = void 0;
        } else {
          $ = n & (u = 4);
          S = _y38[n = b + 1];
          J = _y38[n = b + 2];
          P = _y38[n = b + 3];
          Z = 5;
        }
      }
    }
  }
  break;

处理1:映射原代码段,用编号替换。

例如,N == 3 分支中的整个 if (C == 309) ... else ... 保存为片段 0;依次类推,接着,把每个原代码段替换成对应的编号记录。处理后就变成下面这样:

if (N == 3) {
  if (typeof code_state === "undefined") {
    code_state = 0;
  }
} else {
  if (N < 3) {
    if (N == 1) {
      if (typeof code_state === "undefined") {
        code_state = 1;
      }
    } else {
      if (N < 1) {
        if (typeof code_state === "undefined") {
          code_state = 2;
        }
      } else {
        if (typeof code_state === "undefined") {
          code_state = 3;
        }
      }
    }
  } else {
    if (N == 5) {
      if (typeof code_state === "undefined") {
        code_state = 4;
      }
    } else {
      if (N < 5) {
        if (typeof code_state === "undefined") {
          code_state = 5;
        }
      } else {
        if (typeof code_state === "undefined") {
          code_state = 6;
        }
      }
    }
  }
}

code_state 就是用来记录“选中了哪个片段”,每次代入一个新的 N,都会重新创建初始值为 undefined 的 code_state。

处理2:给 N 代入值,收集选中的编号。

对这段样本,得到的选块结果是:

N 的值 最终记录的 code_state
0 2
1 1
2 3
3 0
4 5
5 4
6、7 6

N 由 (Z >> 3) & 7 得到,实际取值为 0~7。

处理后:还原代码段

case 0:
  switch (N) {
    case 0:
      Z = 8;
      break;
    case 1:
      if (K) {
        _v35 += 1;
      }
      K = 1;
      R = _y38[b = 4 * _v35];
      _ = (n = R) & (u = 1);
      L = (n = R) & (u = 2);
      n = R;
      Z = 48;
      break;
    case 2:
      if (C == 311) {
        Z = 3;
      } else {
        Z = 40;
      }
      break;
    case 3:
      if (C == 309) {
        Z = 1;
      } else {
        Z = 0;
      }
      break;
    case 4:
      Z = void 0;
      break;
    case 5:
      if (C == 310) {
        Z = 4;
      } else {
        Z = 24;
      }
      break;
    default:
      $ = n & (u = 4);
      S = _y38[n = b + 1];
      J = _y38[n = b + 2];
      P = _y38[n = b + 3];
      Z = 5;
      break;
  }
  break;

3.1.10 多层嵌套switch转换成一层switch

多层 switch 如果它们使用的是同一个状态变量或由它算出的派生值,就可以把这些合成一个switch。和上一节的思路类似:先保存各层分派最终选中的代码段,用编号替换;再收集候选状态值,逐一代入,记录每个状态会选中哪个编号;最后根据这份“状态值 → 代码段”的映射,把代码段放回单层 switch 中。

处理前:

经过上一节处理,分派入口是下面这样:

function _q55(ll) {
  for (var Z = 2; void 0 !== Z;) {
    var el,
      rl,
      N = 7 & Z >> 3;
    switch (Z & 7) {
      case 0:
        switch (N) {
          case 0:
            Z = 8;
            break;

处理1:保存原代码段,用临时编号替换。

这样就得到下面临时分派代码:

....
{
  var el,
    rl,
    N = 7 & Z >> 3;
  switch (Z & 7) {
    case 0:
      switch (N) {
        case 0:
          code_state = 0;
          break;
        case 1:
          code_state = 1;
          break;
        case 2:
          code_state = 2;
          break;
        case 3:
          code_state = 3;
          break;
        case 4:
          code_state = 4;
          break;
        case 5:
          code_state = 5;
          break;
        default:
          code_state = 6;
          break;
      }
      break;
    case 1:
      code_state = 7;
      break;
    case 2:
      code_state = 8;
      break;
    case 3:
      code_state = 9;
      break;
    case 4:
      code_state = 10;
      break;
    case 5:
      code_state = 11;
      break;
  }
}

处理2:收集编号,给完整状态值代入。

先从 Z = 2 的入口和各处需要保留的值,每次代入一个 Z,都重新创建初始值为 undefined 的 code_state,执行上面的临时分派代码,再读回编号。

这一轮实际收集得到:

状态 Z 记录的 code_state
0 0
8 1
16 2
24 3
40 5
48 6
1、9 7
2、10 8
3 9
4、12 10
5、13、309 11

处理后:把原代码段放回对应 case

图片描述

3.1.11 删除不透明谓词

不透明谓词是指在程序执行时其值恒定已知,但在静态分析或逆向工程中难以推断的条件表达式,用于增加代码分析难度和实现控制流混淆。

这里处理的思路是:先识别 case 尾部的下一状态赋值分支,再找出条件表达式依赖的变量。结合当前 case 的,按执行顺序跟踪判断变量之前的声明、赋值和计算,中间发生修改,就更新已知值;如果存在无法确定的计算,就将相关值标为未知。只有能够证明判断时条件恒真或恒假,才保留条件求值和实际会执行的分支,否则保持原样。

处理前:

  var _m35 = 0,
    _S17 = 0,
    _M2 = 0;
  _t3 = [];
  _t3 = 0;
  _t3 = 1;
  _t3 = 2;
  _t3 = [];
  _t3 = {};
  _t3 = _f80(_T8, this, 0);
  if (_m35) {
    _x10 = 2;
  } else {
    _x10 = 0;
  }

处理后:

var _m35 = 0,
  _S17 = 0,
  _M2 = 0;
_t3 = [];
_t3 = 0;
_t3 = 1;
_t3 = 2;
_t3 = [];
_t3 = {};
_t3 = _f80(_T8, this, 0);
_x10 = 0;

3.2 第一轮控制流还原

前面的预处理已经把挤在表达式里的操作展开成语句,并把状态分派 if 和多层 switch 整理成直接按完整状态分派的 switch,接下来就要还原这些代码的控制流:找到哪些片段顺序执行,哪里发生分支,哪里形成循环,再把它们写回顺序语句、if/else 和循环。这里我们使用 CFG(Control Flow Graph,控制流图) 来梳理执行顺序:把每个 case 对应的执行片段看成一个节点,再根据能够确认的下一状态跳转,用有向边将节点连接起来,表示代码的执行顺序。

图片描述

3.2.1 建图前预处理:给共享标签补上显式跳转

对于共享标签的情况。思路是:找到空 case 后的第一个非空 case,给空 case 写入这个目标状态,再补上 break。(当前这些共享执行体,没有发现case 代码体里读取状态派生值的情况,因此可以统一补跳转。如果存在这种依赖,就不能直接改成目标状态,而应该给空 case 复制代码体。)

处理前:

case 4:
case 8:
case 52:
  _ql = function i(l) {
    return typeof l;
  };
  n = 2;
  break;

处理后:

case 4:
  n = 52;
  break;
case 8:
  n = 52;
  break;
case 52:
  _ql = function i(l) {
    return typeof l;
  };
  n = 2;
  break;

3.2.2 建图前预处理:合并顺序链

共享标签整理清楚后,还可以减少一些不必要的分派。有些 case 执行完只会进入下一个确定的 case,而这个目标 case 也只有一处明确的状态跳转指向它。从执行顺序来看,这两段代码本来就是前后相接的 ,顺序链合并的思路,就是把这段连续执行的代码接起来,减少中间的分派。

具体做法是:把目标 case 的语句接到当前 case 后面,去掉两者之间的下一状态赋值和 break,保留目标 case 最后的跳转或终止语句。如果后面还有符合条件的连接,就继续合并。这样,业务语句仍按同样的顺序执行。

但是注意两种情况:

  • 目标是循环并且是初始化入口: 这是一个入口节点,并且有单边指向这个入口节点,这种情况就不能合并。
  • case 中使用了状态变量的派生值,就不能合并。 这里检查待合并的两个 case,直接使用状态变量及其派生变量的不能合并。

处理前:

case 48:
  Y = 24;
  break;
case 24:
  if (D) {
    _v2 += 1;
  }
  D = 1;
  J = _z19[Z = 4 * _v2];
  V = (n = J) & (t = 1);
  C = (n = J) & (t = 2);
  n = J;
  Y = 32;
  break;
case 32:
  P = n & (t = 4);
  Q = (n = J) & (t = 8);
  B = (n = J) & (t = 16);
  g = _z19[n = Z + 1];
  x = _z19[n = Z + 2];
  n = Z + 3;
  Y = 16;
  break;
case 16:
  F = _z19[n];
  Y = 5;
  break;

处理后:

case 48:
  if (D) {
    _v2 += 1;
  }
  D = 1;
  J = _z19[Z = 4 * _v2];
  V = (n = J) & (t = 1);
  C = (n = J) & (t = 2);
  n = J;
  P = n & (t = 4);
  Q = (n = J) & (t = 8);
  B = (n = J) & (t = 16);
  g = _z19[n = Z + 1];
  x = _z19[n = Z + 2];
  n = Z + 3;
  F = _z19[n];
  ....

3.2.3 从入口收集可达节点

前面通过预处理已经给共享标签补上了跳转,也合并了一些能够连续执行的 case。接下来使用 DFS 建图:从初始化入口开始,分析当前 case 执行哪些语句、执行后会去哪里,再沿着找到的目标继续搜索。遇到条件跳转,两条可能的路径都要分析;这样既能建立节点之间的执行关系,也能排除从入口始终无法到达、因此不会执行的 case。 图中只记录从入口可达的节点。

3.2.4 定位分支汇合点,恢复 if/else

图建好之后,接下来要把图中的分支恢复成 if/else。这里不能沿着两条路径一直生成到出口,否则两支后面共用的代码也会被分别复制,生成的代码会不断膨胀。因此,对于分支我们需要找到找到分支汇合点。 这里使用后支配关系来找到最近的分支汇合点。

后支配关系:从 B 走向当前分析区域出口的每一条路径,都必须经过 P。则节点 P 后支配节点 B。

这里的思路是:从分支的两个后继出发,找出它们走向出口时都必须经过的节点,再选最近的共同经过的那个节点作为汇合点。(也就是找最近的后支配节点。)

以下面的图举例:状态 1 是分支节点,条件为真进入 52,为假进入 0。从 52 走向出口,必须经过 2;从 0 走向出口,也必须经过 2。因此,2 后支配这两个节点,是两支的共同必经后续。两支在经过 2 之前,没有其他共同节点,所以 2 就是最近的共同必经节点,也就是这个分支的汇合点。

图片描述

找到汇合点后,就可以确定 if/else 的范围:在状态 1 处生成条件判断。 之后分别生成分支在状态 2 之前停止。在 if/else 后面生成状态 2 的代码。

3.2.5 定位自然循环,恢复循环结构

对于图中的有环区域,需要确认哪些代码会重复执行、每一轮从哪里开始。因此,我们需要先找到循环头,再确定循环范围。这里使用支配关系来确认自然循环。

支配关系:从入口走到节点 N 的每一条路径,都必须经过 H,则 H 支配 N。

这里的思路是:在从入口可达的控制流图中,同时满足下面两个条件,确认一个自然循环:

  1. H 支配 N:从入口到 N 的每条路径都经过 H。
  2. 存在真实的边 N → H。

两点同时成立,N → H 就是自然回边,H 就是这个自然循环的循环头。

以下面的图举例:从入口 6 走到状态 8,每条路径都必须经过 48,因此 48 支配 8;图中又确实存在 8 → 48,所以这条边是自然回边,48 就是循环头。确认循环头后,从回跳起点沿图中的边反向收集节点,到循环头为止。图中从 8 反向追到 41,再到 48,因此循环范围是 {48, 41, 8}。

图片描述

找到循环范围后:48、41、8 的代码放在循环内,48是循环头。 图中三条 return 路径则保留各自的返回效果,结束函数调用。

3.2.6 状态派生值:按当前节点计算,再替换读取

CFG 会按照顺序语句、if/else 和循环控制流生成代码,此时需要把for+switch 这样的dispatch分派器删除掉。但代码段中可能仍然存在读取状态变量或它的派生值。这里的思路是:对代码段中使用的状态变量或它的派生值的情况,按每个节点的状态求出对应值,再将该节点中的读取替换成具体常量。

处理前:进入 switch 前计算 x,case 32 中再读取它。

for (var g = 36; void 0 !== g;) {
  var bl,
    wl,
    x = 7 & g >> 3;
  switch (g) {
    // 其他 case 省略
    case 32:
      if (x < 5) {
        throw v = _O8, w = _q26.length, w = _q26[w -= 1], v = new _h73(v, w);
      }
      if (C) {
        _s8 += X;
      } else {
        v = _u9;
        w = R;
        J = a[X];
        v[w] = J[a[F]];
      }
      g = 1;
      break;
  }
}

处理后:去掉分派结构,对应片段直接使用节点确定的值。

4 < 5;
throw v = _O8, w = _q26.length, w = _q26[w -= 1], v = new _h73(v, w);
...

3.2.7 第一轮CFG,存在少量分派器无法还原的情况。

经过前面的语句整理、建图和分支与循环恢复。不少原本依靠 for + switch 跳转的代码,已经能直接按顺序语句、if/else 和循环阅读:

图片描述

但是第一轮并没有恢复所有分派器。原因是分析某个 case 的尾部时,无法确认执行后的下一个状态。发现这个问题后,就要回到具体位置分析:case 尾部为什么没有识别出下一状态?下面就沿着一个残留的分派器,追查它的原因。

下面的例子,它的宿主状态变量是 a[6],这里case的尾部没有出现a[6] = 下一状态,这里就是分析停住的位置:无法从 case 尾部直接确认下一状态。

for (a[71] = xe, a[73] = je, a[9] = me, a[6] = 23; void 0 !== a[6];) {
  // 省略
  switch (a[6]) {
    // 省略其他 case
    case 323:
      a[8] = void 0;
      a[4] = a[9];
      // 省略初始化
      _v8 = 0;
      _v8 = 1;
      _v8 = 2;
      _v8 = [];
      _v8 = {};
      _v8 = _f80(_G12, this, 0);
      _m39;
      _S23 = 0;
      break;
  }
}

但不能因此确认这个case没有下一状态,因为此case里还调用了_G12 函数,_G12 可以修改外部数组 a。接下来要跟进这次调用,看看状态写入是不是在函数里面。

跟进 _G12 :

function _G12(cl) {
  // 省略局部变量声明及其他初始化
  el = _s8;
  _s8 = cl;
  $ = 0;
  while (true) {
    if ($) {
      _s8 += 1;
    }
    $ = 1;
    P = _z25[N = 4 * _s8];
    // 省略根据 P 计算 C、D、Q、ll、rl 的标志语句
    X = _z25[v = N + 1];
    F = _z25[v = N + 2];
    v = N + 3;
    R = _z25[v];
    var L = 0;
    do {
      try {
        // 省略这里的局部变量声明
        if (rl) {
          if (ll) {
            // 省略其他指令分支
          } else {
            if (Q) {
              if (D) {
                // 省略其他指令分支
              } else {
                if (C) {
                  v = _u9;
                  w = R;
                  J = _l20[X];
                  v[w] = J[F];
                } else {
                  // 关键:写入外层 rr 的数组 a
                  a[v = X] = F;
                }
              }
            } else {
              // 省略其他指令分支
            }
          }
        } else {
          // 省略其他指令分支
        }
      } catch (l) {
        // 省略异常捕获、完成状态设置及恢复逻辑
      }
    } while (false);
    // 根据完成状态返回,或者继续取下一条指令
    if (L == 88) {
      return dl;
    } else {
      if (L == 87) {
        return fl;
      } else {
        if (L == 86) {
          return v;
        } else {
          continue;
        }
      }
    }
  }
}

可以看到这是一个VM,并且存在 a[v = X] = F:它先把 X 赋给 v,再将 F 写入 a[X]。不过,只看到这条语句,还不能断定本次调用一定修改了下一状态。还需要确认执行到了哪条指令,以及当时 X、F 分别是多少。这里通过动态运行确认执行到这里时,变量为:

X = 6;
F = 617;
R = 0;
a[v = X] = F;

代入操作数,这条指令的状态写入效果就是a[6] = 617:宿主靠 a[6] 分派,VM 内部的这条指令向同一个位置写入 617。

因此分析完毕这个 case 的拒绝根因:因为下一状态写入隐藏在被调用的 VM 中,没有直接出现在宿主 case 的尾部。 第一轮 CFG 缺少这次调用的状态效果,所以保留了分派器。

接下来要做的就是先发现 VM、恢复它的指令操作,再把已证明的宿主下一状态显式表达出来,才能继续恢复外层控制流。

3.3 VM 还原

3.3.1 寻找解释器并反向找到指令数组

接下来分析 VM,最重要的就是找到VM。我们的思路是:先根据取指和执行特征找到解释器,再找到启动它的调用、确认入口,最后从调用位置反向追踪它实际使用的指令数组。

3.3.1.1 通过取指和执行特征找解释器

还不知道哪个函数是解释器时,可以先遍历源码中的函数,寻这样一组结构:循环中按固定宽度读取数组,用第一个元素选择不同操作,再推进读取数组的位置。

下面以_G12 举例:

function _G12(cl) {
  // 省略
  el = _s8;
  _s8 = cl;
  $ = 0;
  while (true) {
    if ($) {
      _s8 += 1;
    }
    $ = 1;
    // 以4作为指令长度,读取指令。
    P = _z25[N = 4 * _s8];
    C = (v = P) & (w = 1);
    D = (v = P) & (w = 2);
    v = P;
    Q = v & (w = 4);
    // 省略
    // 读取操作码
    X = _z25[v = N + 1];
    F = _z25[v = N + 2];
    v = N + 3;
    R = _z25[v];
    // 省略
  }
}

先看读取位置:N = 4 * _s8,随后读取同一个数组的 N、N + 1、N + 2、N + 3。也就是说,每个 _s8 对应一组四个元素。循环还会对 _s8 加一。再看四个元素如何使用。第一个元素 P 被拿来做 & 1、& 2、& 4,得到 C、D、Q。这些标志就是用来选择执行分支的值。后面的根据它们进入不同代码,例如下面这段 D、C 分支:

if (D) {
  if (C) {
    a[v = X] = _u9[F];
  } else {
    v = _u9[X];
    w = _u9[F];
    _e2(v, w);
  }
} else {
  if (C) {
    v = _u9;
    w = R;
    J = _l20[X];
    v[w] = J[F];
  } else {
    a[v = X] = F;
  }
}

于是,这一步识别出的关系是:

解释器:_G12
PC:_s8
指令数组引用:_z25
每条指令:P、X、F、R,共四个元素

此时通过上面的逻辑可以找到解释器函数,但是还没有确认调用位置和原始指令数组,下一步去找调用位置。

3.3.1.2 通过函数引用查找解释器调用位置

找到解释器后,通过作用域沿函数引用就可以找到调用位置:

_v8 = _f80(_G12, this, 0);

这里还要知道 _f80 做什么。追到它的定义,可以看到:

// 摘录同一文件中的定义,省略相邻的其他变量。
var _mi = Function.prototype.call;

function _Q(l) {
  return l.call.bind(l);
}

var _f80 = _Q(_mi);

_f80 是把 Function.prototype.call 包装成可以直接调用的函数。因此,这次调用的效果可以理解为下面的写法:

_G12.call(this, 0);

_G12 在这里被同步执行;0 作为参数传给了 cl。结合解释器开头的 _s8 = cl,第二个参数0就代表PC的初始值。

3.3.1.3 从调用位置反向追踪实际使用的指令数组

找到解释器及其启动调用后,接下来要确认它实际读取的指令数组来自哪里。这里的思路是:从解释器使用的指令数组引用入手,以启动调用为定位点,沿调用前的定义、赋值和别名关系反向追溯,确认它指向哪一次创建的数组。

以 _G12 为例,它通过 _z25 读取指令:

P = _z25[N = 4 * _s8];

之后我们看函数调用时时这个引用在哪。以VM函数调用的 _f80(_G12, this, 0) 为定位点,反向查找 _z25 的赋值,找到:

var _z25 = _w11,
  _R4 = [];

到这里,追踪对象从 _z25 变成了 _w11。因此继续从这次赋值往前追引用:

(_w11 = []).push(
  10, 1, 4, 0,
  7, 183, 0, 0,
  26, 4, 0, 0,
  4, 0, 2, 5
  ...
);

_w11 = [] 创建了数组,随后的 push 向它写入整数,下一句 _z25 = _w11 又取得这份数组引用。这样就把解释器的取指与具体数组连接起来了。

3.3.2 把指令翻译回js代码

拿到完整指令数组后,接下来就要把这些数字指令重新表达成 JavaScript,思路是:按照解释器的取指方式拆出每条指令,把每条指令的操作码代入解释器的分派条件,找到实际执行的代码,再代入指令的操作数;同时按照解释器更新 PC 的规则,确定下一条指令。最后把这些操作和跳转写成规范for+switch IR,最后交给通用 CFG 恢复执行顺序。

为方便阅读,文中生成的 switch 分派状态变量统一写为 vmState,仅简化展示名称,不改动实际产物。不同执行入口仍各有自己的状态变量。

3.3.2.1 确认每条指令的位置和字段

当前所有识别的 VM,都以四个数组元素组成一条指令。接下来根据解释器的取指和使用方式,确认这四个元素分别对应操作码和哪些操作数。

还是以 _G12 为例,每次按四个元素取指。数组开头按指令划分后,

(
  10, 1, 4, 0,
  7, 183, 0, 0,
  26, 4, 0, 0,
  4, 0, 2, 5
  ...
);

可以整理成下表:

PC(指令位置) 操作码 P 操作数 X 操作数 F 操作数 R
PC 0 10 1 4 0
PC 1 7 183 0 0
PC 2 26 4 0 0

每一行就是一条完整指令,由一个操作码和三个操作数组成。

3.3.2.2 代入操作码和操作数,确认代码段。

这里的思路是:将操作码代入已经确认的分派条件,沿条件选出对应代码,再将操作数代入这段代码。 以 PC 2 的操作码 26 为例,原解释器对应的分派区域是:

// 读取当前指令的操作码,并计算分派标志。
P = _z25[N = 4 * _s8];
C = (v = P) & (w = 1);
D = (v = P) & (w = 2);
v = P;
Q = v & (w = 4);
ll = ~(~((v = P) & (w = 8)) & ~(v & w)); // 等价于 P & 8。
v = P;
w = 1 << _n9("100", 2); // _n9 是 parseInt ,这里 w 为 16。
rl = ~(~(v & w) & ~(v & w)); // 等价于 P & 16。

// 读取同一条指令的三个操作数。
X = _z25[v = N + 1];
F = _z25[v = N + 2];
v = N + 3;
R = _z25[v];

// 下面为分派判断
if (rl) {
  if (ll) {
    if (Q) {} else {
      if (D) {
        C || ((v = _u9)[w = X] = []);
      } else {
        // 省略其他操作。
      }
    }
  } else {
    // 省略其他操作。
  }
} else {
  // 省略其他操作。
}

分析过程:将 26 代入标志计算。

rl = 26 & 16 = 16 → 真
ll = 26 & 8  = 8  → 真
Q  = 26 & 4  = 0  → 假
D  = 26 & 2  = 2  → 真
C  = 26 & 1  = 0  → 假

因此沿 rl 为真 , ll 为真 , Q 为假 , D 为真,最后确认代码块:

C || ((v = _u9)[w = X] = []);

再把 X 为 4、C 为 0 代入,就能确认这条指令的操作:在 _u9[4] 中放入一个新数组。这样,PC 2 的指令 [26,4,0,0] 就与解释器中的这段操作代码对应起来了。

3.3.2.3 按 PC 生成 case,确定下一 PC

前面已经确认了每条指令对应的操作代码,以及当前指令的 PC、操作数和标志值。接下来要做的是把这些分析结果写成可以执行的代码。这里的思路是:用 PC 作为 case 标签,把操作中仍然确定的读取替换成已知值,再逐个检查原 PC、操作数和标志的入口赋值:仍被需要的保留,不再需要的省略。最后按照原解释器更新 PC 的规则确定下一条指令,写入下一分派状态,用 break 回到 switch。 这样就把指令组织成了 for + switch 中间表示。

  1. PC 2:顺序执行

    以 PC 2 为例。上一节已经确认它对应的操作是:

    C || ((v = _u9)[w = X] = []);
    

    当前指令的 C 为 0、X 为 4,这些值已经确认,生成代码时,直接把它们写到操作中的读取位置:

    0 || ((v = _u9)[w = 4] = []);
    

    这个VM会在后续每轮取指前执行 _s8 += 1。PC 2 的操作没有额外修改 _s8,因此下一条指令是 2 + 1 = 3。把当前操作和下一位置放到一起,就得到完整的 case:

    case 2:
      _s8 = 2;
      0 || ((v = _u9)[w = 4] = []);
      vmState = 3;
      break;
    
  2. PC 1:无条件跳转

    接下来再看一个会修改 PC 的跳转指令的例子。PC 1 的指令是 [7,183,0,0],选出的原操作是:

    _s8 += X;
    

    X 为 183,所以操作中的读取直接写成 _s8 += 183。从当前 PC 1 开始,执行这句后 _s8 为 1 + 183 = 184;原解释器在下一轮取指前还会加一,因此真正的下一 PC 是 1 + 183 + 1 = 185。

    处理后:

    case 1:
      _s8 = 1;
      _s8 += 183;
      vmState = 185;
      break;
    
  3. PC 78:条件跳转

    以 PC 78 的指令 [12,1,0,50] 为例,选出的原操作是:

    v = _s8;
    N = a[X];
    _s8 = v + (N ? w = N = F : w = N = R);
    

    代入 X 为 1、F 为 0、R 为 50,判断条件是运行时的 a[1]。它不由操作码决定,所以两支都要保留:

    若 a[1] 为真:78 + 0  + 1 = 79
    若 a[1] 为假:78 + 50 + 1 = 129
    

    处理后:

    case 78:
      _s8 = 78;
      F = 0;
      R = 50;
      v = 78;
      N = a[1];
      _s8 = v + (N ? w = N = F : w = N = R);
      vmState = _s8 === 78 ? 79 : 129;
      break;
    

3.3.2.4 保留回调的创建、调用和入口关系

前面已经能把单条指令写成 case,但有些指令会先创建一个函数并保存起来,等调用这个函数时,再从另一个 PC 进入(也就是回调函数)。因此,不能只沿启动入口排列指令,还要保留回调进入 VM 的执行路径。

先确认回调在哪里创建和保存,再找到它在哪里被调用,确定它重新进入解释器的 PC,最后为启动路径和回调进入的路径分别生成 switch 执行体,用函数调用把两者连接起来。

继续以 _G12 为例。

生成代码保留主函数名 _G12。辅助执行函数按它实际接受的入口 PC 命名:本例只有 PC 2 这个回调入口,因此命名为 _pc2。 _pc2 是新增的 VM 执行函数,不是把原回调换了一个名字。当前样本的 _I37 则由同一辅助执行函数接受 PC 2 和 PC 32 两个回调入口,因此命名为 _pc2_32,而不是把所有 VM 的辅助函数都固定叫 _pc2。

  1. 创建回调:

    PC 0 的指令是 [10,1,4,0],其中 X 为 1、F 为 4。对应的操作先定义一个用来创建回调的函数,再调用这个函数,用来创建回调:

      v = function c(a, o) {
         var n, l;
         l = 0;
         var v = _j50(n = {}, l = _d8),
           s = a;
         (n = _u9)[l = o] = function () {
           // 省略
           n = _f80(o = _G12, this, c = s + 1);
           // 省略
         };
       };
       _f80(v, this, w = X, J = F);
    

    代入当前指令的操作数:

     _f80(v, this, w = 1, J = 4);
    

    这里调用的是用来创建回调函数 v:1 传给参数 a,4 传给参数 o。因此 s = a 保存的是 1,_u9[o] 保存回调的位置就是 _u9[4]。

    再看回调内部的 c = s + 1:它传给解释器的入口值是 1 + 1 = 2。这样就确认了两件事:回调保存在 _u9[4],被调用后从 PC 2 进入解释器。

  2. 调用回调:从调用位置接上 PC 2 的执行路径。

    前面在 PC 0 创建了回调,并把它保存到 _u9[4],但这时只是保存函数,并没有执行函数体。接下来沿启动路径继续看:PC 1 跳到 PC 185,到了这里才取出并调用这个回调。此时 X 为 4,对应的操作是:

    // 省略调用前的状态保存和参数准备,此时 X = 4。
    v = _u9[X];
    il = _o2(v, null, w = _t9);
    

    _o2 是样本对 Function.prototype.apply 的包装。这里先取出 _u9[4] 中保存的函数,再用 _t9 提供的参数调用它。执行到这次调用,才真正进入前面创建的匿名回调。

    再接上回调内部的代码:

    // 位于 _u9[4] 中的匿名回调内部。
    // 省略调用前的参数准备和状态保存;s 是创建回调时保存的 1。
    n = _f80(o = _G12, this, c = s + 1);
    // 省略调用后的状态恢复及结果处理。
    

    这次调用把 s + 1,也就是 2,作为入口参数传给 _G12。因此完整关系是:启动路径走到 PC 185 → 调用 _u9[4] 中的回调 → 回调重新调用解释器,从 PC 2 执行。

  3. 生成回调执行体:连接两个 switch。

    调用关系理清楚后,就知道生成代码时不能只处理从 PC 0 开始的启动路径,还需要一份从 PC 2 开始执行的代码。这里的思路是:在 _G12 内生成辅助执行函数 _pc2,让它用自己的 switch 执行从 PC 2 进入的指令;。

    处理前:回调重新调用解释器,从 PC 2 取指。

    n = _f80(o = _G12, this, c = s + 1);
    

    处理后:回调调用已经翻译好的 PC 2 执行函数。

    n = _pc2.call(this, c = s + 1);
    

    _pc2 每次调用都有自己的一组局部变量和初始化,与启动执行体的局部变量分开。这样生成的两套 switch 就保留了前面确认的关系:启动 switch 执行到 PC 185 时调用回调,回调再调用 _pc2 的 switch,从 PC 2 执行;等这次调用完成,才回到原来的调用位置继续处理。

    处理后的执行结构:

    function _G12(cl) {
      function _pc2(cl) {
        // 省略
        _s8 = cl;
    
        // 回调进入 VM 的执行体:从 PC 2 开始。
        for (var vmState = 2; vmState !== -1;) {
          switch (vmState) {
            case 2:
              _s8 = 2;
              0 || ((v = _u9)[w = 4] = []);
              vmState = 3;
              break;
    
            // 省略其他 case 及完成处理。
          }
        }
      }
    
      // 省略启动调用的局部变量声明、初始化和外层异常处理。
    
      // 启动执行体:本例从 PC 0 开始。
      for (var vmState = 0; vmState !== -1;) {
        switch (vmState) {
          case 0:
            _s8 = 0;
            v = function c(a, o) {
              var n, l;
              l = 0;
              var v = _j50(n = {}, l = _d8),
                s = a;
    
              // 这才是真正保存到 _u9[4] 的回调。
              (n = _u9)[l = o] = function () {
                // 省略局部变量声明、参数准备和状态保存。
                n = _pc2.call(this, c = s + 1);
                // 省略状态恢复及原来的 return/throw 结果处理。
              };
            };
            _f80(v, this, w = 1, J = 4);
            vmState = 1;
            break;
    
          case 1:
            _s8 = 1;
            _s8 += 183;
            vmState = 185;
            break;
    
          case 185:
            _s8 = 185;
            X = 4;
            F = 0;
            R = 0;
            v = _u9[X];
            // 2. 调用这个回调,开始执行回调内部的代码。
            il = _o2(v, null, w = _t9);
            (v = _u9)[w = R] = il;
            vmState = 186;
            break;
    
          case 186:
            _s8 = 186;
            // 省略
            throw v = _O8,
              w = _q26.length,
              w = _q26[w -= 1],
              v = new _h73(v, w);
        }
      }
    }
    

因此对应的逻辑是:

case 185
  → 取出 case 0 创建的回调
  → 调用回调
      → 准备参数、保存状态
      → 调用 _pc2,从 PC 2 执行
      → 按原逻辑恢复状态、处理结果
  → 回调正常返回,结果赋给 il
  → 执行 case 185 后续的状态恢复和结果写入
  → vmState = 186

3.3.2.5 生成规范 switch IR

各条指令和执行入口明确后,就把它们组织成对应的 for + switch,保留必要的初始化和调用关系,再写回完整源码,得到switch 中间表示(IR),后面可以交给通用 CFG 继续还原。不过还需要把 VM 内部对宿主下一状态的写入提取出来,让外层控制流能连起来。

3.3.3 提取 VM 中对宿主 dispatch 下一状态的写入

前面已经把 VM 指令翻译成了 switch,但外层宿主分派器的有些 case 的下一状态是在 VM 内部写入的,因此需要把下一状态提取出来。这里的思路是:从翻译后的指令中找到对宿主状态变量的写入,结合调用关系确认宿主下一状态,再把这个结果显式写到宿主 case 的尾部。

继续沿前面的 _G12 来看。

  1. 宿主尾部

    宿主使用 a[6] 选择 case。case 323 调用 _G12,调用之后没有下一状态:

    switch (a[6]) {
      // 省略其他 case。
      case 323:
        // 省略 _G12 的函数定义、指令数组及调用前的准备代码。
        _v8 = _G12.call(this, 0);
        _m39;
        _S23 = 0;
        break;
    }
    
  2. 沿 VM 的执行关系,找到宿主状态写入。

    前面已经确认,_G12 的启动路径会调用回调,再进入 _pc2,从 PC 2 执行。沿这条路径分析,PC 183 对应的指令是 [20,6,617,0],翻译后是:

    case 183:
      _s8 = 183;
      a[v = 6] = 617;
      vmState = 184;
      break;
    

    a[v = 6] = 617 写入的就是宿主使用的 a[6]。因此,这里把宿主下一状态设为 617。

  3. 把已确认的下一状态写回宿主尾部。

    调用关系和宿主状态对应清楚后,在原 case 的 break 前补上这条赋值:

    处理后:

    switch (a[6]) {
      // 省略其他 case。
      case 323:
        // 省略 _G12 的函数定义、指令数组及调用前的准备代码。
        _v8 = _G12.call(this, 0);
        _m39;
        _S23 = 0;
        a[6] = 617;
        break;
    }
    

    这时宿主尾部已经直接写出 a[6] = 617,通用 CFG 就能建立 323 → 617 这条边。

3.4 第二轮控制流恢复

到这里,VM 指令已经写成规范 switch,隐藏在 VM 中的宿主下一状态也已提出来。接下来使用前面同一个通用 CFG,把 VM 和剩余宿主分派器的控制流恢复成顺序语句、if/else 和循环。这一轮还遇到一种前面没有展开的情况:下一状态是从一张目标表中选出来。

目标表,就是数组中保存了一组确定的目标状态,运行时根据索引选择其中一个。这个结构其实在第一轮控制流还原的时候就有遇到过,但是当时包含它的宿主分派器还有 VM 隐藏下一状态,导致整个控制流没能完成结构化。这里针对目标表处理思路是:先确定“索引 → 目标状态”的对应关系,把这些目标作为后继继续还原,再把每个目标对应的执行代码放进按索引选择的 switch。

以 me 的 case 517 为例,它根据参数 Jn 选择下一状态:

图片描述

图片描述

这些目标已经明确,因此可以沿各条路径还原代码,直接用索引选择对应的执行分支:

处理后:直接选择业务代码。

switch (Jn - 0) {
  case 0:
    // 从原宿主状态 132 开始的代码。
    v = void 0;
    a[80] = void 0;
    a[66] = void 0;
    s = void 0;
    // 省略这条分支的其余代码。
    break;

  case 1:
    // 从原宿主状态 518 开始的代码。
    v = void 0;
    a[80] = void 0;
    a[66] = void 0;
    s = void 0;
    // 省略其余初始化。
    G = d;
    a[55] = N;
    // 省略这条分支的其余代码。
    break;

  // 省略其他索引对应的 case。
  default:
    break;
}

下面是第二轮控制流恢复后的实际代码:

图片描述

3.5 后处理

经过第二轮 CFG,代码的执行顺序已经恢复。接下来还原能够确定的字符串,减少临时变量的中转,并整理变量名称,让代码更容易读。

3.5.1 还原字符串

字符串可能藏在数组中,也可能经过函数调用才得到。这里的思路是:先找出字符串处理函数使用的表,再追踪调用参数,按对应的处理逻辑算出结果,最后替换成字符串。

处理前:

a[3] = _l20[3][154];
a[4] = _l20[3][a[3][0]];
a[0][4] = _i2(_l20[8], null, 10, a[4]);

处理后:

a[3] = _l20[3][154];
a[4] = _l20[3][a[3][0]];
a[0][4] = "ru";

3.5.2 缩短值传递

一个值有时经过临时变量才被使用。确认变量只是中转,而且改写不会改变求值顺序和次数,就把来源直接放到使用位置。

处理前:

T = S;
a[0] = !!T;

处理后:

a[0] = !!S;

3.5.3 重命名变量

一些随机变量名其实是内建函数的别名。确认它引用的对象且没有被重新赋值后,换成能看出用途的名字。

处理前:

var _Bi = void 0,
  _n9 = parseInt,
  _z18 = Uint8Array,
  // 省略同一声明中的其余项。
  _m34 = null;

处理后:

var _Bi = void 0,
  parseIntFn = parseInt,
  Uint8ArrayCtor = Uint8Array,
  // 省略同一声明中的其余项。
  _m34 = null;

四、替换还原后的样本

代码还原完了,再把它放回页面。这里用无头浏览器打开,将页面请求的 fireyejs.js 替换,然后手动操作登录页面。在开发者工具的 Sources 面板中,可以看到页面加载的是还原后的代码。

图片描述

请求体中带有 bx-ua。并且替换后的脚本不只是能够加载,页面操作仍然能够触发请求,并携带 SDK 生成的数据。

图片描述

五、总结

这次还原的难点在于,执行顺序被控制流分派打散,部分状态跳转又隐藏在 VM 指令中。因此,代码还原的关键不是遇到一种混淆就写一条替换规则,而是先弄清楚每段代码做了什么,再借助 AST 把这些执行关系整理清楚。反过来看,样本中一些固定的规律也为批量还原提供了便利:识别出的 VM 都采用四个数组元素组成一条指令,很多 case 的下一状态赋值集中在 break 之前,部分虚假分支的条件在当前 case 内就能确定。因此,从加固来讲:可以增加不同 VM 指令格式的差异,可以在保持执行语义的前提下,把状态写入打散在代码段的不同位置,并让虚假分支的条件需要结合前序状态,不只依赖局部常量。

参考:


冰与火的战歌:Windows内核攻防实战高级班!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。

最后于 1天前 被DriverUnload编辑 ,原因:
收藏
点赞 ・3
打赏
分享
最新回复 (0)
游客
登录 | 注册 方可回帖
返回