Here’s a party trick that should probably worry you more than it amuses you.
Copy this line — the visible part is “clear text, flag: 🇨🇦, hushed payload:” followed by what looks like an empty link — and paste it into ChatGPT or Claude, then ask the model to repeat it back verbatim:
clear text, flag: 🇨🇦, hushed payload:
To you, the payload is nothing — blank space after a colon. To the model, it says shhh!. The bytes were there the whole time. Your font just refused to draw them.
That’s the mechanism behind husher, a small library I built to encode, decode, and strip this hidden layer. I didn’t discover the technique — Riley Goodside demonstrated it on X in January 2024 with a post that looked like a plain sentence but carried a hidden instruction. Paste it into ChatGPT-4 and the model just obeys:
Johann Rehberger wrote the definitive breakdown and an ASCII Smuggler tool shortly after. husher is my hands-on version: something you can run in a pipeline to see the gap for yourself.
The exact trick
Unicode has a block called Tags, code points U+E0000 to U+E007F (UTF-8 F3 A0 80 80 upward). It was originally added for language tagging, deprecated for that purpose years ago, and left in the standard as a curiosity. Two properties make it dangerous today.
First, the block mirrors ASCII. Tag code point U+E00xx corresponds to ASCII 0xxx. So A (0x41) becomes U+E0041, ! (0x21) becomes U+E0021. Encoding is a flat offset — add 0xE0000 to each byte. That’s all hush does.
Second, and this is the whole exploit, the Unicode standard tells implementations to render these as nothing. From UTS #51:
A completely tag-unaware implementation will display any sequence of tag characters as invisible, without any effect on adjacent characters.
“Invisible” here isn’t “renders as a space.” It’s zero advance width — the glyph is not drawn at all. Your terminal, browser, and phone all faithfully draw nothing. But invisible is a rendering instruction, not a data-layer one. A font rasterizer skips these code points; a tokenizer does not. As Rehberger puts it, when the model receives the text, “the tokenizer splits the text back into the tag characters and original characters, and the LLM essentially re-builds the payload for you.”
So there are two readers of the same string, and they disagree completely about what it contains. That’s the bug.
Why this is worth taking seriously
The load-bearing assumption all over the current LLM stack is that a human reviewing an input sees what the model sees — the moderator skimming a support ticket, the engineer eyeballing a prompt before it hits an agent, the “human in the loop” who approves the action. Tag smuggling breaks that assumption cleanly and reproducibly.
The sharp version is indirect injection. An agent that reads inbound email, GitHub issues, or scraped pages and then acts — opens a PR, replies, calls a tool — consumes the full code-point stream, hushed payload and all. A human skimming the same page in a browser sees nothing unusual. The gap between “what the reviewer saw” and “what the model executed” is exactly where the injection lives. It’s the same family as homoglyph phishing (Cyrillic а vs Latin a), except purpose-built for a reader with no eyes, only a tokenizer.
This isn’t theoretical, and it isn’t fully patched. Cisco documented ChatGPT and Grok reading tag-smuggled instructions; Rehberger has kept a running tally of products quietly fixing it, including Sourcegraph’s Amp stripping invisible instructions in 2025. It shows up in the academic taxonomy of injection attacks too. This is an active arms race, not a closed CVE.
The tool, and its defensive half
husher has three functions. hush encodes a payload into the tag range; dehush walks a string, pulls out the tag characters, and reconstructs the hidden text; sanitize just strips them.
npx husher hush="don't tell the human" # → a byte-identical-looking blank
npx husher sanitize="<pasted suspicious text>" # → the visible text, hush removed
sanitize is the point. If you’re building anything that pipes untrusted text into a model — and increasingly that’s everything — you want to strip this character class before the text reaches the LLM. The one-liner is trivial:
const clean = [...input].filter(ch => {
const cp = ch.codePointAt(0);
return cp < 0xE0000 || cp > 0xE007F; // drop the Tags block
}).join('');
Be honest about two limits. First, that filter also nukes the tag characters used in legitimate subdivision flag emoji — 🏴 (Scotland), Wales, England are all built from tag sequences, so a blanket strip mangles them. Whether you care depends on your input. Second, tag smuggling is one vector among several: zero-width joiners, bidi overrides, and homoglyphs all exploit the same human-vs-machine perception split. Stripping one block is necessary, not sufficient.
I want to be clear about the intent: husher is not a delivery mechanism, it’s a way to stop trusting your own eyes as a security control. The whole reason to make the invisible visible is so you build the sanitize step before you get bitten.
Play with the client-side demo at lab.feedox.com/wild-llama/husher, or read the code at github.com/feedox/husher.
One question I keep going back and forth on: should sanitizing at the LLM boundary be the framework’s job — stripped by default the way frameworks auto-escape HTML — or the application’s? Right now everyone’s re-solving it one product at a time, badly. Where do you think it belongs?
References
- Riley Goodside — original demonstration, X, Jan 2024: twitter.com/goodside/status/1746685366952735034
- Johann Rehberger — ASCII Smuggler: Crafting Invisible Text and Decoding Hidden Codes: embracethered.com
- Cisco — Understanding and Mitigating Unicode Tag Prompt Injection: blogs.cisco.com
- Unicode — UTS #51 (tag character rendering): unicode.org/reports/tr51
- Rehberger — Amp Code: Invisible Prompt Injection Fixed by Sourcegraph (2025): embracethered.com