先来看一个奇怪的现象:为什么字符 abc 的长度是23?
一、什么是零宽度字符
1、零宽度字符是隐藏不显示的,也是不可打印的,也就是说这种字符用大多数程序或编辑器是看不到的。
最常见的是零宽度空格,它是Unicode字符空格,就像如果在两个字母间加一个零宽度空格,该空格是不可见的,表面上两个字母还是挨在一起的。比如这两个 () 括号中间我放了5个零宽字符,你们能看见吗?
这种字符的出现是为了文字控制排版作用的,但是由于它拥有肉眼无法观察到的特性,零宽度字符可作为识别某些用户身份的“指纹”数据,也可非常方便地追溯到某些秘密数据的泄露源。
2、下面介绍三种零宽字符
(1)不换行空格,全称 No-Break Space,它是最常见和我们使用最多的空格,大多数的人可能这个字符叫做 Zero Width Space,中文可称为“零宽空白”,这个字符在主流文本编辑器中均没有任何显示效果,就像一只看不见、摸不着的幽灵。拷贝也会带上零宽空白,HTML 字符值引用为: ​
(2)零宽不连字:不换行空格,全称 No-Break Space,它是最常见和我们使用最多的空格,大多数的人可能它叫零宽不连字,全称是 Zero Width Non Joiner,简称“ZWNJ”,是一个不打印字符,放在电子文本的两个字符之间,抑制本来会发生的连字,而是以这两个字符原本的字形来绘制。Unicode 中的零宽不连字字符映射为(zero width non-joiner,U+200C),HTML 字符值引用为: ‍或‌
(3)零宽连字,全称是 Zero Width Joiner,简称“ZWJ”,是一个不打印字符,放在某些需要复杂排版语言(如阿拉伯语、印地语)的两个字符之间,使得这两个本不会发生连字的字符产生了连字效果。
零宽连字符的 Unicode 码位是 U+200D,HTML 字符值引用为: ‌或‍
3、零宽度字符是一种不可打印的Unicode字符,在浏览器等环境不可见,但是真实存在,获取字符串长度时也会占位置,表示某一种控制功能的字符。
下面就是一些常见的零宽度字符及它们的unicode码和原本用途:
零宽空格(zero-width space, ZWSP)用于可能需要换行处。
Unicode: U+200B HTML: ​
零宽不连字 (zero-width non-joiner,ZWNJ)放在电子文本的两个字符之间,抑制本来会发生的连字,而是以这两个字符原本的字形来绘制。
Unicode: U+200C HTML: ‌
零宽连字(zero-width joiner,ZWJ)是一个控制字符,放在某些需要复杂排版语言(如阿拉伯语、印地语)的两个字符之间,使得这两个本不会发生连字的字符产生了连字效果。
Unicode: U+200D HTML: ‍
左至右符号(Left-to-right mark,LRM)是一种控制字符,用于计算机的双向文稿排版中。
Unicode: U+200E HTML: ‎ ‎ 或‎
右至左符号(Right-to-left mark,RLM)是一种控制字符,用于计算机的双向文稿排版中。
Unicode: U+200F HTML: ‏ ‏ 或‏
字节顺序标记(byte-order mark,BOM)常被用来当做标示文件是以UTF-8、UTF-16或UTF-32编码的标记。
Unicode: U+FEFF
二、零宽度字符能做什么?
1、数据防爬
将零宽度字符插入文本中,干扰关键字匹配。爬虫得到的带有零宽度字符的数据会影响他们的分析,但不会影响用户的阅读数据。
2、信息传递
将自定义组合的零宽度字符插入文本中,用户复制后会携带不可见信息,达到传递作用。
3、传递隐密信息
利用零宽度字符不可见的特性,我们可以用零宽度字符在任何未对零宽度字符做过滤的网页内插入不可见的隐形文本。下面是一个简单的利用零宽度字符对文本进行加密/解密的例子:
// 使用零宽度字符加密解密
// str -> 零宽字符
function strToZeroWidth(str) {
return str
.split('')
.map(char => char.charCodeAt(0).toString(2)) // 1 0 空格
.join(' ')
.split('')
.map(binaryNum => {
if (binaryNum === '1') {
return ''; // ​
} else if (binaryNum === '0') {
return ''; // ‌
} else {
return ''; // ‍
}
})
.join('') // ‎
}
// 零宽字符 -> str
function zeroWidthToStr(zeroWidthStr) {
return zeroWidthStr
.split('') // ‎
.map(char => {
if (char === '') { // ​
return '1';
} else if (char === '') { // ‌
return '0';
} else { // ‍
return ' ';
}
})
.join('')
.split(' ')
.map(binaryNum => String.fromCharCode(parseInt(binaryNum, 2)))
.join('')
}
//1、加密
// 为了代码的简洁与易读性,以下代码会忽略性能方面考量
const text = '123';
// Array.from 能让我们正确读取宽度为2的Unicode字符,例:
const textArray = Array.from(text);
// 用codePointAt读取所有字符的十进制Unicode码
// 用toString将十进制Unicode码转化成二进制(除了二进制,我们也可以使用更大的进制来缩短加密后的信息长度,以此提升效率)
const binarify = textArray.map(c => c.codePointAt(0).toString(2));
// 此时binarify中的值是 ["110001", "110010", "110011", "11111011000000000"],下一步我们需要将"1","0"和分隔符映射到响应的零宽度字符上去
// 我们用零宽度连字符来代表1,零宽度断字符来代表0,零宽度空格符来代表分隔符
// 下面的''看上去像是空字符串,但其实都是长度为1,包含零宽度字符的字符串
const encoded = binarify.map(c => Array.from(c).map(b => b === '1' ? '' : '').join('')).join('');
// 此时encoded中包含的就是一串不可见的加密文本了
2、解密
// 接着上面的encoded
// 用分隔符(零宽度空格符)提取加密文本中的字符
const split = encoded.split('');
// 将文本转回成二进制数组
const binary = split.map(c => Array.from(c).map(z => z === '' ? '1' : '0').join(''));
// 此时binary中的值再次回到开始的 ["110001", "110010", "110011", "11111011000000000"]
// 最后一部只需要将二进制文本转回十进制,再使用 String.fromCodePoint 就可以得到原文本了
const decoded = binary.map(b => String.fromCodePoint(parseInt(b, 2))).join('');
// 此时decoded中的值即是 "123"
4、隐形水印
通过零宽度字符我们可以对内部文件添加隐形水印。在浏览者登录页面对内部文件进行浏览时,我们可以在文件的各处插入使用零宽度字符加密的浏览者信息,如果浏览者又恰好使用复制粘贴的方式在公共媒体上匿名分享了这个文件,我们就能通过嵌入在文件中的隐形水印轻松找到分享者了。
5、加密信息分享
通过零宽度字符我们可以在任何网站上分享任何信息。敏感信息的审核与过滤在当今的互联网社区中扮演着至关重要的角色,但是零宽度字符却能如入无人之境一般轻松地穿透这两层信息分享的屏障。对比明文哈希表加密信息的方式,零宽度字符加密在网上的隐蔽性可以说是达到了一个新的高度。仅仅需要一个简单的识别/解密零宽度字符的浏览器插件,任何网站都可以成为信息分享的游乐场。
6、逃脱敏感词过滤
可以看上面实例,敏感词之间加入零宽字符,就可以过滤掉比如阿里云、华为云等的内容审核机制。如下可以看到可轻松绕过