~/blog/zero-width-steganography.md

Hiding Data in Plain Sight with Zero-Width Characters

· #security #steganography

Copy this sentence into a hex viewer and count the bytes: “Hello there.” Now copy it from somewhere you don’t fully trust and do the same thing. If the byte count doesn’t match the character count you expect, you’ve probably found a zero-width character — and if there are several of them placed non-randomly, you’ve probably found a payload.

Unicode has a small set of code points defined to render as literally nothing: no glyph, no width, no visual trace. U+200B (zero width space), U+200C (zero width non-joiner), U+200D (zero width joiner), U+FEFF (originally a byte-order mark, now often repurposed). Browsers, editors, and terminals all happily render text containing them as if they weren’t there. Which means you can use them as a two-symbol alphabet and smuggle a bitstream through any text field that doesn’t strip them.

The encoding is embarrassingly simple

Pick two zero-width characters to stand for 0 and 1. Convert your payload to bits. Interleave the resulting zero-width sequence between the visible characters of a cover text — after every word, at the end, wherever you like, as long as the position is decodable on the other end.

ZW0 = "​"  # zero width space  -> bit 0
ZW1 = "‌"  # zero width non-joiner -> bit 1

def encode(cover: str, payload: bytes) -> str:
    bits = "".join(f"{byte:08b}" for byte in payload)
    hidden = "".join(ZW1 if b == "1" else ZW0 for b in bits)
    # tuck the whole payload at the end — simplest placement, not stealthiest
    return cover + hidden

def decode(text: str) -> bytes:
    bits = "".join("1" if ch == ZW1 else "0" for ch in text if ch in (ZW0, ZW1))
    n = len(bits) - (len(bits) % 8)
    return bytes(int(bits[i:i+8], 2) for i in range(0, n, 8))

Run encode("Meet me at noon.", b"hi") and paste the result into Slack, a GitHub comment, a tweet. It reads as “Meet me at noon.” to every human who looks at it. Run decode on the copy-pasted string and you get b"hi" back, assuming the platform didn’t normalize the text on the way through — which is the whole game, and I’ll get to that.

Why anyone actually does this

The obvious use is a covert channel: two parties who can only exchange plain-looking text — a support ticket, a public comment thread, a forum post — pass a few bytes back and forth without anyone glancing at the thread noticing anything odd. It’s not high-bandwidth. It’s low-and-slow by design.

The less obvious use, and the one that shows up more in practice, is watermarking. If you generate text — AI output, a leaked internal document, a licensed dataset sample — you can zero-width-encode a tracking id into every copy you hand out. Nobody sees it. If a copy leaks, you decode the zero-width characters out of the leaked text and know exactly which recipient it came from. This is a real, deployed technique, and it’s a good reason to habitually strip zero-width characters from any text you paste from an untrusted source before you trust it not to be tagged.

How to actually find it

You don’t need anything exotic. Zero-width characters are just Unicode code points; any language’s standard string handling can spot them.

import unicodedata

ZERO_WIDTH = {"​", "‌", "‍", "‎", "‏", ""}

def suspicious(text: str) -> bool:
    hits = sum(ch in ZERO_WIDTH for ch in text)
    return hits > 0

def strip_zero_width(text: str) -> str:
    return "".join(ch for ch in text if ch not in ZERO_WIDTH)

For a quick manual check on a specific string, len(text.encode("utf-8")) versus what you’d expect from the visible characters is often enough of a tell to send you looking closer. For anything you’re pasting into a codebase — README content, a scraped product description, a config value copied from a chat message — running it through a strip function before it lands in source control costs nothing and closes off a class of “invisible character changed the meaning of this line” bugs that have nothing to do with steganography and everything to do with copy- paste from rendered web pages.

What I learned

Zero-width steganography isn’t clever cryptography — there’s no key, no real secrecy once someone thinks to look, and any text pipeline that normalizes Unicode (NFKC normalization strips several of these) breaks it by accident. Its power is entirely social: nobody looks, because there’s nothing to look at. That’s also its ceiling. Treat it the way you’d treat any obscurity-based scheme — fine for watermarking and casual signaling, not a substitute for an actual authenticated channel when it matters who sent what.

cd .. cd ~ (back to terminal)