Reversing a Shitty University Student Portal API
2024-11-30 09:37 +0000
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:
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.
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.
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
andpersonId
being different things;isAvtive
; andgradeSimbole
, “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 painBecoming Insane — Infected Mushroom
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. ↩︎