The why's and how's of reverse-engineering my gym's app
February 12, 2024
If you don’t want to read the entire prelude, you can skip right into the TL;DR.
I’ve been going to the gym on a very off-and-on basis. Sometimes I have longer streaks, other times I only end up going a couple of days before beginning a (typically laziness-fueled) hiatus. Last Friday was one of those days I said “yeah, I should really exercise more” and actually went to the gym. It had been a few weeks since I last set foot there and my brain wasn’t giving me the faintest of warnings about the absolute horror soon to ensue.
I went looking for the gym app and couldn’t find it. Odd, I thought, but then I remembered the trimming I had done some days ago to my phone’s app list. I must have yeeted it in the process. Oh well, it ain’t a big deal, I’ll just reinstall it from
the Play Aurora Store…
Wait, what?? It takes up 100MB??
Welp, I can’t get in any other way so…
The app finished downloading on the slow gym WiFi (won’t waste 100MB of my already limited data plan) and it was time to block the most tracking 💩I could. After a bit of faffing around with AppManager and XPrivacyLua, I managed to get to a working state with few permissions (the app would just immediately crash without some key things).
I clicked the big green button to show the login form and patiently waited for BitWarden’s credentials auto-fill pop-up to, well, pop up. It was being stubborn, but it sometimes does (OpenBoard still doesn’t integrate those), so I wasn’t yet concerned. However, having tried some of the common “tricks” for making it appear, I realized what’s going on: this app isn’t properly integrated with modern Android features.
Again, I shrugged it off, though slightly annoyed this time, and proceeded with the fallback strategy of copying the password from BitWarden and pasting it onto the form. Man, I tried everything. I restarted the phone, and even unblocked everything barely having anything to do with clipboard. Nothing worked.
Over 10 minutes into this whole ordeal, and with blood pressure levels bordering on the unhealthy, I just copied the 32 long mess of letters, numbers and symbols by hand.
Phiew, glad this is over and I can finally enter the gym.
You may not.
Before you can proceed to your healthy dose of daily exercise, you still need to fight off a lagfest of custom menus and views to render a single, low ECL QR code.
My gym changed apps twice in 2023, providing a worse experience each time. Huge app size, poor performance, awful integration with the system (no auto-fill, no paste, etc). They also trashed their website. All I want is a small thing to get me the QR codes to enter.
# This is where the fun begins
Fueled by this profound experience, I decided to spend my afternoon (after a good post-gym shower) developing a more minimalistic way to get these wretched QR codes.
Having thought of doing such a reverse-engineering project multiple times before, yet accomplishing none, I already had an idea of the tools needed.
First, I would have to figure out which network calls the official app did to get the codes. Since we’re in the year of 2024, I was pretty confident these calls were to REST API, so I started by installing HTTP Toolkit, a neat tool to sniff HTTP requests from multiple sources, including Android devices, much like the
Network tab in modern browser dev-tools.
It works by connecting itself to the device through ADB and installing a VPN-like app that intercepts and logs all traffic. To understand HTTPS traffic, it also installs a custom CA certificate (should be automatic, but it may require manual intervention).
Since this is finicky and fairly dodgy in terms of security, naturally I wanted to avoid having to run it directly on my phone. There are plenty of Android emulator packages out there, but Waydroid seemed the easiest to set up on my system,1 just a couple of packages away. After those are setup, you do
waydroid session start to boot up the virtual device and then
waydroid show-full-ui to get the graphical interface (which runs very smoothly, I must say).
I simply couldn’t get the app to work there. I tried other images, like the one with GApps, to no avail. Since this was supposed to be just an afternoon project I bit the bullet and plugged my phone to the computer. Everything worked flawlessly.
With the setup all done, I had to sniff some HTTP requests and figure out what the heck was going on with that app.
# It do be sniffin’ time!
The first thing I tried was regenerating the QR code. Tapping the button a few times, I got these requests:
So the app is calling the
/api/v2.0/exerp/qr endpoint which returns something like:
Which seems to follow the format:
exerp:checkin:<facility id>p<user id>-<timestamp>-<random hex string (maybe some hash?)>
Encoding that as a low error correction level QR code with a little margin seems to yield nearly identical codes to the ones the app displays. Great!
Now, what about that bearer token? Where does it come from?
I shifted my attention to the app’s starting sequence, which had a bucket load of requests, for tracking and fetching other gym information. In the midst of all that spam, there’s an important request:
/api/email/refresh looks like a typical token refresh request, where one sends the server a refresh token and it replies with a new access-refresh token pair, with an additional expiration time, a very popular security measure in many online systems still.
But where do those tokes come from?
To observe a true session start, I logged off my account and logged back in. Sure enough, some new faces appeared. Let’s look at this one first:
It seems like the
/api/v2.0/pt/exerp/newAuth endpoint is responsible for authenticating the user and providing the initial access-refresh token pair. You give it a URL-encoded form with your e-mail address, password and an access token. Another one?
Yep, seems like this isn’t quite the end of the road yet, we must tread just a little further.
The other “new face” was this
It seems you give it a client secret + ID combo and it returns a temporary access token you can use in the
At this point, I felt I had gathered enough information to make a quick prototype in Python, so I opened my editor and started typing. After a few inevitable bug fixes, I managed to get a very simple server giving me valid looking QR codes, based on my hardcoded credentials.
However, two things lingered:
- Where did those magic client numbers come from? Are they always the same?
- Does this actually work on the live gym system? The codes seem valid, but are they really?
Regarding the first one, I tried the script a bunch and it never failed, but to be more sure that it was going to work, I got a second phone, prepped it up with the gym app and HTTP Toolkit and got sniffing again. As I suspected, these values seem to be the same for every installation, they are probably hardcoded into the APK. Not sure how often they get changed, but they’re pretty easy to get again either way.
As for the second point, I’m writing this on a Sunday and I’ve yet to try it on one of their facilities, but I’ll update the post with the result (and possible amendments) once I do try.
Hopefully it works! 🤞
# Closing remarks
The code for this server is available in sourcehut,2 and available in the public domain. It’s not polished, it’s not properly written. It was an afternoon hack and I don’t plan on improving it, but you’re free to make a better version of it, or develop a little app with this reverse-engineered information.
I hope this was entertaining, insightful or otherwise not a waste of your time. If it inspired you to rebel against the shitty mega-app trend and do some poking around of your own, even better! Drop me an e-mail 😉
# Note for anyone caring about the blog
First, I somewhat doubt you exist.
I will try to post more regularly, about stuff I’m working on, little “hacker” bits like these and whatnot.
For real this time.
Intel+Nvidia Laptop with Arch-based Linux.
Beware it is in Portuguese.