尧图网站设计 尧图网站设计YAOTU DESIGN
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在多数实现里匹配不到 U200B、也不是标点、更不是字母——它压根就没被大多数正则字符类覆盖到。于是它轻松穿过trim()、穿过^\w$、穿过长度上限最后在 MySQL 里被原样存下来。等到做用户去重、做登录名精确匹配时肉眼相同的两个字符串在字节层面完全不同问题才暴露。这件事给我最大的教训是不要假设看不见等于不存在。在字节世界里看不见的东西往往更难处理因为它没有视觉线索你连它在哪都不知道。1.2 五大家族各有各的来路把高频的不可见字符按用途归类大致能分成五族理解这个分类对后面写清洗规则特别有用因为不同族的处理策略完全不一样。第一族是零宽控制类包括 U200B 零宽空格、U200C 零宽非连接符、U200D 零宽连接符、U2060 词连接符。这一族是最常被滥用的因为它们的设计初衷是文本排版控制——让浏览器知道哪里可以断行、哪里不能断、连字要不要合并。它们的共同特征是不占任何宽度字面意义上是透明的。第二族是方向控制类包括 U200E 从左至右标记、U200F 从右至左标记以及一对嵌入与覆盖控制符。它们服务于双向文本比如阿拉伯语和拉丁字母混排的显示顺序。这类字符在正常排版里是必需的但在恶意场景下可以做出视觉字符顺序和实际存储顺序不一致的效果。第三族是空白变体类包括 U00A0 不间断空格、U2000 到 U200A 一系列宽度不同的空格、U202F 窄不换行空格、U205F 中等数学空格、U3000 表意文字空格。它们本质上是空格只是宽度和换行行为不同。这里面 U00A0 的坑最深因为它和普通空格 U0020 在浏览器里渲染宽度几乎一样肉眼看不出区别。第四族是格式与填充类包括 U00AD 软连字符、U034F 字形组合连接符、U180E 蒙古文元音分隔符、U115F 和 U1160 韩文填充符、U3164 韩文填充符、UFFA0 半角韩文填充符、U2800 盲文空白。这一族是可复制的空白的主力军尤其韩文填充符和盲文空白因为它们在绝大多数系统里都找不到对应字形会渲染成一片空白是制作空白昵称的首选。第五族是标签与变体选择符类包括 UFEFF 零宽不换行空格也就是 BOM 的真身以及 UE0000 到 UE007F 的标签字符区、UFE00 到 UFE0F 的变体选择符。标签字符区特别值得注意这是一整块被保留用于语言标记的码位整块都是不可见的容量远超单个零宽字符。1.3 20 个高频不可见字符速查表下面这张表是我自己整理后一直在用的都是从实际日志和用户输入里真实抓到过的码位。表格里字符一列就是实际字符可以直接选中复制UTF-8 字节一列用来看十六进制特征抓包里对照它最快。序号字符码位Unicode 名称UTF-8 字节常见出现场景1​U200BZERO WIDTH SPACEE2 80 8B隐形水印、空白昵称2‌U200CZERO WIDTH NON-JOINERE2 80 8C零宽隐写、波斯语排版3‍U200DZERO WIDTH JOINERE2 80 8D零宽隐写、表情连字4‎U200ELEFT-TO-RIGHT MARKE2 80 8E双向文本定向5‏U200FRIGHT-TO-LEFT MARKE2 80 8F双向文本定向6⁠U2060WORD JOINERE2 81 A0防止断行、隐写7⁡U2061FUNCTION APPLICATIONE2 81 A1数学排版、隐写8⁢U2062INVISIBLE TIMESE2 81 A2数学排版、隐写9⁣U2063INVISIBLE SEPARATORE2 81 A3数学排版、隐写10UFEFFZERO WIDTH NO-BREAK SPACEEF BB BFBOM、Excel 导出11U00A0NO-BREAK SPACEC2 A0网页复制、排版12­U00ADSOFT HYPHENC2 AD断字控制13͏U034FCOMBINING GRAPHEME JOINERCD 8F字形组合阻断14᠎U180EMONGOLIAN VOWEL SEPARATORE1 A0 8E蒙古文排版15ᅟU115FHANGUL CHOSEONG FILLERE1 85 9F韩文填充、占位16ᅠU1160HANGUL JUNGSEONG FILLERE1 85 A0韩文填充、占位17ㅤU3164HANGUL FILLERE3 85 A4空白昵称首选18UFFA0HALFWIDTH HANGUL FILLEREF BE A0空白昵称备选19⠀U2800BRAILLE PATTERN BLANKE2 A0 80空白昵称、占位20U3000IDEOGRAPHIC SPACEE3 80 80中日文全角空格注意表格里的字符在不同字体、不同系统下的渲染结果可能不同。U3164 和 UFFA0 在 Windows 的部分字体下会显示成一个空心方框而在移动端往往完全不可见。判断一个字符是不是真空白永远靠码位而不是靠眼睛。这 20 个只是冰山一角。如果你要做全量扫描更稳妥的做法是不维护黑名单而是判断字符的 Unicode 类别——类别为 Cf格式、Cc控制、Cs代理、Co私用的字符以及 Zs空格分隔符里除 U0020 之外的部分都应该进入重点审查名单。这个思路比枚举码位可靠得多因为 Unicode 标准还在持续新增字符。2. 编码层面看不见的字符凭什么能存在2.1 码位、字形、编码三层要分开理解很多人把字符当成一个整体概念其实这里有三层完全独立的抽象分清楚之后很多困惑自然就解开了。最底层是码位Code Point就是一串数字从 U0000 到 U10FFFF 的整数编号。Unicode 标准做的事情本质上是分配编号和定义语义比如 U200B 这个编号的语义就是零宽空格。第二层是编码形式Encoding Form也就是码位怎么变成字节序列UTF-8、UTF-16、UTF-32 是同一个码位的不同字节表达方式。最上层是字形Glyph是字体文件里画出来的那个图形。不可见字符之所以看不见是因为它要么在字体里根本没有对应字形要么字形本身就是零宽度的空白图形。但它的码位是实打实存在的编码后的字节序列也是实打实占空间的。这就是看不见却存在的根本原因——不可见是渲染层的事存在是编码层的事两者互不影响。这个分层理解还能解释另一个常见困惑为什么同一个字符在不同系统里表现不一样。因为渲染层依赖字体不同平台的字体覆盖范围不同所以同一个码位可能在一个系统里显示为空白在另一个系统里显示为方框。判断问题永远要看码位不要看渲染。2.2 UTF-8 字节结构和我实测的膨胀数据UTF-8 是变长编码规则很规整U0000 到 U007F 用 1 字节U0080 到 U07FF 用 2 字节U0800 到 UFFFF 用 3 字节U10000 以上用 4 字节。上面表格里的 20 个字符除了 U00A0、U00AD 是 2 字节其余全是 3 字节。这个字节结构直接决定了隐写方案的开销。我之前做过一组实测把 1000 个 U200B 零宽空格连起来写进文件ls -l显示文件大小是 3000 字节而同样长度的普通 ASCII 空格只要 1000 字节。也就是说零宽空格在 UTF-8 下的存储开销是普通空格的三倍。这个数字在做容量估算时非常关键后面第 6 章会详细算。还有一个容易踩的点同一个字符在不同编码下字节数不同。U200B 在 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或者把全角字符转成半角。但 U200B、U200C、U200D 这些属于格式类字符它们在标准里没有被定义为可以由其他序列等价替换也不参与兼容分解。结果就是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、把 U3000 全角空格变成 U0020。所以正确的做法是先归一化再显式剔除格式类字符两步都要做顺序不能反。2.4 BOM 的两种身份坑了无数人UFEFF 这个码位很特殊它同时承担两个角色。作为字节序标记BOM时它出现在文件开头用来告诉解析器这个文件是 UTF-8 还是 UTF-16、是大端还是小端。作为零宽不换行空格时它出现在文本中间就是个普通的格式字符。坑就出在这两种身份的混淆上。最经典的事故场景是运营用 Excel 导出 CSVExcel 默认会在 UTF-8 文件头加一个 BOM然后这份 CSV 被程序读取第一个列名就变成了\ufeff用户ID接着拿去查字典或者做 SQL 字段映射全部对不上。因为 Python 读 CSV 时如果不用encodingutf-8-sigBOM 会被当成内容保留下来。# 错误写法第一个字段会带上 BOM with open(data.csv, encodingutf-8) as f: reader csv.reader(f) header next(reader) # header[0] \ufeff用户ID # 正确写法用 utf-8-sig 让 Python 自动吞掉 BOM with open(data.csv, encodingutf-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 转成二进制再用 U200C 表示 0、U200D 表示 1按规则插入到段落之间的空白位置。提取的时候只要扫描全文中所有这两个码位还原出比特流就能得到 ID。这种方案的特点是隐蔽性极强但鲁棒性很弱——只要对方做了任何形式的文本清洗水印就没了。所以它更适合内容分发追踪这种场景不适合版权保护这种需要强对抗的场景。真正要做强对抗水印一般会考虑同形字替换把拉丁字母 a 换成西里尔字母 а、标点微调、或者词序扰动但这些都会影响可读性或者有被检测的风险。零宽字符的优势就在于零成本、零视觉影响代价是抗清洗能力几乎为零。3.2 数据清洗入库前的最后一道闸对绝大多数做后端和数据的同学来说零宽字符的第一身份其实是脏数据源。我在做用户中心项目时采集侧每天能捞出来上千条带隐藏字符的记录来源五花八门网页复制、移动端输入法、某些富文本编辑器的自动插入、从 PDF 里复制文字。清洗的策略有两种思路我一般混着用。黑名单策略是维护一个明确的待删码位集合优点是精准、不会误伤正常字符缺点是覆盖不全Unicode 出新字符就得补。类别策略是按unicodedata.category()判断把 Cf、Cc、Cs、Co 全部剔除Zwj/Zwnj 之类特定语言必需的字符单独放白名单。优点是覆盖全缺点是某些语言的正常文本可能被误伤。我的实际做法是用户昵称、标题、tag 这类短文本用黑名单类别双重过滤正文类长文本只用黑名单。原因是正文里出现的 U200C 可能是波斯语或印地语的正规用法粗暴删掉会破坏内容而昵称里的零宽字符几乎全是垃圾。3.3 CTF 与隐写零宽字符的经典杂项题玩 CTF 杂项的朋友对这块应该很熟。零宽字符隐写是杂项题里的常客出题人一般会把 flag 按二进制编码成 U200C 和 U200D 的序列藏在一段看起来完全正常的文本里选手需要用工具或者自己写脚本提取。识别这类题有个小技巧把文本复制到支持十六进制显示的编辑器里或者直接跑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: fU{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 是 U200B、位置 6 是 U200C、末尾是 UFEFF各自的 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()在很多语言里删不掉 U00A0。Python 的str.strip()在不传参数时删的是 Unicode 空白字符U00A0 属于Zs类别实际上是被删掉的但 JavaScript 的trim()早期版本对 U00A0 的处理就不一致某些环境下会保留。跨语言系统里最保险的做法是显式指定要删的字符集而不是依赖语言内置的空白定义。4.3 还原隐写内容完整可跑的编解码脚本下面这套代码是我在 CTF 和内部水印方案里都在用的两态编码U200C 表示 0、U200D 表示 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 字节否则解码时会多出尾巴。每个可见字符后挂一个零宽字符保证了零宽字符按顺序排列解码时直接过滤就能拿到比特流。载体长度校验提前报错避免写到一半发现容量不够。如果换成四态编码把 U200B、U200C、U200D、U2060 分别映射到 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-filterACM | 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(encodingutf-8, errorsignore).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 字符集插入一个 U200B 时会因为 GBK 里没有这个码位而报错或者替换成问号。我遇到过写入端不报错、读取端拿到问号的情况最后定位到是数据库连接参数里的字符集设置和表定义不一致。建议所有涉及用户输入的字段统一用 utf8mb4不要用 utf8MySQL 的 utf8 是三字节的存不了 emoji 和部分扩展字符。达梦、人大金仓这类国产数据库也有类似的编码页概念做数据迁移时源库和目标库的编码页必须对齐否则零宽字符和生僻字都会在导入阶段出问题。我做过一次从 GBK 源库到 UTF-8 目标库的迁移预处理阶段专门加了一步扫描并标记异常字符的脚本把带隐藏字符的记录挑出来人工确认比迁移完再修划算得多。5.2 前端与序列化trim、JSON、Excel 三处雷前端这边最容易出问题的地方是输入校验。用户在昵称输入框里粘一个 U3164页面上看起来是空的但input.value.length是 1required校验通过了结果后端收到一个看似空白的昵称。修复方式是校验前先做清洗再做空字符串判断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 这块有个历史遗留问题值得说。U2028 和 U2029 在 JavaScript 里曾经是非法的字符串字面量字符后来 ECMAScript 2019 才允许所以如果把包含这两个字符的 JSON 直接内联到 script 标签里会直接导致语法错误。虽然现在规范改进了但老项目里还留着转义处理看到\u2028被手动替换成\\u2028的代码不要奇怪那是有历史原因的。Excel 这边的坑前面提过 BOM还有一个是单元格内容里的隐藏字符导致 VLOOKUP 匹配不上。运营做数据核对时经常遇到明明数据一样却匹配不到八成是两边有一边带了零宽字符或者 U00A0。解决方案是在 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 bfPython 的repr()也是利器它会用\u200b这种转义形式展示不可见字符比直接打印清晰一万倍s admin\u200b print(s) # admin看不出任何异常 print(repr(s)) # admin\u200b一目了然 print(ascii(s)) # admin\u200b对所有非 ASCII 都转义编辑器方面VS Code 前面配置过了Sublime Text 可以用CtrlShiftP打开命令面板搜索 Hex Viewer 插件。Vim 里:set list能显示大部分空白字符但对零宽字符无效得靠ga命令查看光标下字符的码位。我现在的习惯是排查任何字符串异常时第一件事就是repr()一下这个动作能省掉一半的排查时间。5.4 常见问题速查表下面这张表是我这几年攒下来的出问题时按症状查最快。症状大概率原因快速定位方法处理方式两个字符串肉眼相同却不相等混入零宽字符repr()对比清洗后比较CSV 第一列列名异常、字段映射失败文件带 BOMxxd看开头 3 字节用utf-8-sig读昵称显示为空但保存成功U3164 等填充符len()与视觉效果不符前端过滤后再校验数据库去重失效、唯一索引冲突隐藏字符导致字节不同LENGTHvsCHAR_LENGTH入库前统一清洗阿拉伯语与英文混排顺序错乱方向控制符丢失检查是否有 U200E/F不要删方向控制符正则\s匹配不到空格U00A0 不在\s里逐字符打印码位用显式字符类匹配文本体积异常膨胀大量零宽字符文件大小 / 字符数 比值全量扫描并清洗复制到代码里的注释导致语法错误U2028/U2029编辑器语法高亮异常转义或删除提示这张表里文本体积异常膨胀是最容易被忽略的一条。正常情况下 UTF-8 中文文本的字节数是字符数的 3 倍左右如果你发现某个字段的比值远高于 3就要怀疑是不是被塞了大量零宽字符或者标签字符。这个指标可以做成监控项。6. 容量与设计水印方案怎么算才不翻车6.1 每个字符能承载多少比特这是所有隐写方案的基础参数。两态编码U200C 和 U200D每个零宽字符携带 1 bit四态编码U200B、U200C、U200D、U2060每个字符携带 2 bit如果用标签字符区 UE0000 到 UE007F理论上有 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 bit1200 个位置绰绰有余还能留出冗余空间做校验。这个计算过程里有个容易忽略的点压缩率不是稳定的。200 字节的 JSON 压到 120 字节看起来不错但如果载荷本身已经是高熵数据比如加密后的密文压缩几乎没有效果甚至可能变大。所以设计阶段一定要用真实数据实测不要拍脑袋估。6.3 冗余、校验和抗清洗的取舍零宽水印最大的弱点是一洗就没。为了提升一点点鲁棒性我一般加两样东西。一是头部标记。在比特流最前面放一段固定的魔数比如0xB7 0x1E提取时先找魔数再解长度避免误判。这个成本很低但能大幅降低假阳性。二是CRC 校验。载荷后面附 2 字节的 CRC16解码时校验一下如果不通过就说明中间被改动过。比起到处找 bug不如让程序直接告诉你数据坏了。至于冗余重复我一般不推荐在零宽水印上做。因为零宽字符一旦被清洗就是整片消失重复三遍也是一起消失起不到纠错作用。真要做抗清洗水印得改用同形字替换或者词序扰动这类方案那是另一个话题了。6.4 检测与反检测的长期博弈最后说一个现实问题零宽水印和检测工具之间是长期博弈。我维护扫描规则的时候就发现随着检测工具普及水印方案也在进化——从单码位演进到多码位组合从固定位置插入演进到按语义随机插入从明文编码演进到加密后再编码。从防守方角度我能给的建议是不要只依赖黑名单。黑名单只能抓住已知码位稍微改一下就用不了了。更稳的做法是建立文本基线——统计正常文本的字符类别分布一旦某类字符比如 Cf 类的占比超过阈值就告警。这个方法对新型变体也有效因为不管用什么码位格式类字符的占比异常这个特征跑不掉。从工程实现角度我还建议把检测逻辑做成可配置的。不同业务对误报的容忍度不一样昵称字段可以激进一点正文评论必须保守。硬编码一套规则的结果就是要么放过了脏数据要么误伤了正常内容两边都挨骂。这套东西我前后迭代了三四个版本最深的体会是处理不可见字符的核心难点从来不是技术而是意识到它存在。绝大多数事故的根因都是开发阶段压根没考虑过这种输入等到线上出问题才开始查。所以我现在做任何涉及用户文本的项目都会在需求评审阶段加一句这个字段要不要做不可见字符过滤就这一句话能省掉后面无数次半夜爬起来查数据。另外一个实用习惯是任何从网页、PDF、聊天窗口复制来的文本进代码或者进数据库之前先过一遍清洗函数。我本地的编辑器里就绑了个快捷键一键清理剪贴板里的隐藏字符用了两年多救过我不下十次。
返回列表