Skip to main content
Back to blog Samuel

Promoting jQuery JSON to JSONP to trigger XSS

I’ve done quite a bit of security research for Drupal, and one area of exploitation that I often come back to is the AJAX API. Drupal’s AJAX API is built on top of jQuery, and lets developers easily add interactive behavior to the frontend.

One method of enabling this functionality is to add the “use-ajax” class to clickable HTML elements. For example, in a previous blog post “Getting creative with Drupal XSS” I found that you could trigger XSS by pointing one of these elements to an external site:

<a href="" class="use-ajax">Click me!</a>

This has since been fixed to only allow local URLs, but I kept wondering - can this be exploitable?

<a href="<payload>" class="use-ajax">Click me!</a>

Or at a lower level, is this exploitable?

$.ajax("<payload>”, {dataType: "json"})

The answer was, surprisingly, yes. You can trigger XSS by only controlling the AJAX URL, or in some cases just the tail end of the URL, regardless of the caller.

To explain this, let’s read the documentation for jQuery.ajax():

dataType (default: Intelligent Guess (xml, json, script, or html))
Type: String
The type of data that you're expecting back from the server.
"json": Evaluates the response as JSON and returns a JavaScript object. Cross-domain "json" requests that have a callback placeholder, e.g. ?callback=?, are performed using JSONP unless the request includes jsonp: false in its request options. [...]

This was an interesting find for me - to summarize, if you make an AJAX request with the dataType set to “json”, but the URL includes “?callback=?”, the response is treated like JSONP. For those that are unaware, JSONP responses are executed as JavaScript, which means that if you control part of a jQuery AJAX URL and the response, you can trigger XSS. This is true for all up to date versions of jQuery except 4.x which changed the behavior in

Here’s what an exploit might look like:

<script src=""></script> <script>     $.ajax("/payload.json?callback=?", {         dataType: "json",     }); </script>

Or as a portable one-liner:

jQuery.ajax("data:;,alert('@mortensonsam')//?callback=?", {"dataType": "json"})

You may be thinking “When will I ever get to control the URL?”, but it happens! Think of any web application that fetches (normally safe) JSON from an external URL. There may be more uses for this than you assume.

Let’s bring it back to Drupal - as I mentioned before, the magic in the “use-ajax” class only applies to local URLs now, and for this exploit to work you need to control the response data. What kind of responses do lower privileged users control? Images! For this exploit, a path like “/payload.gif?callback=?” will work just as well as “/payload.json?callback=?”.

I won’t give you the full tutorial for embedding JavaScript into images, but if I recall correctly I used this tool for the task

Once a user could upload a malicious image, they could then put in some HTML like this:

<a href="" class="use-ajax">Click me!</a>

To trigger XSS. It’s worth noting that the scope of this wasn’t all encompassing - every Drupal 6, 7, and 8 site needed updating, but not every configuration of those sites were affected. Drupal 8 in particular was hardened by default against this since it doesn’t allow arbitrary classes to be added to links.

So - how do you mitigate something like this? Per the jQuery docs quoted above, adding “jsonp: false” to your AJAX settings should do the trick. For example, this code should be safe:

$.ajax("<payload>", {dataType: "json", jsonp: false})

However, earlier versions of jQuery 1.x did not support the “jsonp” setting, so we had to filter every URL passed to $.ajax to strip anything that would trigger JSON to JSONP promotion (specifically anything matching the regex /\=\?(&|$)/). You can see the full fix for older jQuery versions here:

So, if you’re using jQuery and letting users provide AJAX URLs, make sure you’re up to date and passing the “jsonp: false” setting through!

Drupal released a fix in SA-CORE-2020-007 (CVE-2020-13666), which you can read about here: Thanks to everyone who helped fix this bug!