X

曜彤.手记

随记,关于互联网技术、产品与创业

吉 ICP 备10004938-2号

浅谈前端 JavaScript 代码保护


本文主要介绍了可用于前端 JavaScript 代码保护的相关方案,这些方案仅用于参考,这里并不讨论它们的实用价值。而所谓“代码保护”可以被进一步理解为「如何在外部环境中尽量降低明文 JavaScript 代码的可读性,以让代码的执行细节成为黑盒?」这样一来,我们提到的“代码保护”便可在一定程度上实现。

总的来看,想到的方法可分为以下几种:

代码混淆(Code Obfuscation)

第一种可以直接想到的方式便是使用“代码混淆”(由于两者一般同时进行,因此这里不特意区分“压缩”与“混淆”)。

基本功能

大多数基本的代码混淆工具都可以压缩原始的 ASCII 明文代码,并将其中的诸如:变量名、函数名等,使用简短且无意义的标识符进行替换,这是作为一款代码混淆工具的最基础功能。比如,以我们最常用的 “Uglify” 与 “GCC (Google Closure Compiler)” 两个工具为例,对下面这样一段未经处理的 ES6 源代码进行默认的代码混淆处理:

let times = 0.1 * 8 + 1;
const getExtra = n => [1, 4, 6].map(i => i * n);
const arr = [8, 94, 15, 88, 55, 76, 21, 39];
const newArr = getExtra(times).concat(arr.map(item => item * 2));
const sortarr = arr => {
  for(let i = 0; i < arr.length - 1; i++) {
    for(let j = 0; j < arr.length - 1 - i; j++) {
      if(arr[j] > arr[j + 1]) {
        let temp = arr[j];
        arr[j] = arr[j + 1];
        arr[j + 1] = temp;
      }
    }
  }
  return arr;
}
console.log(sortarr(newArr));

在经过 UglifyJS 的代码处理后,我们可以得到如下结果(这里为了直观比对,保留了空格和换行):

let times = 1.8;
const getExtra = r => [1, 4, 6].map(t => t * r),
  arr = [8, 94, 15, 88, 55, 76, 21, 39],
  newArr = getExtra(times).concat(arr.map(r => 2 * r)),
  sortarr = r => {
    for (let t = 0; t < r.length - 1; t++)
      for (let e = 0; e < r.length - 1 - t; e++)
        if (r[e] > r[e + 1]) {
          let t = r[e];
          r[e] = r[e + 1], r[e + 1] = t
        } return r
  };
console.log(sortarr(newArr));

而在经过 GCC 的代码处理后我们可以得到:

'use strict';
const b = (c => [1, 4, 6].map(a => a * c))(1.8).concat([8, 94, 15, 88, 55, 76, 21, 39].map(c => 2 * c));
var d = console,
  e = d.log;
for (let c = 0; c < b.length - 1; c++)
  for (let a = 0; a < b.length - 1 - c; a++)
    if (b[a] > b[a + 1]) {
      let f = b[a];
      b[a] = b[a + 1];
      b[a + 1] = f
    } e.call(d, b);

对比上述两种工具的代码处理结果可以看到,默认情况下 UglifyJS 不会对原始代码进行“重写”,所有的混淆压缩工作都是在“保证原有代码基本结构不变”这个条件的基础之上进行的。而 GCC 对代码的处理过程则更接近于“编译器”。其除了会对常见的变量名、函数名等标识符进行混淆替换外,还应用了诸多 DCE 优化手段。比如对常量表达式进行提前求值(0.1 * 8 + 1)、通过 “inlining” 减少中间变量的使用等等。当然,UglifyJS 在经过一定的配置后也可以使用一些相对“激进”的优化手段,这里只需知道传统代码混淆工具所具备的能力即可。

借助 UglifyJS,我们也可以编写自己的源码处理程序。比如可以首先使用 UglifyJS.parse 将一段 JavaScript 代码转换成其对应的 AST 形式;然后再通过 UglifyJS.Compressor 等方法对这些 AST 进行转换;最后再使用 print_to_string 方法将处理后的 AST 结构转换成相应的 ASCII 明文代码形式。UglifyJS.Compressor 的本质是一个 “TreeTransformer” 类型,其内部已经封装好了众多常用的代码优化方法,而通过对 UglifyJS.TreeTransformer 进行适当的封装,我们也可以编写自己的代码优化器。如下所示这段简短的代码便实现了一个支持“常量传播”与“常量折叠”的 JavaScript 代码转换器(非完备,仅供参考)。

const UglifyJS = require('uglify-js');

const symbolTable = {};
const binaryOperations = {
  "+": (x, y) => x + y,
  "-": (x, y) => x - y,
  "*": (x, y) => x * y
}
const constexpr = new UglifyJS.TreeTransformer(null, node => {
  if (node instanceof UglifyJS.AST_Binary) {
    if (Number.isInteger(node.left.value) && Number.isInteger(node.right.value)) {
      return new UglifyJS.AST_Number({
        value: binaryOperations[node.operator].call(this, 
          Number(node.left.value), 
          Number(node.right.value))
      });
    } else {
      return new UglifyJS.AST_Number({
        value: binaryOperations[node.operator].call(this, 
          Number(symbolTable[node.left.name].value), 
          Number(symbolTable[node.right.name].value))
      })
    }
  }
  if (node instanceof UglifyJS.AST_VarDef) {
    symbolTable[node.name.name] = node;
  }
});

var ast = UglifyJS.parse(`
  const x = 10 * 2 + 6;
  const y = 4 - 1 * 100;
  console.log(x + y);
`);

// transform and print.
ast.transform(constexpr);
console.log(ast.print_to_string());  // "var x=26;var y=-96;console.log(-70);".

这里我们通过识别特定的 Uglify AST 节点类型(UglifyJS.AST_Binary / UglifyJS.AST_VarDef)来达到对代码进行精准处理的目的。可以看到,变量 xy 的值在代码转换过程中被提前计算。不仅如此,其作为变量的值还被“传递”到了表达式 x + y 中,且整个表达式的值也被提前计算,进而省去了运行时计算的成本。类似的,通过诸如 Babel 的 @babel/traverse 等插件,我们也可实现同样的效果,其基本原理大同小异。

“入侵式”混淆

对于经由 UglifyJS 和 GCC 等传统 JavaScript 代码混淆工具处理后的代码,我们尚能清楚地了解代码的大致执行逻辑。而对于某些更加激进(aggressive)的 JavaScript 代码混淆工具来说,经由它们处理后生成的明文代码已基本丧失了可读性。比如本文开头的那段 JavaScript 代码在经由 “**javascript-obfuscator**” 默认配置下的代码混淆功能处理后,可以得到以下结果。能够看到,这部分代码已基本丧失可读性。

const _0x1dc7 = ['12nNpunz', 'log', '3dzOihv', 'length', '140940hmJpHd', '570593RvJQQj', '30406WiuNiv', '154511VRvByU', '1HvqfmW', '522351ALcOFX', 'map', '1318841sxtxER', '36YiKqrR', '16126qXbaLY'];
const _0x84e884 = _0x3580;
(function(_0x5cef26, _0x52c0dc) {
  const _0x1d18c7 = _0x3580;
  while (!![]) {
    try {
      const _0x1540ed = -parseInt(_0x1d18c7(0x1d4)) * parseInt(_0x1d18c7(0x1d5)) + -parseInt(_0x1d18c7(0x1da)) + -parseInt(_0x1d18c7(0x1db)) + -parseInt(_0x1d18c7(0x1df)) + parseInt(_0x1d18c7(0x1d8)) * parseInt(_0x1d18c7(0x1dd)) + parseInt(_0x1d18c7(0x1d6)) * parseInt(_0x1d18c7(0x1dc)) + parseInt(_0x1d18c7(0x1de)) * parseInt(_0x1d18c7(0x1d3));
      if (_0x1540ed === _0x52c0dc) break;
      else _0x5cef26['push'](_0x5cef26['shift']());
    } catch (_0x51e61c) {
      _0x5cef26['push'](_0x5cef26['shift']());
    }
  }
}(_0x1dc7, 0x5141a));

function _0x3580(_0x3769d1, _0x4940d9) {
  _0x3769d1 = _0x3769d1 - 0x1d3;
  let _0x1dc7dc = _0x1dc7[_0x3769d1];
  return _0x1dc7dc;
}
let times = 0.1 * 0x8 + 0x1;
const getExtra = _0x4cd659 => [0x1, 0x4, 0x6]['map'](_0x25d119 => _0x25d119 * _0x4cd659),
  arr = [0x8, 0x5e, 0xf, 0x58, 0x37, 0x4c, 0x15, 0x27],
  newArr = getExtra(times)['concat'](arr[_0x84e884(0x1e0)](_0x42b6c9 => _0x42b6c9 * 0x2)),
  sortarr = _0x3d0937 => {
    const _0x3e9648 = _0x84e884;
    for (let _0x584873 = 0x0; _0x584873 < _0x3d0937[_0x3e9648(0x1d9)] - 0x1; _0x584873++) {
      for (let _0x1bd1d5 = 0x0; _0x1bd1d5 < _0x3d0937[_0x3e9648(0x1d9)] - 0x1 - _0x584873; _0x1bd1d5++) {
        if (_0x3d0937[_0x1bd1d5] > _0x3d0937[_0x1bd1d5 + 0x1]) {
          let _0x2dace4 = _0x3d0937[_0x1bd1d5];
          _0x3d0937[_0x1bd1d5] = _0x3d0937[_0x1bd1d5 + 0x1], _0x3d0937[_0x1bd1d5 + 0x1] = _0x2dace4;
        }
      }
    }
    return _0x3d0937;
  };
console[_0x84e884(0x1d7)](sortarr(newArr));

为了尽可能降低原始代码的可读性,较为“专业“的 Obfuscator 会使用很多特殊的手段来“打散”原始 JavaScript 代码的执行逻辑。比如就上述生成的这段代码而言,javascript-obfuscator 在处理时会默认采用以下这些代码混淆策略(部分):

除上述这些默认情况下会采用的基本代码混淆策略外,Obfuscator 通常还会使用诸如 “Control Flow Flattening” 等更为激进的、代码执行流层面的混淆策略。“Control Flow Flattening” 翻译过来即“控制流扁平化”,该策略会将源代码中的基本块(函数体、循环,以及条件分支语句等)进行拆分,并将它们全部放入一个死循环结构中。而在这个循环结构中,Obfuscator 将通过 switch 语句来控制程序的实际执行流程。通过这种方式,程序的真实执行流程将变得难以跟踪,从而增加了代码逆向工程的难度。

另一方面,由于增加了这些激进的代码处理逻辑,Obfuscator 生成代码的实际执行效率相较于原始代码会相应下降 15% 至 80% 左右,具体依使用的混淆策略不同而有所差异。

沙盒(Sandboxing)

这种方式的思路也很好理解。其基本形式是将明文的 JavaScript 源代码预先编译为某种 JavaScript VM 实现的内部格式代码,然后将这些内部代码作为对外分发的“源代码”。当在浏览器中执行这些代码时,则需要将完整的 VM 实现托管在浏览器环境中,然后再让 VM 来间接执行这些分发的“内部源代码”。通过这种“间接执行”的方式,实际对外分发的源代码将不再包含任何 JavaScript 代码的语法和语义。而当 VM 的实现变成私有时,这些代码的内部执行细节将无人可知,在某种意义上便可实现真正的“黑盒”。当然,在此期间 VM 将承担一切需要与浏览器交互的任务(DOM 操作、Web API 调用等),因此代码的执行效率会有所降低。上述我们提到的 VM,其内部私有的执行环境便可被理解为“沙盒”。

一个简单的基于沙盒实现的 JavaScript 代码保护机制如下所示:

整个流程可以分为两个阶段:

比如以 JerryScript 这个开源的轻量级 JavaScript 引擎为例,从其文档中我们可以得知,输入的 JavaScript 代码在被真正执行前会被首先转译成中间状态的 Bytecode 字节码格式,而这些编译好的字节码可以连同附加的元数据一起被组织成 JerryScript 内部的 Snapshot 快照格式,这些 Snapshot 可以被 JerryScript 引擎重新加载而继续执行。因此,这里的 Snapshot 便可作为对外分发的一种内部格式。当然,为了进一步保证中间代码格式的私有性,可以选择自行构建相应的 JavaScript VM,以将中间代码的格式和执行模型私有化。基于 JerryScript 构建的 JavaScript 代码保护 POC 可以参考**这里**。

如果你有其他可用于前端 JavaScript 代码保护的方案,欢迎补充!

参考资料

  1. http://lisperator.net/uglifyjs/transform.
  2. https://blog.jscrambler.com/jscrambler-101-control-flow-flattening/.


这是文章底线,下面是评论
  暂无评论,欢迎勾搭 :)