← Shayon

My gym forced a social media down my throat. I reverse-engineered its app.

Shayon 5 min read

I recently joined a new gym. It’s a pretty good gym, with nice equipment, nice management, nice branding.

And an app.

Now, it’s not some random shitty vibe-coded app. It’s actually pretty good as a product. It has pretty slick UI/UX and a well-thought-out, consistent design language. I found only one bug in it.

For me, though, the only use case is to show a QR code at the turnstile for checking in and out.

However, there is a problem. The home screen of the app is a social feed. Once you check out, it prompts you to post a story of your session. The home feed is full of people’s gym selfies. Every time I open the app, I’m forced to see something that essentially looks like Tinder.

Now, my friend thinks this is a genius business idea. According to her, people who work out really want to flaunt their bodies, but posting that on normal social media is frowned upon. So this gym company really found a good product-market fit.

I think she’s right.

However, I’m super aggressive about social media and feeds I don’t control. Any app that shows me a feed I can’t turn off, sort, or filter—I take that as psychological warfare against me.

I haven’t used social media in more than 12 years. I’m super adamant about blocking ads, blocking YouTube comments, Shorts, and suggested videos, and sanitizing YouTube titles and thumbnails. For things I must use, like LinkedIn1, I use browser extensions to block the news feed completely.

So I took it personally.

I wanted to see if I could extract the QR-code logic without all the extra bloat of the app.

So I started digging. I quickly found out that the code changes every 15 seconds. Screenshots are also blocked on that screen, consistent with Android’s FLAG_SECURE. I wondered if it was some kind of TOTP. Was the generation happening server-side?

I did the simplest thing: turned off Wi-Fi. The app immediately told me the codes would remain valid for N days without reconnecting2.

Okay, so it could generate them locally.

I used GLM 5.3 and DeepSeek v4.1 Flash. In my sessions, GLM was burning something like 10–20 times as much in token cost without making that much progress. DeepSeek seemed to move much faster.

To be clear, I’m leaving out some details to keep the gym and myself anonymous. I don’t want to lose my gym subscription. I actually like that gym.

The app was written in Flutter, with most of the logic compiled into Dart native code. We used blutter to inspect that, jadx for the Android-side code, and Android’s SDK tools to inspect the package. Later, we used OpenSSL and WebCrypto to check the encryption, and zbar to decode the QR codes independently.

I was prompting DeepSeek here and there, mostly from intuition, because I’d never done this before. Sometimes it seemed to get lost in a direction where I felt the implementation must be simpler than that. Just telling it my guesses, or saying that I thought there must be a simpler way, helped steer it in the right direction.

We quickly understood that the QR code was a URL containing four things: an encoded account handle, an encrypted expiry timestamp, a random nonce, and a checksum.

The expiry was encrypted using AES-128-CTR. A separate secret was used to calculate the checksum. So we needed those two secrets for my account.

My first idea was to let DeepSeek drive an Android emulator, sign in, and extract the keys. But I quickly realized that my ten-year-old laptop, borrowed from a friend, wasn’t going to handle Android software emulation at any reasonable speed.

DeepSeek suggested a rooted phone. That’s when I remembered I had a functional rooted Android phone lying around.

I booted it, connected over wireless ADB, and let DeepSeek investigate. It turned out that the app logged the request and response payloads for the calls we cared about. The key material was sitting in a plain text log file on the phone.

Half of this project was ripgrep.

With the keys extracted, we built a simple SvelteKit web app to generate the QR codes.

There were a few wrong assumptions along the way. The expiry timestamp needed to be in milliseconds, not seconds. The encoding was base64url, not standard base64, and it kept the padding.

We also had an incorrect AES implementation that passed its own round-trip tests. It encrypted something, decrypted it, and got the original input back. But both directions shared the same mistakes, so that didn’t prove it was AES.

Comparing against WebCrypto exposed the discrepancy. OpenSSL and known test vectors confirmed it. The browser app uses WebCrypto.

We then checked against actual output from the original app. Given the same inputs, our implementation produced a byte-identical URL. We also decoded the QR image independently to check that it contained the right URL.

At that point, we had a working prototype. The web app could generate the codes locally from the extracted keys. This didn’t solve standalone login or remove the need to obtain fresh keys when they rotated, but it was enough to try it at the gym.

The next day, I headed there, very proudly showing my friend what I’d built, but still a bit uncertain whether it would work at the gate.

And voilà, it did!

Check-in and check-out.

No more being forced to get my attention hijacked by dark-patterned, manipulative social media just to enter an establishment to lift some metals.

Footnotes

  1. For the longest time, my LinkedIn bio was “I HATE LINKEDIN.” Someone told me LinkedIn combines the worst of the corporate world with the worst of social media, and I think that only half describes how bad it really is. But alas, I’m in the job market right now, and I need a LinkedIn profile for applications.↩
  2. I’m not revealing the exact number of days to help keep the app anonymous.↩
← Back to writing