Frida runtime hooks fail when the code you want to bypass runs before Frida can attach, or when the app implements pinning entirely in C++ with no ObjC or Swift symbols to intercept. Some apps use a custom TLS stack like Facebook Fizz or roll their own certificate verification in a static initializer that fires before main. Frida arrives too late. The alternative: patch the binary on disk, flip the branch instruction that sends execution down the failure path, and side-load the patched binary. The device never sees the original flow.
Prerequisites
- Decrypted binary -
cryptid=0from frida-ios-dump (see the IPA Decryption post). - Ghidra or Hopper for disassembly.
- Hex editor or
xxd+ddfor the patch. - Jailbroken device with a development-signed entitlement, or a developer cert for re-signing.
Locating the pinning branch in Ghidra
Import the decrypted Mach-O into Ghidra. Let the auto-analysis run.
Find the pinning logic:
- Search for the string
certificateorpinnedCertificatein the string table (Search -> For Strings). - Double-click the reference - it lands you in the function that handles the certificate check.
- Look for a call to
SecTrustEvaluateorSecTrustEvaluateWithErrorfollowed by a conditional branch. - The failure path calls
cancel()or returns early. The success path continues to the network call.
The branch that separates the two paths is what to flip.
ARM64 branch opcodes
ARM64 instructions are 4 bytes, fixed-width. Branch instructions:
| Mnemonic | Encoding (little-endian) | Meaning |
|---|---|---|
B.EQ | 00 00 00 54 (offset-dependent) | branch if equal (ZF=1) |
B.NE | 01 00 00 54 (offset-dependent) | branch if not equal (ZF=0) |
B.LT | 0B 00 00 54 | branch if less than |
B | xx xx xx 14 | unconditional branch |
NOP | 1F 20 03 D5 | no-operation |
To always take the success path: replace the B.EQ/B.NE that jumps to the failure path with an unconditional B to the success path, or replace it with NOP so execution falls through to success.
The exact 4-byte encoding depends on the branch target offset. Ghidra shows the instruction’s address and the encoded bytes in the Listing view.
Reading the bytes to patch
In Ghidra’s Listing view, click the instruction. The bottom bar shows the hex bytes at that address. Or from the command line:
1
2
3
4
5
6
# Find the offset of the instruction in the binary
# Ghidra shows the virtual address; convert to file offset
# file_offset = vaddr - load_address (from LC_SEGMENT_64 vmaddr - fileoff)
# Read 4 bytes at the offset
xxd -s <file_offset> -l 4 Payload/VulnLabAppiOS.app/VulnLabAppiOS
Note the 4 bytes. This is what you are replacing.
Patching with dd
To overwrite 4 bytes at <file_offset> with a NOP:
1
2
3
4
5
6
7
8
9
10
# Copy the binary first - never patch the original
cp Payload/VulnLabAppiOS.app/VulnLabAppiOS Payload/VulnLabAppiOS.app/VulnLabAppiOS.orig
# Write NOP (1F 20 03 D5) at the branch instruction's file offset
printf '\x1f\x20\x03\xd5' \
| dd of=Payload/VulnLabAppiOS.app/VulnLabAppiOS \
bs=1 seek=<file_offset> conv=notrunc
# Confirm
xxd -s <file_offset> -l 4 Payload/VulnLabAppiOS.app/VulnLabAppiOS
To write an unconditional branch that jumps forward by N instructions:
- The encoding is
((N & 0x3FFFFFF) | 0x14000000)in little-endian. - For a forward jump of 1 instruction (skip the next):
0x14000001=\x01\x00\x00\x14.
Re-signing the patched binary
After patching, the binary’s code signature is invalid. Re-sign before installing:
1
2
3
4
5
6
7
# Ad-hoc signing (works on jailbroken device with platform binary entitlement)
codesign -f -s - Payload/VulnLabAppiOS.app/VulnLabAppiOS
# Or with a development identity from Xcode
codesign -f -s "iPhone Developer: Your Name (TEAMID)" \
--entitlements VulnLabAppiOS.entitlements \
Payload/VulnLabAppiOS.app/VulnLabAppiOS
Ad-hoc signing (-s -) produces a self-signed binary. It installs on jailbroken devices. It will not install on stock devices without a developer identity.
Repacking the IPA
1
cd Payload && zip -qry ../VulnLabAppiOSPatched.ipa .
Side-load via ios-deploy or AltStore on a jailbroken device:
1
ios-deploy --bundle Payload/VulnLabAppiOS.app --justlaunch
Frida in-process patching as an alternative
If you have a Frida attach window but the pinning check runs in a constructor, use Memory.patchCode to flip the branch before the check runs. This does not modify the file:
1
2
3
4
5
6
7
8
9
10
// Find the address of the branch instruction at runtime
const baseAddr = Module.findBaseAddress('VulnLabAppiOS');
const branchAddr = baseAddr.add(0x12345); // offset from Ghidra analysis
Memory.patchCode(branchAddr, 4, function(code) {
// ARM64 NOP: 1F 20 03 D5
code.writeByteArray([0x1f, 0x20, 0x03, 0xd5]);
});
console.log('[patch] branch at ' + branchAddr + ' replaced with NOP');
The patch is applied to the live process only. On next launch the original binary runs. Combine with a --no-pause spawn:
1
frida -U -f com.vulnlab.iosapp -l patch.js --no-pause
Verification
With the pinning bypass in place:
- Configure Burp Suite proxy on the same Wi-Fi network.
- Set the device’s HTTP proxy to the Burp host.
- Trigger the network call that previously failed.
Burp should show the intercepted TLS connection. If it does, the patch succeeded. If the connection still fails, the app has a second pinning check or a certificate transparency check that also needs patching.
When static patching is not needed
For most apps, the objection SSL pinning bypass is sufficient:
1
2
objection -g com.vulnlab.iosapp explore
ios sslpinning disable
Objection hooks SecTrustEvaluate, SSLHandshake, and common third-party pinning libraries at runtime. Binary patching is a last resort for custom TLS stacks and constructor-time checks that fire before Frida attaches.
Closing
ARM64 branch flipping is a one-instruction patch. Ghidra finds the offset, dd applies it, codesign re-signs it, and Burp confirms it. The skill transfers to any binary check: root detection, license validation, jailbreak detection. Find the branch, flip it.
Happy Hacking !!