ARTICLE DETAIL

资讯详情

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

Unicode不可见字符全解析:零宽空格、清洗与工程避坑

Unicode不可见字符全解析:零宽空格、清洗与工程避坑 1. 先搞清楚这些看不见的家伙到底是什么Unicode 这套编码体系给世界上每一个字符都发了身份证号可见的字母数字汉字只是其中一部分还有一大批字符的显示结果是什么都不显示或者显示成一小块空白。这类字符就是我们说的特殊不可见字符。它们不是乱码也不是程序错误而是标准里正儿八经定义、有明确码位、有明确语义的合法字符——问题恰恰出在这里它们合法所以能顺利穿过大部分肉眼检查和简单的格式校验。我第一次被这类字符绊倒是帮同事看一段复制来的配置。表面上看是key value手动敲一遍就能跑用他发的那份就报解析错误。把文件丢进十六进制查看器前面赫然站着三个字节E2 80 8B解码出来是 U200B零宽空格。人眼完全看不见编辑器光标却会莫名其妙卡一下。从那以后凡是遇到看起来一模一样但行为不一样的字符串问题我第一反应就是去查不可见字符。这篇文章适合三类人看一是经常处理爬虫、数据清洗、文本比对的开发者二是被复制来的代码跑不通折磨过的运维和测试三是对文本隐写、内容溯源这类玩法感兴趣的安全爱好者。我会把常见不可见字符按家族拆开讲清楚码位和语义给出可直接抄的检测与清洗代码再聊几种把它当工具用的正向玩法最后把踩过的坑整理成速查表。有一点要先说明白不可见不等于无意义。里面有一批字符是排版刚需比如不间断空格、双向控制符、软连字符直接一刀切全删掉阿拉伯语和希伯来语的混排文本会直接乱掉。所以正确处理思路不是黑名单全灭而是分清用途按场景放行或拦截。2. 按家族拆解零宽、空白、控制符各是干什么的2.1 零宽家族三兄弟U200B / U200C / U200D这三个是整个不可见字符领域出场率最高的角色都叫零宽意思是渲染宽度为零光标扫过去不占位置。U200B 叫ZERO WIDTH SPACE零宽空格。它的设计初衷是给没有词间空格的书写系统泰语、部分东南亚文字提供一个可以在这里换行的断点。浏览器和排版引擎看到它会允许在此处断行但不画任何东西。现实中最常见的来源是网页富文本编辑器、Markdown 渲染器、某些 CMS 的自动断行处理以及大量从网页复制内容的场景。U200C 叫ZERO WIDTH NON-JOINER零宽非连接符。它的作用是打断连字比如在某些阿拉伯字母组合里手动阻止它们合并成一个字形。U200D 叫ZERO WIDTH JOINER零宽连接符作用相反强制让相邻字符连成一个字形。U200D 有一个特别值得说的用途Emoji 组合序列。一家三口这个 emoji是男人 ZWJ 女人 ZWJ 女孩拼出来的中间全靠 U200D 粘合。所以如果你在清洗数据时无脑把 U200D 删掉复杂的表情符号会立刻退化成一串散装小人。我在做用户昵称过滤时踩过这个坑当时把零宽字符全清了结果评论区一堆组合 emoji 变成三个独立表情被用户当成 bug 反馈。注意U200B 和 U200D 在处理策略上必须区别对待。前者在绝大多数业务文本里是噪声后者在 emoji 场景下是功能字符。2.2 伪装成空格的空白字符这一类最阴险的地方在于它们的显示宽度和普通空格一模一样肉眼完全分不出来但码位不同字符串比较时就是不等。U00A0 叫NO-BREAK SPACE不间断空格。它是 HTML 里nbsp;的真身也是从 Word 文档、网页表格里复制文字时最常带出来的附赠品。它的语义是此处不允许换行常用于防止100 km这种数值加单位被拆到两行。U2000 到 U200A 是一整排不同宽度的空格EN QUAD、EM QUAD、EN SPACE、EM SPACE、THREE-PER-EM SPACE、FOUR-PER-EM SPACE、SIX-PER-EM SPACE、FIGURE SPACE、PUNCTUATION SPACE、THIN SPACE、HAIR SPACE。它们来自传统印刷排版宽度从全角到头发丝那么细都有主要用于专业排版软件输出。业务系统里碰到它们基本都是数据搬运过程中带进来的脏东西。U3000 叫IDEOGRAPHIC SPACE全角空格中文输入法下按 Shift空格 打出来的就是它。这个反而常见很多中文正文首行缩进就是用它凑的也不一定算问题取决于你的业务规则。U202F 叫NARROW NO-BREAK SPACE窄不间断空格在部分语言的排版规范里用在冒号、分号前。实际排查时这堆字符的典型症状是用户提交的表单里姓名前后看起来没空格但你的长度校验算出来多一位或者数据库里明明两条记录看着一样GROUP BY却不合并。2.3 UFEFF从 BOM 到零宽非断空格的双重身份UFEFF 这个码位身世比较复杂。它最早叫BYTE ORDER MARK字节序标记用来在文件开头标记这个文件是大端还是小端。UTF-16 编码的文本开头两个字节FE FF就是大端序的标记。后来 Unicode 标准把它赋予第二个身份ZERO WIDTH NO-BREAK SPACE零宽非断空格语义上等价于 U00A0 但宽度为零。推荐用法是出现在文件开头时当 BOM 处理出现在文本中间时当零宽非断空格处理。工程实践里这个字符制造过大量麻烦。PHP 项目里出现过页面顶部莫名多一条空白的经典问题原因就是某个被include的 PHP 文件保存成了UTF-8 with BOM那个三字节的 BOM 被当成输出内容发给了浏览器。前端 JSON 解析失败也常见接口返回的字符串第一个字符是 UFEFFJSON.parse直接抛异常。我现在的习惯是所有源码文件一律保存成UTF-8 无 BOM.editorconfig里明确写上charset utf-8CI 里加一条检查扫描文件头三字节是不是EF BB BF是就直接构建失败。2.4 双向控制字符与组合附加符号U202A 到 U202E 是双向文本控制字符LRE、RLE、PDF、LRO、RLO。它们用来在一段文字里临时切换书写方向是阿拉伯语、希伯来语混排拉丁字母时的刚需。同样归在这一族的还有 U2066 到 U2069 这一组隔离符是后来补充的更安全的替代方案。为什么说更安全因为 U202ERIGHT-TO-LEFT OVERRIDE历史上被大量滥用用来伪造文件名后缀制造看起来是图片实际是可执行文件的效果。所以现代浏览器和操作系统在这类字符的处理上普遍加了限制很多上传组件也会直接拒绝含双向控制符的文件名。U0300 到 U036F 是组合用附加符号包括各种重音、变音符号。它们的特点是叠加在前一个字符上。一个a加一个 U0301 组合尖音符视觉上和áU00E1 预组合字符看起来一样但字符串完全不等。这叫规范化不一致是跨系统文本比对的经典陷阱。Unicode 提供了 NFC/NFD/NFKC/NFKD 四种规范化形式来解决它后面会专门讲。2.5 常用不可见字符码位速查码位名称显示宽度典型来源是否建议清除U200B零宽空格0网页复制、CMS 断行建议清除U200C零宽非连接符0阿拉伯文排版按语种决定U200D零宽连接符0Emoji 组合保留UFEFF零宽非断空格 / BOM0文件头、接口返回首字符清除U00A0不间断空格1Word、HTML建议转普通空格U202F窄不间断空格约0.5专业排版建议转普通空格U3000全角空格1中文输入法按业务决定U00AD软连字符0排版软件建议清除U202E从右到左覆盖0双向排版高风险建议拒绝U180E蒙古文元音分隔符0早期蒙古文排版已废弃建议清除注意U180E 在 Unicode 6.3 版之后被重新分类为格式字符显示行为在旧版浏览器上不一致现代项目里直接当噪声处理即可。3. 把它当工具用不可见字符的几种正向玩法3.1 文本水印与内容溯源既然不可见字符不会影响阅读那就可以拿它来编码信息。最朴素的做法是二进制映射把要嵌入的信息转成二进制串用 U200B 表示 0用 U200C 表示 1每插入一个比特就把它塞进正文的字符之间。一篇 1000 字的文章理论上能藏 1000 个比特也就是 125 字节足够放一个用户 ID 加时间戳。用户 A 拿到的版本里嵌的是一串编码用户 B 拿到的是另一串一旦内容外泄解码出来就知道源头是谁。这种水印的优点是完全不影响视觉呈现截图看不出来直接复制粘贴到别的地方也会一起带过去——这恰恰是它有效的原因。不过要注意两个现实限制。第一很多平台在发布前会做文本清洗零宽字符可能被过滤掉水印随之失效。第二有经验的人用十六进制编辑器一看就露馅。所以它适合做低成本溯源提示不适合当成唯一证据。3.2 用零宽字符做弱防复制另一个常见玩法是插入零宽字符防爬。思路是服务器渲染页面时在敏感文本的每个字之间插入 U200B。人眼看起来完全正常但爬虫抓下来的是商\u200b品\u200b价\u200b格这种形式直接入库后做关键词匹配就会失败。这个手段的门槛极低效果也确实有——但它只能挡住最粗糙的爬虫。稍微规范一点的采集程序都会做文本清洗把零宽字符滤掉。所以我的判断是它能提高一点成本但不能当安全措施。真正需要防采集还是得靠频控、签名、行为检测这一套。而且它有个副作用搜索引擎的爬虫也会拿到带零宽字符的文本可能影响收录质量。3.3 双向控制符和软连字符这些真不能乱删前面反复强调过不可见字符里有正经的排版功能字符。U202A-U202E 和 U2066-U2069 是双向文本的必需品U00AD 软连字符是长单词断行的标准方案U200D 是 Emoji 组合的粘合剂。如果你的产品有海外用户尤其是中东和北非地区一棍子全删会导致这些用户的文本渲染错乱段落方向反过来、标点跑到行首、词语中间断在奇怪的位置。正确的做法是分场景处理——用户昵称、商品标题这类短文本可以严一点正文、评论这类长文本要宽松只清理明确无意义的噪声字符。3.4 编码嵌入的完整实现下面这段 Python 把任意文本编码成零宽字符序列用的是每字符三比特、映射到三种零宽字符的方案比纯二进制更紧凑一点。# 零宽字符隐写编码与解码 ZERO \u200b # 表示 00 ONE \u200c # 表示 01 TWO \u200d # 表示 10 END \ufeff # 表示 11兼作结束标记 MAP {00: ZERO, 01: ONE, 10: TWO, 11: END} RMAP {v: k for k, v in MAP.items()} def encode(secret: str, cover: str) - str: 把 secret 编码后逐字符插入 cover 的字符之间 bits .join(f{b:08b} for b in secret.encode(utf-8)) # 补齐到 2 的倍数 if len(bits) % 2: bits 0 payload .join(MAP[bits[i:i 2]] for i in range(0, len(bits), 2)) payload END out [] it iter(payload) for ch in cover: out.append(ch) try: out.append(next(it)) except StopIteration: pass out.append(.join(it)) return .join(out) def decode(text: str) - str: bits [] for ch in text: if ch in RMAP: code RMAP[ch] if code 11: break bits.append(code) bitstr .join(bits) bitstr bitstr[:len(bitstr) // 8 * 8] data bytes(int(bitstr[i:i 8], 2) for i in range(0, len(bitstr), 8)) return data.decode(utf-8) if __name__ __main__: cover 这是一段看起来完全正常的说明文字没有任何异常。 hidden encode(uid10086, cover) print(repr(hidden)) print(长度对比:, len(cover), -, len(hidden)) print(解出内容:, decode(hidden))实测下来uid10086这 9 个字节会膨胀成 36 个零宽字符散布在 24 个汉字的正文里视觉上完全无感。注意 END 标记用的 UFEFF 必须在解码时做短路处理否则后面如果还有正文会被误读成数据。写这类代码有个细节容易翻车如果载体文本本身就已经含有零宽字符解码结果会错位。所以嵌入前务必先对载体做一次清洗保证它是干净的。4. 检测与清洗一套能直接落地的方案4.1 白名单思路比黑名单稳得多清洗不可见字符有两条路。黑名单是列出所有要删的字符问题是 Unicode 里不可见或半可见的字符数量不少还不断新增你永远列不全。白名单是只允许明确安全的字符通过可控性高得多。我的建议是分三层策略第一层字符类别过滤。Unicode 有 General Category 分类Cc是控制字符Cf是格式字符Zs是空格分隔符Mn是非间距组合标记。用这一层就能拦掉绝大多数噪声。第二层显式白名单。对 U200D、U202A-U202E 这些有实际功能、又被广泛滥用的字符单独列出来按场景决定放行还是拦截。第三层规范化。用 NFC 把组合字符合并成预组合形式消灭看起来一样编码不同的问题。4.2 各语言的最小可用实现Python版本一函数搞定清洗和规范化import unicodedata import re # 需要保留的功能性不可见字符按业务调整 KEEP {\u200d} # ZWJ保 Emoji # 覆盖控制符、格式符、各类空白、软连字符 PATTERN re.compile( r[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f r\u00ad\u200b-\u200f\u202a-\u202e\u2060-\u2064 r\u2066-\u2069\ufeff\u180e r\u00a0\u1680\u2000-\u200a\u202f\u205f\u3000] ) def clean(text: str, normalize: str NFC) - str: if not isinstance(text, str): raise TypeError(clean() 只接受 str) def _sub(m): ch m.group(0) if ch in KEEP: return ch # 空白类统一换成普通空格 if ch in \u00a0\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u1680: return return text PATTERN.sub(_sub, text) text unicodedata.normalize(normalize, text) return text.strip() if __name__ __main__: dirty abc\u200bdef\u00a0ghi\u202e.jpg print(repr(clean(dirty)))JavaScript版本适合放前端做提交前预处理。注意trim()在旧版引擎里不认 U00A0得自己处理const CLEAN_RE /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F\u00AD\u200B-\u200F\u202A-\u202E\u2060-\u2064\u2066-\u2069\uFEFF\u180E]/g; const SPACE_RE /[\u00A0\u1680\u2000-\u200A\u202F\u205F\u3000]/g; function cleanText(input) { if (typeof input ! string) return ; return input .replace(CLEAN_RE, ) .replace(SPACE_RE, ) .replace(/\s/g, ) .trim() .normalize(NFC); } // 检测是否含有可疑不可见字符用于日志告警 function hasInvisible(input) { return CLEAN_RE.test(input) || SPACE_RE.test(input); } console.log(JSON.stringify(cleanText(abc\u200Bdef\u00A0ghi)));Java版本要注意String.trim()只处理码位小于等于 U0020 的字符对 U00A0 完全无效必须自己写import java.text.Normalizer; import java.util.regex.Pattern; public final class InvisibleCleaner { private static final Pattern NOISE Pattern.compile( [\\u0000-\\u0008\\u000B\\u000C\\u000E-\\u001F\\u007F \\u00AD\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064 \\u2066-\\u2069\\uFEFF\\u180E]); private static final Pattern WIDE_SPACE Pattern.compile( [\\u00A0\\u1680\\u2000-\\u200A\\u202F\\u205F\\u3000]); public static String clean(String s) { if (s null) return null; String r NOISE.matcher(s).replaceAll(); r WIDE_SPACE.matcher(r).replaceAll( ); r Normalizer.normalize(r, Normalizer.Form.NFC); return r.strip(); } }SQL 层面这条容易被忽略。MySQL 的TRIM()默认只去普通空格REPLACE()可以处理单字符。批量清洗建议走应用层实在要在库里做至少把最常见的几个处理掉-- 清洗 U00A0 和 U200B并做前后去空 UPDATE products SET title TRIM( REPLACE( REPLACE(REPLACE(title, CHAR(0x00A0 USING utf8mb4), ), CHAR(0x200B USING utf8mb4), ), CHAR(0xFEFF USING utf8mb4), ) ) WHERE title REGEXP CONCAT([, CHAR(0x00A0 USING utf8mb4), CHAR(0x200B USING utf8mb4), ]);注意MySQL 8.0 的REGEXP默认用 ICU 引擎对CHAR(... USING utf8mb4)这种表达式的支持比 5.7 靠谱得多老版本上做批量正则匹配性能和结果都要提前验证。4.3 入库、比对、去重三处加固光有清洗函数不够得把它挂到正确的链路上。我的经验是三处必挂入口校验处。所有外部输入——表单、API 请求体、文件导入、消息队列——在进入业务逻辑之前先过一遍清洗。这一步放在参数绑定之前因为很多框架的校验注解对不可见字符不敏感。入库前。ORM 的字段赋值钩子或者 DAO 层统一处理。这里要特别小心清洗后的值如果和原始值不同最好留一条日志方便出问题时回溯。我见过因为清洗掉了首尾的 U200B 导致用户输入的密码校验失败的情况实际上密码里真的有零宽字符用户复制粘贴时带进去的。比对和去重处。这一处最容易被漏掉。两个用户昵称张\u200b三和张三在数据库唯一索引看来是两个不同的值。所以去重判断必须用清洗后的值而不是原始值。做法通常是加一个normalized_name字段存清洗后的结果并在上面建唯一索引。5. 常见坑与排查实录5.1 代码编译报错但肉眼完全看不出问题这是最常见的场景。从网页或聊天工具复制的代码字符串里混进 U200B编译器报invalid character或者方言报非法字符。PHP 里更隐蔽字符串里带 BOMheader()函数报headers already sent。排查手段很直接把出问题的行贴进支持十六进制显示的编辑器或者用命令行过一遍。Linux 上grep -P [\x{200b}-\x{200f}\x{feff}] file就能筛出可疑行。VS Code 有个设置editor.renderWhitespace配合editor.unicodeHighlight.ambiguousCharacters打开后会把可疑的不可见字符用方框标出来一眼就能定位。我的固定习惯是出问题的字符串一律先repr()或JSON.stringify()打印一遍。中文里看不见的东西打印出来都是\u200b这种形式一抓一个准。5.2 数据库唯一索引失效与去重失败现象是明明看着重复的两条数据UNIQUE索引不报冲突DISTINCT去重也不合并。原因就是两个值在字节层面不同。排查顺序我一般这么走第一步把两条记录的值取出来用HEX()或者CONVERT(... USING utf8mb4)看字节序列。字节不一样就直接定位到具体码位了。第二步看数据来源。如果是从 Excel 导入的八成是 U00A0从网页复制的八成是 U200B从接口来的检查是不是序列化时带了 BOM。第三步加固。加规范化字段、加唯一索引、Clean 挂到入库链路。这里有个血泪教训有一批历史数据已经污染了清洗程序跑完之后新值可能和库里已有的另一条记录冲突导致唯一索引建不上。所以清洗历史数据必须先做一次冲突预检把会撞车的记录捞出来人工处理再执行更新。5.3 CSV 和 Excel 导入后的匹配全挂Excel 有个习惯把从网页粘贴进来的nbsp;保留成 U00A0。导出 CSV 时这些字符原样带走导入系统后拿来做JOIN或者WHERE name ?全部匹配不上。处理办法是导入环节加一层清洗同时把 U00A0 统一转成普通空格而不是直接删掉。为什么是转空格而不是删因为张 三和张三在人看来是不同的名字直接删掉会把合法空格也吃掉。转成普通空格既保持了视觉一致又让字符串可比对。另外提醒一句Excel 保存 CSV 时默认用本地编码中文环境常见 GBK跨平台读取容易乱码。建议统一要求导出 UTF-8 带 BOM 的 CSV虽然 BOM 本身也是麻烦但它能让 Excel 正确识别编码两害相权取其轻。5.4 前端校验被绕过前端做了长度校验if (name.length 20)但用户提交的字符串里塞了 500 个零宽字符长度直接超限被拒——这个方向还好。反过来更危险如果前端只校验非空用户提交一个全是零宽字符的昵称trim()拿它没办法看着是空白的名字就存进去了后续在列表页渲染出来一片空白运营看着一脸懵。所以服务端的校验不能只看长度和非空必须叠加不可见字符检测。我在项目里会加一条规则清洗后的长度必须大于 0且清洗前后的长度差不能超过原始长度的某个比例超过就判定为可疑输入直接拒绝或者进人工审核队列。6. 常见问题速查表现象最可能的原因快速验证方法处理方案代码复制后编译报非法字符混入 U200B / UFEFF十六进制查看器或repr()打印手动重输该行编辑器开启 unicode 高亮页面顶部多一条空白文件头有 UTF-8 BOMhead -c 3 file | xxd文件另存为 UTF-8 无 BOMJSON 解析报 unexpected token字符串首字符是 UFEFF打印前几个字符的码点解析前replace(/^\uFEFF/, )数据库去重失败唯一索引不生效值中带 U00A0 / U200BSELECT HEX(col) FROM t加规范化字段并在其上建唯一索引字符串长度比预期多混入零宽或组合字符逐字符打印码点入口统一清洗 NFC 规范化中东语种文本排版错乱误删了双向控制符检查清洗正则是否覆盖 U202A-U202E按语种白名单放行不要全删Emoji 显示成散装小人误删了 U200D检查清洗规则是否包含 ZWJ把 U200D 加入保留名单昵称显示为空白提交了全零宽字符的字符串清洗后判空服务端强制校验清洗后长度大于 0组合重音字符比对失败规范化形式不一致对比 NFC 和 NFD 下的字节统一使用 NFC 规范化文件名后缀看起来正常但类型不对使用 U202E 伪装检查文件名是否含双向控制符上传环节直接拒绝含 RLO 的文件名最后分享一个我在实际项目里坚持了很多年的小习惯任何从外部进来的字符串在落库之前都先过一遍清洗函数并记录原始值和清洗值的差异。差异日志跑上一年你会对自家系统的数据来源质量有一个非常直观的认识也能提前发现不少看似无关的边界问题。上面那套清洗函数我基本没怎么改过直接拷到新项目里就能用唯一需要按业务调整的就是那份保留名单——先把 U200D 留着等你真的遇到问题再决定动不动它。
返回列表