设想这样一个场景:队友把一段 changelog 文本贴进了你的 PR 描述里,这段文字来自一次让 AI 总结第三方网页的对话。英文读起来没问题,diff 看着干净,CI 全绿。两周后一份安全报告指着其中一条 bullet 问你:为什么发布说明里包含一段 41 个字符、位于 U+E0000 区段——也就是 Unicode tag 块——的不可见字符串?团队里没人输入过这串字符,团队里也没人能在渲染后的 Markdown 中看到它。它跟着复制粘贴一路混进来,穿过了你的编辑器、linter、reviewer 的眼睛,再穿过你的静态站点生成器,最终被打进哈希、签进交付给客户的产物。同一周,另一次毫不相关的审计又卡住了你的一条错误信息——有人从富文本编辑器粘了一段翻译过的字符串,U+202E RIGHT-TO-LEFT OVERRIDE 跟着进来了;从那条信息之后,每一条打印到终端的错误码都被这个字符在视觉上反向重排。
这不是什么罕见问题,而是和 AI 助手、多语种内容、富文本来源打交道时的日常产物。ZeroTool 的检测器接收任意粘贴的文本,准确告诉你哪些码点是不可见的、它们归属哪一类、清理后的字符串长什么样——全部在浏览器内完成,不上传任何内容。
什么算”不可见”
Unicode 标准有意定义了一类字符:它们渲染时宽度为零,或者只在不产生字形的前提下对渲染施加副作用。这些字符存在的原因都合理——阿拉伯字母的塑形(shaping)、天城文连字(ligature)、软换行提示、emoji ZWJ 序列、文件编码标记——只有当它们越过作者并未预期的边界时(例如纯文本导出、源代码、或一个只期望 ASCII 的数据库列),才会变成问题。
检测器把不可见码点归为五类,每一类都有各自的攻击面和各自的合法用途:
| 类别 | 码点 | 合法用途 | 被夹带时的风险 |
|---|---|---|---|
| 零宽字符(Zero-width) | U+200B ZWSP、U+200C ZWNJ、U+200D ZWJ、U+2060 WJ、U+FEFF BOM/ZWNBSP、U+3164 Hangul Filler、U+115F / U+1160 / U+FFA0 韩文填充符、U+180E MVS、U+2061–U+2064 不可见数学符号 | 软换行;阿拉伯文/印度系文字塑形;emoji ZWJ 序列;文件 BOM | 标识符冲突、加水印、解析器漂移、指纹追踪 |
| 双向控制符(Bidirectional) | U+200E LRM、U+200F RLM、U+202A–U+202E LRE/RLE/PDF/LRO/RLO、U+2066–U+2069 LRI/RLI/FSI/PDI | 混合 LTR/RTL 段落、阿拉伯文/希伯来文/波斯文 | Trojan-Source(CVE-2021-42574)——在视觉上重排源代码 |
| Tag 字符 | U+E0000–U+E007F | 最初用于纯文本中的语言标记(Unicode 现已废弃 U+E0001 LANGUAGE TAG,并强烈不建议用 tag 字符表示语言标记);目前用于 emoji 行政区旗序列 | 藏入 ASCII 文本(ASCII smuggling):人看不见、LLM 却能读到的提示注入指令 |
| 变体选择符(Variation selectors) | U+FE00–U+FE0F(VS1–VS16)、U+E0100–U+E01EF(VS17–VS256) | 选择 emoji 字形变体(文本式 vs emoji 式)以及中日韩表意文字变体 | 破坏字符串比较,撑大字节长度 |
| 格式控制(Formatting) | U+00AD SOFT HYPHEN、U+034F COMBINING GRAPHEME JOINER、U+206A–U+206F 已废弃的格式控制符 | 建议的连字符断点、字位簇控制 | 粘贴到纯文本后依然存在;破坏子串搜索和分词 |
五个类别合起来,正好是 Unicode 18.0 在 DerivedCoreProperties.txt 中标为 Default_Ignorable_Code_Point 的 4,174 个码点。一个并不不可见的码点也可能是恶意的——同形字(homoglyph)攻击用西里尔字母 а(U+0430)替换拉丁字母 a(U+0061),在大多数字号下肉眼无法区分。那是另一类问题(易混淆字符检测,规范见 UTS #39),不在本工具范围内。本检测器只关心那些不产生字形、或者只对其他字符的渲染产生副作用的码点。
为什么要在意——三个现实威胁
Trojan-Source(CVE-2021-42574)
2021 年 11 月,剑桥的 Nicholas Boucher 和 Ross Anderson 发表了 Trojan Source,证明当时几乎所有编译器、IDE 和代码审查工具都会按照 Unicode Bidirectional Algorithm 渲染双向 Unicode 控制字符——包括源代码中的注释和字符串字面量内部。攻击者只要插入 RLI、LRI、PDI、RLO 控制符,就能写出这样一个源文件:编译器读到的字节是一回事,reviewer 看到的字形是另一回事。
一个经典示例把注释重排,使一条 return 语句看上去位于 /* ... */ 内部,而编译器实际把它当作真实代码执行:
// JavaScript example, with U+202E (RLO) visualised as ⮜
const isAdmin = false;
/* Check if user is admin ⮜ begin admins only */
if (isAdmin) {
console.log("You are an admin.");
/* end admins only ⮜ */ }
编译器随后加了检查:Rust 1.56.1 新增两个默认报错的 lint,text_direction_codepoint_in_literal 和 text_direction_codepoint_in_comment(Rust 安全公告)。但这类检查覆盖的是编辑器和编译器——而不是工具链下游流动的文档、README、Markdown 文件、配置文件、JSON 块、shell 片段。藏在 JSON 配置或 YAML 发布清单里的一个双向控制符,对大多数 reviewer 而言仍然是不可见的。
检测侧的修复非常机械:整个 bidi 块定义明确,剥掉之后字符串的可见顺序就等于字节顺序。把工具跑在任何你自己并未撰写的输入文本上,bidi 计数会告诉你是否需要再仔细看一眼。
通过 tag 字符夹带 ASCII 文本
Unicode tag 块 U+E0000–U+E007F 原本为纯文本中的语言标记而设计(RFC 2482,1999 年)。该区段的 Unicode 码表如今把 U+E0001 LANGUAGE TAG 标为已废弃,并写明强烈不建议用 tag 字符表示语言标记。它目前用于 emoji 行政区旗序列(🏴 苏格兰旗等旗帜使用的 tag 序列)。其余 tag 字符是不可见空间:已分配的码点,不渲染任何东西,与周围字符也不发生交互。
这让整个块几乎成为一个完美的隐写信道。每个 ASCII 字节都可以通过把码点加上 U+E0000 来编码,U+E0020–U+E007E 正好一对一对应 95 个可打印 ASCII 字符。一个 32 字符的令牌只需 32 个不可见 tag 字符就能编码完毕,藏进任何一句普通文本里。
2024 年 1 月,Riley Goodside 演示了用 tag 字符写成、藏在粘贴文本里的指令能对 ChatGPT 做提示注入。随后 Johann Rehberger 发布了 ASCII Smuggler,用来编码和解码这类负载,并演示了 LLM 也能在回答里输出 tag 字符,隐藏文本既能流进对话,也能流出对话。他把这种手法描述为给 LLM 藏指令、在众目睽睽下夹带数据的方式,并建议 LLM 应用在提示和响应两端过滤 tag 字符。
没有公开资料表明 tag 字符是 AI 水印。OpenAI 没有公开说过用 tag 字符标记 ChatGPT 的输出。2025 年 4 月有报告称 o3 和 o4-mini 的输出含有特殊字符,涉及的是 U+202F NARROW NO-BREAK SPACE,并非 tag 字符;报告方 Rumi 随后写明,OpenAI 告诉他们这些字符不是水印,而是「a quirk of large-scale reinforcement learning」(大规模强化学习的副产物)。任何检测器都无法只凭字符判断一段文本是否由 AI 写成。
如果你要把网页内容、文档或对话输出贴进提示、仓库或发布物,你会希望先知道里头有没有 tag 字符。本检测器会标记整个 U+E0000–U+E007F 范围(英格兰、苏格兰、威尔士旗帜里的除外),给每个 tag 字符标出它对应的 ASCII 字母,并提供一个独立模式直接移除该区段。
复制粘贴污染
常见的来源之一就是普通的复制粘贴。Qiita 上 2024 年 8 月的一篇文章记录了从 PowerPoint 复制几行文字到 VS Code 的经过:每个换行前都夹着一个 U+200B,文件以 Shift_JIS 保存后,每行末尾都变成了问号,因为 Shift_JIS 里没有零宽空格。
当这些文本离开富文本环境,落到一个纯文本目的地(数据库 varchar、YAML 文件、Markdown 文章、代码注释、HTTP header)时,不可见字符会一路跟着进来,然后悄悄把事情搞砸:
- 子串搜索匹配失败:
"production"不等于"pr\u200bduction"。 - 哈希和签名校验时好时坏,因为两个看上去一模一样的字符串算出不同摘要。
- 编译器报错指向错误的列号,因为源文件字节数比可见字符数多。
这里的教训是普遍的:任何文本从富文本来源流向一个安全敏感场景,你都需要一种方式去看清里面到底有什么。
如何检测并清除——工作流
打开零宽字符检测器,把文本粘进输入框。输入框下方一行显示发现了多少个不可见字符,旁边是「复制清理后的文本」按钮。再往下是注释视图(每个不可见字符显示为一个带名称的标签)、按类别计数的统计栏和清除模式选择器。
把任何文本粘进去。检测是同步的,每一次按键都会触发。你会看到四份有用的信息:
- 总计 —— 检测到多少个不可见码点,按类别拆分。干净的文档应当为零。
- 逐字符注释 —— 每个不可见码点在原位显示为一个标签,写着
ZWSP、RLO、TAG-h这样的简称。鼠标悬停可以看到码点和完整名称。 - 类别计数 —— 统计栏按类别计数,另外给出可见字符数和码点总数。一长串连续的 tag 字符大概率是藏起来的 ASCII 文本;开头单独一个 BOM 通常只是文件编码标记。
- 清理后的输出 —— 移除所选类别后的同一段文本,可直接复制。
清除模式选择器有五个挡位:
- All —— 移除所有类别的不可见码点,不分类型。当来源就是纯文本、没有任何理由出现这些字符时使用。大部分代码、配置文件、JSON、YAML、日志行都属于这一档。
- Zero-width only —— 仅清除 ZWSP、ZWNJ、ZWJ、WJ、BOM、Hangul filler、MVS、不可见数学符号。保留 bidi 控制符(因为 RTL 文本可能确实需要它们)和变体选择符(因为 emoji 呈现形式依赖它们)。清理那些你希望保留排版意图的混合脚本文本时使用。
- Bidi only —— 仅清除双向控制块。用于源代码、配置文件,以及任何要求可见顺序必须等同于字节顺序的地方,同时保留 emoji 或天城文里合法的 ZWJ 序列。
- Tag only —— 仅清除 U+E0000–U+E007F 范围(上述三面旗帜保留)。用于从网页或对话复制来的文本,且唯一可疑类别就是隐藏的 tag 文本时。其他一切保留不动。
- Variation only —— 仅清除 U+FE00–U+FE0F、U+E0100–U+E01EF 和蒙古文自由变体选择符。想让 emoji 回到文本样式,或字符串比较因为看不见的选择符而失败时使用。
选定一种模式后,清理结果会原地刷新。点击按钮复制,或导出为 .txt 用于二进制干净的传输。
不用工具时如何检测与清除
工具之所以存在,是因为点击比写脚本快。但底层检测在任何语言里都是正则级别的小事。下面是三份参考实现,你可以塞进 CI 步骤、pre-commit hook、或者一个审计用户输入内容的脚本。
Python 版本只用标准库,输出按类别分类的计数加上清理后的字符串。运行方式:python detect_invisible.py < input.txt:
import re
import sys
import unicodedata
CATEGORIES = {
"zero-width": r"[\u200B-\u200D\u2060-\u2064\uFEFF\u180E\u3164]",
"bidi": r"[\u200E\u200F\u202A-\u202E\u2066-\u2069]",
"tag": r"[\U000E0000-\U000E007F]",
"variation": r"[\uFE00-\uFE0F\U000E0100-\U000E01EF]",
"formatting": r"[\u00AD\u034F\u115F\u1160]",
}
def scan(text: str) -> dict[str, list[tuple[int, str, str]]]:
findings: dict[str, list[tuple[int, str, str]]] = {k: [] for k in CATEGORIES}
for name, pattern in CATEGORIES.items():
for match in re.finditer(pattern, text):
cp = match.group(0)
findings[name].append((
match.start(),
f"U+{ord(cp):04X}",
unicodedata.name(cp, "<unknown>"),
))
return findings
def strip_all(text: str) -> str:
combined = "|".join(p.strip("[]") for p in CATEGORIES.values())
return re.sub(f"[{combined}]", "", text)
if __name__ == "__main__":
src = sys.stdin.read()
report = scan(src)
total = sum(len(v) for v in report.values())
print(f"invisible code points: {total}")
for cat, hits in report.items():
if hits:
print(f" {cat}: {len(hits)}")
for offset, cp, name in hits[:5]:
print(f" @{offset} {cp} {name}")
sys.stdout.write(strip_all(src))
JavaScript / TypeScript 版本面向 Node 20+ 与浏览器。同样的正则就能用;唯一的小技巧是 JS 源码需要 u 标志,以及对 U+FFFF 以上码点要用代理对(surrogate pair)感知的写法:
const CATEGORIES = {
"zero-width": /[\u200B-\u200D\u2060-\u2064\uFEFF\u180E\u3164]/gu,
"bidi": /[\u200E\u200F\u202A-\u202E\u2066-\u2069]/gu,
"tag": /[\u{E0000}-\u{E007F}]/gu,
"variation": /[\uFE00-\uFE0F\u{E0100}-\u{E01EF}]/gu,
"formatting": /[\u00AD\u034F\u115F\u1160]/gu,
};
const ALL = new RegExp(
Object.values(CATEGORIES).map(r => r.source).join("|"),
"gu"
);
export function detectInvisible(text) {
const findings = {};
for (const [name, re] of Object.entries(CATEGORIES)) {
findings[name] = [...text.matchAll(re)].map(m => ({
offset: m.index,
codePoint: "U+" + m[0].codePointAt(0).toString(16).toUpperCase().padStart(4, "0"),
}));
}
return findings;
}
export function stripInvisible(text) {
return text.replace(ALL, "");
}
如果只想要 Bash 里的一行守卫——比如让 CI 在 Markdown 文章出现任何 tag 字符时直接失败——在 UTF-8 locale 下可以用带 PCRE 支持的 GNU grep(macOS 上用 Homebrew 安装):
# Fail if any tag character (U+E0000–U+E007F) appears
if grep -P '[\x{E0000}-\x{E007F}]' "$file" >/dev/null; then
echo "tag characters detected in $file" >&2
exit 1
fi
# Strip every category in place with perl
perl -CSDA -i -pe '
s/[\x{200B}-\x{200D}\x{2060}-\x{2064}\x{FEFF}\x{180E}\x{3164}]//g;
s/[\x{200E}\x{200F}\x{202A}-\x{202E}\x{2066}-\x{2069}]//g;
s/[\x{E0000}-\x{E007F}]//g;
s/[\x{FE00}-\x{FE0F}\x{E0100}-\x{E01EF}]//g;
s/[\x{00AD}\x{034F}\x{115F}\x{1160}]//g;
' "$file"
perl -CSDA 在 STDIN、STDOUT 和 @ARGV 上启用 UTF-8,这是在命令行避免 Perl 把多字节输入搅乱的便携做法。同一份脚本可以原样塞进 Git pre-commit hook、GitHub Actions 和 Vercel build 步骤,不需要额外依赖。
坑点
大规模跑不可见字符清理时有五个边角值得留心:
emoji 的 ZWJ 序列是合法的 ZWJ。 家庭 emoji 👨👩👧👦 的编码是 MAN U+200D WOMAN U+200D GIRL U+200D BOY——四个基础 emoji 用三个零宽连接符粘在一起。如果你对一段包含 emoji 的字符串直接剥掉 ZWJ,那个家庭会被拆成四个并排的独立 emoji。🏳️🌈(白旗 + ZWJ + 彩虹)以及职业、发型类的 emoji 序列 都是同样的下场。检测器仍然会在 emoji 内部标出 ZWJ,因为它无法区分”刻意的序列”和”夹带的字节”——视觉上两者都不产生独立字形。处理含 emoji 的文本时改用 Bidi only 或 Tag only,或者从参考表中重新拼回规范的 emoji 序列。
文件 BOM 有时是有意的。 Windows PowerShell 5.1 会把没有 BOM 的脚本按旧的「ANSI」代码页读取,所以微软建议含非 ASCII 字符的脚本保存为带 BOM 的 UTF-8。如果你的文本来自文件而非剪贴板,剥离前要显式判断这个开头的 BOM 是否有意义。检测器不论位置都会把 BOM 当作零宽码点上报;至于这份报告是警告还是无害产物,由你判断。
软连字符在富文本里很正常。 U+00AD 是建议渲染引擎换行点的标准方式。一份排过版的 PDF 或 EPUB 书可能合法地含有几百个。只在目标是纯文本(代码、配置、数据库字段、日志行)时才剥离软连字符。在已排版文档内部移除它们,只会降低换行质量,没有任何安全收益。
tag 字符并不总是隐藏文本。 U+E0000–U+E007F 范围目前还有一个官方用途:emoji 行政区旗序列。威尔士旗 🏴 由黑旗(U+1F3F4)、tag 编码的 ISO 行政区代码 gbwls、以及一个 CANCEL TAG(U+E007F)终结符组成。把整个 tag 块剥掉就会把这些旗删掉。但夹在 🏴 与 U+E007F 之间的 tag 字符不一定是旗,任何人都能在那里塞进任意 tag 文本。推荐通用的只有三条(emoji-sequences.txt 的 RGI_Emoji_Tag_Sequence,Emoji 18.0):英格兰 gbeng、苏格兰 gbsct、威尔士 gbwls。本检测器在所有模式下只保留这三条,其余 tag 字符一律标出,包括包着隐藏文本的伪造旗帜。
客户端清理不会修复上游。 如果 CMS、翻译记忆库、或者 LLM API 才是不可见字符的来源,在浏览器里剥掉只清理你手里这一份。下一次从同一个来源复制还是同样的问题。把检测器当显微镜用,不要当过滤器——用它去验证关于来源的假设,然后在你能控制的边界上加真正的剥离步骤(webhook、CI 步骤、pre-commit hook、或服务端用上文那几份实现做的归一化例程)。
值得多提一个第六个坑:字节长度 ≠ 字符长度 ≠ 可见宽度。一段含 50 个可见字符、80 个 U+200B 这类基本多文种平面内的不可见字符的字符串,在 JavaScript 里 String.length 是 130,在 Python 里 len() 是 130,在终端里 wcswidth 是 50。哈希函数、Content-Length header、数据库 VARCHAR(N) 限制、认证签名都会把不可见字符算进去。如果你比较时按可见宽度归一化但按字节数储存,就会在本应不同的输入上得到假相等,或者在人眼判断相同的输入上得到假不等。Unicode Normalization 里的 NFC / NFKC 归一化能处理一部分场景(组合标记、兼容性分解),但并不会移除不可见码点;剥离是单独的一道工序。
与其他检测器的对比
2026-09-29 我们把本工具的「隐藏的 Tag 文本」示例(一段带家庭 emoji 和 25 个 tag 字符的聊天回复)粘进了另外两个工具。
Invisible Character Viewer 标出了两个零宽连接符和每个 tag 字母。点「Strip Invisible Characters」后得到 Thanks for the fix 👍 Team: 👨👩👧 See you Monday.:隐藏文本没了,连接符也没了,家庭 emoji 拆成了三个人。它还会标出 U+00A0、U+3000 这类看得见的空格,本检测器不标。
ASCII Smuggler 把 tag 字符解成一句可读的英文,并统计出 25 个 Unicode tag 和 2 个其他不可见字符。它把解码结果显示出来供阅读,不提供清理后的文本。
本检测器每次只清除一类。选「仅 Tag」时,输出保留 👨👩👧,只去掉隐藏的那句话。
延伸阅读
站内:
- Unicode 文字转换器 —— 把文字转成 𝐁𝐨𝐥𝐝 这类花体 Unicode 字母。它们是看得见的字符,本检测器不会标出。
- 字符串转义工具 —— 对 JavaScript、JSON 和 HTML 实体做转义与反转义。JavaScript 模式会把 U+200B 写成
\u200b,方便把找到的字符写进测试用例。
站外:
- Trojan Source: Invisible Vulnerabilities —— Boucher & Anderson 描述 CVE-2021-42574 和 CVE-2021-42694 的论文,附攻击模板与缓解指引。
- Unicode Standard Annex #9 — Unicode Bidirectional Algorithm —— 双向控制符的权威规格,包含 Trojan-Source 利用的嵌入与隔离算子。
- Unicode Technical Report 36 — Unicode Security Considerations —— 标准自身对不可见字符、同形字、标识符欺骗的威胁模型。
- Johann Rehberger — ASCII Smuggler Tool: Crafting Invisible Text and Decoding Hidden Codes —— tag 字符如何给 LLM 藏指令,附编码/解码工具和 LLM 输出隐藏文本的演示。
- Unicode 码表:Tags(U+E0000–U+E007F) —— 该区段的字符列表,含 U+E0001 LANGUAGE TAG 的废弃说明。
- RFC 2482 — Language Tagging in Unicode Plain Text —— 最初提出用 tag 字符做语言标记的文档。