For non-jailbroken iOS pentesting, the standard workflow is patching the IPA with FridaGadget.dylib and re-signing with a free developer profile. The app runs on your device, Frida hooks in, you instrument. Apps that ship anti-piracy / anti-tampering SDKs notice that the app was re-signed and refuse to run or rotate behavior. The detection vectors are the things that change between the original signed IPA and the re-signed one.
What changes during re-signing
When you patch and re-sign an IPA:
- The bundle identifier may stay the same or change.
- The provisioning profile changes, yours instead of the developer’s.
- The signing certificate changes, your developer cert instead of theirs.
- The code signature blob changes.
_CodeSignature/CodeResourcesis regenerated.- Mach-O LC_CODE_SIGNATURE load command points at the new signature.
The original developer’s signature can no longer be verified (you do not have their private key). Apps that check whether their original signature is intact detect re-signing.
Detection vectors
1. Bundle identifier check. VulnLabAppiOS’s checkBundleID:
Highlight 1 is let expected = "com.vulnlab.iosapp" - the expected bundle ID is hardcoded; Frida intercepts -[NSBundle bundleIdentifier] and forces it to return this string, making any re-signed bundle appear legitimate.
Highlight 2 is let actual = Bundle.main.bundleIdentifier ?? "" - bundleIdentifier is an Objective-C property call; a single method hook replaces its return value, defeating the entire comparison on the next line.
If re-signing changed the bundle ID (which is sometimes necessary if the developer’s provisioning profile reserves the original), the check fires.
Bypass - checkBundleID is a private Swift method with no @objc thunk, so hook the underlying source of truth instead:
1
2
3
4
5
6
7
8
Interceptor.attach(ObjC.classes.NSBundle['- bundleIdentifier'].implementation, {
onLeave: function (retval) {
const v = new ObjC.Object(retval).toString();
if (v !== 'com.vulnlab.iosapp') {
retval.replace(ObjC.classes.NSString.stringWithString_('com.vulnlab.iosapp'));
}
}
});
2. Provisioning profile / entitlement check. VulnLabAppiOS’s checkEntitlements:
1
2
3
4
5
6
7
8
9
10
private func checkEntitlements() -> Bool {
guard let provisionPath = Bundle.main.path(forResource: "embedded",
ofType: "mobileprovision") else {
// No provisioning profile - distributed via App Store (acceptable)
return true
}
// String search on the plist - easily bypassed by patching the binary
let data = (try? Data(contentsOf: URL(fileURLWithPath: provisionPath))) ?? Data()
return data.range(of: "com.vulnlab.iosapp".data(using: .utf8)!) != nil
}
Bypass - checkEntitlements is private Swift with no @objc thunk. Short-circuit the NSBundle lookup instead:
1
2
3
4
5
6
7
8
9
10
11
12
Interceptor.attach(ObjC.classes.NSBundle['- pathForResource:ofType:'].implementation, {
onEnter: function (args) {
const name = new ObjC.Object(args[2]).toString();
const type = new ObjC.Object(args[3]).toString();
if (name === 'embedded' && type === 'mobileprovision') {
this.suppress = true;
}
},
onLeave: function (retval) {
if (this.suppress) retval.replace(ptr(0)); // nil → returns true (App Store path)
}
});
Or, keep the original embedded.mobileprovision in place when re-signing. Some patching tools leave it; some replace it. Worth checking.
3. Code signature verification via Mach-O header.
SecStaticCodeCheckValidity is macOS-only — it does not exist on iOS. Apps on iOS that implement signing checks instead read the Mach-O LC_CODE_SIGNATURE load command directly or check the cryptid field in LC_ENCRYPTION_INFO:
1
2
3
4
// Common pattern: verify signing team via entitlements dict
SecTaskRef task = SecTaskCreateFromSelf(NULL);
CFStringRef value = SecTaskCopyValueForEntitlement(task, CFSTR("application-identifier"), NULL);
// compare value against expected team + bundle ID
VulnLabAppiOS does not ship this variant; it appears in apps using commercial anti-tamper SDKs (Guardsquare, Promon).
Bypass - hook the entitlement lookup:
1
2
3
4
5
6
7
8
const secLib = Process.findModuleByName('Security');
const secTaskCopy = secLib && secLib.findExportByName('SecTaskCopyValueForEntitlement');
if (secTaskCopy) Interceptor.attach(secTaskCopy, {
onLeave: function (retval) {
// Return the expected application-identifier string
retval.replace(ObjC.classes.NSString.stringWithString_('ABCD1234.com.vulnlab.iosapp'));
}
});
4. Asset / resource hash verification.
Apps that compute hashes over their bundled resources and compare against expected values:
1
2
3
4
let asset = Bundle.main.url(forResource: "config", withExtension: "plist")!
let data = try Data(contentsOf: asset)
let hash = SHA256.hash(data: data).hexString
if hash != "expected-hash-..." { exitGracefully() }
If re-signing modified Info.plist or any resource (which it sometimes does, bundle ID change, entitlement injection), the hash mismatches.
Bypass: hook the comparison or patch the expected hash in the binary.
The “FairPlay encrypted” check
The IPA you download from the App Store has its main executable encrypted with FairPlay DRM. On a jailbroken device or after a memory dump, the executable is decrypted. Apps can check whether their own executable is encrypted:
1
2
3
4
5
uint32_t crypt_id = get_encryption_info()->cryptid;
if (crypt_id == 0) {
// Executable is not encrypted - we are running outside the App Store
exitGracefully();
}
For re-signed apps, the executable was likely decrypted at some point (since you needed to patch it). cryptid == 0 is the giveaway.
Bypass:
1
2
// Patch the cryptid field in the LC_ENCRYPTION_INFO load command
// or hook the function that reads it
The hook depends on which API the app uses to read cryptid. Most apps use getsegbyname("__LINKEDIT") or similar Mach-O introspection. Hook those.
A simpler approach, use a memory-dumping tool that preserves the encrypted state (Frida-iOS-Dump and similar handle this).
The combined drop-in script
Save as ios-resign-bypass.js:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// frida -U -f com.vulnlab.iosapp --no-pause -l ios-resign-bypass.js
setTimeout(function () {
// 1. Bundle ID spoof
Interceptor.attach(ObjC.classes.NSBundle['- bundleIdentifier'].implementation, {
onLeave: function (retval) {
const v = new ObjC.Object(retval).toString();
if (v !== 'com.vulnlab.iosapp') {
retval.replace(ObjC.classes.NSString.stringWithString_('com.vulnlab.iosapp'));
}
}
});
// 2. Suppress embedded.mobileprovision lookup → app takes the App Store path (returns true)
Interceptor.attach(ObjC.classes.NSBundle['- pathForResource:ofType:'].implementation, {
onEnter: function (args) {
const name = new ObjC.Object(args[2]).toString();
const type = new ObjC.Object(args[3]).toString();
if (name === 'embedded' && type === 'mobileprovision') this.suppress = true;
},
onLeave: function (retval) {
if (this.suppress) retval.replace(ptr(0)); // nil → guard fails → returns true
}
});
// 3. SecTaskCopyValueForEntitlement spoof (anti-tamper SDK variant)
const secLib = Process.findModuleByName('Security');
const secTaskCopy = secLib && secLib.findExportByName('SecTaskCopyValueForEntitlement');
if (secTaskCopy) Interceptor.attach(secTaskCopy, {
onLeave: function (retval) {
retval.replace(ObjC.classes.NSString.stringWithString_('ABCD1234.com.vulnlab.iosapp'));
}
});
// 4. exit watcher — identify which check fired if bypass is incomplete
const cLib = Process.findModuleByName('libsystem_c.dylib');
['exit', 'abort', '_exit'].forEach(function (sym) {
const addr = cLib && cLib.findExportByName(sym);
if (!addr) return;
Interceptor.attach(addr, {
onEnter: function () {
console.log('[' + sym + '] called');
console.log(Thread.backtrace(this.context, Backtracer.ACCURATE)
.map(DebugSymbol.fromAddress).join('\n'));
}
});
});
console.log('[*] re-signing bypass hooks armed');
}, 2000);
Detection chain
When testing whether an app has re-signing detection:
- Patch the IPA with FridaGadget.
- Re-sign with your developer profile.
- Install on the device.
- Launch. If it runs and reaches the main UI, no obvious detection.
- If it exits silently or shows a “this app cannot run on this device” message, detection fired.
The first hook should be on exit / abort to capture the stack at the moment of detection:
1
2
3
4
5
6
7
8
9
10
11
12
const cLib = Process.findModuleByName('libsystem_c.dylib');
['exit', 'abort', '_exit'].forEach(function (sym) {
const addr = cLib && cLib.findExportByName(sym);
if (!addr) return;
Interceptor.attach(addr, {
onEnter: function () {
console.log('[' + sym + '] called');
console.log(Thread.backtrace(this.context, Backtracer.ACCURATE)
.map(DebugSymbol.fromAddress).join('\n'));
}
});
});
The backtrace tells you which check fired. Apply the specific bypass.
The “use a paid Apple developer account” alternative
If you have a paid Apple developer account, you can sign with your team’s Apple Development cert, which gives a longer-lived profile and access to certain entitlements. The signature is still yours, not the developer’s, so apps that check the signing team still detect.
For deep testing, the workflow is:
- Use an enterprise distribution provisioning profile (if available to your testing program).
- Or test on a jailbroken device where re-signing is not needed.
- Or get the developer’s permission to use a debug build with their provisioning.
Each has trade-offs. For solo bug bounty, the jailbroken device is the most practical option.
Closing
iOS re-signing detection is the anti-piracy SDK angle. The detection vectors are well-understood. The bypass is iterative, hook exit, see the stack, bypass that check, re-attach. Worth knowing for engagements with anti-tamper SDKs (DexProtector, Promon, Guardsquare’s DexGuard for iOS) where the protection is layered.
Happy Hacking !!

