
1. 项目概述从一道CTF签到题说起最近在整理历年CTF比赛的Writeup时又看到了2021年“网刃杯”那道经典的签到题。题目本身很简单就一个文件很多人可能扫一眼没发现什么就直接跳过了或者用常规的隐写工具跑一遍没结果就放弃了。但恰恰是这种看似“白给”的题最容易让人阴沟里翻船。这道题的核心考点就是零宽字符隐写。对于刚接触CTF杂项Misc的新手来说零宽字符是个既熟悉又陌生的概念——你可能在社交媒体上见过用它做的“隐形水印”或者听说过它能用来在文本里藏信息但真到了实战面对一个.txt文件怎么入手、用什么工具、如何提取往往还是一头雾水。我最初接触这类题目时也踩过不少坑比如用strings命令找不到异常用十六进制编辑器看也没发现明显的非打印字符块甚至怀疑题目文件是不是损坏了。后来经过系统学习和多次实战才摸清了零宽字符隐写的门道。它不像LSB图片隐写那样有成熟的steghide、zsteg工具一键搞定也不像压缩包伪加密那样有固定的文件头尾特征。零宽字符隐写更偏向于对文本编码和Unicode标准的深入理解考验的是选手的细心程度和对非常规信息载体的敏感度。这道“网刃杯”的签到题就是一个绝佳的教学案例。它没有复杂的套娃和加密直指零宽字符隐写的核心原理与提取方法。通过拆解这道题我们不仅能学会解这一道题更能掌握一类题的通用解法。无论你是想入门CTF杂项还是希望深化对信息隐藏技术的理解这篇文章都将带你从原理到实操彻底搞懂零宽字符隐写。2. 零宽字符隐写核心原理深度解析要理解零宽字符隐写首先得知道什么是零宽字符。在Unicode字符集中存在一些特殊字符它们本身不占据任何可见的显示宽度即“零宽度”也不会在大多数文本编辑器或渲染引擎中留下视觉痕迹但它们确实作为有效的字符代码存在于文本数据流中。常见的零宽字符主要有以下几种零宽空格Unicode码点U200B。顾名思义它是一个没有宽度的空格。常用于单词断行等精细排版但在这里它是承载信息的“比特位”。零宽非连接符U200C。历史上用于控制某些文字如阿拉伯文、天城文中字符的连接方式。零宽连接符U200D。与U200C相对用于强制字符连接。零宽非断空格UFEFF字节顺序标记BOM的一部分或U2060。用于指示此处不应换行。这些字符在常规阅读和编辑时是完全“隐形”的但它们可以被程序读取和识别。隐写的原理就是利用这些字符的两种状态存在或不存在来代表二进制信息中的0和1。2.1 编码逻辑与实现方式通常隐写者会选定一个或多个零宽字符作为“载体”。最经典的编码方式是二进制映射将需要隐藏的明文信息如flag{xxx}转换为二进制串例如通过ASCII或UTF-8编码。字符替换遍历载体文本即那个看起来正常的.txt文件内容在字符之间插入零宽字符。例如规定零宽空格U200B代表二进制0零宽非连接符U200C代表二进制1。插入按照二进制串的顺序将对应的零宽字符依次插入到载体文本的各个间隙中。插入的位置可以是每个字符后、每个单词后或者随机位置只要提取时能遵循同样的规则即可。还有一种更简单的模式是存在性编码只使用一种零宽字符如U200B。它的存在代表1不存在代表0。这样通过检查文本中特定位置如每两个可见字符之间是否有该零宽字符就能读出一串二进制流。注意在实际CTF题目中出题人可能会使用自定义的映射表比如用U200B、U200C、U200D三种字符来代表三进制或者用它们的排列顺序来编码以增加难度。但核心思想不变利用不可见字符编码信息。2.2 为什么常规方法会失效理解了原理就能明白为什么新手容易在这里卡住strings命令strings默认只打印出可打印字符序列零宽字符属于控制字符或不可见字符范畴会被直接过滤掉所以输出看起来和普通文本无异。十六进制编辑器如果你直接用十六进制视图看确实能看到E2 80 8BU200B的UTF-8编码、E2 80 8C等序列。但对于不熟悉Unicode编码的新手很容易把这些三字节序列误认为是普通的UTF-8中文字符的编码片段而忽略过去。文本编辑器像VS Code、Sublime Text等现代编辑器虽然可以显示零宽字符通常以特殊符号如“”显示但默认设置下往往是关闭的。Notepad等基础编辑器则完全不会显示。因此解决这类问题的关键在于让不可见变为可见或者编写程序按照规则提取。3. 实战破解2021网刃杯签到题复现假设我们从比赛平台下载到的签到题文件名为checkin.txt。文件内容用肉眼看起来可能就是一段普通的欢迎文本、一段废话文学或者干脆就是“flag就在这里”之类的挑衅语句。3.1 第一步初步侦察与确认首先不要急着上复杂工具。用最简单的命令感知一下文件。# 查看文件大小可能会比纯文本预期的大一点因为包含了隐藏的零宽字符 ls -lh checkin.txt # 用cat命令直接输出看看终端是否有异常有时终端会以乱码或空格形式显示零宽字符 cat checkin.txt # 用hexdump以十六进制和ASCII混合形式查看重点关注E2 80开头的序列 hexdump -C checkin.txt | head -20如果hexdump输出中频繁出现e2 80 8b、e2 80 8c、e2 80 8d这样的字节序列那么基本可以确定存在零宽字符隐写。更直接的方法是使用cat的-A选项在Linux下显示所有字符cat -A checkin.txt这个命令会把制表符显示为^I行尾显示为$而零宽字符通常会显示为M-bM-^M-^K、M-bM-^M-^L之类的乱码形式这是e2 80 8b等序列在终端的一种表示。如果你在行尾或字符间看到了这种乱码那就是铁证。3.2 第二步使用现成工具解码手动从乱码中提取二进制再转换太麻烦。我们可以利用一些现有的CTF工具或在线解码器。方法一使用CyberChef在线神器打开 CyberChef 。将checkin.txt的整个内容复制粘贴到Input区域。在Recipe搜索框搜索“Zero Width”会找到‘Zero-width character decoding’这个模块把它拖到工作区。模块会自动识别并尝试解码。常见的预设映射关系有U200B-0,U200C-1U200B-1,U200C-0使用U200B/U200C/U200D三种字符的。尝试不同的预设观察Output区域。如果解码成功你会直接看到flag{开头的字符串。方法二使用Python脚本最灵活如果在线工具不奏效或者题目用了自定义编码就需要自己写脚本。这是CTF选手的必备技能。#!/usr/bin/env python3 # -*- coding: utf-8 -*- import sys def decode_zero_width(file_path): with open(file_path, r, encodingutf-8) as f: content f.read() # 将零宽字符映射为二进制标识 binary_str for char in content: if char \u200b: # 零宽空格 binary_str 0 elif char \u200c: # 零宽非连接符 binary_str 1 elif char \u200d: # 零宽连接符 # 如果题目用了三种字符这里可能需要做三进制处理或者忽略或者当作分隔符 # 这里先假设题目只用了前两种遇到第三种直接跳过或报错 continue else: # 可见字符跳过。这里也可以选择不跳过用于定位插入点。 continue print(f提取的二进制串: {binary_str}) # 将二进制串每8位一组转换为字符 flag for i in range(0, len(binary_str), 8): byte binary_str[i:i8] if len(byte) 8: break # 忽略末尾不完整的字节 flag chr(int(byte, 2)) print(f解码后的字符串: {flag}) return flag if __name__ __main__: if len(sys.argv) ! 2: print(f用法: {sys.argv[0]} 文件路径) sys.exit(1) decode_zero_width(sys.argv[1])将上述脚本保存为zero_width_decoder.py然后运行python3 zero_width_decoder.py checkin.txt脚本会打印出提取的二进制串和解码后的字符串。如果输出的字符串是乱码可能的原因有二进制分组不对不是8位一组可能是7位ASCII或UTF-8变长。映射关系相反U200B是1U200C是0。题目使用了其他零宽字符如UFEFF。信息不是直接编码的ASCII而是先经过了Base64等编码再隐写。实操心得在写解码脚本时务必先打印出原始内容中所有唯一的Unicode码点。这能帮你快速确定题目到底用了哪几种零宽字符。unique_chars set(content) for c in unique_chars: print(f字符: {repr(c)}, Unicode码点: {hex(ord(c))})这个步骤能让你避免盲目猜测直接锁定目标字符。3.3 第三步处理复杂情况与提取Flag对于2021网刃杯这道题很可能使用上述标准映射就能直接解出Flag。但为了应对更复杂的情况我们需要扩展脚本的功能。情况一信息被多重编码有时出题人会用零宽字符隐写一层Base64编码后的字符串解码出来还需要再进行一次Base64解码。import base64 # ... 假设从上面得到了一个字符串 encoded_flag ... try: decoded_flag base64.b64decode(encoded_flag).decode(utf-8) print(fBase64解码后: {decoded_flag}) except: print(可能不是Base64尝试其他编码或直接输出。)情况二零宽字符作为分隔符隐藏的信息在特定位置另一种套路是零宽字符本身不直接代表比特而是作为“标记”用来从载体文本中挑选出特定的字母。例如在每个单词后面插入一个零宽字符意味着要取这个单词的第一个字母插入两个则取第二个字母以此类推。def decode_by_position(content): words content.split() # 按空白分割单词 flag_chars [] for word in words: # 分析单词末尾零宽字符的数量和类型 # 这里需要自定义解析逻辑 pass return .join(flag_chars)对于网刃杯签到题这种难度大概率是标准的二进制映射。我们按照第二步的方法运行脚本后很可能直接得到类似flag{zer0_w1dth_1s_s0_fun}的结果。4. 零宽字符隐写的攻防扩展与实战技巧掌握了基础解法我们可以更进一步探讨如何出题攻击视角和如何更高效地解题防御与自动化视角。4.1 从出题人视角如何构造一道零宽字符隐写题如果你想自己设计一道题可以遵循以下步骤准备Flag确定你的Flag例如flag{test_zero_width_2024}。选择载体文本找一段英文文章、一段代码、甚至是一段中文古诗作为“掩护文本”。设计编码方案简单方案将Flag转换为二进制ASCII。使用U200B代表0U200C代表1。进阶方案使用三种零宽字符实现三进制编码或者用U200B和U200C的排列顺序如“AB”代表00“BA”代表01等增加复杂度。混淆方案先将Flag用Base64、Base32或ROT13编码再进行零宽字符隐写。编写插入脚本def embed_flag(cover_text, flag): bin_flag .join(format(ord(c), 08b) for c in flag) embedded_text [] # 简单插入在每个可见字符后插入一个零宽字符 for i, visible_char in enumerate(cover_text): embedded_text.append(visible_char) if i len(bin_flag): embedded_text.append(\u200b if bin_flag[i] 0 else \u200c) return .join(embedded_text)增加干扰可以在文件头尾加一些无关的零宽字符或者混合使用多种零宽字符但只有一种是有效的干扰自动解码脚本。4.2 解题者的自动化武器库对于经常打CTF的选手准备一个强大的本地工具库至关重要。针对零宽字符隐写你可以打造一个集成脚本# zero_width_detector.py - 增强版检测与解码脚本 import re import argparse from pathlib import Path ZW_SPACE \u200b ZW_NON_JOINER \u200c ZW_JOINER \u200d ZW_NBSP \u2060 COMMON_PATTERNS { binary_01: {ZW_SPACE: 0, ZW_NON_JOINER: 1}, binary_10: {ZW_SPACE: 1, ZW_NON_JOINER: 0}, ternary: {ZW_SPACE: 0, ZW_NON_JOINER: 1, ZW_JOINER: 2}, # 示例需特殊处理 } def analyze_file(filepath): with open(filepath, r, encodingutf-8, errorsignore) as f: data f.read() print(f[*] 文件长度字符数: {len(data)}) # 1. 检测零宽字符 zw_chars_found {} for zw_char, name in [(ZW_SPACE, 零宽空格), (ZW_NON_JOINER, 零宽非连接符), (ZW_JOINER, 零宽连接符), (ZW_NBSP, 零宽非断空格)]: count data.count(zw_char) if count 0: zw_chars_found[zw_char] count print(f[] 发现 {name} ({zw_char!r}): {count} 次) if not zw_chars_found: print([-] 未发现常见零宽字符。) # 可以尝试搜索所有控制字符和不可见字符 control_chars [c for c in data if ord(c) 32 or 0x2000 ord(c) 0x206F] if control_chars: print(f[!] 发现其他可能用于隐写的控制/不可见字符: {set(control_chars)}) return # 2. 尝试自动解码 print(f\n[*] 尝试自动解码...) # 提取所有零宽字符序列 zw_pattern re.compile(f[{re.escape(.join(zw_chars_found.keys()))}]) matches zw_pattern.findall(data) for match in matches[:5]: # 尝试前几个匹配序列 print(f 匹配序列: {repr(match)}) for pattern_name, mapping in COMMON_PATTERNS.items(): if set(match).issubset(set(mapping.keys())): try: bin_str .join(mapping[ch] for ch in match) # 尝试8位一组解码 text .join(chr(int(bin_str[i:i8], 2)) for i in range(0, len(bin_str), 8) if len(bin_str[i:i8]) 8) if text.isprintable() and flag in text.lower(): print(f [!] 使用模式 {pattern_name} 解码出疑似Flag: {text}) else: print(f 使用模式 {pattern_name} 解码: {text[:50]}...) except: continue # 3. 提供原始数据供手动分析 print(f\n[*] 提取的纯零宽字符序列 (前500字符):) pure_zw .join(c for c in data if c in zw_chars_found) print(repr(pure_zw[:500])) if __name__ __main__: parser argparse.ArgumentParser(description零宽字符隐写分析与解码工具) parser.add_argument(file, typePath, help待分析的文件路径) args parser.parse_args() analyze_file(args.file)这个脚本集成了检测、常见模式解码和原始数据提取可以作为一个强大的起点。4.3 在CTF其他题型中的变种与应用零宽字符隐写的思想可以迁移到其他领域Web题在网页的注释、JSON返回值、Cookie值甚至用户名中插入零宽字符用于传递密钥或触发某些逻辑。Stegano图片隐写虽然零宽字符通常用于文本但有时会将包含零宽字符的文本作为字符串嵌入到图片的元数据如EXIF的Comment字段或拼接到文件末尾。数字取证在聊天记录、日志文件中使用零宽字符进行水印标记或隐蔽通信。注意事项在处理来自不可信来源的文本时零宽字符可能被用于恶意目的如仿冒URL利用零宽字符制造视觉上相同的域名、绕过关键词过滤等。在CTF中这是技巧在真实安全场景中则需要警惕。5. 常见问题排查与技巧实录即使知道了原理和工具实战中还是会遇到各种稀奇古怪的问题。下面是我在多次比赛中总结的“避坑指南”。5.1 问题一工具解码出来是乱码怎么办这是最常见的问题。排查思路如下检查二进制分组ASCII是7位还是8位Unicode是直接编码还是UTF-8尝试不同的分组长度7, 8, 16进行转换。可以写一个循环来暴力尝试。for bit_len in [7, 8, 16]: try: text .join(chr(int(bin_str[i:ibit_len], 2)) for i in range(0, len(bin_str), bit_len) if len(bin_str[i:ibit_len]) bit_len) if any(keyword in text for keyword in [flag, {, }]): print(f尝试位长 {bit_len}: {text}) except: pass检查映射关系尝试交换0和1的映射。如果用了三种字符考虑是否是三进制转十进制再转字符或者只是用其中两种第三种是干扰项。检查是否有多层编码解码出的字符串可能是一串Base64、Hex编码或莫尔斯电码。观察字符串特征Base64尾部的Hex的0-9a-f莫尔斯的.-和/并用CyberChef的“Magic”功能或binwalk -e的熵分析思路进行尝试。检查提取位置零宽字符可能不是均匀插入而是只在标点后、大写字母后等特定位置。尝试只提取这些位置的零宽字符序列。5.2 问题二文件在Windows和Linux下显示/处理结果不同为什么这通常是因为换行符和编码问题。换行符Windows是CRLF(\r\n)Linux是LF(\n)。某些脚本按字符位置处理时换行符的差异会导致索引错位。解决方案在读取文件时统一换行符模式open(file, r, newline)或者直接读取二进制数据再解码。编码确保你的Python脚本或工具使用UTF-8编码打开文件。如果文件本身是UTF-16LE或GBK零宽字符的字节表示会完全不同用UTF-8去读就会出错。可以用chardet库检测编码或者用hexdump看文件头。5.3 问题三在线解码器好用但想彻底弄懂原理如何手动分析手动分析是提升能力的最佳途径。步骤如下获取纯零宽序列用脚本或sed/tr命令过滤掉所有可见字符只留下零宽字符。例如在Linux下可以cat checkin.txt | tr -d [:print:] zw_sequence.txt注意这也会删除换行符。可视化将不同的零宽字符用不同符号替换以便肉眼观察模式。例如用Pythonvis sequence.replace(\u200b, [0]).replace(\u200c, [1]).replace(\u200d, [2])。观察模式看看序列是否是规律的010101还是分组的00011011或者有明显的重复模式。这能帮你猜测编码方式。尝试转换根据观察到的模式手动取一小段比如前16个字符按照你猜测的编码方式二进制、三进制转换成数字再查ASCII表看是否能组成有意义的字母如f的ASCII是102。5.4 高阶技巧利用编辑器与差分对比VS Code安装Zero Width Characters或Highlight Bad Chars这类扩展可以高亮显示所有零宽字符和非打印字符一目了然。Sublime Text在视图菜单中开启“Show Hidden Characters”。差分对比如果你有一个“干净”的载体文本有时题目会提供提示或可以从别处获得可以使用diff工具或在线对比网站将隐写后的文件与干净文件对比差异部分就是隐藏的信息。这对于自定义插入规则的题目特别有效。最后关于2021网刃杯签到题根据常见的出题模式和赛后Writeup其解法很可能就是使用标准二进制映射。当你用正确的方法提取和解码后得到的Flag应该是一个符合比赛格式的字符串。这道题的价值不在于Flag本身而在于它为你打开了一扇门让你意识到文本文件中那些“看不见的空间”里竟然可以隐藏如此多的信息。掌握了零宽字符隐写你再看任何文本文件都会多留一个心眼而这正是CTF比赛想要培养的“安全思维”之一。