Jerome Paulos

Winning a hackathon, losing my sanity

A journey through crumbling-edge software

A few weeks ago, my friend Ben and I won JumboHack, a hackathon at Tufts University. Our entry generates a Spotify Wrapped-like summary of students’ dining habits by scraping Tufts’ meal plan portal. Through some clever promotion on Ben’s part, we managed to get hundreds of students to use it in just a couple of days. We won the general track, ”most complete project,” and the whole shebang.

You can try a live demo of our project here.

Ben and I during judging
Ben and I during judging

Tufts, like Brown and many other colleges, uses a piece of software (protected under an expired patent that’s older than both of us) from a company called Atrium. Their marketing claims, in contrast to “legacy” systems, that it will “delight” users. As we will see, the irony of claiming Atrium’s software isn’t “legacy” is just too good.

Tufts calls their installation “JumboCash” and it can be accessed at (Don’t forget the www.—it won’t resolve without it.) However, the site that is ultimately loaded is determined by the cid (campus/college/customer ID?) parameter. Here’s Brown’s portal loaded through Tufts’ domain: You can even use the raw IP: in all its http:// glory.

Bonus web development crime: Just as I've done here, the header of Tufts' JumboCash portal is hotlinked from the site of a long-deceased student organization
Bonus web development crime: Just as I've done here, the header of Tufts' JumboCash portal is hotlinked from the site of a long-deceased student organization

The portal is really a wrapper around the reanimated corpse of much older software, its rotting flesh visible through nonsensical decisions and the occasional XML response.

The first thing Ben and I noticed when logging into the JumboCash portal is that the session key is stored as a URL parameter, just like the cid.

While a cookie is set, it is ignored. You can copy the URL into a private browsing window (or change the domain to another school’s) and it will work just fine; remove the skey parameter and it won’t. Change to another IP address, and it…breaks? Clearly some attempt at security was made, and it feels a bit too complex to be the result of purely ignorance.

Guest access

Because we can’t instruct students to copy-and-paste the URL (and thus their session key), we needed another way to access students’ transaction history. We turned our attention to JumboCash’s guest access feature.

Presumably intended for parents to reload their children’s accounts, it allows students to given an email address access and choose from a deceptively fine-grained set of permissions. I say “deceptively” because guests can change their own permissions and even add other guests. “Disabling” a guest’s access doesn’t actually restrict any features, and guests can re-enable themselves.

Adding a new guest sends them an email with a password. We purchased the excellent domain and had students add [email protected] to their accounts. We intercepted the emails with Postmark and pulled out the password, running a job to log in and scrape the requisite information.

The email from JumboCash is pretty much the same
The email from JumboCash is pretty much the same

When it came to scraping the transaction history itself, we finally caught a small break: the portal allowed CSV export, making our job a bit easier. However, an HTML table, which is essentially XML, wouldn’t have been much trouble.

The final hurdle

At the time, we decided adding a guest to Atrium’s “delightful” portal was already enough friction and decided not to ask students for their email. In hindsight, we should have collected them at the beginning and given students a custom email address to keep things straight.

To achieve this ✨magical✨ experience, we needed to figure out students’ email addresses from their name, since that’s all the portal seems to have. After an unsuccessful attempt at scraping Tufts’ Outlook directory, we took a closer look at the public directory, which includes the names and emails of every student. Popping the hood, it makes a nice little request to a JSON API, which appears to be a wrapper around one or more LDAP directories. But unfortunately, it’s not allowed to be that easy.

The JumboCash portal displays students’ legal first and last names (e.g. Benjamin not Ben). The directory, on the other hand, can only be searched by students’ preferred names. Though not displayed in the interface, its API does return their legal names in Lastname, Firstname M. format.

Our first idea was to search the directory by last name and lazily use the PHP standard library’s levenshtein() function to find the closest match. However, this doesn’t work well with common or short last names—for example, “Li” is not only common but is included in lots of names like Julian and Colin and returns dozens of matches. Since the directory’s pagination was broken (while I have a theory as to why, it looks like it’s already been fixed), we couldn’t page through to find an exact match. Our gross solution was to try a bunch of formats: Lastname, F. Lastname, etc. and pick the closest name. This works…enough of the time.

Logging in

Actually logging into JumboCash required two HTTP requests and four hours of reverse engineering. I spent so long on it that Ben, who had already scraped JumboCash for a previous project, started working on a Puppeteer solution in parallel.

But first, here’s how authentication actually works. A POST request is sent to /login.php with a username and loginphrase, which creates a session key. Then, second, a GET request to the same page “activates” the token. Failing to do this second request puts the UI into a weird “half-logged-in” state.

The first request returns, instead of a Location header, a snippet of JavaScript (<script type='JavaScript'> to be exact) to rediect to another page. The second page (also /login.php, just with some URL parameters) contains a loader and some nasty JavaScript.

<!-- loader -->
<div class="loader">
    <div class="inner one"></div>
    <div class="inner two"></div>
    <div class="inner three"></div>
<!-- your message could go here -->
The spinner, unceremoniously copy-pasted from some tutorial

While abnormal, this dance wouldn’t have taken the hours it did had one of our tools not lied to us. When using HTTPie to poke around the site, it would render the HTML returned by the first POST request, including executing the JavaScript it contained. However, while HTTPie’s webview still made the request (as a normal browser, without any of our headers), it didn’t show that it had.

We were baffled as to why session keys generated with requests from HTTPie worked, but those using even the cURL code it generated did not. As it turned out, HTTPie was secretly making the second “activation” request. HTTPie’s webview should probably not execute JavaScript, especially without showing the result. (It would also be nice if the webview stayed disabled instead of turning itself back on after every request.)

The JavaScript on the intermediary “loading” screen very responsibly checks if the browser supports the newfangled XMLHttpRequest API before polling an XML API for the status of the skey. While the concatenation in this script (included below) is out of control, I was luckily unable to find an XSS. That would have been quite fun, given the number of .edu domains this software runs on.

Keep scrolling! There’re goodies after, I promise!

var isIE = false;
var req;
var messageHash = -1;
var targetId = -1;
var centerCell;
var size=40;
var increment = 100/size;
var attempts=0;

function pollTaskmaster() {
    var url = "login-check.php?cid=248&skey=3620efdb97b88d111e8ec5388244e978"; 

function validate(url) {
    if (window.XMLHttpRequest) {
        req = new XMLHttpRequest();
    } else if (window.ActiveXObject) {
        isIE = true;
        req = new ActiveXObject("Microsoft.XMLHTTP");
    }"GET", url, true);
    req.onreadystatechange = processPollRequest;

function processPollRequest() {
    if (req.readyState == 4) {
        if (req.status == 200) {
            var message = req.responseXML.getElementsByTagName("message")[0];
            if (!message) {
            var remotestatus = message.childNodes[0].nodeValue;
            if (remotestatus == 1 )
            if (remotestatus == -1 )
        } else {
            window.status = "No Update ";
        window.status = "Processing request...";    
        setTimeout("pollTaskmaster()", 5000);

function gotobadpage() {
setTimeout("pollTaskmaster()", 2000);
setTimeout("gotobadpage()", 300000);

Winning JumboHack

Our site is responsive...but not that responsive
Our site is responsive...but not that responsive

With all the reverse-engineering out of the way, we had a lot of fun designing the actual product. We went for a vivid and bold design, similar to Spotify Wrapped. Given how much time we spent on everything else (and a solid 8 hours of sleep!), I think it turned out alright.

The app&#x27;s interface behaved like Instagram Stories
The app's interface behaved like Instagram Stories

We put posters up around campus and, as Ben details in his post, made a few anonymous posts on Sidechat pretending to be users. They went (relatively) viral, and because we’d conspicuously included the URL on every slide, hundreds of students flocked to try it for themselves.

Overall, JumboHack was a lot of fun! Taking the train down from Providence (for just 10 bucks!), hanging out with Ben, and taking home some AirPods was a great way to spend my Presidents’ Day weekend.

A poster a few weeks later, photo courtesy of Ben
A poster a few weeks later, photo courtesy of Ben