先來看一個奇怪的現象:為什么字符 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、逃脫敏感詞過濾
可以看上面實例,敏感詞之間加入零寬字符,就可以過濾掉比如阿里雲、華為雲等的內容審核機制。如下可以看到可輕松繞過