A reverse engineering adventure
· 2,100 words · 11 minutes reading time
Oh sweet New Year's resolutions! I'm not really a fan of them since I was convinced by CGP Grey's themes some years ago, but the truth is that I just restarted to go to the gym after a break of a couple of months. As I always say, I'm in the group of people that pays for a gym membership as an excuse to say that I'm trying.
Anyway, I'm trying to go regularly now and one thing that annoys me every time that I go to the gym is how to access it. I'm a member of PureGym and, as it happens with other franchises, you have to enter or scan a code at the doors. In my case I can either enter an 8-number PIN or scan a QR from their app. I'm particularly bad at remembering numbers and the small size of their numpad makes it likely for me to make a mistake so I prefer to use the QR code.
If it wasn't because the app takes ages to load and then I'm still one click and one page away from the QR code screen, I would have no complaints. Of course, that doesn't count when the app logs me out and I have to remember the email and the 8-number PIN in order to log in again. Yes, the access code at the door is also the account password, crazy, right?
So why are you so sad? Take a screenshot of the QR and use it till the end of the world. That was my approach until I found out that the QR changes every time I open the app and it expires after a few days (most likely before the next time I go to the gym).
Seriously, what's the point of having a static 8-number PIN but having a dynamic QR for accessing? If I wanted to share my membership it would still be easy. Just make the QR static as well and go a step further and do as Tesco did with their club cards so I can easily add it to Google Wallet. I don't need yet another app on my phone for this.
So the weekend comes and I feel cosy at home. Let's see if I can figure out something.
What's the content of the QR?
If my goal is to understand how their QR codes work, the first step is taking a look at their content. Let's take a screenshot and check in the computer with ZBar:
zbarimg qr.jpg
QR-Code:exerp:checkin:**********-1694277330202-********************************
scanned 1 barcode symbols from 1 images in 0.02 seconds
I'll cover my back by hiding some details from the content with asterisks but basically the QR is composed of an static id, followed by a timestamp when the QR was generated and a dynamic hexadecimal code. I couldn't figure out anything from the dynamic part so it must be just a hash which is validated in their backend.
I had some hopes of finding a way to generate these QR codes without further effort but why would you bother to make them dynamic if you don't need to make a call to an API. It's time to do some real reverse engineering.
What does their website look like?
I always start my reverse engineering journeys by taking a look at the website version if it exists. Sometimes apps and websites share a common API. Even when they took care of protecting their forms with CAPTCHAs, I can still learn things from the network calls in the browser. And who knows, maybe they didn't take care of that, we all know that tech debt is a thing...
Anyway, on this occasion I run out of luck quickly as they simply serve a static card in the page recommending to download their app:
Am I alone in this world?
Sometimes, you just need to take a step back and think that maybe someone else followed the same trail before. And maybe they succeeded, so you can just take the highway instead of going through the jungle.
One of the first results in DuckDuckGo obtained from searching puregym api
is this GitHub repo called puregym-api
. Yes, it doesn't have much and the swagger definition is a mix between PureGym's API and a demo API for a pet shop (?). But hey there is a base url (https://capi.puregym.com/api/v2
) and there is a GET endpoint (/member/qrcode
) that seems related. And the documented response example matches with the QR code content that I got before:
{
"QrCode": "exerp:checkin:{gymId}p000000-0000000000000-0123456789abcdef0123456789abcdef",
"RefreshAt": "2022-05-06T12:23:00.5678999Z",
"ExpiresAt": "2022-05-13T12:17:00.5678999Z",
"RefreshIn": "0:01:00",
"ExpiresIn": "167:55:00"
}
Unfortunately there is nothing documented about the authentication process. In a different repo, puregym-attendance, I find again the same base url as before and this time there is some kind of authentication logic there! One thing is different, the API version that they use is older than the one documented in the previous repo. Let's try:
curl -X POST \
-H "Content-Type: application/x-www-form-urlencoded" \
-H "User-Agent: PureGym/1523 CFNetwork/1312 Darwin/21.0.0" \
-d "grant_type=password&username=<EMAIL>&password=<PIN>&scope=pgcapi&client_id=ro.client" \
https://auth.puregym.com/connect/token
{"access_token":"...","expires_in":5184000,"token_type":"Bearer","scope":"pgcapi"}
Ok, looks promising, let's use the access_token
in the authorization header for the QR code endpoint:
curl -X GET \
-H "Authorization: Bearer <access_token>" \
https://capi.puregym.com/api/v2/member/qrcode
{"QrCode":"exerp:checkin:********-*************-********************************","RefreshAt":"2024-01-15T15:48:42.1212574Z","ExpiresAt":"2024-01-22T15:42:42.1212574Z","RefreshIn":"0:01:00","ExpiresIn":"167:55:00"}
Wow, it works! I can call it a day now and finish this post. However, I'll continue as this wouldn't have worked. I hope I made this work before putting some effort in what will become the next section, but I encoded the payload in the POST request as a JSON body and, of course, I got an error from the auth service. I assumed the repo was outdated as the last commit was from 2 years ago and I saw a closed issue where the problem was that the authentication stopped working.
But those are just excuses. It was my bad for suspecting that the problem was not between the chair and the keyboard. Also, this post is all about a learning process so it is still valid to go deeper and do what I suspect the authors of those repos did.
The man in the middle
It's time to check how the app works. At this point there are 2 options, either I try to decompile the app so I can directly check the logic in it or I can try to analyse the traffic that it generates and try to make some sense of it. I'll see if I'm lucky with the second option, as the app looks like a UI façade of their API.
You can expect that the traffic will be encrypted. The calls to the API will be sent through HTTPS, so just eavesdropping the traffic will give me nothing. The idea is to trick the app into thinking that it is talking directly to the API when in reality it will be communicating with a proxy. The proxy will simply replay the requests to the API impersonating the app and sending the response back to the app impersonating the API. All of this while keeping secure channels between all parties as it is expected by both the app and the API.
Of course, the TLS protocol used in HTTPS prevents this from happening in a normal environment. As part of the connection handshake between server and client, the server provides to the client a certificate with its public encryption key. This certificate is signed by a certificate authority (CA) and the client verifies that everything is in order before following with the communication. The original certificate can't be tampered, so the proxy will need a certificate of its own. Of course, this certificate won't be signed by any CA as the whole protocol relies on trusting that these 3rd parties will only issue certificates to the actual owners. That means that the app will need to think that the proxy is a CA and its self-signed certificate is legit.
How will the app trust the proxy? By default, Android apps rely on the operating system for that. I would just need to add the proxy as a CA in Android. But it could also happen that the app was originally bundled with information about the API's certificate, meaning that no matter what the OS claims, the app won't trust the proxy's certificate. This is called certificate pinning, and if PureGym is doing this, I will be done with this section and maybe with the whole quest.
For now let's have some hope. First, I will install an Android emulator on my computer. For that I will use Genymotion, an old companion from a time when I was into the Android development world. For sure, there might be better options like the emulator from Android Studio, but Genymotion's personal-use license will work fine for this. And once I'm done, I'll simply trash everything.
Regarding the proxy, I'll go with mitmproxy and its TUI. It is a robust open source product which takes care of mostly everything that I mentioned before. I only need to add the CA certificate to the emulator, a step which is becoming more and more complex with the newer versions of Android, but I'm not entering into details here.
For installing the CA certificate, mitmproxy recommends using Magisk, a suite of programs that allows modifying read-only partitions (like the one storing the CA certificates) through modules and provides root access for apps. I found this guide from Genymotion's support page easier to follow than mitmproxy's documentation about how to install Magisk in the emulator. By following it, I just end up dragging and dropping some files into the emulator and Magisk gets installed.
Let's now install mitmproxy. For that, I'll rely on their official docker image. As easy as executing docker run --rm -it -v ./data:/home/mitmproxy/.mitmproxy -p 8080:8080 mitmproxy/mitmproxy
, where the ./data
directory will be used for storing the proxy's certificates and port 8080
will expose the proxy server. If the official image is not available for your platform, you can always follow these instructions for building it locally.
After launching the container, the TUI is shown in the terminal and I can find a zip file called mitmproxy-magisk-module.zip
in ./data
. I drag and drop it into the emulator and install the module using Magisk. Last step, I set up the proxy through Android's network configuration, providing the IP of my computer and the proxy's port (8080
).
Let's now open the PureGym app in the emulator. Eureka! I can see the unencrypted traffic in the TUI.
The aftermath
The API endpoints ended up being pretty much what was described in the repos that I discussed earlier in the post. In order to get the QR code's content, first I need to get the authentication token with the following POST request:
curl -X POST \
-H "Content-Type: application/x-www-form-urlencoded" \
-H "Authorization: Basic cm8uY2xpZW50Og==" \
-d "grant_type=password&username=<EMAIL>&password=<PIN>&scope=pgcapi offline_access" \
https://auth.puregym.com/connect/token
{"access_token":"...","expires_in":5184000,"token_type":"Bearer","refresh_token":"...","scope":"offline_access pgcapi"}
And then call the GET /member/qrcode
endpoint as follows:
curl -X GET \
-H "Authorization: Bearer <access_token>" \
https://capi.puregym.com/api/v2/member/qrcode
{"QrCode":"exerp:checkin:********-*************-********************************","RefreshAt":"2024-01-15T16:42:28.1847852Z","ExpiresAt":"2024-01-22T16:36:28.1847852Z","RefreshIn":"0:01:00","ExpiresIn":"167:55:00"}
Now that I have this knowledge about their API, I'm thinking of using it for a personal Telegram bot, hoping that it will give me a QR faster than the app. Maybe I can even find a way to get the QR sent to me if I get close to the gym based on my location. But all of that might come in a future post.