Invisible Unicode Tags Smuggle Instructions Past Your LLM Filter. The Detection Is a Byte Pattern on the Raw Stream

A block of Unicode characters that renders as literally nothing — no glyph, no width, no cursor movement — can carry a full paragraph of instructions into your model, and the content filter you bought to stop prompt injection can miss it whenever it inspects a sanitized, normalized, or visually-rendered representation while the model receives the original code points. The characters live in the Unicode tags block, U+E0000 through U+E007F; within it, U+E0020U+E007E correspond one-to-one to printable ASCII (0x200x7E), while U+E0001 and U+E007F serve special tag functions. Several production models still read the ASCII-tag characters as ordinary text — the block began as a language-tagging mechanism, now deprecated for that use, though a constrained form survives in emoji tag sequences. Your guardrail passes the message clean. Your human-in-the-loop reviewer approves a prompt they cannot read. And if your gateway normalized the request before it hit the index, the evidence is already gone.

TL;DR

  • Invisible Unicode tag characters (U+E0000–U+E007F; U+E0020–U+E007E mirror printable ASCII) carry full instructions past content filters that inspect a sanitized or rendered representation, because the model’s preprocessing preserves the invisible code points the screen never shows.
  • Scrubbing behavior is model-specific, point-in-time, and cuts both ways: FireTail’s Sept-2025 testing found the ChatGPT/Copilot/Claude surfaces scrubbed inbound tags while Gemini/Grok/DeepSeek processed them — but other research shows tag interpretation through Claude-related MCP workflows, so treat it as surface-, version-, and date-specific, not a permanent matrix; any model can also emit tags as an invisible exfiltration channel inside Markdown links.
  • Detection is a byte pattern, not a word list — every tag character starts with UTF-8 prefix F3 A0, and Cisco’s rule fires at ten-plus occurrences as a noise-reduction heuristic (real payloads run longer; it also clears the ~six-codepoint subdivision-flag emoji, England/Scotland/Wales) — not a boundary an attacker can’t split under.
  • Put the sensor on the earliest lossless representation — the raw request/response body at the gateway proxy, before normalization — since app telemetry often captures only the already-sanitized prompt; scan it in memory and record tag counts/offsets/hashes rather than warehousing raw bodies wholesale.
  • NFKC does not strip tag characters (no compatibility decomposition); explicit codepoint-range removal is required, and in UTF-16 implementations (AWS’s Java case) interleaved surrogates can need repeated passes. The tag-block rule still misses Sneaky Bits and variation-selector encodings, so instrument document-reading agents first.

This is not a theoretical primitive anymore. Embrace The Red has been tracking the family since Riley Goodside first published the tag-character trick in early 2024, and the encoding has only gotten denser since — the “Sneaky Bits” variant encodes arbitrary bytes with two invisible math operators (U+2062, U+2064), and variation selectors give you a third channel that maps clean onto extended ASCII. The point of all three is the same: get instructions or exfiltrated data into a byte stream that a person, and most naive filters, will treat as empty.

So the interesting question for a defender is not “does this work.” It works. The question is where you put the sensor, and that answer is narrower than most teams assume.

Why the filter misses it

The mechanism is dumb, which is what makes it durable. Tag characters are Default_Ignorable_Code_Point in the Unicode database — rendering engines are supposed to draw nothing for them, and they mostly comply. The tokenizer does not comply. As Cisco put it, the tokenizer splits the text back into tag characters and original characters, and the model rebuilds the payload for you. Invisible on the screen, fully legible to the token stream.

Two consequences fall out of that, and they pull your sensor in opposite directions.

Inbound, the smuggled text is an instruction: a poisoned support ticket, a résumé pasted into a screening agent, a calendar invite. FireTail’s September 2025 testing showed Gemini reading a tampered event body without the victim ever accepting the invite, while the Grok, DeepSeek, and Gemini surfaces processed hidden tag characters that the ChatGPT, Copilot, and Claude surfaces scrubbed on the way in. Read that as a point-in-time result, not a permanent capability matrix — other researchers have demonstrated Claude interpreting hidden tags through MCP tool metadata, which means behavior at one interface tells you nothing certain about another version, API, or agent path. So provider-side stripping reduces exposure on the surface you tested, but it is not a security boundary: validate every ingestion path — chat, tool metadata, RAG documents, uploaded files, connectors, agent memory — and keep independent input and output controls regardless of which model you front this quarter.

Outbound is the vector people forget. The model can emit tag characters too, and an agent told to encode a secret into an invisible suffix on a Markdown link is a working exfiltration channel the moment a downstream system renders that link and a user clicks. The visible URL looks fine. Your DLP, watching visible text, sees a normal hostname. Which is why relying on “our provider scrubs input” is a half-measure — it does nothing for output, and nothing for the models that don’t scrub.

The detection is a byte pattern, and it belongs at the gateway

You are not going to catch this with a word list. There are no words. You catch it by looking at the raw request and response bodies for codepoints in the tag range, and the cleanest signature is the UTF-8 encoding: every tag character starts with the two-byte prefix F3 A0. Cisco’s YARA rule keys on exactly that, counting F3 A0 occurrences anywhere in the object and firing above ten, on the reasoning that any real payload is longer than a couple of characters. That threshold is a sensible noise-reduction heuristic, and I’ll come back to what it does and doesn’t buy you.

The hard part is not the regex. It is having the bytes at all.

Application-level telemetry may capture only the post-processing representation — depending on where callbacks and logging middleware execute, the “prompt” field your app team proudly added to the structured log is often the cleaned string, not what arrived on the wire. You want the sensor upstream of any normalization, and in practice that means the proxy you already run in front of the model. A LiteLLM proxy, an Envoy sidecar with a Lua filter, whatever terminates the call to Bedrock or Azure OpenAI. Inspect the body there, before anything transforms it — but do it carefully. LLM request and response bodies carry credentials, API keys, PII, PHI, source code, system prompts, and retrieved documents, so the safe pattern is to scan in memory and record the evidence — tag counts, offsets, direction, a hash, the decoded suspicious payload — quarantining a full raw body only under tight retention, access, and encryption controls. A verbatim raw-body log of every call is a bigger sensitive-data repository than the app it protects.

In Splunk, if you’re shipping that proxy’s access log through HEC, the detection is a search over the raw message content — conceptually, counting tag-range codepoints in request.messages{}.content and response.choices{}.message.content. Treat the SPL below as conceptual, not copy-paste executable:

sourcetype=llm_gateway
| regex _raw="(\xf3\xa0[\x80-\x81][\x80-\xbf]){10,}"
| stats count by user, app_id, _time

That query is a sketch for a reason. Whether \xF3\xA0... matches depends entirely on the representation your index actually stores — literal supplementary-plane characters, UTF-8 bytes, JSON escapes, or UTF-16 surrogate notation (\uDB40\uDC20) — and if the HEC pipeline decoded and re-encoded the body, the raw byte sequence may not survive to the search head at all. Two more caveats: {10,} demands ten contiguous tag characters, which is stricter than Cisco’s YARA (it counts F3 A0 occurrences anywhere, #pattern1 > 10), and an attacker can defeat contiguity by splitting the payload across fields. Check with a known test string before you trust a zero-hit dashboard, and prefer counting code points at ingest — a script processor summing codePointAt values in [0xE0000, 0xE007F] — over a search-time byte regex. Inspect the earliest lossless representation you have, whether that’s bytes or a decoded Unicode string, before sanitization or destructive normalization touches it.

What you actually tune in week one

The realistic picture is a low base rate of real attacks and a thin trickle of false positives — not a flood — and the false ones have a specific, almost funny source: subdivision flag emoji.

The flags for England, Scotland, and Wales aren’t single codepoints. They’re built from a base waving-flag character plus a short sequence of tag letters and a terminator — the England flag alone is six codepoints out of the tag block. So the naive “flag any tag character at all” rule lights up every time someone drops 🏴󠁧󠁢󠁥󠁮󠁧󠁿 into a Slack message that gets summarized by a bot. That is also the practical argument for a threshold above one: a legitimate subdivision flag lands around six tag codepoints, so a floor above six suppresses that case, and Cisco settles on more-than-ten because realistic hidden prompts run longer than a handful of characters. Cisco doesn’t document “ten” as chosen specifically to clear the England flag, even if the inference is reasonable. Either way it’s noise reduction, not a boundary — an attacker can deliberately keep segments short or spread tag characters across fields to slide under any fixed count. If your users paste a lot of flags — sports channels, i18n teams — bump it and move on.

The other week-one noise is documents. PDFs and web pages pulled into a RAG pipeline occasionally carry stray Default_Ignorable characters from whatever produced them. Those cluster below the threshold too, mostly, but a genuinely weird source document will trip it. First tuning pass is boring: exclude the flag-emoji base sequence, set the count floor, and route the survivors to a queue where an analyst can actually see the decoded payload — because the whole problem is that nobody could see it in the first place.

One caveat that trips up teams relying on normalization as the fix. Running the input through NFKC does not strip tag characters; they have no compatibility decomposition, so they pass through your normalizer unchanged. If your remediation story is “we normalize everything,” you have not remediated this. Stripping requires explicit removal by codepoint range — and in UTF-16 implementations there’s a non-obvious trap AWS demonstrates in Java: interleaved or malformed surrogate sequences (\uDB40 high surrogates) can reconstitute a tag character after your first pass removes its neighbor, so you either iterate until the string stops changing or explicitly reject orphan surrogates. AWS notes Python’s ordinary Unicode string model doesn’t require that recursive treatment. One-shot UTF-16 filters miss it.

Where it maps, and where the residual risk sits

This is an input-validation failure first and foremost, so SI-10 (information input validation) is the anchor control — the smuggled instruction is untrusted input that reached an interpreter without being validated for the byte ranges that carry it. The logging gap maps to AU-3 / AU-12 (audit content and generation): you cannot detect what your audit record already normalized away, so the org-defined control objective is capturing sufficient evidence at the boundary — not a bare prompt field, and not, per the caveat above, a wholesale raw-body warehouse. The exfiltration variant is really an information-flow problem: AC-4 (information flow enforcement), backed by SC-7 (boundary protection) and SI-4 / AU-6 (monitoring and review) — an unmonitored egress channel riding inside rendered output — rather than SC-8, which is about protecting data in transit, not hidden data inside an otherwise-protected session. If the poisoned content arrives through a third-party MCP server or tool description, name the supply-chain controls — SR-3 (supply-chain processes) and SR-11 (component authenticity) — and a scanner like Invariant’s mcp-scan belongs in that pipeline. Build-time, red-team your own apps for this with something like Promptfoo’s ASCII-smuggling plugin under SA-11, before it’s a detection problem in prod.

The residual risk is honest and worth stating: byte-pattern detection catches the tag block cleanly, but Sneaky Bits and variation-selector encodings fall outside this byte pattern, so the tag-block rule alone will miss them. Widening to “count all Default_Ignorable codepoints above a threshold” catches more but is a blunt instrument — that property also covers variation selectors, joiners (ZWJ), bidirectional controls, and script-shaping characters that appear in perfectly legitimate emoji and multilingual text, so a flat count drowns you in noise. Better to score by category — tag characters vs variation selectors vs joiners vs bidi vs Sneaky-Bits codepoints — plus signals like unexpected density or placement and whether the model-facing text materially differs from its visible-text projection. Where you set all this depends on whether your traffic is chatbot-shaped or agent-shaped. Agents that read untrusted documents and then act are the ones I’d instrument first. A chatbot answering questions from a fixed corpus can wait.

Put the sensor on the raw stream at the gateway, set the floor at ten, exclude the flags, and decode the survivors for a human. The payload was always there. You just weren’t looking at the bytes.

Sources


This post was engineered and validated through a multi-agent AI workflow — drafted, adversarially reviewed by several independent models, checked against primary sources, and given a human review before publishing. See an inaccuracy, or found this useful? Leave a comment below — corrections and feedback are read and shape what comes next.

Leave a comment