原文:
annas-archive.org/md5/46c71d4b3d6fceaba506eebc55284aa5
译者:飞龙
协议:CC BY-NC-SA 4.0
哈希是 DFIR 中最常见的处理过程之一。这个过程允许我们总结文件内容,并分配一个代表文件内容的独特且可重复的签名。我们通常使用 MD5、SHA1 和 SHA256 等算法对文件和内容进行哈希。这些哈希算法非常有价值,因为我们可以用它们进行完整性验证——即使文件内容只改动了一个字节,生成的哈希值也会完全改变。这些哈希也常用于形成白名单,排除已知或不相关的内容,或者用于警报列表,快速识别已知的感兴趣文件。然而,在某些情况下,我们需要识别近似匹配的文件——而这正是 MD5、SHA1 和 SHA256 无法独立处理的任务。
协助相似性分析的最常见工具之一是 ssdeep,由 Jessie Kornblum 开发。这个工具实现了 spamsum 算法,该算法由 Andrew Tridgell 博士开发,用于生成一个 base64 编码的签名,表示文件内容。无论文件内容如何,这些签名都可以用来帮助确定两个文件的相似度。这使得这两个文件的比较可以在计算上更轻便,并且生成相对较短的签名,便于共享或存储。
本章中,我们将做以下内容:
使用 Python 对数据进行 MD5、SHA1 和 SHA256 算法哈希
讨论如何对数据流、文件和文件目录进行哈希
探索 spamsum 算法的工作原理,并在 Python 中实现一个版本
通过 Python 绑定利用已编译的 ssdeep 库,提高性能和功能
本章的代码是在 Python 2.7.15 和 Python 3.7.1 环境下开发和测试的。
哈希数据是法医社区中常用的技术之一,用于为文件生成指纹
。通常,我们会创建整个文件的哈希;然而,在本章稍后的脚本中,我们将对文件的各个片段进行哈希,以评估两个文件之间的相似性。在深入研究模糊哈希的复杂性之前,让我们先了解如何使用 Python 生成加密哈希值,如 MD5 和 SHA1 值。
如前所述,DFIR(数字取证与事件响应)社区和工具常用多种算法。在生成文件哈希之前,我们必须决定使用哪种算法。这是一个困难的问题,因为有多个因素需要考虑。消息摘要算法 5(MD5)生成一个 128 位的哈希值,是法医工具中最常用的加密哈希算法之一。该算法相对轻量,生成的哈希值长度较短,相比其他算法,具有更小的系统资源占用。由于加密哈希有固定的输出长度,选择一个较短长度的算法可以帮助减少对系统资源的影响。
然而,MD5 的主要问题是哈希碰撞的概率。哈希碰撞是指两个不同的输入值产生相同的哈希值,这是因为哈希值的长度是固定的。对于法医领域来说,这是一个问题,因为我们依赖哈希算法作为表示数据完整性的唯一指纹。如果算法存在已知的碰撞问题,那么哈希值可能不再是唯一的,也不能保证数据的完整性。因此,在大多数法医情况下,不推荐将 MD5 作为主要的哈希算法。
除了 MD5 外,还有一些其他常见的加密哈希算法,包括安全哈希算法(SHA)系列。SHA 系列包括 SHA-1(160 位)、SHA-256(256 位)和 SHA-512(512 位),这些都是在法医领域常用的算法之一。SHA-1 算法通常与 MD5 哈希一起出现在大多数法医工具中。最近,一个研究小组发现了 SHA-1 算法的碰撞问题,并在他们的网站上分享了他们的发现,shattered.io/
。像 MD5 一样,SHA-1 在这个领域的流行度也在下降。
在 Python 中,利用这些哈希算法非常简单。在以下代码块中,我们将在解释器中演示使用 MD5、SHA-1 和 SHA-256 算法进行哈希处理的示例。
为了实现这一点,我们需要导入标准库hashlib
,并提供数据来生成哈希值。在导入hashlib
后,我们使用md5()
方法创建一个哈希对象。定义为m
后,我们可以使用.update()
函数向算法中添加数据,使用hexdigest()
方法生成我们常见的十六进制哈希值。这个过程可以通过以下一行代码完成:
>>> import hashlib
>>> m = hashlib.md5()
>>> m.update('This will be hashed!')
>>> m.hexdigest()
'0fc0cfd05cc543be3a2f7e7ed2fe51ea'
>>> hashlib.md5('This will be hashed!').hexdigest()
'0fc0cfd05cc543be3a2f7e7ed2fe51ea'
>>> hashlib.sha1('This will be hashed!').hexdigest()
'5166bd094f3f27762b81a7562d299d887dbd76e3'
>>> hashlib.sha256('This will be hashed!').hexdigest()
'03bb6968581a6d6beb9d1d863b418bfdb9374a6ee23d077ef37df006142fd595'
在前面的示例中,我们对一个字符串对象进行了哈希处理。但文件呢?毕竟,这才是我们真正感兴趣的操作。
要对文件进行哈希处理,我们需要将文件的内容传递给哈希对象。如代码块所示,我们首先打开并写入一个文件,生成一些样本数据供我们进行哈希处理。在设置完成后,我们关闭并重新打开文件以供读取,使用read()
方法将文件的完整内容读取到buffer
变量中。此时,我们将buffer
的值提供为哈希数据,生成我们独特的哈希值。请参见以下代码:
>>> output_file = open('output_file.txt', 'w')
>>> output_file.write('TmV2ZXIgR29ubmEgR2l2ZSBZb3UgVXA=')
>>> output_file.close()
>>> input_file = open('output_file.txt', 'r')
>>> buffer = input_file.read()
>>> hashlib.sha1(buffer).hexdigest()
'aa30b352231e2384888e9c78df1af47a9073c8dc'
>>> hashlib.md5(buffer).hexdigest()
'1b49a6fb562870e916ae0c040ea52811'
>>> hashlib.sha256(buffer).hexdigest()
'89446e08f985a9c201fa969163429de3dbc206bd7c7bb93e490631c308c653d7'
这里展示的哈希方法适用于小文件或数据流。如果我们希望更灵活地处理文件,则需要调整方法。
本章中的第一个脚本简短明了;它将允许我们使用指定的加密算法对提供的文件内容进行哈希处理。这段代码可能更适合作为一个大型脚本中的功能,例如我们的文件列出工具;我们将演示一个独立示例,以便了解如何以节省内存的方式处理文件的哈希处理。
首先,我们只需要导入两个库,argparse
和hashlib
。通过这两个内置库,我们能够生成哈希,如前面的例子所示。在第 33 行,我们列出了支持的哈希算法。这个列表应该只包含作为模块存在于hashlib
中的算法,因为我们将从列表中调用(例如)md5
作为hashlib.md5()
。第二个常量定义在第 34 行,是BUFFER_SIZE
,用于控制每次读取多少文件内容。这个值应该较小,在本例中为 1MB,以节省每次读取所需的内存,尽管我们也希望它足够大,以减少对文件的读取次数。你可能会发现这个数字会根据你选择的运行系统进行调整。为此,你可以考虑将其指定为参数,而不是常量:
001 """Sample script to hash large files effiently."""
002 import argparse
003 import hashlib
...
033 HASH_LIBS = ['md5', 'sha1', 'sha256', 'sha512']
034 BUFFER_SIZE = 1024**3
接下来,我们定义我们的参数。这里非常简洁,因为我们只接受一个文件名和一个可选的算法规格:
036 parser = argparse.ArgumentParser()
037 parser.add_argument("FILE", help="File to hash")
038 parser.add_argument("-a", "--algorithm",
039 help="Hash algorithm to use", choices=HASH_LIBS,
040 default="sha512")
041 args = parser.parse_args()
一旦我们知道了指定的参数,我们将把选定的算法从参数转换为一个可以调用的函数。为此,我们使用第 43 行中显示的getattr()
方法。这个内置函数允许我们从对象中检索函数和属性(例如,来自库的方法,如下面的代码所示)。我们在行尾加上()
,因为我们希望调用指定算法的初始化方法,并创建一个可以用于生成哈希的alg
对象实例。这一行代码等价于调用alg = hashlib.md5()
(例如),但是以适应参数的方式执行:
043 alg = getattr(hashlib, args.algorithm)()
在第 45 行,我们打开文件进行读取,从第 47 行开始将第一个缓冲区长度读取到buffer_data
变量中。然后我们进入一个while
循环,在第 49 行更新我们的哈希算法对象,然后在第 50 行获取下一个数据缓冲区。幸运的是,Python 会从input_file
读取所有数据,即使BUFFER_SIZE
大于文件中剩余的内容。此外,Python 在读取到文件末尾时会退出循环,并在退出with
上下文时为我们关闭文件。最后,在第 52 行,我们打印我们计算的哈希值的.hexdigest()
:
045 with open(args.FILE, 'rb') as input_file:
046
047 buffer_data = input_file.read(BUFFER_SIZE)
048 while buffer_data:
049 alg.update(buffer_data)
050 buffer_data = input_file.read(BUFFER_SIZE)
051
052 print(alg.hexdigest())
现在我们已经掌握了如何生成加密哈希,让我们开始生成模糊哈希。我们将讨论一些可以用于相似性分析的技术,并通过一个简单的例子展示 ssdeep 和 spamsum 如何使用滚动哈希来帮助生成更强健的签名。
不言而喻,我们进行相似性分析的最准确方法是将两个文件的字节内容并排比较,查看差异。虽然我们可以使用命令行工具或差异分析工具(如 kdiff3)来完成这一工作,但这仅适用于小规模的比较。当我们从比较两个小文件转向比较多个小文件,或者几个中等大小的文件时,我们需要一种更高效的方法。此时,签名生成就派上了用场。
要生成签名,我们必须弄清楚以下几点:
我们希望为签名使用哪个字母表
我们希望如何将文件分割成可总结的块
将我们的块摘要转换为字母表中字符的技术
虽然字母表是一个可选组件,但它使我们人类能够更好地回顾和理解数据。我们始终可以将其存储为整数,并节省一些计算资源。Base64 是字母表的常见选择,并被 spamsum 和 ssdeep 使用。
对于上述的第二和第三项,让我们讨论一些技术,如何将我们的文件切割并生成哈希值。在这个示例中(为了保持简单),我们将以下字符序列作为文件内容:
abcdefghijklmnopqrstuvwxyz01
我们的第一种方法是将文件切割成相等大小的块。以下示例中的第一行是我们的文件内容,第二行是每个字符的数字 ASCII 值。为了这个示例,我们决定将文件切割为 4 字节块,并使用竖线和颜色编码的数字 ASCII 值:
https://github.com/OpenDocCN/freelearn-sec-pt3-zh/raw/master/docs/lrn-py-frsc-2e/img/bcd0cb6e-f1ee-49c5-a62b-9917512558d6.png
然后,我们通过将四个字符的 ASCII 值相加来总结这些 4 字节的块,如表格的第三行所示。接着,我们通过将 394 对 64 取模(394 % 64),得到 10,或者说是 Base64 字母表中的 K。这个 Base64 值,正如你可能猜到的,在第四行显示。
字母 K 成为我们第一个块的总结,字母 a 代表第二个,依此类推,直到我们得到完整的文件签名 Kaq6KaU。
在下图中,有一个略微修改过的版本的原始文件。如图所示,有人将 jklmn 替换为 hello。现在我们可以对这个文件运行我们的哈希算法,看看两个版本之间发生了多少变化:
https://github.com/OpenDocCN/freelearn-sec-pt3-zh/raw/master/docs/lrn-py-frsc-2e/img/89201581-e5a0-496f-89e5-cd5cd7d50b39.png
使用相同的技术,我们计算 Kai6KaU 的新哈希值。如果我们想要比较两个文件的相似性,我们应该能够利用我们的签名来促进比较,对吗?所以在这个例子中,我们的签名之间有一个字母的差异,这意味着我们的两个文件流大致相似!
正如你可能已经注意到的,这里存在一个问题:我们在使用算法时发现了哈希冲突。在之前的示例中,每个文件的第四个块不同;第一个是 mnop,第二个是 loop。由于我们正在汇总文件内容来确定签名值,我们注定会得到不健康的哈希冲突。这些冲突可能会让我们误以为文件是相似的,实际上并非如此,而这种情况不幸的是由于在没有使用加密哈希算法的情况下总结文件内容所导致的。因此,我们必须在总结文件内容和遇到哈希冲突之间找到更好的平衡。
我们的下一个示例演示了插入发生时会发生什么。正如您在下面的图表中看到的,字母 h 被插入到 mn 后面,文件增加了一个字节,并且整个内容向右移动了一个位置。在这个例子中,我们的最后一个块只包含数字 1,尽管一些实现可能会有不同的处理方式:
https://github.com/OpenDocCN/freelearn-sec-pt3-zh/raw/master/docs/lrn-py-frsc-2e/img/4e15eba7-e435-418c-ad0a-649fbaff8669.png
使用相同的公式,我们计算了 KaqyGUbx 的哈希。这个哈希与 Kaq6KaU 完全不同。事实上,一旦我们到达包含变化的块,哈希值完全不同,即使文件后半部分的内容是相似的。
这也是为什么使用固定块大小不是进行相似性分析的最佳方法的主要原因之一。任何内容的移动都会使数据跨越边界,导致我们为相似内容计算完全不同的哈希值。为了解决这个问题,我们需要以另一种方式设置这些边界。
正如你可能猜到的,这就是 CTPH 的作用所在。本质上,我们的目标是使用这种技术来计算重置点。在这种情况下,重置点是类似于我们在先前示例中使用的 4 字节边界,因为我们使用这些重置点来确定我们想要总结的文件部分。一个显著的例外是,我们根据文件内容(即我们的上下文触发)而不是固定窗口来选择边界。这意味着我们使用滚动哈希(由 ssdeep 和 spamsum 使用)来计算文件中的值;当找到这个特定值时,会画出边界线,并总结自上一个边界以来的内容(分段哈希)。在以下示例中,我们使用简化的计算来确定是否达到了重置点。
虽然 spamsum 和 ssdeep 都会为每个文件计算重置点数字,但在我们的示例中,我们将使用 7 来简化问题。这意味着每当我们的滚动哈希值为 7 时,我们将总结此边界和之前之间的内容。额外说明一下,这种技术适用于超过 28 字节的文件,因此我们的哈希值在这里会非常短,因此在我们的示例之外用途不大。
在进入示例之前,让我们先讨论一下什么是滚动哈希。我们将再次使用之前相同的示例文件内容。然后,我们使用所谓的滚动哈希来计算文件中每个字节的值。滚动哈希的工作原理是:在文件的某个窗口内计算所有字符的哈希值。在我们的例子中,窗口的大小为 3。我们文件中的窗口移动如下所示,经过前四次迭代:
['a', '', ''] = [97, 0, 0]
['a', 'b', ''] = [97, 98, 0]
['a', 'b', 'c'] = [97, 98, 99]
['b', 'c', 'd'] = [98, 99, 100]
如你所见,滚动窗口会继续遍历文件,每次迭代添加一个新的字节,并以 FIFO 的方式删除最旧的字节。为了生成该窗口的哈希值,我们需要对窗口中的值执行一系列进一步的计算。
对于这个例子,如你可能猜到的那样,我们将对 ASCII 值求和以保持简单。这个求和结果显示在下一个示例的第一行。为了使数字更小,我们将对求和后的 ASCII 值(S)进行模 8 运算(S % 8),并使用这个整数来寻找文件内容中的边界。这个数字可以在下图的第二行找到。如果S % 8 == 7,我们已经达到了重置点,可以创建之前块的汇总。
ssdeep 和 spamsum 算法在处理滚动窗口计算时有所不同,尽管计算的结果在使用方式上是相同的。我们简化了计算过程,以便更容易讨论这个过程。
由于我们的重置点是 7,如前所述,我们将在每次滚动哈希计算返回 7 时,定义文件的一个块。下图通过水平线显示了我们在文件中设置的块。
对于每个块,我们将以与之前相同的方式计算签名:将整个块内的 ASCII 整数值求和(如第四行所示),然后应用模 64 运算得到签名的字符(如最后一行所见)。请记住,在这个例子中,第 2 行和第 4 行之间的唯一关系是,第 2 行告诉我们何时设置重置点并计算第 4 行中显示的数字。这两个哈希在算法上是独立的。第 4 行仍然是对a + b + c + d + e + f的 ASCII 值求和,而不是滚动哈希输出的和:
https://github.com/OpenDocCN/freelearn-sec-pt3-zh/raw/master/docs/lrn-py-frsc-2e/img/ee120ac7-f0a4-4081-9c02-a9fc71583a47.png
这生成了签名 VUUD。尽管短得多,但我们现在得到了上下文触发的哈希。如前所述,我们通过使用滚动哈希来定义边界(即上下文触发),并通过对块的求和(逐块哈希)来识别文件中的共同块,从而将其与具有相似重置点大小的文件进行比较(或者其他重置点为 7 的文件)。
在我们的最终示例中,让我们回顾一下当我们插入字母 h 时发生了什么。使用我们的滚动哈希来计算基于上下文的块(如第一行所示),我们可以使用相同的算法计算块的摘要,并生成签名 VUH1D:
https://github.com/OpenDocCN/freelearn-sec-pt3-zh/raw/master/docs/lrn-py-frsc-2e/img/bf27cd4e-684f-4f06-a801-7bcf23102561.png
如你所见,这项技术对插入更具韧性,允许我们比使用固定块更准确地比较文件之间的差异。在这种情况下,我们的签名显示两个文件的差异比实际差异更大,尽管这种技术比我们的固定块计算更准确,因为它理解文件的尾部在我们两个版本之间是相同的。
显然,这项技术需要大于 28 字节的文件才能产生准确的结果,尽管希望这个简化过程能够帮助说明这些模糊哈希是如何生成的。理解这一点后,让我们开始编写我们的脚本。
该脚本已在 Python 版本 2.7.15 和 3.7.1 上进行了测试,并且没有使用任何第三方库。
在我们深入了解模糊哈希算法之前,让我们像之前一样开始脚本。我们从导入开始,所有这些都是我们之前使用过的标准库,接下来会展示在代码中。我们还定义了一组常量,从第 36 行到第 47 行。第 37 行和第 38 行定义了我们的签名字母表,在这个例子中是所有 base64 字符。下一组常量用于 spamsum 算法生成哈希。CONTEXT_WINDOW
定义了我们将读取多少文件内容用于滚动哈希。FNV_PRIME
用于计算哈希,而 HASH_INIT
为我们的哈希设置初始值。接下来是 SIGNATURE_LEN
,它定义了我们的模糊哈希签名应该有多长。最后,OUTPUT_OPTS
列表用于与我们的参数解析一起显示支持的输出格式——更多内容稍后介绍:
001 """Spamsum hash generator."""
002 import argparse
003 import logging
004 import json
005 import os
006 import sys
007
008 """ The original spamsum algorithm carries the following license:
009 Copyright (C) 2002 Andrew Tridgell
010
011 This program is free software; you can redistribute it and/or
012 modify it under the terms of the GNU General Public License
013 as published by the Free Software Foundation; either version 2
014 of the License, or (at your option) any later version.
015
016 This program is distributed in the hope that it will be useful,
017 but WITHOUT ANY WARRANTY; without even the implied warranty of
018 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
019 GNU General Public License for more details.
020
021 You should have received a copy of the GNU General Public License
022 along with this program; if not, write to the Free Software
023 Foundation, Inc.,
024 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
025
026 CHANGELOG:
027 Implemented in Python as shown below by Chapin Bryce &
028 Preston Miller
029 """
030
031 __authors__ = ["Chapin Bryce", "Preston Miller"]
032 __date__ = 20181027
033 __description__ = '''Generate file signatures using
034 the spamsum algorithm.'''
035
036 # Base64 Alphabet
037 ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
038 ALPHABET += 'abcdefghijklmnopqrstuvwxyz0123456789+/'
039
040 # Constants for use with signature calculation
041 CONTEXT_WINDOW = 7
042 FNV_PRIME = 0x01000193
043 HASH_INIT = 0x28021967
044 SIGNATURE_LEN = 64
045
046 # Argument handling constants
047 OUTPUT_OPTS = ['txt', 'json', 'csv']
048 logger = logging.getLogger(__file__)
这个脚本有三个功能:main()
、fuzz_file()
和 output()
。main()
函数作为我们的主要控制器,处理目录和单个文件的处理,并调用 output()
函数来显示哈希结果。fuzz_file()
函数接受文件路径并生成一个 spamsum 哈希值。然后,output()
函数接受哈希值和文件名,并以指定的格式显示这些值:
051 def main(file_path, output_type):
...
087 def fuzz_file(file_path):
...
188 def output(sigval, filename, output_type='txt'):
我们脚本的结构相当直接,正如下图所示。由虚线所示,fuzz_file()
函数是唯一返回值的函数。这是因为我们的 output()
函数将内容显示在控制台上,而不是返回给 main()
:
https://github.com/OpenDocCN/freelearn-sec-pt3-zh/raw/master/docs/lrn-py-frsc-2e/img/ec8fd59d-9b8c-4fbf-ad5c-8ae4d19a960b.png
最后,我们的脚本以参数处理和日志初始化结束。对于命令行参数,我们接受一个文件或文件夹的路径以及输出格式。我们的输出将写入控制台,当前支持文本、CSV 和 JSON 输出类型。我们的日志参数是标准的,与我们其他实现非常相似,唯一不同的是我们将日志消息写入sys.stderr
,以便用户仍然可以与通过sys.stdout
生成的输出进行交互:
204 if __name__ == '__main__':
205 parser = argparse.ArgumentParser(
206 description=__description__,
207 epilog='Built by {}. Version {}'.format(
208 ", ".join(__authors__), __date__),
209 formatter_class=argparse.ArgumentDefaultsHelpFormatter
210 )
211 parser.add_argument('PATH',
212 help='Path to file or folder to generate hashes for. '
213 'Will run recursively.')
214 parser.add_argument('-o', '--output-type',
215 help='Format of output.', choices=OUTPUT_OPTS,
216 default="txt")
217 parser.add_argument('-l', help='specify log file path',
218 default="./")
219
220 args = parser.parse_args()
221
222 if args.l:
223 if not os.path.exists(args.l):
224 os.makedirs(args.l) # create log directory path
225 log_path = os.path.join(args.l, 'fuzzy_hasher.log')
226 else:
227 log_path = 'fuzzy_hasher.log'
228
229 logger.setLevel(logging.DEBUG)
230 msg_fmt = logging.Formatter("%(asctime)-15s %(funcName)-20s"
231 "%(levelname)-8s %(message)s")
232 strhndl = logging.StreamHandler(sys.stderr) # Set to stderr
233 strhndl.setFormatter(fmt=msg_fmt)
234 fhndl = logging.FileHandler(log_path, mode='a')
235 fhndl.setFormatter(fmt=msg_fmt)
236 logger.addHandler(strhndl)
237 logger.addHandler(fhndl)
238
239 logger.info('Starting Fuzzy Hasher v. {}'.format(__date__))
240 logger.debug('System ' + sys.platform)
241 logger.debug('Version ' + sys.version.replace("\n", " "))
242
243 logger.info('Script Starting')
244 main(args.PATH, args.output_type)
245 logger.info('Script Completed')
有了这个框架,让我们来探讨一下main()
函数是如何实现的。
main()
函数开始我们的主函数接受两个参数:文件路径和输出类型。我们首先检查输出类型,确保它在OUTPUT_OPTS
列表中,以防函数是从没有验证的其他代码中调用的。如果是一个未知的输出格式,我们将抛出错误并退出脚本:
051 def main(file_path, output_type):
052 """
053 The main function handles the main operations of the script
054 :param file_path: path to generate signatures for
055 :param output_type: type of output to provide
056 :return: None
057 """
058
059 # Check output formats
060 if output_type not in OUTPUT_OPTS:
061 logger.error(
062 "Unsupported output format '{}' selected. Please "
063 "use one of {}".format(
064 output_type, ", ".join(OUTPUT_OPTS)))
065 sys.exit(2)
然后,我们开始处理文件路径,在第 67 行获取其绝对路径,在第 69 行检查它是否是一个目录。如果是,我们就开始遍历目录和子目录,查找并处理其中的所有文件。第 71 到第 73 行的代码应该在第五章,Python 中的数据库中见过。第 74 行,我们调用fuzz_file()
函数生成我们的哈希值sigval
。然后,这个sigval
值连同文件名和输出格式一起传递给我们的output()
函数:
067 # Check provided file path
068 file_path = os.path.abspath(file_path)
069 if os.path.isdir(file_path):
070 # Process files in folders
071 for root, _, files in os.walk(file_path):
072 for f in files:
073 file_entry = os.path.join(root, f)
074 sigval = fuzz_file(file_entry)
075 output(sigval, file_entry, output_type)
我们的main()
函数的其余部分处理单个文件的处理和无效路径的错误处理。如第 76 行到第 79 行所示,如果路径是一个文件,我们将按照之前的方式处理它,通过fuzz_file()
生成哈希值,并将值传递给output()
函数。最后,在第 80 行到第 84 行,我们处理访问指定文件或文件夹路径时的错误:
076 elif os.path.isfile(file_path):
077 # Process a single file
078 sigval = fuzz_file(file_path)
079 output(sigval, file_path, output_type)
080 else:
081 # Handle an error
082 logger.error("Error - path {} not found".format(
083 file_path))
084 sys.exit(1)
在我们深入讨论fuzz_file()
函数的代码之前,让我们简要讨论一下其中的工作部分:
一个滚动哈希
一个从文件大小得出的计算重置点
两个传统的哈希,在本例中利用了 FNV 算法
滚动哈希与我们之前的示例相似,用于识别我们将使用传统哈希进行总结的边界。对于 ssdeep 和 spamsum,滚动哈希比较的重置点(在我们之前的示例中设置为7
)是基于文件的大小来计算的。我们稍后会展示用于确定这个值的确切函数,不过我们想强调的是,这意味着只有具有相同块大小的文件才能进行比较。虽然在概念上还有更多要讨论的,但让我们开始通过代码来实践这些概念。
现在我们进入有趣的部分:fuzz_file()
函数。这个函数接受一个文件路径,并使用文件开头找到的常量来处理签名的计算:
087 def fuzz_file(file_path):
088 """
089 The fuzz_file function creates a fuzzy hash of a file
090 :param file_path (str): file to read.
091 :return (str): spamsum hash
092 """
以下代码块是我们的滚动哈希函数。现在,函数内部再嵌套一个函数可能看起来有些奇怪,但这种设计有一些优点。首先,它有助于组织代码。这个滚动哈希代码块仅由我们的 fuzz_file()
函数使用,通过将其嵌套在这个函数内部,我们可以告知下一个阅读我们代码的人情况就是如此。其次,通过将这个函数放置在 fuzz_file()
内部,我们可以确保任何导入我们代码作为模块的人不会误用滚动哈希函数。虽然选择这种设计还有其他多个效率和管理方面的理由,但我们希望在这个脚本中引入这一特性,让你了解这一概念。正如你在我们的其他脚本中看到的,这并不总是用于特殊功能,但它是你可以在脚本中使用的工具,以优化其设计。
这个嵌套函数接受两个参数,分别缩写为 nb
(代表 new_byte
)和 rh
(代表我们的滚动哈希追踪字典)。在我们之前的示例中,为了计算滚动哈希,我们将整个窗口的 ASCII 值相加。在这个函数中,我们将执行一系列计算,帮助我们生成一个更大的 7 字节窗口的滚动哈希:
095 def update_rolling_hash(nb, rh):
096 """
097 Update the rolling hash value with the new byte
098 :param nb (int): new_byte as read from file
099 :param rh (dict): rolling hash tracking dictionary
100 :return: computed hash value to compare to reset_point
101 """
rh
滚动哈希追踪字典用于监控此滚动哈希中的移动部分。这里存储了三个数字,分别是 r1
、r2
和 r3
。这些数字需要进行额外的计算,如下方代码块所示,三者的和作为整数返回,代表该文件帧的滚动哈希。
字典追踪的其他两个元素是 rn
和 rw
。rn
键保存滚动哈希在文件中的偏移位置,用于确定窗口中哪个字符被 nb
、new_byte
值替换。这个窗口,正如你猜到的那样,存储在 rw
中。与我们之前的示例不同,在那里每次计算滚动哈希时,窗口中的每个字符都向左移动,这个实现只会替换数组中的最旧字符。这提高了效率,因为它只需进行一次操作,而不是八次:
102 # Calculate R2
103 rh['r2'] -= rh['r1']
104 rh['r2'] += (CONTEXT_WINDOW * nb)
105
106 # Calculate R1
107 rh['r1'] += nb
108 rh['r1'] -= rh['rw'][rh['rn'] % CONTEXT_WINDOW]
109
110 # Update RW and RN
111 rh['rw'][rh['rn'] % CONTEXT_WINDOW] = nb
112 rh['rn'] += 1
113
114 # Calculate R3
115 rh['r3'] = (rh['r3'] << 5) & 0xFFFFFFFF
116 rh['r3'] = rh['r3'] ^ nb
117
118 # Return the sum of R1 + R2 + R3
119 return rh['r1'] + rh['r2'] + rh['r3']
这个逻辑在计算上与 ssdeep 和 spamsum 使用的逻辑相同。首先,我们通过减去 r1
并加上 CONTEXT_WINDOW
与 new_byte
的乘积来计算 r2
值。然后,我们通过加上 new_byte
并减去窗口中最旧的字节来更新 r1
值。这意味着 r1
存储整个窗口的总和,类似于我们在前一个示例中的整个滚动哈希算法。
在第 111 行,我们开始更新窗口,用 new_byte
字符替换最旧的字节。之后,我们递增 rn
值,以准确追踪文件中的偏移量。
最后,我们计算r3
值,它使用了一些我们尚未介绍的操作符。<<
运算符是一个按位运算符,它将我们的值向左移动,在这个例子中是移动五位。这等同于我们将值乘以 2**5。第 115 行的第二个新的按位运算符是&
,它在 Python 中是按位的AND
运算符。这个运算符逐位比较两边的值,如果两个值在某一位上都是1
,则该位置在输出中为1
,否则为0
。需要注意的是,在按位AND
运算中,两边在同一位置上都是0
时,结果不会是1
。第 116 行的第三个新按位运算符是^
,即排它的OR
运算符,也称为 XOR 操作。它的工作原理大致与按位AND
相反,即如果两个值在同一位置的比特不同,则该位置返回1
;如果相同,则返回0
。
有关 Python 中按位运算符的更多信息,请访问wiki.python.org/moin/BitwiseOperators
。
处理完按位运算后,我们返回r1
、r2
和r3
的总和,用于进一步的模糊哈希计算。
回到我们的fuzz_file()
函数,我们评估提供的文件,看看它是否包含内容,如果有,则打开文件。我们将该文件的大小存储起来,以供后续使用:
122 fsize = os.stat(file_path).st_size
123 if fsize == 0:
124 logger.warning("File is 0-bytes. Skipping...")
125 return ""
126 open_file = open(file_path, 'rb')
我们现在开始哈希算法中的第一个因素——重置点。这个值被标记为签名中的第一个值,因为它用于确定可以进行比较的哈希值。为了计算这个数字,我们从3
开始,这个值在 spamsum 算法中被选为最小的重置点。然后我们将重置点加倍,如第 130 行所示,直到它大于filesize / 64
:
129 reset_point = 3
130 while reset_point * 64 < fsize:
131 reset_point *= 2
一旦我们有了初始的重置点,我们将文件读入内存作为bytearray
,因为我们希望将每个字符作为字节读取,这样我们可以进行解释。然后我们设置while
循环,如果需要调整reset_point
的大小,就在这里进行——稍后会详细讨论:
134 complete_file = bytearray(open_file.read())
135 done = False
136 while not done:
一旦进入我们的while
循环,我们将初始化哈希对象。第一个对象是rolling_hash
,这是一个包含五个键的字典。r1
、r2
和r3
键用于计算哈希值;rn
键跟踪文件中光标的位置;rw
键保存一个大小为CONTEXT_WINDOW
常量的列表。这个字典在我们的update_rolling_hash()
函数中被大量引用。现在你已经看过rolling_hash
字典的结构,重新阅读这一部分可能会有所帮助。
紧接着这个字典,我们初始化了trad_hash1
和trad_hash2
,并赋予它们HASH_INIT
常量的值。最后,我们初始化了两个签名:sig1
和sig2
。变量trad_hash1
用于填充sig1
的值,类似地,trad_hash2
用于填充sig2
的值。稍后我们将展示如何计算这些传统哈希并更新签名:
138 rolling_hash = {
139 'r1': 0,
140 'r2': 0,
141 'r3': 0,
142 'rn': 0,
143 'rw': [0 for _ in range(CONTEXT_WINDOW)]
144 }
145 trad_hash1 = HASH_INIT
146 trad_hash2 = HASH_INIT
147 sig1 = ""
148 sig2 = ""
一旦我们初始化了哈希值,就可以开始按行遍历文件,如第 151 行所示。在第 153 行,我们使用文件中的最新字节和rolling_hash
字典来计算滚动哈希。记住,字典可以作为参数传递给函数并进行更新,而且更新后的值可以在函数外部保留,而无需返回。这使得与滚动哈希函数的接口更简洁。该函数仅返回计算出的滚动哈希值,之前已经讨论过,它的形式是一个整数。这个滚动哈希使我们能够通过字节流对数据的移动(或滚动)窗口进行哈希,并用于确定在文件中何时应向签名中添加字符:
151 for new_byte in complete_file:
152 # Calculate our rolling hash
153 rh = update_rolling_hash(new_byte, rolling_hash)
计算滚动哈希值后,我们需要更新传统哈希。这些哈希使用Fowler–Noll–Vo(FNV)哈希算法,其中我们将哈希的前一个值与固定的素数相乘,这个素数是我们常量之一,然后与新的数据字节进行异或运算(之前讨论过的^
)。与滚动哈希不同,这些哈希值会随着每个新字节的加入而不断增加,直到我们到达某个边界。
156 trad_hash1 = (trad_hash1 * FNV_PRIME) ^ new_byte
157 trad_hash2 = (trad_hash2 * FNV_PRIME) ^ new_byte
这些边界通过两个条件语句进行评估,每个哈希/签名对一个条件。第 161 行到 164 行的功能等同于第 165 行到 168 行,唯一不同的是使用了不同的传统哈希和签名。为了简化,我们先来分析第一个。
在第 161 行和 162 行(由于换行),我们有第一个条件语句,它判断我们的滚动哈希与reset_point
的乘积对reset_point
取模后,是否等于reset_point - 1
。我们还确保整体签名长度小于最大签名长度减去 1。如果这些条件满足,就表示我们已到达边界,并会将传统哈希值转换为签名字符,如第 163 行所示。在向签名中添加字符后,我们会将传统哈希值重置为初始值,这意味着下一个数据块的哈希值将从与前一个数据块相同的位置开始。
如前所述,这对于第二个签名是重复的,显著的例外是第二个签名正在修改reset_point
(将其乘以 2)和最大签名长度(将其除以 2)。第二个重置点的添加是为了满足 spamsum 签名较短的需求——默认 64 个字符。这意味着主签名可能被截断,文件的尾部可能只代表签名的一个字符。为了应对这个问题,spamsum 添加了第二个签名来生成一个代表更多(如果不是全部)文件的值。第二个签名实际上有一个reset_point
,其值是第一个签名的两倍:
159 # Check if our rolling hash reaches a reset point
160 # If so, update sig and reset trad_hash
161 if (rh % reset_point == reset_point - 1
162 and len(sig1) < SIGNATURE_LEN - 1):
163 sig1 += ALPHABET[trad_hash1 % 64]
164 trad_hash1 = HASH_INIT
165 if (rh % (reset_point * 2) == (reset_point * 2) - 1
166 and len(sig2) < (SIGNATURE_LEN / 2) - 1):
167 sig2 += ALPHABET[trad_hash2 % 64]
168 trad_hash2 = HASH_INIT
这是我们for
循环的结束;该逻辑将重复,直到我们到达文件末尾,尽管签名的长度将分别只增长到 63 和 31 个字符。在我们的for
循环退出后,我们会评估是否应该重新开始while
循环(从第 136 行开始)。如果我们的第一个签名少于 32 个字符,且我们的reset_point
不是默认值 3,我们希望这样做。如果签名过短,我们将reset_point
值减半并重新运行整个计算。这意味着我们在while
循环中需要每一分效率,因为我们可能会反复处理内容:
170 # If sig1 is too short, change block size and recalculate
171 if len(sig1) < SIGNATURE_LEN / 2 and reset_point > 3:
172 reset_point = reset_point // 2
173 logger.debug("Shortening block size to {}".format(
174 reset_point))
175 else:
176 done = True
如果我们的签名长度大于 32 个字符,我们退出while
循环并生成签名的最后一个字符。如果我们的滚动哈希值的乘积不等于零,我们会将最后一个字符添加到每个签名中,如第 180 行和第 181 行所示:
178 # Add any values from the tail to our hash
179 if rh != 0:
180 sig1 += ALPHABET[trad_hash1 % 64]
181 sig2 += ALPHABET[trad_hash2 % 64]
182
183 # Close the file and return our new signature
184 open_file.close()
185 return "{}:{}:{}".format(reset_point, sig1, sig2)
到此为止,我们可以关闭文件并返回完整的 spamsum/ssdeep 签名。这个签名有三个, hopefully 可识别的部分:
我们的reset_point
值
主要签名
次要签名
幸运的是,我们的最后一个函数比前一个要简单得多。这个函数提供了以一种支持的格式输出签名和文件名。在过去,我们编写了单独的函数来处理不同的格式,然而在这个情况下,我们选择将它们都放在同一个函数中。这个设计决策的原因是我们希望能够接近实时地提供结果,特别是当用户正在处理多个文件时。由于我们的日志被重定向到STDERR
,我们可以使用print()
函数将结果提供到STDOUT
。这样可以为用户提供灵活性,用户可以将输出通过管道传送到另一个程序(例如 grep),并对结果进行额外处理:
188 def output(sigval, filename, output_type='txt'):
189 """Write the output of the script in the specified format
190 :param sigval (str): Calculated hash
191 :param filename (str): name of the file processed
192 :param output_type (str): Formatter to use for output
193 """
194 if output_type == 'txt':
195 print("{} {}".format(sigval, filename))
196 elif output_type == 'json':
197 print(json.dumps({"sig": sigval, "file": filename}))
198 elif output_type == 'csv':
199 print("{},\"{}\"".format(sigval, filename))
200 else:
201 raise NotImplementedError(
202 "Unsupported output type: {}".format(output_type))
以下截图展示了我们如何在目录中的一组文件上生成模糊哈希并对输出进行后处理。在这种情况下,我们通过将STDERR
发送到/dev/null
来隐藏日志消息。然后,我们将输出通过管道传输到jq
,一个格式化和查询 JSON 数据的工具,来以漂亮的格式呈现输出:
https://github.com/OpenDocCN/freelearn-sec-pt3-zh/raw/master/docs/lrn-py-frsc-2e/img/4e98a7a3-2022-44aa-a1f5-95ca7c1e6af6.png
在这个输出中,你可能会注意到一些事情。首先,我们要强调的是文件没有按照字母顺序排列。这是因为我们的os.walk
函数在遍历路径时默认不会保持字母顺序。第二个问题是,尽管这些文件的大小相同,但它们的块大小不同。这意味着一些文件(包含随机内容)没有足够的块,因此签名太短。这意味着我们需要将块大小减半并重新尝试,这样当我们进入比较部分时,就能比较具有足够相似块的文件。另一方面,具有 3,072 个块的文件(file_2
和file_4
)的第二个签名可以部分与其他块大小为 6,144 的文件的第一个签名进行比较。
我们提供了这些测试文件供你使用和比较,以确认我们的实现与你的实现相符,并且与下一个脚本的输出一致。
这个脚本已经在 Python 2.7.15 和 3.7.1 版本中进行了测试,并且需要 ssdeep 版本 3.3 的第三方库。
正如你可能已经注意到的,之前的实现几乎慢得不可忍受。在这种情况下,最好利用像 C 语言这样的语言,它能够更快速地执行这个操作。幸运的是,spamsum 最初是用 C 语言编写的,后来通过 ssdeep 项目进一步扩展,依然是 C 语言实现的。ssdeep 项目提供的扩展之一是 Python 绑定。这些绑定允许我们仍然使用熟悉的 Python 函数调用,同时将繁重的计算任务卸载到已编译的 C 代码中。我们的下一个脚本涵盖了在 Python 模块中实现 ssdeep 库,以产生相同的签名并处理比较操作。
在这个模糊哈希的第二个示例中,我们将使用 ssdeep Python 库实现一个类似的脚本。这使我们能够利用 ssdeep 工具和 spamsum 算法,后者在数字取证和信息安全领域得到了广泛的应用和接受。这段代码将是大多数场景下模糊哈希的首选方法,因为它在资源使用上更高效,且能产生更准确的结果。这个工具在社区中得到了广泛的支持,许多 ssdeep 签名可以在线获取。例如,VirusShare 和 VirusTotal 网站上托管了来自 ssdeep 的哈希值。这些公开的信息可以用来检查已知的恶意文件,它们可能与主机机器上的可执行文件匹配或相似,而无需下载恶意文件。
ssdeep 的一个弱点是它仅提供匹配百分比的信息,并且无法比较具有显著不同块大小的文件。这可能是一个问题,因为 ssdeep 会根据输入文件的大小自动生成块大小。这个过程使得 ssdeep 比我们的脚本运行更高效,并且在扩展性方面表现得更好;然而,它并没有提供手动指定块大小的解决方案。我们可以拿之前的脚本并硬编码块大小,尽管这会引入其他(之前讨论过的)问题。
这个脚本与另一个脚本相同,唯一的不同是新增了 ssdeep 库的导入。要安装此库,请运行 pip install ssdeep==3.3
,如果失败,可以按照 pypi.python.org/pypi/ssdeep
上的文档运行 BUILD_LIB=1 pip install ssdeep==3.3
。这个库并不是 ssdeep 的开发者创建的,而是社区的另一位成员创建的,提供了 Python 与 C 基于的库之间需要的绑定。安装完成后,可以像第 7 行所示那样导入:
001 """Example script that uses the ssdeep python bindings."""
002 import argparse
003 import logging
004 import os
005 import sys
006
007 import ssdeep
这个版本的结构与我们之前的版本相似,尽管我们将所有计算工作交给了 ssdeep
库。虽然我们可能缺少了哈希和比较函数,但我们仍然以非常相似的方式使用我们的 main
和 output
函数:
047 def main():
...
104 def output():
我们的程序流程与之前的迭代相似,尽管它缺少了我们在上一次迭代中开发的内部哈希函数。如流程图所示,我们仍然在 main()
函数中调用 output()
函数:
https://github.com/OpenDocCN/freelearn-sec-pt3-zh/raw/master/docs/lrn-py-frsc-2e/img/54931c15-04a4-4b6a-b142-6f5b246441e5.png
我们的参数解析和日志配置几乎与之前的脚本相同。主要的区别是我们引入了一个新的文件路径参数,并重命名了接受文件或文件夹的参数。在第 134 行,我们再次创建了 argparse
对象来处理我们的两个位置参数和两个可选的输出格式及日志标志。该代码块的其余部分与之前的脚本一致,唯一的区别是我们重命名了日志文件:
134 if __name__ == '__main__':
135 parser = argparse.ArgumentParser(
136 description=__description__,
137 epilog='Built by {}. Version {}'.format(
138 ", ".join(__authors__), __date__),
139 formatter_class=argparse.ArgumentDefaultsHelpFormatter
140 )
141 parser.add_argument('KNOWN',
142 help='Path to known file to use to compare')
143 parser.add_argument('COMPARISON',
144 help='Path to file or directory to compare to known. '
145 'Will recurse through all sub directories')
main()
函数这个 main()
函数与之前的脚本非常相似,虽然它添加了一些额外的代码行,因为我们增加了一些功能。该脚本从检查输出类型是否为有效格式开始,如第 56 行到第 62 行所示。然后,我们在第 63 行添加了另一个条件,使我们能够打印 CSV 表头行,因为这个输出比上一个版本更复杂:
047 def main(known_file, comparison, output_type):
048 """
049 The main function handles the main operations of the script
050 :param known_file: path to known file
051 :param comparison: path to look for similar files
052 :param output_type: type of output to provide
053 :return: None
054 """
055
056 # Check output formats
057 if output_type not in OUTPUT_OPTS:
058 logger.error(
059 "Unsupported output format '{}' selected. Please "
060 "use one of {}".format(
061 output_type, ", ".join(OUTPUT_OPTS)))
062 sys.exit(2)
063 elif output_type == 'csv':
064 # Special handling for CSV headers
065 print('"similarity","known_file","known_hash",'
066 '"comp_file","comp_hash"')
现在我们已经处理了输出格式的验证,让我们转向文件比较部分。首先,我们会获取已知文件和比较路径的绝对路径,以便与之前的脚本保持一致。然后,在第 73 行,我们检查已知文件是否存在。如果存在,我们会在第 78 行计算 ssdeep 哈希值。这个计算完全由 ssdeep 处理;我们需要做的只是提供一个有效的文件路径给hash_from_file()
方法。此方法返回一个包含 ssdeep 哈希值的字符串,结果与我们在之前脚本中的fuzz_file()
函数相同。这里的主要区别是通过使用高效的 C 代码在ssdeep
模块中运行,从而提升了速度:
068 # Check provided file paths
069 known_file = os.path.abspath(known_file)
070 comparison = os.path.abspath(comparison)
071
072 # Generate ssdeep signature for known file
073 if not os.path.exists(known_file):
074 logger.error("Error - path {} not found".format(
075 comparison))
076 sys.exit(1)
077
078 known_hash = ssdeep.hash_from_file(known_file)
现在我们有了已知的哈希值,可以评估比较路径。如果该路径是一个目录,如第 81 行所示,我们将遍历该文件夹及其子文件夹,寻找要处理的文件。在第 86 行,我们生成这个比较文件的哈希值,方法与已知文件相同。下一行引入了compare()
方法,允许我们提供两个哈希值进行评估。该比较方法返回一个介于 0 到 100(包括 0 和 100)之间的整数,表示这两个文件内容相似的可信度。然后,我们将所有部分(包括文件名、哈希值和结果相似度)提供给我们的输出函数,并附上我们的格式化规范。这段逻辑会一直进行,直到我们递归处理完所有文件:
080 # Generate and test ssdeep signature for comparison file(s)
081 if os.path.isdir(comparison):
082 # Process files in folders
083 for root, _, files in os.walk(comparison):
084 for f in files:
085 file_entry = os.path.join(root, f)
086 comp_hash = ssdeep.hash_from_file(file_entry)
087 comp_val = ssdeep.compare(known_hash, comp_hash)
088 output(known_file, known_hash,
089 file_entry, comp_hash,
090 comp_val, output_type)
我们的下一个条件处理相同的操作,但只针对一个文件。如你所见,它使用与目录操作相同的hash_from_file()
和compare()
函数。一旦所有的值都被分配,我们会以相同的方式将它们传递给我们的output()
函数。我们的最终条件处理输入错误的情况,通知用户并退出:
092 elif os.path.isfile(comparison):
093 # Process a single file
094 comp_hash = ssdeep.hash_from_file(comparison)
095 comp_val = ssdeep.compare(known_hash, comp_hash)
096 output(known_file, known_hash, file_entry, comp_hash,
097 comp_val, output_type)
098 else:
099 logger.error("Error - path {} not found".format(
100 comparison))
101 sys.exit(1)
output()
函数我们的最后一个函数是output()
;这个函数接收多个值,并将它们整齐地呈现给用户。就像我们之前的脚本一样,我们将支持 TXT、CSV 和 JSON 输出格式。为了展示这种类型的函数的不同设计,我们将使用特定格式的条件来构建一个模板。然后,使用这个模板以格式化的方式打印内容。如果将来我们打算将输出函数(在本例中是print()
)更换为其他输出函数,这种方法就会非常有用。
104 def output(known_file, known_hash, comp_file, comp_hash, comp_val,
105 output_type='txt'):
106 """Write the output of the script in the specified format
107 :param sigval (str): Calculated hash
108 :param filename (str): name of the file processed
109 :param output_type (str): Formatter to use for output
110 """
首先,我们需要将我们的整数值comp_val
转换为字符串,以便与模板兼容。在第 112 行完成此操作后,我们将构建文本格式的模板。文本格式让我们能够自由地以适合视觉审查的方式展示数据。以下是一个选项,但你可以根据需要进行修改。
在第 113 和第 114 行,我们通过使用大括号包围占位符标识符来构建带有命名占位符的模板。跳到第 127 到第 132 行,你可以看到当我们调用 msg.format()
时,我们通过与占位符相同的名称提供我们的值作为参数。这告诉 format()
方法应该用哪个值填充哪个占位符。命名占位符的主要优势在于,我们在调用 format()
方法时,可以按任何顺序安排值,甚至可以让模板格式中的元素在不同位置:
111 comp_val = str(comp_val)
112 if output_type == 'txt':
113 msg = "{similarity} - {known_file} {known_hash} | "
114 msg += "{comp_file} {comp_hash}"
接下来是我们的 JSON 格式化。json.dumps()
方法是输出字典为 JSON 内容的首选方式,尽管在这个例子中我们将探讨如何通过其他方式实现类似的目标。通过使用相同的模板方法,我们构建了一个字典,其中键是固定的字符串,值是占位符。由于模板语法使用单个大括号来表示占位符,我们必须使用第二个大括号来转义单个大括号。这意味着我们的整个 JSON 对象被额外的大括号包裹——别担心,只有两个大括号中的一个会在打印时显示:
115 elif output_type == 'json':
116 msg = '{{"similarity": {similarity}, "known_file": '
117 msg += '"{known_file}", "known_hash": "{known_hash}", '
118 msg += '"comparison_file": "{comp_file}", '
119 msg += '"comparison_hash": "{comp_hash}"}}'
最后,我们有了我们的 CSV 输出,再次使用了命名占位符模板。正如你可能注意到的,我们将每个值都用双引号包围,以确保值中的任何逗号不会导致格式问题:
120 elif output_type == 'csv':
121 msg = '"{similarity}","{known_file}","{known_hash}"'
122 msg += '"{comp_file}","{comp_hash}"'
我们的 msg
变量在此处出现在多行的唯一原因是为了换行。除此之外,没有任何东西阻止你将整个格式模板放在一行。最后,我们有了 else
条件,它会捕捉到任何不支持的输出类型:
123 else:
124 raise NotImplementedError(
125 "Unsupported output type: {}".format(output_type))
在条件语句后,我们打印出已应用值的模板,以替代占位符。如果我们想支持一种新的或替代的格式,我们可以在上方添加新的条件,并创建所需的模板,而无需重新实现这个 print()
函数:
127 print(msg.format(
128 similarity=comp_val,
129 known_file=known_file,
130 known_hash=known_hash,
131 comp_file=comp_file,
132 comp_hash=comp_hash))
现在我们可以运行脚本,例如,提供 test_data/file_3
作为已知文件,并将整个 test_data/
文件夹作为比较集。再次使用 JSON 输出,我们可以在接下来的两个截图中看到我们模板化的结果:
https://github.com/OpenDocCN/freelearn-sec-pt3-zh/raw/master/docs/lrn-py-frsc-2e/img/34112c19-7d80-433e-a0c4-c0140a7d8d8a.png
以下是我们继续的输出:
https://github.com/OpenDocCN/freelearn-sec-pt3-zh/raw/master/docs/lrn-py-frsc-2e/img/125e09b2-8cf2-414b-bdef-fb5f225d80f0.png
你还会注意到,使用 ssdeep
库的这个脚本,产生了与我们之前实现相同的签名!需要注意的一点是这两个脚本之间的速度差异。通过使用工具时间,我们运行了两个脚本,对相同文件夹中的这六个文件进行了处理。正如接下来的截图所示,使用我们导入的 ssdeep
模块,性能有了显著提升:
https://github.com/OpenDocCN/freelearn-sec-pt3-zh/raw/master/docs/lrn-py-frsc-2e/img/25a60288-e966-45e6-b4ea-4e190365c2b0.png
你已经创建了一个实现 spamsum 算法的脚本,用来生成与 ssdeep 兼容的哈希值!接下来,还有一些额外的挑战等着你。
首先,我们提供了六个示例文件,这些文件位于前面提到的test_data/
目录中。这些文件可用于确认你是否获得了与打印的哈希值相同的值,并且可以让你进行一些额外的测试。file_1
、file_2
和file_3
文件是我们的原始文件,而附加了a
的文件是原始文件的修改版本。随附的README.md
文件包含了我们所做的修改说明,简而言之,我们进行了以下操作:
file_1
将部分文件内容移至文件的后半部分
file_2
在文件的前半部分插入内容
file_3
移除文件的开头部分
我们鼓励你进行额外的测试,了解 ssdeep 如何应对不同类型的修改。随意修改原始文件并与社区分享你的发现!
另一个挑战是研究 ssdeep 或 spamsum 代码,了解它如何处理比较组件,目标是将其加入到第一个脚本中。
我们还可以开发代码来展示,例如,Word 文档的内容,并生成该文档内容的 ssdeep 哈希,而不是二进制文件的哈希。这可以应用于其他类型的文件,不仅限于文本内容。例如,如果我们发现某个可执行文件被打包了,我们可能还想生成解包后字节内容的模糊哈希。
最后,市面上还有其他相似度分析工具。举例来说,sdhash
工具采用了一种不同的方法来识别两个文件之间的相似性。我们建议你花些时间使用这个工具,将其应用于你和我们提供的测试数据,看看它如何应对不同类型的修改和变化。有关sdhash
的更多信息,请访问网站:roussev.net/sdhash/sdhash.html
。
Kornblum, J. (2006). 使用上下文触发分段哈希识别几乎相同的文件,数字调查,91-97. 2015 年 10 月 31 日检索自dfrws.org/2006/proceedings/12-Kornblum.pdf
Stevens, M. Karpmanm P. Peyrin, T. (2015),研究人员呼吁:行业标准 SHA-1 应尽快撤回,2015 年 10 月 31 日检索自ee788fc4-a-62cb3a1a-s-sites.googlegroups.com/site/itstheshappening/shappening_PR.pdf
哈希是 DFIR 工作流程中的一个关键组成部分。虽然大多数哈希的应用场景集中在完整性检查上,但相似性分析的使用使我们能够了解更多关于近似匹配和文件关系的信息。这个过程可以为恶意软件检测、识别未授权位置的受限文档以及仅基于内容发现紧密相关的项目提供深入的见解。通过使用第三方库,我们能够利用 C 语言背后的强大功能,同时享受 Python 解释器的灵活性,构建出既适合用户又适合开发者的强大工具。这个项目的代码可以从 GitHub 或 Packt 下载,具体信息见前言。
模糊哈希是一种元数据的形式,或者说是关于数据的数据。元数据还包括嵌入的属性,如文档编辑时间、图像地理位置信息和源应用程序。在下一章中,您将学习如何从各种文件中提取嵌入的元数据,包括图像、音频文件和办公文档。
元数据,或描述数据的数据,是调查员可以利用的强大工具,帮助回答调查问题。广义来说,元数据可以通过检查文件系统和嵌入的元素来找到。文件权限、MAC 时间戳和文件大小记录在文件系统级别。然而,对于特定的文件类型,如 JPEG,额外的元数据会嵌入到文件本身中。
嵌入式元数据更特定于相关对象。这个嵌入式元数据可以提供额外的时间戳、特定文档的作者,甚至是照片的 GPS 坐标。像 Phil Harvey 的 ExifTool 这样的完整软件应用程序存在,用于从文件中提取嵌入式元数据,并将其与文件系统元数据合并。
本章将涵盖以下主题:
使用第一方和第三方库从文件中提取元数据
理解 可交换图像文件格式(EXIF)、ID3 和 Microsoft Office 嵌入式元数据
学习构建框架以促进脚本的快速开发和集成
本章的代码在 Python 2.7.15 和 Python 3.7.1 上开发和测试。
框架对于大型 Python 项目非常有用。我们在第六章中曾提到过 UserAssist
脚本是一个框架,从二进制文件中提取数据;然而,它实际上并不完全符合这个模型。我们将构建的框架将有一个抽象的顶层,这个顶层将作为程序的控制器。这个控制器将负责执行插件和写入程序。
插件是包含在单独脚本中的代码,它为框架添加特定功能。开发完成后,插件应该能够通过几行代码轻松集成到现有框架中。插件还应该能够执行独立的功能,而不需要修改控制器来操作。例如,我们将编写一个插件专门处理 EXIF 元数据,另一个插件处理 Office 元数据。框架模型的一个优点是,它允许我们以有组织的方式将多个插件组合在一起,并为共同的目标执行它们,例如从文件中提取各种类型的嵌入式元数据。
构建框架需要一些前瞻性思维和规划。规划和测试你希望在框架中使用的数据结构类型是至关重要的。不同的数据结构适合不同的任务。考虑框架将处理的输入和输出类型,并以此为依据,选择合适的数据类型。发现更优的数据结构后重新编写框架可能是一个令人沮丧且耗时的任务。
如果没有这一步,框架可能会迅速变得无法控制,变成一团糟。想象一下每个插件都需要自己独特的参数,更糟糕的是,返回不同类型的数据,需要特殊处理。例如,一个插件可能返回一个字典的列表,而另一个插件可能返回一个字典中的字典。你的代码大部分会写成将这些数据类型转换为一个通用格式,以供编写器使用。为了保持理智,我们建议创建标准化的输入输出,每个插件都应遵循。这将使你的框架更容易理解,并避免不必要的转换错误,从而使其更稳定。
编写器从插件获取处理后的数据,并将其写入输出文件。我们熟悉的一种编写器是 CSV 编写器。在前面的章节中,我们的 CSV 编写器将处理后的数据输入并写入文件。在更大的项目中,比如这个项目,我们可能会有多种类型的编写器用于输出。例如,在本章中,我们将开发一个 Google Earth KML 编写器,以绘制我们从嵌入式 EXIF 元数据中提取的 GPS 数据。
EXIF 元数据是一种标准,用于图像和音频文件标签,这些标签由设备和应用程序创建。最常见的,这种嵌入式元数据与 JPEG 文件相关联。然而,EXIF 元数据也存在于 TIFF、WAV 和其他文件中。在 JPEG 文件中,EXIF 元数据可以包含用于拍摄照片的技术相机设置,如快门速度、光圈值和 ISO 值。这些可能对检查员没有直接用处,但包含照片的制造商、型号和 GPS 位置的标签可以用于将某个人与犯罪联系起来。每个元素都与一个标签相关联。例如,制造商元数据是 EXIF 标签 271 或0x010F
。标签的完整列表可以在www.exiv2.org/tags.html
找到。
EXIF 元数据存储在 JPEG 图像的开头,如果存在,它位于字节偏移量 24 处。EXIF 头以十六进制0x45786966
开始,这是“Exif”在 ASCII 中的表示。以下是 JPEG 图像前 52 个字节的十六进制转储:
https://github.com/OpenDocCN/freelearn-sec-pt3-zh/raw/master/docs/lrn-py-frsc-2e/img/33d72819-5c38-454c-a051-a310b140a4f1.png
请注意,从偏移量 24 开始的 EXIF 头。跟随其后的十六进制0x4D4D
代表摩托罗拉或大端字节对齐。位于字节偏移 40 的0x010F
标签 ID 是 EXIF 的Make
元数据标签。每个标签由四个组件组成:
字节偏移量 | 名称 | 描述 |
---|---|---|
0-1 | ID | 代表特定 EXIF 元数据元素的标签 ID |
2-3 | 类型 | 数据类型(整数、字符串等) |
4-7 | 长度 | 数据的长度 |
8-11 | 偏移量 | 从字节对齐值的偏移量 |
在前面的表格中,Make
标签的数据类型为 2,对应于 ASCII 字符串,长度为 6 字节,位于字节对齐值 0x4D4D
之后 2206 字节的位置。第二个截图显示了从文件开始 2206 字节位置开始的 52 字节数据切片。在这里,我们可以看到 Nokia,这是拍摄该照片时使用的手机品牌,作为一个长 6 字节的 ASCII 字符串:
https://github.com/OpenDocCN/freelearn-sec-pt3-zh/raw/master/docs/lrn-py-frsc-2e/img/58d35dd2-900d-4b52-96b2-a43e6b902e3d.png
如果我们有兴趣,也可以使用 struct
解析头部并获取相关的 EXIF 元数据。幸运的是,第三方 Python Imaging Library(PIL)模块已经支持 EXIF 元数据,并且使得这项任务变得更加简便。
Pillow(版本 5.3.0)是一个活跃维护的 Python 图像库的分支,是一个功能强大的模块,可以存档、显示和处理图像文件。可以在 www.pillow.readthedocs.io
阅读该模块的详细说明。可以使用 pip
如下安装此库:
pip install pillow==5.3.0
PIL 提供一个名为 _getexif()
的函数,它返回一个标签及其值的字典。标签以十进制格式存储,而不是十六进制格式。将大端格式的 0x010F
解释为十进制值 271 对应于 Make
标签。我们无需通过 struct
繁琐地操作,只需简单地查询某个标签是否存在,如果存在,则处理其值:
>>> from PIL import Image
>>> image = Image.open('img_42.jpg')
>>> exif = image._getexif()
>>> if 271 in exif.keys():
... print('Make:', exif[271])
...
Make: Nokia
ID3 元数据容器通常与 MP3 文件相关联。嵌入结构有两个版本:ID3v1 和 ID3v2。ID3v1 版本是文件的最后 128 字节,其结构与更新格式不同。我们将重点介绍的新版位于文件的开头,长度是可变的。
与 EXIF 标签相比,ID3 标签具有更简单的结构。前 16 字节均匀地分配在标签 ID 和元数据的长度之间。之后是元数据本身。以下截图显示了一个 MP3 文件的前 144 字节:
https://github.com/OpenDocCN/freelearn-sec-pt3-zh/raw/master/docs/lrn-py-frsc-2e/img/a6599dbf-35ca-49ae-a9d0-c880ba3ca339.png
MP3 文件的文件签名是 ASCII ID3。紧接着签名后,我们可以看到不同的标签,如 TP1、TP2 和 TCM。这些分别是艺术家、乐队和作曲家的元数据标签。紧接在 TP1 后的 8 字节表示由十六进制 0x0B
或 11 表示的长度。接下来是 2 字节的缓冲区,之后是曾用名为 The Artist
的艺术家的数据。The Artist
长度为 10 字节,并且在数据前加上一个空字节(0x00),总长度为 11 字节。我们将使用名为 Mutagen 的模块来加载文件并读取任何存在的 ID3 标签。
一些 MP3 文件可能没有嵌入 ID3 元数据。在这种情况下,我们在前面截图中看到的标签可能并不存在。
Mutagen(版本 1.41.1)能够读取和写入不同的音频元数据格式。Mutagen 支持多种嵌入式音频格式,如 ASF、FLAC、M4A 和 MP3(ID3)。该模块的完整文档可以在www.mutagen.readthedocs.io
找到。我们可以通过以下方式使用pip
安装该模块:
pip install mutagen==1.41.1
使用 Mutagen 非常简单。我们需要通过打开 MP3 文件创建一个 ID3 对象,然后像使用 PIL 一样,在字典中查找特定的标签,如下所示:
>>> from mutagen import id3
>>> id = id3.ID3('music.mp3')
>>> if 'TP1' in id.keys():
... print('Artist:', id['TP1'])
...
Artist: The Artist
随着 Office 2007 的发布,微软为其 Office 产品引入了一种新的专有格式,如.docx
、.pptx
和.xlsx
文件。这些文档实际上是一个包含 XML 和二进制文件的压缩目录。这些文档包含大量嵌入的元数据,存储在文档中的 XML 文件中。我们将要查看的两个 XML 文件是core.xml
和app.xml
,它们存储不同类型的元数据。
core.xml
文件存储与文档相关的元数据,例如作者、修订号以及最后修改文档的人。app.xml
文件存储更具体的文件内容相关的元数据。例如,Word 文档存储页面、段落、行、单词和字符计数,而 PowerPoint 演示文稿存储与幻灯片、隐藏幻灯片和注释计数等相关的信息。
要查看这些数据,使用你选择的归档工具解压现有的 2007 或更高版本的 Office 文档。你可能需要在文件末尾添加.zip
扩展名,以便使用你选择的工具解压该归档文件。以下是解压后的 Word 文档内容的截图:
https://github.com/OpenDocCN/freelearn-sec-pt3-zh/raw/master/docs/lrn-py-frsc-2e/img/25a35a59-7a76-4ff5-977a-013e38a4242d.png
在docProps
文件夹中,我们可以看到两个 XML 文件,它们包含与我们特定 Word 文档相关的元数据。Word 目录包含实际的 Word 文档本身,存储在document.xml
中,以及任何插入的媒体,存储在媒体子目录中。现在,让我们来看一下core.xml
文件:
https://github.com/OpenDocCN/freelearn-sec-pt3-zh/raw/master/docs/lrn-py-frsc-2e/img/115bd8be-b47b-45cd-b39a-0e4e90401a78.png
在第四章,处理序列化数据结构中,我们讨论了序列化数据,并提到 XML 是一种流行的数据序列化格式。XML 基于指令、命名空间和标签的概念,与另一种流行的标记语言 HTML 类似。大多数 XML 文件以头部指令开始,详细说明版本、编码和解析器的任何指令。
core.xml
文件还包含五个命名空间,这些命名空间只在文件开始时声明一次,之后通过它们分配的命名空间变量进行引用。命名空间的主要目的是避免名称冲突,它们是通过xmlns
属性创建的。
在命名空间之后,我们有各种标签,类似于 HTML,如标题、主题和创建者。我们可以使用 XML 解析器,如 lxml
,来遍历这些标签并处理它们。
lxml
(版本 4.2.5)是一个第三方模块,提供了对 C 语言 libxml2
和 libxslt
库的 Python 绑定。这个模块由于其速度快,是非常流行的 XML 解析器,并且可以用来解析 HTML 文件。我们将使用该模块遍历每个 child
标签,并打印出我们感兴趣的内容。该库的完整文档可以在 www.lxml.de
找到。再次提醒,使用 pip
安装库非常简单:
pip install lxml==4.2.5
让我们来看看如何在交互式提示符中遍历 core.xml
文件。etree
或元素树 API 提供了一种简单的机制来遍历 XML 文件中的子元素。首先,我们需要将 XML 文件解析为元素树。接下来,我们获取树中的根元素。通过根元素,我们可以使用 root.iter()
函数遍历每个子元素,并打印出标签和文本值。请注意,标签包含了完整展开的命名空间。在短短几行代码中,我们就可以轻松使用 lxml
解析基本的 XML 文件:
>>> import lxml.etree.ElementTree as ET
>>> core = ET.parse('core.xml')
>>> root = core.getroot()
>>> for child in root.iter():
... print(child.tag, ':', child.text)
...
{http://purl.org/dc/elements/1.1/}title : Metadata Title
{http://purl.org/dc/elements/1.1/}subject : Digital Forensics
{http://purl.org/dc/elements/1.1/}creator : Preston Miller & Chapin Bryce
...
现在我们已经理解了框架的概念以及我们所处理的数据类型,我们可以详细探讨框架实现的具体内容。与流程图不同,我们使用高级图示来展示脚本之间如何相互作用:
https://github.com/OpenDocCN/freelearn-sec-pt3-zh/raw/master/docs/lrn-py-frsc-2e/img/41d06044-4d8e-46f0-8eb2-e042ff464511.png
该框架将由 metadata_parser.py
脚本控制。这个脚本将负责启动我们的三个插件脚本,然后将返回的数据传递给相应的写入插件。在处理过程中,插件会调用处理器来帮助验证数据或执行其他处理功能。我们有两个写入插件,一个用于 CSV 输出,另一个用于使用 Google Earth 的 KML 格式绘制带地理标记的数据。
每个插件将以一个单独的文件作为输入,并将解析后的元数据标签存储在字典中。然后,这个字典会返回到 metadata_parser.py
中,并附加到一个列表中。一旦所有输入文件处理完毕,我们将这些字典列表发送给写入插件。我们使用 csv
模块中的 DictWriter
来将字典输出写入到 CSV 文件中。
类似于 第六章,从二进制文件中提取文档,我们将有多个 Python 目录来以逻辑的方式组织我们的代码。为了使用这些包,我们需要通过 __init__.py
脚本使目录可搜索,然后在代码中导入该目录:
|-- metadata_parser.py
|-- plugins
|-- __init__.py
|-- exif_parser.py
|-- id3_parser.py
|-- office_parser.py
|-- processors
|-- __init__.py
|-- utility.py
|-- writers
|-- __init__.py
|-- csv_writer.py
|-- kml_writer.py
metadata_parser.py
脚本包含一个单独的函数main()
,位于第 45 行,该函数负责协调我们插件和写入器之间的逻辑。在脚本顶部,我们调用了本章将要使用的导入内容。在第 8 行和第 9 行,我们特别导入了我们创建的插件和写入器目录,如下所示:
001 """EXIF, ID3, and Office Metadata parser."""
002 from __future__ import print_function
003 import argparse
004 import os
005 import sys
006 import logging
007
008 import plugins
009 import writers
...
045 def main(input_dir, output_dir):
在第 133 行,我们为程序设置参数。此脚本接受两个位置参数,一个输入目录和一个输出目录,还有一个可选的日志参数,用于更改日志文件的目录和名称。第 142 到 154 行专注于设置日志,和前面章节一样。代码如下:
131 if __name__ == '__main__':
132
133 parser = argparse.ArgumentParser(description=__description__,
134 epilog='Developed by ' +
135 __author__ + ' on ' +
136 __date__)
137 parser.add_argument('INPUT_DIR', help='Input Directory')
138 parser.add_argument('OUTPUT_DIR', help='Output Directory')
139 parser.add_argument('-l', help='File path of log file.')
140 args = parser.parse_args()
141
142 if args.l:
143 if not os.path.exists(args.l):
144 os.makedirs(args.l)
145 log_path = os.path.join(args.l, 'metadata_parser.log')
146 else:
147 log_path = 'metadata_parser.log'
148 logging.basicConfig(filename=log_path, level=logging.DEBUG,
149 format=('%(asctime)s | %(levelname)s | '
150 '%(message)s'), filemode='a')
151
152 logging.info('Starting Metadata_Parser')
153 logging.debug('System ' + sys.platform)
154 logging.debug('Version ' + sys.version)
在第 156 行,如果提供的输出目录不存在,我们将创建该输出目录。这个输出目录是通过makedirs()
函数创建的。该函数接受一个表示文件路径的字符串,并创建目录以及路径中不存在的任何中间目录。在第 159 行,我们检查提供的输入是否是一个目录并且是否存在。如果是的话,在第 161 行,调用main()
函数,并传入输入和输出目录的参数。如果输入不存在或不是一个目录,我们将记录并打印错误,并以状态码 1 退出。我们有以下代码:
156 if not os.path.exists(args.OUTPUT_DIR):
157 os.makedirs(args.OUTPUT_DIR)
158
159 if(os.path.exists(args.INPUT_DIR) and
160 os.path.isdir(args.INPUT_DIR)):
161 main(args.INPUT_DIR, args.OUTPUT_DIR)
162 else:
163 msg =('Supplied input directory doesn't exist or is'
164 'not a directory')
165 print('[-]', msg)
166 logging.error(msg)
167 sys.exit(1)
main()
函数控制我们的框架在第 57 到 59 行,我们创建了列表,用来存储从插件调用中返回的字典。但在我们调用插件之前,需要从用户的输入目录参数生成文件列表。我们在第 65 行使用了os.walk()
函数,这在前面的章节中已经使用过。一个新的参数topdown
被传递到我们的目录遍历循环中。这使我们能够控制迭代的流程,从顶级目录到最深层级逐步遍历。虽然这是默认行为,但也可以指定以确保预期行为。对于每个文件,我们需要使用join()
函数将其与根路径连接,生成文件的完整路径:
045 def main(input_dir, output_dir):
046 """
047 The main function generates a file listing, sends files to be
048 processed, and output written.
049 :param input_dir: The input directory to scan for suported
050 embedded metadata containing files
051 :param output_dir: The output directory to write metadata
052 reports to
053 :return: Nothing.
054 """
055 # Create lists to store each supported embedded metadata
056 # before writing to output
057 exif_metadata = []
058 office_metadata = []
059 id3_metadata = []
060
061 # Walk through list of files
062 msg = 'Generating file listing and running plugins.'
063 print('[+]', msg)
064 logging.info(msg)
065 for root, subdir, files in os.walk(input_dir, topdown=True):
066 for file_name in files:
067 current_file = os.path.join(root, file_name)
068 ext = os.path.splitext(current_file)[1].lower()
最后,在第 68 行,我们使用os.path.splitext()
函数将扩展名与完整路径分离。splitext()
函数接受一个表示文件路径的字符串,并返回一个列表,列表的第一个元素是路径,第二个元素是扩展名。我们也可以使用split()
函数,通过分割点来拆分路径,并获取新列表中的最后一个元素:
>>> '/path/to/metadata_image.jpg'.split('.')[-1]
jpg
在获得 current_file
之后,我们会在第 71、83 和 96 行查看其扩展名,以确定我们现有的插件是否合适。如果文件是 JPEG 图像,那么第 71 行的条件将评估为 True
。在第 73 行,我们调用我们的 exif_parser()
函数,该函数位于插件子目录中的 exif_parser.py
脚本中。因为我们只匹配扩展名,所以这个函数调用被包装在 try
和 except
中,以处理由于文件签名不匹配而在 exif_parser()
函数中引发错误的情况:
070 # PLUGINS
071 if ext == '.jpeg' or ext == '.jpg':
072 try:
073 ex_metadata, exif_headers = plugins.exif_parser.exif_parser(
074 current_file)
075 exif_metadata.append(ex_metadata)
076 except TypeError:
077 print(('[-] File signature mismatch. '
078 'Continuing to next file.'))
079 logging.error((('JPG & TIFF File Signature '
080 'check failed for ' + current_file)))
081 continue
如果函数没有引发错误,它将返回该特定文件的 EXIF 元数据以及 CSV 写入器的头信息。在第 75 行,我们将 EXIF 元数据结果附加到我们的 exif_metadata
列表,并继续处理其他输入文件:
083 elif ext == '.docx' or ext == '.pptx' or ext == '.xlsx':
084 try:
085 of_metadata, office_headers = plugins.office_parser.office_parser(
086 current_file)
087 office_metadata.append(of_metadata)
088 except TypeError:
089 print(('[-] File signature mismatch. '
090 'Continuing to next file.'))
091 logging.error((('DOCX, XLSX, & PPTX File '
092 'Signature check failed for ' + current_file))
093 )
094 continue
095
096 elif ext == '.mp3':
097 try:
098 id_metadata, id3_headers = plugins.id3_parser.id3_parser(
099 current_file)
100 id3_metadata.append(id_metadata)
101 except TypeError:
102 print(('[-] File signature mismatch. '
103 'Continuing to next file.'))
104 logging.error((('MP3 File Signature check '
105 'failed for ' + current_file)))
106 continue
请注意,其他两个插件采用了相似的结构。所有插件只接受一个输入 current_file
,并返回两个输出值:元数据字典和 CSV 头信息。仅需要八行代码来正确调用并存储每个插件的结果。还需要多写几行代码,将存储的数据写入输出文件。
一旦遍历了所有文件,我们就可以开始写入必要的输出。在第 113、119 和 123 行,我们检查元数据列表中是否包含字典。如果包含,我们会调用 csv_writer()
函数,该函数位于 writers
子目录中的 csv_writer.py
脚本中。对于 EXIF 元数据,我们还会在第 114 行调用 kml_writer()
函数以绘制 GPS 坐标:
108 # WRITERS
109 msg = 'Writing output to ' + output_dir
110 print('[+]', msg)
111 logging.info(msg)
112
113 if len(exif_metadata) > 0:
114 writers.kml_writer.kml_writer(exif_metadata,
115 output_dir, 'exif_metadata.kml')
116 writers.csv_writer.csv_writer(exif_metadata, exif_headers,
117 output_dir, 'exif_metadata.csv')
118
119 if len(office_metadata) > 0:
120 writers.csv_writer.csv_writer(office_metadata,
121 office_headers, output_dir, 'office_metadata.csv')
122
123 if len(id3_metadata) > 0:
124 writers.csv_writer.csv_writer(id3_metadata, id3_headers,
125 output_dir, 'id3_metadata.csv')
126
127 msg = 'Program completed successfully -- exiting..'
128 print('[*]', msg)
129 logging.info(msg)
这完成了我们框架的控制器逻辑。主要的处理发生在每个独立的插件文件中。现在,让我们看看第一个插件。
exif_parser
插件是我们首先开发的插件,由于依赖 PIL 模块,它相对简单。该脚本中有三个函数:exif_parser()
、get_tags()
和 dms_to_decimal()
。第 39 行的 exif_parser()
函数是该插件的入口点,它唯一的输入是一个表示文件名的字符串。此函数主要充当插件的协调逻辑。
第 62 行的 get_tags()
函数负责解析输入文件的 EXIF 标签。最后,第 172 行的 dms_to_decimal()
函数是一个小助手函数,负责将 GPS 坐标转换为十进制格式。请看以下代码:
001 from datetime import datetime
002 import os
003 from time import gmtime, strftime
004
005 from PIL import Image
006
007 import processors
...
039 def exif_parser():
...
062 def get_tags():
...
172 def dms_to_decimal():
exif_parser()
函数该函数有三个用途:验证输入文件、提取标签并将处理后的数据返回给 metadata_parser.py
。为了验证输入值,我们会根据已知签名评估其文件签名。我们不依赖文件的扩展名,因为它可能不正确,而是检查签名,以避免其他错误来源。
检查文件签名,有时被称为文件的魔术数字,通常是通过检查文件的前几个字节,并将其与该文件类型的已知签名进行比较。Gary Kessler 在他的网站上有一份详细的文件签名列表,网址是www.garykessler.net/library/file_sigs.html
:
039 def exif_parser(filename):
040 """
041 The exif_parser function confirms the file type and sends it
042 to be processed.
043 :param filename: name of the file potentially containing EXIF
044 metadata.
045 :return: A dictionary from get_tags, containing the embedded
046 EXIF metadata.
047 """
在第 50 行,我们创建了一个已知的 JPEG 图像文件签名列表。在第 52 行,我们调用了位于processors
子目录下的utility.py
脚本中的check_header()
函数。如果文件的头部与提供的已知签名之一匹配,该函数将返回True
:
049 # JPEG signatures
050 signatures = ['ffd8ffdb','ffd8ffe0', 'ffd8ffe1', 'ffd8ffe2',
051 'ffd8ffe3', 'ffd8ffe8']
052 if processors.utility.check_header(
053 filename,signatures, 4) == True:
054 return get_tags(filename)
055 else:
056 print(('File signature doesn't match known '
057 'JPEG signatures.'))
058 raise TypeError(('File signature doesn't match '
059 'JPEG object.'))
如果我们确实拥有一个合法的 JPEG 文件,我们将在第 54 行调用并返回get_tags()
函数的结果。或者,如果check_header()
返回False
,则说明存在不匹配的情况,我们会向父脚本metadata_parser.py
引发一个TypeError
异常,以便适当处理这种情况。
get_tags()
函数get_tags()
函数借助 PIL 模块,从我们的 JPEG 图像中解析 EXIF 元数据标签。在第 72 行,我们创建了一个 CSV 输出的标题列表。这个列表包含所有可能在 EXIF 字典中创建的键,并按照我们希望它们在 CSV 文件中显示的顺序排列。由于并非所有 JPEG 图像都包含相同或任何嵌入的 EXIF 标签,我们会遇到某些字典标签比其他字典更多的情况。通过向写入器提供按顺序排列的键列表,我们可以确保字段按照适当的顺序和列顺序写入:
062 def get_tags(filename):
063 """
064 The get_tags function extracts the EXIF metadata from the data
065 object.
066 :param filename: the path and name to the data object.
067 :return: tags and headers, tags is a dictionary containing
068 EXIF metadata and headers are the order of keys for the
069 CSV output.
070 """
071 # Set up CSV headers
072 headers = ['Path', 'Name', 'Size', 'Filesystem CTime',
073 'Filesystem MTime', 'Original Date', 'Digitized Date', 'Make',
074 'Model', 'Software', 'Latitude', 'Latitude Reference',
075 'Longitude', 'Longitude Reference', 'Exif Version', 'Height',
076 'Width', 'Flash', 'Scene Type']
在第 77 行,我们使用Image.open()
函数打开 JPEG 文件。再次执行最后一步验证,使用verify()
函数。如果文件损坏,它将引发错误。如果没有问题,在第 84 行,我们调用_getexif()
函数,该函数返回一个 EXIF 元数据字典:
077 image = Image.open(filename)
078
079 # Detects if the file is corrupt without decoding the data
080 image.verify()
081
082 # Descriptions and values of EXIF tags
083 # http://www.exiv2.org/tags.html
084 exif = image._getexif()
在第 86 行,我们创建了一个字典tags
,用于存储关于文件对象的元数据。在第 87 行到第 94 行,我们向字典中填充了一些文件系统元数据,如完整路径、名称、大小以及创建和修改时间戳。os.path.basename()
函数获取完整路径名并返回文件名。例如,os.path.basename('Users/LPF/Desktop/myfile.txt')
将简单地返回myfile.txt
。
使用getsize()
函数将返回文件的字节大小。数字越大,对人类越不直观。我们更习惯于看到带有常见前缀的大小,如 MB、GB 和 TB。convert_size()
处理函数正是为了这个目的,使数据对人类分析师更加有用。
在第 91 行和第 93 行,我们将 os.path.getctime()
返回的整数值转换为表示自纪元以来以秒为单位的创建时间。纪元 01/01/1970 00:00:00
可以通过调用 time.gmtime(0)
来确认。我们使用 gmtime()
函数将这些秒数转换为时间结构对象(类似于 datetime
)。我们使用 strftime
来将时间对象格式化为我们所需的日期字符串:
086 tags = {}
087 tags['Path'] = filename
088 tags['Name'] = os.path.basename(filename)
089 tags['Size'] = processors.utility.convert_size(
090 os.path.getsize(filename))
091 tags['Filesystem CTime'] = strftime('%m/%d/%Y %H:%M:%S',
092 gmtime(os.path.getctime(filename)))
093 tags['Filesystem MTime'] = strftime('%m/%d/%Y %H:%M:%S',
094 gmtime(os.path.getmtime(filename)))
在第 95 行,我们检查 exif
字典中是否存在任何键。如果存在,我们遍历每个键并检查其值。我们查询的值来自于 www.exiv2.org/tags.html
中描述的 EXIF 标签。EXIF 标签有很多,但我们只查询一些与法医分析相关的标签。
如果 exif
字典中确实存在某个特定标签,那么我们会将该值转移到我们的标签字典中。某些标签需要额外处理,例如时间戳、场景、闪光和 GPS 标签。时间戳标签的显示格式与我们表示其他时间戳的格式不一致。例如,第 99 行的标签 36867 所代表的时间,使用冒号分隔,并且顺序不同:
2015:11:11 10:32:15
在第 100 行,我们使用 strptime
函数将现有的时间字符串转换为 datetime
对象。在接下来的下一行,我们使用 strftime
函数将其转换为我们所需的日期字符串格式:
095 if exif:
096 for tag in exif.keys():
097 if tag == 36864:
098 tags['Exif Version'] = exif[tag]
099 elif tag == 36867:
100 dt = datetime.strptime(exif[tag],
101 '%Y:%m:%d %H:%M:%S')
102 tags['Original Date'] = dt.strftime(
103 '%m/%d/%Y %H:%M:%S')
104 elif tag == 36868:
105 dt = datetime.strptime(exif[tag],
106 '%Y:%m:%d %H:%M:%S')
107 tags['Digitized Date'] = dt.strftime(
108 '%m/%d/%Y %H:%M:%S')
场景标签(41990
)和闪光标签(37385
)具有整数值,而不是字符串。如前所述,在线文档 (www.exiv2.org/tags.html
) 解释了这些整数代表什么。在这两种情况下,我们创建一个字典,包含潜在的整数作为键,以及它们的描述作为值。我们检查标签的值是否是字典中的键。如果存在,我们将描述存储在标签字典中,而不是存储整数。同样,这样做是为了便于分析人员的分析。看到场景或闪光标签的字符串描述比看到代表该描述的数字更有价值:
109 elif tag == 41990:
110 # Scene tags
111 # http://www.awaresystems.be/imaging/tiff/tifftags/privateifd/exif/scenecapturetype.html
112 scenes = {0: 'Standard', 1: 'Landscape',
113 2: 'Portrait', 3: 'Night Scene'}
114 if exif[tag] in scenes:
115 tags['Scene Type'] = scenes[exif[tag]]
116 else:
117 pass
118 elif tag == 37385:
119 # Flash tags
120 # http://www.awaresystems.be/imaging/tiff/tifftags/privateifd/exif/flash.html
121 flash = {0: 'Flash did not fire',
122 1: 'Flash fired',
123 5: 'Strobe return light not detected',
124 7: 'Strobe return light detected',
125 9: 'Flash fired, compulsory flash mode',
126 13: 'Flash fired, compulsory flash mode, return light not detected',
127 15: 'Flash fired, compulsory flash mode, return light detected',
128 16: 'Flash did not fire, compulsory flash mode',
129 24: 'Flash did not fire, auto mode',
130 25: 'Flash fired, auto mode',
131 29: 'Flash fired, auto mode, return light not detected',
132 31: 'Flash fired, auto mode, return light detected',
133 32: 'No flash function',
134 65: 'Flash fired, red-eye reduction mode',
135 69: 'Flash fired, red-eye reduction mode, return light not detected',
136 71: 'Flash fired, red-eye reduction mode, return light detected',
137 73: 'Flash fired, compulsory flash mode, red-eye reduction mode',
138 77: 'Flash fired, compulsory flash mode, red-eye reduction mode, return light not detected',
139 79: 'Flash fired, compulsory flash mode, red-eye reduction mode, return light detected',
140 89: 'Flash fired, auto mode, red-eye reduction mode',
141 93: 'Flash fired, auto mode, return light not detected, red-eye reduction mode',
142 95: 'Flash fired, auto mode, return light detected, red-eye reduction mode'}
143 if exif[tag] in flash:
144 tags['Flash'] = flash[exif[tag]]
145 elif tag == 271:
146 tags['Make'] = exif[tag]
147 elif tag == 272:
148 tags['Model'] = exif[tag]
149 elif tag == 305:
150 tags['Software'] = exif[tag]
151 elif tag == 40962:
152 tags['Width'] = exif[tag]
153 elif tag == 40963:
154 tags['Height'] = exif[tag]
最后,在第 155 行,我们查找存储在键 34853 下的 GPS 标签,这些标签是作为嵌套字典存储的。如果纬度和经度标签存在,我们将它们传递给 dms_to_decimal()
函数,以将其转换为更适合 KML 写入器的格式:
155 elif tag == 34853:
156 for gps in exif[tag]:
157 if gps == 1:
158 tags['Latitude Reference'] = exif[tag][gps]
159 elif gps == 2:
160 tags['Latitude'] = dms_to_decimal(
161 exif[tag][gps])
162 elif gps == 3:
163 tags['Longitude Reference'] = exif[tag][gps]
164 elif gps == 4:
165 tags['Longitude'] = dms_to_decimal(
166 exif[tag][gps])
167 else:
168 pass
169 return tags, headers
dms_to_decimal()
函数dms_to_decimal()
函数将 GPS 坐标从度分秒格式转换为十进制格式。存在一个简单的公式可以在这两种格式之间进行转换。我们从 EXIF 元数据中提取的 GPS 数据包含三个元组,位于另一个元组内。每个内部元组表示度、分或秒的分子和分母。首先,我们需要将嵌套元组中的度、分、秒的分子与分母分开。下图展示了如何将提取的 GPS 数据转换为十进制格式:
https://github.com/OpenDocCN/freelearn-sec-pt3-zh/raw/master/docs/lrn-py-frsc-2e/img/f06fd0dd-c6be-440b-b2a6-5d509011cf7d.png
在第 178 行,我们使用列表推导式创建一个包含元组中每个元素第一个元素的列表。然后,我们将这个列表解包为三个元素:deg
、min
和 sec
。我们使用的公式依赖于度数值是正数还是负数。
如果 deg
为正,则我们将加上分钟和秒数。我们将秒数除以 360,0000 而不是 3,600,因为最初我们没有将秒数值除以其分母。如果 deg
为负,我们则按如下方式减去分钟和秒数:
172 def dms_to_decimal(dms):
173 """
174 Converts GPS Degree Minute Seconds format to Decimal format.
175 :param dms: The GPS data in Degree Minute Seconds format.
176 :return: The decimal formatted GPS coordinate.
177 """
178 deg, min, sec = [x[0] for x in dms]
179 if deg > 0:
180 return "{0:.5f}".format(deg + (min / 60.) + (
181 sec / 3600000.))
182 else:
183 return "{0:.5f}".format(deg - (min / 60.) - (
184 sec / 3600000.))
id3_parser.py
id3_parser
与我们之前讨论的 exif_parser
类似。第 37 行定义的 id3_parser()
函数检查文件签名,然后调用 get_tags()
函数。get_tags()
函数依赖于 mutagen
模块来解析 MP3 和 ID3 标签:
001 import os
002 from time import gmtime, strftime
003
004 from mutagen import mp3, id3
005
006 import processors
..
037 def id3_parser():
...
059 def get_tags():
id3_parser()
函数该函数与 exif_parser()
函数相同,唯一的区别是用于检查文件头的签名。MP3 格式只有一个文件签名 0x494433
,与 JPEG 格式不同。当我们调用 check_header()
函数时,我们需要提供文件、已知签名和要读取的头部字节数。如果签名匹配,我们就会调用并返回 get_tags()
函数的结果,如下所示:
037 def id3_parser(filename):
038 """
039 The id3_parser function confirms the file type and sends it to
040 be processed.
041 :param filename: name of the file potentially containing exif
042 metadata.
043 :return: A dictionary from get_tags, containing the embedded
044 EXIF metadata.
045 """
尽管每个插件中看到相同类型的逻辑可能很无聊,但这大大简化了我们框架的逻辑。在大型框架的场景中,以相同的统一方式创建内容有助于那些维护代码的人保持理智。复制和粘贴现有插件并从中进行工作,通常是一种确保开发方式一致的好方法。请查看以下代码:
047 # MP3 signatures
048 signatures = ['494433']
049 if processors.utility.check_header(
050 filename, signatures, 3) == True:
051 return get_tags(filename)
052 else:
053 print(('File signature doesn't match known '
054 'MP3 signatures.'))
055 raise TypeError(('File signature doesn't match '
056 'MP3 object.'))
get_tags()
函数get_tags()
函数遵循了我们为 EXIF 插件使用的相同逻辑。像所有好的程序员一样,我们复制了那个脚本,并做了一些修改以适应 ID3 元数据。在 get_tags()
函数中,我们首先需要在第 69 行创建我们的 CSV 表头。这些表头代表我们的字典可能拥有的键以及我们希望它们在 CSV 输出中出现的顺序:
059 def get_tags(filename):
060 """
061 The get_tags function extracts the ID3 metadata from the data
062 object.
063 :param filename: the path and name to the data object.
064 :return: tags and headers, tags is a dictionary containing ID3
065 metadata and headers are the order of keys for the CSV output.
066 """
067
068 # Set up CSV headers
069 header = ['Path', 'Name', 'Size', 'Filesystem CTime',
070 'Filesystem MTime', 'Title', 'Subtitle', 'Artist', 'Album',
071 'Album/Artist', 'Length (Sec)', 'Year', 'Category',
072 'Track Number', 'Comments', 'Publisher', 'Bitrate',
073 'Sample Rate', 'Encoding', 'Channels', 'Audio Layer']
在第 74 行,我们创建了我们的标签字典,并以与 EXIF 插件相同的方式填充一些文件系统元数据,如下所示:
074 tags = {}
075 tags['Path'] = filename
076 tags['Name'] = os.path.basename(filename)
077 tags['Size'] = processors.utility.convert_size(
078 os.path.getsize(filename))
079 tags['Filesystem CTime'] = strftime('%m/%d/%Y %H:%M:%S',
080 gmtime(os.path.getctime(filename)))
081 tags['Filesystem MTime'] = strftime('%m/%d/%Y %H:%M:%S',
082 gmtime(os.path.getmtime(filename)))
Mutagen 有两个类可以用来从 MP3 文件中提取元数据。第一个类 MP3
存储了一些常见的 MP3 文件元数据,例如比特率、声道和时长(秒)。Mutagen 提供了内置函数来访问这些信息。首先,我们需要创建一个 MP3 对象,这可以通过第 85 行使用 mp3.MP3()
函数来完成。接下来,我们可以使用 info.bitrate()
函数,例如,来返回 MP3 文件的比特率。我们将在第 88 行至第 92 行将这些值存储在我们的标签字典中,如下所示:
084 # MP3 Specific metadata
085 audio = mp3.MP3(filename)
086 if 'TENC' in audio.keys():
087 tags['Encoding'] = audio['TENC'][0]
088 tags['Bitrate'] = audio.info.bitrate
089 tags['Channels'] = audio.info.channels
090 tags['Audio Layer'] = audio.info.layer
091 tags['Length (Sec)'] = audio.info.length
092 tags['Sample Rate'] = audio.info.sample_rate
第二个类 ID3
从 MP3 文件中提取 ID3 标签。我们需要首先使用 id3.ID3()
函数创建一个 ID3 对象。这将返回一个字典,其中 ID3 标签作为键。听起来很熟悉吧?这正是我们在前一个插件中看到的。唯一的区别是,字典中的值以稍有不同的格式存储:
{'TPE1': TPE1(encoding=0, text=[u'The Artist']),...}
要访问 The Artist
的值,我们需要将其作为列表处理,并指定第零索引处的元素。
以类似的方式,我们查找每个感兴趣的标签,并将第一个元素存储在标签字典的值中。经过这一过程后,我们将标签和头信息对象返回给 id3_parser()
,然后它再返回给 metadata_parser.py
脚本:
094 # ID3 embedded metadata tags
095 id = id3.ID3(filename)
096 if 'TPE1' in id.keys():
097 tags['Artist'] = id['TPE1'][0]
098 if 'TRCK' in id.keys():
099 tags['Track Number'] = id['TRCK'][0]
100 if 'TIT3' in id.keys():
101 tags['Subtitle'] = id['TIT3'][0]
102 if 'COMM::eng' in id.keys():
103 tags['Comments'] = id['COMM::eng'][0]
104 if 'TDRC' in id.keys():
105 tags['Year'] = id['TDRC'][0]
106 if 'TALB' in id.keys():
107 tags['Album'] = id['TALB'][0]
108 if 'TIT2' in id.keys():
109 tags['Title'] = id['TIT2'][0]
110 if 'TCON' in id.keys():
111 tags['Category'] = id['TCON'][0]
112 if 'TPE2' in id.keys():
113 tags['Album/Artist'] = id['TPE2'][0]
114 if 'TPUB' in id.keys():
115 tags['Publisher'] = id['TPUB'][0]
116
117 return tags, header
office_parser.py
最后一个插件 office_parser.py
解析 DOCX、PPTX 和 XLSX 文件,提取嵌入的元数据 XML 文件。我们使用标准库中的 zipfile
模块解压并访问 Office 文档的内容。此脚本有两个函数,office_parser()
和 get_tags()
:
001 import zipfile
002 import os
003 from time import gmtime, strftime
004
005 from lxml import etree
006 import processors
...
037 def office_parser():
...
059 def get_tags():
office_parser()
函数office_parser()
函数首先检查输入文件是否符合已知的文件签名。所有 Office 文档共享相同的文件签名 0x504b0304140006000
,如果输入文件匹配,则由 get_tags()
函数进一步处理,具体如下:
037 def office_parser(filename):
038 """
039 The office_parser function confirms the file type and sends it
040 to be processed.
041 :param filename: name of the file potentially containing
042 embedded metadata.
043 :return: A dictionary from get_tags, containing the embedded
044 metadata.
045 """
046
047 # DOCX, XLSX, and PPTX signatures
048 signatures = ['504b030414000600']
049 if processors.utility.check_header(
050 filename, signatures, 8) == True:
051 return get_tags(filename)
052 else:
053 print(('File signature doesn't match known '
054 'signatures.'))
055 raise TypeError(('File signature doesn't match '
056 'Office objects.'))
get_tags()
函数在第 70 行,我们为潜在的字典创建标题列表。第 81 行是所谓的“魔法发生”的地方。内置的 zipfile
库用于读取、写入、追加和列出 ZIP 文件中的内容。在第 81 行,我们创建了一个 ZIP 文件对象,允许我们读取其中包含的文档。见下列代码:
059 def get_tags(filename):
060 """
061 The get_tags function extracts the office metadata from the
062 data object.
063 :param filename: the path and name to the data object.
064 :return: tags and headers, tags is a dictionary containing
065 office metadata and headers are the order of keys for the CSV
066 output.
067 """
068
069 # Set up CSV headers
070 headers = ['Path', 'Name', 'Size', 'Filesystem CTime',
071 'Filesystem MTime', 'Title', 'Author(s)','Create Date',
072 'Modify Date', 'Last Modified By Date', 'Subject', 'Keywords',
073 'Description', 'Category', 'Status', 'Revision',
074 'Edit Time (Min)', 'Page Count', 'Word Count',
075 'Character Count', 'Line Count',
076 'Paragraph Count', 'Slide Count', 'Note Count',
077 'Hidden Slide Count', 'Company', 'Hyperlink Base']
078
079 # Create a ZipFile class from the input object
080 # This allows us to read or write to the 'Zip archive'
081 zf = zipfile.ZipFile(filename)
具体来说,在第 86 和 87 行,我们读取核心和应用程序 XML 文件,并将其转换为 XML 元素树。etree.fromstring()
方法允许我们从字符串构建元素树,这是完成本章早些时候描述的相同任务的另一种方法,后者使用了 ElementTree.parse()
函数:
083 # These two XML files contain the embedded metadata of
084 # interest
085 try:
086 core = etree.fromstring(zf.read('docProps/core.xml'))
087 app = etree.fromstring(zf.read('docProps/app.xml'))
088 except KeyError as e:
089 assert Warning(e)
090 return {}, headers
与前面的部分一样,我们创建了标签字典,并用一些文件系统元数据填充它:
092 tags = {}
093 tags['Path'] = filename
094 tags['Name'] = os.path.basename(filename)
095 tags['Size'] = processors.utility.convert_size(
096 os.path.getsize(filename))
097 tags['Filesystem CTime'] = strftime('%m/%d/%Y %H:%M:%S',
098 gmtime(os.path.getctime(filename)))
099 tags['Filesystem MTime'] = strftime('%m/%d/%Y %H:%M:%S',
100 gmtime(os.path.getmtime(filename)))
从第 104 行开始,我们通过使用 iterchildren()
函数迭代核心 XML 文档的子元素。每当我们迭代一个子元素时,我们会在 child.tag
字符串中查找各种关键词。如果找到了,我们将 child.text
字符串与标签字典中的适当键关联起来。
core.xml
和 app.xml
文件中的这些标签并不总是存在,这就是为什么我们必须先检查它们是否存在才能提取它们的原因。某些标签,例如修订标签,仅存在于特定的 Office 文档中。我们将在 app.xml
文件中看到更多这种情况:
102 # Core Tags
103
104 for child in core.iterchildren():
105
106 if 'title' in child.tag:
107 tags['Title'] = child.text
108 if 'subject' in child.tag:
109 tags['Subject'] = child.text
110 if 'creator' in child.tag:
111 tags['Author(s)'] = child.text
112 if 'keywords' in child.tag:
113 tags['Keywords'] = child.text
114 if 'description' in child.tag:
115 tags['Description'] = child.text
116 if 'lastModifiedBy' in child.tag:
117 tags['Last Modified By Date'] = child.text
118 if 'created' in child.tag:
119 tags['Create Date'] = child.text
120 if 'modified' in child.tag:
121 tags['Modify Date'] = child.text
122 if 'category' in child.tag:
123 tags['Category'] = child.text
124 if 'contentStatus' in child.tag:
125 tags['Status'] = child.text
126
127 if (filename.endswith('.docx') or
128 filename.endswith('.pptx')):
129 if 'revision' in child.tag:
130 tags['Revision'] = child.text
app.xml
文件包含特定于给定应用程序的元数据。在第 133 行,当我们遍历元素树的子元素时,我们仅检查特定扩展名的标签。
例如,DOCX 文件包含页面和行数的元数据,这对 PPTX 和 XLSX 文件没有意义。因此,我们根据文件扩展名来区分我们需要查找的标签。TotalTime
标签特别有用,它表示编辑文档所花费的时间(以分钟为单位)。请参见以下代码:
132 # App Tags
133 for child in app.iterchildren():
134
135 if filename.endswith('.docx'):
136 if 'TotalTime' in child.tag:
137 tags['Edit Time (Min)'] = child.text
138 if 'Pages' in child.tag:
139 tags['Page Count'] = child.text
140 if 'Words' in child.tag:
141 tags['Word Count'] = child.text
142 if 'Characters' in child.tag:
143 tags['Character Count'] = child.text
144 if 'Lines' in child.tag:
145 tags['Line Count'] = child.text
146 if 'Paragraphs' in child.tag:
147 tags['Paragraph Count'] = child.text
148 if 'Company' in child.tag:
149 tags['Company'] = child.text
150 if 'HyperlinkBase' in child.tag:
151 tags['Hyperlink Base'] = child.text
152
153 elif filename.endswith('.pptx'):
154 if 'TotalTime' in child.tag:
155 tags['Edit Time (Min)'] = child.text
156 if 'Words' in child.tag:
157 tags['Word Count'] = child.text
158 if 'Paragraphs' in child.tag:
159 tags['Paragraph Count'] = child.text
160 if 'Slides' in child.tag:
161 tags['Slide Count'] = child.text
162 if 'Notes' in child.tag:
163 tags['Note Count'] = child.text
164 if 'HiddenSlides' in child.tag:
165 tags['Hidden Slide Count'] = child.text
166 if 'Company' in child.tag:
167 tags['Company'] = child.text
168 if 'HyperlinkBase' in child.tag:
169 tags['Hyperlink Base'] = child.text
170 else:
171 if 'Company' in child.tag:
172 tags['Company'] = child.text
173 if 'HyperlinkBase' in child.tag:
174 tags['Hyperlink Base'] = child.text
175
176 return tags, headers
在 writers 目录下,我们有两个脚本:csv_writer.py
和 kml_writer.py
。这两个写入器根据在 metadata_parser.py
框架中处理的数据类型来调用。
在本章中,我们将使用 csv.DictWriter
代替 csv.writer
,就像在 第五章,Python 中的数据库 和 第六章,从二进制文件中提取文档 中做的那样。提醒一下,区别在于 DictWriter
将字典对象写入 CSV 文件,而 csv.writer
函数更适合写入列表。
csv.DictWriter
的优点在于,在创建写入器对象时,它需要一个参数 fieldnames
。fieldnames
参数应该是一个列表,表示输出列的期望顺序。此外,所有可能的键必须包含在 fieldnames
列表中。如果某个键存在,但不在列表中,则会引发异常。另一方面,如果某个键不在字典中,但在 fieldnames
列表中,那么该列将被跳过:
001 from __future__ import print_function
002 import sys
003 import os
004 if sys.version_info[0] == 2:
005 import unicodecsv as csv
006 elif sys.version_info[0] == 3:
007 import csv
008 import logging
...
040 def csv_writer(output_data, headers, output_dir, output_name):
041 """
042 The csv_writer function uses the csv DictWriter module to
043 write the list of dictionaries. The DictWriter can take
044 a fieldnames argument, as a list, which represents the
045 desired order of columns.
046 :param output_data: The list of dictionaries containing
047 embedded metadata.
048 :param headers: A list of keys in the dictionary that
049 represent the desired order of columns in the output.
050 :param output_dir: The folder to write the output CSV to.
051 :param output_name: The name of the output CSV.
052 :return:
053 """
054 msg = 'Writing ' + output_name + ' CSV output.'
055 print('[+]', msg)
056 logging.info(msg)
057
058 out_file = os.path.join(output_dir, output_name)
059
060 if sys.version_info[0] == 2:
061 csvfile = open(out_file, "wb")
062 elif sys.version_info[0] == 3:
063 csvfile = open(out_file, "w", newline='',
064 encoding='utf-8')
在第 69 行,我们创建了 csv.DictWriter
函数,传入输出文件和作为 fieldnames
列表的头部信息,这个列表来自我们的插件函数。为了写入 CSV 文件的头部,我们可以简单地调用 writeheader
函数,它使用 fieldnames
列表作为头部信息。最后,我们需要遍历元数据容器列表中的每个字典,并使用第 76 行的 writerow()
函数写入它们,如下所示:
066 with csvfile:
067 # We use DictWriter instead of Writer to write
068 # dictionaries to CSV.
069 writer = csv.DictWriter(csvfile, fieldnames=headers)
070
071 # Writerheader writes the header based on the supplied
072 # headers object
073 writer.writeheader()
074 for dictionary in output_data:
075 if dictionary:
076 writer.writerow(dictionary)
kml_writer.py
脚本使用 simplekml
模块(版本 1.3.1)快速生成我们的 KML 输出。此模块的完整文档可以在 simplekml.com
找到。可以使用 pip
安装此模块:
pip install simplekml==1.3.1
使用这个模块,我们可以通过三行代码创建并添加地理标记点并保存 KML 文件:
001 from __future__ import print_function
002 import os
003 import logging
004
005 import simplekml
...
036 def kml_writer(output_data, output_dir, output_name):
037 """
038 The kml_writer function writes JPEG and TIFF EXIF GPS data to
039 a Google Earth KML file. This file can be opened
040 in Google Earth and will use the GPS coordinates to create
041 'pins' on the map of the taken photo's location.
042 :param output_data: The embedded EXIF metadata to be written
043 :param output_dir: The output directory to write the KML file.
044 :param output_name: The name of the output KML file.
045 :return:
046 """
在第 51 行,我们使用 simplekml.Kml()
调用创建了 KML 对象。此函数接受一个可选的关键字参数 name,表示 KML 文件的名称。第 52-71 行检查是否存在原始的日期键,并准备将我们的 GPS 点添加到 KML 对象中:
047 msg = 'Writing ' + output_name + ' KML output.'
048 print('[+]', msg)
049 logging.info(msg)
050 # Instantiate a Kml object and pass along the output filename
051 kml = simplekml.Kml(name=output_name)
052 for exif in output_data:
053 if ('Latitude' in exif.keys() and
054 'Latitude Reference' in exif.keys() and
055 'Longitude Reference' in exif.keys() and
056 'Longitude' in exif.keys()):
057
058 if 'Original Date' in exif.keys():
059 dt = exif['Original Date']
060 else:
061 dt = 'N/A'
062
063 if exif['Latitude Reference'] == 'S':
064 latitude = '-' + exif['Latitude']
065 else:
066 latitude = exif['Latitude']
067
068 if exif['Longitude Reference'] == 'W':
069 longitude = '-' + exif['Longitude']
070 else:
071 longitude = exif['Longitude']
我们的 GPS 坐标来自exif_parser.py
脚本,格式为十进制。然而,在这个脚本中,我们没有考虑参考点的问题。参考点决定了 GPS 坐标的符号。南纬参考会使纬度为负数,同样,西经参考会使经度为负数。
一旦这些问题解决,我们就可以创建带有地理标签的点,传入点的名称、描述和坐标。如果纬度和经度的 EXIF 标签检查返回False
,那么第 76 行和 77 行的else
语句会被执行。虽然这两行代码可以省略,但它们应该被保留下来,作为实现逻辑的提示。创建所有点之后,我们可以通过调用kml.save()
函数,传入所需的输出路径和文件名,保存 KML 文件。以下是第 73 行到 78 行的代码:
073 kml.newpoint(name=exif['Name'],
074 description='Originally Created: ' + dt,
075 coords=[(longitude, latitude)])
076 else:
077 pass
078 kml.save(os.path.join(output_dir, output_name))
processors
目录包含一个脚本,utility.py
。这个脚本包含一些辅助函数,当前所有插件都在使用这些函数。我们将这些函数集中在一个脚本中,而不是为每个插件分别编写。
这个脚本包含两个函数,check_header()
和convert_size()
。前者执行文件签名匹配,而后者将表示文件字节大小的整数转换为人类可读的格式,如下所示:
001 import binascii
002 import logging
...
033 def check_header(filename, headers, size):
034 """
035 The check_header function reads a supplied size of the file
036 and checks against known signatures to determine the file
037 type.
038 :param filename: The name of the file.
039 :param headers: A list of known file signatures for the
040 file type(s).
041 :param size: The amount of data to read from the file for
042 signature verification.
043 :return: Boolean, True if the signatures match;
044 otherwise, False.
045 """
check_header()
函数定义在第 33 行,它接受文件名、已知签名的列表以及读取文件的字节数作为参数。在第 46 行,我们打开输入文件,然后根据传入的大小参数读取前几个字节。在第 48 行,我们将数据的 ASCII 表示转换为十六进制字符串。在第 49 行,我们遍历每个已知签名并将其与hex_header
进行比较。如果匹配,我们返回True
,否则返回False
并记录警告,具体如下:
046 with open(filename, 'rb') as infile:
047 header = infile.read(size)
048 hex_header = binascii.hexlify(header).decode('utf-8')
049 for signature in headers:
050 if hex_header == signature:
051 return True
052 else:
053 pass
054 logging.warn(('The signature for {} ({}) doesn't match '
055 'known signatures: {}').format(
056 filename, hex_header, headers))
057 return False
convert_size()
函数是一个有用的工具函数,它将字节大小的整数转换为人类可读的格式。在第 66 行,我们创建了一个潜在前缀的列表。注意,我们假设用户在未来几年内不会遇到需要TB
前缀的文件:
059 def convert_size(size):
060 """
061 The convert_size function converts an integer representing
062 bytes into a human-readable format.
063 :param size: The size in bytes of a file
064 :return: The human-readable size.
065 """
066 sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']
我们使用while
循环不断将大小除以 1024,直到结果小于 1024 为止。每次除法操作后,我们将索引加一。当大小小于 1024 时,索引指向size
列表中适当前缀的位置。
在第 71 行,我们使用字符串格式化函数format
,以所需的方式返回浮点数和前缀。{:.2f}
告诉格式化函数,第一个参数是浮点数,并且我们希望四舍五入到小数点后两位:
067 index = 0
068 while size > 1024:
069 size /= 1024.
070 index += 1
071 return '{:.2f} {}'.format(size, sizes[index])
如下图所示,我们可以在目录中运行框架,并生成一个输出报告供我们审查。在这个例子中,我们对一个包含地理位置信息的图像文件夹运行了代码。
https://github.com/OpenDocCN/freelearn-sec-pt3-zh/raw/master/docs/lrn-py-frsc-2e/img/4e8004b2-ac33-435a-bf1a-24dc6f68e422.png
我们的输出报告如下所示,尽管我们已将列进行换行,以确保其适合一页。
https://github.com/OpenDocCN/freelearn-sec-pt3-zh/raw/master/docs/lrn-py-frsc-2e/img/373a91e2-6212-49e5-95ae-17f24e8612c5.png
我们的脚本还生成了可在 Google Earth 中查看的 KML 输出,如下所示:
https://github.com/OpenDocCN/freelearn-sec-pt3-zh/raw/master/docs/lrn-py-frsc-2e/img/bed99712-e9ee-4794-b294-10ccb6d9864b.png
框架在组织多个脚本集合方面非常有用,可以将它们集中在一个地方。使用框架也会面临一些挑战,主要是在项目发展过程中保持标准化的操作。我们的 metadata_parser.py
框架处于第一版本,如果我们继续开发它,可能会发现当前的设置只适用于较小的规模。
例如,随着我们实现越来越多的功能,我们可能会意识到框架的效率开始下降。到那时,我们需要回到设计阶段,确定是否正在使用正确的数据类型,或者是否选择了最佳的方法来编写插件和编写器。
我们在决定本章的两个主要挑战之间遇到了困难。我们可以添加额外的插件,或者完善当前已存在的功能。在实际开发中,您的时间将花费在平衡这两个目标上,随着框架的不断发展。对于本章,我们提出了一个基于递归的挑战。
请记住,在解释 Office 2007 文档格式时,我们曾确定附加的媒体文件存储在文档的媒体子目录中。在当前版本中,当遇到一个 Office 文档时,那个媒体子目录(它可能包含嵌入的元数据文件的副本)不会被处理。这里的挑战是将新发现的文件添加到当前的文件列表中。
我们可能通过将新发现的文件列表返回到 metadata_parser.py
来解决这个问题。另一种方法是在 office_parser.py
脚本中检查文件扩展名,并立即将它们传递给适当的插件。后者方法虽然更容易实现,但并不理想,因为它从 metadata_parser.py
脚本中移除了一些控制权。最终,开发人员需要确定完成此挑战的最有效和最合理的方法。
除此之外,还可以取得一些其他的效率成就。例如,我们不需要每次调用插件时都返回插件的头信息。由于头信息始终相同,我们只需要创建/返回一次即可。或者,该框架受到它支持的编写器类型的限制。可以考虑为 Excel 电子表格添加一个编写器,以创建更有用的报告。
在本章中,你学习了如何处理一些流行的嵌入式元数据格式,执行基本的文件签名分析,并在 Python 中创建框架。随着程序复杂性的增加,框架成为一种常见的编程解决方案。这个项目的代码可以从 GitHub 或 Packt 下载,如在前言中所述。
在下一章中,你将学习如何使用 Python 中的 TkInter 模块开发一个基本的图形用户界面(GUI)。这个 GUI 将负责将各种类型的时间戳转换为人类可读的格式。
时间戳以多种格式存储,这些格式通常是由负责生成它们的操作系统或应用程序所独有的。在取证中,转换这些时间戳可能是调查的重要部分。
作为示例,我们可以汇总转换后的时间戳,创建一个综合事件时间线,确定跨平台的动作顺序。这种时间评估有助于我们判断行动是否在定义的范围内,并为我们提供关于两个事件之间关系的洞察。
为了解读这些格式化的时间戳,我们可以使用工具来解释原始值,并将其转换为人类可读的时间。大多数取证工具在解析已知的伪造数据结构时都会默默地执行此操作(类似于我们的脚本经常解析 Unix 时间戳的方式)。
在某些情况下,我们没有能够正确或统一处理特定时间戳的工具,必须依靠我们的聪明才智来解读时间值。
我们将使用常见的库来解析用户输入的时间戳,并将其转换为所需的格式。利用 TkInter 库,我们将设计一个图形用户界面(GUI),用户可以通过该界面展示日期信息。我们将使用 Python 类来更好地组织我们的 GUI,并处理诸如用户点击 GUI 上按钮等事件。
在本章中,我们将构建一个图形界面,借助以下主题将时间戳在机器可读格式和人类可读格式之间进行转换。
在 Python 中创建跨平台的图形用户界面
常见原始时间戳值在机器可读格式和人类可读格式之间的转换。
Python 类设计与实现的基础,允许灵活地添加更多时间格式。
本章代码在 Python 2.7.15 和 Python 3.7.1 环境下开发并测试。
时间戳格式通常归结为两个组成部分:一个参考点和用来表示从该参考点起已过的时间量的约定或算法。大多数时间戳都有相应的文档,可以帮助我们确定将原始时间数据转换为人类可读时间戳的最佳方式。
如介绍中所述,时间戳格式种类繁多,其中一些我们已经遇到过,如 Unix 时间和 Windows FILETIME。这使得转换过程变得更加复杂,因为我们开发的取证脚本可能需要准备好处理多种时间格式。
Python 附带了几个标准库,可以帮助我们转换时间戳。我们以前使用过 datetime
模块来正确处理时间值并将其存储在 Python 对象中。我们将介绍两个新库——time
(它是标准库的一部分)和第三方库 dateutil
。
我们可以通过运行pip install python-dateutil==2.7.5
来下载并安装dateutil
(版本 2.7.5)。这个库将用于将字符串解析为datetime
对象。dateutil
库中的parser()
方法接受一个字符串作为输入,并尝试自动将其转换为datetime
对象。与strptime()
方法不同,后者需要显式声明时间戳的格式,dateutil.parser
可以将不同格式的时间戳转换为datetime
对象,而无需开发者输入。
一个示例字符串可以是2015 年 12 月 8 日星期二 18:04
或 12/08/2015 18:04
,这两者都会被parser()
方法转换成相同的datetime
对象。下面的代码块演示了这一功能,适用于 Python 2.7.15 和 Python 3.7.1:
>>> from dateutil import parser as duparser
>>> d = duparser.parse('Tuesday December 8th, 2015 at 6:04 PM')
>>> d.isoformat()
'2015-12-08T18:04:00'
>>> d2 = duparser.parse('12/08/2015 18:04')
>>> d2.isoformat()
'2015-12-08T18:04:00'
在代码块的第一行,我们导入dateutil
解析器并创建一个别名duparser
,因为parser
这个函数名是通用术语,可能会与其他变量或函数冲突。然后,我们调用parse()
方法并传递一个表示时间戳的字符串。将解析后的值赋给变量d
,我们使用isoformat()
函数查看其 ISO 格式。接着,我们用第二个格式不同的时间戳重复这些步骤,观察到相同的结果。
请参考文档,获取有关parse()
方法的更多细节,访问 dateutil.readthedocs.org/en/latest/parser.html
。
纪元是一个时间点,被标记为给定时间格式的起始时间,通常用作跟踪时间流逝的参考点。尽管我们在这里省略了任何与时间度量相关的哲学讨论,但我们将在本章中使用并参考纪元作为给定时间格式的起点。
大多数时间戳关联着两个主要的纪元时间:1970-01-01 00:00:00
和 1601-01-01 00:00:00
。第一个纪元从 1970 年开始,传统上被称为 POSIX 时间,因为它是 Unix 及类 Unix 系统中常见的时间戳。在大多数 Unix 系统中,时间戳是从 POSIX 时间开始计算的秒数。这个概念也延伸到了某些应用中,存在使用从同一纪元起的毫秒数的变种。
第二个纪元,基于 1601 年,通常出现在基于 Windows 的系统中,之所以使用这个时间点,是因为它是格里高利历中第一个包含闰年的 400 年周期的起始点。1601 年开始的 400 年周期是第一个存在数字文件的周期,因此这个值成为另一个常见的纪元。在 Windows 系统中,常见的时间戳是从这个纪元起计算的 100 纳秒时间段的计数。这个值通常以十六进制或整数形式存储。
下一个代码块描述了将不同纪元的时间戳进行转换的过程。正如我们在前面的章节中所看到的,我们可以使用datetime
模块的fromtimestamp()
方法来转换 Unix 时间戳,因为它使用的是 1970 年纪元。对于基于 1601 年的时间戳,我们需要在使用fromtimestamp()
函数之前先进行转换。
为了简化这个转换过程,我们来计算这两个日期之间的常数,并利用这个常数在两个纪元之间进行转换。在第一行,我们导入datetime
库。接下来,我们将两个时间戳相减,以确定1970-01-01
和1601-01-01
之间的时间差。这个语句生成一个datetime.timedelta
对象,存储两个值之间以天、秒和微秒计的时间差。
在这个例子中,1970 年和 1601 年时间戳之间的差值恰好是 134,774 天。我们需要将这个差值转换成微秒时间戳,以便能够在转换中准确地使用它。因此,在第三行中,我们将天数(time_diff.days
)转换为微秒,通过将其乘以86400000000
(24 小时 x 60 分钟 x 60 秒 x 1,000,000 微秒的积)并打印常数值11644473600000000
。请查看以下代码:
>>> import datetime
>>> time_diff = datetime.datetime(1970,1,1) - datetime.datetime(1601,1,1)
>>> print (time_diff.days * 86400000000)
11644473600000000
使用这个值,我们就可以在这两个纪元之间转换时间戳,并正确处理基于 1601 年的纪元时间戳。
在本章中,我们将使用 GUI 将时间戳在原始格式和人类可读格式之间进行转换。时间戳转换是一个很好的借口来探索编程 GUI,因为它提供了一个解决常见调查活动的方案。通过使用 GUI,我们大大提高了脚本的可用性,尤其是对于那些被命令提示符及其各种参数和开关所吓退的用户。
Python 中有许多 GUI 开发的选项,但在本章中,我们将重点介绍 TkInter。TkInter 库是一个跨平台的 Python GUI 开发库,它与操作系统的Tcl
/Tk
库结合使用,支持 Windows、macOS 以及多个 Linux 平台。
这个跨平台框架允许我们构建一个平台无关的通用界面。虽然 TkInter 的 GUI 界面可能看起来不那么现代,但它们让我们能够以相对简单的方式快速构建一个功能性界面进行交互。
在这里,我们只会介绍 TkInter GUI 开发的基础知识。有关更详细的信息,可以通过在线资源或专门讲解 TkInter 开发过程和特定功能的书籍找到。在 www.python.org/
网站上有一个详细的资源列表,可以学习和使用 TkInter,更多信息请见 wiki.python.org/moin/TkInter
。
我们将使用 TkInter 的几个不同功能来展示我们的 GUI。每个 TkInter GUI 需要的第一个元素是根窗口,也叫做主窗口,它作为我们添加到 GUI 中的任何其他元素的顶级父窗口。在这个窗口中,我们将结合多个对象来允许用户与我们的界面进行互动,例如Label
、Entry
和Button
等元素:
Label
对象允许我们在界面上放置无法编辑的文本标签。这使得我们可以添加标题或为指示应写入或显示到字段中的对象提供描述。
Entry
对象允许用户输入一行文本作为应用程序的输入。
Button
对象允许我们在按下时执行命令。在我们的例子中,按钮将调用适当的函数来转换特定格式的时间戳,并使用返回值更新界面。
使用这三个功能,我们已经介绍了界面所需的所有 GUI 元素。还有更多可用的对象,详细信息可以在 TkInter 文档中找到,网址为docs.python.org/3/library/tkinter.html
。
我们将以兼容 Python 2 和 Python 3 的方式编写代码。因此,在 Python 2(例如,版本 2.7.15)中,我们将按如下方式导入Tkinter
:
>>> from Tkinter import *
对于 Python 3,例如版本 3.7.1,我们将按如下方式导入:
>>> from tkinter import *
为了简化这个过程,我们可以使用sys
模块来检测 Python 版本并导入相应的模块,如下所示:
import sys
if sys.version_info[0] == 2:
from Tkinter import *
elif sys.version_info[0] == 3:
from tkinter import *
本节展示了一个创建 TkInter GUI 的简单示例。在前七行中,我们导入了创建界面所需的两个模块。这种导入方式虽然复杂,但可以让我们以 Python 2 或 Python 3 特定的方式导入这两个模块。
第一个模块导入了所有 TkInter GUI 设计所需的默认对象。ttk
模块导入了主题 TkInter 包,根据主机操作系统应用额外的界面格式化,是改善界面外观的简单方法。在最后一行,我们创建了根窗口。
当在 Python 解释器中输入时,执行最后一行应显示一个空白的 200 像素×200 像素的方形窗口,位于屏幕的左上角。尺寸和位置是默认设置,可以修改。请参见以下代码块:
>>> import sys
>>> if sys.version_info[0] == 2:
>>> from Tkinter import *
>>> import ttk
>>> elif sys.version_info[0] == 3:
>>> from tkinter import *
>>> import tkinter.ttk as ttk
>>> root = Tk()
以下截图展示了在 macOS 系统上执行代码块时创建的 TkInter 根窗口:
https://github.com/OpenDocCN/freelearn-sec-pt3-zh/raw/master/docs/lrn-py-frsc-2e/img/a0086a92-5426-4b23-b56e-a362f2462909.png
创建根窗口后,我们可以开始向界面添加元素。一个好的开始元素是标签。在后面提到的代码块中,我们将从主题ttk
包中添加一个标签到窗口:
>>> first_label = ttk.Label(root, text="Hello World")
Label
参数需要两个参数:要显示的父窗口和显示的文本。可以为标签分配其他属性,如字体和文本大小。
请注意,在执行代码块的第一行后,窗口不会更新。相反,我们必须指定如何在窗口内显示对象,使用其中一个可用的布局管理器。
TkInter 使用布局管理器来确定对象在窗口中的位置。常见的布局管理器有三种:grid
、pack
和 place
。
grid
布局管理器根据行和列的规范来放置元素。
pack
布局管理器更简单,它将元素彼此放置,无论是垂直还是水平,具体取决于指定的配置。
最后,place
布局管理器使用 x 和 y 坐标来放置元素,并且需要最多的维护和设计工作。
对于此示例,我们选择使用 pack
方法,如代码块的第二行所示。一旦我们描述了要使用的布局管理器,界面会更新,并显示标签:
>>> first_label.pack()
以下截图显示了将标签添加到我们的 GUI 中的效果:
https://github.com/OpenDocCN/freelearn-sec-pt3-zh/raw/master/docs/lrn-py-frsc-2e/img/56703f87-63e8-455d-9545-db2801e8fd93.png
如前面的截图所示,根窗口已经缩小以适应其元素的大小。此时,我们可以通过拖动边缘来调整窗口的大小,缩小或增大主窗口的尺寸。
让我们在 Label
对象周围添加一些空间。我们可以通过两种不同的技术来实现。第一种方法是在 Label
对象周围添加内边距,使用 .config()
方法。为了添加内边距,我们必须为 x 和 y 轴提供一个像素值的元组。
在此示例中,我们在 x 和 y 轴上都添加了 10 像素的内边距。当执行以下行时,它会在 GUI 中自动更新,因为布局管理器已经配置好:
>>> first_label.config(padding=(10,10))
内边距显示在以下截图中:
https://github.com/OpenDocCN/freelearn-sec-pt3-zh/raw/master/docs/lrn-py-frsc-2e/img/4f642c0a-eec7-4b71-9fe7-1580d538c3a2.png
这仅仅是为标签本身添加了内边距,而不是整个根窗口。要更改根窗口的尺寸,我们需要调用 geometry()
方法,并提供宽度、高度、距离屏幕左侧的距离和距离屏幕顶部的距离。
在以下示例中,我们将设置宽度为 200 像素,高度为 100 像素,距离屏幕左侧 30 像素,距离屏幕顶部 60 像素的偏移量:
>>> root.geometry('200x100+30+60')
新的 GUI 分辨率显示在以下截图中:
https://github.com/OpenDocCN/freelearn-sec-pt3-zh/raw/master/docs/lrn-py-frsc-2e/img/5eb7c94c-cbb8-4383-a1bb-fda167e633bc.png
根据你的操作系统,GUI 中的默认颜色可能会因可用的主题包而有所不同。
让我们介绍一下其他两个我们将使用的 GUI 元素:Entry
和Button
。现在我们将初始化Entry
对象,它允许用户输入文本,程序可以捕获并使用这些文本。在第一行中,我们初始化了一个StringVar()
变量,它将与Entry
对象一起使用。与之前的脚本不同,我们需要设置特定的变量,以响应 GUI 接口的事件驱动特性:
>>> text = StringVar()
TkInter 支持多种特殊变量,例如用于字符串的StringVar()
函数、用于布尔值的BooleanVar()
、用于浮动数值的DoubleVar()
,以及用于整数的IntVar()
。每个这些对象都允许通过set()
方法设置值,并通过get()
方法获取值。上述代码展示了StringVar()
的初始化,将其设置为默认值,并将其分配给创建的Entry
元素,然后将其打包到根窗口中。最后,我们可以通过get()
方法获取用户输入:
>>> text.set("Enter Text Here")
>>> text_entry = ttk.Entry(root, textvariable=text)
>>> text_entry.pack()
>>> text.get()
'Hello World!'
以下两个连续的截图展示了我们实现的新代码块对 GUI 的更新:
https://github.com/OpenDocCN/freelearn-sec-pt3-zh/raw/master/docs/lrn-py-frsc-2e/img/b04742d7-c0c7-40c1-a53e-af9e54bc8f45.png
上面的截图展示了Entry
框中的默认文本,而下面的截图展示了修改值后的样子:
https://github.com/OpenDocCN/freelearn-sec-pt3-zh/raw/master/docs/lrn-py-frsc-2e/img/a7d2b484-88fd-4073-8c3f-bfe05ce16828.png
请注意,我们在执行text.get()
方法之前,将Hello World!
写入了Entry
对象中。
Button
对象用于在点击按钮时触发事件。为了启动操作,我们需要调用一个函数。
在下一个示例中,我们定义了clicked()
函数,它会打印一个字符串,如以下代码块所示。在这个函数之后,我们使用ttk
主题包定义了按钮,将按钮文本设置为Go
,并将函数名作为command
参数。将按钮打包到根窗口后,我们可以点击它,并在终端中看到打印的语句,如下面代码块的最后一行所示。虽然这个功能不是非常实用,但它演示了按钮如何调用一个操作。我们的脚本将进一步展示Button
对象及其命令参数的用途:
>>> def clicked():
... print "The button was clicked!"
...
>>> go = ttk.Button(root, text="Go", command=clicked)
>>> go.pack()
The button was clicked!
添加此按钮的效果如下截图所示:
https://github.com/OpenDocCN/freelearn-sec-pt3-zh/raw/master/docs/lrn-py-frsc-2e/img/f2fb6d53-b7d4-447c-b330-e65f5996d548.png
TkInter 提供了另一个我们将使用的对象,名为frame
。框架是我们可以放置信息的容器,它提供了额外的组织结构。在我们的最终界面中,将有两个框架。第一个是输入框架,包含所有用户交互的对象;第二个是输出框架,显示脚本处理的所有信息。在本章的最终代码中,两个frame
对象将是根窗口的子对象,并充当其中Label
、Entry
和/或Button
对象的父对象。
frame
对象的另一个好处是,每个框架可以使用自己的几何管理器。由于每个父对象只能使用一个几何管理器,这使得我们可以在整个 GUI 中使用多个不同的管理器。
在我们的脚本中,我们将使用pack()
管理器来组织根窗口中的框架,并使用grid()
管理器来组织每个框架内的元素。
在本书中我们尚未直接使用类;然而,类是设计 GUI 的首选方式。类允许我们构建一个可以包含函数和属性的对象。事实上,我们经常在不自觉中使用类。我们熟悉的对象,如datetime
对象,就是包含函数和属性的类。
尽管本书中类的内容并不多,但它们可能会让新开发者感到困惑,但对于更高级的脚本来说,它们是推荐的。我们将在本章简要介绍类,并建议随着你对 Python 的理解加深,进一步研究类。本章中涉及的类内容仅限于 GUI 示例。
类的定义语法与函数类似,我们使用class
关键字代替def
。一旦定义类,我们将函数嵌套在constructor
类中,以使这些函数可以通过class
对象调用。这些嵌套的函数称为方法,与我们从库中调用的方法是同义的。方法允许我们像函数一样执行代码。到目前为止,我们主要是将方法和函数交替使用。对此我们表示歉意,这样做是为了避免让你和我们都厌烦重复相同的词汇。
到目前为止,类看起来不过是函数的集合。那么到底是什么原因呢?类的真正价值在于,我们可以创建同一个类的多个实例,并为每个实例分配不同的值。进一步来说,我们可以对每个实例单独运行预定义的方法。举个例子,假设我们有一个时间类,其中每个时间都有一个关联的datetime
变量。我们可能决定将其中一些转换为 UTC 时间,而将其他保持在当前时区。这种隔离性使得在类中设计代码变得非常有价值。
类在 GUI 设计中非常有用,因为它们允许我们在函数之间传递值,而不需要额外的重复参数。这是通过self
关键字实现的,它允许我们在类中指定可在类实例及其所有方法中使用的值。
在下一个示例中,我们创建了一个名为SampleClass
的类,它继承自object
。这是类定义的基本设置,虽然还有更多可用的参数,但我们将在本章中专注于基础内容。在第 2 行,我们定义了第一个名为__init__()
的方法,这是一个特殊的函数。你可能会注意到,它有双前导和尾部下划线,类似于我们在脚本中创建的if __name__ == '__main__'
语句。如果类中存在__init__()
方法,它将在类初始化时执行。
在示例中,我们定义了__init__()
方法,传递self
和init_cost
作为参数。self
参数必须是任何方法的第一个参数,并允许我们引用存储在self
关键字下的值。接下来,init_cost
是一个变量,必须在类首次被用户调用时设置。在第 3 行,我们将用户提供的init_cost
值赋给self.cost
。将参数(除了self
)赋值为类实例化时的类变量是一种惯例。在第 4 行,我们定义了第二个方法number_of_nickels()
,并将self
值作为其唯一参数。在第 5 行,我们通过返回self.cost * 20
的整数来完成类的定义,如下所示:
>>> class SampleClass(object):
... def __init__(self, init_cost):
... self.cost = init_cost
... def number_of_nickels(self):
... return int(self.cost * 20)
...
接下来,我们将s1
初始化为SampleClass
类的一个实例,并将初始值设置为24.60
。然后,我们通过使用s1.cost
属性来调用它的值。s1
变量引用了SampleClass
的一个实例,并授予我们访问类内方法和值的权限。我们在s1
上调用number_of_nickels()
方法,并将其存储的值更改为15
,这会更新number_of_nickels()
方法的结果。接着,我们定义了s2
并为其分配了不同的值。即使我们运行相同的方法,我们也只能查看与特定类实例相关的数据:
>>> s1 = SampleClass(24.60)
>>> s1.cost
24.6
>>> s1.number_of_nickels()
492
>>> s1.cost = 15
>>> s1.number_of_nickels()
300
>>> s2 = SampleClass(10)
>>> s2.number_of_nickels()
200
该脚本已在 Python 2.7.15 和 3.7.1 版本中进行了测试,并使用了python-dateutil
(版本 2.7.5)第三方库,可以通过以下命令使用pip
进行安装:
pip install python-dateutil==2.7.5
在介绍完时间戳、GUI 开发和 Python 类之后,让我们开始开发date_decoder.py
脚本。我们将设计一个具有两个主要功能的 GUI,供最终用户进行交互。
首先,GUI 允许用户输入来自文物的时间戳原生格式,并将其转换为人类可读的时间。第二个功能允许用户输入人类可读的时间戳,并选择一个选项将其转换为相应的机器时间。为了构建这个功能,我们将使用一个输入框、几个标签以及不同类型的按钮供用户与界面进行交互。
所有通过此代码处理的日期都假定使用本地机器时间作为时区。请确保将所有时间戳来源转换为统一的时区,以简化分析。
与其他脚本一样,代码从导入语句开始,后跟作者信息。在导入datetime
和logging
之后,我们根据 Python 2 和 Python 3 的条件导入 TkInter 和主题资源模块。然后我们导入dateutil
,正如之前讨论的,它将处理日期解析和转换操作。接着我们设置脚本的许可协议、文档和日志记录值:
001 """Example usage of Tkinter to convert dates."""
002 import datetime
003 import logging
004 import sys
005 if sys.version_info[0] == 2:
006 from Tkinter import *
007 import ttk
008 elif sys.version_info[0] == 3:
009 from tkinter import *
010 import tkinter.ttk as ttk
011 from dateutil import parser as duparser
...
042 __authors__ = ["Chapin Bryce", "Preston Miller"]
043 __date__ = 20181027
044 __description__ = '''This script uses a GUI to show date values
045 interpreted by common timestamp formats'''
046 logger = logging.getLogger(__name__)
我们首先定义了 GUI 的属性,如窗口的尺寸、背景和标题,并创建了根窗口。在配置完 GUI 的基础设置后,我们用之前讨论的控件填充 GUI。界面设计完成后,我们创建处理事件的方法,如转换时间戳并将结果显示在 GUI 中。我们没有使用通常的main()
函数,而是创建了这个类的实例,它将在执行时启动 GUI 窗口。
代码从声明DateDecoder
类及其__init__()
方法开始。该方法不需要传递任何参数,因为我们将通过 GUI 接收所有输入值和设置。接下来定义的函数是第 74 行的run()
控制器。这个控制器调用设计 GUI 的函数,并启动该 GUI:
049 class DateDecoder(object):
...
054 def __init__():
...
074 def run():
为了以结构化的方式展示 GUI,我们需要将 GUI 分成多个功能单元。在第 84 和 119 行的方法中,我们创建了组成 GUI 的输入和输出框架。这些框架包含与其功能相关的控件,并且由自己的几何布局控制:
084 def build_input_frame():
...
119 def build_output_frame():
界面设计完成后,我们可以专注于处理逻辑操作和按钮点击事件的功能。convert()
方法用于调用时间戳转换器,将值解析为日期。
这些转换器是针对每个受支持的时间戳定义的,定义在第 175、203 和 239 行。我们的最后一个类方法output()
用于更新界面。这个方法可能会让人误解,因为我们脚本中的之前的output()
函数通常会创建某种报告。在这个例子中,我们将使用这个output()
方法来更新 GUI,向用户展示信息,以一种有组织且有帮助的方式:
151 def convert():
...
175 def convert_unix_seconds():
...
203 def convert_win_filetime_64():
...
239 def convert_chrome_time():
...
183 def output():
与前几章不同,这个函数不需要处理命令行参数。然而,我们仍然设置了日志记录,并实例化并运行我们的图形用户界面(GUI)。此外,从第 202 行开始,我们使用基本的日志记录约定初始化一个日志记录器。由于没有传递命令行参数给该脚本,我们将日志文件路径硬编码。在第 211 和 212 行,初始化类并调用run()
方法,以便创建并显示我们的 GUI,如下所示:
286 if __name__ == '__main__':
287 """
288 This statement is used to initialize the GUI. No
289 arguments needed as it's a graphic interface
290 """
291 # Initialize Logging
292 log_path = 'date_decoder.log'
293
294 logger.setLevel(logging.DEBUG)
295 msg_fmt = logging.Formatter("%(asctime)-15s %(funcName)-20s"
296 "%(levelname)-8s %(message)s")
297 fhndl = logging.FileHandler(log_path, mode='a')
298 fhndl.setFormatter(fmt=msg_fmt)
299 logger.addHandler(fhndl)
300
301 logger.info('Starting Date Decoder v. {}'.format(__date__))
302 logger.debug('System ' + sys.platform)
303 logger.debug('Version ' + sys.version.replace("\n", " "))
304
305 # Create Instance and run the GUI
306 dd = DateDecoder()
307 dd.run()
由于流程图过宽,我们将其拆分为两张截图。第一张截图展示了设置DateDecoder
类和初始run()
调用的流程,后者创建了我们的框架:
https://github.com/OpenDocCN/freelearn-sec-pt3-zh/raw/master/docs/lrn-py-frsc-2e/img/8c62754e-4882-4165-86cb-6b51cd050f3b.png
第二张截图显示了操作代码的流程,其中我们的转换函数调用特定的时间转换函数,然后调用我们的output()
函数将其显示给用户:
https://github.com/OpenDocCN/freelearn-sec-pt3-zh/raw/master/docs/lrn-py-frsc-2e/img/c1d70252-399a-49d3-b824-6e25463917ac.png
__init__()
方法我们使用class
关键字初始化我们的类,后跟类名,并将object
参数作为第 49 行所示的参数传递。最佳实践是使用 camelCase 约定命名类,并使用下划线命名方法以避免混淆。在第 50 行,我们定义了前面描述的__init__()
特殊方法,只有self
参数。此类在初始化时不需要任何用户输入,因此我们不需要考虑添加额外的参数。请看以下代码:
049 class DateDecoder(object):
050 """
051 The DateDecoder class handles the construction of the GUI
052 and the processing of date & time values
053 """
054 def __init__(self):
055 """
056 The __init__ method initializes the root GUI window and
057 variable used in the script
058 """
在第 60 行,我们创建了 GUI 的根窗口,并将其赋值给self
对象内的一个值。这使我们能够在类中的其他方法中引用它和使用self
创建的任何其他对象,而无需将其作为参数传递,因为self
参数存储了整个类实例中可用的值。在第 61 行,我们定义了窗口的大小,宽度为 500 像素,高度为 180 像素,并在屏幕的顶部和左侧各偏移了 40 像素。
为了改善界面的外观,我们已经添加了背景颜色以反映 macOS 上显示的主题,尽管这可以设置为任何十六进制颜色,如第 62 行所示。最后,我们修改了根窗口的标题属性,给它一个显示在 GUI 窗口顶部的名称:
059 # Init root window
060 self.root = Tk()
061 self.root.geometry("500x180+40+40")
062 self.root.config(background = '#ECECEC')
063 self.root.title('Date Decoder')
在初始化 GUI 定义之后,我们需要为重要变量设置基础值。虽然这不是必需的,但通常最好的做法是在__init__()
方法中创建共享值,并使用默认值定义它们。在定义将存储我们处理过的时间值的三个类变量后,我们还定义了基于 1601 年和 1970 年的时间戳的时代常量。代码如下:
065 # Init time values
066 self.processed_unix_seconds = None
067 self.processed_windows_filetime_64 = None
068 self.processed_chrome_time = None
069
070 # Set Constant Epoch Offset
071 self.epoch_1601 = 11644473600000000
072 self.epoch_1970 = datetime.datetime(1970,1,1)
__init__()
方法应该用于初始化类属性。在某些情况下,您可能希望该类还运行类的主要操作,但我们不会在我们的代码中实现该功能。我们将运行时操作分离到一个名为run()
的新方法中,以允许我们启动特定于运行主代码的操作。这允许用户在启动 GUI 之前更改类配置信息。
以下方法非常简短,由对我们稍后讨论的其他方法的函数调用组成。这包括为 GUI 构建输入和输出帧以及启动主事件监听器循环。由于类已经初始化了__init__()
方法中找到的变量,我们可以安全地引用这些对象,如下所示:
074 def run(self):
075 """
076 The run method calls appropriate methods to build the
077 GUI and set's the event listener loop.
078 """
079 logger.info('Launching GUI')
080 self.build_input_frame()
081 self.build_output_frame()
082 self.root.mainloop()
build_input_frame()
方法是frame
小部件的第一次实例化,定义在第 90 到 92 行。与我们在早期示例中定义此元素的方式类似,我们调用了主题化的frame
小部件,并将self.root
对象作为此框架的父级窗口。在第 91 行,我们在框架的X轴周围添加了30
像素的填充,然后在第 92 行使用pack()
几何管理器。由于每个窗口或框架只能使用一个几何管理器,因此我们现在必须在添加到root
对象的任何附加框架或小部件上使用pack()
管理器:
084 def build_input_frame(self):
085 """
086 The build_input_frame method builds the interface for
087 the input frame
088 """
089 # Frame Init
090 self.input_frame = ttk.Frame(self.root)
091 self.input_frame.config(padding = (30,0))
092 self.input_frame.pack()
创建框架后,我们开始向框架中添加小部件以供用户输入。在第 95 行,我们使用新的input_frame
作为父级创建一个标签,文本为Enter Time Value
。此标签被放置在网格的第一行和第一列。使用网格管理器时,第一个位置将是左上角位置,其他所有元素将围绕它布局。由于我们之后不需要调用这个标签,所以不将其分配给变量,而是可以直接调用.grid()
方法将其添加到我们的 GUI 中:
094 # Input Value
095 ttk.Label(self.input_frame,
096 text="Enter Time Value").grid(row=0, column=0)
在第 98 行,我们初始化StringVar()
,该变量用于存储用户输入的字符串。我们将在代码的各个地方引用此对象和信息,因此希望将其分配给对象self.input_time
。
在第 99 行,我们创建另一个小部件,这次是Entry
,并且同样不将其分配给变量,因为在创建后我们不需要操作该元素。我们需要从该元素获取的信息将存储在self.input_time
变量中。为了指示Entry
对象将值存储在此对象中,我们必须将对象名称作为textvariable
参数传递。我们还将字段的宽度指定为 25 个字符,使用grid()
方法将其添加到 GUI 中,并将其放置在标签的下一列:
098 self.input_time = StringVar()
099 ttk.Entry(self.input_frame, textvariable=self.input_time,
100 width=25).grid(row=0, column=1, padx=5)
在创建输入区域后,我们必须为用户提供选项,以便指定输入类型。这使得用户可以选择源是机器可读格式还是人类可读格式。我们创建另一个StringVar()
变量来保存用户选择的值。
由于我们希望默认操作是将原始时间戳转换为格式化时间戳,我们在第 104 行调用set()
方法,在self.time_type
变量上自动选择第 106 行创建的raw
单选按钮。
在第 106 行,我们创建第一个单选按钮,将输入框架作为父级,单选按钮标签设置为Raw Value
,并将反映用户是否选择了该单选按钮的变量设置为self.time_type
。最后,我们使用网格管理器显示此按钮。在第 110 行,我们创建第二个单选按钮,文本和值设置为反映格式化的时间戳输入。此外,我们将此单选按钮放置在与第一个单选按钮相邻的同一行的列中。请查看以下代码:
102 # Radiobuttons
103 self.time_type = StringVar()
104 self.time_type.set('raw')
105
106 ttk.Radiobutton(self.input_frame, text="Raw Value",
107 variable=self.time_type, value="raw").grid(row=1,
108 column=0, padx=5)
109
110 ttk.Radiobutton(self.input_frame, text="Formatted Value",
111 variable=self.time_type, value="formatted").grid(
112 row=1, column=1, padx=5)
最后,我们构建用于提交来自 Entry
字段的数据进行处理的按钮。该按钮的设置与其他小部件类似,唯一不同的是添加了 command
关键字,当按钮被点击时,它会执行指定的方法。然后,我们将 convert()
方法分配为按钮的点击动作。
该方法在没有额外参数的情况下启动,因为它们被存储在 self
属性中。我们通过网格管理器将此元素添加到界面中,使用 columnspan
属性将信息跨越两个或更多列。我们还使用 pady
(垂直间距)属性在输入字段和按钮之间提供一些垂直空间:
114 # Button
115 ttk.Button(self.input_frame, text="Run",
116 command=self.convert).grid(
117 row=2, columnspan=2, pady=5)
build_output_frame()
方法输出框架的设计类似于输入框架。不同之处在于,我们需要将小部件保存到变量中,以确保在处理日期值时可以更新它们。在方法和文档字符串定义之后,我们创建 output_frame
并配置框架的高度和宽度。因为我们在根窗口中使用了 pack()
管理器,所以必须继续使用它将该框架添加到 GUI 的根窗口中:
119 def build_output_frame(self):
120 """
121 The build_output_frame method builds the interface for
122 the output frame
123 """
124 # Output Frame Init
125 self.output_frame = ttk.Frame(self.root)
126 self.output_frame.config(height=300, width=500)
127 self.output_frame.pack()
初始化之后,我们向 output_frame
添加各种小部件。所有输出小部件都是标签,因为它们允许我们轻松地向用户显示字符串值,而不会增加额外的负担。完成此任务的另一种方法是将输出放入文本输入框中并将其标记为只读。或者,我们可以创建一个大型文本区域,方便用户复制。两者都是本章末尾指定的挑战,供您在自己的 GUI 实现中进行更多实验。
第一个标签元素名为 转换结果
,并通过 pack(fill=X)
方法在第 134 行居中。此方法填充 x 轴的区域,并垂直堆叠所有已打包的兄弟元素。在第 131 行创建标签后,我们使用 config()
方法配置字体大小,并将一个元组传递给 font
关键字。此参数的第一个元素应该是字体名称,第二个元素是字体大小。如果省略字体名称,我们将保留默认字体,并仅修改大小:
129 # Output Area
130 ## Label for area
131 self.output_label = ttk.Label(self.output_frame,
132 text="Conversion Results")
133 self.output_label.config(font=("", 16))
134 self.output_label.pack(fill=X)
以下三个标签代表支持的每个时间戳的结果。所有三个标签都将输出框架作为它们的父窗口,并将其文本设置为反映时间戳类型以及默认的 N/A
值。最后,每个标签都调用 pack(fill=X)
方法,以便在框架内正确居中和垂直排列值。我们必须将这三个标签分配给变量,以便在处理后更新它们的值,反映转换后的时间戳。标签在此处设置:
136 ## For Unix Seconds Timestamps
137 self.unix_sec = ttk.Label(self.output_frame,
138 text="Unix Seconds: N/A")
139 self.unix_sec.pack(fill=X)
140
141 ## For Windows FILETIME 64 Timestamps
142 self.win_ft_64 = ttk.Label(self.output_frame,
143 text="Windows FILETIME 64: N/A")
144 self.win_ft_64.pack(fill=X)
145
146 ## For Chrome Timestamps
147 self.google_chrome = ttk.Label(self.output_frame,
148 text="Google Chrome: N/A")
149 self.google_chrome.pack(fill=X)
convert()
方法一旦用户点击输入框中的按钮,convert()
方法就会被调用。这个方法负责验证输入、调用转换器,并将结果写入上一节中构建的标签中。可以说,这个方法替代了通常的 main()
方法。在初步定义和文档字符串之后,我们记录了用户提供的时间戳和格式(原始或格式化)。这有助于跟踪活动并排查可能出现的错误:
151 def convert(self):
152 """
153 The convert method handles the event when the button is
154 pushed. It calls to the converters and updates the
155 labels with new output.
156 """
157 logger.info('Processing Timestamp: {}'.format(
158 self.input_time.get()))
159 logger.info('Input Time Format: {}'.format(
160 self.time_type.get()))
首先,在第 163 行到第 165 行之间,我们将三个时间戳变量的值重置为 N/A
,以清除当应用程序重新运行时可能存在的任何残留值。然后,我们在第 168 行到第 170 行调用了处理时间戳转换的三个方法。这些方法是独立的,它们会更新三个时间戳参数的值,而不需要我们返回任何值或传递参数。
正如你所看到的,self
关键字确实帮助我们简化了类的定义,因为它提供了访问共享类变量的方式。在第 173 行,我们调用了 output()
方法,将新转换的格式写入到图形界面中:
162 # Init values every instance
163 self.processed_unix_seconds = 'N/A'
164 self.processed_windows_filetime_64 = 'N/A'
165 self.processed_chrome_time = 'N/A'
166
167 # Use this to call converters
168 self.convert_unix_seconds()
169 self.convert_win_filetime_64()
170 self.convert_chrome_time()
171
172 # Update labels
173 self.output()
convert_unix_seconds()
方法Unix 时间戳是我们在本章中转换的三种时间戳中最直接的一种。在第 175 行到第 179 行之间,我们定义了方法及其文档字符串,然后进入了一个 if
语句。第 180 行的 if
语句判断之前描述的单选按钮的值是否等于 raw
字符串或 formatted
。如果设置为 raw
,我们将解析时间戳为自 1970-01-01 00:00:00.0000000
以来的秒数。这相对简单,因为这是 datetime.datetime.fromtimestamp()
方法使用的纪元。在这种情况下,我们只需将输入转换为浮动数值,如第 182 行和第 183 行所示,再进行转换。
然后,在第 183 行和第 184 行之间,我们将新创建的 datetime
对象格式化为 YYYY-MM-DD HH:MM:SS
格式的字符串。第 182 行的逻辑被包裹在一个 try-except 语句中,以捕获任何错误并将其报告到日志文件和用户界面中,以简化的形式显示出来。这使得我们能够在输入日期时测试每个公式。第 188 行概述了当我们无法成功转换时间戳时,转换错误将被显示出来。这将提醒用户发生了错误,并让他们判断是否是预期的错误。
175 def convert_unix_seconds(self):
176 """
177 The convert_unix_seconds method handles the conversion of
178 timestamps per the Unix seconds format
179 """
180 if self.time_type.get() == 'raw':
181 try:
182 dt_val = datetime.datetime.fromtimestamp(
183 float(self.input_time.get())).strftime(
184 '%Y-%m-%d %H:%M:%S')
185 self.processed_unix_seconds = dt_val
186 except Exception as e:
187 logger.error(str(type(e)) + "," + str(e))
188 self.processed_unix_seconds = str(
189 type(e).__name__)
如果时间戳是格式化值,我们首先需要解析输入,然后尝试将其转换为 Unix 时间戳,因为它可能不符合预期格式。一旦通过 dateutil.parser
进行转换,我们就可以使用预定义的纪元对象计算时间戳与纪元之间的秒数差异,如第 195 行到第 197 行所示。如果发生错误,它将像前面的 if
语句一样被捕获,记录下来并显示给用户,如下所示:
191 elif self.time_type.get() == 'formatted':
192 try:
193 converted_time = duparser.parse(
194 self.input_time.get())
195 self.processed_unix_seconds = str(
196 (converted_time - self.epoch_1970
197 ).total_seconds())
198 except Exception as e:
199 logger.error(str(type(e)) + "," + str(e))
200 self.processed_unix_seconds = str(
201 type(e).__name__)
convert_win_filetime_64()
方法进行转换Microsoft Windows 的 FILETIME 值的转换稍微复杂一些,因为它使用1601-01-01 00:00:00
作为纪元,并从那个时间点起以 100 纳秒为单位计时。为了正确转换这个时间戳,我们需要比前面的部分多进行几个步骤。
这个方法与上一个方法相同,包含if
-else
语法来识别时间戳类型。如果是原始格式,我们必须将输入的十六进制字符串转换为基于 10 的十进制数,这在第 210 和 211 行通过int(value, 16)
类型转换实现。这允许我们告诉int()
将基数为 16 的值转换为十进制(基数为 10)。基数为 16 的值通常被称为十六进制值。
一旦转换,整数就表示自纪元以来 100 纳秒为单位的时间,因此我们所要做的就是将微秒转换为datetime
值,然后加上纪元的datetime
对象。在第 212 到 214 行,我们使用datetime.timedelta()
方法生成一个可以用于添加到纪元datetime
的对象。一旦转换完成,我们需要将datetime
对象格式化为时间字符串,并将其赋值给相应的标签。错误处理与之前的转换器相同,转换错误将显示如下:
203 def convert_win_filetime_64(self):
204 """
205 The convert_win_filetime_64 method handles the
206 conversion of timestamps per the Windows FILETIME format
207 """
208 if self.time_type.get() == 'raw':
209 try:
210 base10_microseconds = int(
211 self.input_time.get(), 16) / 10
212 datetime_obj = datetime.datetime(1601,1,1) + \
213 datetime.timedelta(
214 microseconds=base10_microseconds)
215 dt_val = datetime_obj.strftime(
216 '%Y-%m-%d %H:%M:%S.%f')
217 self.processed_windows_filetime_64 = dt_val
218 except Exception as e:
219 logger.error(str(type(e)) + "," + str(e))
220 self.processed_windows_filetime_64 = str(
221 type(e).__name__)
如果输入的时间戳是格式化的值,我们需要反向转换。我们之前在第 212 行使用datetime.timedelta()
方法时采取了一些捷径。当反向转换时,我们需要手动计算微秒数,然后再转换为十六进制。
首先,在第 225 行,我们将数据从字符串转换为datetime
对象,以便开始处理这些值。然后,我们从转换后的时间中减去纪元值。减法之后,我们将datetime.timedelta
对象转换为微秒值,基于存储的三个值。我们需要将秒数乘以一百万,将天数乘以 864 亿,以将每个值转换为微秒。最后,在第 229 到 231 行,我们几乎准备好将时间戳转换,经过加和这三个值之后:
223 elif self.time_type.get() == 'formatted':
224 try:
225 converted_time = duparser.parse(
226 self.input_time.get())
227 minus_epoch = converted_time - \
228 datetime.datetime(1601,1,1)
229 calculated_time = minus_epoch.microseconds + \
230 (minus_epoch.seconds * 1000000) + \
231 (minus_epoch.days * 86400000000)
在第 232 和 233 行,我们通过将最内层的calculated_time
转换为整数来执行转换。在整数状态下,它会乘以 10,转换为 100 纳秒组数,然后通过hex()
类型转换将其转换为十六进制。由于代码要求输出为字符串,我们将十六进制值转换为字符串,如第 232 行的外部包装所示,然后将其赋值给self.processed_windows_filetime_64
变量。
与其他转换函数类似,我们在第 234 到 237 行将错误处理加入到转换器中:
232 self.processed_windows_filetime_64 = str(
233 hex(int(calculated_time)*10))
234 except Exception as e:
235 logger.error(str(type(e)) + "," + str(e))
236 self.processed_windows_filetime_64 = str(
237 type(e).__name__)
我们展示的最后一个时间戳是 Google Chrome 时间戳,它与之前提到的两个时间戳类似。这个时间戳是自1601-01-01 00:00:00
纪元以来的微秒数。我们将利用前面定义的self.unix_epcoh_offset
值来帮助转换。在第 248 行,我们开始通过一系列函数转换原始时间戳。
首先,我们将时间戳转换为浮动数,并减去 1601 纪元常量。接下来,我们将该值除以一百万,将微秒转换为秒,以便datetime.datetime.fromtimestamp()
方法可以正确地解释该值。最后,在第 251 行,我们使用strftime()
函数将converted_time
格式化为字符串。在第 253 到 255 行,我们处理可能由于无效值而引发的异常,正如之前部分所示:
239 def convert_chrome_time(self):
240 """
241 The convert_chrome_time method handles the
242 conversion of timestamps per the Google Chrome
243 timestamp format
244 """
245 # Run Conversion
246 if self.time_type.get() == 'raw':
247 try:
248 dt_val = datetime.datetime.fromtimestamp(
249 (float(self.input_time.get()
250 )-self.epoch_1601)/1000000)
251 self.processed_chrome_time = dt_val.strftime(
252 '%Y-%m-%d %H:%M:%S.%f')
253 except Exception as e:
254 logger.error(str(type(e)) + "," + str(e))
255 self.processed_chrome_time = str(type(e).__name__)
当传递一个格式化的值作为输入时,我们必须逆转该过程。与其他函数一样,我们使用duparser.parse()
方法将输入从字符串转换为datetime
对象。一旦转换完成,我们通过将 1601 纪元常量加到total_seconds()
方法来计算秒数。
这些秒数会乘以一百万,转换为微秒。计算完成后,我们可以将该整数值转换为字符串,并在我们的 GUI 中显示。如果发生任何错误,我们会在第 264 到 266 行捕获它们,就像之前的方法一样:
257 elif self.time_type.get() == 'formatted':
258 try:
259 converted_time = duparser.parse(
260 self.input_time.get())
261 chrome_time = (converted_time - self.epoch_1970
262 ).total_seconds()*1000000 + self.epoch_1601
263 self.processed_chrome_time = str(int(chrome_time))
264 except Exception as e:
265 logger.error(str(type(e)) + "," + str(e))
266 self.processed_chrome_time = str(type(e).__name__)
该类的最后一个方法是output()
方法,它更新了 GUI 底部框架上的标签。这个简单的结构允许我们评估处理后的值,并在它们是字符串类型时显示出来。正如第 273 行所见,在方法定义和文档字符串之后,我们检查self.processed_unix_seconds
的值是否为字符串类型。
如果是字符串类型,则我们通过调用text
属性作为字典键来更新标签,如第 274 行和 275 行所示。这也可以通过使用config()
方法来实现,但在此实例中,使用这种方式更简单。当该属性发生变化时,标签会立即更新,因为该元素已经由几何管理器设置。这个行为会对每个需要更新的标签重复,如第 277 行到 283 行所示:
268 def output(self):
269 """
270 The output method updates the output frame with the
271 latest value.
272 """
273 if isinstance(self.processed_unix_seconds, str):
274 self.unix_sec['text'] = "Unix Seconds: " + \
275 self.processed_unix_seconds
276
277 if isinstance(self.processed_windows_filetime_64, str):
278 self.win_ft_64['text'] = "Windows FILETIME 64: " + \
279 self.processed_windows_filetime_64
280
281 if isinstance(self.processed_chrome_time, str):
282 self.google_chrome['text'] = "Google Chrome: " + \
283 self.processed_chrome_time
使用完整代码后,我们可以执行 GUI 并开始将日期从机器格式转换为人类可读格式,反之亦然。正如下面的截图所示,完成的 GUI 反映了我们的设计目标,并允许用户轻松地交互和处理日期:
https://github.com/OpenDocCN/freelearn-sec-pt3-zh/raw/master/docs/lrn-py-frsc-2e/img/dffb48b8-0436-4ba8-9d3a-fcf1d08e40e8.png
上面的截图还展示了我们输入一个格式化的时间值,并从我们的函数中获取三个转换后的原始时间戳。接下来,我们提供一个 Unix 秒格式的原始输入,并可以看到我们的 Unix 秒解析器返回了正确的日期:
https://github.com/OpenDocCN/freelearn-sec-pt3-zh/raw/master/docs/lrn-py-frsc-2e/img/87c3f71c-7585-412f-a8d3-56396478b67b.png
本脚本介绍了 GUI 以及我们通过 TkInter 模块可用的一些时间戳转换方法。这个脚本可以通过多种方式进行扩展。我们建议那些希望更好地了解 Python 中 GUI 开发的人尝试以下挑战。
如本章所述,我们只指定了三种在法医领域常见的格式的转换,并使用几种不同的方法提供转换。尝试为 FAT 目录时间戳条目添加支持,将其转换为原始格式和从原始格式转换。该脚本的设计使得添加额外的格式化器变得非常简单,只需定义原始和格式化的处理程序,添加标签到输出框架,并将方法名添加到 convert()
中。
此外,可以考虑将输出标签替换为输入字段,以便用户可以复制和粘贴结果。这个挑战的提示是查看 Entry
小部件的 set()
和 read-only
属性。
我们展示的最后一个挑战让用户可以指定一个时区,既可以通过命令行也可以通过 GUI 界面。pytz
库可能在这个任务中派上大用场。
在这一章中,我们讲解了如何在机器可读和人类可读的时间戳之间进行转换,并在 GUI 中显示这些信息。法医开发者的主要目标是能够快速设计和部署能够为调查提供洞察的工具。
然而,在这一章中,我们更多地关注了最终用户,花了一些额外的时间为用户构建了一个便于操作和互动的漂亮界面。本项目的代码可以从 GitHub 或 Packt 下载,如 前言 中所述。
在下一章中,我们将探讨分诊系统,以及如何使用 Python 从系统中收集重要的实时和易变数据。