Research Context
XOR is the canonical encryption primitive in bare-metal assembly. It is symmetric — applying the same key twice returns the original byte — so the encrypt and decrypt loops are identical code. No library call, no padding, no block size: one instruction per byte. The two implementations here cover the two standard variants: a fixed single-byte key (fast, simple, weak against frequency analysis) and a rolling key that changes every iteration (stronger, same loop structure, one extra add). Both read a file, encrypt in-place, and write to stdout using only Linux syscalls.
1-XOR Properties: Why One Instruction Handles Both Directions
Three identities make XOR useful as an encryption operation:
x ^ 0 = x (key 0 is a no-op)
x ^ x = 0 (anything XORed with itself is zero)
x ^ k ^ k = x (applying k twice returns x — symmetry)
The third identity is the one that matters here: ciphertext = plaintext ^ key, and plaintext = ciphertext ^ key. The operation is its own inverse. This means the decrypt loop is byte-for-byte identical to the encrypt loop — run the same code twice and you are back to the original file.
2-Fixed-Key XOR: File Read, In-Place Encrypt, Write
The first implementation reads a file into a .bss buffer, iterates over every byte XORing it against 0x11, copies the result to an rbp-anchored stack buffer, then writes to stdout:
|
|
The loop body is four instructions per byte: xor, mov, inc, inc, plus the cmp/jne at the end. r14 holds the byte count from sys_read, so the loop runs exactly as many times as there are bytes in the file.
3-The lea vs mov Pointer Bug
The most common mistake in this pattern — and in any buffer loop — is writing mov rdx, [buffer] instead of lea rdx, [buffer].
|
|
What happens at runtime with the wrong version: rdx contains the first eight bytes of example.txt interpreted as a 64-bit integer — for a text file this is typically something in the range 0x6C6C6548... (ASCII characters). The first xor byte [rdx], 0x11 writes to that address, which is not mapped. The result is SIGSEGV.
PRO TIP: The rule is simple —
mov reg, [label]is a load (dereference),lea reg, [label]is an address calculation (no memory access). Whenever you need a pointer to a buffer, uselea. Whenever you need the value stored at a label (likemov rdi, [fd_no]), usemov.
The same distinction applies to lea rsi, [buffer] in the sys_read call. Both must be lea — sys_read expects a pointer to write into, not a value loaded from there.
4-Rolling Key XOR: One Extra Instruction, Much Stronger
The fixed-key version has a repeating pattern in the ciphertext — every occurrence of the same plaintext byte produces the same ciphertext byte, which makes frequency analysis straightforward. The rolling key variant fixes this by updating the key after each byte:
|
|
The only additions compared to the fixed-key version are movzx r15, byte [xor_key] before the loop and add r15b, 0x11 inside it. The key sequence produced is 0x11, 0x22, 0x33, ..., 0xFF, 0x10, 0x21, ... — a deterministic keystream that the receiver can reproduce given only the starting key.
5-8-bit Register Auto Wrap-Around
|
|
r15b is the lowest 8 bits of r15. An 8-bit register holds values 0x00–0xFF. When the value exceeds 0xFF, the carry is discarded — not propagated to the upper bytes of r15. This is modulo-256 arithmetic for free, no and r15, 0xFF required.
r15b = 0xEE > add 0x11 > 0xFF (no wrap)
r15b = 0xFF > add 0x11 > 0x10 (wrapped — carry dropped)
r15b = 0xF5 > add 0x11 > 0x06 (wrapped)
PRO TIP:
movzx r15, byte [xor_key]zero-extends the 8-bit value into the full 64-bit register before the loop. Withoutmovzx, the upper bytes ofr15might contain garbage from earlier computation, andxor al, r15bstill works since it only touches the low byte — but keepingr15clean avoids confusion during debugging.
6-Decrypt: Same Code, Same Key
Because XOR is symmetric, decrypt is the encrypt loop run again with the same starting key. The fixed-key version needs no state — just run the program on the ciphertext file with the same 0x11 key. The rolling-key version requires the starting key and the same increment (0x11) — both known to the receiver.
Python verification of the rolling decrypt:
|
|
The & 0xFF in Python does what the 8-bit register does in assembly — clamps the key to one byte. Run this against the output of rolling_xor and the original example.txt is recovered exactly.
Conclusion
Two programs, one core idea: a pointer, a counter, a key, and xor byte [ptr], key inside a loop. The fixed-key version establishes the structure; the rolling-key version adds one line that fundamentally changes the statistical properties of the output. The most instructive part of building both is the lea vs mov bug — it appears the first time every assembly programmer writes a buffer loop, and understanding why it segfaults ingrains the distinction between an address and a value for every subsequent program.
🔗 Related Research
Coding: CFG Flattening with CMOV: Antivirus & EDR Evasion via Control Flow Obfuscation in x64 Assembly
Coding: VESQER: DPCM+RLE Hybrid Shellcode Compression in x64 Assembly
Coding: Linux x64 Assembly Syscall ABI: Registers, File Descriptors & .bss Segment
⚠️ Legal Disclaimer
This article is written for educational purposes and security research only. The techniques described are standard computer science knowledge covered in any cryptography or systems programming curriculum. The author is not responsible for any misuse. Applying these methods to systems you do not own or have explicit written permission to test is strictly prohibited.
MITRE ATT&CK: T1027 · T1027.013