14 minute read

Past lessons in this series:

Note: We work on the macOS dialect of the AARCH64 ARM assembly, but I may happen to jump on Linux (and why not? some BSDs!). I’m not digressing. I think that seeing how different OS solve a given problem gives you a better understanding of the overarching structures. Sorry, no Windows here - I am not that good on that system anymore.

Hello, debugger!

I made a deliberate choice: avoid the “hello world” program. This is more to defy the laws of tradition rather than for any pedagogical reason. However, this choice forces me to give you a tool to see what’s going on in the machine: having no printouts is a severe limitation after all. But reversers need to sharpen their teeth at the beginning of their career on the tool they’ll use forever: the debugger. I learnt reversing this way, I teach it this way.

Another note: I use nano in these initial programs. On more complex projects, working with nano is not easy and you may want to choose an editor that fits your needs better. When it comes to assembly, VS Code is great — and so is Xcode. If you have better options, off you go, select the one you want.

Now open a terminal and create a new file - main.s:

nano main.s

Fill this new file with:

.global _main
.extern _exit

_main:
    mov X11, #0x1723

    mov X0, #0x0000    // exit code
    bl _exit           // invokes exit

This is not so different from the ASM_o_DETH we saw in https://bytearchitect.io/macos-security/security-reversing/Introduction-to-registers/ and if you followed the lesson https://bytearchitect.io/macos-security/security-reversing/More-on-registers-the-ABI/ you already know what this program does: it moves an immediate value (a constant, expressed in hexadecimal format) into a register (X11), then it exits with the value 0.

Then compile it:

gabrielebiondo@RevEng3 02-mov % as -mmacosx-version-min=14.0 main.s
gabrielebiondo@RevEng3 02-mov % file main.o
main.o: Mach-O 64-bit object arm64

and link it:

gabrielebiondo@RevEng3 02-mov % SDK=$(xcrun --sdk macosx --show-sdk-path)
gabrielebiondo@RevEng3 02-mov % ld -arch arm64 -platform_version macos 14.0 14.0 -syslibroot "$SDK" -lSystem  -o hellodebugger  main.o
gabrielebiondo@RevEng3 02-mov % ls -al
total 88
drwxr-xr-x@ 5 gabrielebiondo  staff    160 12 Dec 07:14 .
drwxr-xr-x@ 8 gabrielebiondo  staff    256 12 Dec 06:55 ..
-rwxr-xr-x@ 1 gabrielebiondo  staff  33440 12 Dec 07:14 hellodebugger
-rw-r--r--@ 1 gabrielebiondo  staff    408 12 Dec 07:11 main.o
-rw-r--r--@ 1 gabrielebiondo  staff    133 12 Dec 07:01 main.s
gabrielebiondo@RevEng3 02-mov % file hellodebugger
hellodebugger: Mach-O 64-bit executable arm64

If you read the previous lessons, the syntax should be quite easy to understand. Here we just forced the minimum macOS version compatibility, but you could also do the same with the default values:

gabrielebiondo@RevEng3 02-mov % mv main.o main14.o
gabrielebiondo@RevEng3 02-mov % as main.s
gabrielebiondo@RevEng3 02-mov % ls
hellodebugger	main.o		main.s		main14.o
gabrielebiondo@RevEng3 02-mov % file main.o
main.o: Mach-O 64-bit object arm64
gabrielebiondo@RevEng3 02-mov % ld -arch arm64 -syslibroot $SDK -o hellodebugger26 -lSystem
ld: Missing -platform_version option
gabrielebiondo@RevEng3 02-mov % ld \
  -arch arm64 \
  -platform_version macos 26.0 26.0 \
  -syslibroot "$SDK" \
  -lSystem \
  -o hellodebugger26 \
  main.o

From now on, I assume you have no issues compiling and linking. Running this program gives you nothing (try if you want).

Interestingly, the fact that the code has been compiled for different macOS versions, in this case, does not change its size:

gabrielebiondo@RevEng3 02-mov % ls -al hellodebugger*
-rwxr-xr-x@ 1 gabrielebiondo  staff  33440 12 Dec 07:14 hellodebugger
-rwxr-xr-x@ 1 gabrielebiondo  staff  33440 12 Dec 07:17 hellodebugger26

Well, so far nothing new. In the same terminal now run:

lldb hellodebugger

or lldb <name-of-the-executable-you-just-obtained>. You should obtain:

gabrielebiondo@RevEng3 02-mov % lldb hellodebugger
(lldb) target create "hellodebugger"
Current executable set to '/Users/gabrielebiondo/MiB/51 - Reversing 101/reversing-101/lessons/02-mov/hellodebugger' (arm64).
(lldb)

lldb is the main debugger we will use for the rest of this course. When working with Linux, we may end up using gdb and peda. We’ll revert to gdb also when working with other BSD flavours.

We have attached (technical term. This one is as horrible as immediate for constants!) lldb to a process. Now we can use it to debug the process.

When working with REPLs, the first thing you want to do is to invoke the help. In this case, you receive a wall of text. If I were to explain all the commands, concepts, and options, we’d be here for another dozen of lessons, so let’s start the hard way: I will explain commands when we use them, and for the minimum extent required for our goals.

If you are not fond with debuggers, you may think that the first thing you have to do is running the program:

(lldb) run
Process 55042 launched: '/Users/gabrielebiondo/reversing-101/lessons/02-mov/hellodebugger' (arm64)
Process 55042 exited with status = 0 (0x00000000)

and with contempt you realise that you cannot interact with the program. Actually this is how the debugger is meant to work: it stops at breakpoints. So you need to create a breakpoint. Do you remember that in past lessons I emphasised the _main symbol? Well, that symbol exists within the present scope, what you need is a breakpoint. It only has a different name - main, no trailing underscore.

If you do:

(lldb) breakpoint
Commands for operating on breakpoints (see 'help b' for shorthand.)

Syntax: breakpoint <subcommand> [<command-options>]

The following subcommands are supported:

      clear   -- Delete or disable breakpoints matching the specified source file and line.
      command -- Commands for adding, removing and listing LLDB commands executed when a breakpoint is hit.
      delete  -- Delete the specified breakpoint(s).  If no breakpoints are specified, delete them all.
      disable -- Disable the specified breakpoint(s) without deleting them.  If none are specified, disable all
                 breakpoints.
      enable  -- Enable the specified disabled breakpoint(s). If no breakpoints are specified, enable all of them.
      list    -- List some or all breakpoints at configurable levels of detail.
      modify  -- Modify the options on a breakpoint or set of breakpoints in the executable.  If no breakpoint is
                 specified, acts on the last created breakpoint.  With the exception of -e, -d and -i, passing an
                 empty argument clears the modification.
      name    -- Commands to manage breakpoint names
      read    -- Read and set the breakpoints previously saved to a file with "breakpoint write".
      set     -- Sets a breakpoint or set of breakpoints in the executable.
      write   -- Write the breakpoints listed to a file that can be read in with "breakpoint read".  If given no
                 arguments, writes all breakpoints

you see all the options and switch that the command breakpoint accepts. Reading this contextual help is always a good thing to do, within REPLs. Now, let’s try with

(lldb) breakpoint list
No breakpoints currently set.

Now we create our first breakpoint with:

(lldb) breakpoint set --name main
Breakpoint 1: where = hellodebugger`main, address = 0x00000001000003c0
(lldb) breakpoint list
Current breakpoints:
1: name = 'main', locations = 1
  1.1: where = hellodebugger`main, address = hellodebugger[0x00000001000003c0], unresolved, hit count = 0

A few notes:

  • the REPL supports auto-completion
  • in general, lldb commands have a short version. In fact we could do:
(lldb) breakpoint delete 1
1 breakpoints deleted; 0 breakpoint locations disabled.
(lldb) breakpoint list
No breakpoints currently set.
(lldb) b main
Breakpoint 2: where = hellodebugger`main, address = 0x00000001000003c0
(lldb) breakpoint list
Current breakpoints:
2: name = 'main', locations = 1
  2.1: where = hellodebugger`main, address = hellodebugger[0x00000001000003c0], unresolved, hit count = 0

just as planned.

The next step is executing the program. We expect:

  1. the program stops as soon as it hits a breakpoint
  2. there must be a way to navigate through instructions and see what is going on.

let’s try now launching the run command:

(lldb) run
Process 55119 launched: '/Users/gabrielebiondo/reversing-101/lessons/02-mov/hellodebugger' (arm64)
Process 55119 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 2.1
    frame #0: 0x00000001000003c0 hellodebugger`main
hellodebugger`main:
->  0x1000003c0 <+0>: mov    x11, #0x1723 ; =5923
    0x1000003c4 <+4>: mov    x0, #0x0 ; =0
    0x1000003c8 <+8>: bl     0x1000003cc    ; symbol stub for: exit

hellodebugger`exit:
    0x1000003cc <+0>: adrp   x16, 4
Target 0: (hellodebugger) stopped.

Great - the output is very dense, but tells you everything you need to know:

  1. a process (PID 55119) has been launched. Its URL follows.
  2. it is now stopped, the reason being breakpoint 2.1
  3. the listing of the program follows
    1. first column is the memory address,
    2. the value between angle brackets (damn idiots that cannot say “lesser” and “greater than” have invented these other brackets. Again… terrible wording) denotes the offset in bytes from the beginning of the program,
    3. then it’s easy to recognise the program.
    4. the comments you see after the ; symbol are not part of the binary. They are added by LLDB, based on metadata produced by the linker.
      1. pretty prints
      2. stubs
      3. etc.

I hope you have invoked the help before. If you didn’t, now it’s time to do so. You will see a command called register:

(lldb) register
Commands to access registers for the current thread and stack frame.

Syntax: register [read|write|info] ...

The following subcommands are supported:

      info  -- View information about a register.
      read  -- Dump the contents of one or more register values from the current frame.  If no register is
               specified, dumps them all.
      write -- Modify a single register value.

It’s a good place to start - after all, by now you only have the concept of registers in quite an abstract fashion, and have a generic knowledge of what bl and mov do.

Let’s read the registers, then. All of them, hang the cost!

(lldb) register read
General Purpose Registers:
        x0 = 0x0000000000000001
        x1 = 0x000000016fdff0e0
        x2 = 0x000000016fdff0f0
        x3 = 0x000000016fdff260
        x4 = 0x0000000000000001
        x5 = 0x0000000000000010
        x6 = 0x0000000000000041
        x7 = 0x0000000000000000
        x8 = 0x00000001000003c0  hellodebugger`main
        x9 = 0x0000000201920df0  dyld`lsl::sPoolBytes + 3408
       x10 = 0x000000016fdfe568
       x11 = 0x00000002019400a0
       x12 = 0x0000000000020000
       x13 = 0x0000000194e59000
       x14 = 0x0000000000000001
       x15 = 0x0000000000000055
       x16 = 0x0000000195227884  libsystem_platform.dylib`os_unfair_lock_unlock
       x17 = 0x0000000203204278
       x18 = 0x0000000000000000
       x19 = 0x0000000201920060  lsl::sAllocatorBuffer
       x20 = 0x0000000201bca9c8  lsl::sMemoryManagerBuffer
       x21 = 0x0000000201920df0  dyld`lsl::sPoolBytes + 3408
       x22 = 0xfffffffffffffff0
       x23 = 0x0000000201bce0e0  dyld`vm_page_mask
       x24 = 0x0000000000000001
       x25 = 0x000000016fdfec60
       x26 = 0x0000000201bce0f0  dyld`mach_task_self_
       x27 = 0x0000000000000000
       x28 = 0x0000000000000000
        fp = 0x000000016fdff0c0
        lr = 0x0000000194e61d54  dyld`start + 7184
        sp = 0x000000016fdfea70
        pc = 0x00000001000003c0  hellodebugger`main
      cpsr = 0x600018000

plenty of stuff here. Not dense, anyway - there are a lot of registers, so a lot of lines - but lines are not that full of information. We will be more specific from now on - I think there is no need to explain the syntax here:

(lldb) register read x0
      x0 = 0x0000000000000001
(lldb) register read x0 x11
      x0 = 0x0000000000000001
     x11 = 0x00000002019400a0

Great. We also observe that some of the registers mention the current breakpoint, hellodebugger'main (yes, it must be a backtick. I use markdown and my current editor stinks when it comes to special characters: the backtick is a reserved markdown symbol. Sorry). Let’s keep them under control in our probes:

(lldb) register read x0 x8 x11 pc
      x0 = 0x0000000000000001
      x8 = 0x00000001000003c0  hellodebugger`main
     x11 = 0x00000002019400a0
      pc = 0x00000001000003c0  hellodebugger`main

Ok, now we need to move away from the current instruction - which means:

  • having the current instruction 0x1000003c0 <+0>: mov x11, #0x1723 executed
  • jump magically (by now!) to the next instruction to be executed, which should be 0x1000003c4 <+4>: mov x0, #0x0

Now, if you’re here and you haven’t read the help yet, you won the prize “I just installed Kali Linux what now”. Otherwise you have seen the step and stepi commands. They do exactly what the help tells you: execute the current instruction and jump magically to the next:

(lldb) step
Process 55119 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = instruction step into
    frame #0: 0x00000001000003c4 hellodebugger`main + 4
hellodebugger`main:
->  0x1000003c4 <+4>: mov    x0, #0x0 ; =0
    0x1000003c8 <+8>: bl     0x1000003cc    ; symbol stub for: exit

hellodebugger`exit:
    0x1000003cc <+0>: adrp   x16, 4
    0x1000003d0 <+4>: ldr    x16, [x16]

Let’s compare the previous state of the registers with the current one. The previous state is above, I write it again here for your convenience:

Before step

(lldb) register read x0 x8 x11 pc
      x0 = 0x0000000000000001
      x8 = 0x00000001000003c0  hellodebugger`main
     x11 = 0x00000002019400a0
      pc = 0x00000001000003c0  hellodebugger`main

After step

(lldb) register read x0 x8 x11 pc
      x0 = 0x0000000000000001
      x8 = 0x00000001000003c0  hellodebugger`main
     x11 = 0x0000000000001723
      pc = 0x00000001000003c4  hellodebugger`main + 4

Observations:

  1. the value of X11 actually changed to 0x1723. Great!
  2. The value of this strange pc changed to 0x00000001000003c4 hellodebugger'main + 4. Hmmm… interesting.
    1. we conjecture that if we do another step (or simply s):
      1. the value of X0 becomes 0, and even more interestingly:
      2. the value of pc is incremented by 4 more bytes.

Let us validate our hypotheses:

(lldb) s
Process 55119 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = instruction step into
    frame #0: 0x00000001000003c8 hellodebugger`main + 8
hellodebugger`main:
->  0x1000003c8 <+8>: bl     0x1000003cc    ; symbol stub for: exit

hellodebugger`exit:
    0x1000003cc <+0>: adrp   x16, 4
    0x1000003d0 <+4>: ldr    x16, [x16]
    0x1000003d4 <+8>: br     x16
Target 0: (hellodebugger) stopped.
(lldb) register read x0 x8 x11 pc
      x0 = 0x0000000000000000
      x8 = 0x00000001000003c0  hellodebugger`main
     x11 = 0x0000000000001723
      pc = 0x00000001000003c8  hellodebugger`main + 8

Just as planned. Now, observe the instruction to be executed, namely:

->  0x1000003c8 <+8>: bl     0x1000003cc    ; symbol stub for: exit

and look at what we have at the address 0x1000003c8:

hellodebugger`exit:
    0x1000003cc <+0>: adrp   x16, 4
    0x1000003d0 <+4>: ldr    x16, [x16]
    0x1000003d4 <+8>: br     x16

Technically speaking hellodebugger'exit: is a label for another piece of code. What that code does is outside the scope of the current post but - again: remember what we said with regards to bl: it branches to a label. Et voila!

Dense lesson, but there is more to that

Technical Analysis

We have seen that every time an instruction is executed, the register named pc is incremented by 4. Why?

The program counter

The register pc is called program counter. It is a 64-bit register (8 bytes) that always contains the address of the instruction currently executed.

We did not mention it yet, but if you have looked at the mov page in the ARM site (https://developer.arm.com/documentation/ddi0597/2025-09/Base-Instructions/MOV–MOVS–immediate—Move–immediate–) you should have noticed that there is a row of 32 values. This is because every AArch64 instruction is 32 bits (4 bytes) in size.

Whenever the execution of an instruction terminates, the register is updated. This actually implements the sequential execution flow of the code.

Conclusion

At this point, we have seen enough to draw a few solid conclusions.

First, instruction execution on AArch64 is strictly sequential by default. Each instruction is 32 bits wide, and once an instruction completes, the pc is advanced by 4 bytes. This simple mechanism is what implements the linear flow of execution we intuitively expect when reading assembly code.

Second, stepping through instructions with a debugger makes this mechanism explicit. Instructions modify registers, and only the registers explicitly targeted by an instruction are affected. Nothing “magical” happens: the machine does exactly what the instruction encoding prescribes, no more and no less.

Finally, the debugger is not an optional accessory. It is the tool that bridges static code and runtime behaviour. Reading disassembly tells you what could happen; stepping through execution tells you what actually happens. From this point onward, we will rely on the debugger to validate assumptions, test hypotheses, and reason about control flow with precision.

One last note on bl: for the purpose of this post, we have seen enough. We observed that bl alters the control flow by branching to a different piece of code, and we verified this behavior directly with the debugger. That is all we needed here. The mechanics behind what happens around the branch — return addresses, link registers, and calling conventions — will be revisited later, when we have the proper context to reason about them rigorously.

This is the foundation. Everything else builds on top of it.

References

Next Lesson

The next lesson will focus even more on the debugger, and we will see some other instructions. It takes time to understand registers, and in the next lesson we will spipple again with PC. Fasten your seatbelt, the next post will be here shortly.

Want the deep dive?

If you’re a security researcher, incident responder, or part of a defensive team and you need the full technical details (labs, YARA sketches, telemetry tricks), email me at info@bytearchitect.io or DM me on X (@reveng3_org). I review legit requests personally and will share private analysis and artefacts to verified contacts only.

Prefer privacy-first contact? Tell me in the first message and I’ll share a PGP key.


Subscribe to The Byte Architect mailing list for release alerts and exclusive follow-ups.


Gabriel(e) Biondo
ByteArchitect · RevEng3 · Rusted Pieces · Sabbath Stones