ARTICLE DETAIL

资讯详情

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

零宽字符:看不见的字符如何破坏你的数据与代码?

零宽字符:看不见的字符如何破坏你的数据与代码? 你有没有遇到过这样的情况屏幕上看起来完全一样的两个字符串放在if里却怎么都匹配不上一段从网页复制的文本粘贴到编辑器里以后缩进莫名其妙多出一个“透明空格”一份导入系统的手机号名单数据库里明明确实存在这条记录用WHERE却查不出来。如果你认真排查过多半会撞见一个平时根本感知不到的东西——零宽字符。零宽字符听起来很玄乎说白了就是 Unicode 字符集里一类“宽度为 0、不可见、但仍然实实在在参与文本处理”的字符。它不显示任何字形却在排版、文本比较、数据清洗、移动端渲染甚至版权追踪里都能横插一脚。这是一种容易被忽略、又极容易导致线上事故的字符。这篇文章我会从 Unicode 底层的成员分类开始讲清楚为什么要设计这种东西再用三个我实际遇到过的线上事故复盘排查链路最后给出一套检测和防御方案顺便聊聊合法利用它们做隐形水印的思路。适合所有写代码、做数据入库、做内容平台或经常处理文本导入导出的人。1. 从“看不见的字”说起零宽字符的定义与家族图谱1.1 为什么它明明是个字符却看不见理解零宽字符先要跳出“字符等于可见符号”的直觉。Unicode 为字符分配的码点里有相当一部分属于“格式控制字符”Format Characters。它们不表示具体的字母、数字或标点而是给排版或语义处理下达指令。这类字符在大多数渲染引擎里没有像素宽度于是就成了“零宽”。你可以把它们想象成文字印刷时用的“定位贴纸”你肉眼看不到贴纸本身但文字会不会换行、字母之间是否连写、emoji 能不能组合成家庭形象全由这些贴纸在背后操控。正因为不可见它们经常混在正常文本中直到某一天某个校对、比较或校验逻辑被它卡死你才会发现异常。1.2 零宽字符家族不是只有一种很多人在网上搜“零宽字符”看到最多的可能是“零宽空格”。但实际操作一段时间后你会发现真正给你惹麻烦的兄弟几个各有各的脾气。码点名称常见缩写作用典型场景U200B零宽空格ZWSP提供一个可换行点但不产生任何可见宽度长 URL、文件路径、长单词内断行U200C零宽不连字ZWNJ阻止两个字符形成连字波斯文、印地文等语种的字母处理U200D零宽连字ZWJ让字符强制组合成连体字形或 emoji 序列家庭成员 emoji、梵文等复杂文字UFEFF零宽不换行空格 / BOMBOM文件开头标记字节序或作为零宽不换行空格UTF-8 文件头部、配置文件解析U2060单词连接符WJ禁止在此处发生断行且看不见数字与单位之间、人名内部断行保护这五个是最常见的。U200B 稍微特殊一点它本质是“建议断行点”很多排版引擎遇到长单词或 URL 时会把这里当成合法的拆分位置U200D 在 emoji 组合里用得很多例如多个 emoji 之间一旦夹入 ZWJ就会渲染成一个复合图形UFEFF 则是很多人的老朋友也就是 UTF-8 BOM一个文件开头如果没有正确处理它就会变成一个幽灵字符挂在字符串最前面。1.3 家庭成员各自负责什么这里我挑几个最容易出问题的展开说。零宽空格U200B最常见的出现场景是用户从某个富文本编辑器或 PDF 里复制内容时“顺手带出”的。它可以在长文本中提供换行机会但因为不可见一旦被复制进手机号、邮箱、JSON key、订单号等字段里就会造成比对失败。我在数据库里见过很多次字段肉眼看着就只有 11 位数字实际char_length显示 12问题就出在这里。零宽连字U200D看起来用途很专一但影响面不小。在复杂文字中它决定两个字母是否连接成特殊字形在 emoji 体系中它组合出“家庭”“情侣”“不同肤色”等复杂符号。你写文本处理脚本时如果粗暴地删除所有“不可见字符”很容易把正常的组合字形拆散移动端显示直接“缺胳膊少腿”。BOMUFEFF则是另一个重量级选手。UTF-8 文件如果带 BOM很多解析器会在第一个字段里混进这个不可见字符。之前有同事把一个带 BOM 的 CSV 导入线上配置库第一列的 key 一直报“找不到”肉眼压根看不出问题最后hexdump一看才发现文件头部多了ef bb bf。2. 正面战场排版、emoji 与版权水印里的合法角色很多人一听到“零宽字符”第一反应是“邪恶”“攻击”其实它在正经业务里也有举足轻重的合法用途。我甚至觉得正是因为这些合法用途太常见很多人反而没意识到背后是零宽字符在起作用。2.1 排版与换行控制让长 URL 和专有名词不再“断得难看”网页排版和 PDF 导出里一个常见痛点是长 URL、文件名或药品名在一行显示不下。CSS 有overflow-wrap但很多老旧渲染环境不支持手动加空格又会影响语义。此时往文本中合适位置插入零宽空格U200B等于给排版引擎一个“允许断行点”告诉它如果放不下可以在这里折行但正常显示时这里不会出现空格。这个技巧在我处理导出报告时特别好用。你要做的是在生成文件的代码里把长 URL 每隔固定字符数插入 U200B而不是全塞在字符串尾部。当然要小心如果导入方不做零宽清理这些字符可能会被带到后续业务字段里。所以我的原则是“展示层可以加入库前必须清”。2.2 emoji 组合与 ZWJ 序列拆开它们等于拆散一家人你可以不用零宽字符做排版但你一定在手机里见过它的产物。一个家庭群体的 emoji实际上是由“男性”“女性”“儿童”三个基础 emoji 通过两个 ZWJ 连在一起组成的组合序列。类似地肤色不同的握手、职业形象也都依赖 ZWJ 把多个码点“粘”成一个整体。这里有个很多人踩过的坑写“删除全部特殊字符”的正则时把\p{Cf}整个删掉。结果用户反馈某个印度语名字在页面上变成了断裂的两个部分家庭成员 emoji 也被拆成了孤零零的三个图标。在处理多语言文本时你必须把 U200D 和 U200C 列入白名单否则你以为是清理实际上是在破坏真实语义。2.3 隐形水印给文档和文章加上“溯源码”零宽字符最精彩的合法用法之一是隐形水印。原理不复杂把一段标识信息编码成零宽字符的序列混入正常文章中。肉眼看不出来但复制出去之后这些零宽字符会跟着文本走。检测时只要按规则反向解码就能知道这段内容来自哪个用户、哪份文档。我做过一个半自动化的内容溯源工具核心思路是这样的ZW_DICT { 0: \u200C, # 零宽不连字 1: \u200D, # 零宽连字 sep: \u200B # 零宽空格用于分隔 } def embed_watermark(text: str, user_id: str) - str: raw user_id.encode(utf-8) bits .join(f{byte:08b} for byte in raw) invisible_seq ZW_DICT[sep].join(ZW_DICT[bit] for bit in bits) return text invisible_seq def extract_user_id(candidate: str) - str: bits .join( 0 if ch ZW_DICT[0] else 1 if ch ZW_DICT[1] else for ch in candidate ) raw bytes(int(bits[i:i8], 2) for i in range(0, len(bits), 8)) return raw.decode(utf-8, errorsignore)把user_id编码成二进制0 映射为 ZWNJ1 映射为 ZWJ再用 ZWSP 做位分隔。嵌入到合同条款、文章正文或导出报表里一旦外部泄露把可疑文本丢进提取函数就能反查来源。公司给客户发密钥文件时也可以这么做出现外泄时能快速定位到具体哪一方。这里必须说一句这种技术只能用在版权保护、数据溯源等合法场景。拿它去伪造信息、规避平台规则或者做欺诈不仅缺德而且违法我不希望任何人看完这篇文章走上歪路。3. 反面案例那些被零宽字符坑惨的线上事故聊完了正面用途再讲几次我实际排查过的“事故现场”。零宽字符真正让人头疼的地方在于它不像普通报错那样有明确提示所有症状都表现为“看起来正常但结果不对”。3.1 事故一手机号校验“非她不可却查不到”运营同学导了 2000 条用户手机号进后台做精准运营但其中一批号码始终匹配不到系统记录。我看了一眼原始 Excel数字边缘确实没有任何多余空格trim后也还是查不到。当时的第一反应是“编码问题”于是把匹配不到的号码丢进数据库做二进制检查。排查链路是这样的SELECT phone, CHAR_LENGTH(phone) AS char_cnt, LENGTH(phone) AS byte_cnt, HEX(phone) AS phone_hex FROM user_info WHERE id 12345;如果char_cnt比肉眼看到的位数多或者HEX里出现E2808B这是 UTF-8 编码下的 U200B基本可以锁定零宽空格。最终发现那批号码是在富文本编辑器里被“无形地”插入了零宽空格。处理方法也很简单写入数据前洗一遍import unicodedata def strip_zero_width_chars(s: str) - str: return .join(ch for ch in s if unicodedata.category(ch) ! Cf)但要注意手机号这类强规则字段不能只做清洗更稳妥的方案是入库前用“白名单校验”只允许数字、加号、括号、空格其余字符一律拒绝。宁可报错让用户重填也不能让脏数据在库里扎下根。3.2 事故二“看起来一样”的内容匹配总是失败另一个项目做舆情系统需要对采集到的文章做相似度去重。最开始按simhash计算结果很多相同文章被判成不相似。人工打开一看肉眼完全一致。后来把两个字符串逐字符做 Unicode 码点对比才发现其中一篇的每个标点附近都混入了 U200B。这种场景在爬虫采集、OCR 识别、PDF 转文本时特别常见。我的经验是文本进入去重、搜索、聚类流程之前必须预先执行一个“格式字符清理”步骤。简单点用unicodedata.category(ch) Cf过滤即可但如果业务涉及中东或南亚语言别一刀切。可以给清洗函数加一个参数保留 U200D 和 U200C这样既不影响去重也不破坏语言语义。3.3 事故三Markdown 表格和前端渲染被“透明空格”搅乱有一次用户反馈说提交的 Markdown 文章里表格对不齐后台预览和线上效果不一致。我看原始文本时表格里的竖线、空格都规规矩矩但用 HTML 实体编码查看后发现某些行的末尾混着#8203;。这就是 U200B。它在纯文本里看着不存在但一旦进入富文本渲染流程会被部分编辑器转成真实节点导致表格列宽计算偏移。前端框架处理文本节点时它也会被当成一个“字符”参与 DOM 计算从而影响测量宽度。解决这个问题最直接的办法是在提交接口层把不可见格式字符过滤掉或者在前端文本序列化时统一替换为普通空格。不要只依赖编辑器自己的“清除格式”功能很多用户根本不会用。4. 怎么把看不见的字符揪出来检测与排查工具箱如果你已经怀疑字符串里混入了零宽字符但文本不大、又不想马上写代码可以先从“显示不可见字符”这个思路入手。4.1 编辑器层的显示策略大多数主流编辑器都有“显示不可见字符”的开关VS Codeeditor.renderWhitespace设为all或按CtrlShiftP搜索 “Render Whitespace”。Notepad菜单栏“视图 → 显示符号 → 显示所有字符”。Sublime Text在设置里把draw_white_space设为all。打开这些开关后普通空格会显示为小圆点或中点Tab 会显示为箭头零宽字符在某些编辑器里也会显示为特殊标记块。比如 VS Code 通常会把 U200B 渲染成一个极淡的占位方块。这个方法适合快速人工确认不适合大规模数据筛查。4.2 命令行方案用十六进制看清真相文件层面的排查我习惯直接上hexdump。零宽字符在 UTF-8 编码下有比较明显的十六进制特征U200B 是E2 80 8BU200C 是E2 80 8CU200D 是E2 80 8DUFEFF 是EF BB BF。你可以把可疑文件转出来看hexdump -C file.txt | grep -E e2 80 (8b|8c|8d|8e|8f)|ef bb bf在类 Unix 系统上也可以用od -c查看字符编码。这个是纯离线手段不依赖任何在线服务适合处理敏感数据。4.3 Python 横扫字符串一个脚本揪出所有可疑字符日常处理字符串我一般会写一个类似下面的“字符体检”函数它会把所有格式类字符的位置、码点和名称都打出来import unicodedata SPECIAL_POINTS { 0x200B: ZERO WIDTH SPACE, 0x200C: ZERO WIDTH NON-JOINER, 0x200D: ZERO WIDTH JOINER, 0x200E: LEFT-TO-RIGHT MARK, 0x200F: RIGHT-TO-LEFT MARK, 0xFEFF: ZERO WIDTH NO-BREAK SPACE / BOM, 0x2060: WORD JOINER, } def find_format_chars(text: str): found [] for i, ch in enumerate(text): cp ord(ch) if cp in SPECIAL_POINTS or unicodedata.category(ch) Cf: name unicodedata.name(ch, Unknown-Format-Char) found.append((i, fU{cp:04X}, name)) return found sample 13800138000 for pos, cp, name in find_format_chars(sample): print(fposition{pos}, codepoint{cp}, name{name})这个脚本在字符串比较前、数据入库前都可以直接复用。这里有个值得注意的细节unicodedata.category(ch) Cf能覆盖绝大多数格式字符但它也包含 U200D 和 U200C。如果你处理的是多语言文本正确姿势不是无脑全删而是保留一个白名单集合只删名单以外的格式字符。4.4 数据库层里的长度与编码排查数据已经落库以后最实用的排查手段是同时比较字符长度、字节长度和十六进制值。以 MySQL 为例SELECT CHAR_LENGTH(field), LENGTH(field), HEX(field) FROM table_name WHERE id 目标ID;CHAR_LENGTH按字符数统计LENGTH按字节数统计。UTF-8 下普通英文字符两种长度相同一旦出现中日韩字符或零宽字符两者就会有差异。如果HEX结果里出现E2808B、E2808C、E2808D或EFBBBF那就实锤了。PostgreSQL 下可以用convert_to(field, UTF8)看二进制内容道理一样。5. 防御与规范让零宽字符真正“可控”排查手段再多也不如在入口处把规则定好。这部分我分享的是自己在项目里的落地经验和一套比较稳妥的防御思路。5.1 入口清洗与白名单策略用户输入、文件上传、API 入参这三个入口必须当成“不信任区域”看待。零宽字符不一定来自恶意的用户更多时候是从 Word、PDF、网页复制粘贴时自动带出来的。但这不代表系统就该照单全收。我的做法是在服务端写一个统一的清洗函数用“保留白名单 删除可疑字符”的思路处理。白名单里包括普通文字、数字、常用标点、可见符号以及多语言场景必要的 U200C 和 U200D。不属于白名单的格式控制字符直接剔除或替换为空。KEEP_ZERO_WIDTH { \u200C, \u200D } def sanitize_text(text: str, keep_language_joiners: bool True) - str: result [] for ch in text: if unicodedata.category(ch) Cf: if keep_language_joiners and ch in KEEP_ZERO_WIDTH: result.append(ch) continue result.append(ch) return .join(result)要注意的是清洗不等于“消毒”。清洗是为了去除影响业务逻辑的隐藏字符但对于手机号、身份证号、金额等强规则字段建议直接做白名单正则校验拒绝包含任何非预期字符的输入而不是静默清洗后入库。5.2 比对、去重与搜索前先统一化很多线上问题不是数据本身脏而是“部分数据脏、部分数据干净”导致匹配不上。为了防止这种情况我通常在两个位置统一处理写入前的清洗和查询前的比对前处理。对于查询场景不用真的去改库里数据只要在业务代码里对用户输入的查询词做同样规则的清洗就能避免“明明有记录但查不到”的诡异现象。做去重和相似度计算时更应该在哈希前先做一次标准化把格式字符去掉否则一个隐藏字符就能让两条相同内容产生完全不同的指纹。5.3 日志与审计给隐藏字符“可见化”日志系统里也容易藏坑。如果线上埋点直接打印用户提交的 JSON零宽字符会被原样写进日志。事后排查时你从日志里复制出的字符串同样带着隐形字符越查越迷糊。建议在线上日志输出和本地调试时对字符串做repr()或unicode_escape处理让\u200b变成可读形式。例如log_message freceived input: {raw_text!r}这样零宽字符在日志里会显示为\u200b而不是隐形物。排查效率会高很多。5.4 跨语言的保留原则不要把“清理”变成“破坏”阿拉伯文、印地文、孟加拉文等文字系统里U200C 和 U200D 是字形连接的重要控制符。删错一个可能让原本连写的文字变成断裂形态甚至改变语义。所以凡是面向多语言用户的业务特殊字符清理一定要分语言处理。最简单的策略是这样如果业务语言是中文、英文等不依赖连字控制的文字可以直接过滤全部格式字符如果业务覆盖阿拉伯语、印度语系或需要支持复杂 emoji则保留 U200C 和 U200D只删除其他格式字符。方案要提前写进开发规范不要等上线后由客服去发现。5.5 一个建议在发布流程里加“字符体检”这道工序最后给一个非常实操的建议如果你所在团队经常处理文本类数据可以考虑在 CI 流程或数据处理管道里增加一个“特殊字符扫描”步骤让机器替你筛查异常。扫描范围可以定为 U200B 到 U206F 这段区域再加一个 BOM 检测。一旦发现可疑字符打日志、告警或者干脆阻断发布。我在项目里就保持了这样一个脚本地狱的模式凡是入库数据、导出文件、生成 PDF都会先跑一遍“字符体检”函数。实测下来它帮我拦住了很多从 Excel、网页编辑器、第三方接口带回来的脏数据。投入成本不高但能省下后期大量排查时间。最后分享一个持续用到今天的小技巧在 VS Code 里把editor.renderWhitespace设为all并养成在提交文本类变更前用hexdump -C抽查关键文件的习惯。零宽字符本身不可怕可怕的是整个技术链路里没有人知道它可能已经存在。希望这篇偏实战的拆解能让你少花点时间在“看不见的 Bug”上也让你在做文本处理时多一分对不可见字符的敬畏。
返回列表