In a write-up published August 26, 2024, Johann Rehberger showed hidden instructions in an email or shared document taking over Microsoft 365 Copilot. Copilot searched the user's other mail and documents, in one demonstration for a Slack MFA code, then hid what it found inside an ordinary-looking link [4]. The stolen data sat in the URL as characters that render as nothing. One click sent it to the attacker's server.
The characters were Unicode tags, and the technique is called ASCII smuggling. Most teams file it under prompt injection and reach for a better classifier. We think that undersells how simple it is to stop. Legitimate text almost never contains a decodable run of tag letters outside an emoji flag, so a hidden instruction is a fact about the input, not a probability. Sanitize both directions, keep what you strip as evidence, and let the finding block on its own.
A second alphabet that renders as nothing
The Tags block occupies U+E0000 to U+E007F in Plane 14 [1]. Its core is 95 tag components, from U+E0020 TAG SPACE through U+E007E TAG TILDE, each named for the printable ASCII character it mirrors: U+E0041 is TAG LATIN CAPITAL LETTER A, U+E0061 is TAG LATIN SMALL LETTER A [1]. Add 0xE0000 to an ASCII code and you get its tag twin; subtract it and you get the ASCII back.
The block's original purpose, language tagging introduced by U+E0001 LANGUAGE TAG, is deprecated, and the code chart says its use is strongly discouraged [1]. The surviving legitimate use is emoji: Unicode Technical Standard #51 builds subdivision flags from U+1F3F4 BLACK FLAG, tag letters and a closing U+E007F CANCEL TAG [2]. The same standard states the property attackers rely on: "A completely tag-unaware implementation will display any sequence of tag characters as invisible" [2].
So there are two readers. A person, a review interface and most log viewers see nothing. A tokenizer that learned these code points from training data sees a clean, decodable message. The string is hidden only from the humans meant to oversee it.
The left panel is what a person sees in a chat, ticket or email. The right panel is the same string as the model receives it: the visible sentence followed by an instruction spelled in tag characters, one code point per ASCII letter.
Teardown: the Copilot chain, step by step
Rehberger's January 14, 2024 post introduced ASCII Smuggler, a tool that encodes, decodes and detects tag payloads, and credited Riley Goodside with the initial proof of concept [3]. It showed ChatGPT acting on hidden text, likely because training data contained these characters, and, crediting Kai Greshake, noted that a model can be induced to emit hidden text [3]. Reading and writing are the two preconditions. The Copilot chain used both [4]:
- Delivery. Hidden instructions arrive in an email or a shared document the victim's Copilot will read.
- Takeover. Copilot follows them and searches the user's other emails and documents, in one demonstration retrieving a Slack MFA code.
- Encoding. Copilot writes the retrieved data as invisible tag characters inside a URL and renders it as an ordinary-looking link.
- Exfiltration. The user clicks, and the data goes to the attacker's server.
The disclosure timeline separates what was fixed from what was not. MSRC initially closed the ASCII smuggling report as low severity in January 2024. An end-to-end exfiltration exploit followed in February, and in July the researcher reported that his repro no longer worked [4]. He wrote that "It is unclear how exactly Microsoft fixed the vulnerability," that hyperlinks appeared to stop rendering a few months before publication, and that prompt injection "is still possible" [4]. Our reading: the exfiltration path in steps three and four was closed, while step two, reading hidden text, remained a separate question. That is a lesson about where fixes tend to land, not a claim about the product today.
The January post also makes the point defenders should hold onto: a person can copy, forward or approve text without noticing what it carries [3]. Approval workflows assume the approver sees what the model sees, and tag characters break that assumption silently. Its mitigation advice was direct: filter tag code points at both prompt and response time [3].
Both directions defeat the same assumption
Inbound: injection a reviewer cannot see
Any text an agent ingests can carry a payload: an email, a ticket, a calendar invite, a web page, a PDF text layer, a code comment, an MCP tool description. The visible text passes human review and keyword filters while the hidden part tells the model to call a tool, slant a summary or disregard its instructions. Tag smuggling removes indirect injection's main cost: staying unnoticed by the person who reads the same content.
Outbound: data leaving where nobody looks
If an injected instruction tells the model to append secrets as tag characters to a link, a status message or a tool argument, the output looks normal to the user and to whoever approves it. In URLs the characters usually travel percent-encoded: U+E0049, the tag twin of capital I, is the four UTF-8 bytes F3 A0 81 89, so it appears as %F3%A0%81%89 in a query string. An egress check that looks only for visible secrets in decoded text will miss it.
Picture an illustrative accounts-payable agent at a fictional distributor, Ardent Supply, with read access to vendor bank details and a send-email tool a clerk approves. A plausible invoice email carries a tag-encoded instruction to append the vendor's bank account and last three payments, as tag characters, to the portal link in the reply. The clerk approves a polite message with a normal-looking URL. The approval was real; the information behind it was not.
Both directions attack oversight rather than the model.
The rest of the invisible family
Tag characters are the most expressive of a larger group because they carry a full ASCII alphabet. The others hide or rearrange text without forming a complete channel, and still break the link between what is shown and what is processed.
Bidirectional controls are the best studied. Boucher and Anderson's Trojan Source paper showed that source code can be encoded so its tokens are logically ordered differently from how they are displayed, with working examples from C and Python to SQL and Solidity [5]. The reordering attack is CVE-2021-42574 and a homoglyph variant CVE-2021-42694; the authors recommend that compilers and build pipelines warn on unterminated bidirectional controls and that editors and repository front ends make these characters perceptible [6]. A reviewer reading a reordered instruction is reading a different instruction from the one the model receives.
Zero-width characters can split a keyword so a filter misses it, or encode bits between visible letters; variation selectors, invisible without a base character, can be stacked to carry data. Every class has legitimate uses, so the table sets a default for each rather than stripping everything.
| Class | Code points | Legitimate use | Abuse pattern | Default handling |
|---|---|---|---|---|
| Tag components | U+E0020 to U+E007E, plus U+E007F CANCEL TAG [1] | Emoji tag sequences such as subdivision flags [2] | A full hidden ASCII channel for instructions in and data out [3] [4] | Flag and strip, except inside a well-formed emoji tag sequence |
| Language tag | U+E0001 [1] | None current; deprecated and strongly discouraged [1] | Same as tag components | Flag and strip in every context |
| Zero-width characters | U+200B, U+200C, U+200D, U+2060, U+FEFF | Joiners in emoji sequences and in scripts that need ZWJ or ZWNJ; byte order mark at file start | Splitting keywords to evade filters; encoding bits between visible letters | Flag; strip outside allowed emoji and script contexts |
| Bidi embeddings, overrides and isolates | U+202A to U+202E, U+2066 to U+2069 | Mixing right-to-left and left-to-right text | Displayed order differs from logical order [5] [6] | Flag; reject unterminated sequences; show controls visibly in review |
| Bidi marks | U+200E, U+200F, U+061C | Placing punctuation correctly in right-to-left text | Small reorderings and filter evasion | Allow in right-to-left content; flag elsewhere |
| Variation selectors | U+FE00 to U+FE0F, U+E0100 to U+E01EF | Glyph variants; emoji versus text presentation | Invisible carriers when stacked or used without a base character | Flag more than one selector per base character |
Breaking the chain: one sanitizer, both lanes
The control is one sanitizer on every text span entering the model and every span leaving it: normalize, detect, then decide. In the Ardent case it had three chances, none of which required understanding the attacker's intent. The inbound email held a long run of tag characters outside any emoji sequence. The draft held encoded tag bytes in a URL on a domain the agent had never linked before. And a render-visible approval screen would have shown the clerk the hidden characters in both.
Input text and output text pass through the same steps: NFKC normalization, detection of tag, zero-width and bidi characters, then a split into a logged flag-and-strip record and clean text that continues to the model. Egress gets exactly the treatment ingress does.
- Normalize with NFKC to fold fullwidth letters, ligatures and other compatibility forms. It does not remove tag, zero-width or bidi characters, so it is preparation, not the control.
- Detect explicitly by code point class, using the ranges in the table. Decode tag runs by subtracting 0xE0000 so the hidden text becomes readable evidence.
- Decode before you inspect egress. Percent-decode URLs and unescape HTML entities and common encodings in tool arguments first, because outbound payloads rarely travel as raw code points.
- Apply context rules. Keep tag characters only inside a well-formed emoji tag sequence that starts with U+1F3F4 and ends with CANCEL TAG [2], ZWJ inside emoji sequences, ZWNJ in scripts that require it, and bidi marks in right-to-left content.
- Flag and strip, never strip silently. Record the span, its source, the character class, the count and the decoded payload. A silent strip erases the clearest evidence of intent you will ever get.
- Decide by what you found. A decoded tag run that reads as an instruction to the model blocks the request or response. A stray zero-width space in a pasted document is stripped and logged, not escalated.
Run it on everything that becomes model context (retrieved documents, tool results, MCP tool descriptions, file contents, turns loaded from memory) and on everything the model produces for a person or system: chat output, drafted messages, tool-call arguments and URLs. The Copilot chain came in through a document and left through a link, so one-sided sanitizing would have left half of it open [4].
Show the hidden text to the person who approves
Detection protects the model; render-visible review protects the oversight tag smuggling targets. Any screen where a person approves an agent's action, or reads content the agent will act on, should show invisible characters as visible placeholders: a highlighted code point label, or the decoded text in a warning style. That covers approval queues, ticket views, prompt-template code review and log viewers, as Trojan Source's advice on making these characters perceptible suggests [6]. Store raw strings with escapes in logs, not rendered copies, so an analyst can see that a field which looks empty held a payload. If the approval screen and the model disagree about what a string says, the approval is not informed consent.
Why a hidden instruction can block on its own
Most injection detection is probabilistic: a classifier or model judge scores how instruction-like a span is, and teams tune thresholds. A decodable run of tag letters outside an emoji flag needs none of that. It is a structural fact, in the same class as an unterminated bidi override in source code, so block on it alone and treat a judge's score on the decoded text as supporting context. False positives are cheap because legitimate uses are narrow and enumerable; a false negative is an attack built to get past human review.
What this does not do: close every covert channel. A model can still be steered to encode data in visible but unremarkable forms, such as word choice, whitespace patterns or a long base64 string, and those need egress controls that reason about destinations and data rather than code points. Detection also depends on unwrapping every encoding layer an attacker stacks. This removes a cheap, high-yield technique. It is not a cure for injection.
Where Intertrace applies the structural rule
Intertrace's enforcer applies the structural rule inline on LLM, MCP and agent-to-agent traffic: hidden instructions can block a call on their own, while labels that come only from a model judge flag unless a cheaper second signal agrees. MCP tool descriptions are scanned for hidden and model-addressed instructions, and every decision is a structured event naming the rule, layer and evidence. NFKC normalization, emoji and script context rules, and render-visible approval screens in your own applications remain yours to implement.
One test before the next model upgrade
Send your agent a message whose visible text is benign and whose tag-encoded text asks for a harmless, observable change, such as ending the reply with a specific word. Repeat it through a retrieved document and an MCP tool description, and ask the model to put tag characters in a URL query string. Each should be flagged or blocked, the decoded payload should be in the log, and the approval screen should show the hidden characters without a hex dump. A subdivision flag emoji should pass unchanged. Rerun it whenever you change models, tokenizers, rendering libraries or the gateway.