资讯专栏INFORMATION COLUMN

保护 Node.js 项目的源代码

Steven / 1988人阅读

摘要:而对于应用越来越广泛的而言,运行的则是源代码。通过查阅的相关代码,可以发现字节码的头部保存着这些信息其中第项就是源代码长度。本文同时发表于作者个人博客保护项目的源代码

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

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

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

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

原理

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

const vm = require("vm");
const CODE = "console.log("Hello world");"; // 源代码
const script = new vm.Script(CODE, {
  produceCachedData: true
});
const bytecodeBuffer = script.cachedData; // 字节码

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

const anotherScript = new vm.Script(" ".repeat(CODE.length), {
  cachedData: bytecodeBuffer
});
anotherScript.runInThisContext(); // "Hello world"

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

既然源代码的字节码已经在 bytecodeBuffer 中,为何还要传入第一个参数?

为何传入与源代码长度相同的空格?

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

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

// 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]。

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

const lengthBytes = bytecodeBuffer.slice(8, 12);

其结果类似于:

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

即为 0x0000001b,也就是十进制的 27。计算方法如下:

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

写成代码如下:

const length = lengthBytes.reduce((sum, number, power) => {
  return sum += number * Math.pow(256, power);
}, 0); // 27

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

const length = bytecodeBuffer.readIntLE(8, 4); // 27

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

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 目录内的两个文件为源代码,内容分别为:

// lib.js
console.log("I am lib");
exports.add = function(a, b) {
  return a + b;
};
// index.js
console.log("I am index");
const lib = require("./lib");
console.log(lib.add(1, 2));

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

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,内容如下:

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 为例,包装后的代码如下:

(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)操作:

const script = new vm.Script(Module.wrap(code), {
  produceCachedData: true
});

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

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 部分,即函数体部分,只有在函数被调用的时候才会被编译。

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

const v8 = require("v8");
v8.setFlagsFromString("--no-lazy");

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

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 代码如下:

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 完成了需求,再研究其原理。

本文同时发表于作者个人博客:《保护 Node.js 项目的源代码》

文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。

转载请注明本文地址:https://www.ucloud.cn/yun/105412.html

相关文章

  • Node Hero】8. 使用 Passport.js 进行 Node.js 身份验证

    摘要:本文转载自众成翻译译者网络埋伏纪事链接原文本教程中将学习如何使用和实现一个本地身份验证策略。我们将有一个用户页,一个备注页,和一些与身份验证相关的功能。下一步下一章主要涉及应用程序的单元测试。你会学习单元测试测试金字塔测试替代等概念。 本文转载自:众成翻译译者:网络埋伏纪事链接:http://www.zcfy.cc/article/1755原文:https://blog.risings...

    CoderStudy 评论0 收藏0
  • 新上课程推荐:TypeScript完全解读(总26课时)

    摘要:本套课程包含两大部分,第一部分是基础部分,也是重要部分,参考官方文档结构,针对内容之间的关联性和前后顺序进行合理调整。 showImg(https://segmentfault.com/img/bVbpBA0?w=1460&h=400); 讲师简介: iview 核心开发者,iview-admin 作者,百万级虚拟渲染表格组件 vue-bigdata-table 作者。目前就职于知名互...

    caozhijian 评论0 收藏0
  • [译]保持Node.js速度-创建高性能Node.js Servers工具、技术和提示

    摘要:本文翻译自原文地址中文标题保持的速度创建高性能的工具技术和提示快速摘要是一个非常多彩的平台,而创建服务就是其非常重要的能力之一。在目录下,我们执行译者注现在的话可以使用新的形式的命令语法会在剖析完毕后,创建文件并自动打开浏览器。 pre-tips 本文翻译自: Keeping Node.js Fast: Tools, Techniques, And Tips For Making Hi...

    Lavender 评论0 收藏0
  • 阿里云容器服务区块链解决方案全新升级 支持Hyperledger Fabric v1.1

    摘要:阿里云容器服务区块链解决方案第一时间同步升级,在新功能的基础上,提供了弹性裸金属服务器神龙内置容器化集成阿里云日志服务等方面的增强。 摘要: 全球开源区块链领域影响最为广泛的Hyperledger Fabric日前宣布了1.1版本的正式发布,带来了一系列丰富的新功能以及在安全性、性能与扩展性等方面的显著提升。阿里云容器服务区块链解决方案第一时间同步升级,在v1.1新功能的基础上,提供了...

    vvpale 评论0 收藏0
  • 如果有人问你爬虫抓取技术门道,请叫他来看这篇文章

    摘要:未授权的爬虫抓取程序是危害原创内容生态的一大元凶,因此要保护网站的内容,首先就要考虑如何反爬虫。反爬虫的银弹目前的反抓取机器人检查手段,最可靠的还是验证码技术。机器人协议除此之外,在爬虫抓取技术领域还有一个白道的手段,叫做协议。 本文首发于我的个人博客,同步发布于SegmentFault专栏,非商业转载请注明出处,商业转载请阅读原文链接里的法律声明。 web是一个开放的平台,这也奠定了...

    raoyi 评论0 收藏0

发表评论

0条评论

Steven

|高级讲师

TA的文章

阅读更多
最新活动
阅读需要支付1元查看
<