尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

零宽字符隐写技术:从CTF签到题到信息隐藏实战

零宽字符隐写技术:从CTF签到题到信息隐藏实战 1. 项目概述当“看不见”的字符成为秘密信使如果你玩过CTFCapture The Flag夺旗赛尤其是网刃杯这类国内知名的赛事一定对“签到题”不陌生。它通常是比赛的第一道关卡旨在让选手热身快速进入状态。但2021年网刃杯的这道签到题却给不少新手甚至老手来了个下马威——它没有复杂的代码审计没有眼花缭乱的加密算法而是用了一种极其隐蔽的信息隐藏技术零宽字符隐写。简单来说这道题就是给选手一段看似普通的文本比如一句问候语或一段提示。但在这段文本里隐藏着用“零宽字符”编码的Flag比赛的目标答案。零宽字符顾名思义就是宽度为零的字符。它们在绝大多数文本编辑器、网页和聊天窗口中都是完全不可见的就像幽灵一样潜伏在字里行间。只有通过特定的工具或方法将其“提取”出来才能发现其中隐藏的秘密。这道题的精妙之处在于它完美结合了CTF中“杂项”Misc和“隐写”Steganography两大领域的知识点考验的不仅是选手的技术工具使用能力更是对信息编码和数字文本底层原理的理解。对于CTF新手这可能是第一次接触到“文本隐写”的概念会感到无从下手。而对于有经验的选手这也是一次巩固基础、审视细节的好机会。本文将彻底拆解“零宽字符隐写”的原理、常见编码方式、解题工具链以及实战中的排查技巧让你不仅能够复现这道签到题的解法更能掌握一类题目的通解思路未来在遇到类似如LSB图片隐写、宽高修复、乃至更复杂的视频隐写时都能触类旁通。2. 零宽字符隐写技术原理深度拆解要理解如何解题首先必须明白零宽字符是什么以及它们如何被用来传递信息。2.1 什么是零宽字符在Unicode这个全球统一的字符编码标准中除了我们日常看到的字母、数字、汉字还存在一系列特殊用途的控制字符或格式字符。零宽字符就是其中一类它们本身不占据任何显示宽度也不对应任何可见的字形但在文本的底层编码中是真实存在的。它们的核心作用是控制文本的排版或格式例如指示文本的流向。在CTF隐写中最常被“借用”来传递信息的零宽字符主要有以下三种零宽空格用于在特定语言中标记词边界但在大多数上下文中不可见。零宽非连接符用于阻止字符之间的连字效果。零宽连接符用于促使字符之间产生连字效果。这三种字符就像三种不同的“摩尔斯电码”中的点和划通过它们的排列组合可以表示二进制信息0和1。2.2 零宽字符隐写的编码逻辑既然零宽字符不可见我们如何用它们编码呢最主流的方法是将它们映射为二进制位。一个常见的映射方案是零宽空格代表二进制0零宽非连接符代表二进制1零宽连接符作为“分隔符”用于区分不同的字节或字符。编码过程示例 假设我们要隐藏的Flag是flag{hello}。首先我们需要将每个字符转换为其二进制形式的ASCII码或Unicode码。字符f的ASCII码是102二进制是01100110。按照上述映射这个二进制串可以编码为[零宽非连接符][零宽空格][零宽空格][零宽非连接符][零宽非连接符][零宽空格][零宽空格][零宽非连接符]。在编码完一个字节8位后可以插入一个零宽连接符作为分隔。将这一长串零宽字符序列插入到一段宿主文本比如“欢迎来到2021网刃杯CTF”的任意位置开头、结尾或中间。由于它们没有宽度宿主文本的显示完全不受影响。解码过程则是逆向操作从文本中提取出所有零宽字符按照映射规则空格0非连接符1连接符分隔转换回二进制串再将二进制串每8位一组转换为ASCII字符最终得到隐藏的Flag。注意具体的映射规则并非一成不变。出题人可能会自定义映射例如调换零宽空格和零宽非连接符的含义或者使用其他零宽字符如左至右标记、右至左标记。这是解题时需要灵活应对的关键点。2.3 为什么选择零宽字符进行隐写这种隐写方式在CTF中备受青睐原因在于其极强的隐蔽性和迷惑性视觉不可见在常规查看方式下信息完全隐藏直接复制粘贴文本也不会发现异常除非特意检查字符编码。平台通用性零宽字符是Unicode标准的一部分在任何支持Unicode的系统、编辑器或网页中都能存在并保持其“隐形”特性。易于嵌入可以嵌入到任何文本载体中包括题目描述、网页源码、PDF文档、甚至社交媒体帖子。考验基本功解题不需要复杂的密码学知识但要求选手对文本编码、编辑器高级功能如显示不可见字符和脚本编写有基本了解非常适合作为签到题或入门题。3. 实战解题从发现到提取的全流程了解了原理我们来看如何实战解决“2021网刃杯CTF 签到”这类题目。解题过程可以归纳为“发现-提取-解码”三步。3.1 第一步发现隐藏的零宽字符题目通常会给出一段文本。第一步是怀疑并验证其中是否含有零宽字符。方法一使用支持显示不可见字符的文本编辑器这是最直接的方法。推荐使用以下工具Sublime Text / VS Code安装如HexViewer或使用内置的“选择编码”功能但更简单的是直接复制文本到编辑器零宽字符有时会以特殊空白形式如一个小点或竖线显示。Notepad在“视图” - “显示符号” - “显示所有字符”中零宽字符通常会显示为特殊的标记如LS、RS等。在线工具直接搜索“Zero-width character detector”或“零宽字符在线检测”将文本粘贴进去工具会高亮或列出发现的零宽字符。方法二使用Python脚本进行初步探测如果无法使用图形化工具写一个简单的Python脚本快速检查字符的Unicode码点是非常高效的方式。# 假设题目文本保存在 ‘ctf_text.txt‘ 中 with open(ctf_text.txt, r, encodingutf-8) as f: text f.read() for i, char in enumerate(text): # 检查字符的Unicode码点是否属于常见的零宽字符范围 if ord(char) in [8203, 8204, 8205, 8234, 8235, 8236, 8237, 8238]: print(f位置 {i}: 发现零宽字符 U{ord(char):04X} ({char.encode(unicode_escape).decode()}))这段代码会遍历文本中的每个字符检查其Unicode码点是否属于几个常见的零宽字符U200B, U200C, U200D等并打印出位置和编码。3.2 第二步提取并清洗零宽字符序列发现零宽字符后需要将它们从宿主文本中分离出来。我们继续用Python实现。def extract_zero_width(text): # 定义常见的零宽字符Unicode码点 ZERO_WIDTH_SPACE \u200b # U200B ZERO_WIDTH_NON_JOINER \u200c # U200C ZERO_WIDTH_JOINER \u200d # U200D LEFT_TO_RIGHT_MARK \u200e # U200E RIGHT_TO_LEFT_MARK \u200f # U200F zero_width_chars [] for char in text: if char in [ZERO_WIDTH_SPACE, ZERO_WIDTH_NON_JOINER, ZERO_WIDTH_JOINER, LEFT_TO_RIGHT_MARK, RIGHT_TO_LEFT_MARK]: zero_width_chars.append(char) # 也可以根据码点判断if 0x200b ord(char) 0x200f: # zero_width_chars.append(char) return .join(zero_width_chars) # 使用函数提取 hidden_sequence extract_zero_width(text) print(f提取到的零宽字符序列: {hidden_sequence}) print(f序列长度: {len(hidden_sequence)}) # 为了方便查看可以将其转换为转义序列 print(f转义表示: {hidden_sequence.encode(unicode_escape).decode()})这个函数会过滤掉所有可见字符只保留我们关心的零宽字符得到一个纯净的零宽字符序列。3.3 第三步解码还原隐藏信息这是最关键的一步我们需要将零宽字符序列按照某种映射规则翻译成二进制再转换成字符串。假设最常见的映射规则零宽空格(0x200B) ‘0’ 零宽非连接符(0x200C) ‘1’ 零宽连接符(0x200D) 分隔符。def decode_zero_width(sequence): # 定义映射字典 mapping { \u200b: 0, # 零宽空格 - 0 \u200c: 1, # 零宽非连接符 - 1 \u200d: , # 零宽连接符 - 空格分隔符 } binary_str for char in sequence: if char in mapping: binary_str mapping[char] else: # 如果遇到未定义的零宽字符可以忽略或作为错误处理 # binary_str ? pass print(f转换后的二进制/分隔字符串: {binary_str}) # 现在需要解析这个binary_str。 # 情况A如果使用了分隔符空格则按空格分割每个部分是一个字节的二进制 if in binary_str: binary_list binary_str.split( ) # 过滤掉空字符串 binary_list [b for b in binary_list if b] result for byte_bin in binary_list: # 将8位二进制字符串转换为整数再转换为字符 # 注意二进制字符串长度可能不足8位可能是出题人自定义这里按8位处理 if len(byte_bin) 8: result chr(int(byte_bin, 2)) else: print(f警告发现非8位二进制段 {byte_bin}尝试直接转换...) result chr(int(byte_bin, 2)) return result # 情况B如果没有明确分隔符则假设每8位为一个字节 else: result for i in range(0, len(binary_str), 8): byte_bin binary_str[i:i8] if len(byte_bin) 8: result chr(int(byte_bin, 2)) else: # 最后一段可能不足8位可能是填充或错误通常忽略 print(f忽略不足8位的尾部: {byte_bin}) break return result # 解码 flag_candidate decode_zero_width(hidden_sequence) print(f解码得到的可能Flag: {flag_candidate})执行流程与结果判断 运行上述代码后你会得到一个字符串。它很可能就是Flag格式通常为flag{...}或ctf{...}。如果得到的是一堆乱码可能有以下几个原因映射规则错误出题人可能使用了不同的映射比如空格1非连接符0。你需要尝试交换映射关系。分隔符识别错误可能使用了其他零宽字符作为分隔或者没有分隔符但字节顺序不对如小端序。需要调整解析逻辑。编码不是ASCII隐藏的信息可能不是直接的ASCII文本而是经过了一次Base64编码或其它编码。你需要对解码出的二进制或字节流进行进一步的解码。例如将得到的字符串尝试进行Base64解码。包含了其他零宽字符除了上述三种可能还使用了U200E或U200F需要将它们加入映射字典并赋予0或1的含义。实操心得在CTF比赛中时间紧迫。建议在编写解码脚本时优先尝试最常见的映射方案。如果不行立刻将提取出的零宽字符序列转义形式粘贴到在线的零宽字符解码工具中搜索“zero-width decoder”这些工具通常提供多种预设映射和尝试可以快速验证思路节省大量时间。不要陷入自己调试脚本的死循环。4. 进阶技巧与通用解题框架掌握了基础解法后我们可以构建一个更健壮、更通用的解题框架以应对更狡猾的题目变种。4.1 构建自适应解码脚本一个优秀的解题脚本应该能自动尝试多种可能性。下面是一个增强版的解码函数框架def brute_force_decode(zero_width_seq): # 所有可能的零宽字符 chars [\u200b, \u200c, \u200d, \u200e, \u200f] # 多种可能的映射组合这里仅示例两种基本组合实际可以更多 mapping_templates [ {\u200b:0, \u200c:1, \u200d: }, # 模板1 {\u200b:1, \u200c:0, \u200d: }, # 模板2交换 {\u200b:0, \u200c:1, \u200e: }, # 模板3换分隔符 # 可以加入更多比如将U200E/U200F也作为0/1 ] results [] for template in mapping_templates: binary_str for zw_char in zero_width_seq: binary_str template.get(zw_char, ) # 如果字符不在模板中忽略 # 尝试多种解析方式 decoded try_parse_binary(binary_str) if decoded and looks_like_flag(decoded): # looks_like_flag是一个简单判断函数 results.append((template, decoded)) return results def try_parse_binary(bin_str): # 尝试按空格分割解析 if in bin_str: bytes_list bin_str.split() try: return .join([chr(int(b, 2)) for b in bytes_list if b]) except: pass # 尝试按每8位解析 if len(bin_str) % 8 0: try: return .join([chr(int(bin_str[i:i8], 2)) for i in range(0, len(bin_str), 8)]) except: pass # 尝试直接作为整体转换可能是Base64编码的字符串 try: # 先将二进制字符串转换为字节 n int(bin_str, 2) bytes_data n.to_bytes((n.bit_length() 7) // 8, big) # 尝试用常见编码解码 for encoding in [utf-8, ascii, latin-1]: try: return bytes_data.decode(encoding) except: continue # 尝试Base64解码 import base64 try: return base64.b64decode(bytes_data).decode(utf-8) except: pass except: pass return None def looks_like_flag(text): import re flag_patterns [rflag{.*}, rctf{.*}, rFLAG{.*}] for pattern in flag_patterns: if re.search(pattern, text): return True return False这个脚本会暴力尝试多种映射和解析组合并自动过滤出看起来像Flag的结果大大提高了容错率和解题速度。4.2 处理复杂变种嵌套编码与组合隐写高水平的CTF题目不会只使用一层零宽字符隐写。常见的变种包括零宽字符 Base64提取出的零宽字符解码后得到的是一个Base64字符串需要再次解码才能获得真正的Flag。零宽字符 其他编码解码后可能是Hex十六进制、莫尔斯电码、甚至是另一个零宽字符序列套娃。与其他隐写技术结合题目附件可能是一张图片但图片的文件名、EXIF信息中的注释字段或者用Stegsolve工具在某个颜色通道中发现的文本里面包含了零宽字符序列。应对策略建立管道化处理思维。将上一步的输出无论是文本、二进制还是编码字符串自然作为下一步的输入依次尝试常见的转换零宽字符序列 - 二进制/文本 - (可能为) Base64串 -解码- (可能为) Hex串 -解码- 最终文本。 在Python中可以方便地链式调用bytes.fromhex().decode()、base64.b64decode()等函数。4.3 利用现有工具链提速虽然自己写脚本很有成就感但比赛时效率至上。熟悉以下工具能让你事半功倍CyberChef瑞士军刀般的网络工具。你可以将包含零宽字符的文本直接粘贴进去使用“Remove whitespace”操作但需小心它可能误删零宽字符或者更好的方法是使用“To Hex”操作查看完整的十六进制表示零宽字符会显示为e2 80 8b等序列。CyberChef也有“Decode text”模块可以尝试多种编码。在线零宽字符解码器如前所述这类网站提供一键解码并允许你自定义映射。Hex编辑器如010 Editor或HxD。将文本文件以二进制形式打开你可以直接看到E2 80 8B这样的UTF-8编码序列从而直观地定位零宽字符。5. 常见问题排查与避坑指南在实际操作中尤其是比赛高压环境下很容易踩坑。以下是我总结的几个典型问题及解决方案。5.1 问题一复制粘贴导致零宽字符丢失现象从网页或PDF中复制题目文本到本地编辑器后用脚本检测不到任何零宽字符。原因某些网页或应用程序在复制文本时可能会过滤掉Unicode控制字符。PDF的复制粘贴行为也因阅读器而异。解决方案优先“另存为”如果题目是网页尝试右键“查看页面源代码”然后保存整个HTML文件。如果题目是PDF尝试用pdfdetachLinux或pdftotext命令提取文本或者使用Python的PyPDF2或pdfplumber库来提取它们保留特殊字符的可能性更高。使用浏览器开发者工具在网页中按F12打开开发者工具在“元素”选项卡中找到包含文本的DOM节点直接复制其textContent或innerText这通常能保留所有字符。直接下载文件如果题目提供了文本文件下载链接一定要通过下载获取原始文件而不是复制网页上的内容。5.2 问题二解码结果乱码找不到Flag格式现象成功提取并解码出一串字符但不是flag{开头而是一堆乱码或看似无意义的字母数字组合。排查步骤检查映射这是最常见的原因。立刻尝试交换0和1的映射关系重新解码。检查字节顺序尝试将二进制串按每8位一小段的顺序反转即小端序。例如二进制01100110(f) 如果被反转成01100110本身不变但1100110可能就需要考虑。尝试不同编码不要假设结果是UTF-8或ASCII。将解码得到的字节数据注意是字节不是字符串用chardet库检测编码或手动尝试gbk、utf-16、latin-1等。识别二次编码观察解码出的字符串。如果它全是A-Za-z0-9/且长度是4的倍数很可能是Base64。如果类似666c61677b...则是Hex编码。使用CyberChef的“Magic”功能或依次尝试解码。检查零宽字符类型确认你是否提取了所有类型的零宽字符。使用print([hex(ord(c)) for c in sequence])打印所有字符的码点看看是否有超出200b-200f范围的“可疑字符”。5.3 问题三脚本在特定环境运行出错现象在本地Python环境运行良好的脚本在比赛提供的在线Web终端或Docker环境中报编码错误。原因环境默认编码可能不是UTF-8。解决方案在Python脚本的开头显式指定编码# -*- coding: utf-8 -*- import sys import io sys.stdout io.TextIOWrapper(sys.stdout.buffer, encodingutf-8)在读写文件时始终使用open(‘file‘, ‘r‘, encoding‘utf-8‘)。对于从网络请求如用requests库获取的文本确保正确设置response.encoding ‘utf-8‘或使用response.content.decode(‘utf-8‘)。5.4 问题四如何验证Flag是否正确现象得到了一个形似Flag的字符串但提交到比赛平台显示错误。排查核对格式确认比赛要求的Flag格式。是flag{...}、ctf{...}、FLAG{...}还是其他形式大小写是否敏感检查完整性零宽字符序列在传输过程中是否被意外截断确保提取的序列完整。可以打印序列长度并与二进制串长度做交叉验证。反向验证如果可能用你推测出的映射规则将你得到的Flag重新编码为零宽字符插入一段文本看看是否能被你的解码脚本正确还原。这是一个很好的闭环测试。团队协作与队友共享提取出的零宽字符序列原始转义字符串如\u200b\u200c...让队友用他的脚本独立解码对比结果。避坑终极心法保持思维发散但操作收敛。先使用通用工具如CyberChef、在线解码器进行快速试探验证思路可行性。一旦确定了解码方向再编写或调整脚本进行精准提取。不要一开始就埋头写复杂的脚本以免在错误的方向上浪费宝贵时间。对于签到题解法通常简洁优雅复杂的代码往往意味着思路跑偏了。
返回列表