Reversing a Shitty University Student Portal API


This is something that I have had on my to-do list for the longest time. So, finally I’m doing it. I’m going to be reversing the RESTful API of my university’s student portal.

You may be asking why, and the answer is simple. The interface of the damn thing looks like it was designed in the early 2000s1, and I’m curious what in the ever-loving hell their doing with the backend.

As I’m writing this after already reversing it, I’ll give a little trigger warning: horrible spelling. Like actually HORRIBLE. Have fun :)

How The Hell Do We Start

To reverse an API we need to get the sent requests and the gotten responses. I decided to use mitmproxy to capture packets, and mitmproxy2swagger to generate OpenAPI spec from the captured packets. Of course, with everything, there are simpler options. One example would be by just using the browser DevTools. However, I hope you understand why I decided not to do that.

After setting up mitmproxy, I went around the website, which is uni.tsu.ge, trying to get every possible API call I could. At the end, I ended up with 41 API routes and that they all are located at /api.

Now, it’s time to look around. I’m not going to cover every single route here, as that would take too long, but there here’s an OpenAPI YAML file you can check out. The routes here are what I found to be the most “interesting” out of the bunch. So, without further ado, Let’s start at the obvious.

The Login Route

Here’s the amazing login page to the student portal:

Login page for the TSU student portal

This route is one of the MOST IMPORTANT route in this API. It’s located at the /Auth/Login, is a POST route and has a very basic request JSON

{
  "Username": "01230123012", // Personal ID
  "Password": "password",    // Plain-text password
  "LanguageId": 1            // Language for response
}

As far as I have tested it, they force HTTPS via HSTS, which helps not exposing the plain-text password to anyone other than the server. However, how the passwords are saved in the database is a mystery. So, let’s hope they are hashing them.

There might be another thing you might have noticed. On the login form there is a “KEEP ME LOGGED IN” toggle. This is a standard practice and there ain’t any problems with it. However, as far as I can tell it does nothing.

The LanguageId – which will be referenced in quite a lot of places – is basically just an enumeration that effects the response. 1 implies a Georgian response, and 2 English. There is also Russian, but I don’t think they ever worked on it. As if you ever try to use it, it will always return null values.

Now, it’s the time for my favorite part. The response of this route:

{
  "id": 0,
  "username": "string",
  "password": null,
  "currentPassword": null,
  "firstName": "string",
  "lastName": "string",
  "token": "string",
  "userType": {},
  "userId": {},
  "email": {},
  "student": {
    "studentId": 0,
    "personId": 0,
    "statusId": 0,
    "fName": "string",
    "sName": "string",
    "primaryStudyProgramId": 0,
    "primaryStudyProgramGroupId": 0,
    "primarySpecializationId": {},
    "secondaryStudyProgramID": 0,
    "studyProgramCredit": 0,
    "studyLanguageId": 0,
    "facultyId": 0,
    "studyLevelId": 0,
    "profileImageFileStream": "string",
    "semesterNumber": 0,
    "studentCardSelecteds": [
      {
        "id": 0,
        "studId": 0,
        "syllabusId": {},
        "courseId": {},
        "studentSemesterNumber": {},
        "credit": 0,
        "sumMark": 0,
        "enrollStatusId": 0,
        "enrolledFromId": {},
        "subjectId": 0,
        "sessionId": 0,
        "studyYearId": 0,
        "studySemesterId": 0,
        "enableImprove": 0,
        "createDate": {}
      }
    ],
    "finStatusEnabled": 0,
    "financeCredits": {
      "studyYearId": 0,
      "studySemesterId": 0,
      "credit": 0
    },
    "enabledCredits": {},
    "programCoreSemester": 0,
    "subjects": [
      {
        "subjectId": 0,
        "syllabusId": 0,
        "credit": 0,
        "score": 0,
        "status": 0,
        "studyYearId": 0,
        "studySemesterId": 0,
        "enableImprove": 0
      }
    ],
    "subjectsImpr": [
      {
        "subjectId": 0,
        "syllabusId": 0,
        "credit": 0,
        "score": 0,
        "status": 0,
        "studyYearId": 0,
        "studySemesterId": 0,
        "enableImprove": 0
      }
    ],
    "syllabi": [
      {
        "subjectId": 0,
        "syllabusId": 0,
        "credit": 0,
        "score": 0,
        "status": 0,
        "studyYearId": 0,
        "studySemesterId": 0,
        "enableImprove": 0
      }
    ],
    "sumCredits": 0,
    "sumCreditsImprove": 0,
    "avgMark": 0,
    "gpa": 0,
    "moduleCredits": 0
  },
  "lecturer": {},
  "adminUser": {},
  "userNameNotExists": 0,
  "oneTimeCodeNotExists": {}
}

This fucking thing is a god-damn roller coaster. Just the SIZE of this thing is crazy. However, the CONTENT of this response is JUST GODLY. I mean, just look at password and currentPassword. What are they for? I don’t fucking know. They are always returned as null and would be completely useless if used. Like, WHAT WOULD BE THE DAMN DIFFERENCE BETWEEN THEM??

To give you the “in-use” size of this thing, the response that gets returned to me is MORE THAN 14 KB IN SIZE. There can’t be anything worse than that, right..? (obvious foreshadowing)

The biggest offender in this response – in my opinion – seems to be the student object. That object has NO NECESSARY information needed for the “main page”. However, that’s not the only problem. Look at firstName and secondName. These two are normal to return, BUT now look inside the student object. There’s fName and sName… Like, WHAT’S THE POINT OF HAVING BOTH OF THEM!?

The only thing in the student object that is in any way useful is the profileImageFileStream field, which gives the UUID of the student’s profile picture. Nothing else in this pile of garbage is ever even used on the main page.

So, what do you think is happening? Because I believe that when you log in to your profile, something like the following is executed

SELECT * FROM STUDENTS WHERE ID = WHATEVER_THE_ID_IS

Maybe an inner join somewhere in there, but you get the point. This – for anyone interested in back-end development – is a terrible practice. NEVER EVEN CONSIDER DOING THIS. Unless it’s for debugging. If you debug databases for a living.

In addition to this, the route also returns a .AspNetCore.Identity.Application cookie. This cookie is used to keep you logged in, and it expires either if the account is accessed from another device or browser, or if the session cookie expires.

The Getting The Classrooms Route

So, do you remember when I said that response sizes couldn’t get worse? Yea, this is the point where that changes. The route is located at the very correctly spelled /Sheared/GetClassRooms, and is – for some reason – a POST route. Even though this is a POST route, it does not require any JSON.

So, you might be wondering how big this response is. IT’S MORE THAN 400 KB. What’s the response? A 2532 element array of JSON objects.

[
  {
    "id": 0,
    "parentid": null,
    "typeId": 0,
    "text": "string",
    "typeCaption": "string",
    "value": 0,
    "icon": "string",
    "seatPlace": 0
  }
]

If the parentid – the lower-case i is not my fault – didn’t give it away, this is some kind of collection of linked lists. Why a linked list? The reason – as I can see – is that this list saves not only the classrooms but also the locations where the classrooms are. So, you would have a classroom with id of 5 and parentid of, say, 4 which will be the building it’s in.

A portion of the GetClassRooms response

Now that is not the worst way of handling this, but there’s one problem. THE IDS ARE NOT THE ARRAY INDEXES. The array isn’t even sorted by the IDs. So, every time you want to traverse through the “linked list”, you have to scan the WHOLE ARRAY OF 2532 OBJECTS to find each parent. Unless they came up with some next-level searching algorithm, which I HEAVILY DOUBT.

To add on top of this, the fields id and value – as shown above – have THE SAME VALUE. ALWAYS. Also, note the use of an incorrect quotation marks in the first object’s text field.

Another part of the response showing an inconsistency

Sometimes, the fields don’t even have consistency. As seen in the above images, the seatPlace value is a number value, that for some reason, sometimes is given the null value.

The GetSex

I have no words for this. There is a route literally called /Sheared/GetSex. It’s a POST route – BIG SURPRISE – and has no request body – BIG SURPRISE. What does it return? This:

[
    {
        "id": 0,
        "caption": "Fimale"
    },
    {
        "id": 1,
        "caption": "Male"
    },
    {
        "id": 2,
        "caption": "Not defined"
    }
]

Why? I don’t know. Like… Why would you ever do this? What does this accomplish that couldn’t be done IN THE CODEBASE WITHOUT THE USE OF AN API? The “Fimale” is just a cherry on top. Also, calling intersex “Not defined” is just great.

There is also a sister route called /Sheared/GetLanguage, which like GetSex, returns an array that could have just been implemented in code.

[
    {
        "id": 1,
        "caption": "ქართული"
    },
    {
        "id": 2,
        "caption": "English"
    },
    {
        "id": 3,
        "caption": "Русский"
    },
    {
        "id": 4,
        "caption": "Deutsch"
    },
    {
        "id": 5,
        "caption": "Français"
    }
]

There’s also more. But, I honestly can’t be asked to discuss them. Just looking at it makes me die inside a little more.

Now, these type of API endpoints aren’t absolutely ridiculous. They actually could have genuine uses. Like, if they want to scale to much more languages (which I highly doubt). Maybe if they are planning to make a mobile app and want consistency. But the problem is they 100% ARE NOT. So, as far as I’m concerned this is dumb as hell, and should be burnt with hellfire like it’s supposed to.

The Very Useful Student Profile

Going into your “student’s card”, a POST request – another surprise – is made to a route called /Card/StudentProfile with a LanguageId, but without JSON, just the number. Its response might give you a slight déjà vu

{
  "studentProfile": [
    {
      "id": 0,
      "courseId": {},
      "subjectId": 0,
      "syllabusId": {},
      "studyYearId": 0,
      "studySemesterId": 0,
      "studentId": 0,
      "semesterNumber": 0,
      "lecturerList": {},
      "credit": 0,
      "subjectName": "string",
      "subjectNameEng": "string",
      "subjectType": "string",
      "subjectTypeId": 0,
      "yearName": "string",
      "semName": "string",
      "yearSemName": "string",
      "enrollStatus": "string",
      "enrollStatusId": 0,
      "subjectFrom": {},
      "sumMark": 0,
      "isAvtive": 0,
      "gpa": 0,
      "gradeSimbole": "string",
      "subjectStatus": "string",
      "subjectStatusId": 0,
      "oldCaption": {},
      "comment": {},
      "lecturerName": {},
      "improve": 0,
      "thesesDefault": {},
      "thesesEng": {},
      "changeSubject": {}
    }
  ],
  "student": {
    "studId": 0,
    "fName": null,
    "sName": null,
    "fNameEn": null,
    "sNameEn": null,
    "persNumber": null,
    "passportNumber": null,
    "sex": null,
    "sexId": 0,
    "fakulty": null,
    "studyLevel": null,
    "studyLevelId": 0,
    "studyProgram": null,
    "programId": 0,
    "programGroupId": 0,
    "specialization": null,
    "specializationId": null,
    "facultyId": 0,
    "studyYear": null,
    "studyYearId": 0,
    "studySemester": null,
    "studySemesterId": 0,
    "semesterPayment": null,
    "creditPayment": null,
    "complateYear": null,
    "naecReiting": null,
    "studentCode": null,
    "birthDay": null,
    "mobile": null,
    "email": null,
    "semester": 0,
    "programCoreSemester": 0,
    "studyStatus": null,
    "avgMark": 99.9,
    "gpa": 3.999,
    "gpaDanarti": 3.999,
    "sumCredits": 239.9,
    "personId": null,
    "fileId": null,
    "debt": null,
    "grant": null,
    "socialGrant": null,
    "ufasoGrant": null,
    "otherGrant": null,
    "adminRegPrecondition": false,
    "adminRegManualAllowed": false,
    "akadRegistrationPassed": false,
    "enterYear": null
  }
}

If you’re curious, almost everything in the student object is returned as null, 0, or false values. Only avgMark, gpa, gpaDanarti (“danarti” is a Georgian word, FYI), and sumCredits fields have logical and useful values. I don’t even know how they managed to do this, or why.

The only thing this route is useful for is getting the studentProfile array, which has all the subjects/classes a student has taken or is currently taking. It also makes the student object obsolete, as you can just calculate all the fields it has from studentProfile.

ufasoGrant being my favorite, as it’s a combination of an easily translatable word – “ufaso” translates into “free” – and “grant” which is repeated 4 times in the object. Why not translate it, or why not create an object called grant where they would be more organized, I don’t know. What even is a “free” grant, and how is it different from just “grant”?

There is also some more tiny deficiencies, such as:

  • birthDay, I guess “birthday” is two separate words;
  • naecReiting, “Reiting”;
  • complateYear, “complate”;
  • fakulty, really?
  • persNumber and personId being different things;
  • isAvtive; and
  • gradeSimbole, “Simbole” instead of symbol.

The Professor’s Portal

As I don’t have access to it, I can’t say much. However, I know that it exists, and that it has a different domain, uni-acad.tsu.ge. The login page has nothing different on the surface, but it has one massive advantage over the student’s one. It has SMS 2FA. I don’t know why they keep this lector-only, but I digress.

The main interface, of course, has a lot of different things. But, from all, one stands out the most. A slider for text size. A feature which seems logical to be included in both portals, but I guess student accessibility is not as important.

Reaching a Conclusion

Now is this the worst ever API in existence? No, it’s not. But is it bad? Yea. The amount of abysmal misspellings and the amount of bloat is ridiculous. It gives of “I don’t care how it looks, just make it work” kind of vibes. If this was a hobby project, fair. But, it clearly isn’t.

As a university that teaches computer science, this shows nothing but inadequacy in the basic principles of software engineering. Like what am I, as a student, supposed to think after seeing this. If this is the standard we are going for, we ain’t making it far.

It’s even worse that I have to learn “software engineering” and “project management” from this university, while also knowing this monstrosity exists. But, honestly I don’t even care at this point. So, I have decided to, at least, use whatever the hell this is and create a different frontend for it. If I succeed you will hopefully see another post here. But for now, I’ll go play Thresh mid, as I have nothing better to do.

I also want to add that this isn’t ALL the API. There’s still more “fun” parts to be explored. Too bad I have lost too many brain cells and can’t be asked to talk about them. If you want to check them out you can here, just plug the YAML into OpenAPI’s online editor and have fun. But, as a heads-up, I’m too lazy to document absolutely everything. So, if you find anything that you can’t fully understand. Too bad.


Wake me up before I change again
Remind me the story that I won’t get insane
Tell me why it’s always the same
Explain me the reason why I’m so much in pain

Becoming Insane — Infected Mushroom


  1. As a fun fact, I’m talking about the “new” version that they put out, like a year ago. The previous version was worse, but honestly, the newer almost has no improvements. ↩︎