1. 从“一无所获”到“柳暗花明”MISC题中的时间戳隐写做CTF的MISC题最让人头疼的往往不是那些花里胡哨的加密算法而是那种你用遍了所有工具——binwalk、zsteg、Stegsolve、010 Editor——结果都显示“一切正常”的题目。你对着文件翻来覆去地看十六进制视图都快看花了眼就是找不到任何可疑的字符串、文件头尾或者异常数据块。这时候很多新手就会陷入自我怀疑“是不是我工具用错了还是我漏掉了什么高级的扫描参数”我刚开始打CTF那会儿也经常卡在这种地方。后来经验多了才发现出题人的脑洞有时候就藏在最“光明正大”的地方。比如文件的属性信息。我们平时查看一张图片、一个文档谁会特意去关注它的“创建时间”、“修改时间”或者“访问时间”呢但在CTF的世界里这些由操作系统维护的时间戳恰恰可以成为一个非常隐蔽的信息载体。攻防世界的这道“时间刺客”题就是一个经典的案例。它没有在文件数据区里做任何手脚而是把Flag巧妙地“写”在了一堆图片文件的修改时间上。当你用常规隐写分析工具一无所获时转换思路去检查一下文件的“元数据”往往就能打开新世界的大门。这种利用文件系统时间戳进行隐写的手法我习惯称之为“时间戳隐写”。它的核心思路很简单将想要隐藏的信息比如Flag的ASCII码转换成数字再将这些数字伪装成文件的修改时间戳。对于查看者来说这只是一堆看起来随机、或许有些规律的时间但对于解题者来说你需要像侦探一样发现这些时间戳的“异常”并找到将它们还原回原始信息的方法。接下来我们就一步步拆解如何从“工具扫描无果”的困境中找到并解开这个“时间戳里的秘密”。2. 解题第一步发现“异常”的时间戳当你拿到一个MISC题目尤其是像攻防世界这类平台提供的题目包里面常常是一个压缩文件。解压之后你可能会看到一个文件夹里面躺着几十张甚至上百张图片。你的第一反应是什么肯定是先跑一遍binwalk看看有没有文件拼接再用zsteg扫扫PNG的LSB隐写最后用Stegsolve翻翻颜色通道对吧这都是标准操作流程。但假如我是说假如所有这些工具都安静地告诉你“这里什么都没有。” 这时候千万别急着放弃。我的习惯是马上打开命令行用ls -lLinux/macOS或者dirWindows的详细模式快速浏览一下这些文件的基本属性。你可能会看到类似这样的输出-rw-r--r-- 1 user group 10240 Mar 12 14:08 image1.png -rw-r--r-- 1 user group 10240 Mar 12 14:08 image2.png -rw-r--r-- 1 user group 10240 Mar 12 14:09 image3.png ...粗看之下修改时间似乎很接近这很正常可能是批量生成或下载的。但“时间刺客”这类题目的狡猾之处在于它会让你觉得“好像有点规律但又说不上来”。你需要更精确地查看时间戳。在Linux下可以用stat命令查看文件的详细状态信息包括精确到纳秒的修改时间Modify time。stat image1.png File: image1.png Size: 10240 Blocks: 24 IO Block: 4096 regular file Access: (0644/-rw-r--r--) Uid: ( 1000/ user) Gid: ( 1000/ group) Access: 2023-03-12 14:08:15.123456789 0800 Modify: 2023-03-12 14:08:15.123456789 0800 Change: 2023-03-12 14:08:15.123456789 0800在Windows下你可以使用PowerShell的Get-Item命令来获取类似信息。当你把文件夹里所有图片的时间戳都列出来时真正的“异常”就浮现了这些时间戳的秒数部分或者纳秒部分看起来不像自然生成的时间。自然生成的时间其秒数在0-59之间随机分布而这里的时间戳其数字序列可能呈现出某种明显的分组特征或者直接就是一些超出正常时间范围的数字比如秒数大于59。另一种更直接的“异常”是所有文件的修改时间其“日期”部分都相同但“时分秒”部分却各不相同且变化没有逻辑。在真实的文件操作中如果你批量修改了文件内容它们的修改日期应该相同时间也可能非常接近。但如果时间是信息编码的结果那么这些时间点可能会显得非常“刻意”比如14:08:1514:09:3714:11:04…… 如果你把秒数提取出来15, 37, 04会不会联想到ASCII码呢15对应的是SIShift In不可打印字符但37对应的是%04对应的是EOT传输结束。这只是一个简单的联想真正的编码方式可能更复杂。所以解题的第一步也是最重要的一步就是培养对“元数据”的敏感性。当数据区干干净净的时候别忘了文件本身自带的“属性”也是一片可以藏匿信息的沃土。时间戳、文件大小如果大量文件大小有规律地变化、甚至文件名本身都可能是解题的关键。3. 理解时间戳从系统时间到Unix时间戳要想解码时间戳里的信息我们得先搞清楚计算机是怎么记录时间的。我们看到的“2023-03-12 14:08:15”这种格式对人类友好但对计算机进行数学运算并不方便。因此在计算机系统内部广泛使用一种叫做Unix时间戳Unix Timestamp的表示法。Unix时间戳定义了一个绝对的时刻它表示从协调世界时UTC1970年1月1日0时0分0秒开始所经过的秒数。注意这里说的是秒不是毫秒或纳秒。例如时间“2023-03-12 14:08:15”对应的Unix时间戳可能是一个10位整数如果精确到秒比如1678622895。这个数字就是自1970年那个起点以来流逝的总秒数。在Python中我们可以用os.path.getmtime(filename)函数来获取一个文件的修改时间戳。这个函数返回的是一个浮点数float单位是秒。整数部分就是Unix时间戳的秒数小数部分则表示不足一秒的部分通常是微秒或纳秒的精度取决于操作系统。例如1678622895.123456789就表示1678622895秒又123456789纳秒。为什么理解这个很重要因为在“时间刺客”这道题里出题人很可能就是利用了这个浮点数时间戳。他们可能将Flag的每一个字符的ASCII码经过某种运算后转换成一个小数然后直接设置成文件的修改时间。当我们用脚本读取这个时间戳时拿到手的那个浮点数就是我们需要解码的“密文”。这里还有一个容易踩坑的地方时区。os.path.getmtime获取的时间戳通常是基于操作系统当前时区的一个“本地时间”概念转换而来的。但Unix时间戳本身是UTC时间。不过在CTF题目中为了简化出题人通常会忽略时区的影响或者明确说明所有时间戳都是基于UTC。我们在解题时一般也默认时间戳是UTC或者直接使用原始数值进行计算避免时区转换引入的复杂度。4. 编写解码脚本将时间戳转化为字符当我们确信时间戳里藏了东西并且理解了时间戳的格式后接下来就是最核心的环节写脚本把信息提取出来。我们直接来看攻防世界官方Writeup解题报告里给出的那个Python脚本并一行行拆解它的逻辑。这个脚本是解题的关键但原文的注释比较简略我这里会展开讲透每一个步骤的用意和可能遇到的坑。首先脚本的大致框架是遍历./images目录下的所有图片文件获取它们的修改时间戳然后进行一系列运算最终拼接出Flag。import os img_dir os.listdir(./images) time_stamp_list [] flag 这部分是初始化。os.listdir列出目录下所有文件名time_stamp_list可能原本想用来存所有时间戳虽然后面没用到flag是最终结果。关键循环开始了for image_name in img_dir: time_stamp int(int(int(os.path.getmtime(images/image_name)*(10**9)) % (2**64-1)) / (10**9)) str_time_stamp str(time_stamp) print(str_time_stamp)这一行非常密集我们把它拆开看os.path.getmtime(images/image_name)获取文件的修改时间戳得到一个浮点数单位秒。*(10**9)乘以10的9次方。这是干什么因为getmtime返回的是秒可能带小数。乘以10^9相当于把单位从秒转换成了纳秒。1秒 1,000,000,000纳秒。这样操作后原来小数部分的信息比如0.123456789秒就变成了整数部分123456789纳秒方便我们后续进行整数运算。连续两个int()第一个int()是把乘以10^9后的浮点数强制转换成整数。第二个int()是配合后面的取模运算。这里写得有点冗余但意图是确保参与运算的是整数。% (2**64-1)这是一个取模运算。模数是2^64 - 1。为什么要这么做注释里提到“由于有的时间戳是负数”。在极少数情况下比如1970年以前的文件Unix时间戳可能是负数。取模运算(a % m)在Python中对负数处理的结果始终是非负的结果在0到m-1之间。2^64-1是一个非常大的数18446744073709551615用它取模可以确保无论原始时间戳纳秒表示是正还是负我们都能得到一个统一范围内的正数。这步操作是为了数据规范化消除负数带来的干扰。/ (10**9)最后再除以10^9。这步操作是把单位从纳秒重新转换回秒吗仔细看经过前面取模后这个数已经是一个巨大的整数了再除以10^9得到的是一个整数因为用了int()包裹除法。实际上这步操作的目的可能是为了获取时间戳的“秒数”部分但丢弃了原始的“纳秒”部分。注意这里的“秒数”是经过取模处理后的一个数字它已经不是原始的Unix时间戳了而是一个编码后的数字。所以time_stamp变量最终保存的是一个整数它由原始时间戳纳秒表示经过取模规范化后再舍弃纳秒部分除以10^9取整得到。这个整数很可能就对应着Flag中某个字符的ASCII码或者是需要进一步处理的中间数字。接下来是第二段关键逻辑负责将数字字符串解析为ASCII字符ascii_code for i in range(len(str_time_stamp)): ascii_code str_time_stamp[i] if int(ascii_code) 127: ascii_code ascii_code[:len(ascii_code) - 1] flag chr(int(ascii_code)) ascii_code ascii_code str_time_stamp[i] else: if i len(str_time_stamp)-1: flag chr(int(ascii_code)) print(flag)这段代码是可变长度数字解析。它假设str_time_stamp这个数字字符串是由一个或多个ASCII码的数字拼接而成的。例如数字字符串84105115116可能表示ASCII码84, 105, 115, 116对应字符This。解析算法如下初始化一个空字符串ascii_code用于临时累积数字。遍历时间戳数字字符串的每一个字符即每一个数字。将当前数字字符拼接到ascii_code后面。检查ascii_code转换为整数后是否大于127。为什么是127因为标准ASCII码的范围是0-127。大于127的就不是可打印的标准ASCII字符了。如果大于127说明当前累积的数字已经超出了一个ASCII码的范围。那么就把刚刚加进来的那个数字字符去掉ascii_code ascii_code[:len(ascii_code) - 1]。此时ascii_code就是一个有效的ASCII码数字了将其转换为字符chr(int(ascii_code))并拼接到flag中。然后重置ascii_code并把当前这个“导致超范围”的数字字符作为新的开始ascii_code str_time_stamp[i]。如果不大于127则检查是否是字符串的最后一个字符。如果是说明遍历完了当前累积的ascii_code就是一个完整的ASCII码将其转换为字符并拼接。这个算法巧妙地处理了数字字符串中没有明确分隔符的情况。它依靠“ASCII码值不超过127”这个约束来自动判断在哪里进行“切割”。最终脚本会打印出每一个文件解析出的字符并逐步拼接出完整的Flagflag{T1m3_f1ie5}。5. 脚本逻辑的深度剖析与数学原理上一节我们走读了脚本知道了它“怎么做”。这一节我们深挖一下它“为什么这么做”背后的数学原理和设计思路是什么。这对于我们举一反三解决其他变种题目至关重要。首先为什么要放大10^9倍到纳秒这主要是为了利用时间戳的小数部分。如果出题人只是把ASCII码放在时间戳的“秒数”部分整数部分那么getmtime返回的浮点数其小数部分可能就是.0或者是一些无关的随机值。但将时间戳放大到纳秒后原始浮点数的小数部分就变成了整数部分的高位或低位数字取决于处理方式。在本题的脚本中虽然最后又除以了10^9看似丢掉了纳秒部分但请注意取模运算% (2**64-1)是在放大之后进行的。这意味着原始时间戳小数部分的信息也就是纳秒部分已经参与到取模运算中并影响了最终time_stamp整数的值。所以纳秒部分的信息并没有被丢弃而是被编码进了最终的整数里。这是一种常见的编码技巧将信息隐藏在数据的精度中。其次取模运算% (2**64-1)的真正目的是什么除了处理负数它更重要的一个作用是将数据范围限制在一个已知、可控的区间内。2^64-1是64位无符号整数能表示的最大值。经过这个运算time_stamp一定是一个介于0到2^64-1之间的正整数。这为后续的解析提供了便利因为我们可以预期解析出的数字不会无限大。在信息编码中这种“模运算”也常用于构造一种类似哈希的映射或者简单的加密。最后也是最精妙的部分可变长度数字解析算法。这个算法是这道题的灵魂。它基于一个前提Flag由可打印的ASCII字符组成其码值在32-126之间通常小于127。算法从左到右扫描数字字符串贪婪地累积数字直到累积的数字超过127。一旦超过就认为前一个累积是有效的ASCII码输出对应字符然后从导致超限的那个数字重新开始累积。这就像我们读一段没有空格的英文句子thisisatest。我们的大脑会根据词汇库已知的单词来尝试切分this-is-a-test。这里的算法用的“词汇库”规则更简单只要数字小于等于127就可能是有效的ASCII码。但这里有个潜在问题如果ASCII码是两位数比如99对应c或三位数比如108对应l算法都能正确切分。但如果出现像10和0这样的组合算法会先累积1小于127再累积10小于127再累积100小于127不100小于127是有效的但可能这不是出题人的本意。实际上这个算法要求编码时每个ASCII码对应的数字在拼接时其本身作为一个整数必须大于前一个码值的前缀或者说出题人设计的数字序列必须能通过这个“小于等于127”的规则被唯一地、正确地切分。这通常意味着如果ASCII码是65A那么数字字符串中就不会出现6或65作为另一个码值前缀的情况。这体现了出题人在编码时设计的精巧性。理解了这个原理我们就能自己写出更健壮、更通用的解码脚本。比如我们可以不依赖“大于127”的判断而是尝试所有可能的前缀分割然后根据是否可打印字符来筛选。但这道题的编码方式恰好完美适配了这个简单算法。6. 举一反三时间戳隐写的其他变种与防御通过“时间刺客”这道题我们掌握了时间戳隐写的基本套路信息编码 - 写入文件时间戳 - 解题时读取并解码。但CTF出题人的创意是无穷的他们会在基础上玩出各种花样。了解这些变种能帮助我们在赛场上更快地识别并破解它们。变种一使用多个时间戳字段。一个文件通常有至少三个时间戳访问时间atime、修改时间mtime、状态改变时间ctime。出题人可能将信息拆分到这三个时间戳里。比如用mtime的秒数存第一个字符用atime的秒数存第二个字符用ctime的纳秒部分存校验码等等。解题时就需要同时读取这三个时间戳并按特定顺序组合。变种二使用时间戳差值或排序。不给直接编码的时间戳而是给一批文件它们的时间戳本身可能无意义但文件按照修改时间排序后其顺序隐含了信息。比如按时间先后顺序排列的文件名首字母连起来就是Flag。或者相邻文件的时间戳之差秒数对应ASCII码。这就需要我们先将文件按时间排序再计算差值。变种三结合文件名或文件大小。单纯的数字时间戳可能太明显。出题人可能会把编码后的数字进行简单加密如异或一个固定值或者将信息分散在时间戳和文件大小两个属性中。例如文件大小对256取模得到一个数时间戳的秒数对256取模得到另一个数两者组合成一个完整的ASCII码。变种四使用非常规的时间格式。我们习惯了Unix时间戳。但Windows系统还有另一种时间表示FILETIME它是从1601年1月1日开始的100纳秒间隔数。有些题目可能会提供Windows系统下的文件其时间戳就是FILETIME格式。这就需要我们使用Python的win32file库如果环境允许或进行时间格式转换。那么作为防守方比如在取证或安全监测中我们如何发现这类隐写呢我分享几个实战中的小技巧批量检查时间戳的规律性写个小脚本批量提取一批文件的时间戳特别是修改时间观察其秒数、纳秒部分的数值分布。如果大量文件的秒数都集中在32-126这个区间ASCII可打印字符范围或者纳秒部分有非随机的规律那就非常可疑。检查时间戳的合理性看看文件的时间戳是否与其内容、类型相符。比如一个声称是1990年的老文档但其时间戳的纳秒部分精度极高这就不符合当时系统的特性。关注“元数据”的一致性对比同一批文件的创建时间、修改时间和访问时间。正常情况下它们之间可能存在逻辑关系如创建时间早于修改时间。如果发现大量文件的这些时间戳之间呈现某种数学关系如差值恒定、呈等差数列等那就值得深究。使用专门的元数据分析工具除了系统命令还有一些取证工具能更深入地分析文件元数据并高亮显示异常值。7. 实战演练打造你自己的时间戳隐写工具光会解题还不够过瘾如果我们能自己动手制作一个简单的“时间戳隐写”工具把一段秘密信息藏进一堆图片的时间戳里那对原理的理解会上一个全新的台阶。下面我就用Python写一个简单的编码工具过程正好是解题的逆过程。我们的目标将字符串Hello_CTF!隐藏到10张图片的修改时间戳里。假设我们有一个./source_images文件夹里面有10张普通的PNG图片img0.png到img9.png。第一步规划编码方案。我们需要决定如何将字符映射到时间戳。为了简单起见我们采用类似题目但更清晰的方法将每个字符转换为其ASCII码十进制。将这个ASCII码作为一个“偏移量”加到一个基础时间戳上。基础时间戳可以固定为一个值比如1678622895对应某个具体日期时间。最终文件的时间戳 基础时间戳 字符的ASCII码。这样解码时只需要用文件时间戳减去基础时间戳就能得到ASCII码再转回字符即可。这个方案比原题的“数字字符串拼接”更直观。第二步编写编码脚本。我们需要用到os.utime函数来修改文件的访问和修改时间。注意修改系统时间戳通常需要相应的权限。import os import sys def hide_text_in_timestamps(image_dir, text, base_timestamp): 将文本隐藏到一批图片的修改时间戳中。 :param image_dir: 图片所在目录路径 :param text: 要隐藏的文本 :param base_timestamp: 基础Unix时间戳整数秒 # 获取目录下所有图片文件按文件名排序以确保顺序 images sorted([f for f in os.listdir(image_dir) if f.lower().endswith((.png, .jpg, .jpeg))]) if len(images) len(text): print(f错误图片数量({len(images)})少于文本长度({len(text)})) return print(f找到 {len(images)} 张图片。将隐藏文本: {text}) print(f基础时间戳: {base_timestamp}) print(- * 40) for i, char in enumerate(text): img_path os.path.join(image_dir, images[i]) ascii_val ord(char) # 获取字符的ASCII码 # 新的时间戳 基础时间戳 ASCII码 # 注意我们同时设置访问时间(atime)和修改时间(mtime)为相同值避免因atime更新而泄露信息 new_timestamp base_timestamp ascii_val # os.utime 接受一个元组 (atime, mtime) os.utime(img_path, (new_timestamp, new_timestamp)) print(f 文件: {images[i]:15} | 字符: {char} (ASCII: {ascii_val:3d}) | 新时间戳: {new_timestamp}) print(- * 40) print(编码完成) # 使用示例 if __name__ __main__: # 基础时间戳可以任意指定这里用一个示例值 BASE_TS 1678622895 # 对应 2023-03-12 14:08:15 UTC TEXT_TO_HIDE Hello_CTF! IMAGE_DIR ./source_images hide_text_in_timestamps(IMAGE_DIR, TEXT_TO_HIDE, BASE_TS)第三步编写解码脚本。解码就是编码的逆过程。我们读取每个文件的时间戳减去基础时间戳得到ASCII码再转换为字符。import os def extract_text_from_timestamps(image_dir, base_timestamp, text_length): 从一批图片的修改时间戳中提取隐藏的文本。 :param image_dir: 图片所在目录路径 :param base_timestamp: 编码时使用的基础Unix时间戳 :param text_length: 预期提取的文本长度 :return: 提取出的文本 images sorted([f for f in os.listdir(image_dir) if f.lower().endswith((.png, .jpg, .jpeg))]) if len(images) text_length: print(f警告图片数量可能不足。) text_length len(images) extracted_chars [] print(f从 {len(images)} 张图片中提取信息 (预期长度: {text_length})...) print(f基础时间戳: {base_timestamp}) print(- * 40) for i in range(text_length): img_path os.path.join(image_dir, images[i]) # 获取修改时间戳 file_timestamp os.path.getmtime(img_path) # 计算ASCII码当前时间戳 - 基础时间戳 ascii_val int(round(file_timestamp - base_timestamp)) # 取整消除浮点误差 # 将ASCII码转换为字符 char chr(ascii_val) extracted_chars.append(char) print(f 文件: {images[i]:15} | 时间戳: {file_timestamp:.6f} | 差值: {ascii_val:3d} | 字符: {char}) extracted_text .join(extracted_chars) print(- * 40) print(f提取出的文本: {extracted_text}) return extracted_text # 使用示例 if __name__ __main__: BASE_TS 1678622895 IMAGE_DIR ./source_images # 假设这是已经被编码工具修改过的图片目录 EXPECTED_LENGTH len(Hello_CTF!) flag extract_text_from_timestamps(IMAGE_DIR, BASE_TS, EXPECTED_LENGTH)运行编码脚本后你的source_images目录下前10张图片的修改时间就会被改变。运行解码脚本输入同样的基础时间戳就能完美还原出Hello_CTF!。通过这个亲手实践的过程你会对“时间戳如何承载信息”有肌肉记忆般的理解。下次再遇到类似的CTF题你一眼就能看穿它的把戏。