Apps that try to detect debuggers on iOS use three primary techniques: ptrace(PT_DENY_ATTACH) to refuse attachment, sysctl to check the process’s P_TRACED flag, and getppid() comparisons. Each is bypassable with one Frida hook. Apps that ship these as their only debugger defence are not actually defended.
Vector 1, ptrace PT_DENY_ATTACH
The classic. The app calls:
1
2
3
extern int ptrace(int request, pid_t pid, caddr_t addr, int data);
#define PT_DENY_ATTACH 31
ptrace(PT_DENY_ATTACH, 0, 0, 0);
This tells the kernel “do not let any debugger attach to this process”. If a debugger is already attached when the call happens, the kernel SIGKILLs the process. If a debugger tries to attach later, the attach fails.
The bypass: intercept the ptrace call and turn it into a no-op:
1
2
3
4
5
6
7
8
9
10
const kernLib = Process.findModuleByName('libsystem_kernel.dylib');
const ptrace = kernLib && kernLib.findExportByName('ptrace');
if (ptrace) Interceptor.attach(ptrace, {
onEnter: function (args) {
if (args[0].toInt32() === 31) { // PT_DENY_ATTACH
console.log('[ptrace PT_DENY_ATTACH] intercepted');
args[0] = ptr(0); // replace with PT_TRACE_ME (harmless)
}
}
});
The app’s call to ptrace(PT_DENY_ATTACH, ...) becomes a no-op. The kernel does not lock down the process. Frida (which has to attach before the ptrace call fires) survives.
The timing nuance: if the app calls ptrace(PT_DENY_ATTACH) very early in startup (load time methods or +initialize), Frida via frida -F (cold-spawn) catches it. Frida via frida -n (attach to running process) fails because the call already fired.
Vector 2, sysctl P_TRACED
VulnLabAppiOS’s checkSysctlTraced:
Highlight 1 is sysctl(&mib, UInt32(mib.count), &info, &size, nil, 0) - reads the kernel kinfo_proc structure for the current PID; the P_TRACED flag inside that structure indicates a debugger is attached.
Highlight 2 is return (info.kp_proc.p_flag & P_TRACED) != 0 - a single Frida hook on sysctl that clears p_flag in the output buffer zeroes this entire detection; the function then returns false regardless of actual debugger state.
The kernel reports the process’s current flags via sysctl. If the P_TRACED flag is set (debugger attached), the check fires.
Bypass: hook sysctl and clear the flag from the returned struct:
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
const kernLib = Process.findModuleByName('libsystem_kernel.dylib');
const sysctl = kernLib && kernLib.findExportByName('sysctl');
if (sysctl) Interceptor.attach(sysctl, {
onEnter: function (args) {
this.mib = args[0];
this.namelen = args[1].toInt32();
this.oldp = args[2];
},
onLeave: function (retval) {
if (this.namelen < 4) return;
const mib0 = this.mib.readU32();
const mib1 = this.mib.add(4).readU32();
const mib2 = this.mib.add(8).readU32();
if (mib0 === 1 /* CTL_KERN */ && mib1 === 14 /* KERN_PROC */ &&
mib2 === 1 /* KERN_PROC_PID */) {
// The oldp now contains struct kinfo_proc. Find p_flag and clear P_TRACED.
// kp_proc starts at offset 0 in struct kinfo_proc.
// p_flag is at offset 32 within struct extern_proc (kp_proc).
const pFlagAddr = this.oldp.add(32);
const flag = pFlagAddr.readU32();
const P_TRACED = 0x00000800;
if (flag & P_TRACED) {
pFlagAddr.writeU32(flag & ~P_TRACED);
}
}
}
});
The check sees p_flag without the P_TRACED bit. Detection passes.
Vector 3, getppid
VulnLabAppiOS’s checkGetppid:
1
2
3
private func checkGetppid() -> Bool {
return getppid() != 1
}
On non-debugged processes the parent is launchd (PID 1). On debugged processes the parent is the debugger.
Bypass — spoof the syscall return value:
1
2
3
4
5
6
7
const kernLib = Process.findModuleByName('libsystem_kernel.dylib');
const getppid = kernLib && kernLib.findExportByName('getppid');
if (getppid) Interceptor.attach(getppid, {
onLeave: function (retval) {
retval.replace(1);
}
});
Note: checkSysctlTraced and checkGetppid are private Swift methods — no @objc thunk, not hookable by ObjC selector. Hook the underlying syscalls instead (shown above).
The check sees parent PID 1, concludes no debugger.
The exit-on-detect pattern
Apps that detect a debugger typically respond with one of:
1
2
3
4
exit(0);
abort();
_exit(0);
*(char*)0 = 0; // intentional crash
The exit hooks itself:
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 - stack:');
console.log(Thread.backtrace(this.context, Backtracer.ACCURATE)
.map(DebugSymbol.fromAddress).join('\n'));
}
});
});
Watching the stack at the moment of exit tells you which check fired and where. Then apply the specific bypass and re-attach.
The combined drop-in script
The three vectors plus the exit watcher stitch into a single file. Save as ios-debug-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
51
52
53
54
55
56
// frida -U -f com.vulnlab.iosapp --no-pause -l ios-debug-bypass.js
setTimeout(function () {
const kernLib = Process.findModuleByName('libsystem_kernel.dylib');
const cLib = Process.findModuleByName('libsystem_c.dylib');
// 1. ptrace PT_DENY_ATTACH → no-op
const ptrace = kernLib && kernLib.findExportByName('ptrace');
if (ptrace) Interceptor.attach(ptrace, {
onEnter: function (args) {
if (args[0].toInt32() === 31) args[0] = ptr(0); // PT_DENY_ATTACH → harmless
}
});
// 2. sysctl P_TRACED → cleared
const sysctl = kernLib && kernLib.findExportByName('sysctl');
if (sysctl) Interceptor.attach(sysctl, {
onEnter: function (args) {
this.mib = args[0];
this.namelen = args[1].toInt32();
this.oldp = args[2];
},
onLeave: function (retval) {
if (this.namelen < 4) return;
const mib0 = this.mib.readU32();
const mib1 = this.mib.add(4).readU32();
const mib2 = this.mib.add(8).readU32();
if (mib0 === 1 && mib1 === 14 && mib2 === 1) { // CTL_KERN, KERN_PROC, KERN_PROC_PID
const pFlagAddr = this.oldp.add(32);
const flag = pFlagAddr.readU32();
const P_TRACED = 0x00000800;
if (flag & P_TRACED) pFlagAddr.writeU32(flag & ~P_TRACED);
}
}
});
// 3. getppid → 1
const getppid = kernLib && kernLib.findExportByName('getppid');
if (getppid) Interceptor.attach(getppid, {
onLeave: function (retval) { retval.replace(1); }
});
// 4. exit watcher — identify which check fired if bypass is incomplete
['exit', '_exit', 'abort'].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('[*] debugger bypass hooks armed');
}, 2000);
Load it cold-spawn (-f) for best results — ptrace bypass must be in place before startup:
1
frida -U -f com.vulnlab.iosapp -l ios-debug-bypass.js
For attach (-n), the ptrace bypass is too late, the PT_DENY_ATTACH call already fired during app startup and the kernel has already refused the attach. But the sysctl and getppid bypasses still work for ongoing checks (the polling-style detections that re-check throughout the app’s lifetime).
Native-side variants
Beyond the three syscall-level checks, apps using anti-tamper SDKs add native-side detection:
- Direct
task_for_pidcalls that should fail for non-privileged callers. mach_port_request_notificationon the task port, succeeds for debugged processes.- Comparing
_dyld_image_countto expected values.
These are bypassed by hooking the relevant Mach functions. The pattern is the same as the libc hooks but on different symbols (task_for_pid, host_processor_set_priv, etc.).
The “no-debugger” theatre
Apps that combine all the above checks into a deep “is anything attached” routine still fall to the methodology of:
- Hook
exit/abortto see the stack at the moment of detection. - Identify the specific check by the calling function.
- Apply the bypass for that check.
- Re-attach.
Iterate until the app stops exiting. Usually two or three iterations are enough.
Closing
iOS debugger detection is the bypass-on-attach exercise that every iOS pentester runs at the start of an engagement. The three vectors are well-known. The drop-in script above covers them. For the deeper anti-tamper SDKs, the methodology is hook-exit → see stack → bypass that check → repeat.
Happy Hacking !!

