tamior
2024·iOS · Frida·Target: DVIA

iOS Anti-Tampering Bypass

Working through jailbreak detection and anti-debug protections on DVIA with Frida.


The Target

DVIA, Damn Vulnerable iOS Application, is an intentionally broken iOS app used as a training ground for mobile security research. It implements the same detection patterns real apps use: file system-based jailbreak checks, ObjC method guards, ptrace-based anti-debugging. It’s a clean environment to understand each technique in isolation before dealing with the noise of a production binary.

Setup: frida-server on a jailbroken device over USB, attached with:

frida -U -F -l script.js

Jailbreak Detection: File System Layer

The most common check is also the most obvious: call stat() on a path that only exists on a jailbroken device, /Applications/Cydia.app, /private/var/lib/apt, /bin/bash. If the call succeeds, the device is flagged as jailbroken.

The hook sits at the C layer and intercepts every stat() call. When the path matches a known indicator, onLeave forces the return value to -1, same as if the file didn’t exist. The app gets back exactly what it would see on a clean device:

// Hook stat() to hide jailbreak paths from the app's checks.
const statPtr = Module.findGlobalExportByName('stat');
if (statPtr) {
    Interceptor.attach(statPtr, {
        onEnter(args) {
            const path = args[0].readCString();
            if (path && (
                path.includes('/Applications/Cydia.app') ||
                path.includes('/private/var/lib/apt') ||
                path.includes('/bin/bash')
            )) {
                this.shouldSpoof = true;
            }
        },
        onLeave(retval) {
            if (this.shouldSpoof) {
                retval.replace(ptr(-1));
            }
        }
    });
}

Same approach works for access() and fopen(), hook them the same way and return the appropriate failure value.

Jailbreak Detection: ObjC Layer

DVIA runs its checks inside JailbreakDetectionViewController methods. Hooking at the C layer covers the syscalls, but there’s a cleaner option here: replace the ObjC method implementation directly. The ObjC runtime stores method implementations as function pointers, and Frida can swap them out at runtime:

// DVIA implements its jailbreak checks as ObjC methods.
// We can replace the implementation entirely via the ObjC runtime.
const JBDetection = ObjC.classes.JailbreakDetectionViewController;
const method = JBDetection['- jailbreakTest1Tapped:'];
const origImpl = method.implementation;

Interceptor.replace(origImpl, new NativeCallback(function(self, sel, sender) {
    // no-op - the check never runs
    console.log('[*] jailbreakTest1Tapped suppressed');
}, 'void', ['pointer', 'pointer', 'pointer']));

The replacement callback still has to match the ObjC calling convention, self and _cmd (the selector) come first, then any declared parameters. Get the signature wrong and you’ll corrupt the stack.

Anti-Debug: ptrace

iOS apps can call ptrace(PT_DENY_ATTACH, 0, 0, 0) to tell the kernel not to allow any debugger to attach. If a debugger is already attached when the call fires, the process is killed. DVIA does this early in startup.

The fix is to replace ptrace entirely and intercept request 31 before it reaches the kernel. Everything else, legitimate ptrace calls, gets forwarded to the original. Note that origPtrace is constructed before Interceptor.replace runs, so the trampoline pointer is stable when the callback eventually uses it:

// Apps call ptrace(PT_DENY_ATTACH, 0, 0, 0) early in startup
// to prevent debuggers from attaching. PT_DENY_ATTACH is request 31.
// If you're already attached when it fires, the process dies.
// Hook it before that happens and drop the call.
const ptraceAddr = Module.findGlobalExportByName('ptrace');
if (ptraceAddr) {
    const origPtrace = new NativeFunction(
        ptraceAddr, 'int', ['int', 'int', 'pointer', 'pointer']
    );
    Interceptor.replace(ptraceAddr, new NativeCallback(
        function(request, pid, addr, data) {
            if (request === 31) {
                console.log('[*] PT_DENY_ATTACH suppressed');
                return 0;
            }
            return origPtrace(request, pid, addr, data);
        },
        'int', ['int', 'int', 'pointer', 'pointer']
    ));
}

Some apps also call sysctl with KERN_PROC / P_TRACED to check if a debugger is attached. That’s a separate hook on sysctl, same idea, different syscall.