cd ../blog

How to Decrypt and Analyze iOS Apps for Penetration Testing

Red TeamPlatformSecurity TeamMay 24, 20267 min read

// INSIDER BRIEF — TUESDAYS

Don't read this and disappear.

The CVE teardowns, exploit notes, and red-team write-ups we don't publish go straight to subscribers. We send one email a week. You can unsubscribe at any time.

Read by security teams shipping in production.

Most iOS security tutorials end where the real work begins. You've bypassed jailbreak detection, hooked a few methods with Frida, maybe dumped some API calls. Great. But if you're doing actual penetration testing and not just following a CTF writeup, you need the decrypted binary. You need to know what's actually in the app before you start poking at it.

This post covers the full workflow: automated IPA decryption, static analysis to find interesting targets, and advanced instrumentation techniques that go beyond runtime hooking. We'll use tooling that makes this practical for real engagements, not just academic exercises.

Why Decryption Still Matters

App Store binaries are FairPlay encrypted. This means strings, class-dump, Hopper, IDA, and every other static analysis tool you'd normally reach for gives you nothing useful. You get encrypted blobs where executable code should be. Dynamic analysis with Frida works fine at runtime, but you're flying blind. You don't know what classes exist, what methods are available, or where sensitive operations happen in the binary until you stumble onto them.

Decryption unlocks three things that runtime tools alone can't give you:

  1. Static analysis - Disassemble in Hopper or Ghidra, run automated scanners like MobSF, grep for hardcoded secrets, map out the attack surface before writing a single Frida script
  2. Informed dynamic analysis - Know exactly which classes and methods to target instead of guessing based on function names at runtime
  3. Persistent instrumentation - Repackage the decrypted binary with a Frida gadget or custom dylib, resign it, sideload it. Now you have instrumentation that survives app restarts without needing a jailbroken device or constant USB attachment

The traditional workflow for getting a decrypted IPA involves SSH into a jailbroken device, running frida-ios-dump, waiting for it to dump memory, pulling the files back over scp, manually reconstructing the IPA directory structure, and hoping you didn't miss any frameworks. It works, but it's tedious and error-prone.

Automating Decryption with ipa-decryptor

ipa-decryptor collapses the entire manual workflow into a single command. It handles the SSH connection, memory dumping via Frida, pulling the binary and frameworks, and rebuilding a proper IPA structure.

Installation is straightforward:

git clone https://github.com/MattKeeley/ipa-decryptor.git
cd ipa-decryptor
pip3 install -r requirements.txt

You need a jailbroken iOS device (checkra1n, unc0ver, whatever you prefer) with frida-server running and SSH enabled. The device needs to be on the same network as your host or connected via USB with iproxy forwarding port 22.

Basic usage:

python3 ipa-decryptor.py -b com.example.targetapp -o decrypted.ipa

The tool connects over SSH (default credentials root:alpine unless you've changed them), identifies the running process, attaches Frida, dumps the decrypted binary from memory, pulls all frameworks and dylibs, packages everything into a proper IPA, and drops it in your current directory.

For this walkthrough, I'm targeting a fintech app. Replace com.fintech.example with your target's bundle ID:

python3 ipa-decryptor.py -b com.fintech.example -o fintech-decrypted.ipa

Output looks like this:

[*] Connecting to device via SSH (root@192.168.1.100:22)
[*] Found process: com.fintech.example (PID: 1234)
[*] Dumping binary via Frida...
[*] Decrypting: /var/containers/Bundle/Application/.../Example.app/Example
[*] Pulling frameworks...
[*] Packaging IPA structure...
[*] Decrypted IPA saved to: fintech-decrypted.ipa

Verify decryption worked:

unzip -q fintech-decrypted.ipa
otool -l Payload/Example.app/Example | grep -A 5 LC_ENCRYPTION_INFO

If you see cryptid 0, the binary is decrypted. If you see cryptid 1, something went wrong (app has anti-debugging, Frida detection, or you didn't dump it while it was running).

What to Look For in Decrypted Binaries

Now you have a decrypted IPA. Most tutorials stop here. Let's keep going.

Hardcoded Secrets and API Keys

iOS developers love embedding secrets directly in binaries. API keys, OAuth client secrets, encryption keys, AWS credentials, internal endpoint URLs. All sitting there in plaintext (or Base64, which is plaintext with extra steps).

Extract strings and grep for common patterns:

strings -a Payload/Example.app/Example | grep -E '(api[_-]?key|secret|token|password|aws|firebase)' -i

You'll get noise, but you'll also find gems like:

api_key_production=AIzaSyC...
aws_access_key_id=AKIA...
stripe_secret_key=sk_live_...

For Base64-encoded secrets:

strings -a Payload/Example.app/Example | grep -E '^[A-Za-z0-9+/]{20,}={0,2}$' | while read line; do echo "$line" | base64 -d 2>/dev/null; done

A more structured approach is to load the binary into Hopper or Ghidra and search for strings in the __cstring and __const sections. You get more context about where the string is referenced, which helps determine if it's actually used in auth flows or just a debug artifact.

Insecure Cryptographic Implementations

CommonCrypto is the standard iOS crypto library. It's not hard to use correctly, but developers mess it up constantly. ECB mode, hardcoded IVs, static encryption keys stored in the binary.

In Hopper, search for references to CCCrypt. You'll find calls that look like:

CCCrypt(kCCEncrypt, kCCAlgorithmAES128, kCCOptionECBMode, key, keyLength, NULL, plaintext, plaintextLen, ciphertext, ciphertextLen, &numBytesEncrypted);

The third argument is the mode. kCCOptionECBMode means ECB (electronic codebook), which is deterministic and should never be used for anything sensitive. If you see this in production code encrypting user data, that's a finding.

Worse, check where key comes from. If it's loaded from a hardcoded byte array in the binary, you can extract it. Search for CCCrypt cross-references in Hopper, trace back to the key initialization, and you'll often find something like:

char key[16] = {0x4a, 0x3f, 0x2e, 0x1d, 0x6c, 0x5b, 0x8a, 0x9f, ...};

Copy those bytes, convert to hex, and you have the encryption key.

Certificate Pinning Implementations

Certificate pinning is common in high-security apps (banking, healthcare, anything handling PII). The app hardcodes expected certificate hashes or public keys and rejects connections if the server cert doesn't match. This breaks Burp Suite and other MITM proxies out of the box.

You need to find the pinning logic before you can bypass it. In Hopper, search for:

  • NSURLSession delegate methods like didReceiveChallenge
  • SecTrustEvaluate
  • String references to certificate hashes (SHA256 fingerprints are 64-character hex strings)

Here's a common pattern:

- (void)URLSession:(NSURLSession *)session 
              task:(NSURLSessionTask *)task 
didReceiveChallenge:(NSURLAuthenticationChallenge *)challenge 
 completionHandler:(void (^)(NSURLSessionAuthChallengeDisposition, NSURLCredential *))completionHandler {
    
    NSString *expectedHash = @"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
    // Compare with server cert hash...
}

Once you know where the pinning logic lives, write a Frida script to bypass it:

// Hook the challenge handler and always accept
var NSURLSession = ObjC.classes.NSURLSession;
var handle = ObjC.classes.NSURLSessionTask["- URLSession:task:didReceiveChallenge:completionHandler:"];

Interceptor.attach(handle.implementation, {
    onEnter: function(args) {
        console.log("[*] Certificate pinning challenge intercepted");
        
        // args[2] is the challenge
        // args[3] is the completion handler
        var completionHandler = new ObjC.Block(args[3]);
        
        // Call completion handler with NSURLSessionAuthChallengeUseCredential
        // This accepts whatever cert the server presents
        var credential = ObjC.classes.NSURLCredential.credentialForTrust_(args[2].protectionSpace().serverTrust());
        completionHandler(1, credential); // 1 = NSURLSessionAuthChallengeUseCredential
    }
});

But you wouldn't know to hook didReceiveChallenge or what arguments to expect without having disassembled the binary first. That's the value of static analysis.

Data Storage Locations

Apps store data in predictable locations: Documents, Library, Caches, and the Keychain. But knowing which sensitive data lives where requires reading the code.

Search for string references to these paths in Hopper:

  • NSDocumentDirectory
  • NSLibraryDirectory
  • NSCachesDirectory

Then trace forward to see what's being written. You might find code like:

NSString *path = [documentsPath stringByAppendingPathComponent:@"auth_token.dat"];
[tokenData writeToFile:path atomically:YES];

This tells you the app is storing auth tokens in the Documents directory, which is backed up to iCloud and accessible without device unlock on some iOS versions. That's often a vulnerability on its own.

For Keychain access, search for SecItemAdd, SecItemCopyMatching, SecItemUpdate. Check the kSecAttrAccessible attribute. If it's set to kSecAttrAccessibleAlways, the Keychain item is accessible even when the device is locked. For sensitive data like auth tokens or encryption keys, it should be kSecAttrAccessibleWhenUnlockedThisDeviceOnly.

Repackaging with Persistent Instrumentation

Runtime Frida hooking is great for interactive testing, but it requires keeping your laptop connected and reattaching scripts after every app restart. For longer engagements, you want persistent instrumentation baked into the app.

The technique: inject the Frida gadget (a dylib that embeds a Frida server into the app itself), resign the IPA with a free Apple developer certificate, and sideload it via Xcode or a tool like ios-deploy. Now the app has Frida built in and you can attach wirelessly.

Step 1: Inject the Frida Gadget

Download the iOS Frida gadget from Frida's releases. Grab the frida-gadget-[version]-ios-universal.dylib file.

Copy it into the app bundle:

unzip -q fintech-decrypted.ipa -d fintech-unpacked
cp frida-gadget-ios-universal.dylib fintech-unpacked/Payload/Example.app/Frameworks/FridaGadget.dylib

Modify the app's main binary to load the gadget on launch. Use insert_dylib:

git clone https://github.com/Tyilo/insert_dylib.git
cd insert_dylib
make
./insert_dylib @executable_path/Frameworks/FridaGadget.dylib fintech-unpacked/Payload/Example.app/Example

This injects a LC_LOAD_DYLIB command into the Mach-O header, telling the dynamic linker to load the gadget when the app starts.

Step 2: Resign the IPA

Apple's code signing is mandatory. You need to resign the modified IPA with your own certificate. If you have a paid Apple Developer account, use that. Otherwise, create a free provisioning profile in Xcode (open Xcode, create a new iOS project, sign in with your Apple ID, and Xcode will generate a free certificate).

Use codesign to resign:

# Find your signing identity
security find-identity -v -p codesigning

# Sign the Frameworks first
codesign -f -s "iPhone Developer: Your Name (ABCDE12345)" fintech-unpacked/Payload/Example.app/Frameworks/FridaGadget.dylib

# Sign the main binary
codesign -f -s "iPhone Developer: Your Name (ABCDE12345)" fintech-unpacked/Payload/Example.app/Example

# Sign the .app bundle
codesign -f -s "iPhone Developer: Your Name (ABCDE12345)" fintech-unpacked/Payload/Example.app

# Rebuild the IPA
cd fintech-unpacked
zip -qr ../fintech-instrumented.ipa Payload
cd ..

For a cleaner process, use ios-app-signer or sideload.sh scripts that automate this.

Step 3: Sideload and Attach

Install the IPA on your device:

# Via Xcode: Drag the IPA into Xcode's Devices and Simulators window
# Or via ios-deploy:
ios-deploy --bundle fintech-instrumented.ipa --debug

Launch the app. The Frida gadget starts automatically. Attach to it from your host:

frida -U Gadget

You're now attached to the app with persistent instrumentation. No USB cable required after initial sideloading. Write your scripts and they'll work across app restarts.

This is particularly useful for testing time-based logic (session expiration, token refresh) or multi-step flows where you'd otherwise lose your hooks between restarts.

Binary Diffing for Patch Analysis

Vendors release updates constantly. Sometimes they're fixing bugs you reported. Sometimes they're patching vulnerabilities you haven't found yet. Binary diffing tells you what changed between versions.

Decrypt two versions of the same app (old and new) using ipa-decryptor. Load both binaries into BinDiff (IDA plugin) or Diaphora (Ghidra plugin).

These tools compare function control flow graphs and identify:

  • New functions (potential new features or endpoints)
  • Removed functions (deprecated functionality)
  • Modified functions (bug fixes, logic changes)

Example workflow with Diaphora in Ghidra:

# Analyze both binaries in Ghidra
ghidraRun Example_v1.0
ghidraRun Example_v1.1

# Export Diaphora databases for each
# Run Diaphora comparison

Diaphora will flag functions with high similarity but minor differences. These are your patch candidates. For security testing, focus on:

  • Authentication functions (changes to token validation, session handling)
  • Crypto functions (did they switch from ECB to CBC? Did they rotate hardcoded keys?)
  • Network functions (new API endpoints, changed request signing)

In a real engagement, I found a fintech app had patched a session fixation bug by adding a CSRF token to their API calls. The diff showed a new function generating UUIDs and attaching them to POST requests. Without the diff, I wouldn't have known to look for that specific change.

Putting It All Together

Here's the full workflow for a typical iOS pentest:

  1. Decrypt the IPA with ipa-decryptor to unlock static analysis
  2. Run automated scans (MobSF for low-hanging fruit: insecure flags, exported services, URL schemes)
  3. Manual static analysis in Hopper/Ghidra (find hardcoded secrets, crypto misuse, pinning logic, storage implementations)
  4. Write targeted Frida scripts based on what static analysis revealed (bypass pinning, dump decrypted data, hook auth flows)
  5. Repackage with Frida gadget for persistent instrumentation if needed for long-term testing
  6. Diff against newer versions to identify patches and new attack surface

The decrypted binary is the foundation. Everything else builds on it. You can do runtime analysis without it, but you're working blind. The tools here make this practical for real engagements where you don't have unlimited time to reverse engineer everything from scratch.

Toolchain Summary

All of this is standard tooling. Nothing exotic. The difference between a surface-level "I ran Frida and dumped some API calls" assessment and actual in-depth testing is knowing what to look for and having the decrypted binary to work from. Start there.

High-Impact Next Step

We find these before attackers do.

See what we would uncover in your stack with exploitability context and prioritized fixes your team can ship quickly.