Information Security 18 min read

Using Inline ARM64 Assembly in iOS for Debug Detection and Security Hardening

This article explains how to write and integrate ARM64 inline assembly in iOS applications to implement anti‑debugging checks, discusses the syntax, constraints, calling conventions, common pitfalls, and provides complete assembly‑only solutions for secure runtime detection.

Sohu Tech Products
Sohu Tech Products
Sohu Tech Products
Using Inline ARM64 Assembly in iOS for Debug Detection and Security Hardening

Introduction

The idea of writing an article about using assembly on iOS has been lingering for a while, and recent security‑hardening work requires more assembly to improve protection (the assembly uses the ARM64 instruction set).

Inline Assembly Syntax

__asm__ [keyword](
    instruction
    : [output operand list]
    : [input operand list]
    : [clobbered register list]
);

Example of adding three variables a = b + c :

__asm__ volatile(
    "mov x0, %[b]\n"
    "mov x1, %[c]\n"
    "add x2, x0, x1\n"
    "mov %[a], x2\n"
    : [a]"=r"(a)
    : [b]"r"(b), [c]"r"(c)
);

volatile

The volatile keyword prevents the compiler from re‑optimising the assembly, though in practice the generated code is often the same with or without it.

Operands

Operands are written as [limits]constraint . For example, =r means write‑only and stored in a general‑purpose register.

limits

Keyword

Meaning

=

write‑only, general‑purpose output operand

+

read‑write, can only be used for output operands

&

declare that the register is only used for output

constraint

Keyword

Meaning

f

floating‑point registers f0‑f7

G/H

floating‑point immediate constants

I/L/K

immediates used in data processing

J

index in the range -4095~4095

l/r

registers r0‑r15

M

constants 0~2⁵/2ⁿ

m

memory address

w

vector registers s0‑s31

X

any type of operand

Instructions

Because ARM64 has many instructions, only a few key syntaxes are described here. Parameter placeholders use %0~%N or %[param] :

__asm__ volatile(
    "mov x0, %1\n"
    "mov x1, %2\n"
    "add x2, x0, x1\n"
    "mov %0, x2\n"
    : "=r"(a)
    : "r"(b), "r"(c)
);

Using named parameters ( %[param] ) improves readability.

Register Access

Square brackets around a register treat its stored value as an address, e.g.:

"mov x0, #0x10086\n"
"mov x1, [x0]\n"
"mov x2, #0x100086\n"
"str x1, [x2]\n"

Immediate values start with # and are usually written in hexadecimal.

Calling Convention

ARM64 follows the AAPCS64 convention: the first eight arguments are placed in x0‑x7 ; additional arguments are passed on the stack. Return values are placed in x0 (or x0/x8 for larger structs). The table below lists special registers:

Register

Special Name

Rule

r31

SP

Stack‑pointer

r30

LR

Link register (return address)

r29

FP

Frame pointer

r19‑r28

Caller‑saved registers (must be preserved by callee)

r18

Platform register, not recommended for temporary use

r17

IP1

In‑process register, not recommended for temporary use

r16

IP0

Same as r17, also used for syscall arguments in

svc

r9‑r15

Temporary registers (used to hold function addresses when embedding assembly)

r8

Return‑value register (also temporary)

r0‑r7

Argument registers;

r0

also holds return value

NZCV

Status flags register

Practical Example – Debug Detection

Original C Implementation

__attribute__((__always_inline)) bool checkTracing() {
    size_t size = sizeof(struct kinfo_proc);
    struct kinfo_proc proc;
    memset(&proc, 0, size);
    int name[4];
    name[0] = CTL_KERN;
    name[1] = KERN_PROC;
    name[2] = KERN_PROC_PID;
    name[3] = getpid();
    sysctl(name, 4, &proc, &size, NULL, 0);
    return proc.kp_proc.p_flag & P_TRACED;
}

Because fishhook can replace symbols, using sysctl directly is unsafe; many developers replace the call with an inline‑assembly version.

Inline‑Assembly Version (initial attempt)

size_t size = sizeof(struct kinfo_proc);
struct kinfo_proc proc;
memset(&proc, 0, size);
int name[4];
name[0] = CTL_KERN;
name[1] = KERN_PROC;
name[2] = KERN_PROC_PID;
name[3] = getpid();
__asm__(
    "mov x0, %[name_ptr]\n"
    "mov x1, #4\n"
    "mov x2, %[proc_ptr]\n"
    "mov x3, %[size_ptr]\n"
    "mov x4, #0x0\n"
    "mov x5, #0x0\n"
    "mov w16, #202\n"
    "svc #0x80\n"
    :
    :[name_ptr]"r"(&name), [proc_ptr]"r"(&proc), [size_ptr]"r"(&size)
);
return proc.kp_proc.p_flag & P_TRACED;

The compiled code generated temporary variables that overwrote the registers used for the svc call, causing the application to hang.

Pitfall Explanation

// Function entry generated temporaries
add x0, sp, #0x24   // x0 = name
add x1, sp, #0x34   // x1 = proc (incorrectly overwritten later)
add x2, sp, #0x20   // x2 = size
...
// Inline assembly
mov x0, x0          // name OK
mov x1, #4          // proc data corrupted
mov x2, x1          // size data corrupted
...

The order of temporaries does not match the order expected by the svc call.

Fix – Insert Temporary Variable

By inserting a dummy variable between name and proc , the registers line up correctly:

int placeholder;
int name[4];
/* ... fill name ... */

Compiled assembly now aligns x0 → name, x1 → placeholder, x2 → proc, x3 → size.

Fix – Reorder Operand Loading

__asm__(
    "mov x0, %[name_ptr]\n"
    "mov x3, %[size_ptr]\n"
    "mov x2, %[proc_ptr]\n"
    "mov x1, #4\n"
    "mov x4, #0x0\n"
    "mov x5, #0x0\n"
    "mov w16, #202\n"
    "svc #0x80\n"
    :
    :[name_ptr]"r"(&name), [proc_ptr]"r"(&proc), [size_ptr]"r"(&size)
);

Now the registers are set before they are clobbered, and the call works.

Full Assembly Implementation

When mixing C and assembly cannot guarantee register safety, a completely assembly‑only implementation is preferable. Key points:

Mark the function as __attribute__((naked)) to avoid compiler‑generated prologue/epilogue.

Allocate all variables on the stack and manage the stack manually.

Use safe registers r19‑r28 for temporary storage.

Stack layout (simplified):

----------
|   FP   |
----------  sp + 0x2f8
|   LR   |
----------  sp + 0x2f0
|   r20  |
... (r19‑r28 saved)
| p_size |
----------  sp + 0x298
|  proc |
----------  sp + 0x10
|  name |
----------  sp

After saving registers, the function performs:

Allocate 0x2a0 bytes on the stack.

Store name , proc , and size in r19‑r22 .

Call memset via a function pointer.

Fill the name array, using svc to obtain getpid() .

Invoke sysctl with the prepared registers.

Load proc.kp_proc.p_flag , mask with P_TRACED , and return the result.

Example of Loading the Flag

#define P_TRACED 0x00000800
__asm__ volatile(
    "ldr x24, [x20, #0x20]\n"   // x24 = proc.kp_proc.p_flag
    "mov x25, #0x800\n"          // x25 = P_TRACED
    "and x0, x24, x25\n"        // x0 = x24 & x25 (result)
);

Further Reading

[1] https://www.theiphonewiki.com/wiki/Kernel_Syscalls [2] https://juejin.im/post/5cadeda55188251ad87b0eed [3] https://juejin.im/post/5a786c555188257a6854b18c [4] http://infocenter.arm.com/help/topic/com.arm.doc.ihi0055b/IHI0055B_aapcs64.pdf

iOSsecurityARM64sysctlanti-debugginginline assembly
Sohu Tech Products
Written by

Sohu Tech Products

A knowledge-sharing platform for Sohu's technology products. As a leading Chinese internet brand with media, video, search, and gaming services and over 700 million users, Sohu continuously drives tech innovation and practice. We’ll share practical insights and tech news here.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.