Debugging the changing QR codes for Fitness SF
The QR codes for Fitness SF started changing. I had previously created an Apple Wallet pass that would automatically open the code up when you arrived. This stopped scanning at the end of May, and although updating my member ID temporarily fixed it, it broke again two weeks later. This lined up with the update of the app, where v1.3.22 let us know that "this update includes a fix to resolve the error with the QR code when checking in". Let's see if we can figure out what it's doing, and how to replicate the behavior for the Wallet pass. 1 Or take a shortcut and jump to the shortcut.
Jailbreaking
My hypothesis was that it was some TOTP
2
Time-based one time password, which you've encountered if you've ever used Two-Factor Authentication with an app like Duo or Google Authenticator. If you're wondering how they work, check out this recent blog post I enjoyed on their implementation. If you're wondering why I use acronyms when I have the compulsive need to explain them anyways, let me know if you find out.
based on the user ID (which was prompted by the Fitness SF employees always telling me to just close the QR screen and open it again). My plan to find out what was going on was to 1) load up the app, 2) go to the QR screen, and then 3) reverse engineer how the QR code was determined.
3
Unfortunately while I've done some recent reverse engineering it's all been with Apple apps where I can use the Simulator. To reverse engineer a private app we're going to need a jailbroken device. Time to repurpose my old iPhone 6S which I've been using for Spotify Connect.
4
I really wish that Spotify Connect supported multi-device playback. I looked into it, but couldn't find any great implementations or private APIs that I could hijack (though my iOS RE skills are getting a lot better recently).
The 6S is running the latest version that it supports, 15.8.4. Normally you can't jailbreak the latest versions of iPhones because Apple has released a security patch. Luckily the iPhone 6S has a hardware exploit with checkm8
5
Sadly the the era of hardware exploits is ending, with the last checkm8
vunerable device, the iPad 7th generation, not getting iOS 26.
that's unpatchable, so this is on the table.
6
Unpatchable and going for $30 on eBay is a pretty great deal.
While Cydia
7
I just heard this pronounced by its creator saurik as rhyming with Medea instead of Lydia, as I've always said it. The world feels askew.
is dead, we can jailbreak using Dopamine and install Sileo, its successor.
Dopamine
I mostly followed this guide, though I had to use Sideloadly on my Windows 10 device because it was insta-crashing on my Mac (the M3s don't have Rosetta by default).
This installs Sileo, and prompts you to enter a password. Dopamine is rootless, as opposed to old jailbreaks, which are rootful (basically this just means that tweaks aren't installed in /
anymore). This has a lot of benefits in terms of stability, but means that most of the old Cydia tweaks aren't compatible,
10
palera1n
is supposed to have a rootful install, but I couldn't get it to work.
with replacements sometimes harder to come by. We need to get SSH and debugserver
up and running to start, which I found by adding the source https://apt.procurs.us/
and installing debugserver-16
and openssh
. Let’s jump into it!
Attaching LLDB
We would like to use a debugger on the app. Open a terminal window and ssh into your iPhone with ssh mobile@192.168.1.30
, using the password you created when setting up Sileo (rip alpine
11
alpine
was historically the default password for root, apparently from Apple's codename for the first iPhone OS version, and the first thing you would change after jailbreaking.
). After opening the app we can run ps aux | grep Fitness
to find the PID 4812
for var/containers/Bundle/Application/C132D9F4-B149-41D6-ADB6-12D578686BEB/FitnessSF.app/FitnessSF
. We can then set up debugserver
with debugserver-16 "0.0.0.0:1234" --attach=4812
.
Then in a new terminal, we'll connect to it. Run lldb
to initialize LLDB and then platform select remote-ios
to let it know what we'll be doing. This will print out a list of SDK roots that it understands. Make sure that your device is on here!
12
If you ignore this and just go ahead you'll get warning: libobjc.A.dylib is being read from process memory. This indicates that LLDB could not find the on-disk shared cache for this device. This will likely reduce debugging performance.
and <redacted>
for all system calls. Don't ignore this.
In this case, I'm running an iPhone 6S on iOS 15.8.4, which doesn't show up.
SDK Path: "/Users/abeals/Library/Developer/Xcode/iOS DeviceSupport/iPhone13,3 18.0.1 (22A3370)"
SDK Roots: [ 0] "/Users/abeals/Library/Developer/Xcode/iOS DeviceSupport/iPhone13,3 18.0.1 (22A3370)"
SDK Roots: [ 1] "/Users/abeals/Library/Developer/Xcode/iOS DeviceSupport/iPhone17,1 18.1.1 (22B91)"
SDK Roots: [ 2] "/Users/abeals/Library/Developer/Xcode/iOS DeviceSupport/iPhone17,1 18.1 (22B83)"
SDK Roots: [ 3] "/Users/abeals/Library/Developer/Xcode/iOS DeviceSupport/iPhone13,3 18.0 (22A3354)"
SDK Roots: [ 4] "/Users/abeals/Library/Developer/Xcode/iOS DeviceSupport/iPhone13,3 17.5.1 (21F90)"
SDK Roots: [ 5] "/Users/abeals/Library/Developer/Xcode/iOS DeviceSupport/iPhone13,3 17.4.1 (21E236)"
SDK Roots: [ 6] "/Users/abeals/Library/Developer/Xcode/iOS DeviceSupport/iPhone13,3 17.6.1 (21G93)"
SDK Roots: [ 7] "/Users/abeals/Library/Developer/Xcode/iOS DeviceSupport/iPhone17,1 18.5 (22F76)"
SDK Roots: [ 8] "/Users/abeals/Library/Developer/Xcode/iOS DeviceSupport/iPhone17,1 18.4.1 (22E252)"
Xcode will automatically copy over the shared symbols that we need if you connect your iPhone to your computer with a cable, open Xcode, and go to platform select remote-ios
now includes iPhone8,1 15.8.4 (19H390)
, which means we're good to go, and you won't need to do this again in the future.
Platform: remote-ios
Connected: no
SDK Path: "/Users/abeals/Library/Developer/Xcode/iOS DeviceSupport/iPhone13,3 18.0.1 (22A3370)"
SDK Roots: [ 0] "/Users/abeals/Library/Developer/Xcode/iOS DeviceSupport/iPhone13,3 18.0.1 (22A3370)"
SDK Roots: [ 1] "/Users/abeals/Library/Developer/Xcode/iOS DeviceSupport/iPhone17,1 18.1.1 (22B91)"
SDK Roots: [ 2] "/Users/abeals/Library/Developer/Xcode/iOS DeviceSupport/iPhone8,1 15.8.4 (19H390)"
SDK Roots: [ 3] "/Users/abeals/Library/Developer/Xcode/iOS DeviceSupport/iPhone17,1 18.1 (22B83)"
SDK Roots: [ 4] "/Users/abeals/Library/Developer/Xcode/iOS DeviceSupport/iPhone13,3 18.0 (22A3354)"
SDK Roots: [ 5] "/Users/abeals/Library/Developer/Xcode/iOS DeviceSupport/iPhone13,3 17.5.1 (21F90)"
SDK Roots: [ 6] "/Users/abeals/Library/Developer/Xcode/iOS DeviceSupport/iPhone13,3 17.4.1 (21E236)"
SDK Roots: [ 7] "/Users/abeals/Library/Developer/Xcode/iOS DeviceSupport/iPhone13,3 17.6.1 (21G93)"
SDK Roots: [ 8] "/Users/abeals/Library/Developer/Xcode/iOS DeviceSupport/iPhone17,1 18.5 (22F76)"
SDK Roots: [ 9] "/Users/abeals/Library/Developer/Xcode/iOS DeviceSupport/iPhone17,1 18.4.1 (22E252)"
Finally on the Mac we run process connect connect://192.168.1.30:1234
to link to the remote debugger and we're ready to start reverse engineering.
Investigating the QR code screen
Type c
to get back control of the app, and open the QR code screen. With that open we can run po [[UIWindow keyWindow] recursiveDescription]
to get the full screen.
In this case you can grab the element that we want to understand (0x12d7058e0
, the UIImageView
that's the QR code) and figure out what controller eventually owns it. By repeatedly calling po [0x12d7058e0 nextResponder]
we walk up the chain until we get to <UIView: 0x12d725770 ...>
, whose responder is <FitnessSF.QRCodeViewController: 0x12c9d9600>
. This is the main controller we want to debug.
<UIWindow: 0x12bf0c500; frame = (0 0; 375 667); gestureRecognizers = <NSArray: 0x28286fdb0>; layer = <UIWindowLayer: 0x28286ff60>>
| <UIView: 0x12bf07c50; frame = (0 0; 375 20); tag = 38482458385; layer = <CALayer: 0x282642de0>>
| <UITransitionView: 0x12bdfb750; frame = (0 0; 375 667); autoresize = W+H; layer = <CALayer: 0x2826fa6a0>>
| | <UIDropShadowView: 0x12bdfc210; frame = (0 0; 375 667); autoresize = W+H; layer = <CALayer: 0x2826fa600>>
| <UITransitionView: 0x12d685a50; frame = (0 0; 375 667); autoresize = W+H; layer = <CALayer: 0x282688c00>>
| | <UIView: 0x12d725770; frame = (0 0; 375 667); autoresize = W+H; layer = <CALayer: 0x2826ceb40>>
| | | <UIView: 0x12d6836e0; frame = (0 0; 375 20); tag = 12321; layer = <CALayer: 0x2826dfda0>>
| | | <_UITextLayoutCanvasView: 0x12d737c70; frame = (0 20; 375 647); layer = <CALayer: 0x2826c6220>>
| | | | <UIView: 0x12d7258e0; frame = (0 0; 375 647); autoresize = RM+BM; layer = <CALayer: 0x2826cf880>>
| | | | | <UIView: 0x12d64ac70; frame = (0 0; 375 647); autoresize = RM+BM; layer = <CALayer: 0x28268a3c0>>
| | | | | | <UILabel: 0x12d735350; frame = (0 0; 385 647); userInteractionEnabled = NO; layer = <_UILabelLayer: 0x280532fd0>>
| | | | | | | <CAGradientLayer: 0x2826c5d20> (layer)
| | | | | | <UIButton: 0x12d7052e0; frame = (309 32; 42 42); opaque = NO; autoresize = RM+BM; layer = <CALayer: 0x2826c0500>>
| | | | | | | <UIButtonLabel: 0x12d7386a0; frame = (16 12; 10 18); text = 'X'; opaque = NO; userInteractionEnabled = NO; layer = <_UILabelLayer: 0x280532940>>
| | | | | | <UILabel: 0x12d6443e0; frame = (48 109; 279 65); text = 'Scan this QR Code at the ...'; opaque = NO; autoresize = RM+BM; userInteractionEnabled = NO; layer = <_UILabelLayer: 0x280535090>>
| | | | | | <UIView: 0x12bd6fce0; frame = (111.5 194; 152 152); autoresize = RM+BM; layer = <CALayer: 0x28268a220>>
| | | | | | | <UIImageView: 0x12d63d8a0; frame = (10 10; 132 132); clipsToBounds = YES; opaque = NO; autoresize = RM+BM; userInteractionEnabled = NO; layer = <CALayer: 0x28268a520>>
| | | | | | <UILabel: 0x12d6659b0; frame = (62.5 396; 250 90); text = 'Have a great workout!'; opaque = NO; autoresize = RM+BM; userInteractionEnabled = NO; layer = <_UILabelLayer: 0x280535630>>
| | | | | | <UIControl: 0x12d7055a0; frame = (48 517; 279 60); autoresize = RM+BM; layer = <CALayer: 0x2826c0840>>
| | | | | | | <UIStackView: 0x12d705750; frame = (46 15; 187.5 30); opaque = NO; autoresize = RM+BM; userInteractionEnabled = NO; layer = <CALayer: 0x2826c1900>>
| | | | | | | | <UIImageView: 0x12d7058e0; frame = (0 0; 30 30); clipsToBounds = YES; autoresize = RM+BM; userInteractionEnabled = NO; layer = <CALayer: 0x2826c27e0>>
| | | | | | | | <UILabel: 0x12d721790; frame = (40 0; 147.5 30); text = 'Refer a Friend'; opaque = NO; autoresize = RM+BM; userInteractionEnabled = NO; layer = <_UILabelLayer: 0x28053c960>>
Inspecting QRCodeViewController
We want to know what methods this controller has. The Obj-C runtime has to know about these, so we can use the private instance method __methodDescriptionForClass
on any NSObject to print out the methods in a class using po [@"" __methodDescriptionForClass:(id)[0x12c9d9600 class]]
.
14
Once again, Bryce's videos were invaluable references, go check out his stuff if you haven't yet.
From a quick scan the main interesting methods are related to the QRCode, the VC loading, and a mysterious timerAction
method.
in FitnessSF.QRCodeViewController:
Properties:
@property (nonatomic, weak) UIButton* btnClose; (@synthesize btnClose = btnClose;)
@property (nonatomic, weak) UIImageView* imgQRCode; (@synthesize imgQRCode = imgQRCode;)
@property (nonatomic, weak) UILabel* lblCheckIn; (@synthesize lblCheckIn = lblCheckIn;)
@property (nonatomic, weak) UIView* grView; (@synthesize grView = grView;)
@property (nonatomic, weak) UILabel* lblWorkOut; (@synthesize lblWorkOut = lblWorkOut;)
@property (nonatomic, weak) UIControl* btnShare; (@synthesize btnShare = btnShare;)
@property (nonatomic, weak) UIView* viewMain; (@synthesize viewMain = viewMain;)
@property (nonatomic, weak) UIView* viewQRBackground; (@synthesize viewQRBackground = viewQRBackground;)
@property (nonatomic, weak) NSLayoutConstraint* lblWorkOutHeight; (@synthesize lblWorkOutHeight = lblWorkOutHeight;)
@property (nonatomic, weak) NSLayoutConstraint* lblCheckOutHeight; (@synthesize lblCheckOutHeight = lblCheckOutHeight;)
Instance Methods:
- (id) btnClose; (0x10062ab78)
- (void) setBtnClose:(id)arg1; (0x10062ab98)
- (id) imgQRCode; (0x10062abac)
- (void) setImgQRCode:(id)arg1; (0x10062abcc)
- (id) lblCheckIn; (0x10062abe0)
- (void) setLblCheckIn:(id)arg1; (0x10062ac00)
- (id) grView; (0x10062ac14)
- (void) setGrView:(id)arg1; (0x10062ac34)
- (id) lblWorkOut; (0x10062ac48)
- (void) setLblWorkOut:(id)arg1; (0x10062ac68)
- (id) btnShare; (0x10062ac7c)
- (void) setBtnShare:(id)arg1; (0x10062ac9c)
- (id) viewMain; (0x10062acb0)
- (void) setViewMain:(id)arg1; (0x10062acd0)
- (id) viewQRBackground; (0x10062ace4)
- (void) setViewQRBackground:(id)arg1; (0x10062ad04)
- (id) lblWorkOutHeight; (0x10062ad18)
- (void) setLblWorkOutHeight:(id)arg1; (0x10062ad38)
- (id) lblCheckOutHeight; (0x10062ad4c)
- (void) setLblCheckOutHeight:(id)arg1; (0x10062ad6c)
- (void) viewDidLoad; (0x10062c238)
- (void) viewWillAppear:(BOOL)arg1; (0x10062c844)
- (void) viewWillDisappear:(BOOL)arg1; (0x10062caa4)
- (void) dealloc; (0x10062cb48)
- (void) notificationReceived:(id)arg1; (0x10062d14c)
- (void) appDidEnterBackground; (0x10062d2f4)
- (void) timerAction; (0x10062d31c)
- (void) tap; (0x10062d4c4)
- (void) actionCloseBtnClicked:(id)arg1; (0x10062ea88)
- (void) actionReferFrndClicked:(id)arg1; (0x10062eba0)
- (id) initWithNibName:(id)arg1 bundle:(id)arg2; (0x10062f3cc)
- (id) initWithCoder:(id)arg1; (0x10062f600)
- (void) .cxx_destruct; (0x10062cbd4)
If we set a breakpoint at setImgQRCode
with b 0x10062abcc
and then reopen the QR screen we can trace this to [UIViewController loadView]
instantiating from a nib, which is mostly uninteresting.
15
This defines a layout file, and stands for NeXTSTEP Interface Builder. Because Avie Tevanian built them exactly the OS they needed.
But if we set a breakpoint on -[UIImageView setImage:]
and timerAction
we can actually see that setImage
is called downstream of timerAction
. Great! Let's decompile the method and take a look inside.
Dumping the IPA
This is harder than it seems, because apps distributed through the App store are encrypted with FairPlay encryption which makes decompilation much harder (maybe impossible?).
16
DRM is a scourge, but at least there are people like EMPRESS out there.
Again our salvation lies in jailbreaking, because in order to run the app the unencrypted version has to be loaded into memory. We can add in code to take that unencrypted version in memory and download it. Adding https://alias20.gitlab.io/apt/
as a source in Sileo allows us to install a rootless version of bfdecrypt
which does just that. Once it's installed go to
It automatically saves the ipa in /private/var/mobile/Containers/Data/Application
as decrypted-app.ipa
, though I'm not sure how to find the specific folder (I just cd
'd to that directory and ran find . -name decrypted-app.ipa -type f
). Then from my laptop I copied it over with scp mobile@192.168.1.30:/private/var/mobile/Containers/Data/Application/62E5839D-B37C-4621-AA1A-45071FFF4919/Documents/decrypted-app.ipa
. Loading it up in Hopper
18
Still haven't paid the $100 to have it not quit every 30 minutes. Not sure when the calculus makes sense, but the retroactive wasted pain will hurt one day when I finally pull the trigger.
I jumped to the relevant place flagged by the breakpoint. While we do find timerAction
we don't find a TOTP. Instead we're just making an API request to /api/profile/qr-code?caid=
.
This is good and bad news: good news in the sense that we understand where the QR code is coming from, but bad news in the sense that it's not an on-client thing. Also bad news in the sense that if I had sat and thought for a while, the network requests might've been an easier place to start. Regardless, onwards and upwards!
Mitmproxy
Better late than never so let's take a look at the network requests. The main tool I use for network debugging is mitmproxy
, which I can kick off with mitmweb --listen-port 48640
and point my iPhone to it (see my post on reverse engineering Letterboxd's API internals for more details if you're unfamiliar with this). Unfortunately we get a lot of certificate failure errors:
[18:11:11.211] client connect
[18:11:11.234] server connect fsf-prod-sfhb.fitnesssf.com:443 (54.193.98.129:443)
[18:11:11.294] Client TLS handshake failed. The client disconnected during the handshake. If this happens consistently for fsf-prod-sfhb.fitnesssf.com, this may indicate that the client does not trust the proxy's certificate.
[18:11:11.295] client disconnect
[18:11:11.295] server disconnect fsf-prod-sfhb.fitnesssf.com:443 (54.193.98.129:443)
This is because unlike Letterboxd, Fitness SF is doing some certificate pinning, which tries to prevent the man-in-the-middle attack we're trying to do. Luckily for us, we're using a jailbroken iPhone, and these rules don't apply to us. Adding Misty's
19
It's funny that the tooling for finding security problems is so rife with security problems. Sure, random Chinese person with an anime profile picture, write some unverified code that I'll happily install on my device!
repository as a source on Sileo (https://repo.misty.moe/apt/
) and installing "SSL Kill Switch 3 (1.5.1+rootless)" allows us to toggle on
With this, mitmproxy
will happily start processing requests, showing that the app pings this API about once a second.
Inspecting a sample request indicates that it takes in the user's ID, authenticates it with an Authorization Bearer token, and returns the current QR code in the data
field. Logging out and in with mitmproxy
running shows that the Bearer token can be obtained by a POST
request to https://fsf-prod-sfhb.fitnesssf.com/app/account/auth-v2
with the fields email
and password
.
20
Along with the field idCA = ""
, though you can drop it without a problem.
That request returns JSON with the caid
in data.user.idCA
and the token in data.token
(and also returns the QR code in data.user.scanCode
, so turns out we only have to do one request).
{
"status": 200,
"success": true,
"message": "You have logged in successfully.",
"data": {
"user": {
"sfId": "<id>",
"name": null,
"email": "<email>",
...
"scanCode": "Q7*****6S",
...
"idCA": "<idCA>",
...
},
"token": "<token>",
"refreshToken": "<refreshToken>",
"referralLinkText": "Get a FREE month credited toward your membership dues by using my referral link."
}
}
We now know that we can confidently get the QR code string, but how do we update the Wallet automatically? Unfortunately while it is possible it's not very extensible. But you know what is extensible? Apple Shortcuts!
Shortcut Alternative
Sure, with a hammer everything looks like a nail, but reverse engineering Shortcuts gave me a lot of respect for how powerful the app is (albeit with a slightly crummy interface for power users, as evidenced by ScPL and JellyCuts and Cherri). It has built-in support for the four pieces that we need: run a POST request, parse the output, create a QR codes, and show it when you get nearby—so I created a shortcut to do just this. When you install it, it will prompt you for your email and password, 21 This is only sent to the official Fitness SF API and is locally stored. How the tables have turned! and use that to show the current QR code. 22 I changed it to show the UI in the old layout using image overlays, because the first time I showed just the raw QR code they looked at me funny.
Import screen
Shortcut code
In action
Finally create an automation for each gym that triggers when you get in range, and have it run this shortcut. The UX is a little worse, but at least it won't stop working every two weeks! Unless they change their API...🤨
-
Or take a shortcut and jump to the shortcut. ↩︎
-
Time-based one time password, which you've encountered if you've ever used Two-Factor Authentication with an app like Duo or Google Authenticator. If you're wondering how they work, check out this recent blog post I enjoyed on their implementation. If you're wondering why I use acronyms when I have the compulsive need to explain them anyways, let me know if you find out. ↩︎
-
I really wish that Spotify Connect supported multi-device playback. I looked into it, but couldn't find any great implementations or private APIs that I could hijack (though my iOS RE skills are getting a lot better recently). ↩︎
-
Sadly the the era of hardware exploits is ending, with the last
checkm8
vunerable device, the iPad 7th generation, not getting iOS 26. ↩︎ -
Unpatchable and going for $30 on eBay is a pretty great deal. ↩︎
-
I just heard this pronounced by its creator saurik as rhyming with Medea instead of Lydia, as I've always said it. The world feels askew. ↩︎
-
The "Open in TrollStore" button didn't work. What did work was downloading the IPA, opening it, going to the Share Sheet, and then selecting TrollStore in the list of apps. ↩︎
-
Reminiscent of the JailbreakMe days when you could just do this from Safari. They should get some intern to roll their own PDF parsing code again. ↩︎
-
palera1n
is supposed to have a rootful install, but I couldn't get it to work. ↩︎ -
alpine
was historically the default password for root, apparently from Apple's codename for the first iPhone OS version, and the first thing you would change after jailbreaking. ↩︎ -
If you ignore this and just go ahead you'll get
warning: libobjc.A.dylib is being read from process memory. This indicates that LLDB could not find the on-disk shared cache for this device. This will likely reduce debugging performance.
and<redacted>
for all system calls. Don't ignore this. ↩︎ -
Screenshotting this was a nightmare because of ScreenShield. Finally ended up getting it by enabling FLEX by longpress triple-tapping (installed from Github), inspecting a low level UIView and exporting its preview view. Was it worth it? ↩︎
-
Once again, Bryce's videos were invaluable references, go check out his stuff if you haven't yet. ↩︎
-
This defines a layout file, and stands for NeXTSTEP Interface Builder. Because Avie Tevanian built them exactly the OS they needed. ↩︎
-
DRM is a scourge, but at least there are people like EMPRESS out there. ↩︎
-
As far as I can tell this is broken. If I chose "Yes" it would just crash the app. ↩︎
-
Still haven't paid the $100 to have it not quit every 30 minutes. Not sure when the calculus makes sense, but the retroactive wasted pain will hurt one day when I finally pull the trigger. ↩︎
-
It's funny that the tooling for finding security problems is so rife with security problems. Sure, random Chinese person with an anime profile picture, write some unverified code that I'll happily install on my device! ↩︎
-
Along with the field
idCA = ""
, though you can drop it without a problem. ↩︎ -
This is only sent to the official Fitness SF API and is locally stored. How the tables have turned! ↩︎
-
I changed it to show the UI in the old layout using image overlays, because the first time I showed just the raw QR code they looked at me funny. ↩︎