iOS App Hardening Intro

Even though Apple controls the deployment process in one of the most theoretically secure default pipelines, the apps binary can still become compromised. App hardening protects the binary from anyone who wants to tamper with it or steal data from it. There are three main ways attackers can compromise your iOS app binary: - Static Analysis: Attackers using tools like Hopper or IDA Pro to decompile your code and read your logic. - Dynamic Analysis: Using debuggers (like LLDB) or instrumentation tools (like Frida) to modify your app's behavior while it's running. - Environment Risks: Running the app on a jailbroken device where the standard iOS sandbox protections are bypassed. For this article lets go over Static Analysis and how we can protect our app from that. We would use a method called Passive Hardening. These steps make it harder for someone to look at your code and understand what it does. - Symbol Obfuscation: By default, Swift/Objective-C binaries contain metadata that reveals class and method names. Tools like SwiftShield can rename these to random strings (e.g., class UserManager becomes class a1b2). - String Encryption: Plaintext strings (like your API endpoints or keys) are easy to find using the strings command. Hardening involves encrypting these strings and only decrypting them in memory when needed. - Control Flow Flattening: This mangles the logical flow of your code (if/else, loops) into a complex web of switch statements, making the assembly nearly impossible to follow. Now lets see how this actually works. Lets say that we have a SwiftUI app that contains an API key of a secret string: class LicenseManager { let secretKey = "SUPER_SECRET_12345" func verify(key: String) -> Bool { return key == secretKey } } Now how would an attacker get that value? Inside a SwiftUI View, that string is sitting in the __TEXT,__cstring section of our binary. An attacker would run: strings YourAppName | grep SUPER Then they find "SUPER_SECRET_12345". We can use a tool like SwiftSheild to begin obfuscating the code. For instance this is what the code would like like in the binary after running obfuscating it: struct a1: View { // ContentView private var b2 = c3() // LicenseManager @State private var d4 = "" @State private var e5 = "" var body: some View { VStack { Text(e5) TextField("...", text: $d4) Button("Submit") { if(d4 == b2.f6) { // secretKey e5 = "..." } } } } } This would help with throwing off the trail initially. Instead of seeing LiscenseManager, it would see b2 = c3() which isn’t perfect but it’s a great first step. Now we actually need to make this more secure. Why not put the string into a separate file like a plist file or a config file? If we move "SUPER_SECRET_12345" into a file called Secrets.plist or Config.json: - The Bundle is Transparent: An .ipa is just a .zip file. Anyone can right-click your app, "Show Package Contents," and open your .plist. - Zero Effort Reversing: The attacker doesn't even need to look at code; they just look at your file structure. - Standard Locations: Tools like Passionfruit or Objection automatically scan for common configuration files to dump secrets. So for something like user data or an API key, the best way to handle this would be to store this in Secure Enclave.