前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
圈层
工具
发布
首页
学习
活动
专区
圈层
工具
社区首页 >专栏 >保护 Node.js 项目的源代码

保护 Node.js 项目的源代码

原创
作者头像
保利威视频云
修改于 2020-05-07 02:13:31
修改于 2020-05-07 02:13:31
3.5K00
代码可运行
举报
文章被收录于专栏:云市场·精选汇云市场·精选汇
运行总次数:0
代码可运行

SaaS(Software as a Service,软件即服务),是一种通过互联网提供软件服务的模式。服务提供商会全权负责软件服务的搭建、维护和管理,使得他们的客户从这些繁琐的工作中解放出来。对于许多中小型企业而言,SaaS 是采用先进技术的最好途径。

然而,对于大型企业而言,情况有所不同。出于产品定制、功能稳定以及掌握自身数据资产等方面的考虑,即使成本增加,他们也更乐意把相关服务部署在企业自己的硬件设备上,也就是常说的私有化部署

在私有化部署的过程中,服务提供商首先要确保自己的源代码不被泄露,否则产品就可以随意复制和更改,得不偿失。传统的后端运行环境,如 Java、.NET,其源代码是经过编译才部署到服务器上运行的,不存在泄露的风险。而对于应用越来越广泛的 Node.js 而言,运行的则是源代码。即使经过压缩混淆,也可以很大程度地还原。

本文介绍一种可用于 Node.js 端的代码保护方案,使得 Node.js 项目也可以放心地进行私有化部署。

原理

当 V8 编译 JavaScript 代码时,解析器将生成一个抽象语法树,进一步生成字节码。Node.js 有一个叫做 vm 的内置模块,创建 vm.Script 的实例时,只要在构造函数中传入 produceCachedData 属性,并设为 true,就可以获取对应代码的字节码。例如:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
const vm = require('vm');
const CODE = 'console.log("Hello world");'; // 源代码
const script = new vm.Script(CODE, {
  produceCachedData: true
});
const bytecodeBuffer = script.cachedData; // 字节码

并且,这段字节码可以脱离源代码运行:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
const anotherScript = new vm.Script(' '.repeat(CODE.length), {
  cachedData: bytecodeBuffer
});
anotherScript.runInThisContext(); // 'Hello world'

这段代码看起来不那么容易理解,主要体现在创建 vm.Script 实例时传入的第一个参数:

  1. 既然源代码的字节码已经在 bytecodeBuffer 中,为何还要传入第一个参数?
  2. 为何传入与源代码长度相同的空格?

首先,创建 vm.Script 实例时,V8 会检查字节码(cachedData)是否与源代码(第一个参数传入的代码)匹配,所以第一个参数不能省略。其次,这个检查非常简单,它只会对比代码长度是否一致,所以只要使用与源代码长度相同的空格,就可以“欺骗”这个检查。

细心的读者会发现,这样一来,其实字节码并没有完全脱离源代码运行,因为需要用到源代码长度这项数据。而实际上,还有其他方法可以解决这个问题。试想一下,既然有源代码长度检查,那就说明字节码中也必然保存着源代码的长度信息,否则就无法对比了。通过查阅 V8 的相关代码,可以发现字节码的头部保存着这些信息:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
// The data header consists of uint32_t-sized entries:
// [0] magic number and (internally provided) external reference count
// [1] version hash
// [2] source hash
// [3] cpu features
// [4] flag hash

其中第 [2] 项 source hash 就是源代码长度。但因为 Node.js 的 buffer 是 Uint8Array 类型的数组,所以 uint32 数组中的 [2],相当于 uint8 数组中的 [8, 9, 10, 11]。

图1
图1

接着把上述位置的数据提取出来:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
const lengthBytes = bytecodeBuffer.slice(8, 12);

其结果类似于:

<Buffer 1b 00 00 00>

这是一种叫做 Little-Endian 的字节序,低位字节排放在内存的低地址端,高位字节排放在内存的高地址端。

Little-Endian
Little-Endian

<Buffer 1b 00 00 00> 即为 0x0000001b,也就是十进制的 27。计算方法如下:

firstByte + (secondByte 256) + (thirdByte 256**2) + (forthByte * 256**3)

写成代码如下:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
const length = lengthBytes.reduce((sum, number, power) => {
  return sum += number * Math.pow(256, power);
}, 0); // 27

此外,还有一种更简单的方法:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
const length = bytecodeBuffer.readIntLE(8, 4); // 27

综上所述,运行字节码的代码可以优化为:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
const length = bytecodeBuffer.readIntLE(8, 4);
const anotherScript = new vm.Script(' '.repeat(length), {
  cachedData: bytecodeBuffer
});
anotherScript.runInThisContext();

编译文件

讲清楚原理之后,下面就尝试编译一个很简单的项目,目录结构如下:

  • src/
    • lib.js
    • index.js
  • dist/
  • compile.js

src 目录内的两个文件为源代码,内容分别为:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
// lib.js
console.log('I am lib');
exports.add = function(a, b) {
  return a + b;
};
代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
// index.js
console.log('I am index');
const lib = require('./lib');
console.log(lib.add(1, 2));

dist 目录用于放置编译后的代码。compile.js 即为执行编译操作的文件,其流程也非常简单,读取源文件内容,编译为字节码后保存为文件(dist/*.jsc):

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
const path = require('path');
const fs = require('fs');
const vm = require('vm');
const glob = require('glob'); // 第三方依赖包

const srcPath = path.resolve(__dirname, './src');
const destPath = path.resolve(__dirname, './dist');

glob.sync('**/*.js', { cwd: srcPath }).forEach((filePath) => {
  const fullPath = path.join(srcPath, filePath);
  const code = fs.readFileSync(fullPath, 'utf8');
  const script = new vm.Script(code, {
    produceCachedData: true
  });
  fs.writeFileSync(
    path.join(destPath, filePath).replace(/\.js$/, '.jsc'),
    script.cachedData
  );
});

运行 node compile 后,就可以在 dist 目录内生成源代码对应的字节码文件,接下来就是运行字节码文件。然而,直接执行 node index.jsc 是无法运行的,因为 Node.js 在默认情况下会把目标文件当做 JavaScript 源代码来执行。

此时,就需要对 jsc 文件使用特殊的加载逻辑。在 dist 目录内新建文件 main.js,内容如下:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
const Module = require('module');
const path = require('path');
const fs = require('fs');
const vm = require('vm');

// 加载 jsc 文件的扩展
Module._extensions['.jsc'] = function(module, filename) {
  const bytecodeBuffer = fs.readFileSync(filename);
  const length = bytecodeBuffer.readIntLE(8, 4);
  const script = new vm.Script(' '.repeat(length), {
    cachedData: bytecodeBuffer
  });
  script.runInThisContext();
};

// 调用字节码文件
require('./index');

执行 node dist/main,虽然 jsc 文件可以加载进来了,但是就出现了另一段异常信息:

ReferenceError: require is not defined

这是个奇怪的问题,在 Node.js 中,require 是个很基础的函数,怎么会未定义呢?原来,Node.js 在编译 js 文件的过程中会对其内容进行包装。以 index.js 为例,包装后的代码如下:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
(function (exports, require, module, __filename, __dirname) {
  console.log('I am index');
  const lib = require('./lib');
  console.log(lib.add(1, 2));
});

包装这个操作并不在编译字节码这个步骤里面,而是在之前执行。所以,要在 compile.js 补上包装(Module.wrap)操作:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
const script = new vm.Script(Module.wrap(code), {
  produceCachedData: true
});

加上包装之后,script.runInThisContext 就会返回一个函数,执行这个函数才能运行模块,修改代码如下:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
Module._extensions['.jsc'] = function(module, filename) {
  // 省略 N 行代码

  const compiledWrapper = script.runInThisContext();
  return compiledWrapper.apply(module.exports, [
    module.exports,
    id => module.require(id),
    module,
    filename,
    path.dirname(filename),
    process,
    global
  ]);
};

再次执行 node dist/main.js,出现了另一条错误信息:

SyntaxError: Unexpected end of input

这是一个让人一脸懵逼,不知道从何查起的错误。但是,仔细观察控制台又可以发现,在错误信息之前,两条日志已经打印出来了:

I am index I am lib

由此可见,错误信息是执行 lib.add 时产生的。所以,结论就是,函数以外的逻辑可以正常执行,函数内部的逻辑执行失败。

回想 V8 编译的流程。它解析 JavaScript 代码的过程中,Toplevel 部分会被解释器完全解析,生成抽象语法树以及字节码。Non Toplevel 部分仅仅被预解析(语法检查),不会生成语法树,更不会生成字节码。Non Toplevel 部分,即函数体部分,只有在函数被调用的时候才会被编译。

所以问题也就一目了然了:函数体没有编译成字节码。幸好,这种行为也是可以更改的:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
const v8 = require('v8');
v8.setFlagsFromString('--no-lazy');

设置了 no-lazy 标志后再执行 node compile 进行编译,函数体也可以被完全解析了。最终 compile.js 代码如下:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
const path = require('path');
const fs = require('fs');
const vm = require('vm');
const Module = require('module');
const glob = require('glob');
const v8 = require('v8');
v8.setFlagsFromString('--no-lazy');

const srcPath = path.resolve(__dirname, './src');
const destPath = path.resolve(__dirname, './dist');

glob.sync('**/*.js', { cwd: srcPath }).forEach((filePath) => {
  const fullPath = path.join(srcPath, filePath);
  const code = fs.readFileSync(fullPath, 'utf8');
  const script = new vm.Script(Module.wrap(code), {
    produceCachedData: true
  });
  fs.writeFileSync(
    path.join(destPath, filePath).replace(/\.js$/, '.jsc'),
    script.cachedData
  );
});

dist/main.js 代码如下:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
const Module = require('module');
const path = require('path');
const fs = require('fs');
const vm = require('vm');
const v8 = require('v8');
v8.setFlagsFromString('--no-lazy');

Module._extensions['.jsc'] = function(module, filename) {
  const bytecodeBuffer = fs.readFileSync(filename);
  const length = bytecodeBuffer.readIntLE(8, 4);
  const script = new vm.Script(' '.repeat(length), {
    cachedData: bytecodeBuffer
  });

  const compiledWrapper = script.runInThisContext();
  return compiledWrapper.apply(module.exports, [
    module.exports,
    id => module.require(id),
    module,
    filename,
    path.dirname(filename),
    process,
    global
  ]);
};

require('./index');

bytenode

实际上,如果你真的需要把 JavaScript 源代码编译成字节码,并不需要自己去编写这么多的代码。npm 平台上已经有一个叫做 bytenode 的包可以完成这些事情,并且它在细节和兼容性上做得更好。

字节码的问题

虽然编译成字节码后可以保护源代码,但字节码也会存在一些问题:

  • JavaScript 源代码可以在任何平台的 Node.js 环境中运行,但字节码是平台相关的,在何种平台下编译,就只能在何种平台下运行(比如在 Windows 下编译的字节码不能在 macOS 下运行)。
  • 修改源代码后要再次编译为字节码,较为繁琐。对于一些如数据库服务器地址、端口号等配置信息,建议不要编译成字节码,仍使用源文件运行,方便随时修改。

保利威在线教育平台---视频点播>>>>

后记

作为一名聪明的读者,你必定能猜到,本文是以倒叙的方式写的。笔者是先使用 bytenode 完成了需求,再研究其原理。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
暂无评论
推荐阅读
JS底层运行机制
众所周知,计算机是有内存的,计算机会在内存中开辟一块空间去供js执行,这个空间我们称之为执行栈
子夜星辰
2022/11/15
2K0
重学JS基础-作用域链和闭包
JS有一个全局对象,window,在全局声明的变量都属于window的属性,未使用声明符声明的属性也是window的属性。
Jou
2022/08/10
6090
java作用域-javaScript预编译、作用域,作用域链详解
  ES5中只分为全局作用域和函数作用域java作用域,也就是说for,if,while等语句是不会创建作用域的。ES6(let,const)除外。
宜轩
2022/12/29
1.5K0
JS学习系列 06 - 变量对象
上一节我们讨论了执行上下文,那么在上下文中到底有什么内容,为什么它会和作用域链扯上关系,JS 解释器又是怎么找到我们声明的函数和变量,看完这一节,相信大家就不会再迷惑了。
leocoder
2024/02/01
1200
JS学习系列 06 - 变量对象
JS学习系列 06 - 变量对象
上一节我们讨论了执行上下文,那么在上下文中到底有什么内容,为什么它会和作用域链扯上关系,JS 解释器又是怎么找到我们声明的函数和变量,看完这一节,相信大家就不会再迷惑了。
leocoder
2018/10/31
1.3K0
深入理解变量对象、作用域链和闭包
执行上下文、执行栈、作用域链、闭包,这其实是一整套相关的东西,之前转载的文章也有讲到这些。下面两篇文章会更加详细地解释这些概念。
Chor
2019/11/07
7440
深入理解作用域和闭包
JavaScript中的变量是松散类型的,没有规则定义它必须包含什么数据类型,它的值和数据类型在执行期间是可以改变的。
神奇的程序员
2022/04/10
5610
深入理解作用域和闭包
如何编写高质量的 JS 函数(1) -- 敲山震虎篇
此系列文章将会从函数的执行机制、鲁棒性、函数式编程、设计模式等方面,全面阐述如何通过 JavaScript 编写高质量的函数。
2020labs小助手
2019/08/26
1.3K0
深入理解Js中的this
JavaScript作用域为静态作用域static scope,但是在Js中的this却是一个例外,this的指向问题就类似于动态作用域,其并不关心函数和作用域是如何声明以及在何处声明的,只关心它们从何处调用,this的指向在函数定义的时候是确定不了的,只有函数执行的时候才能确定this到底指向谁,当然实际上this的最终指向的是那个调用它的对象。
WindRunnerMax
2021/02/25
4460
这一次,彻底弄懂 JavaScript 函数执行机制
作用域就是JS函数和变量的可访问范围,分为全局作用域、局部作用域和块级作用域。全局作用域是整个程序都能访问到的区域,web环境下为window对象,node环境下为Global对象。局部作用域也就是函数作用域,在函数内部形成一个独立的作用域,函数执行结束就销毁,函数内部的变量只能在函数内部访问。块级作用域,使用let或const关键字声明变量之后,会生成块级作用域,声明的变量只在这个块中有效,并且在这个块中let或const声明的变量必须先声明后使用。
小丑同学
2021/05/18
1.1K0
这一次,彻底弄懂 JavaScript 函数执行机制
由 JavaScript 的 with 引发的探索
1. 背景 某天吃饭的时候突然想到,都说 with 会有问题,那么是什么问题,是怎样导致的呢?知其然不知其所以然,在好奇心的驱使下,从 with 出发,一路追溯到 VO、AO。那么先来复习一下 with 是干嘛的吧。 2. with js 的 with 是为对象访问提供命名空间式的访问方式,with 创建一个对象的命名空间,在这个命名空间内你可以直接访问对象的属性,而不需要通过对象来访问: const o = { a: 1, b: 2 }; with (o) { console.log(a); /
用户1097444
2022/06/29
3210
由 JavaScript 的 with 引发的探索
JavaScript的预编译过程
在全局环境中会生成一个 GO对象 (Global Object),还是按照上面的四步执行。
FinGet
2019/06/28
4080
JavaScript的预编译过程
手把手教会你JavaScript引擎如何执行JavaScript代码
JavaScript 在运行过程中与其他语言有所不一样,如果不理解 JavaScript 的词法环境、执行上下文等内容,很容易会在开发过程中产生 Bug,比如this指向和预期不一致、某个变量不知道为什么被改了,等等。所以今天我们就来聊一聊 JavaScript 代码的运行过程。
前端皮皮
2022/08/17
4610
手把手教会你JavaScript引擎如何执行JavaScript代码
js常见错误总结
var声明的变量即是全局变量,也相当于给GO(window)设置了一个属性,而且两者建立映射机制
IT工作者
2021/12/30
1.9K0
js堆栈内存详解
变量提升:当前上下文执行之前,会把var/function声明或者定义提升,带var的只声明,带function的声明+定义
花落花相惜
2021/12/15
2K0
javascript中function用法_年终总结反思不足之处
整理了JavaScript中函数Function的各种,感觉函数就是一大对象啊,各种知识点都能牵扯进来,不单单是 Function 这个本身原生的引用类型的各种用法,还包含执行环境,作用域,闭包,上下文,私有变量等知识点的深入理解。
全栈程序员站长
2022/09/20
5270
js堆栈内存
变量提升:当前上下文执行之前,会把var/function声明或者定义提升,带var的只声明,带function的声明+定义
ruochen
2021/11/21
1.9K0
前端入门18-JavaScript进阶之作用域链声明正文-作用域链
作为一个前端小白,入门跟着这几个来源学习,感谢作者的分享,在其基础上,通过自己的理解,梳理出的知识点,或许有遗漏,或许有些理解是错误的,如有发现,欢迎指点下。
请叫我大苏
2018/12/24
4490
深入理解JavaScript 执行上下文
只有理解了执行上下文,才能更好地理解 JavaScript 语言本身,比如变量提升,作用域,闭包等
木子星兮
2020/07/16
3900
JavaScript之再学习
JavaScript 是一种面向对象的动态语言,它包含类型、运算符、标准内置( built-in)对象和方法。它的语法来源于 Java 和 C,所以这两种语言的许多语法特性同样适用于 JavaScript。需要注意的一个主要区别是 JavaScript 不支持类,类这一概念在 JavaScript 通过对象原型(object prototype)得到延续。另一个主要区别是 JavaScript 中的函数也是对象,JavaScript 允许函数在包含可执行代码的同时,能像其他对象一样被传递。
Abalone
2022/07/14
3630
JavaScript之再学习
相关推荐
JS底层运行机制
更多 >
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档