JS `Node.js` `vm` 模块 `Sandboxing` 的局限性与逃逸方法

好的,各位观众老爷,今天咱们聊点刺激的——Node.js 的 vm 模块沙箱逃逸!

开场白:沙箱,你以为的安全屋?

Node.js 的 vm 模块,顾名思义,就是个虚拟机,或者说“沙箱”。它的设计初衷是让你在安全的环境里执行不受信任的代码,避免恶意代码污染你的主进程,比如,你从网上 Down 了一段代码,不知道它会不会删库跑路,那就先扔到 vm 里跑跑看。

理想很丰满,现实很骨感。vm 模块并非绝对安全,存在各种各样的逃逸方式。这意味着,坏人可以通过一些巧妙的手段,突破沙箱的限制,执行原本不被允许的操作,甚至控制你的整个服务器。

第一幕:vm 模块的基础知识

先来回顾一下 vm 模块的基本用法。

const vm = require('vm');

// 创建一个沙箱环境
const sandbox = {
  animal: 'cat',
  count: 2
};

// 要执行的代码
const code = `
  animal = 'dog';
  count = 5;
  result = animal + ' says meow ' + count + ' times';
`;

// 创建一个 Script 对象
const script = new vm.Script(code);

// 在沙箱环境中运行代码
script.runInNewContext(sandbox);

// 打印沙箱中的变量
console.log(sandbox); // 输出: { animal: 'cat', count: 2 }
console.log(sandbox.result); // 输出: undefined

// 在当前上下文中运行代码
script.runInThisContext();

console.log(animal); // 输出: dog
console.log(count); // 输出: 5
console.log(result); // 输出: dog says meow 5 times

上面的例子展示了 vm.ScriptrunInNewContext 的基本用法。 runInNewContext 在一个新的上下文中运行代码,保证了代码不会污染当前上下文。但是,请注意,runInNewContext 并不能阻止所有类型的逃逸。

第二幕:逃逸的常见套路

接下来,让我们看看一些常见的沙箱逃逸技巧。

  • 原型链污染

    JavaScript 的原型链是个神奇的东西,它允许对象继承属性和方法。如果我们可以修改沙箱中对象的原型链,就有可能访问到沙箱外部的属性和方法。

    const vm = require('vm');
    
    const code = `
      // 尝试修改 Object 的原型
      Object.prototype.isAdmin = true;
    
      // 检查是否成功
      result = isAdmin;
    `;
    
    const sandbox = {};
    
    const script = new vm.Script(code);
    script.runInNewContext(sandbox);
    
    console.log(sandbox.result); // 在某些版本中,可能输出 true,造成逃逸
    console.log(isAdmin); // 在当前上下文中,可能输出 true,造成污染

    在旧版本的 Node.js 中,这种方式是有效的。但现在,Node.js 已经加强了对原型链的保护。

  • process 对象

    process 对象是 Node.js 的全局对象,提供了访问当前 Node.js 进程的各种信息和功能。如果沙箱中可以访问 process 对象,那就相当于拥有了整个服务器的控制权。

    const vm = require('vm');
    
    const code = `
      // 尝试访问 process 对象
      result = process.version;
    
      // 尝试执行系统命令
      require('child_process').execSync('whoami');
    `;
    
    const sandbox = {};
    
    const script = new vm.Script(code);
    
    try {
      script.runInNewContext(sandbox);
      console.log(sandbox.result);
    } catch (e) {
      console.error("Error:", e.message);
    }
    

    默认情况下,process 对象在沙箱中是不可用的。但是,如果你不小心将 process 对象传递到沙箱中,那就惨了。

    const vm = require('vm');
    
    const sandbox = {
      process: process // 千万不要这么做!
    };
    
    const code = `
      // 现在可以访问 process 对象了
      result = process.version;
    `;
    
    const script = new vm.Script(code);
    script.runInNewContext(sandbox);
    
    console.log(sandbox.result); // 输出 Node.js 版本
  • require 函数

    require 函数是 Node.js 中用来加载模块的。如果沙箱中可以使用 require 函数,那就意味着可以加载任何模块,包括 fschild_process 等危险模块。

    const vm = require('vm');
    
    const code = `
      // 尝试加载 fs 模块
      const fs = require('fs');
    
      // 读取文件
      result = fs.readFileSync('/etc/passwd', 'utf8');
    `;
    
    const sandbox = {
      require: require // 千万不要这么做!
    };
    
    const script = new vm.Script(code);
    
    try {
        script.runInNewContext(sandbox);
        console.log(sandbox.result);
    } catch (e) {
        console.error("Error:", e.message);
    }

    同样,默认情况下,require 函数在沙箱中是不可用的。但是,如果你不小心将 require 函数传递到沙箱中,那就等着被黑吧。

  • Function 构造函数

    Function 构造函数可以用来动态创建函数。如果沙箱中可以使用 Function 构造函数,就可以执行任意 JavaScript 代码,包括逃逸沙箱的代码。

    const vm = require('vm');
    
    const code = `
      // 使用 Function 构造函数创建一个函数
      const evilFunction = new Function('return process.version');
    
      // 执行函数
      result = evilFunction();
    `;
    
    const sandbox = {};
    
    const script = new vm.Script(code);
    
    try {
        script.runInNewContext(sandbox);
        console.log(sandbox.result);
    } catch (e) {
        console.error("Error:", e.message);
    }
    

    同样,默认情况下,Function 构造函数在沙箱中是不可用的。但是,如果你的代码中存在漏洞,允许攻击者执行任意 JavaScript 代码,那么攻击者就可以利用 Function 构造函数来逃逸沙箱。

  • eval 函数

    eval 函数可以用来执行字符串形式的 JavaScript 代码。如果沙箱中可以使用 eval 函数,就可以执行任意 JavaScript 代码,包括逃逸沙箱的代码。eval 函数的危险性跟 Function 构造函数类似。

    const vm = require('vm');
    
    const code = `
      // 使用 eval 函数执行代码
      result = eval('process.version');
    `;
    
    const sandbox = {};
    
    const script = new vm.Script(code);
    
    try {
        script.runInNewContext(sandbox);
        console.log(sandbox.result);
    } catch (e) {
        console.error("Error:", e.message);
    }

    同样,默认情况下,eval 函数在沙箱中是不可用的。

  • Error.prepareStackTrace

    这是一个相对冷门的逃逸点。Error.prepareStackTrace 允许自定义错误堆栈信息的格式。通过巧妙地利用这个特性,可以访问到沙箱外部的变量。

    const vm = require('vm');
    
    const code = `
      Error.prepareStackTrace = function(error, structuredStackTrace) {
        // 访问 global 对象
        return structuredStackTrace[0].getThis();
      };
    
      try {
        throw new Error();
      } catch (e) {
        // 现在可以访问 global 对象了
        globalThis.result = e.stack.process.version;
      }
    `;
    
    const sandbox = {};
    
    const script = new vm.Script(code);
    script.runInNewContext(sandbox);
    
    console.log(sandbox.result);

    这个逃逸方法依赖于对错误堆栈信息的操纵,比较隐蔽。

  • 计时器函数 (setTimeout, setInterval)

    虽然直接调用 processrequire 受限,但计时器函数有时能间接访问这些受保护的资源。例如,在计时器回调中尝试访问全局对象或执行某些操作,可能会暴露沙箱的边界。

    const vm = require('vm');
    
    const code = `
      setTimeout(() => {
        try {
          result = process.version; // 尝试访问 process
        } catch (e) {
          result = 'Access denied';
        }
      }, 100);
    `;
    
    const sandbox = {};
    
    const script = new vm.Script(code);
    script.runInNewContext(sandbox);
    
    setTimeout(() => {
      console.log(sandbox.result); // 稍后输出结果
    }, 200);

    这种方式的成功与否取决于 Node.js 版本和 vm 的配置。

第三幕:如何防止逃逸?

既然 vm 模块存在这么多逃逸风险,那么如何才能安全地使用它呢?

  • 最小化沙箱权限

    这是最重要的一点。只向沙箱传递必要的变量和函数,不要传递任何可能被利用的敏感对象,比如 processrequireFunctioneval 等。

  • 使用 Object.freezeObject.seal

    Object.freeze 可以冻结对象,使其属性不可修改。Object.seal 可以封闭对象,使其属性不可添加或删除。

    const vm = require('vm');
    
    const sandbox = {
      animal: 'cat',
      count: 2
    };
    
    Object.freeze(sandbox); // 冻结沙箱
    
    const code = `
      animal = 'dog'; // 尝试修改属性
      newProperty = 'value'; // 尝试添加属性
    `;
    
    const script = new vm.Script(code);
    
    try {
        script.runInNewContext(sandbox);
        console.log(sandbox);
    } catch (e) {
        console.error("Error:", e.message);
    }

    但是,Object.freezeObject.seal 只能防止直接修改对象,无法防止通过原型链修改对象。

  • 使用 vm.createContext 创建全新的上下文

    vm.createContext 可以创建一个全新的上下文,与全局上下文完全隔离。

    const vm = require('vm');
    
    const context = vm.createContext({}); // 创建一个全新的上下文
    
    const code = `
      global.isAdmin = true; // 尝试修改全局对象
    `;
    
    const script = new vm.Script(code);
    script.runInContext(context);
    
    console.log(global.isAdmin); // 输出: undefined (全局对象未被修改)
    console.log(context.isAdmin); // 输出: undefined (新上下文中不存在该属性)
  • 使用更安全的沙箱方案

    除了 vm 模块,还有一些更安全的沙箱方案,比如:

    • Isolate: Isolate 是一个 V8 引擎的隔离器,可以创建多个独立的 V8 引擎实例。每个 Isolate 都有自己的堆和垃圾回收器,可以实现更彻底的隔离。
    • 沙箱进程: 将不受信任的代码运行在一个独立的进程中,通过进程间通信 (IPC) 与主进程进行交互。这种方式的隔离性更强,但性能开销也更大。
  • 代码审查

    最重要的一点是代码审查。仔细检查要运行的代码,确保其中不包含任何恶意代码。

第四幕:一个综合案例分析

让我们来看一个更复杂的例子,分析一下可能存在的逃逸风险。

const vm = require('vm');

function createSandbox(data) {
  const sandbox = {
    data: data,
    util: {
      escape: (str) => str.replace(/</g, '&lt;').replace(/>/g, '&gt;')
    },
    console: {
      log: console.log // 为了方便调试,允许沙箱打印日志
    }
  };

  return sandbox;
}

const untrustedCode = `
  // 尝试修改 data 对象
  data.name = 'Evil';

  // 尝试访问 util 对象
  result = util.escape('<script>alert("XSS")</script>');

  // 尝试访问全局对象
  //global.isAdmin = true; // 禁用 global

  // 尝试访问 process 对象
  //result = process.version; // 禁用 process

  // 尝试使用 Function 构造函数
  //const evilFunction = new Function('return process.version'); // 禁用 Function

  // 尝试读取文件
  //const fs = require('fs');  // 禁用 require

  // 使用计时器
  setTimeout(() => {
      console.log("Hello from the sandbox!");
  }, 100);
`;

const data = {
  name: 'Guest',
  age: 20
};

const sandbox = createSandbox(data);

const script = new vm.Script(untrustedCode);

try {
  script.runInNewContext(sandbox);
  console.log('Data:', data); // 查看原始 data 对象是否被修改
  console.log('Result:', sandbox.result);
} catch (error) {
  console.error('Sandbox Error:', error);
}

在这个例子中,我们创建了一个沙箱,并将 data 对象和 util.escape 函数传递到沙箱中。我们还允许沙箱使用 console.log 函数打印日志。

攻击者可以尝试以下逃逸方式:

  1. 修改 data 对象: 攻击者可以修改 data 对象的属性,例如将 data.name 修改为 'Evil'
  2. 利用 util.escape 函数: 虽然 util.escape 函数本身是安全的,但攻击者可以通过构造特殊的输入,尝试绕过过滤。
  3. 访问全局对象: 攻击者可以尝试访问全局对象,例如 global 对象,并修改全局变量。
  4. 访问 process 对象: 攻击者可以尝试访问 process 对象,获取 Node.js 进程的信息。
  5. 使用 Function 构造函数: 攻击者可以使用 Function 构造函数动态创建函数,并执行任意代码。
  6. 使用 require 函数: 攻击者可以使用 require 函数加载模块,例如 fs 模块,并读取文件。

为了防止逃逸,我们可以采取以下措施:

  1. 深拷贝 data 对象: 不要将原始 data 对象传递到沙箱中,而是传递一个深拷贝。这样,即使沙箱修改了 data 对象,也不会影响原始 data 对象。
  2. 限制 util 对象的功能: 只向沙箱传递必要的函数,不要传递任何可能被利用的函数。
  3. 禁用全局对象: 不要向沙箱传递全局对象。
  4. 禁用 process 对象: 不要向沙箱传递 process 对象。
  5. 禁用 Function 构造函数: 不要向沙箱传递 Function 构造函数。
  6. 禁用 require 函数: 不要向沙箱传递 require 函数。

第五幕:总结与展望

vm 模块是一个强大的工具,但也是一把双刃剑。如果不小心使用,可能会导致严重的后果。

漏洞类型 描述 防御措施
原型链污染 修改原型链,访问沙箱外部的属性和方法 冻结对象,使用全新的上下文
process 对象 访问 process 对象,获取 Node.js 进程的信息和功能 不要向沙箱传递 process 对象
require 函数 使用 require 函数加载模块,例如 fs 模块,并读取文件 不要向沙箱传递 require 函数
Function 构造函数 使用 Function 构造函数动态创建函数,并执行任意代码 不要向沙箱传递 Function 构造函数
eval 函数 使用 eval 函数执行字符串形式的 JavaScript 代码 不要向沙箱传递 eval 函数
Error.prepareStackTrace 自定义错误堆栈信息格式,访问沙箱外部的变量 谨慎处理错误堆栈信息,避免暴露敏感信息
计时器函数 间接访问受保护资源,例如 processrequire 限制计时器函数的使用,确保回调函数不会执行恶意代码
其他漏洞 未知的漏洞,例如 V8 引擎的漏洞 保持 Node.js 版本更新,及时修复漏洞,使用更安全的沙箱方案

未来,随着 Node.js 的发展,vm 模块的安全性和易用性将会得到进一步提升。同时,也会出现更多更隐蔽的逃逸技巧。我们需要不断学习和探索,才能更好地保护我们的系统安全。

记住,安全无小事。

谢幕:安全之路,永无止境

今天的讲座就到这里。希望大家能够对 Node.js 的 vm 模块沙箱逃逸有更深入的了解。记住,安全之路,永无止境。下次再见!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注