拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

特殊不可见字符:零宽空格检测、清洗与隐写工程实战

特殊不可见字符:零宽空格检测、清洗与隐写工程实战

上周帮朋友排一个线上问题,两个用户名字在页面上显示得一模一样,后台查出来却是两条不同记录,登录的时候还会串号。把两个字符串粘进 Python 里len()一跑,一个 6 一个 7;多出来的那个字符print()出来是一片空白,repr()一看是'\u200b'。顺藤摸瓜查下去,发现是用户在某个输入框里复制了网页上的"空白昵称",把一个零宽空格带进了数据库。这就是特殊不可见字符的典型事故现场——Unicode 编码里存在一大批渲染时占位为零、肉眼看不出任何痕迹的码位,它们能安安稳稳地穿过输入校验、数据库写入、接口传输,直到某个字符串比较、字典查键或者去重逻辑上突然炸给你看。

我前后在内容风控、数据清洗和文本溯源这几个方向上跟这类字符打了几年交道,踩过的坑不算少:有人拿它做隐形水印溯源搬运,有人拿它做 CTF 杂项题的隐写载体,也有团队因为一个 BOM 字符导致 CSV 导入全部错列。这篇文章就把我积累的东西一次性摊开讲——特殊不可见字符的家族分类、编码层面的原理、20 个高频码位速查表、检测与清洗的完整脚本、零宽隐写的编解码实现、以及工程落地时最容易踩的那些坑。不管你是做后端数据治理、做前端输入校验、玩 CTF 杂项,还是单纯被一个"看起来一样却不相等"的 bug 折磨过,这篇都能直接抄作业。代码全部用 Python 写,复制粘贴就能跑。

1. 摸清家底:不可见字符到底藏在哪些角落

1.1 从一次字符串比较失败说起

那次排查的过程其实很典型。第一步是确认两个字符串的长度差异,len(a)和len(b)一比,差的正好是 1;第二步是把差异字符逐个打印码位,用[hex(ord(c)) for c in name]列出来,立刻看到多出来的那个是0x200b;第三步是查这个码位是什么,unicodedata.name('\u200b')返回ZERO WIDTH SPACE。到这一步问题定性就完成了:不是编码错乱,不是数据库字符集问题,而是源头输入里真的混进了一个零宽字符。

关键在于,这个字符为什么能一路畅通无阻地走到数据库。因为绝大多数输入校验只做三件事:长度检查、正则格式匹配、敏感词过滤。而零宽空格在 Unicode 里属于格式类字符(Category Cf),它既不是空白(\s在多数实现里匹配不到 U+200B)、也不是标点、更不是字母——它压根就没被大多数正则字符类覆盖到。于是它轻松穿过trim()、穿过^\w+$、穿过长度上限,最后在 MySQL 里被原样存下来。等到做用户去重、做登录名精确匹配时,肉眼相同的两个字符串在字节层面完全不同,问题才暴露。

这件事给我最大的教训是:不要假设"看不见"等于"不存在"。在字节世界里,看不见的东西往往更难处理,因为它没有视觉线索,你连它在哪都不知道。

1.2 五大家族,各有各的来路

把高频的不可见字符按用途归类,大致能分成五族,理解这个分类对后面写清洗规则特别有用,因为不同族的处理策略完全不一样。

第一族是零宽控制类,包括 U+200B 零宽空格、U+200C 零宽非连接符、U+200D 零宽连接符、U+2060 词连接符。这一族是最常被滥用的,因为它们的设计初衷是文本排版控制——让浏览器知道哪里可以断行、哪里不能断、连字要不要合并。它们的共同特征是不占任何宽度,字面意义上是"透明的"。

第二族是方向控制类,包括 U+200E 从左至右标记、U+200F 从右至左标记,以及一对嵌入与覆盖控制符。它们服务于双向文本(比如阿拉伯语和拉丁字母混排)的显示顺序。这类字符在正常排版里是必需的,但在恶意场景下可以做出"视觉字符顺序和实际存储顺序不一致"的效果。

第三族是空白变体类,包括 U+00A0 不间断空格、U+2000 到 U+200A 一系列宽度不同的空格、U+202F 窄不换行空格、U+205F 中等数学空格、U+3000 表意文字空格。它们本质上是"空格",只是宽度和换行行为不同。这里面 U+00A0 的坑最深,因为它和普通空格 U+0020 在浏览器里渲染宽度几乎一样,肉眼看不出区别。

第四族是格式与填充类,包括 U+00AD 软连字符、U+034F 字形组合连接符、U+180E 蒙古文元音分隔符、U+115F 和 U+1160 韩文填充符、U+3164 韩文填充符、U+FFA0 半角韩文填充符、U+2800 盲文空白。这一族是"可复制的空白"的主力军,尤其韩文填充符和盲文空白,因为它们在绝大多数系统里都找不到对应字形,会渲染成一片空白,是制作空白昵称的首选。

第五族是标签与变体选择符类,包括 U+FEFF 零宽不换行空格(也就是 BOM 的真身),以及 U+E0000 到 U+E007F 的标签字符区、U+FE00 到 U+FE0F 的变体选择符。标签字符区特别值得注意,这是一整块被保留用于语言标记的码位,整块都是不可见的,容量远超单个零宽字符。

1.3 20 个高频不可见字符速查表

下面这张表是我自己整理后一直在用的,都是从实际日志和用户输入里真实抓到过的码位。表格里"字符"一列就是实际字符,可以直接选中复制;"UTF-8 字节"一列用来看十六进制特征,抓包里对照它最快。

序号字符码位Unicode 名称UTF-8 字节常见出现场景
1​U+200BZERO WIDTH SPACEE2 80 8B隐形水印、空白昵称
2‌U+200CZERO WIDTH NON-JOINERE2 80 8C零宽隐写、波斯语排版
3‍U+200DZERO WIDTH JOINERE2 80 8D零宽隐写、表情连字
4‎U+200ELEFT-TO-RIGHT MARKE2 80 8E双向文本定向
5‏U+200FRIGHT-TO-LEFT MARKE2 80 8F双向文本定向
6⁠U+2060WORD JOINERE2 81 A0防止断行、隐写
7⁡U+2061FUNCTION APPLICATIONE2 81 A1数学排版、隐写
8⁢U+2062INVISIBLE TIMESE2 81 A2数学排版、隐写
9⁣U+2063INVISIBLE SEPARATORE2 81 A3数学排版、隐写
10U+FEFFZERO WIDTH NO-BREAK SPACEEF BB BFBOM、Excel 导出
11U+00A0NO-BREAK SPACEC2 A0网页复制、排版
12­U+00ADSOFT HYPHENC2 AD断字控制
13͏U+034FCOMBINING GRAPHEME JOINERCD 8F字形组合阻断
14᠎U+180EMONGOLIAN VOWEL SEPARATORE1 A0 8E蒙古文排版
15ᅟU+115FHANGUL CHOSEONG FILLERE1 85 9F韩文填充、占位
16ᅠU+1160HANGUL JUNGSEONG FILLERE1 85 A0韩文填充、占位
17ㅤU+3164HANGUL FILLERE3 85 A4空白昵称首选
18ᅠU+FFA0HALFWIDTH HANGUL FILLEREF BE A0空白昵称备选
19⠀U+2800BRAILLE PATTERN BLANKE2 A0 80空白昵称、占位
20U+3000IDEOGRAPHIC SPACEE3 80 80中日文全角空格

注意:表格里的字符在不同字体、不同系统下的渲染结果可能不同。U+3164 和 U+FFA0 在 Windows 的部分字体下会显示成一个空心方框,而在移动端往往完全不可见。判断一个字符是不是"真空白",永远靠码位而不是靠眼睛。

这 20 个只是冰山一角。如果你要做全量扫描,更稳妥的做法是不维护黑名单,而是判断字符的 Unicode 类别——类别为 Cf(格式)、Cc(控制)、Cs(代理)、Co(私用)的字符,以及 Zs(空格分隔符)里除 U+0020 之外的部分,都应该进入重点审查名单。这个思路比枚举码位可靠得多,因为 Unicode 标准还在持续新增字符。

2. 编码层面:看不见的字符凭什么能存在

2.1 码位、字形、编码,三层要分开理解

很多人把"字符"当成一个整体概念,其实这里有三层完全独立的抽象,分清楚之后很多困惑自然就解开了。

最底层是码位(Code Point),就是一串数字,从 U+0000 到 U+10FFFF 的整数编号。Unicode 标准做的事情本质上是分配编号和定义语义,比如 U+200B 这个编号的语义就是"零宽空格"。第二层是编码形式(Encoding Form),也就是码位怎么变成字节序列,UTF-8、UTF-16、UTF-32 是同一个码位的不同字节表达方式。最上层是字形(Glyph),是字体文件里画出来的那个图形。

不可见字符之所以"看不见",是因为它要么在字体里根本没有对应字形,要么字形本身就是零宽度的空白图形。但它的码位是实打实存在的,编码后的字节序列也是实打实占空间的。这就是"看不见却存在"的根本原因——不可见是渲染层的事,存在是编码层的事,两者互不影响。

这个分层理解还能解释另一个常见困惑:为什么同一个字符在不同系统里表现不一样。因为渲染层依赖字体,不同平台的字体覆盖范围不同,所以同一个码位可能在一个系统里显示为空白,在另一个系统里显示为方框。判断问题永远要看码位,不要看渲染。

2.2 UTF-8 字节结构和我实测的膨胀数据

UTF-8 是变长编码,规则很规整:U+0000 到 U+007F 用 1 字节,U+0080 到 U+07FF 用 2 字节,U+0800 到 U+FFFF 用 3 字节,U+10000 以上用 4 字节。上面表格里的 20 个字符,除了 U+00A0、U+00AD 是 2 字节,其余全是 3 字节。

这个字节结构直接决定了隐写方案的开销。我之前做过一组实测:把 1000 个 U+200B 零宽空格连起来写进文件,ls -l显示文件大小是 3000 字节,而同样长度的普通 ASCII 空格只要 1000 字节。也就是说,零宽空格在 UTF-8 下的存储开销是普通空格的三倍。这个数字在做容量估算时非常关键,后面第 6 章会详细算。

还有一个容易踩的点:同一个字符在不同编码下字节数不同。U+200B 在 UTF-8 下是 3 字节,在 UTF-16 下是 2 字节,在 GBK 下压根不存在,转码时会直接变成问号或者报错。所以如果你的链路里存在编码转换(比如从 UTF-8 存到 GBK 数据库),不可见字符可能在中途被静默替换成?或者被丢弃,这会导致同一个字符串在不同系统里长度不一致。我遇到过一次线上事故,写入端是 UTF-8、读取端按 GBK 解,结果一个零宽字符变成了三个?,用户看到的昵称后面跟了三个问号。排查了半天才发现是编码页没对齐。

2.3 为什么 NFC 和 NFKC 归一化干不掉它们

这是最容易被误解的一点。很多人以为对字符串做一次unicodedata.normalize('NFC', s)就能把隐藏字符清理干净,实测下来完全不是这么回事。

Unicode 归一化的作用范围是**规范等价(Canonical Equivalence)和兼容等价(Compatibility Equivalence)**的字符序列,典型场景是把"字母 e + 组合重音符"合并成"带重音的 e",或者把全角字符转成半角。但 U+200B、U+200C、U+200D 这些属于格式类字符,它们在标准里没有被定义为"可以由其他序列等价替换",也不参与兼容分解。结果就是:

import unicodedata s = 'admin\u200b' for form in ('NFC', 'NFD', 'NFKC', 'NFKD'): t = unicodedata.normalize(form, s) print(form, len(t), repr(t))

跑出来的结果是四种形式长度全部是 6,零宽空格一次都没被去掉。当你看到这个结果的时候,方案就得改了——归一化只能统一等价形式,不能做清理,清理必须显式指定要删除的码位范围。我见过有团队在数据入库前只做了一次 NFKC 就以为万事大吉,结果隐藏字符原封不动进了库,去重逻辑依然失效。

顺便说,NFKC 也不是完全没用。它能处理全角数字、全角空格的转换,把'1'变成'1'、把 U+3000 全角空格变成 U+0020。所以正确的做法是先归一化,再显式剔除格式类字符,两步都要做,顺序不能反。

2.4 BOM 的两种身份,坑了无数人

U+FEFF 这个码位很特殊,它同时承担两个角色。作为字节序标记(BOM)时,它出现在文件开头,用来告诉解析器这个文件是 UTF-8 还是 UTF-16、是大端还是小端。作为零宽不换行空格时,它出现在文本中间,就是个普通的格式字符。

坑就出在这两种身份的混淆上。最经典的事故场景是:运营用 Excel 导出 CSV,Excel 默认会在 UTF-8 文件头加一个 BOM,然后这份 CSV 被程序读取,第一个列名就变成了\ufeff用户ID,接着拿去查字典或者做 SQL 字段映射,全部对不上。因为 Python 读 CSV 时如果不用encoding='utf-8-sig',BOM 会被当成内容保留下来。

# 错误写法:第一个字段会带上 BOM with open('data.csv', encoding='utf-8') as f: reader = csv.reader(f) header = next(reader) # header[0] == '\ufeff用户ID' # 正确写法:用 utf-8-sig 让 Python 自动吞掉 BOM with open('data.csv', encoding='utf-8-sig') as f: reader = csv.reader(f) header = next(reader) # header[0] == '用户ID'

这个编码名utf-8-sig是 Python 特有的,意思是"读的时候如果有 BOM 就吃掉,没有也能正常读"。写文件时用这个编码则会在文件头主动写入 BOM。我的经验是:给下游程序读的 CSV 一律用utf-8-sig写、用utf-8-sig读;给程序内部交换的 JSON 一律不带 BOM。JSON 规范本身不允许 BOM,虽然在 Python 里可能不报错,但换个解析器就可能直接失败。

3. 这玩意儿真实用在哪里:四类典型场景

3.1 溯源水印:用隐形序列标记内容流向

零宽字符最正经的商业用途之一是内容溯源水印。原理不复杂:平台在给每个用户分发同一份文本时,插入一段与用户 ID 绑定的零宽字符序列,肉眼完全看不出任何差异,但如果这份内容被泄露或者被搬运出去,把水印提取出来就能定位到是哪个账号流出的。

我参与过一次类似的方案设计。具体做法是把用户 ID 转成二进制,再用 U+200C 表示 0、U+200D 表示 1,按规则插入到段落之间的空白位置。提取的时候只要扫描全文中所有这两个码位,还原出比特流就能得到 ID。这种方案的特点是隐蔽性极强但鲁棒性很弱——只要对方做了任何形式的文本清洗,水印就没了。所以它更适合"内容分发追踪"这种场景,不适合"版权保护"这种需要强对抗的场景。

真正要做强对抗水印,一般会考虑同形字替换(把拉丁字母 a 换成西里尔字母 а)、标点微调、或者词序扰动,但这些都会影响可读性或者有被检测的风险。零宽字符的优势就在于零成本、零视觉影响,代价是抗清洗能力几乎为零。

3.2 数据清洗:入库前的最后一道闸

对绝大多数做后端和数据的同学来说,零宽字符的第一身份其实是"脏数据源"。我在做用户中心项目时,采集侧每天能捞出来上千条带隐藏字符的记录,来源五花八门:网页复制、移动端输入法、某些富文本编辑器的自动插入、从 PDF 里复制文字。

清洗的策略有两种思路,我一般混着用。黑名单策略是维护一个明确的待删码位集合,优点是精准、不会误伤正常字符;缺点是覆盖不全,Unicode 出新字符就得补。类别策略是按unicodedata.category()判断,把 Cf、Cc、Cs、Co 全部剔除,Zwj/Zwnj 之类特定语言必需的字符单独放白名单。优点是覆盖全,缺点是某些语言的正常文本可能被误伤。

我的实际做法是:用户昵称、标题、tag 这类短文本用黑名单+类别双重过滤,正文类长文本只用黑名单。原因是正文里出现的 U+200C 可能是波斯语或印地语的正规用法,粗暴删掉会破坏内容;而昵称里的零宽字符几乎全是垃圾。

3.3 CTF 与隐写:零宽字符的经典杂项题

玩 CTF 杂项的朋友对这块应该很熟。零宽字符隐写是杂项题里的常客,出题人一般会把 flag 按二进制编码成 U+200C 和 U+200D 的序列,藏在一段看起来完全正常的文本里,选手需要用工具或者自己写脚本提取。

识别这类题有个小技巧:把文本复制到支持十六进制显示的编辑器里,或者直接跑xxd,只要看到大量重复的e2808b、e2808c、e2808d字节序列,基本就能确认是零宽隐写。另外,有些题会做多层编码,先 base64 再零宽,这时候提取出来的比特流要先按字节切分再解码。

我在练习平台上做过一道很有意思的题:出题人把零宽字符藏在 markdown 源码的每行末尾,不同段落之间的零宽字符编码还不一样,需要先按换行符分段再分别解码,最后拼起来才是完整 flag。那道题让我意识到,提取脚本必须考虑分段和顺序,不能简单地全文扫一遍就完事。

3.4 多语言排版:提醒一句,不是所有不可见字符都是坏的

写这部分是怕读者走极端。前面说的都是"怎么删",但实际上有一大类不可见字符是国际化的必需品。

阿拉伯语、希伯来语这类从右往左书写的语言,跟拉丁字母混排时必须用方向控制符,否则顺序会乱。印度语系里 ZWNJ 和 ZWJ 用来控制字形连写,删掉的话文字会变成乱码。蒙古文里的元音分隔符、韩文里的填充符,都是规范里明确定义的排版工具。表情符号里的家庭组合、肤色修饰,靠的也是 ZWJ 把多个 emoji 粘连成一个。

所以做清洗规则的时候,我一般的处理原则是:按字段类型区分策略。用户昵称、商品标题、搜索关键词这类字段允许激进过滤;多语言正文、评论、私信这类字段必须保留语言必需的不可见字符。如果一刀切全删,做国际化的时候会收到一堆"文字显示错误"的反馈。

4. 动手实操:检测、清洗、还原三件套

4.1 五分钟写出一个靠谱的扫描器

先把最实用的工具写出来。这个脚本的作用是扫描一段文本,把里面所有可疑字符的码位、名称、UTF-8 字节和位置全部打印出来。

import unicodedata # 高危码位集合:格式类 + 控制类 + 常见空白变体 SUSPICIOUS = set( '\u00a0\u00ad\u034f\u061c\u115f\u1160\u17b4\u17b5\u180b\u180c\u180d\u180e' '\u200b\u200c\u200d\u200e\u200f\u2028\u2029\u202a\u202b\u202c\u202d\u202e' '\u202f\u205f\u2060\u2061\u2062\u2063\u2064\u2066\u2067\u2068\u2069' '\u206a\u206b\u206c\u206d\u206e\u206f\ufeff\uffa0\u2800' ) def utf8_bytes(ch: str) -> str: return ' '.join(f'{b:02X}' for b in ch.encode('utf-8')) def scan(text: str, label: str = 'text') -> list: hits = [] for idx, ch in enumerate(text): cp = ord(ch) category = unicodedata.category(ch) name = unicodedata.name(ch, 'UNKNOWN') # 命中黑名单,或者类别属于格式/控制/代理/私用 if ch in SUSPICIOUS or category in ('Cf', 'Cc', 'Cs', 'Co'): hits.append({ 'index': idx, 'code': f'U+{cp:04X}', 'name': name, 'category': category, 'bytes': utf8_bytes(ch), }) print(f'[{label}] 长度={len(text)},可疑字符={len(hits)}') for h in hits: print(f" 位置{h['index']:>5} {h['code']} {h['category']} " f"{h['bytes']:<12} {h['name']}") return hits if __name__ == '__main__': demo = 'admin\u200b\u200c123\ufeff' scan(demo, '测试样例')

跑出来会长这样:位置 5 是 U+200B、位置 6 是 U+200C、末尾是 U+FEFF,各自的 UTF-8 字节一清二楚。这个脚本我建议直接塞进你们的数据质量巡检任务里,每天跑一次抽样,超阈值就告警,比出了事故再查高效得多。

提醒:unicodedata.category()返回的是两字母类别码。Cf 是格式字符,Cc 是控制字符,Cs 是代理项,Co 是私用区,Mn 是非间距组合标记。做过滤时 Mn 要谨慎处理,因为带重音的欧洲语言字母分解后就是 Mn,删了会改变文字本身。

4.2 清洗策略:黑名单和类别过滤怎么配合

扫描器只能发现问题,真正要做的是清洗。清洗函数的写法取决于你的字段类型,我一般准备两个:

import re import unicodedata # 策略一:激进清洗,适合昵称、标题、tag AGGRESSIVE_RE = re.compile( '[\u00ad\u034f\u061c\u115f\u1160\u17b4\u17b5\u180b-\u180e' '\u200b-\u200f\u2028-\u202f\u205f-\u2064\u2066-\u206f' '\ufeff\uffa0\u2800]' ) def clean_aggressive(s: str) -> str: s = unicodedata.normalize('NFKC', s) s = AGGRESSIVE_RE.sub('', s) s = ''.join( ch for ch in s if unicodedata.category(ch) not in ('Cf', 'Cc', 'Cs', 'Co') ) return s.strip() # 策略二:保守清洗,适合多语言正文 SAFE_RE = re.compile('[\u200b\u2060\ufeff\u00ad]') def clean_conservative(s: str) -> str: s = SAFE_RE.sub('', s) return s.strip()

激进策略做了三件事:先 NFKC 归一化统一全角半角、再用正则删掉明确的危险码位、最后按类别兜底。三管齐下基本能覆盖所有已知变体。保守策略只删最典型那几个,保留方向控制符和连字控制符,避免破坏阿拉伯语、印度语系的正常显示。

有个细节值得单独说:strip()在很多语言里删不掉 U+00A0。Python 的str.strip()在不传参数时删的是 Unicode 空白字符,U+00A0 属于Zs类别,实际上是被删掉的;但 JavaScript 的trim()早期版本对 U+00A0 的处理就不一致,某些环境下会保留。跨语言系统里最保险的做法是显式指定要删的字符集,而不是依赖语言内置的空白定义。

4.3 还原隐写内容:完整可跑的编解码脚本

下面这套代码是我在 CTF 和内部水印方案里都在用的,两态编码,U+200C 表示 0、U+200D 表示 1,带 4 字节长度头,避免解码时长度对不齐。

ZW_0 = '\u200c' ZW_1 = '\u200d' def encode(cover: str, payload: bytes) -> str: """把 payload 藏进 cover 文本,每个可见字符后面挂一个零宽字符。""" header = len(payload).to_bytes(4, 'big') data = header + payload bits = [] for byte in data: for shift in range(7, -1, -1): bits.append((byte >> shift) & 1) if len(bits) > len(cover): need = (len(bits) + 7) // 8 raise ValueError( f'载体长度不足:需要 {len(bits)} 个可见字符,当前 {len(cover)} 个,' f'建议扩展到至少 {need} 个字符' ) out = [] for i, ch in enumerate(cover): out.append(ch) if i < len(bits): out.append(ZW_1 if bits[i] else ZW_0) return ''.join(out) def decode(stego: str) -> bytes: bits = [1 if c == ZW_1 else 0 for c in stego if c in (ZW_0, ZW_1)] if len(bits) < 32: raise ValueError('未检测到有效零宽序列') raw = bytearray() for i in range(0, len(bits) - 7, 8): byte = 0 for b in bits[i:i + 8]: byte = (byte << 1) | b raw.append(byte) declared = int.from_bytes(raw[:4], 'big') return bytes(raw[4:4 + declared]) if __name__ == '__main__': cover = '这是一段完全正常的封面文字,用来承载隐藏数据。' * 3 payload = b'{"uid":10086,"ts":1730000000}' stego = encode(cover, payload) print('载体长度:', len(cover), '隐写后长度:', len(stego)) print('还原结果:', decode(stego))

这段代码有几个设计点值得说明。加 4 字节长度头是为了处理载荷里可能出现的填充 0 字节,否则解码时会多出尾巴。每个可见字符后挂一个零宽字符保证了零宽字符按顺序排列,解码时直接过滤就能拿到比特流。载体长度校验提前报错,避免写到一半发现容量不够。

如果换成四态编码,把 U+200B、U+200C、U+200D、U+2060 分别映射到 00、01、10、11,每个字符承载 2 bit,容量直接翻倍,代价是编码解码逻辑稍微复杂一点,而且四个码位同时出现在一篇文章里更容易被检测出来。

4.4 把检查挂到 CI 和 Git 钩子上

脚本写完了,关键是怎么让它自动跑起来。我的做法是三处布防。

第一处是编辑器层。VS Code 默认会高亮不可见字符,如果你发现被关掉了,检查一下这个配置:

{ "editor.unicodeHighlight.invisibleCharacters": true, "editor.unicodeHighlight.ambiguousCharacters": true, "editor.unicodeHighlight.includeComments": true, "editor.renderWhitespace": "all" }

打开之后,零宽字符会在编辑器里显示成一个小方块加码位提示,肉眼能直接看到。

第二处是提交层。用 pre-commit 钩子拦住带隐藏字符的代码提交,特别是那些从网页复制粘贴到代码注释里的内容,最容易带进来。

#!/usr/bin/env bash # .git/hooks/pre-commit set -e FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(py|js|ts|json|md|txt)$' || true) [ -z "$FILES" ] && exit 0 python3 - <<'PY' import re, sys, pathlib BAD = re.compile('[\u200b-\u200f\u2028-\u202f\u2060-\u206f\ufeff\u00a0\u00ad]') files = sys.argv[1:] if len(sys.argv) > 1 else [] bad = [] for f in files: p = pathlib.Path(f) if not p.exists(): continue for i, line in enumerate(p.read_text(encoding='utf-8', errors='ignore').splitlines(), 1): if BAD.search(line): bad.append(f'{f}:{i}') if bad: print('检测到不可见字符,请处理后再提交:') print('\n'.join(bad)) sys.exit(1) PY

第三处是接口层。在网关或者统一入参校验的地方加一道拦截,对昵称、标题这类短文本字段做一次扫描,命中高危码位就拒绝或者自动清洗。这一层是兜底,前两层漏了的都在这里拦。

5. 工程落地里的坑:我踩过的都在这了

5.1 数据库的长度陷阱:LENGTH 和 CHAR_LENGTH 不是一回事

这一条是排查问题的关键线索。在 MySQL 里,LENGTH()返回字节数,CHAR_LENGTH()返回字符数。对于VARCHAR(20)这样的定义,MySQL 限制的是字符数不是字节数,所以一个零宽空格只占一个字符位,但占 3 个字节。

-- 找出昵称里字节数和字符数不匹配的异常记录 SELECT id, nickname, CHAR_LENGTH(nickname) AS char_len, LENGTH(nickname) AS byte_len FROM users WHERE nickname REGEXP '[\\x{200B}-\\x{200F}\\x{2060}\\x{FEFF}]';

排查的时候我会同时看这两个值。正常的纯中文昵称byte_len大约是char_len的 3 倍,纯英文是 1 倍。如果一个看起来 5 个字的昵称char_len是 6 而byte_len是 18,那就说明混进了一个零宽字符。这个判断方法比逐个字符打印快得多,适合做批量筛查。

另一个坑是字符集转换的静默失败。如果某个字段是 GBK 字符集,插入一个 U+200B 时会因为 GBK 里没有这个码位而报错或者替换成问号。我遇到过写入端不报错、读取端拿到问号的情况,最后定位到是数据库连接参数里的字符集设置和表定义不一致。建议所有涉及用户输入的字段统一用 utf8mb4,不要用 utf8(MySQL 的 utf8 是三字节的,存不了 emoji 和部分扩展字符)。

达梦、人大金仓这类国产数据库也有类似的编码页概念,做数据迁移时源库和目标库的编码页必须对齐,否则零宽字符和生僻字都会在导入阶段出问题。我做过一次从 GBK 源库到 UTF-8 目标库的迁移,预处理阶段专门加了一步"扫描并标记异常字符"的脚本,把带隐藏字符的记录挑出来人工确认,比迁移完再修划算得多。

5.2 前端与序列化:trim、JSON、Excel 三处雷

前端这边最容易出问题的地方是输入校验。用户在昵称输入框里粘一个 U+3164,页面上看起来是空的,但input.value.length是 1,required校验通过了,结果后端收到一个看似空白的昵称。修复方式是校验前先做清洗,再做空字符串判断:

const INVISIBLE = /[\u200b-\u200f\u2028-\u202f\u2060-\u206f\ufeff\u00a0\u00ad\u3164\uffa0\u2800]/g; function normalizeInput(raw) { return raw.replace(INVISIBLE, '').trim(); } // 校验时用清洗后的值 if (normalizeInput(input.value).length === 0) { showError('昵称不能为空'); }

JSON 这块有个历史遗留问题值得说。U+2028 和 U+2029 在 JavaScript 里曾经是非法的字符串字面量字符(后来 ECMAScript 2019 才允许),所以如果把包含这两个字符的 JSON 直接内联到 script 标签里,会直接导致语法错误。虽然现在规范改进了,但老项目里还留着转义处理,看到\u2028被手动替换成\\u2028的代码不要奇怪,那是有历史原因的。

Excel 这边的坑前面提过 BOM,还有一个是单元格内容里的隐藏字符导致 VLOOKUP 匹配不上。运营做数据核对时经常遇到"明明数据一样却匹配不到",八成是两边有一边带了零宽字符或者 U+00A0。解决方案是在 Excel 里套一层SUBSTITUTE和TRIM,或者干脆在导入数据库之前用脚本统一清洗一遍。

5.3 怎么让这些字符在终端里现形

处理这类问题的核心能力是"看见"。我常用的几个手段:

xxd或hexdump -C是最直接的方式,把文件转成十六进制,看到e2 80 8b就知道有零宽空格。缺点是长文件看起来费劲,所以一般配合 grep:

# 找出文件中所有包含零宽字符的行号 grep -nP '[\x{200B}-\x{200F}\x{2060}\x{FEFF}]' target.txt # 看看具体位置和十六进制 xxd target.txt | grep -i 'e280 8b\|e280 8c\|e280 8d\|efbb bf'

Python 的repr()也是利器,它会用\u200b这种转义形式展示不可见字符,比直接打印清晰一万倍:

s = 'admin\u200b' print(s) # admin(看不出任何异常) print(repr(s)) # 'admin\u200b'(一目了然) print(ascii(s)) # 'admin\u200b'(对所有非 ASCII 都转义)

编辑器方面,VS Code 前面配置过了,Sublime Text 可以用Ctrl+Shift+P打开命令面板搜索 "Hex Viewer" 插件。Vim 里:set list能显示大部分空白字符,但对零宽字符无效,得靠ga命令查看光标下字符的码位。我现在的习惯是排查任何字符串异常时,第一件事就是repr()一下,这个动作能省掉一半的排查时间。

5.4 常见问题速查表

下面这张表是我这几年攒下来的,出问题时按症状查最快。

症状大概率原因快速定位方法处理方式
两个字符串肉眼相同却不相等混入零宽字符repr()对比清洗后比较
CSV 第一列列名异常、字段映射失败文件带 BOMxxd看开头 3 字节用utf-8-sig读
昵称显示为空但保存成功U+3164 等填充符len()与视觉效果不符前端过滤后再校验
数据库去重失效、唯一索引冲突隐藏字符导致字节不同LENGTHvsCHAR_LENGTH入库前统一清洗
阿拉伯语与英文混排顺序错乱方向控制符丢失检查是否有 U+200E/F不要删方向控制符
正则\s匹配不到"空格"U+00A0 不在\s里逐字符打印码位用显式字符类匹配
文本体积异常膨胀大量零宽字符文件大小 / 字符数 比值全量扫描并清洗
复制到代码里的注释导致语法错误U+2028/U+2029编辑器语法高亮异常转义或删除

提示:这张表里"文本体积异常膨胀"是最容易被忽略的一条。正常情况下 UTF-8 中文文本的字节数是字符数的 3 倍左右,如果你发现某个字段的比值远高于 3,就要怀疑是不是被塞了大量零宽字符或者标签字符。这个指标可以做成监控项。

6. 容量与设计:水印方案怎么算才不翻车

6.1 每个字符能承载多少比特

这是所有隐写方案的基础参数。两态编码(U+200C 和 U+200D)每个零宽字符携带 1 bit;四态编码(U+200B、U+200C、U+200D、U+2060)每个字符携带 2 bit;如果用标签字符区 U+E0000 到 U+E007F,理论上有 128 个可用码位,每个字符能携带 7 bit,效率高得多。

但效率不是唯一的考虑因素,还要看隐蔽性和可发现性。标签字符区在正规渲染引擎里完全不可见,但很多文本处理工具会主动剥离它们,而且数量一旦多了,字节序列会呈现出非常规整的模式,容易被检测。相比之下,两态编码用的两个码位在正常情况下也可能因为排版需求出现,隐蔽性更好。

我的选择逻辑是:内部分发追踪用两态,隐蔽性优先;CTF 或者小载荷场景用四态,容量优先;超过 1KB 的载荷基本不考虑零宽方案,直接换其他载体。

6.2 一次完整的容量计算演示

举一个我实际做过的方案来算。场景是:给一篇 800 字的中文文章插入追溯水印,水印数据是 200 字节的 JSON,要求肉眼无感知。

先算容量需求。200 字节 = 200 × 8 = 1600 bit。载体有 800 个可见字符,如果每个字符后面挂一个零宽字符,那么可用位置是 800 个。

  • 用两态编码:800 bit,只能承载 100 字节,不够。
  • 用四态编码:800 × 2 = 1600 bit,刚好承载 200 字节,够用。

再看体积变化。原文 800 个中文汉字,UTF-8 下是 2400 字节。插入 800 个零宽字符后,隐写文本 = 800 个可见字符(2400 字节)+ 800 个零宽字符(2400 字节)= 4800 字节,体积膨胀到原来的 2 倍。

如果换成两态编码硬塞 200 字节,就需要 1600 个零宽字符,但载体只有 800 个位置,要么把文章扩写到 1600 字以上,要么把载荷压缩。实际方案里我选了扩写文章到 1200 字,同时用 gzip 把 JSON 压到 120 字节左右,这样两态编码下 120 × 8 = 960 bit,1200 个位置绰绰有余,还能留出冗余空间做校验。

这个计算过程里有个容易忽略的点:压缩率不是稳定的。200 字节的 JSON 压到 120 字节看起来不错,但如果载荷本身已经是高熵数据(比如加密后的密文),压缩几乎没有效果,甚至可能变大。所以设计阶段一定要用真实数据实测,不要拍脑袋估。

6.3 冗余、校验和抗清洗的取舍

零宽水印最大的弱点是一洗就没。为了提升一点点鲁棒性,我一般加两样东西。

一是头部标记。在比特流最前面放一段固定的魔数,比如0xB7 0x1E,提取时先找魔数再解长度,避免误判。这个成本很低,但能大幅降低假阳性。

二是CRC 校验。载荷后面附 2 字节的 CRC16,解码时校验一下,如果不通过就说明中间被改动过。比起到处找 bug,不如让程序直接告诉你数据坏了。

至于冗余重复,我一般不推荐在零宽水印上做。因为零宽字符一旦被清洗就是整片消失,重复三遍也是一起消失,起不到纠错作用。真要做抗清洗水印,得改用同形字替换或者词序扰动这类方案,那是另一个话题了。

6.4 检测与反检测的长期博弈

最后说一个现实问题:零宽水印和检测工具之间是长期博弈。我维护扫描规则的时候就发现,随着检测工具普及,水印方案也在进化——从单码位演进到多码位组合,从固定位置插入演进到按语义随机插入,从明文编码演进到加密后再编码。

从防守方角度,我能给的建议是不要只依赖黑名单。黑名单只能抓住已知码位,稍微改一下就用不了了。更稳的做法是建立"文本基线"——统计正常文本的字符类别分布,一旦某类字符(比如 Cf 类)的占比超过阈值就告警。这个方法对新型变体也有效,因为不管用什么码位,格式类字符的占比异常这个特征跑不掉。

从工程实现角度,我还建议把检测逻辑做成可配置的。不同业务对误报的容忍度不一样,昵称字段可以激进一点,正文评论必须保守。硬编码一套规则的结果就是要么放过了脏数据,要么误伤了正常内容,两边都挨骂。


这套东西我前后迭代了三四个版本,最深的体会是:处理不可见字符的核心难点从来不是技术,而是"意识到它存在"。绝大多数事故的根因都是开发阶段压根没考虑过这种输入,等到线上出问题才开始查。所以我现在做任何涉及用户文本的项目,都会在需求评审阶段加一句:"这个字段要不要做不可见字符过滤?"就这一句话,能省掉后面无数次半夜爬起来查数据。另外一个实用习惯是:任何从网页、PDF、聊天窗口复制来的文本,进代码或者进数据库之前,先过一遍清洗函数。我本地的编辑器里就绑了个快捷键,一键清理剪贴板里的隐藏字符,用了两年多,救过我不下十次。

返回列表