Null bytes in shellcode still ruin exploits in 2026
Why 0x00 breaks strcpy-style delivery, how nulls sneak into reverse TCP structs, and what to do when your encoder pass lies to you.
Null-byte avoidance sounds like a CTF relic. It is not. Any delivery path that treats your payload as a string will truncate on 0x00. That includes naive stack overflows, some format string bugs, and half the "copy shellcode into this form field" workflows people still run in internal labs.
What a null actually breaks
strcpy, strcat, sprintf without length discipline: the first null wins. Your shellcode becomes a prefix. The CPU executes garbage or nothing.
Even when you have binary-safe memcpy, nulls can still hurt:
- Unicode transforms that normalize bytes
- WAF rules that dislike certain ranges
- Debug prints that stop early and hide the tail of your buffer
Where nulls come from in real payloads
People blame the generator. Often it is the data you embedded.
Reverse TCP shellcode carries a sockaddr struct. IPv4 addresses and ports are packed into 32-bit and 16-bit fields. A listener on port 1024 is fine. A port like 0x0100 in the wrong field layout can introduce zeros depending on how the template builds the struct.
IP addresses ending in .0.0 are a classic footgun in training environments. So are addresses with trailing zero octets that you did not notice because the UI prettified them.
Encoder passes are not magic
Running an encoder to kill nulls can:
- inflate size past your buffer
- change register assumptions your ROP chain depends on
- introduce new bad chars (0x0a, 0x0d) you forgot to list
I set bad-char constraints before picking an encoder order, not after a failed exploit attempt.
In shellcodes, the "Avoid null bytes" mission preset starts from ReverseTcp with a null-oriented bad-char filter. That is a starting point. Your target may also forbid 0x09, 0x20, or uppercase ASCII if the injection channel is picky.
Testing null-freedom correctly
Do not eyeball hex.
blob = open("payload.bin", "rb").read()
bad = [i for i, b in enumerate(blob) if b == 0]
assert not bad, f"null at offsets {bad}"
print("ok", len(blob))
Scan for off-by-one issues too. Some tools strip a trailing null thinking it is a string terminator. Your injector might need the raw length explicit.
Attacker perspective vs defender perspective
Attacker (authorized): null constraints shrink the feasible opcode set. You pay size and complexity.
Defender: null checks in copy paths are not sufficient protection. They reduce accidental truncation bugs in exploit chains. They do not stop a binary-safe write primitive.
Practical tradeoff
Perfect null-free shellcode that is 400 bytes longer may not fit. Sometimes the move is not better shellcode. It is a different primitive: ROP to read(), staged loader, or a smaller bootstrap that decodes the rest on the stack.
Null avoidance is a constraint, not a moral victory.