好的,各位观众老爷,今天咱们聊点刺激的——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.Script
和 runInNewContext
的基本用法。 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
函数,那就意味着可以加载任何模块,包括fs
、child_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)
虽然直接调用
process
或require
受限,但计时器函数有时能间接访问这些受保护的资源。例如,在计时器回调中尝试访问全局对象或执行某些操作,可能会暴露沙箱的边界。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
模块存在这么多逃逸风险,那么如何才能安全地使用它呢?
-
最小化沙箱权限
这是最重要的一点。只向沙箱传递必要的变量和函数,不要传递任何可能被利用的敏感对象,比如
process
、require
、Function
、eval
等。 -
使用
Object.freeze
和Object.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.freeze
和Object.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, '<').replace(/>/g, '>')
},
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
函数打印日志。
攻击者可以尝试以下逃逸方式:
- 修改
data
对象: 攻击者可以修改data
对象的属性,例如将data.name
修改为'Evil'
。 - 利用
util.escape
函数: 虽然util.escape
函数本身是安全的,但攻击者可以通过构造特殊的输入,尝试绕过过滤。 - 访问全局对象: 攻击者可以尝试访问全局对象,例如
global
对象,并修改全局变量。 - 访问
process
对象: 攻击者可以尝试访问process
对象,获取 Node.js 进程的信息。 - 使用
Function
构造函数: 攻击者可以使用Function
构造函数动态创建函数,并执行任意代码。 - 使用
require
函数: 攻击者可以使用require
函数加载模块,例如fs
模块,并读取文件。
为了防止逃逸,我们可以采取以下措施:
- 深拷贝
data
对象: 不要将原始data
对象传递到沙箱中,而是传递一个深拷贝。这样,即使沙箱修改了data
对象,也不会影响原始data
对象。 - 限制
util
对象的功能: 只向沙箱传递必要的函数,不要传递任何可能被利用的函数。 - 禁用全局对象: 不要向沙箱传递全局对象。
- 禁用
process
对象: 不要向沙箱传递process
对象。 - 禁用
Function
构造函数: 不要向沙箱传递Function
构造函数。 - 禁用
require
函数: 不要向沙箱传递require
函数。
第五幕:总结与展望
vm
模块是一个强大的工具,但也是一把双刃剑。如果不小心使用,可能会导致严重的后果。
漏洞类型 | 描述 | 防御措施 |
---|---|---|
原型链污染 | 修改原型链,访问沙箱外部的属性和方法 | 冻结对象,使用全新的上下文 |
process 对象 |
访问 process 对象,获取 Node.js 进程的信息和功能 |
不要向沙箱传递 process 对象 |
require 函数 |
使用 require 函数加载模块,例如 fs 模块,并读取文件 |
不要向沙箱传递 require 函数 |
Function 构造函数 |
使用 Function 构造函数动态创建函数,并执行任意代码 |
不要向沙箱传递 Function 构造函数 |
eval 函数 |
使用 eval 函数执行字符串形式的 JavaScript 代码 |
不要向沙箱传递 eval 函数 |
Error.prepareStackTrace |
自定义错误堆栈信息格式,访问沙箱外部的变量 | 谨慎处理错误堆栈信息,避免暴露敏感信息 |
计时器函数 | 间接访问受保护资源,例如 process 和 require |
限制计时器函数的使用,确保回调函数不会执行恶意代码 |
其他漏洞 | 未知的漏洞,例如 V8 引擎的漏洞 | 保持 Node.js 版本更新,及时修复漏洞,使用更安全的沙箱方案 |
未来,随着 Node.js 的发展,vm
模块的安全性和易用性将会得到进一步提升。同时,也会出现更多更隐蔽的逃逸技巧。我们需要不断学习和探索,才能更好地保护我们的系统安全。
记住,安全无小事。
谢幕:安全之路,永无止境
今天的讲座就到这里。希望大家能够对 Node.js 的 vm
模块沙箱逃逸有更深入的了解。记住,安全之路,永无止境。下次再见!