Anti-Disassembly on ARM64
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.
Anti-Disassembly on ARM64
Techniques for fighting disassemblers on ARM64 using inline assembly.
DCB / DCW / DCD / DCQ
Per the ARM documentation:
The
DCBdirective allocates one or more bytes of memory, and defines the initial runtime contents of the memory.
DCB (and DCW/DCD/DCQ likewise) allocates one or more bytes of memory and sets its initial value.
B = byte, W = word (2 bytes), D = dword (4 bytes), Q = qword (8 bytes).
ARM even gives a friendly example:
C_string DCB "C_string",0
Plainly, DCQ is an assembly directive for conveniently defining and initializing (string) constants. Since it’s a data definition, it belongs in a readable/writable data section. A sane compiler would never put DCQ in a read-only code section — but a less-sane security engineer might. Since DCQ is just data, we can build one with inline assembly: use .long followed by any four bytes. I’ll use 12345678 and 0x12345678 here (or 0xdeadbeef, whatever). Make a new Xcode demo and paste this in:
static __attribute__((always_inline)) void DCQ_Demo() {
#ifdef __arm64__
__asm__(
".long 0x12345678\n"
".long 12345678\n"
);
#endif
}
Clean, build, drop the product into IDA. There’s the familiar DCQ — those two .longs drag the following disassembly off the rails, and IDA stops parsing:

Now look at it dynamically in Xcode. The first .long (0x12345678) disassembles into a normal instruction. The second (12345678) can’t be decoded, so it’s marked unknown opcode. Before scrolling down, take a guess at what happens if you let execution run past the breakpoint:

0x12345678 decodes into a normal instruction. At runtime it modifies w24; since nothing references w24 here, that line has no side effect. But 12345678 (unknown opcode) — when the CPU reaches it and can’t decode it, it throws an exception and the program crashes.
Takeaway: DCQ is a normal assembly pseudo-instruction for declaring memory and setting its initial value. A DCQ in the code section (readable/executable, non-writable) can declare data. The resulting garbage instruction can’t be parsed by IDA nor executed by the CPU. Combined with other instructions it’s a useful obfuscation primitive.
B + DCQ
We’ve seen what DCQ is and how to build one with inline assembly. But DCQ is fundamentally data: if it decodes to a real instruction it has unpredictable runtime effects; if it can’t decode, the CPU throws.
How do you build a DCQ that confuses the disassembler but still lets the program run? Use a B instruction to “jump over” the undecodable instructions. The DCQ fools the disassembler; the B skips over the bad instructions.
How do you write a B in inline assembly? From the ARM documentation on B:
B
Branch causes an unconditional branch to a label at a PC-relative offset, with a hint that this is not a subroutine call or return.
Plain translation:
Bis an unconditional branch (conditional branches are a separate topic).Bis a relative branch — relative to what? The PC.Bisn’t a subroutine call, so there’s no return: unlikeBL, it doesn’t changeLR.
For newcomers: PC is the program counter. Each instruction advances it by one unit — four bytes on ARM64.
The OS loads programs with ASLR, so the load address differs every run, and the same code executes at a different PC each time. But that doesn’t affect a PC-relative branch — you just fix the relative offset.
0x100000000 b 0xc # PC = 0x100000000, so the destination = 0x100000000 + 0xc → jumps straight to C
0x100000004 A
0x100000008 B
0x10000000c C
0x100000010 D
0x100000014 E
So B skips over the disassembler-fooling instructions. The code below can be inserted anywhere — it’s just an extra b, no register clobber, no effect on program logic. The 0x12345678 between the Bs can be anything, and the padding length is arbitrary. The B operand = (padding length + 1) × 4. Below, two instructions are padded, so the operand is (2 + 1) × 4 = 12 = 0xc.
static __attribute__((always_inline)) void B_DCQ_Demo() {
#ifdef __arm64__
__asm__(
"b 0xc\n"
".long 12345678\n"
".long 12345678\n"
);
#endif
}
In IDA:

The observant reader may notice: same two .longs, yet the first screenshot’s subsequent code is all DCQ, while this one shows only two. Here’s my guess.
Disassembler scanning strategies fall into two camps: linear sweep and recursive-descent (flow-oriented) sweep. Linear sweep is easy to understand — just scan instruction by instruction. But it requires fixed-length instructions; variable lengths throw it off. (Honestly, for ARM64’s fixed 4-byte instructions, linear sweep is a reasonable fit.) Intel x86 is variable-length, and ARM32 can switch to 16-bit Thumb, so linear sweep breaks there — like copying answers off a neighbor’s scantron: shift by one and everything after is wrong.
So mainstream disassemblers use flow-oriented scanning. In screenshot two, when the disassembler hits the B instruction, the control flow forks, and IDA continues scanning from the B’s destination. The skipped region is left in a raw, un-disassembled form. In screenshot one, IDA hits a bad instruction, stops scanning that flow, and returns to the previous fork point to continue — which, if I’m right, is the next function.
That’s just my guess, not the point of this post, so don’t take it as gospel. Still, knowing a bit of decompiler internals helps you write better countermeasures.
Bogus control flow + DCQ
DCQ fools IDA but crashes at runtime; B skips over it but weakens the confusion. Is there something better? Yes — put the DCQ in a branch that never executes. That’s bogus control flow.
Constructing bogus control flow is a common trick with many variants. The main concern: the predicate must not be optimized away by a clever compiler (constant folding and the like). A few classic examples: for integer x, (x+1)(x+2) % 2 is always 0, and (x+1)(x+2)(x+3) % 3 is always 0. Negate a tautology and you get an always-false condition.
Here’s a bogus control flow built from Pythagorean triples. 3-4-5 is a real triple, so I’ll make it 3-4-6 — bogus enough. sqrt lives in math.h and won’t be optimized away. The variable names are sloppy; in production, pick names that won’t collide.
static __attribute__((always_inline)) void BogonControlFlow_DCQ_Demo() {
#ifdef __arm64__
int a = 3;
int b = 4;
int c = 6;
if (c < sqrt(a*a+b*b)) {
__asm__(
".long 12345678\n"
".long 12345678\n"
);
}
#endif
}
Dropping the garbage instructions into bogus control flow fools IDA — the viewDidLoad name even vanishes from the function list, and the disassembly turns red and can’t be decompiled (no F5):

B + bogus control flow + DCQ
With bogus control flow and DCQ, you can build more elaborate cases. Inside the never-executed branch you can mangle any instruction. This time add a B that jumps straight onto the garbage. IDA looks the same, but we can extend the trick into another obfuscation: stack imbalance.
static __attribute__((always_inline)) void B_BogonControlFlow_DCQ_Demo() {
#ifdef __arm64__
int a = 3;
int b = 4;
int c = 6;
if (c < sqrt(a*a+b*b)) {
__asm__(
"b 0x4\n"
".long 12345678\n"
);
}
#endif
}
B + bogus control flow + stack imbalance
static __attribute__((always_inline)) void B_BogonControlFlow_ADD_SP() {
#ifdef __arm64__
int a = 3;
int b = 4;
int c = 6;
if (c < sqrt(a*a+b*b)) {
__asm__(
"b 0x4\n"
"add sp,sp,#0x100\n"
"add sp,sp,#0x100\n"
);
}
#endif
}
The program runs on the stack, which grows downward (full-descending: from high to low addresses). Allocating space is sub sp, sp, #0x1234; reclaiming is add sp, sp, #0x1234. Allocated and reclaimed space must balance. What if they don’t? Above, the bogus control flow adds to sp, so when IDA analyzes it, the stack is unbalanced and F5 fails.

Indirect jumps with BR
Core idea: hide the jump target in a register referenced by BR. IDA is a static disassembler — it doesn’t compute register values during analysis.
From the official documentation on BR:
BR
Branch to Register branches unconditionally to an address in a register, with a hint that this is not a subroutine return.
Straight to code: hide the target in a register. Static analysis can’t know a register’s runtime value, so the analysis stalls.
The key question: how to get the address right after br before executing br? Use the position-independent ADR instruction to compute the address immediately after the br, stash it in x8, and br to it — the simplest indirect jump.
static __attribute__((always_inline)) void BR_2_X8() {
#ifdef __arm64__
__asm__(
"mov x8,#0x1\n"
"adr x9, #0x10\n"
"mul x8, x9, x8\n"
".long 0x12345678\n"
"br x8\n"
);
#endif
}

RET TO SELF
A fun one — I call it “ret to self”. As established, IDA scans flow-oriented, so if the program contains no flow (no B, BR, BL, BLR, etc.), IDA linearly sweeps to the function end. In other words, we can craft a case where IDA linearly scans to a ret and assumes the function has ended.
Straight to code. The first instruction computes, with adr, the PC of the instruction right after the ret. The second puts that address into x30. Why?
static __attribute__((always_inline)) void RET_2_SELF() {
#ifdef __arm64__
__asm__(
"adr x8,#0xc\n"
"mov x30,x8\n"
"ret\n"
);
#endif
}
Look at RET:
RET
Return from subroutine branches unconditionally to an address in a register, with a hint that this is a subroutine return.
When function a calls b, what happens when b returns? It jumps back to the instruction after the call site in a. Where’s that address stored? The LR register. What’s LR? x30.
So ret has an “equivalent” form: mov pc lr.
Now the code above is obvious: after ret, execution actually continues right after the ret itself. Hence “ret to self” is a fair name.
And IDA is fooled: it finds no flow, linearly hits the ret, and assumes the function ended. F5 yields an empty function.

Closing
Thanks for reading.
Code: https://github.com/AppleReer/Anti-Disassembly-On-Arm64
The snippets above are basic demos — combine them freely. Don’t copy-paste into production as-is; pollute a register and crash the program, and you’ll have some explaining to do.
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