零宽字符与 Unicode 隐写
考点概念
零宽字符是宽度为零、肉眼不可见的 Unicode 字符
包括零宽空格(U+200B)、零宽非连接符(U+200C)、零宽连接符(U+200D)、零宽不中断空格(U+FEFF)
不占显示宽度,却在字节流中真实存在。可以在"看起来完全正常"的文本中隐藏信息,通常把 3 个字符映射为 0/1/2 的进位制来编码数据。
Unicode 隐写还包括同形异义字(Cyrillic а 与 Latin a 视觉相同但码点不同)与不可见控制符(右至左标记 U+202E 可颠倒显示顺序)等变体,识别难度略高于零宽。
把不可见字符序列还原为比特流再分组
统计可见文本之外的码点,按约定进制重组。工具层面 vim 与 hexdump 均可暴露零宽字符。
四字符二进制映射方案
零宽空格 U+200B 记 00,零宽非连接符 U+200C 记 01,零宽连接符 U+200D 记 10,零宽不中断空格 U+FEFF 记 11。映射表由出题工具自定,解码遇到未知约定时穷举排列
运算规则
编码端先把明文按字符集转成字节序列,每个字节 8 位。四字符方案下把位流按两位为一组查表转成对应零宽字符,两字符方案只保留两种码点逐位记 0 与 1,两者只是分组粒度不同。转换出的零宽字符序列插入任意正常文本,屏幕显示不变。三字符(三进制)方案把每段数据映射到 0/1/2 三种状态,原理一致,仅基数不同。解码端遍历文本只保留零宽码点,逆查映射表还原位流,每 8 位一组转回字节,按原字符集还原明文。
以字母 a 完整演算四字符方案的编码与解码。a 的 ASCII 码点为 97,二进制 01100001。
编码:按两位切分为 01、10、00、01 四段;
查表 01 对应 U+200C 零宽非连接符,10 对应 U+200D 零宽连接符,00 对应 U+200B 零宽空格,01 再次对应 U+200C;把 U+200C、U+200D、U+200B、U+200C 依次插入任意正常句子即完成隐藏,显示与原句完全一致。
解码:扫描载体只收集零宽字符得到同一序列,逆映射得到 01、10、00、01,拼接为 01100001,转十进制 97 查 ASCII 得 a,闭环还原成功。
识别特征
文件特征:文本肉眼正常但文件字节数异常(如 100 字符的 txt 占 2KB)。 编辑器特征:vim 中显示为^@或高亮不可见符;hexdump 可见 E2 80 8B/8C/8D(UTF-8 编码的 200B/200C/200D)。 字符串特征:用 Pythonlen(s)与可见字符数不符,或去空白后re仍多出字符。 提示语:题目描述出现"隐藏、看不见、间谍、句子中藏了东西"等词。
靶场题目
[UTCTF2020]zero(BUUCTF,另见 NSSCTF 收录) zero-width stego(NSSCTF) [安洵杯 2020]BeCare4(NSSCTF)
Writeup
题目:[UTCTF2020]zero(BUUCTF) 识别:文本肉眼正常但字节数与可见字符数严重不符;用 PyCharm 或 vim 打开可见大量 ZWJ(零宽连接符 U+200D)字符夹在字母之间;binwalk 与 foremost 分离不到文件,排除文件级隐藏
解题:把文本提交零宽字符解码工具即可,用在线工具一次读出 flag;脚本做法是遍历文本统计零宽码点出现次序,按常用映射还原位流,每 8 位一组转字节成 ASCII。若工具默认映射与出题映射不符,交换 0/1 或换四字符映射重试,flag{...} 出现即停止。 脚本:
ZWSP = "\u200b" ZWNJ = "\u200c" def extract(s: str) -> bytes: bits = [] for ch in s: if ch == ZWSP: bits.append("0") elif ch == ZWNJ: bits.append("1") out = bytearray() for i in range(0, len(bits) - len(bits) % 8, 8): out.append(int("".join(bits[i:i + 8]), 2)) return bytes(out) carrier = "It looks like a perfectly normal English novel sentence." secret = b"flag{zwsp_demo}" bits = "".join(format(b, "08b") for b in secret) hidden = "".join(ZWNJ if x == "1" else ZWSP for x in bits) text = carrier[:20] + hidden + carrier[20:] print("可见字符 %d 个, len() = %d" % (len(carrier), len(text))) print(extract(text)) assert extract(text) == secret工具:https://330k.github.io/misc_tools/unicode_steganography.html
题目:[安洵杯 2020]BeCare4(NSSCTF) 识别:txt 打开"空白"但文件字节数不为零,hexdump 可见 E2 80 8B/8C/8D 序列,判定零宽字符携带信息;顺带排除 binwalk 分离。 解题:txt 交零宽解码工具还原,得到压缩包密码 RealV1siBle;解压得到一张 jpg;jpg 用 SilentEye 解密提取出隐藏信息,读出 flag{1nV1sible_flag_Can_You_find?}
脚本:
ZWSP, ZWNJ = "\u200b", "\u200c" password = "RealV1siBle" bits = "".join(format(b, "08b") for b in password.encode()) embedded = "\u8fd9\u884c\u770b\u4e0d\u51fa\u4efb\u4f55\u4e1c\u897f" + \ "".join(ZWNJ if x == "1" else ZWSP for x in bits) + "\u3002" got = "".join("1" if c == ZWNJ else "0" for c in embedded if c in (ZWSP, ZWNJ)) out = bytes(int(got[i:i + 8], 2) for i in range(0, len(got), 8)).decode() print(out) assert out == password工具:Unicode Steganography with Zero-Width Characters(SilentEye 为本地图形软件)
脚本
ZWSP = "\u200b" ZWNJ = "\u200c" def hide(data: bytes, carrier: str) -> str: bits = [] for byte in data: for i in range(8): bits.append("1" if (byte >> (7 - i)) & 1 else "0") hidden = "".join(ZWNJ if b == "1" else ZWSP for b in bits) return carrier + hidden + "。结尾正常文本" def extract(s: str) -> bytes: bits = [] for ch in s: if ch == ZWSP: bits.append("0") elif ch == ZWNJ: bits.append("1") out = bytearray() for i in range(0, len(bits), 8): out.append(int("".join(bits[i:i + 8]), 2)) return bytes(out) cipher = hide(b"flag{zero_width_ok}", "这是看起来完全正常的句子。") flag = extract(cipher) print("提取结果: %s" % flag.decode()) assert flag == b"flag{zero_width_ok}" print("SUCCESS: flag{zero_width_ok}")