Node.js原型链污染

0x00 前置知识

继承与原型链

在JavaScript中,继承是通过原型链实现的。

JavaScript 只有一种结构:对象(object)。每个对象都有一个私有属性指向另一个名为原型(prototype)的对象。原型对象也有一个自己的原型,层层向上直到一个对象的原型为 null。整体看来就是多个对象层层继承,实例对象的原型链接形成了一条链,也就是 js 的原型链。

一个对象的原型可以通过其__proto__ 属性访问。

举个例子:

function Person(name) {
this.name = name;
}

Person.prototype.sayHello = function() {
console.log(`Hello, my name is ${this.name}`);
};

const person1 = new Person("Alice");
person1.sayHello(); // 输出: Hello, my name is Alice

上面的例子创建了一个名为Person的构造函数,并将prototype上的sayHello设置为一个打招呼的函数。prototypePerson的一个属性,所有用类Person进行实例化的对象,都会拥有prototype的全部内容。所以当创建一个名为person1的实例时,它会继承Person.prototype对象上的sayHello方法。因此,当调用person1.sayHello()时,会输出“Hello, my name is Alice”。

原型链污染

如果修改了一个对象的原型,那么会影响所有来自于这个原型的对象,这就是原型链污染。

举例:

// 假设我们有一个简单的用户输入处理函数
function pollute(target, key, value) {
target[key] = value; // 修改目标对象
}

// 用户输入的对象
const userInput = {
"__proto__": {
isAdmin: true // 修改了 Object.prototype 的属性
}
};

// 调用函数,污染原型链
pollute({}, userInput.__proto__, {});

console.log({}.isAdmin); // 输出: true

在上面的例子中,pollute 函数将用户输入的原型链中的 isAdmin 属性添加到 Object.prototype 上,从而使得所有对象都具有 isAdmin 属性。

原型链污染通常出现在对象的键名(属性名)可控,同时这些键名的值是通过赋值语句进行设置的情况下 ( 通常使用 json 传值 )。

0x01 实战:Hackergame 2024- Node.js is Web Scale

思路分析

// server.js
const express = require("express");
const bodyParser = require("body-parser");
const path = require("path");
const { execSync } = require("child_process");

const app = express();
app.use(bodyParser.json());
app.use(express.static(path.join(__dirname, "public")));

let cmds = {
getsource: "cat server.js",
test: "echo 'hello, world!'",
};

let store = {};

// GET /api/store - Retrieve the current KV store
app.get("/api/store", (req, res) => {
res.json(store);
});

// POST /set - Set a key-value pair in the store
app.post("/set", (req, res) => {
const { key, value } = req.body;

const keys = key.split(".");
let current = store;

for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i];
if (!current[key]) {
current[key] = {};
}
current = current[key];
}

// Set the value at the last key
current[keys[keys.length - 1]] = value;

res.json({ message: "OK" });
});

// GET /get - Get a key-value pair in the store
app.get("/get", (req, res) => {
const key = req.query.key;
const keys = key.split(".");

let current = store;
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
if (current[key] === undefined) {
res.json({ message: "Not exists." });
return;
}
current = current[key];
}

res.json({ message: current });
});

// GET /execute - Run commands which are constant and obviously safe.
app.get("/execute", (req, res) => {
const key = req.query.cmd;
const cmd = cmds[key];
res.setHeader("content-type", "text/plain");
res.send(execSync(cmd).toString());
});

app.get("*", (req, res) => {
res.sendFile(path.join(__dirname, "public", "index.html"));
});

// Start the server
const PORT = 3000;
app.listen(PORT, () => {
console.log(`KV Service is running on port ${PORT}`);
});

以上代码是一个 Node.js 应用,提供了基本的 Key-Value 存储 功能,并且允许执行预定义的安全命令。

关键路由分析:

  1. GET /api/store:返回当前的键值对存储。
  2. POST /set:允许用户设置键值对,键以点(.)分隔,可以创建多层嵌套的键。
  3. GET /get:通过查询参数获取指定键的值。
  4. GET /execute:通过查询参数执行预定义的命令,如 getsourcetest

在代码中,/set 路由允许用户提供一个键值对,将值嵌套存储在 store 对象中。我们可以尝试使用 __proto__ 作为键,污染对象的原型链。通过设置 cmds 对象的 __proto__ 属性,可能让 cmds 对象获取额外的指令,允许执行任意命令,比如 cat /flag

漏洞利用

  1. 利用 /set 路由进行原型链污染

如果向 /set 路由提交 {"key": "exploit", "value": "cat /flag"},这个键值对仅仅会保存在 store 对象中,而不会对 cmds 对象产生任何影响。

为了成功执行未定义的命令,尝试通过原型链污染的方式向 cmds 添加新属性。:

{
"key": "__proto__.exploit",
"value": "cat /flag"
}

这会将 exploit 命令挂载到 cmds 对象的原型链上,使得 cmds.exploit 实际上等于 cat /flag

  1. 通过 /execute 路由触发执行命令
  • const key = req.query.cmd; 这行代码从请求的查询字符串中提取出名为 cmd 的参数。用户在访问 /execute 时,可以通过 ?cmd=xxx 形式传递这个参数。

  • const cmd = cmds[key]; 这行代码根据提取的 keycmds 对象中获取对应的命令。

请求 URL: /execute?cmd=exploit