Binary Logic, Shifts, and the Zero Register
I know I’m late, guys. I know you were ready to shred some assembly and dive into new territory. But you also know how it goes: shipping a new version of 0tH is never cheap in terms of time.
Here we go!
Past lessons in this series:
- Reversing 101 - introduction
- Preparing to Reverse
- Introduction to registers
- More on registers: the ABI
- Understanding Instruction Flow on AArch64 with LLDB
Lesson Objective
In this lesson, we keep working with LLDB, focusing on binary logic and operators. We’re not done with
movyet: you’ll keep seeing it in the next lessons.
More importantly, this is where I want to introduce a key part of the reverser’s mindset: experimenting, observing, and repeating. Again and again.
The Code
Last time we saw this simple program:
.global _main
.extern _exit
_main:
mov X11, #0x1723
mov X0, #0x0000 // exit code
bl _exit // invokes exit
and we followed its execution with lldb. Let’s enrich it. For your convenience, I converted the value 0x1723 to binary:
[0 sixteen times] 0001 0111 0010 0011
Boolean operators
If you’re here, you’re supposed to know what boolean operators are and how boolean logic works, so I’ll spare myself the job - and spare you the boring definitions.
As the boolean operator with highest priority is the logical negation, we’ll start from there.
Funny enough, NOT is not implemented directly in the AARCH64 ISA. Instead, it is implemented with a MOV that negates the bits being moved. Considering that, in turn, MOV is just an alias for an ORR (hang on, we’ll see this later. For now, trust me), a NOT is implemented as an inverted or.
Before cursing all boolean gods in search for answers, keep in mind that AARCH64 is designed to be a RISC architecture, so the set of instructions is deliberately kept as small as possibile.
MVN
MVN has the following basic syntax:
MVN dest, source
In this case, dest and source are both registers.
This means: take source, invert its bits, put the result into dest. From now on, I will denote the operation of putting a value into a variable with the symbol ←, so if there is no ambiguity, operations will be logically denoted as follows:
dest ← NOT(source)
Observe that ← is not assembly language.
There is yet another form of this command; I will show it after presenting the basic forms of the other boolean operators.
AND
The most basic form of AND has the following syntax:
AND dest, op1, op2
where dest, op1, and op2 are registers. The result of this instruction is:
dest ← AND(op1, op2)
Like for the MVN operator, there are other forms of this instruction - we’ll discuss them shortly.
OR
In the AArch64 ISA, the OR operator is implemented with the instruction ORR, whose most basic form is
ORR dest, op1, op2
where dest, op1, and op2 are registers. The result of this instruction is:
dest ← OR(op1, op2)
Like for the MVN and AND operators, there are other forms of this instruction.
I wrote a little program to show the basic functioning of these instructions. You may want to change it - and you are actually encouraged to do so!
.global _main
.extern _exit
_main:
mov X11, #0x1723
mov W12, #0x15
// playing with NOT
mvn X13, X11
mvn W14, W12
// playing with AND
// AND X15, X11, W12 // this won't compile
AND X15, X11, X12 // this would.
AND X15, XZR, X15 // a new guy!
// playing with OR
ORR X14, X15, X11
mov X0, #0x0000 // exit code
bl _exit // invokes exit
Compile and link it:
gabrielebiondo@RevEng3 03-operators % as main.s
gabrielebiondo@RevEng3 03-operators % ld \
-arch arm64 \
-platform_version macos 26.0 26.0 \
-syslibroot "$SDK" \
-lSystem \
-o operators \
main.o
gabrielebiondo@RevEng3 03-operators % ls -alh
total 88
drwxr-xr-x 5 gabrielebiondo staff 160B 22 Dec 06:06 .
drwxr-xr-x@ 9 gabrielebiondo staff 288B 21 Dec 12:38 ..
-rw-r--r--@ 1 gabrielebiondo staff 432B 22 Dec 06:06 main.o
-rw-r--r--@ 1 gabrielebiondo staff 398B 22 Dec 06:06 main.s
-rwxr-xr-x@ 1 gabrielebiondo staff 33K 22 Dec 06:06 operators
I will debug the code above. If you make changes to it, try to make a sense of it.
Now, launch the debugger and attach it to the newly compiled program:
gabrielebiondo@RevEng3 03-operators % lldb operators
(lldb) target create "operators"
Current executable set to '/Users/gabrielebiondo/MiB/51 - Reversing 101/reversing-101/lessons/03-operators/operators' (arm64).
In the last lesson, I told you the importance of setting a breakpoint in the right place. We will use the same method we used there:
(lldb) breakpoint set --name main
Breakpoint 1: where = operators`main, address = 0x00000001000003c0
(lldb) breakpoint list
Current breakpoints:
1: name = 'main', locations = 1
1.1: where = operators`main, address = operators[0x00000001000003c0], unresolved, hit count = 0
Execute the program - again, if you have doubts, go to the previous lesson:
(lldb) run
Process 38880 launched: '/Users/gabrielebiondo/MiB/51 - Reversing 101/reversing-101/lessons/03-operators/operators' (arm64)
Process 38880 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 1.1
frame #0: 0x00000001000003c0 operators`main
operators`main:
-> 0x1000003c0 <+0>: mov x11, #0x1723 ; =5923
0x1000003c4 <+4>: mov w12, #0x15 ; =21
0x1000003c8 <+8>: mvn x13, x11
0x1000003cc <+12>: mvn w14, w12
Target 0: (operators) stopped.
The first two instructions have been previously explained. Nothing new. Observe that before running any step, you still can interrogate any register to see that it’s not initialised, for instance:
(lldb) register read x11
x11 = 0x00000001fe25c0a0
The contents of x11 are just garbage, for us. Move a couple of steps forward:
(lldb) s
Process 38880 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = instruction step into
frame #0: 0x00000001000003c4 operators`main + 4
operators`main:
-> 0x1000003c4 <+4>: mov w12, #0x15 ; =21
0x1000003c8 <+8>: mvn x13, x11
0x1000003cc <+12>: mvn w14, w12
0x1000003d0 <+16>: and x15, x11, x12
(lldb) register read x11 w12
x11 = 0x0000000000001723
w12 = 0x00020000
and
(lldb) s
Process 38880 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = instruction step into
frame #0: 0x00000001000003c8 operators`main + 8
operators`main:
-> 0x1000003c8 <+8>: mvn x13, x11
0x1000003cc <+12>: mvn w14, w12
0x1000003d0 <+16>: and x15, x11, x12
0x1000003d4 <+20>: and x15, xzr, x15
Target 0: (operators) stopped.
(lldb) register read x11 w12
x11 = 0x0000000000001723
w12 = 0x00000015
This should be quite easy for you now to understand. Notice that the next instruction introduces the register x13, so prepare the next register read:
(lldb) register read x11 x13 w12
x11 = 0x0000000000001723
x13 = 0x0000000191775000
w12 = 0x00000015
Moreover: here we will play with bits, and working in binary makes everything more visible. To do so:
(lldb) register read --format binary x11 x13 w12
x11 = 0b0000000000000000000000000000000000000000000000000001011100100011
x13 = 0b0000000000000000000000000000000110010001011101110101000000000000
w12 = 0b00000000000000000000000000010101
Observe the result of the next instruction, writing into x13 the contents of x11, inverted:
(lldb) s
Process 38880 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = instruction step into
frame #0: 0x00000001000003cc operators`main + 12
operators`main:
-> 0x1000003cc <+12>: mvn w14, w12
0x1000003d0 <+16>: and x15, x11, x12
0x1000003d4 <+20>: and x15, xzr, x15
0x1000003d8 <+24>: orr x14, x15, x11
Target 0: (operators) stopped.
(lldb) register read --format binary x11 x13 w12
x11 = 0b0000000000000000000000000000000000000000000000000001011100100011
x13 = 0b1111111111111111111111111111111111111111111111111110100011011100
w12 = 0b00000000000000000000000000010101
Just as planned. The next instruction will work with 32 bits registers - not very different from the previous one and hence deserving the same treatment:
(lldb) register read --format binary x11 x13 w12 w14
x11 = 0b0000000000000000000000000000000000000000000000000001011100100011
x13 = 0b1111111111111111111111111111111111111111111111111110100011011100
w12 = 0b00000000000000000000000000010101
w14 = 0b00000000000000000000000000000001
(lldb) s
Process 38880 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = instruction step into
frame #0: 0x00000001000003d0 operators`main + 16
operators`main:
-> 0x1000003d0 <+16>: and x15, x11, x12
0x1000003d4 <+20>: and x15, xzr, x15
0x1000003d8 <+24>: orr x14, x15, x11
0x1000003dc <+28>: mov x0, #0x0 ; =0
Target 0: (operators) stopped.
(lldb) register read --format binary x11 x13 w12 w14
x11 = 0b0000000000000000000000000000000000000000000000000001011100100011
x13 = 0b1111111111111111111111111111111111111111111111111110100011011100
w12 = 0b00000000000000000000000000010101
w14 = 0b11111111111111111111111111101010
The next instruction uses three registers: x15, x11, and x12. We will shortly prepare the next register read instruction, but first we have to observe explicitly that the objection “but we did never instantiate x12” is de facto wrong. We instantiated w12, which contains the less significant bytes of x12. The most significant bytes of the latter are padded with zeroes, as the following shows:
(lldb) register read --format binary x11 x13 w12 w14 x12
x11 = 0b0000000000000000000000000000000000000000000000000001011100100011
x13 = 0b1111111111111111111111111111111111111111111111111110100011011100
w12 = 0b00000000000000000000000000010101
w14 = 0b11111111111111111111111111101010
x12 = 0b0000000000000000000000000000000000000000000000000000000000010101
so we will keep the notation short and proceed:
(lldb) register read --format binary x11 x12 x15
x11 = 0b0000000000000000000000000000000000000000000000000001011100100011
x12 = 0b0000000000000000000000000000000000000000000000000000000000010101
x15 = 0b0000000000000000000000000000000000000000000000000000000001010101
(lldb) s
Process 38880 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = instruction step into
frame #0: 0x00000001000003d4 operators`main + 20
operators`main:
-> 0x1000003d4 <+20>: and x15, xzr, x15
0x1000003d8 <+24>: orr x14, x15, x11
0x1000003dc <+28>: mov x0, #0x0 ; =0
0x1000003e0 <+32>: bl 0x1000003e4 ; symbol stub for: exit
Target 0: (operators) stopped.
(lldb) register read --format binary x11 x12 x15
x11 = 0b0000000000000000000000000000000000000000000000000001011100100011
x12 = 0b0000000000000000000000000000000000000000000000000000000000010101
x15 = 0b0000000000000000000000000000000000000000000000000000000000000001
This is correct and expected. Now, the next instruction is a bit strange. There is a new kid in town, xzr. Time to introduce this virtual register - or pseudo-register. First, the behaviour: if the instruction requires a source register (like in the case under analysis), the hardware returns a 0 (in fact, xzr can be seen as the mnemonics for zero register). When the instruction requires a destination register, the hardware simply ignores the result (pretty much, like writing to /dev/null).
xzr is also implemented differently from the x0…x30 registers. These are typically built with flip-flops or SRAM. xzr is hard wired, instead. This is also reflected on the following result:
(lldb) register read --format binary x11 x12 x15 xzr
x11 = 0b0000000000000000000000000000000000000000000000000001011100100011
x12 = 0b0000000000000000000000000000000000000000000000000000000000010101
x15 = 0b0000000000000000000000000000000000000000000000000000000000000001
error: Invalid register name 'xzr'.
Now, we have:
error: Invalid register name 'xzr'.
(lldb) s
Process 38880 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = instruction step into
frame #0: 0x00000001000003d8 operators`main + 24
operators`main:
-> 0x1000003d8 <+24>: orr x14, x15, x11
0x1000003dc <+28>: mov x0, #0x0 ; =0
0x1000003e0 <+32>: bl 0x1000003e4 ; symbol stub for: exit
operators`exit:
0x1000003e4 <+0>: adrp x16, 4
Target 0: (operators) stopped.
(lldb) register read --format binary x14 x15
x14 = 0b0000000000000000000000000000000011111111111111111111111111101010
x15 = 0b0000000000000000000000000000000000000000000000000000000000000000
I wanted to show you this operation (zeroing a register) because it’s one of the foundations of shellcoding. Not important right now, but keep it in the back of your mind, it’ll come back.
The rest of the program is quite similar to what we have seen already, I believe it does not need any comment.
So far, this post has been full of “forward declarations”. To close the loop, we first need to introduce another family of operators: shifts. Also in this context we present the results before the theory.
So let’s expand the previous program with this:
.global _main
.extern _exit
_main:
mov X11, #0x1723
mov W12, #0x15
// playing with NOT
mvn X13, X11
mvn W14, W12
// playing with AND
// AND X15, X11, W12 // this won't compile
AND X15, X11, X12 // this would.
AND X15, XZR, X15 // a new guy!
// playing with OR
ORR X14, X15, X11
// Shifts
mov X0, #-230
mov X1, #230
// and, were you wondering how to store a negative hex number:
mov X2, #-0x8197
LSL X3, X1, #2
LSL X4, X2, #2
LSR X5, X1, #2
LSR X6, X2, #2
ASR X7, X1, #2
ASR X8, X2, #2
mov X0, #0x0000 // exit code
bl _exit // invokes exit
Let’s focus on the second part - from the comment // Shifts onwards. Uh, yes, I forgot to mention that, but it should be obvious by now: as supports C++ comments (introduced by //).
To properly understand what follows you should know how the negative numbers are represented into memory. For more information, revert to Two’s complement and come back if and only if you understood what’s there.
Compile exactly as you did before. Attach lldb to the program, set the usual breakpoint.
Now, let’s proceed with the debugging from where we stopped to introduce the new instructions.
LSL
LSL stands for Logical Shift Left. The most generic form of this instruction is
LSL dest, source, #imm
and the effect is
dest ← source << #imm
in simple terms, this instruction shifts the bits in the register source to their left, padding the result with zeros, and writing into dest. In the context of shift/rotating operations, dest and source are always registers unless differently specified.
Let’s see what happens in our debug session - if you followed the example, you should be in this situation:
Process 40944 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = instruction step into
frame #0: 0x00000001000003dc operators`main + 28
operators`main:
-> 0x1000003dc <+28>: mov x0, #-0xe6 ; =-230
0x1000003e0 <+32>: mov x1, #0xe6 ; =230
0x1000003e4 <+36>: mov x2, #-0x8197 ; =-33175
0x1000003e8 <+40>: lsl x3, x1, #2
Target 0: (operators) stopped.
so hit step (or s) three times, to see this:
Process 40944 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = instruction step into
frame #0: 0x00000001000003e8 operators`main + 40
operators`main:
-> 0x1000003e8 <+40>: lsl x3, x1, #2
0x1000003ec <+44>: lsl x4, x2, #2
0x1000003f0 <+48>: lsr x5, x1, #2
0x1000003f4 <+52>: lsr x6, x2, #2
Target 0: (operators) stopped.
(lldb) register read --format binary x0 x1 x2
x0 = 0b1111111111111111111111111111111111111111111111111111111100011010
x1 = 0b0000000000000000000000000000000000000000000000000000000011100110
x2 = 0b1111111111111111111111111111111111111111111111110111111001101001
If you didn’t know about the 2’s complement, you may want to take a deeper look at the contents of the registers x0 and x1. It’s evident noticing what happens if you add the contents - this also justifies the choice of the 2’s complement to represent negative integers.
At this point hitting s again gives
* thread #1, queue = 'com.apple.main-thread', stop reason = instruction step into
frame #0: 0x00000001000003ec operators`main + 44
operators`main:
-> 0x1000003ec <+44>: lsl x4, x2, #2
0x1000003f0 <+48>: lsr x5, x1, #2
0x1000003f4 <+52>: lsr x6, x2, #2
0x1000003f8 <+56>: asr x7, x1, #2
Target 0: (operators) stopped.
(lldb) register read --format binary x1 x3
x1 = 0b0000000000000000000000000000000000000000000000000000000011100110
x3 = 0b0000000000000000000000000000000000000000000000000000001110011000
as expected. Bits moved to the left by 2 positions, zero padded. You should ask yourself what is the practical implication of this operation, from a numerical standpoint.
Negative numbers are treated similarly: hitting s gives
Process 40944 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = instruction step into
frame #0: 0x00000001000003f0 operators`main + 48
operators`main:
-> 0x1000003f0 <+48>: lsr x5, x1, #2
0x1000003f4 <+52>: lsr x6, x2, #2
0x1000003f8 <+56>: asr x7, x1, #2
0x1000003fc <+60>: asr x8, x2, #2
(lldb) register read --format binary x2 x4
x2 = 0b1111111111111111111111111111111111111111111111110111111001101001
x4 = 0b1111111111111111111111111111111111111111111111011111100110100100
Exercise 1:
Enrich the program in the following manner:
- copy the value
0x2000000000000000inx0 - Shift it left 2 positions
- Observe what happens and find a reason
LSR
The dual operation of LSL is shifting the sequence of bits right by a given number of positions. Unsurprisingly, this is called LSR (logical shift right), the base syntax is:
LSR dest, source, #imm
and the effect is
dest ← source >> #imm
with zero padding on the most significant bits. This poses a problem that will be evident in the debug session. From here:
Process 40944 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = instruction step into
frame #0: 0x00000001000003f0 operators`main + 48
operators`main:
-> 0x1000003f0 <+48>: lsr x5, x1, #2
0x1000003f4 <+52>: lsr x6, x2, #2
0x1000003f8 <+56>: asr x7, x1, #2
0x1000003fc <+60>: asr x8, x2, #2
the next steps gives
Process 40944 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = instruction step into
frame #0: 0x00000001000003f4 operators`main + 52
operators`main:
-> 0x1000003f4 <+52>: lsr x6, x2, #2
0x1000003f8 <+56>: asr x7, x1, #2
0x1000003fc <+60>: asr x8, x2, #2
0x100000400 <+64>: mov x0, #0x0 ; =0
Target 0: (operators) stopped.
(lldb) register read --format binary x1 x5
x1 = 0b0000000000000000000000000000000000000000000000000000000011100110
x5 = 0b0000000000000000000000000000000000000000000000000000000000111001
as planned; but another step gives:
-> 0x1000003f8 <+56>: asr x7, x1, #2
0x1000003fc <+60>: asr x8, x2, #2
0x100000400 <+64>: mov x0, #0x0 ; =0
0x100000404 <+68>: bl 0x100000408 ; symbol stub for: exit
Target 0: (operators) stopped.
(lldb) register read --format binary x2 x6
x2 = 0b1111111111111111111111111111111111111111111111110111111001101001
x6 = 0b0011111111111111111111111111111111111111111111111101111110011010
The value just changed sign. In reversing this is not a great problem, usually - but if you were to write code, something like this could cause you severe headaches. This is the typical insidious bug: hard to find, completely legal, makes debugging a nightmare.
This explains why there’s need for another shift right operator, a sign-savvy one.
ASR
ASR means Arithmetic Shift Right. The syntax is
ASR dest, source, #imm
and the effect is
dest ← source >> #imm
but it preserves the sign. Thus a step after
Process 40944 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = instruction step into
frame #0: 0x00000001000003f8 operators`main + 56
operators`main:
-> 0x1000003f8 <+56>: asr x7, x1, #2
0x1000003fc <+60>: asr x8, x2, #2
0x100000400 <+64>: mov x0, #0x0 ; =0
0x100000404 <+68>: bl 0x100000408 ; symbol stub for: exit
we would see:
Process 40944 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = instruction step into
frame #0: 0x00000001000003fc operators`main + 60
operators`main:
-> 0x1000003fc <+60>: asr x8, x2, #2
0x100000400 <+64>: mov x0, #0x0 ; =0
0x100000404 <+68>: bl 0x100000408 ; symbol stub for: exit
operators`exit:
0x100000408 <+0>: adrp x16, 4
Target 0: (operators) stopped.
(lldb) register read --format binary x1 x7
x1 = 0b0000000000000000000000000000000000000000000000000000000011100110
x7 = 0b0000000000000000000000000000000000000000000000000000000000111001
and another step would give us:
Process 40944 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = instruction step into
frame #0: 0x0000000100000400 operators`main + 64
operators`main:
-> 0x100000400 <+64>: mov x0, #0x0 ; =0
0x100000404 <+68>: bl 0x100000408 ; symbol stub for: exit
operators`exit:
0x100000408 <+0>: adrp x16, 4
0x10000040c <+4>: ldr x16, [x16]
Target 0: (operators) stopped.
(lldb) register read --format binary x2 x8
x2 = 0b1111111111111111111111111111111111111111111111110111111001101001
x8 = 0b1111111111111111111111111111111111111111111111111101111110011010
finally.
Conclusions
This was a dense lesson, and deliberately so.
At this point, we are no longer just learning instructions: we are learning how small, perfectly legal details can radically change the behaviour of a program. Zero-extension, logical versus arithmetic shifts, implicit assumptions about signedness — these are exactly the kind of details that matter when reversing real binaries.
If there is one takeaway from this lesson, it is this: never trust intuition alone. Always verify. The debugger is not a support tool; it is the ground truth.
You may have noticed that I often rely on “forward declarations” and end up splitting lessons more than I initially planned. This is intentional. Some concepts only make sense once you have seen their consequences first. Teaching assembly bottom-up is tempting, but it is rarely effective.
We are now close to completing the foundational layer of this series. Once that is done, the format will become more compact and less guided. Fewer explanations, more observations — closer to how reversing is actually done in practice.
Next Lesson
The next lesson closes all remaining forward declarations and ties together the concepts introduced here.
See you next time. ‘til then… Have fun!
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