Featured image of post nodejs原型链污染小例子

nodejs原型链污染小例子

bin大哥NB

node.js 原型链污染小探索

觉得就我这个打比赛的速度还是写写知识记录类的博客吧,以后尽量一周两更,把最近学的东西记录下来,也是强迫自己把东西弄清楚的一种手段吧,但是里面写的东西我只能说不保证严谨,只作为个人记录

引入

JavaScript 里面万物皆对象并非,这种设计对用他的人而言很符合写作直觉,对他的设计者而言也有很多塞小巧思的空间

考虑现在有一个定义客户的构造函数,其创建的实例可以存name属性,以及一个sayHi的方法。

构造函数并不是一个特殊的函数类型,而是函数中承担构造责任的一类角色

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
function Person(name) {
  this.name = name;
  this.sayHi = function () {
    console.log("Hi, " + this.name);
  };
}

const p1 = new Person("铅笔");
const p2 = new Person("小鱼");

console.log(p1.sayHi === p2.sayHi); // false

对于p1p2两个实例,p1.sayHi 和 p2.sayHi 虽然代码一样,但在内存中是两个完全不同的函数对象,如果你创建了 1 万个实例,内存里就会有 1 万个 sayHi 函数的副本 为了节省内存,现在考虑如下写法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
function Person(name) {
    this.name = name;
}

// 方法定义在原型上
Person.prototype.sayHi = function() {
    console.log('Hi, ' + this.name);
};

const p1 = new Person('铅笔');
const p2 = new Person('小鱼');

console.log(p1.sayHi === p2.sayHi); // true
p1.sayHi(); //直接调用

上面的写法中,p1 和 p2 实例本身没有 sayHi 方法,但当调用时,JavaScript 引擎会沿着原型链找到 Person.prototype 中定义的sayHi。 无论创建多少个实例,sayHi 函数在内存中只有一份。接下来介绍一下这个原型链的思想

关于原型链

JS中,普通函数(本身也是对象的一种)都有一个prototype的对象(箭头函数没有),该函数创造的实例中会有一个__proto__的访问器属性,通过后者可以接触甚至修改前者

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
function Person(name) {
    this.name = name;
}

const p1 =new Person('铅笔')
const p2 = new Person('小鱼');

console.log(p1.__proto__ === Person.prototype) //true
console.log(p1.__proto__ === p2.__proto__) // true

p1.__proto__.sayHi = function() {
  console.log('Hi, '+ this.name)
}

p2.sayHi() //Hi, 小鱼

我们可以把prototype 理解为 “共享工具箱”,把 __proto__ 理解为 “连接工具箱的线索”,

在上面的例子中,可以发现由p1出发定义的__proto__中,方法sayHi也可以被p2使用,因为这个过程是从p1找到他的构造函数(也就是Person)的“共享工具箱”,操作之后,当p2需要执行sayHi功能时,就会调用“共享工具箱”之前定义的方法

当实例访问他的属性/方法的时候,首先会尝试调用实例自身的属性/方法, 否则去顺着 __proto__ 找当前构造函数的 prototype(共享工具箱); 如果还是没找到,就会继续找这个“共享工具箱”本身的 __proto__(通常是 Object.prototype),层层向上,一直找下去直到 __proto__ 为 null,这就是原型链。

原型链污染示例

然而越是精巧的系统往往越不能抗压,如果有办法构造一段精心设计的payload可以更改__proto__的内容, 那对应函数的所有实例都可以像访问自有属性那样调用到原型链上的污染数据,考虑下面这个例子

 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
47
48
49
50
51
52
53
54
55
56
57
58
59


const isObject = (obj) => obj && obj.constructor && obj.constructor === Object;

function merge(a, b) {
  console.log(
    `[Merge] a是: ${a === Object.prototype ? "全局原型" : "普通对象"}`,
  );

  for (var attr in b) {
    console.log(`  -> 当前属性 key: "${attr}"`);

    if (attr === "__proto__") {
      console.log(
        `   #a["__proto__"] 实际指向: ${a[attr] === Object.prototype ? "全局原型" : "其他原型"}`, // 也就是{}.__proto__
      );
    }

    if (isObject(a[attr]) && isObject(b[attr])) {
      // 第一轮判定{}.__proto__ 和 b.__proto__都是对象进入下一轮,但是前者通过访问器进入的是全局原型,后者进入一个叫__proto__的b的自有属性
      console.log(`  -> 递归合并: merge(a["${attr}"], b["${attr}"])`);
      merge(a[attr], b[attr]);
    } else {
      // 当第二轮递归时,这里的 a 已经是 Object.prototype,attr 是 "role"
      // 这行代码等价于 Object.prototype["role"] = "admin"
      console.log(`  -> 直接赋值: a["${attr}"] = ${JSON.stringify(b[attr])}`);
      a[attr] = b[attr];
    }
  }
  return a;
}

function clone(a) {
  return merge({}, a);
}

console.log("====正常输入====");
let user1 = JSON.parse('{"person": {"name": "pencil","age": "20"}}');
console.log("user1:", user1);

console.log("\n执行合并user1 ,user2:");
let user2 = clone(user1);
console.log("user2:", user2);

// 现代 JS 引擎中,JSON.parse 通常会将 __proto__ 视为普通字符串 key,生成自有属性
console.log("\n====恶意输入====");
let user3 = JSON.parse('{"__proto__": {"role": "admin"}}');
console.log("user3:", user3);
console.log("user3 自有属性:", Object.getOwnPropertyNames(user3));

// 执行合并,触发漏洞
console.log("\n执行合并user3,user4:");
let user4 = clone(user3);
console.log("user4:", user4);

console.log("\n====验证结果====");
console.log("user1.role (污染后):", user1.role);
console.log("user1 是否有自有属性 role:", user1.hasOwnProperty("role"));
console.log("Object.prototype.role:", Object.prototype.role);

这个例子里定义了一个递归合并对象的函数merge(a, b), 预期效果就是:

  1. a有b没有的属性 保持不变
  2. a有b也有的属性 修改为b中对应的值
  3. a没有b有的属性 a中添加b对应属性
  4. 支持对象的递归调用,属性可以是个对象

例子里的clone()函数就是把用户输入的对象合并到一个空的对象{}上,最后得到的user2和user1的内容是一致的

但是如果我们构造输入为{"__proto__": {"role": "admin"}},就会触发原型连污染,让之后Object构造函数定义的所有实例都可以调用一个role的属性为admin

漏洞分析

观察上面的代码,数据通过JSON解析器接收赋值的时候,是按照普通字符串的方法,定义为自有属性的,这个是对的,如果没有这种转义,我可以直接通过__proto__访问器污染原型; 真正的问题在于,后续的merge函数中没有这种特殊的转义处理,或者对于访问器的限制

当merge函数执行merge({},user3)时,第一个参数{},就是一个空的对象字面量; 第二个参数user3,是一个带有__proto__的key的自由属性,这个属性的value是一个对象,对象的值是{“role”: “admin”}

  1. 第一层: 函数遍历attr为__proto__,则a[attr]就是{}.__proto__b[attr]就是user3.__proto__if (isObject(a[attr]) && isObject(b[attr]))的判断中,函数首先发现他俩都是对象,于是递归进入下一层,但是前者通过访问器进入的是全局原型,后者进入一个叫__proto__的b的对象
  2. 第二层 函数发现b中有一个role: admin的属性而a中没有,就在a,也就是全局原型中添加了一条恶意属性

最后去输出user4的结果发现是{},我们在函数里做的操作都作用在原型对象里面,而不是在这个user4本身上,那现在就有Object.prototype.role === "admin" 为真,而这个role的属性是所有相关实例都可以通过原型链调用到的,这个攻击就完成了。

总结

没啥想总结的,睡觉了

伊蕾娜小姐可爱捏

你好,这是一个随便写写,随便看看的无聊而与我很重要的网站。
使用 Hugo 构建
主题 StackJimmy 设计