A Universal iOS Anti-Anti-Debugging Tweak

About the author. I’m Colin Xiao — an independent reverse engineer, mostly working solo on contract jobs. I dig into compiled code of every kind, including but not limited to: Android and iOS apps, x86/x64/ARM native binaries, packers and obfuscators, DRM and license checks, network protocols and APIs, crypto and signature algorithms, malware, game binaries, and embedded/IoT firmware. I also handle large-scale data collection and vulnerability research. If you have any project in mind, I’d be glad to take it on — whatever it is, as long as it’s within my wheelhouse. Just reach out. You can reach me on X at @colinxiao_lab or on Telegram at https://t.me/ColinXiao666.

This research is published for educational and defensive purposes. The target sample is anonymized throughout.


A Universal iOS Anti-Anti-Debugging Tweak

I’ve written before about iOS anti-debugging and its bypasses (dynamically bypassing inline svc anti-debugging). This post shares a tool I put together that makes bypassing anti-debugging easy.

Background

Prerequisites:

  • Mach-O file structure
  • dyld link/load flow
  • iOS inline hooking
  • PreferenceLoader and AppList module integration

The usual ways to bypass anti-debugging: write a hooking tweak per app, or launch under a debugger, locate the anti-debugging point, and NOP the instruction or patch a register. The other day, while using RevealLoader, I noticed its UI and thought — what if there were a universal tweak where you just tick an app and it bypasses that app’s anti-debugging, no per-app analysis needed? So I built this from my accumulated notes on the topic.

Integrating AppList and PreferenceLoader

Reading RevealLoader’s source, adding a per-app switch in Settings needs two modules: PreferenceLoader and AppList.

PreferenceLoader (a MobileSubstrate module) lets developers add an app entry to the system Settings. AppList is a library for reading installed apps. Combine the two and you get RevealLoader’s UI.

For the tweak, use MonkeyDev: create a Logos Tweak project, then under /Package/Library create a PreferenceLoader/Preferences folder holding ProjectName.plist and the tweak’s icon:

The plist’s contents — ALSettingsPath is where the tweak’s settings are stored; ALSettingsKeyPrefix is the per-app key prefix:

Add AppList and PreferenceLoader to the Depends field in control:

Writing the tweak

The code first reads the plist config. It joins the prefix with the app’s bundle id to decide whether the switch is on for this app; if so, it runs the AntiDebug_Hook below:

NSDictionary *pref = [NSDictionary dictionaryWithContentsOfFile:@"/var/mobile/Library/Preferences/com.yunnigu.AntiAntiDebug.plist"];
    NSString *keyPath = [NSString stringWithFormat:@"AntiAntiDebugEnabled-%@", [[NSBundle mainBundle] bundleIdentifier]];
    if ([[pref objectForKey:keyPath] boolValue]) {
        NSLog(@"-------AntiAntiDebug----------Start-------------");
        //ptrace
        MSHookFunction((void *)MSFindSymbol(NULL,"_ptrace"),(void*)my_ptrace,(void**)&orig_ptrace);
        //dlsym
        MSHookFunction((void *)dlsym,(void*)my_dlsym (void**)&orig_dlsym);
        //sysctl
        MSHookFunction((void *)sysctl,(void*)my_sysctl,(void**)&orig_sysctl);
        //syscall
        MSHookFunction((void *)syscall,(void*)my_syscall,(void**)&orig_syscall);
        //svc 0x80
        hook_svc_x80();
        NSLog(@"[AntiAntiDebug] Module loaded!!!");
        NSLog(@"-------AntiAntiDebug----------End-------------");
    }

I’ve covered the general anti-debugging knowledge before; here I’ll focus on hooking svc 0x80.

Hooking svc 0x80

This technique is how apps call ptrace and friends via inline assembly. The usual bypass NOPs that assembly, which works well when you know the call’s address. But this tool can’t know it, so instead it scans the __text segment for svc 0x80 instructions. Since apps sometimes use svc 0x80 legitimately, a blanket NOP could crash — so after finding each svc 0x80, we hook it and inspect the arguments to decide whether it’s anti-debugging.

Here’s hook_svc0x80, built on Hookzz (now Dobby). Comments explain each step; understanding the svc 0x80 retrieval flow requires some Mach-O familiarity.

void hook_svc_x80() {
    zaddr svc_x80_addr;
    zaddr curr_addr, text_start_addr, text_end_addr;
    uint32_t svc_x80_byte = 0xd4001001;
    // Get the main binary's mach_header via _dyld_get_image_header
    const struct mach_header *header = _dyld_get_image_header(0);
    // Get the __TEXT segment structure
    struct segment_command_64 *seg_cmd_64 = zz_macho_get_segment_64_via_name((struct mach_header_64 *)header, (char *)"__TEXT");
    // Compute the slide from the in-memory base to __TEXT
    zsize slide = (zaddr)header - (zaddr)seg_cmd_64->vmaddr;
    // Get the __text section structure
    struct section_64 *sect_64 = zz_macho_get_section_64_via_name((struct mach_header_64 *)header, (char *)"__text");
    // Compute the actual __text addresses
    text_start_addr = slide + (zaddr)sect_64->addr;
    text_end_addr = text_start_addr + sect_64->size;
    curr_addr = text_start_addr;
    // Loop to find svc 0x80 instructions and hook them
    while (curr_addr < text_end_addr) {
        svc_x80_addr = (zaddr)zz_vm_search_data((zpointer)curr_addr, (zpointer)text_end_addr, (zbyte *)&svc_x80_byte, 4);
        if (svc_x80_addr) {
            NSLog(@"hook svc #0x80 at %p with aslr (%p without aslr)",
                  (void *)svc_x80_addr, (void *)(svc_x80_addr - slide));
            ZzBuildHookAddress((void *)svc_x80_addr, (void *)(svc_x80_addr + 4),
                               hook_svc_pre_call, hook_svc_half_call, TRUE);
            ZzEnableHook((void *)svc_x80_addr);
            curr_addr = svc_x80_addr + 4;
        } else {
            break;
        }
    }
}


struct section_64 *
zz_macho_get_section_64_via_name(sstruct segment_command_64 *seg_cmd_64,
                                 char *sect_name) {
    struct section_64 *sect_64;
   
    sect_64 = (struct section_64 *)((zaddr)seg_cmd_64 + sizeof(struct segment_command_64));
    // Loop the __TEXT sections and return __text
    for (zsize j = 0; j < seg_cmd_64->nsects; 
		j++, sect_64 = (struct section_64 *)((zaddr)sect_64 + sizeof(struct section_64))) {
        if (!strcmp(sect_64->sectname, sect_name)) {
        	return sect_64;
        }
    }
}


zpointer zz_vm_search_data(const zpointer start_addr, zpointer end_addr, zbyte *data,
                           zsize data_len)
{
    zpointer curr_addr;
    if (start_addr <= (zpointer)0)
        printf("search address start_addr(%p) < 0", (zpointer)start_addr);
    if (start_addr > end_addr)
        printf("search start_add(%p) < end_addr(%p)", (zpointer)start_addr, (zpointer)end_addr);
    
    curr_addr = start_addr;
    
    while (end_addr > curr_addr)
    {
        if (!memcmp(curr_addr, data, data_len))
        {
            return curr_addr;
        }
        curr_addr = (zpointer)((zaddr)curr_addr + data_len);
    }
    return 0;
}

struct segment_command_64 *
zz_macho_get_segment_64_via_name(struct mach_header_64 *header,
                                 char *segment_name) {
    struct load_command *load_cmd;
    struct segment_command_64 *seg_cmd_64;
    struct section_64 *sect_64;
    // Locate the load_cmd address
    load_cmd = (struct load_command *)((zaddr)header + sizeof(struct mach_header_64));
    // Loop the load_cmd segments and return __TEXT
    for (zsize i = 0; i < header->ncmds;
         i++, load_cmd = (struct load_command *)((zaddr)load_cmd + load_cmd->cmdsize)) {
        if (load_cmd->cmd == LC_SEGMENT_64) {
            seg_cmd_64 = (struct segment_command_64 *)load_cmd;
            if(!strcmp(seg_cmd_64->segname, segment_name)) {
                return seg_cmd_64;
            }
        }
    }
    return NULL;
}


void hook_svc_pre_call(RegState *rs, ThreadStack *threadstack, CallStack *callstack) {
    int num_syscall;
    int request;
    num_syscall = (int)(uint64_t)(rs->general.regs.x16);
    request = (int)(uint64_t)(rs->general.regs.x0);
    
    if (num_syscall == SYS_syscall) {
        int arg1 = (int)(uint64_t)(rs->general.regs.x1);
        if (request == SYS_ptrace && arg1 == PT_DENY_ATTACH) {
            *(unsigned long *)(&rs->general.regs.x1) = 0;
            NSLog(@"[AntiAntiDebug] catch 'SVC #0x80; syscall(ptrace)' and bypass");
        }
        
    } else if (num_syscall == SYS_ptrace) {
        request = (int)(uint64_t)(rs->general.regs.x0);
        if (request == PT_DENY_ATTACH) {
            *(unsigned long *)(&rs->general.regs.x0) = 0;
            NSLog(@"[AntiAntiDebug] catch 'SVC-0x80; ptrace' and bypass");
        }
    } else if(num_syscall == SYS_sysctl) {
        STACK_SET(callstack, (char *)"num_syscall", num_syscall, int);
        STACK_SET(callstack, (char *)"info_ptr", rs->general.regs.x2, zpointer);
    }
}

void hook_svc_half_call(RegState *rs, ThreadStack *threadstack, CallStack *callstack) {
    
    if(STACK_CHECK_KEY(callstack, (char *)"num_syscall")) {
        int num_syscall = STACK_GET(callstack, (char *)"num_syscall", int);
        struct kinfo_proc *info = STACK_GET(callstack, (char *)"info_ptr", struct kinfo_proc *);
        if (num_syscall == SYS_sysctl)
        {
            NSLog(@"[AntiAntiDebug] catch 'SVC-0x80; sysctl' and bypass");
            info->kp_proc.p_flag &= ~(P_TRACED);
        }
    }
}

Summary

So far it bypasses anti-debugging on every app I’ve tested. If you hit one it can’t handle, leave a comment — and thanks in advance for helping improve it.

On an iOS 12.1.1 device I noticed that after ticking an app, debugserver couldn’t find the process; attaching at launch worked around it. The root cause is still under investigation.

Project on GitHub: https://github.com/yunnigu/AntiAntiDebug


Colin Xiao — I take on contract reverse-engineering work, and writeups like this are how I show what I can do. If you need help with anything in this space — including but not limited to the areas I cover above — I’m available. No project is too big or too small — if it’s reverse engineering and I can do it, I’ll take it. DM me on X (@colinxiao_lab) or Telegram (@ColinXiao666).

Get in touch