在整本书中,配方包括与法证证据容器一起工作、解析移动和桌面操作系统的证据、从文档和可执行文件中提取嵌入式元数据,以及识别妥协指标等主题。您还将学习如何将脚本与应用程序接口(API)(如VirusTotal和PassiveTotal)以及工具(如Axiom、Cellebrite和EnCase)集成。到本书结束时,您将对Python有扎实的理解,并将知道如何在调查中使用它来处理证据。
《第一章》(part0029.html#RL0A0-260f9401d2714cb9ab693c4692308abe),基本脚本和文件信息配方,向您介绍了本书中使用的Python的约定和基本特性。在本章结束时,您将创建一个强大而有用的数据和元数据保存脚本。
《第二章》(part0071.html#23MNU0-260f9401d2714cb9ab693c4692308abe),创建证据报告配方,演示了使用法证证据创建报告的实用方法。从电子表格到基于Web的仪表板,我们展示了各种报告格式的灵活性和实用性。
《第三章》(part0097.html#2SG6I0-260f9401d2714cb9ab693c4692308abe),深入移动取证配方,介绍了iTunes备份处理、已删除的SQLite数据库记录恢复,以及从CellebriteXML报告中映射Wi-Fi接入点MAC地址。
《第四章》(part0127.html#3P3NE0-260f9401d2714cb9ab693c4692308abe),提取嵌入式元数据配方,揭示了包含嵌入式元数据的常见文件类型以及如何提取它。我们还向您提供了如何将Python脚本与流行的法证软件EnCase集成的知识。
《第五章》(part0158.html#4MLOS0-260f9401d2714cb9ab693c4692308abe),网络和妥协指标配方,侧重于网络和基于Web的证据,以及如何从中提取更多信息。您将学习如何从网站保留数据,与处理后的IEF结果交互,为X-Ways创建哈希集,并识别恶意域名或IP地址。
《第七章》(part0212.html#6A5N80-260f9401d2714cb9ab693c4692308abe),基于日志的证据配方,说明了如何处理来自多种日志格式的证据,并使用Python信息报告或其他行业工具(如Splunk)进行摄取。您还将学习如何开发和使用Python配方来解析文件并在Axiom中创建证据。
《第八章》(part0241.html#75QNI0-260f9401d2714cb9ab693c4692308abe),与法证证据容器配方一起工作,展示了与法证证据容器交互和处理所需的基本法证库,包括EWF和原始格式。您将学习如何从法证容器中访问数据,识别磁盘分区信息,并遍历文件系统。
第九章,探索Windows取证工件配方第一部分,利用了在第八章中开发的框架,处理取证证据容器配方,来处理取证证据容器中的各种Windows工件。这些工件包括$I回收站文件、各种注册表工件、LNK文件和Windows.edb索引。
第十章,探索Windows取证工件配方第二部分,继续利用在第八章中开发的框架,处理取证证据容器配方,来处理取证证据容器中的更多Windows工件。这些工件包括预取文件、事件日志、Index.dat、卷影副本和Windows10SRUM数据库。
为了跟随并执行本食谱中的配方,使用一台连接到互联网的计算机,并安装最新的Python2.7和Python3.5。配方可能需要安装额外的第三方库;有关如何执行此操作的说明将在配方中提供。
为了更轻松地开发和实施这些配方,建议您设置和配置一个Ubuntu虚拟机进行开发。这些配方(除非另有说明)是在Ubuntu16.04环境中使用Python2.7和3.5构建和测试的。一些配方将需要使用Windows操作系统,因为许多取证工具只能在此平台上运行。
如果您是数字取证检察官、网络安全专家或热衷于了解Python基础知识并希望将其提升到更高水平的分析师,那么这本书适合您。在学习的过程中,您将了解到许多适用于解析取证证据的库。您将能够使用和构建我们开发的脚本,以提升其分析能力。
本书中,您会经常看到几个标题(准备工作,如何做…,它是如何工作的…,还有更多…,以及另请参阅)。
为了清晰地说明如何完成一个配方,我们使用以下这些部分:
本节告诉您配方中可以期待什么,并描述了为配方设置任何软件或所需的任何初步设置的方法。
本节包含跟随配方所需的步骤。
本节通常包括对前一节中发生的事情的详细解释。
本节包含有关配方的其他信息,以使读者更加了解配方。
本节提供了有用的链接,以获取配方的其他有用信息。
在本书中,您会发现许多文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。
文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟URL、用户输入和Twitter用户名显示如下:“我们可以通过调用get_data()函数来收集所需的信息。”
代码块设置如下:
defhello_world():print(“HelloWorld!”)hello_world()任何命令行输入或输出都是按照以下格式编写的:
#pipinstalltqdm==4.11.2新术语和重要单词以粗体显示。您在屏幕上看到的单词,例如菜单或对话框中的单词,会以这种方式出现在文本中:“从管理面板中选择系统信息。”
警告或重要说明会以这种方式出现。提示和技巧会以这种方式出现。
本章涵盖了以下配方:
数字取证涉及识别和分析数字媒体,以协助法律、商业和其他类型的调查。我们分析的结果往往对调查的方向产生重大影响。鉴于“摩尔定律”或多或少成立,我们预期要审查的数据量正在稳步增长。因此,可以断定,调查人员必须依赖某种程度的自动化来有效地审查证据。自动化,就像理论一样,必须经过彻底的审查和验证,以免导致错误的结论。不幸的是,调查人员可能使用工具来自动化某些过程,但并不完全了解工具、潜在的取证物件或输出的重要性。这就是Python发挥作用的地方。
在《Python数字取证食谱》中,我们开发和详细介绍了一些典型场景的示例。目的不仅是演示Python语言的特性和库,还要说明它的一个巨大优势:即对物件的基本理解。没有这种理解,就不可能首先开发代码,因此迫使您更深入地理解物件。再加上Python的相对简单和自动化的明显优势,很容易理解为什么这种语言被社区如此迅速地接受。
确保调查人员理解我们脚本的产品的一种方法是提供有意义的文档和代码解释。这就是本书的目的。本书中演示的示例展示了如何配置参数解析,这既易于开发,又简单易懂。为了增加脚本的文档,我们将介绍有效记录脚本执行过程和遇到的任何错误的技术。
与操作系统和附加卷上找到的文件进行交互是数字取证中设计的任何脚本的核心。在分析过程中,我们需要访问和解析具有各种结构和格式的文件。因此,准确和正确地处理和与文件交互非常重要。本章介绍的示例涵盖了本书中将继续使用的常见库和技术:
配方难度:简单
Python版本:2.7或3.5
操作系统:任何
A人:我来这里是为了进行一场好的争论!
B人:啊,不,你没有,你来这里是为了争论!
A人:一个论点不仅仅是矛盾。
B人:好吧!可能吧!
旨在建立一个命题。
B人:不,不是!
A人:是的,是的!不仅仅是矛盾。
此脚本中使用的所有库都包含在Python的标准库中。虽然还有其他可用的参数处理库,例如optparse和ConfigParser,但我们的脚本将利用argparse作为我们的事实命令行处理程序。虽然optparse是以前版本的Python中使用的库,但argparse已成为创建参数处理代码的替代品。ConfigParser库从配置文件中解析参数,而不是从命令行中解析。这对于需要大量参数或有大量选项的代码非常有用。在本书中,我们不会涵盖ConfigParser,但如果发现您的argparse配置变得难以维护,值得探索一下。
在此脚本中,我们执行以下步骤:
首先,我们导入print_function和argparse模块。通过从__future__库导入print_function,我们可以像在Python3.X中编写打印语句一样编写它们,但仍然在Python2.X中运行它们。这使我们能够使配方与Python2.X和3.X兼容。在可能的情况下,我们在本书中的大多数配方中都这样做。
在创建有关配方的一些描述性变量之后,我们初始化了我们的ArgumentParser实例。在构造函数中,我们定义了description和epilog关键字参数。当用户指定-h参数时,这些数据将显示,并且可以为用户提供有关正在运行的脚本的额外上下文。argparse库非常灵活,如果需要,可以扩展其复杂性。在本书中,我们涵盖了该库的许多不同特性,这些特性在其文档页面上有详细说明:
from__future__importprint_functionimportargparse__authors__=["ChapinBryce","PrestonMiller"]__date__=20170815__description__='Asimpleargparseexample'parser=argparse.ArgumentParser(description=__description__,epilog="Developedby{}on{}".format(",".join(__authors__),__date__))创建了解析器实例后,我们现在可以开始向我们的命令行处理程序添加参数。有两种类型的参数:位置参数和可选参数。位置参数以字母开头,与可选参数不同,可选参数以破折号开头,并且需要执行脚本。可选参数以单个或双破折号字符开头,不是位置参数(即,顺序无关紧要)。如果需要,可以手动指定这些特性以覆盖我们描述的默认行为。以下代码块说明了如何创建两个位置参数:
#AddPositionalArgumentsparser.add_argument("INPUT_FILE",help="Pathtoinputfile")parser.add_argument("OUTPUT_FILE",help="Pathtooutputfile")除了更改参数是否必需,我们还可以指定帮助信息,创建默认值和其他操作。help参数有助于传达用户应提供的内容。其他重要参数包括default、type、choices和action。default参数允许我们设置默认值,而type将输入的类型(默认为字符串)转换为指定的Python对象类型。choices参数使用定义的列表、字典或集合来创建用户可以选择的有效选项。
action参数指定应用于给定参数的操作类型。一些常见的操作包括store,这是默认操作,用于存储与参数关联的传递值;store_true,将True分配给参数;以及version,打印由版本参数指定的代码版本:
#OptionalArgumentsparser.add_argument("--hash",help="Hashthefiles",action="store_true")parser.add_argument("--hash-algorithm",help="Hashalgorithmtouse.iemd5,sha1,sha256",choices=['md5','sha1','sha256'],default="sha256")parser.add_argument("-v","--version","--script-version",help="Displaysscriptversioninformation",action="version",version=str(__date__))parser.add_argument('-l','--log',help="Pathtologfile",required=True)当我们定义和配置了我们的参数后,我们现在可以解析它们并在我们的代码中使用提供的输入。以下片段显示了我们如何访问这些值并测试用户是否指定了可选参数。请注意我们如何通过我们分配的名称来引用参数。如果我们指定了短和长的参数名,我们必须使用长名:
#Parsingandusingtheargumentsargs=parser.parse_args()input_file=args.INPUT_FILEoutput_file=args.OUTPUT_FILEifargs.hash:ha=args.hash_algorithmprint("Filehashingenabledwith{}algorithm".format(ha))ifnotargs.log:print("Logfilenotdefined.Willwritetostdout")当组合成一个脚本并在命令行中使用-h参数执行时,上述代码将提供以下输出:
如此所示,-h标志显示了脚本帮助信息,由argparse自动生成,以及--hash-algorithm参数的有效选项。我们还可以使用-v选项来显示版本信息。--script-version参数以与-v或-version参数相同的方式显示版本,如下所示:
下面的屏幕截图显示了当我们选择我们的一个有效的哈希算法时在控制台上打印的消息:
这个脚本可以进一步改进。我们在这里提供了一些建议:
示例难度:简单
通常需要迭代一个目录及其子目录以递归处理所有文件。在这个示例中,我们将说明如何使用Python遍历目录并访问其中的文件。了解如何递归地浏览给定的输入目录是关键的,因为我们经常在我们的脚本中执行这个操作。
这个脚本中使用的所有库都包含在Python的标准库中。在大多数情况下,用于处理文件和文件夹迭代的首选库是内置的os库。虽然这个库支持许多有用的操作,但我们将专注于os.path()和os.walk()函数。让我们使用以下文件夹层次结构作为示例来演示Python中的目录迭代是如何工作的:
SecretDocs/|--key.txt|--Plans||--plans_0012b.txt||--plans_0016.txt|`--Successful_Plans||--plan_0001.txt||--plan_0427.txt|`--plan_0630.txt|--Spreadsheets||--costs.csv|`--profit.csv`--Team|--Contact18.vcf|--Contact1.vcf`--Contact6.vcf4directories,11files如何做…在这个示例中执行以下步骤:
我们创建了一个非常基本的参数处理程序,接受一个位置输入DIR_PATH,即要迭代的输入目录的路径。例如,我们将使用~/Desktop路径作为脚本的输入参数,它是SecretDocs的父目录。我们解析命令行参数并将输入目录分配给一个本地变量。现在我们准备开始迭代这个输入目录:
from__future__importprint_functionimportargparseimportos__authors__=["ChapinBryce","PrestonMiller"]__date__=20170815__description__="Directorytreewalker"parser=argparse.ArgumentParser(description=__description__,epilog="Developedby{}on{}".format(",".join(__authors__),__date__))parser.add_argument("DIR_PATH",help="Pathtodirectory")args=parser.parse_args()path_to_scan=args.DIR_PATH要迭代一个目录,我们需要提供一个表示其路径的字符串给os.walk()。这个方法在每次迭代中返回三个对象,我们已经在root、directories和files变量中捕获了这些对象:
在命名目录和文件变量时要小心。在Python中,dir和file名称被保留用于其他用途,不应该用作变量名。
#Iterateoverthepath_to_scanforroot,directories,filesinos.walk(path_to_scan):通常会创建第二个for循环,如下面的代码所示,以遍历该目录中的每个文件,并对它们执行某些操作。使用os.path.join()方法,我们可以将根目录和file_entry变量连接起来,以获取文件的路径。然后我们将这个文件路径打印到控制台上。例如,我们还可以将这个文件路径追加到一个列表中,然后对列表进行迭代以处理每个文件:
#Iterateoverthefilesinthecurrent"root"forfile_entryinfiles:#createtherelativepathtothefilefile_path=os.path.join(root,file_entry)print(file_path)我们也可以使用root+os.sep()+file_entry来实现相同的效果,但这不如我们使用的连接路径的方法那样符合Python的风格。使用os.path.join(),我们可以传递两个或更多的字符串来形成单个路径,比如目录、子目录和文件。
当我们用示例输入目录运行上述脚本时,我们会看到以下输出:
如所见,os.walk()方法遍历目录,然后会进入任何发现的子目录,从而扫描整个目录树。
这个脚本可以进一步改进。以下是一个建议:
现在我们可以遍历文件和文件夹,让我们学习如何记录这些对象的元数据。文件元数据在取证中扮演着重要的角色,因为收集和审查这些信息是大多数调查中的基本任务。使用单个Python库,我们可以跨平台收集一些最重要的文件属性。
此脚本中使用的所有库都包含在Python的标准库中。os库再次可以在这里用于收集文件元数据。收集文件元数据最有帮助的方法之一是os.stat()函数。需要注意的是,stat()调用仅提供当前操作系统和挂载卷的文件系统可用的信息。大多数取证套件允许检查员将取证图像挂载为系统上的卷,并通常保留stat调用可用的file属性。在第八章,使用取证证据容器配方中,我们将演示如何打开取证获取以直接提取文件信息。
我们将使用以下步骤记录文件属性:
print("Filemode:",stat_info.st_mode)print("Fileinode:",stat_info.st_ino)major=os.major(stat_info.st_dev)minor=os.minor(stat_info.st_dev)print("DeviceID:",stat_info.st_dev)print("\tMajor:",major)print("\tMinor:",minor)st_nlink属性返回文件的硬链接数。我们可以分别使用st_uid和st_gid属性打印所有者和组信息。最后,我们可以使用st_size来获取文件大小,它返回一个表示文件大小的整数(以字节为单位)。
请注意,如果文件是符号链接,则st_size属性反映的是指向目标文件的路径的长度,而不是目标文件的大小。
print("Numberofhardlinks:",stat_info.st_nlink)print("OwnerUserID:",stat_info.st_uid)print("GroupID:",stat_info.st_gid)print("FileSize:",stat_info.st_size)但等等,这还不是全部!我们可以使用os.path()模块来提取更多的元数据。例如,我们可以使用它来确定文件是否是符号链接,就像下面展示的os.islink()方法一样。有了这个,我们可以警告用户,如果st_size属性不等于目标文件的大小。os.path()模块还可以获取绝对路径,检查它是否存在,并获取父目录。我们还可以使用os.path.dirname()函数或访问os.path.split()函数的第一个元素来获取父目录。split()方法更常用于从路径中获取文件名:
方法难度:简单
操作系统:Windows
保留文件是数字取证中的一项基本任务。通常情况下,最好将文件容器化为可以存储松散文件的哈希和其他元数据的格式。然而,有时我们需要以数字取证的方式从一个位置复制文件到另一个位置。使用这个方法,我们将演示一些可用于复制文件并保留常见元数据字段的方法。
除了安装pywin32库之外,我们还需要安装pytz,这是一个第三方库,用于在Python中管理时区。我们可以使用pip命令安装这个库:
pipinstallpytz==2017.2如何做…我们执行以下步骤来在Windows系统上进行取证复制文件:
from__future__importprint_functionimportargparsefromdatetimeimportdatetimeasdtimportosimportpytzfrompywintypesimportTimeimportshutilfromwin32fileimportSetFileTime,CreateFile,CloseHandlefromwin32fileimportGENERIC_WRITE,FILE_SHARE_WRITEfromwin32fileimportOPEN_EXISTING,FILE_ATTRIBUTE_NORMAL__authors__=["ChapinBryce","PrestonMiller"]__date__=20170815__description__="Gatherfilesystemmetadataofprovidedfile"这个配方的命令行处理程序接受两个位置参数,source和dest,分别代表要复制的源文件和输出目录。这个配方有一个可选参数timezone,允许用户指定一个时区。
为了准备源文件,我们存储绝对路径并从路径的其余部分中分离文件名,如果目标是目录,则稍后可能需要使用。我们最后的准备工作涉及从用户那里读取时区输入,这是四个常见的美国时区之一,以及UTC。这使我们能够为后续在配方中使用初始化pytz时区对象:
为此,我们必须通过使用以下if语句构建copy2()调用复制的文件的目标路径,以便在命令行提供目录时连接正确的路径:
操作系统:任意
文件哈希是确定文件完整性和真实性的广泛接受的标识符。虽然一些算法已经容易受到碰撞攻击,但这个过程在这个领域仍然很重要。在这个配方中,我们将介绍对一串字符和文件内容流进行哈希处理的过程。
此脚本中使用的所有库都包含在Python的标准库中。为了生成文件和其他数据源的哈希值,我们实现了hashlib库。这个内置库支持常见的算法,如MD5、SHA-1、SHA-256等。在撰写本书时,许多工具仍然利用MD5和SHA-1算法,尽管当前的建议是至少使用SHA-256。或者,可以使用文件的多个哈希值来进一步减少哈希冲突的几率。虽然我们将展示其中一些算法,但还有其他不常用的算法可供选择。
我们使用以下步骤对文件进行哈希处理:
首先,我们必须像下面所示导入hashlib。为了方便使用,我们已经定义了一个算法字典,我们的脚本可以使用MD5、SHA-1、SHA-256和SHA-512。通过更新这个字典,我们可以支持其他具有update()和hexdigest()方法的哈希函数,包括一些不属于hashlib库的库中的函数:
from__future__importprint_functionimportargparseimporthashlibimportos__authors__=["ChapinBryce","PrestonMiller"]__date__=20170815__description__="Scripttohashafile'snameandcontents"available_algorithms={"md5":hashlib.md5,"sha1":hashlib.sha1,"sha256":hashlib.sha256,"sha512":hashlib.sha512}parser=argparse.ArgumentParser(description=__description__,epilog="Developedby{}on{}".format(",".join(__authors__),__date__))parser.add_argument("FILE_NAME",help="Pathoffiletohash")parser.add_argument("ALGORITHM",help="Hashalgorithmtouse",choices=sorted(available_algorithms.keys()))args=parser.parse_args()input_file=args.FILE_NAMEhash_alg=args.ALGORITHM注意我们如何使用字典和命令行提供的参数来定义我们的哈希算法对象,然后使用括号来初始化对象。这在添加新的哈希算法时提供了额外的灵活性。
定义了我们的哈希算法后,我们现在可以对文件的绝对路径进行哈希处理,这是在为iOS设备的iTunes备份命名文件时使用的类似方法,通过将字符串传递到update()方法中。当我们准备显示计算出的哈希的十六进制值时,我们可以在我们的file_name对象上调用hexdigest()方法:
file_name=available_algorithms[hash_alg]()abs_path=os.path.abspath(input_file)file_name.update(abs_path.encode())print("The{}ofthefilenameis:{}".format(hash_alg,file_name.hexdigest()))让我们继续打开文件并对其内容进行哈希处理。虽然我们可以读取整个文件并将其传递给hash函数,但并非所有文件都足够小以适应内存。为了确保我们的代码适用于更大的文件,我们将使用以下示例中的技术以分段方式读取文件并以块的方式进行哈希处理。
通过以rb打开文件,我们将确保读取文件的二进制内容,而不是可能存在的字符串内容。打开文件后,我们将定义缓冲区大小以读取内容,然后读取第一块数据。
进入while循环,我们将根据文件中的内容更新我们的哈希对象。只要文件中有内容,这是可能的,因为read()方法允许我们传递一个要读取的字节数的整数,如果整数大于文件中剩余的字节数,它将简单地传递给我们剩余的字节。
读取整个文件后,我们调用对象的hexdigest()方法来向检查员显示文件哈希:
file_content=available_algorithms[hash_alg]()withopen(input_file,'rb')asopen_file:buff_size=1024buff=open_file.read(buff_size)whilebuff:file_content.update(buff)buff=open_file.read(buff_size)print("The{}ofthecontentis:{}".format(hash_alg,file_content.hexdigest()))当我们执行代码时,我们会看到两个打印语句的输出,显示文件的绝对路径和内容的哈希值。我们可以通过在命令行中更改算法来为文件生成额外的哈希:
pipinstalltqdm==4.11.2如何做…要创建一个简单的进度条,我们按照以下步骤进行:
contains_berry=0forfruitintqdm.tqdm(fruits):if"berr"infruit.lower():contains_berry+=1sleep(.1)print("{}fruitnamescontain'berry'or'berries'".format(contains_berry))通过指定关键字参数,可以轻松地扩展默认配置以超出进度条。进度条对象也可以在循环开始之前创建,并使用列表对象fruits作为可迭代参数。以下代码展示了如何使用列表、描述和提供单位名称定义我们的进度条。
一旦我们进入循环,我们可以使用set_postfix()方法显示发现的结果数量。每次迭代都会在进度条右侧提供我们找到的命中数量的更新:
contains_berry=0pbar=tqdm.tqdm(fruits,desc="Reviewingnames",unit="fruits")forfruitinpbar:if"berr"infruit.lower():contains_berry+=1pbar.set_postfix(hits=contains_berry)sleep(.1)print("{}fruitnamescontain'berry'or'berries'".format(contains_berry))进度条的另一个常见用途是在一系列整数中测量执行。由于这是该库的常见用法,开发人员在库中构建了一个称为trange()的范围调用。请注意,我们可以在这里指定与之前相同的参数。由于数字较大,我们将在此处使用一个新参数unit_scale,它将大数字简化为一个带有字母表示数量的小数字:
foriintqdm.trange(10000000,unit_scale=True,desc="Trange:"):pass当我们执行代码时,将显示以下输出。我们的第一个进度条显示默认格式,而第二个和第三个显示了我们添加的自定义内容:
食谱难度:简单
进度条之外,我们通常需要向用户提供消息,描述执行过程中发生的任何异常、错误、警告或其他信息。通过日志记录,我们可以在执行过程中提供这些信息,并在文本文件中供将来参考。
此脚本中使用的所有库都包含在Python的标准库中。本食谱将使用内置的logging库向控制台和文本文件生成状态消息。
以下步骤可用于有效记录程序执行数据:
from__future__importprint_functionimportloggingimportsyslogger=logging.getLogger(__file__)logger.setLevel(logging.DEBUG)msg_fmt=logging.Formatter("%(asctime)-15s%(funcName)-20s""%(levelname)-8s%(message)s")处理程序允许我们指定日志消息应记录在哪里,包括日志文件、标准输出(控制台)或标准错误。在下面的示例中,我们使用标准输出作为我们的流处理程序,并使用脚本名称加上.log扩展名作为文件处理程序。最后,我们将这些处理程序注册到我们的记录器对象中:
strhndl=logging.StreamHandler(sys.stdout)strhndl.setFormatter(fmt=msg_fmt)fhndl=logging.FileHandler(__file__+".log",mode='a')fhndl.setFormatter(fmt=msg_fmt)logger.addHandler(strhndl)logger.addHandler(fhndl)日志库默认使用以下级别,按严重性递增:NOTSET、DEBUG、INFORMATION、WARNING、ERROR和CRITICAL。为了展示格式字符串的一些特性,我们将从函数中记录几种类型的消息:
logger.info("informationmessage")logger.debug("debugmessage")deffunction_one():logger.warning("warningmessage")deffunction_two():logger.error("errormessage")function_one()function_two()当我们执行此代码时,我们可以看到从脚本调用中获得的以下消息信息。检查生成的日志文件与在控制台中记录的内容相匹配:
这个脚本可以进一步改进。这是一个建议:
食谱难度:中等
虽然Python以单线程闻名,但我们可以使用内置库来启动新进程来处理任务。通常,当有一系列可以同时运行的任务并且处理尚未受到硬件限制时,这是首选,例如网络带宽或磁盘速度。
此脚本中使用的所有库都包含在Python的标准库中。使用内置的multiprocessing库,我们可以处理大多数需要多个进程有效地解决问题的情况。
通过以下步骤,我们展示了Python中的基本多进程支持:
from__future__importprint_functionimportloggingimportmultiprocessingasmpfromrandomimportrandintimportsysimporttime在创建进程之前,我们设置一个函数,它们将执行。这是我们在返回主线程之前应该执行的每个进程的任务。在这种情况下,我们将线程睡眠的秒数作为唯一参数。为了打印允许我们区分进程的状态消息,我们使用current_process()方法访问每个线程的名称属性:
defsleepy(seconds):proc_name=mp.current_process().namelogger.info("{}issleepingfor{}seconds.".format(proc_name,seconds))time.sleep(seconds)定义了我们的工作函数后,我们创建了我们的logger实例,从上一个食谱中借用代码,并将其设置为仅记录到控制台。
logger=logging.getLogger(__file__)logger.setLevel(logging.DEBUG)msg_fmt=logging.Formatter("%(asctime)-15s%(funcName)-7s""%(levelname)-8s%(message)s")strhndl=logging.StreamHandler(sys.stdout)strhndl.setFormatter(fmt=msg_fmt)logger.addHandler(strhndl)现在我们定义要生成的工作人员数量,并在for循环中创建它们。使用这种技术,我们可以轻松调整正在运行的进程数量。在我们的循环内,我们使用Process类定义每个worker,并设置我们的目标函数和所需的参数。一旦定义了进程实例,我们就启动它并将对象附加到列表以供以后使用:
num_workers=5workers=[]forwinrange(num_workers):p=mp.Process(target=sleepy,args=(randint(1,20),))p.start()workers.append(p)通过将workers附加到列表中,我们可以按顺序加入它们。在这种情况下,加入是指在执行继续之前等待进程完成的过程。如果我们不加入我们的进程,其中一个进程可能会在脚本的末尾继续并在其他进程完成之前完成代码。虽然这在我们的示例中不会造成很大问题,但它可能会导致下一段代码过早开始:
这个脚本可以进一步改进。我们在这里提供了一个建议:
在本章中,我们将涵盖以下配方:
在您开始从事网络安全职业的前几个小时内,您可能已经弯腰在屏幕前,疯狂地扫描电子表格以寻找线索。这听起来很熟悉,因为这是真实的,也是大多数调查的日常流程的一部分。电子表格是网络安全的基础。其中包含了各种流程的细节以及从有价值的物件中提取的具体信息。在这本食谱书中,我们经常会将解析后的物件数据输出到电子表格中,因为它便携且易于使用。然而,考虑到每个网络安全专业人员都曾经为非技术人员创建过技术报告,电子表格可能不是最佳选择。
为什么要创建报告?我想我以前听到过紧张的审查员喃喃自语。今天,一切都建立在信息交换之上,人们希望尽快了解事情。但这并不一定意味着他们希望得到一个技术电子表格并自己弄清楚。审查员必须能够有效地将技术知识传达给非专业观众,以便正确地完成他们的工作。即使一个物件可能非常好,即使它是某个案例的象征性证据,它很可能需要向非技术人员进行详细解释,以便他们完全理解其含义和影响。放弃吧;报告会一直存在,对此无能为力。
在本章中,您将学习如何创建多种不同类型的报告以及一个用于自动审计我们调查的脚本。我们将创建HTML、XLSX和CSV报告,以便以有意义的方式总结数据:
HTML可以是一份有效的报告。有很多时髦的模板可以使即使是技术报告看起来也很吸引人。这是吸引观众的第一步。或者至少是一种预防措施,防止观众立刻打瞌睡。这个配方使用了这样一个模板和一些测试数据,以创建一个视觉上引人注目的获取细节的例子。我们在这里确实有很多工作要做。
这个配方介绍了使用jinja2模块的HTML模板化。jinja2库是一个非常强大的工具,具有许多不同的文档化功能。我们将在一个相当简单的场景中使用它。此脚本中使用的所有其他库都包含在Python的标准库中。我们可以使用pip来安装jinja2:
pipinstalljinja2==2.9.6除了jinja2之外,我们还将使用一个稍微修改过的模板,称为轻量级引导式仪表板。这个稍微修改过的仪表板已经随配方的代码捆绑提供了。
我们遵循以下原则部署HTML仪表板:
首先,我们导入所需的库来处理参数解析、创建对象计数和复制文件:
from__future__importprint_functionimportargparsefromcollectionsimportCounterimportshutilimportosimportsys这个配方的命令行处理程序接受一个位置参数OUTPUT_DIR,它表示HTML仪表板的期望输出路径。在检查目录是否存在并在不存在时创建它之后,我们调用main()函数并将输出目录传递给它:
if__name__=="__main__":#Command-lineArgumentParserparser=argparse.ArgumentParser(description=__description__,epilog="Developedby{}on{}".format(",".join(__authors__),__date__))parser.add_argument("OUTPUT_DIR",help="DesiredOutputPath")args=parser.parse_args()main(args.OUTPUT_DIR)在脚本顶部定义了一些全局变量:DASH、TABLE和DEMO。这些变量代表脚本生成的各种HTML和JavaScript文件。这是一本关于Python的书,所以我们不会深入讨论这些文件的结构和工作原理。不过,让我们看一个示例,展示jinja2如何弥合这些类型文件和Python之间的差距。
以下代码片段捕获了全局变量DEMO的一部分。请注意,字符串块被传递给jinja2.Template()方法。这使我们能够创建一个对象,可以使用jinja2与之交互并动态插入数据到JavaScript文件中。具体来说,以下代码块显示了两个我们可以使用jinja2插入数据的位置。这些位置由双大括号和我们在Python代码中将引用它们的关键字(pi_labels和pi_series)表示:
DEMO=Template("""type=['','info','success','warning','danger'];[snip]Chartist.Pie('#chartPreferences',dataPreferences,optionsPreferences);Chartist.Pie('#chartPreferences',{labels:[{{pi_labels}}],series:[{{pi_series}}]});[snip]""")现在让我们转向main()函数。由于您将在第二个配方中理解的原因,这个函数实际上非常简单。这个函数创建一个包含示例获取数据的列表列表,向控制台打印状态消息,并将该数据发送到process_data()方法:
defmain(output_dir):acquisition_data=[["001","DebbieDowner","Mobile","08/05/201713:05:21","32"],["002","DebbieDowner","Mobile","08/05/201713:11:24","16"],["003","DebbieDowner","External","08/05/201713:34:16","128"],["004","DebbieDowner","Computer","08/05/201714:23:43","320"],["005","DebbieDowner","Mobile","08/05/201715:35:01","16"],["006","DebbieDowner","External","08/05/201715:54:54","8"],["007","EvenSteven","Computer","08/07/201710:11:32","256"],["008","EvenSteven","Mobile","08/07/201710:40:32","32"],["009","DebbieDowner","External","08/10/201712:03:42","64"],["010","DebbieDowner","External","08/10/201712:43:27","64"]]print("[+]Processingacquisitiondata")process_data(acquisition_data,output_dir)process_data()方法的目的是将示例获取数据转换为HTML或JavaScript格式,以便我们可以将其放置在jinja2模板中。这个仪表板将有两个组件:可视化数据的一系列图表和原始数据的表格。以下代码块处理了后者。我们通过遍历获取列表并使用适当的HTML标记将表的每个元素添加到html_table字符串中来实现这一点:
defprocess_data(data,output_dir):html_table=""foracqindata:html_table+="
device_types=Counter([x[2]forxindata])custodian_devices=Counter([x[1]forxindata])date_dict={}foracqindata:date=acq[3].split("")[0]ifdateindate_dict:date_dict[date]+=int(acq[4])else:date_dict[date]=int(acq[4])output_html(output_dir,len(data),html_table,device_types,custodian_devices,date_dict)output_html()方法首先通过在控制台打印状态消息并将当前工作目录存储到变量中来开始。我们将文件夹路径附加到light-bootstrap-dashboard,并使用shutil.copytree()将bootstrap文件复制到输出目录。随后,我们创建三个文件路径,表示三个jinja2模板的输出位置和名称:
defoutput_html(output,num_devices,table,devices,custodians,dates):print("[+]RenderingHTMLandcopyfilesto{}".format(output))cwd=os.getcwd()bootstrap=os.path.join(cwd,"light-bootstrap-dashboard")shutil.copytree(bootstrap,output)dashboard_output=os.path.join(output,"dashboard.html")table_output=os.path.join(output,"table.html")demo_output=os.path.join(output,"assets","js","demo.js")让我们先看看两个HTML文件,因为它们相对简单。在为两个HTML文件打开文件对象之后,我们使用jinja2.render()方法,并使用关键字参数来引用Template对象中花括号中的占位符。使用Python数据呈现文件后,我们将数据写入文件。简单吧?幸运的是,JavaScript文件并不难:
withopen(dashboard_output,"w")asoutfile:outfile.write(DASH.render(num_custodians=len(custodians.keys()),num_devices=num_devices,data=calculate_size(dates)))withopen(table_output,"w")asoutfile:outfile.write(TABLE.render(table_body=table))虽然在语法上与前一个代码块相似,但这次在呈现数据时,我们将数据提供给return_labels()和return_series()方法。这些方法从Counter对象中获取键和值,并适当地格式化以与JavaScript文件一起使用。您可能还注意到在前一个代码块中对dates字典调用了calculate_size()方法。现在让我们来探讨这三个支持函数:
withopen(demo_output,"w")asoutfile:outfile.write(DEMO.render(bar_labels=return_labels(dates.keys()),bar_series=return_series(dates.values()),pi_labels=return_labels(devices.keys()),pi_series=return_series(devices.values()),pi_2_labels=return_labels(custodians.keys()),pi_2_series=return_series(custodians.values())))calculate_size()方法简单地使用内置的sum()方法返回每个日期键收集的总大小。return_labels()和return_series()方法使用字符串方法适当地格式化数据。基本上,JavaScript文件期望标签在单引号内,这是通过format()方法实现的,标签和系列都必须用逗号分隔:
defcalculate_size(sizes):returnsum(sizes.values())defreturn_labels(list_object):return",".join("'{}'".format(x)forxinlist_object)defreturn_series(list_object):return",".join(str(x)forxinlist_object)当我们运行这个脚本时,我们会收到报告的副本,以及加载和呈现页面所需的资产,放在指定的输出目录中。我们可以将这个文件夹压缩并提供给团队成员,因为它被设计为可移植的。查看这个仪表板,我们可以看到包含图表信息的第一页:
以及作为采集信息表的第二页:
菜谱难度:中等
大多数成像工具都会创建记录采集介质细节和其他可用元数据的审计日志。承认吧;除非出现严重问题,否则这些日志大多不会被触及,如果证据验证了。让我们改变这种情况,利用前一个菜谱中新创建的HTML仪表板,并更好地利用这些采集数据。
此脚本中使用的所有库都存在于Python的标准库中,或者是从之前的脚本中导入的函数。
我们通过以下步骤解析采集日志:
首先,我们导入所需的库来处理参数解析、解析日期和我们在上一个菜谱中创建的html_dashboard脚本:
from__future__importprint_functionimportargparsefromdatetimeimportdatetimeimportosimportsysimporthtml_dashboard这个菜谱的命令行处理程序接受两个位置参数,INPUT_DIR和OUTPUT_DIR,分别代表包含采集日志的目录路径和期望的输出路径。在创建输出目录(如果需要)并验证输入目录存在后,我们调用main()方法并将这两个变量传递给它:
defmain(in_dir,out_dir):ftk_logs=[xforxinos.listdir(in_dir)ifx.lower().endswith(".txt")]print("[+]Processing{}potentialFTKImagerLogsfoundin{}""directory".format(len(ftk_logs),in_dir))ftk_data=[]forloginftk_logs:log_data={"e_numb":"","custodian":"","type":"","date":"","size":""}log_name=os.path.join(in_dir,log)ifvalidate_ftk(log_name):值得庆幸的是,每个FTKImager日志的第一行都包含"CreatedbyAccessData"这几个词。我们可以依靠这一点来验证该日志很可能是有效的FTKImager日志。使用输入的log_file路径,我们打开文件对象并使用readline()方法读取第一行。提取第一行后,我们检查短语是否存在,如果存在则返回True,否则返回False:
defvalidate_ftk(log_file):withopen(log_file)aslog:first_line=log.readline()if"CreatedByAccessData"notinfirst_line:returnFalseelse:returnTrue回到main()方法,在验证了FTKImager日志之后,我们打开文件,将一些变量设置为None,并开始迭代文件中的每一行。基于这些日志的可靠布局,我们可以使用特定关键字来识别当前行是否是我们感兴趣的行。例如,如果该行包含短语"EvidenceNumber:",我们可以确定该行包含证据编号值。实际上,我们分割短语并取冒号右侧的值,并将其与字典e_numb键关联。这种逻辑可以应用于大多数所需的值,但也有一些例外。
withopen(log_name)aslog_file:bps,sec_count=(None,None)forlineinlog_file:if"EvidenceNumber:"inline:log_data["e_numb"]=line.split("Number:")[1].strip()elif"Notes:"inline:log_data["custodian"]=line.split("Notes:")[1].strip()elif"ImageType:"inline:log_data["type"]=line.split("Type:")[1].strip()elif"Acquisitionstarted:"inline:acq=line.split("started:")[1].strip()date=datetime.strptime(acq,"%a%b%d%H:%M:%S%Y")log_data["date"]=date.strftime("%M/%d/%Y%H:%M:%S")每个扇区的字节数和扇区计数与其他部分处理方式略有不同。由于HTML仪表板脚本期望接收数据大小(以GB为单位),我们需要提取这些值并计算获取的媒体大小。一旦识别出来,我们将每个值转换为整数,并将其分配给最初为None的两个局部变量。在完成对所有行的迭代后,我们检查这些变量是否不再是None,如果不是,则将它们发送到calculate_size()方法。该方法执行必要的计算并将媒体大小存储在字典中:
defcalculate_size(bytes,sectors):return(bytes*sectors)/(1024**3)处理完文件后,提取的获取数据的字典将附加到ftk_data列表中。在处理完所有日志后,我们调用html_dashboard.process_data()方法,并向其提供获取数据和输出目录。process_data()函数当然与上一个示例中的完全相同。因此,您知道这些获取数据将替换上一个示例中的示例获取数据,并用真实数据填充HTML仪表板:
elif"BytesperSector:"inline:bps=int(line.split("Sector:")[1].strip())elif"SectorCount:"inline:sec_count=int(line.split("Count:")[1].strip().replace(",",""))ifbpsisnotNoneandsec_countisnotNone:log_data["size"]=calculate_size(bps,sec_count)ftk_data.append([log_data["e_numb"],log_data["custodian"],log_data["type"],log_data["date"],log_data["size"]])print("[+]CreatingHTMLdashboardbasedacquisitionlogs""in{}".format(out_dir))html_dashboard.process_data(ftk_data,out_dir)当我们运行这个工具时,我们可以看到获取日志信息,如下两个截图所示:
每个人都曾经在CSV电子表格中查看过数据。它们是无处不在的,也是大多数应用程序的常见输出格式。使用Python编写CSV是创建处理数据报告的最简单方法之一。在这个配方中,我们将演示如何使用csv和unicodecsv库来快速创建Python报告。
这个配方的一部分使用了unicodecsv模块。该模块替换了内置的Python2csv模块,并添加了Unicode支持。Python3的csv模块没有这个限制,可以在不需要任何额外库支持的情况下使用。此脚本中使用的所有其他库都包含在Python的标准库中。unicodecsv库可以使用pip安装:
我们按照以下步骤创建CSV电子表格:
首先,我们导入所需的库来写入电子表格。在这个配方的后面,我们还导入了unicodecsv模块:
from__future__importprint_functionimportcsvimportosimportsys这个配方不使用argparse作为命令行处理程序。相反,我们根据Python的版本直接调用所需的函数。我们可以使用sys.version_info属性确定正在运行的Python版本。如果用户使用的是Python2.X,我们调用csv_writer_py2()和unicode_csv_dict_writer_py2()方法。这两种方法都接受四个参数,最后一个参数是可选的:要写入的数据、标题列表、所需的输出目录,以及可选的输出CSV电子表格的名称。或者,如果使用的是Python3.X,我们调用csv_writer_py3()方法。虽然相似,但在两个版本的Python之间处理CSV写入的方式有所不同,而unicodecsv模块仅适用于Python2:
ifsys.version_info<(3,0):csv_writer_py2(TEST_DATA_LIST,["Name","Age","CoolFactor"],os.getcwd())unicode_csv_dict_writer_py2(TEST_DATA_DICT,["Name","Age","CoolFactor"],os.getcwd(),"dict_output.csv")elifsys.version_info>=(3,0):csv_writer_py3(TEST_DATA_LIST,["Name","Age","CoolFactor"],os.getcwd())这个配方有两个表示样本数据类型的全局变量。其中第一个TEST_DATA_LIST是一个嵌套列表结构,包含字符串和整数。第二个TEST_DATA_DICT是这些数据的另一种表示,但存储为字典列表。让我们看看各种函数如何将这些样本数据写入输出CSV文件:
TEST_DATA_LIST=[["Bill",53,0],["Alice",42,5],["Zane",33,-1],["Theodore",72,9001]]TEST_DATA_DICT=[{"Name":"Bill","Age":53,"CoolFactor":0},{"Name":"Alice","Age":42,"CoolFactor":5},{"Name":"Zane","Age":33,"CoolFactor":-1},{"Name":"Theodore","Age":72,"CoolFactor":9001}]csv_writer_py2()方法首先检查输入的名称是否已提供。如果仍然是默认值None,我们就自己分配输出名称。接下来,在控制台打印状态消息后,我们在所需的输出目录中以"wb"模式打开一个File对象。请注意,在Python2中重要的是以"wb"模式打开CSV文件,以防止在生成的电子表格中的行之间出现干扰间隙。一旦我们有了File对象,我们使用csv.writer()方法将其转换为writer对象。有了这个,我们可以使用writerow()和writerows()方法分别写入单个数据列表和嵌套列表结构。现在,让我们看看unicodecsv如何处理字典列表:
defcsv_writer_py2(data,header,output_directory,name=None):ifnameisNone:name="output.csv"print("[+]Writing{}to{}".format(name,output_directory))withopen(os.path.join(output_directory,name),"wb")ascsvfile:writer=csv.writer(csvfile)writer.writerow(header)writer.writerows(data)unicodecsv模块是内置csv模块的替代品,可以互换使用。不同之处在于,unicodecsv自动处理Unicode字符串的方式与Python2中的内置csv模块不同。这在Python3中得到了解决。
首先,我们尝试导入unicodecsv模块,并在退出脚本之前,如果导入失败,则在控制台打印状态消息。如果我们能够导入库,我们检查是否提供了名称输入,并在打开File对象之前创建一个名称。使用这个File对象,我们使用unicodecsv.DictWriter类,并提供它的标题列表。默认情况下,该对象期望提供的fieldnames列表中的键表示每个字典中的所有键。如果不需要这种行为,或者如果不是这种情况,可以通过将extrasaction关键字参数设置为字符串ignore来忽略它。这样做将导致所有未在fieldnames列表中指定的附加字典键被忽略,并且不会添加到CSV电子表格中。
设置DictWriter对象后,我们使用writerheader()方法写入字段名称,然后使用writerows()方法,这次将字典列表写入CSV文件。另一个重要的事情要注意的是,列将按照提供的fieldnames列表中元素的顺序排列:
defunicode_csv_dict_writer_py2(data,header,output_directory,name=None):try:importunicodecsvexceptImportError:print("[+]Installunicodecsvmodulebeforeexecutingthis""function")sys.exit(1)ifnameisNone:name="output.csv"print("[+]Writing{}to{}".format(name,output_directory))withopen(os.path.join(output_directory,name),"wb")ascsvfile:writer=unicodecsv.DictWriter(csvfile,fieldnames=header)writer.writeheader()writer.writerows(data)最后,csv_writer_py3()方法的操作方式基本相同。但是,请注意File对象创建方式的不同。与在Python3中以"wb"模式打开文件不同,我们以"w"模式打开文件,并将newline关键字参数设置为空字符串。在这样做之后,其余的操作与之前描述的方式相同:
defcsv_writer_py3(data,header,output_directory,name=None):ifnameisNone:name="output.csv"print("[+]Writing{}to{}".format(name,output_directory))withopen(os.path.join(output_directory,name),"w",newline="")as\csvfile:writer=csv.writer(csvfile)writer.writerow(header)writer.writerows(data)当我们运行这段代码时,我们可以查看两个新生成的CSV文件中的任何一个,并看到与以下截图中相同的信息:
让我们从上一个配方进一步进行Excel。Excel是一个非常强大的电子表格应用程序,我们可以做很多事情。我们将使用Excel创建一个表格,并绘制数据的图表。
有许多不同的Python库,对Excel及其许多功能的支持各不相同。在这个配方中,我们使用xlsxwriter模块来创建数据的表格和图表。这个模块可以用于更多的用途。可以使用以下命令通过pip安装这个模块:
我们还使用了一个基于上一个配方编写的自定义utilcsv模块来处理与CSV的交互。此脚本中使用的所有其他库都包含在Python的标准库中。
我们通过以下步骤创建Excel电子表格:
首先,我们导入所需的库来处理参数解析、创建对象计数、解析日期、编写XLSX电子表格,以及我们的自定义utilcsv模块,该模块在这个配方中处理CSV的读取和写入:
from__future__importprint_functionimportargparsefromcollectionsimportCounterfromdatetimeimportdatetimeimportosimportsysfromutilityimportutilcsvtry:importxlsxwriterexceptImportError:print("[-]Installrequiredthird-partymodulexlsxwriter")sys.exit(1)这个配方的命令行处理程序接受一个位置参数:OUTPUT_DIR。这代表了XLSX文件的期望输出路径。在调用main()方法之前,我们检查输出目录是否存在,如果不存在则创建它:
if__name__=="__main__":#Command-lineArgumentParserparser=argparse.ArgumentParser(description=__description__,epilog="Developedby{}on{}".format(",".join(__authors__),__date__))parser.add_argument("OUTPUT_DIR",help="DesiredOutputPath")args=parser.parse_args()ifnotos.path.exists(args.OUTPUT_DIR):os.makedirs(args.OUTPUT_DIR)main(args.OUTPUT_DIR)main()函数实际上非常简单;它的工作是在控制台打印状态消息,使用csv_reader()方法(这是从上一个配方稍微修改的函数),然后使用xlsx_writer()方法将结果数据写入输出目录:
defmain(output_directory):print("[+]Readinginsampledataset")#Skipfirstrowofheadersdata=utilcsv.csv_reader("redacted_sample_event_log.csv")[1:]xlsx_writer(data,output_directory)xlsx_writer()从打印状态消息和在输出目录中创建workbook对象开始。接下来,我们为仪表板和数据工作表创建了两个worksheet对象。仪表板工作表将包含一个总结数据工作表上原始数据的图表:
title_format=workbook.add_format({'bold':True,'font_color':'white','bg_color':'black','font_size':30,'font_name':'Calibri','align':'center'})date_format=workbook.add_format({'num_format':'mm/dd/yyhh:mm:ssAM/PM'})设置格式后,我们可以枚举列表中的每个列表,并使用write()方法写入每个列表。这个方法需要一些输入;第一个和第二个参数是行和列,然后是要写入的值。请注意,除了write()方法之外,我们还使用write_number()和write_datetime()方法。这些方法保留了XLSX电子表格中的数据类型。特别是对于write_datetime()方法,我们提供了date_format变量来适当地格式化日期对象。循环遍历所有数据后,我们成功地将数据存储在电子表格中,并保留了其值类型。但是,我们可以在XLSX电子表格中做的远不止这些。
我们使用add_table()方法创建刚刚写入的数据的表格。为了实现这一点,我们必须使用Excel符号来指示表格的左上角和右下角列。除此之外,我们还可以提供一个对象字典来进一步配置表格。在这种情况下,字典只包含表格每列的标题名称:
fori,recordinenumerate(data):data_sheet.write_number(i,0,int(record[0]))data_sheet.write(i,1,record[1])data_sheet.write(i,2,record[2])dt=datetime.strptime(record[3],"%m/%d/%Y%H:%M:%S%p")data_sheet.write_datetime(i,3,dt,date_format)data_sheet.write_number(i,4,int(record[4]))data_sheet.write(i,5,record[5])data_sheet.write_number(i,6,int(record[6]))data_sheet.write(i,7,record[7])data_length=len(data)+1data_sheet.add_table("A1:H{}".format(data_length),{"columns":[{"header":"Index"},{"header":"FileName"},{"header":"ComputerName"},{"header":"WrittenDate"},{"header":"EventLevel"},{"header":"EventSource"},{"header":"EventID"},{"header":"FilePath"}]})完成数据工作表后,现在让我们把焦点转向仪表板工作表。我们将在这个仪表板上创建一个图表,按频率分解事件ID。首先,我们使用Counter对象计算这个频率,就像HTML仪表板配方中所示的那样。接下来,我们通过合并多列并设置标题文本和格式来为这个页面设置一个标题。
完成后,我们遍历事件ID频率Counter对象,并将它们写入工作表。我们从第100行开始写入,以确保数据不会占据前台。一旦数据写入,我们使用之前讨论过的相同方法将其转换为表格:
event_ids=Counter([x[6]forxindata])dashboard.merge_range('A1:Q1','EventLogDashboard',title_format)fori,recordinenumerate(event_ids):dashboard.write(100+i,0,record)dashboard.write(100+i,1,event_ids[record])dashboard.add_table("A100:B{}".format(100+len(event_ids)),{"columns":[{"header":"EventID"},{"header":"Occurrence"}]})最后,我们可以绘制我们一直在谈论的图表。我们使用add_chart()方法,并将类型指定为柱状图。接下来,我们使用set_title()和set_size()方法来正确配置这个图表。剩下的就是使用add_series()方法将数据添加到图表中。这个方法使用一个带有类别和值键的字典。在柱状图中,类别值代表x轴,值代表y轴。请注意使用Excel符号来指定构成类别和值键的单元格范围。选择数据后,我们在worksheet对象上使用insert_chart()方法来显示它,然后关闭workbook对象:
event_chart=workbook.add_chart({'type':'bar'})event_chart.set_title({'name':'EventIDBreakdown'})event_chart.set_size({'x_scale':2,'y_scale':5})event_chart.add_series({'categories':'=Dashboard!$A$101:$A${}'.format(100+len(event_ids)),'values':'=Dashboard!$B$101:$B${}'.format(100+len(event_ids))})dashboard.insert_chart('C5',event_chart)workbook.close()当我们运行这个脚本时,我们可以在XLSX电子表格中查看数据和我们创建的总结事件ID的图表:
保持详细的调查笔记是任何调查的关键。没有这些,很难将所有的线索放在一起或准确地回忆发现。有时,有一张屏幕截图或一系列屏幕截图可以帮助您回忆您在审查过程中所采取的各种步骤。
为了创建具有跨平台支持的配方,我们选择使用pyscreenshot模块。该模块依赖于一些依赖项,特别是PythonImagingLibrary(PIL)和一个或多个后端。这里使用的后端是WXGUI库。这三个模块都可以使用pip安装:
此脚本中使用的所有其他库都包含在Python的标准库中。
我们使用以下方法来实现我们的目标:
首先,我们导入所需的库来处理参数解析、脚本休眠和截图:
from__future__importprint_functionimportargparsefrommultiprocessingimportfreeze_supportimportosimportsysimporttimetry:importpyscreenshotimportwxexceptImportError:print("[-]Installwxandpyscreenshottousethisscript")sys.exit(1)这个配方的命令行处理程序接受两个位置参数,OUTPUT_DIR和INTERVAL,分别表示所需的输出路径和截图之间的间隔。可选的total参数可用于对应该采取的截图数量设置上限。请注意,我们为INTERVAL和total参数指定了整数类型。在验证输出目录存在后,我们将这些输入传递给main()方法:
defmain(output_dir,interval,total):i=0whileTrue:i+=1time.sleep(interval)image=pyscreenshot.grab()output=os.path.join(output_dir,"screenshot_{}.png").format(i)image.save(output)print("[+]Tookscreenshot{}andsaveditto{}".format(i,output_dir))iftotalisnotNoneandi==total:print("[+]Finishedtaking{}screenshotsevery{}""seconds".format(total,interval))sys.exit(0)随着截图脚本每五秒运行一次,并将图片存储在我们选择的文件夹中,我们可以看到以下输出,如下截图所示:
本章涵盖以下食谱:
也许这已经成为陈词滥调,但事实仍然如此,随着技术的发展,它继续与我们的生活更加紧密地融合。这从未如此明显,如第一部智能手机的发展。这些宝贵的设备似乎永远不会离开其所有者,并且通常比人类伴侣更多地接触。因此,毫不奇怪,智能手机可以为调查人员提供大量关于其所有者的见解。例如,消息可能提供有关所有者心态或特定事实的见解。它们甚至可能揭示以前未知的信息。位置历史是我们可以从这些设备中提取的另一个有用的证据,可以帮助验证个人的不在场证明。我们将学习提取这些信息以及更多内容。
此食谱需要安装第三方库biplist。此脚本中使用的所有其他库都包含在Python的标准库中。biplist模块提供了处理XML和二进制PLIST文件的方法。
Python有一个内置的PLIST库,plistlib;然而,发现这个库不像biplist那样广泛支持二进制PLIST文件。
使用pip可以完成安装biplist:
pipinstallbiplist==1.0.2确保获取自己的Info.plist文件以便使用此脚本进行处理。如果找不到Info.plist文件,任何PLIST文件都应该合适。我们的脚本并不那么具体,理论上应该适用于任何PLIST文件。
我们将采用以下步骤处理PLIST文件:
首先,我们导入所需的库来处理参数解析和处理PLIST文件:
from__future__importprint_functionimportargparseimportbiplistimportosimportsys该配方的命令行处理程序接受一个位置参数PLIST_FILE,表示我们将处理的PLIST文件的路径:
if__name__=="__main__":parser=argparse.ArgumentParser(description=__description__,epilog="Developedby{}on{}".format(",".join(__authors__),__date__))parser.add_argument("PLIST_FILE",help="InputPListFile")args=parser.parse_args()我们使用os.exists()和os.path.isfile()函数来验证输入文件是否存在并且是一个文件,而不是一个目录。我们不对这个文件进行进一步的验证,比如确认它是一个PLIST文件而不是一个文本文件,而是依赖于biplist库(和常识)来捕捉这样的错误。如果输入文件通过了我们的测试,我们调用main()函数并将PLIST文件路径传递给它:
ifnotos.path.exists(args.PLIST_FILE)or\notos.path.isfile(args.PLIST_FILE):print("[-]{}doesnotexistorisnotafile".format(args.PLIST_FILE))sys.exit(1)main(args.PLIST_FILE)main()函数相对简单,实现了读取PLIST文件然后将数据打印到控制台的目标。首先,我们在控制台上打印一个更新,表示我们正在尝试打开文件。然后,我们使用biplist.readPlist()方法打开并读取PLIST到我们的plist_data变量中。如果PLIST文件损坏或无法访问,biplist会引发InvalidPlistException或NotBinaryPlistException错误。我们在try和except块中捕获这两种错误,并相应地exit脚本:
defmain(plist):print("[+]Opening{}file".format(plist))try:plist_data=biplist.readPlist(plist)except(biplist.InvalidPlistException,biplist.NotBinaryPlistException)ase:print("[-]InvalidPLISTfile-unabletobeopenedbybiplist")sys.exit(2)一旦我们成功读取了PLIST数据,我们遍历结果中的plist_data字典中的键,并将它们打印到控制台上。请注意,我们打印Info.plist文件中除了Applications和iTunesFiles键之外的所有键。这两个键包含大量数据,会淹没控制台,因此不适合这种类型的输出。我们使用format方法来帮助创建可读的控制台输出:
print("[+]PrintingInfo.plistDevice""andUserInformationtoConsole\n")forkinplist_data:ifk!='Applications'andk!='iTunesFiles':print("{:<25s}-{}".format(k,plist_data[k]))请注意第一个花括号中的额外格式化字符。我们在这里指定左对齐输入字符串,并且宽度为25个字符。正如你在下面的截图中所看到的,这确保了数据以有序和结构化的格式呈现:
Python版本:3.5
如前所述,SQLite数据库是移动设备上的主要数据存储库。Python有一个内置的sqlite3库,可以用来与这些数据库进行交互。在这个脚本中,我们将与iPhone的sms.db文件交互,并从message表中提取数据。我们还将利用这个脚本的机会介绍csv库,并将消息数据写入电子表格。
该配方遵循以下基本原则:
首先,我们导入所需的库来处理参数解析、写入电子表格和与SQLite数据库交互:
from__future__importprint_functionimportargparseimportcsvimportosimportsqlite3importsys该配方的命令行处理程序接受两个位置参数SQLITE_DATABASE和OUTPUT_CSV,分别表示输入数据库和期望的CSV输出的文件路径:
if__name__=='__main__':#Command-lineArgumentParserparser=argparse.ArgumentParser(description=__description__,epilog="Developedby{}on{}".format(",".join(__authors__),__date__))parser.add_argument("SQLITE_DATABASE",help="InputSQLitedatabase")parser.add_argument("OUTPUT_CSV",help="OutputCSVFile")args=parser.parse_args()接下来,我们使用os.dirname()方法仅提取输出文件的目录路径。我们这样做是为了检查输出目录是否已经存在。如果不存在,我们使用os.makedirs()方法创建输出路径中尚不存在的每个目录。这样可以避免以后尝试将输出CSV写入不存在的目录时出现问题:
directory=os.path.dirname(args.OUTPUT_CSV)ifdirectory!=''andnotos.path.exists(directory):os.makedirs(directory)一旦我们验证了输出目录存在,我们将提供的参数传递给main()函数:
main(args.SQLITE_DATABASE,args.OUTPUT_CSV)main()函数向用户的控制台打印状态更新,然后检查输入文件是否存在且是否为文件。如果不存在,我们使用sys.exit()方法退出脚本,使用大于0的值指示脚本由于错误退出:
defmain(database,out_csv):print("[+]Attemptingconnectionto{}database".format(database))ifnotos.path.exists(database)ornotos.path.isfile(database):print("[-]Databasedoesnotexistorisnotafile")sys.exit(1)接下来,我们使用sqlite3.conn()方法连接到输入数据库。重要的是要注意,sqlite3.conn()方法会打开所提供名称的数据库,无论它是否存在。因此,重要的是在尝试打开连接之前检查文件是否存在。否则,我们可能会创建一个空数据库,在与其交互时可能会导致脚本出现问题。一旦建立了连接,我们需要创建一个Cursor对象来与数据库交互:
#ConnecttoSQLiteDatabaseconn=sqlite3.connect(database)c=conn.cursor()现在,我们可以使用Cursor对象的execute()命令对数据库执行查询。此时,我们传递给execute函数的字符串只是标准的SQLlite查询。在大多数情况下,您可以运行与与SQLite数据库交互时通常运行的任何查询。从给定命令返回的结果存储在Cursor对象中。我们需要使用fetchall()方法将结果转储到我们可以操作的变量中:
#QueryDBforColumnNamesandDataofMessageTablec.execute("pragmatable_info(message)")table_data=c.fetchall()columns=[x[1]forxintable_data]fetchall()方法返回一组结果的元组。每个元组的第一个索引中存储了每列的名称。通过使用列表推导,我们将message表的列名存储到列表中。这在稍后将数据结果写入CSV文件时会发挥作用。在获取了message表的列名后,我们直接查询该表的所有数据,并将其存储在message_data变量中:
c.execute("select*frommessage")message_data=c.fetchall()提取数据后,我们向控制台打印状态消息,并将输出的CSV和消息表列和数据传递给write_csv()方法:
print("[+]WritingMessageContentto{}".format(out_csv))write_csv(out_csv,columns,message_data)您会发现大多数脚本最终都会将数据写入CSV文件。这样做有几个原因。在Python中编写CSV非常简单,对于大多数数据集,可以用几行代码完成。此外,将数据放入电子表格中可以根据列进行排序和过滤,以帮助总结和理解大型数据集。
在开始写入CSV文件之前,我们使用open()方法创建文件对象及其别名csvfile。打开此文件的方式取决于您是否使用Python2.x或Python3.x。对于Python2.x,您以wb模式打开文件,而不使用newline关键字参数。对于Python3.x,您可以以w模式打开文件,并将newline关键字设置为空字符串。在可能的情况下,代码是针对Python3.x编写的,因此我们使用后者。未以这种方式打开文件对象会导致输出的CSV文件在每行之间包含一个空行。
打开文件对象后,我们将其传递给csv.writer()方法。我们可以使用该对象的writerow()和writerows()方法分别写入列标题列表和元组列表。顺便说一句,我们可以遍历msgs列表中的每个元组,并为每个元组调用writerow()。writerows()方法消除了不必要的循环,并在这里使用:
这个食谱将演示如何通过编程方式使用主键来识别给定表中的缺失条目。这种技术允许我们识别数据库中不再有效的记录。我们将使用这个方法来识别从iPhone短信数据库中删除了哪些消息以及删除了多少条消息。然而,这也适用于使用自增主键的任何表。
管理SQLite数据库及其表的一个基本概念是主键。主键通常是表中特定行的唯一整数列。常见的实现是自增主键,通常从第一行开始为1,每一行递增1。当从表中删除行时,主键不会改变以适应或重新排序表。
此脚本中使用的所有库都包含在Python的标准库中。这个食谱需要一个数据库来运行。在这个例子中,我们将使用iPhonesms.db数据库。
在这个食谱中,我们将执行以下步骤:
首先,我们导入所需的库来处理参数解析和与SQLite数据库交互:
from__future__importprint_functionimportargparseimportosimportsqlite3importsys这个食谱的命令行处理程序接受两个位置参数,SQLITE_DATABASE和TABLE,分别表示输入数据库的路径和要查看的表的名称。一个可选参数column,由破折号表示,可以用来手动提供主键列(如果已知):
if__name__=="__main__":#Command-lineArgumentParserparser=argparse.ArgumentParser(description=__description__,epilog="Developedby{}on{}".format(",".join(__authors__),__date__))parser.add_argument("SQLITE_DATABASE",help="InputSQLitedatabase")parser.add_argument("TABLE",help="Tabletoqueryfrom")parser.add_argument("--column",help="Optionalcolumnargument")args=parser.parse_args()如果提供了可选的列参数,我们将它作为关键字参数与数据库和表名一起传递给main()函数。否则,我们只将数据库和表名传递给main()函数,而不包括col关键字参数:
ifargs.columnisnotNone:main(args.SQLITE_DATABASE,args.TABLE,col=args.column)else:main(args.SQLITE_DATABASE,args.TABLE)main()函数,与前一个食谱一样,首先执行一些验证,验证输入数据库是否存在且是一个文件。因为我们在这个函数中使用了关键字参数,所以我们必须在函数定义中使用**kwargs参数来指示这一点。这个参数充当一个字典,存储所有提供的关键字参数。在这种情况下,如果提供了可选的列参数,这个字典将包含一个col键值对:
defmain(database,table,**kwargs):print("[+]Attemptingconnectionto{}database".format(database))ifnotos.path.exists(database)ornotos.path.isfile(database):print("[-]Databasedoesnotexistorisnotafile")sys.exit(1)在验证输入文件后,我们使用sqlite3连接到这个数据库,并创建我们用来与之交互的Cursor对象:
#ConnecttoSQLiteDatabaseconn=sqlite3.connect(database)c=conn.cursor()为了确定所需表的主键,我们使用带有插入括号的表名的pragmatable_info命令。我们使用format()方法动态地将表的名称插入到否则静态的字符串中。在我们将命令的结果存储在table_data变量中后,我们对表名输入进行验证。如果用户提供了一个不存在的表名,我们将得到一个空列表作为结果。我们检查这一点,如果表不存在,就退出脚本。
#QueryTableforPrimaryKeyc.execute("pragmatable_info({})".format(table))table_data=c.fetchall()iftable_data==[]:print("[-]Checkspellingoftablename-'{}'didnotreturn""anyresults".format(table))sys.exit(2)在这一点上,我们为脚本的其余部分创建了一个if-else语句,具体取决于用户是否提供了可选的列参数。如果col是kwargs字典中的一个键,我们立即调用find_gaps()函数,并将Cursor对象c、表名和用户指定的主键列名传递给它。否则,我们尝试在table_data变量中识别主键。
先前在table_data变量中执行并存储的命令为给定表中的每一列返回一个元组。每个元组的最后一个元素是1或0之间的二进制选项,其中1表示该列是主键。我们遍历返回的元组中的每个最后一个元素,如果它们等于1,则将元组的索引一中存储的列名附加到potential_pks列表中。
if"col"inkwargs:find_gaps(c,table,kwargs["col"])else:#AddPrimaryKeystoListpotential_pks=[]forrowintable_data:ifrow[-1]==1:potential_pks.append(row[1])一旦我们确定了所有的主键,我们检查列表以确定是否存在零个或多个键。如果存在这些情况中的任何一种,我们会提醒用户并退出脚本。在这些情况下,用户需要指定哪一列应被视为主键列。如果列表包含单个主键,我们将该列的名称与数据库游标和表名一起传递给find_gaps()函数。
iflen(potential_pks)!=1:print("[-]Noneormultipleprimarykeysfound--please""checkifthereisaprimarykeyorspecifyaspecific""keyusingthe--columnargument")sys.exit(3)find_gaps(c,table,potential_pks[0])find_gaps()方法首先通过在控制台显示一条消息来提醒用户脚本的当前执行状态。我们尝试在try和except块中进行数据库查询。如果用户指定的列不存在或拼写错误,我们将从sqlite3库接收到OperationalError。这是用户提供的参数的最后验证步骤,如果触发了except块,脚本将退出。如果查询成功执行,我们获取所有数据并将其存储在results变量中。
deffind_gaps(db_conn,table,pk):print("[+]IdentifyingmissingROWIDsfor{}column".format(pk))try:db_conn.execute("select{}from{}".format(pk,table))exceptsqlite3.OperationalError:print("[-]'{}'columndoesnotexist--""pleasecheckspelling".format(pk))sys.exit(4)results=db_conn.fetchall()我们使用列表推导和内置的sorted()函数来创建排序后的主键列表。results列表包含索引0处的一个元素的元组,即主键,对于sms.db的message表来说,就是名为ROWID的列。有了排序后的ROWID列表,我们可以快速计算表中缺少的条目数。这将是最近的ROWID减去列表中存在的ROWID数。如果数据库中的所有条目都是活动的,这个值将为零。
我们假设最近的ROWID是实际最近的ROWID。有可能删除最后几个条目,而配方只会将最近的活动条目检测为最高的ROWID。
rowids=sorted([x[0]forxinresults])total_missing=rowids[-1]-len(rowids)如果列表中没有缺少任何值,我们将这一幸运的消息打印到控制台,并以0退出,表示成功终止。另一方面,如果我们缺少条目,我们将其打印到控制台,并显示缺少条目的计数。
iftotal_missing==0:print("[*]NomissingROWIDsfrom{}column".format(pk))sys.exit(0)else:print("[+]{}missingROWID(s)from{}column".format(total_missing,pk))为了计算缺失的间隙,我们使用range()方法生成从第一个ROWID到最后一个ROWID的所有ROWIDs的集合,然后将其与我们拥有的排序列表进行比较。difference()函数可以与集合一起使用,返回一个新的集合,其中包含第一个集合中不在括号中的对象中的元素。然后我们将识别的间隙打印到控制台,这样脚本的执行就完成了。
#FindMissingROWIDsgaps=set(range(rowids[0],rowids[-1]+1)).difference(rowids)print("[*]MissingROWIDS:{}".format(gaps))此脚本的输出示例可能如下截图所示。请注意,控制台可以根据已删除消息的数量迅速变得混乱。然而,这并不是此脚本的预期结束。我们将在本章后面的更高级的食谱“深入挖掘以恢复消息”中使用此脚本的逻辑,来识别并尝试定位潜在可恢复的消息:
在这个食谱中,我们将把未加密的iTunes备份转换成人类可读的格式,这样我们就可以轻松地探索其内容,而无需任何第三方工具。备份文件可以在主机计算机的MobileSync\Backup文件夹中找到。
在iOS10中引入的新备份格式中,文件存储在包含文件名前两个十六进制字符的子文件夹中。每个文件的名称都是设备上路径的SHA-1哈希。在设备的备份文件夹的根目录中,有一些感兴趣的文件,例如我们之前讨论过的Info.plist文件和Manifest.db数据库。此数据库存储了每个备份文件的详细信息,包括其SHA-1哈希、文件路径和名称。我们将使用这些信息来使用人类友好的名称重新创建本机备份文件夹结构。
此脚本中使用的所有库都包含在Python的标准库中。要跟随操作,您需要获取一个未加密的iTunes备份文件进行操作。确保备份文件是较新的iTunes备份格式(iOS10+),与之前描述的内容相匹配。
我们将使用以下步骤来处理此食谱中的iTunes备份:
首先,我们导入所需的库来处理参数解析、日志记录、文件复制和与SQLite数据库交互。我们还设置了一个变量,用于稍后构建食谱的日志记录组件:
from__future__importprint_functionimportargparseimportloggingimportosfromshutilimportcopyfileimportsqlite3importsyslogger=logging.getLogger(__name__)此食谱的命令行处理程序接受两个位置参数,INPUT_DIR和OUTPUT_DIR,分别表示iTunes备份文件夹和所需的输出文件夹。可以提供一个可选参数来指定日志文件的位置和日志消息的冗长程度。
if__name__=="__main__":#Command-lineArgumentParserparser=argparse.ArgumentParser(description=__description__,epilog="Developedby{}on{}".format(",".join(__authors__),__date__))parser.add_argument("INPUT_DIR",help="LocationoffoldercontainingiOSbackups,""e.g.~\Library\ApplicationSupport\MobileSync\Backupfolder")parser.add_argument("OUTPUT_DIR",help="OutputDirectory")parser.add_argument("-l",help="Logfilepath",default=__file__[:-2]+"log")parser.add_argument("-v",help="Increaseverbosity",action="store_true")args=parser.parse_args()接下来,我们开始为此食谱设置日志。我们检查用户是否提供了可选的冗长参数,如果有,我们将将级别从INFO增加到DEBUG:
ifargs.v:logger.setLevel(logging.DEBUG)else:logger.setLevel(logging.INFO)对于此日志,我们设置消息格式并为控制台和文件输出配置处理程序,并将它们附加到我们定义的logger:
msg_fmt=logging.Formatter("%(asctime)-15s%(funcName)-13s""%(levelname)-8s%(message)s")strhndl=logging.StreamHandler(sys.stderr)strhndl.setFormatter(fmt=msg_fmt)fhndl=logging.FileHandler(args.l,mode='a')fhndl.setFormatter(fmt=msg_fmt)logger.addHandler(strhndl)logger.addHandler(fhndl)设置好日志文件后,我们向日志记录一些调试详细信息,包括提供给此脚本的参数以及有关主机和Python版本的详细信息。我们排除了sys.argv列表的第一个元素,这是脚本的名称,而不是提供的参数之一:
logger.info("StartingiBackupVisualizer")logger.debug("Suppliedarguments:{}".format("".join(sys.argv[1:])))logger.debug("System:"+sys.platform)logger.debug("PythonVersion:"+sys.version)使用os.makedirs()函数,如果必要,我们将为所需的输出目录创建任何必要的文件夹,如果它们尚不存在:
ifnotos.path.exists(args.OUTPUT_DIR):os.makedirs(args.OUTPUT_DIR)最后,如果输入目录存在并且确实是一个目录,我们将提供的输入和输出目录传递给main()函数。如果输入目录未通过验证,我们将在退出脚本之前向控制台打印错误并记录:
ifos.path.exists(args.INPUT_DIR)andos.path.isdir(args.INPUT_DIR):main(args.INPUT_DIR,args.OUTPUT_DIR)else:logger.error("Suppliedinputdirectorydoesnotexistorisnot""adirectory")sys.exit(1)main()函数首先调用backup_summary()函数来识别输入文件夹中存在的所有备份。在继续main()函数之前,让我们先看看backup_summary()函数并了解它的作用:
defmain(in_dir,out_dir):backups=backup_summary(in_dir)backup_summary()函数使用os.listdir()方法列出输入目录的内容。我们还实例化backups字典,用于存储每个发现的备份的详细信息:
defbackup_summary(in_dir):logger.info("IdentifyingalliOSbackupsin{}".format(in_dir))root=os.listdir(in_dir)backups={}对于输入目录中的每个项目,我们使用os.path.join()方法与输入目录和项目。然后我们检查这是否是一个目录,而不是一个文件,以及目录的名称是否为40个字符长。如果目录通过了这些检查,这很可能是一个备份目录,因此我们实例化两个变量来跟踪备份中文件的数量和这些文件的总大小:
forxinroot:temp_dir=os.path.join(in_dir,x)ifos.path.isdir(temp_dir)andlen(x)==40:num_files=0size=0我们使用第一章中讨论的os.walk()方法,并为备份文件夹下的根目录、子目录和文件创建列表。因此,我们可以使用文件列表的长度,并在迭代备份文件夹时继续将其添加到num_files变量中。类似地,我们使用一个巧妙的一行代码将每个文件的大小添加到size变量中:
forroot,subdir,filesinos.walk(temp_dir):num_files+=len(files)size+=sum(os.path.getsize(os.path.join(root,name))fornameinfiles)在我们完成对备份的迭代之后,我们使用备份的名称作为键将备份添加到backups字典中,并将备份文件夹路径、文件计数和大小作为值存储。一旦我们完成了所有备份的迭代,我们将这个字典返回给main()函数。让我们接着来看:
backups[x]=[temp_dir,num_files,size]returnbackups在main()函数中,如果找到了任何备份,我们将每个备份的摘要打印到控制台。对于每个备份,我们打印一个任意的标识备份的数字,备份的名称,文件数量和大小。我们使用format()方法并手动指定换行符(\n)来确保控制台保持可读性:
print("BackupSummary")print("="*20)iflen(backups)>0:fori,binenumerate(backups):print("BackupNo.:{}\n""BackupDev.Name:{}\n""#Files:{}\n""BackupSize(Bytes):{}\n".format(i,b,backups[b][1],backups[b][2]))接下来,我们使用try-except块将Manifest.db文件的内容转储到db_items变量中。如果找不到Manifest.db文件,则识别的备份文件夹可能是旧格式或无效的,因此我们使用continue命令跳过它。让我们简要讨论一下process_manifest()函数,它使用sqlite3连接到并提取Manifest.db文件表中的所有数据:
try:db_items=process_manifest(backups[b][0])exceptIOError:logger.warn("Non-iOS10backupencounteredor""invalidbackup.Continuingtonextbackup.")continueprocess_manifest()方法以备份的目录路径作为唯一输入。对于这个输入,我们连接Manifest.db字符串,表示这个数据库应该存在在一个有效的备份中的位置。如果发现这个文件不存在,我们记录这个错误并向main()函数抛出一个IOError,正如我们刚才讨论的那样,这将导致在控制台上打印一条消息,并继续下一个备份:
defprocess_manifest(backup):manifest=os.path.join(backup,"Manifest.db")ifnotos.path.exists(manifest):logger.error("ManifestDBnotfoundin{}".format(manifest))raiseIOError如果文件确实存在,我们连接到它,并使用sqlite3创建Cursor对象。items字典使用每个条目在Files表中的SHA-1哈希作为键,并将所有其他数据存储为列表中的值。请注意,这里有一种替代方法来访问查询结果,而不是在以前的示例中使用的fetchall()函数。在我们从Files表中提取了所有数据之后,我们将字典返回给main()函数:
conn=sqlite3.connect(manifest)c=conn.cursor()items={}forrowinc.execute("SELECT*fromFiles;"):items[row[0]]=[row[2],row[1],row[3]]returnitems回到main()函数,我们立即将返回的字典,现在称为db_items,传递给create_files()方法。我们刚刚创建的字典将被下一个函数用来执行对文件SHA-1哈希的查找,并确定其真实文件名、扩展名和本地文件路径。create_files()函数执行这些查找,并将备份文件复制到输出文件夹,并使用适当的路径、名称和扩展名。
else语句处理了backup_summary()函数未找到备份的情况。我们提醒用户应该是适当的输入文件夹,并退出脚本。这完成了main()函数;现在让我们继续进行create_files()方法:
create_files(in_dir,out_dir,b,db_items)print("="*20)else:logger.warning("Novalidbackupsfound.Theinputdirectoryshouldbe""theparent-directoryimmediatelyabovetheSHA-1hash""iOSdevicebackups")sys.exit(2)我们通过在日志中打印状态消息来启动create_files()方法:
defcreate_files(in_dir,out_dir,b,db_items):msg="CopyingFilesforbackup{}to{}".format(b,os.path.join(out_dir,b))logger.info(msg)接下来,我们创建一个计数器来跟踪在清单中找到但在备份中找不到的文件数量。然后,我们遍历从process_manifest()函数生成的db_items字典中的每个键。我们首先检查关联的文件名是否为None或空字符串,否则继续到下一个SHA-1哈希项:
files_not_found=0forx,keyinenumerate(db_items):ifdb_items[key][0]isNoneordb_items[key][0]=="":continue如果关联的文件名存在,我们创建几个表示输出目录路径和输出文件路径的变量。请注意,输出路径被附加到备份名称b的名称上,以模仿输入目录中备份文件夹的结构。我们使用输出目录路径dirpath首先检查它是否存在,否则创建它:
else:dirpath=os.path.join(out_dir,b,os.path.dirname(db_items[key][0]))filepath=os.path.join(out_dir,b,db_items[key][0])ifnotos.path.exists(dirpath):os.makedirs(dirpath)我们创建了一些路径变量,包括输入目录中备份文件的位置。我们通过创建一个字符串,其中包括备份名称、SHA-1哈希键的前两个字符和SHA-1键本身,它们之间用斜杠分隔来实现这一点。然后将其连接到输入目录中:
original_dir=b+"/"+key[0:2]+"/"+keypath=os.path.join(in_dir,original_dir)有了所有这些路径创建好后,我们现在可以开始执行一些验证步骤,然后将文件复制到新的输出目的地。首先,我们检查输出文件是否已经存在于输出文件夹中。在开发这个脚本的过程中,我们注意到一些文件具有相同的名称,并存储在输出文件夹中的同一文件夹中。这导致数据被覆盖,并且备份文件夹和输出文件夹之间的文件计数不匹配。为了解决这个问题,如果文件已经存在于备份中,我们会附加一个下划线和一个整数x,表示循环迭代次数,这对我们来说是一个唯一的值:
ifos.path.exists(filepath):filepath=filepath+"_{}".format(x)解决了文件名冲突后,我们使用shutil.copyfile()方法来复制由路径变量表示的备份文件,并将其重命名并存储在输出文件夹中,由filepath变量表示。如果路径变量指的是不在备份文件夹中的文件,它将引发IOError,我们会捕获并记录到日志文件中,并添加到我们的计数器中:
try:copyfile(path,filepath)exceptIOError:logger.debug("Filenotfoundinbackup:{}".format(path))files_not_found+=1然后,我们向用户提供一个警告,告知在Manifest.db中未找到的文件数量,以防用户未启用详细日志记录。一旦我们将备份目录中的所有文件复制完毕,我们就使用shutil.copyfile()方法逐个复制备份文件夹中存在的非混淆的PLIST和数据库文件到输出文件夹中:
iffiles_not_found>0:logger.warning("{}fileslistedintheManifest.dbnot""foundinbackup".format(files_not_found))copyfile(os.path.join(in_dir,b,"Info.plist"),os.path.join(out_dir,b,"Info.plist"))copyfile(os.path.join(in_dir,b,"Manifest.db"),os.path.join(out_dir,b,"Manifest.db"))copyfile(os.path.join(in_dir,b,"Manifest.plist"),os.path.join(out_dir,b,"Manifest.plist"))copyfile(os.path.join(in_dir,b,"Status.plist"),os.path.join(out_dir,b,"Status.plist"))当我们运行这段代码时,我们可以在输出中看到以下更新后的文件结构:
WiGLE是一个在线可搜索的存储库,截至撰写时,拥有超过3亿个Wi-Fi网络。我们将使用Python的requests库访问WiGLE的API,以基于Wi-FiMAC地址执行自动搜索。要安装requests库,我们可以使用pip,如下所示:
这个教程遵循以下步骤来实现目标:
首先,我们导入所需的库来处理参数解析、编写电子表格、处理XML数据以及与WiGLEAPI交互:
from__future__importprint_functionimportargparseimportcsvimportosimportsysimportxml.etree.ElementTreeasETimportrequests这个教程的命令行处理程序接受两个位置参数,INPUT_FILE和OUTPUT_CSV,分别表示带有Wi-FiMAC地址的输入文件和期望的输出CSV。默认情况下,脚本假定输入文件是CellebriteXML报告。用户可以使用可选的-t标志指定输入文件的类型,并在xml或txt之间进行选择。此外,我们可以设置包含我们API密钥的文件的路径。默认情况下,这在用户目录的基础上设置,并命名为.wigle_api,但您可以更新此值以反映您的环境中最容易的内容。
保存您的API密钥的文件应具有额外的保护措施,通过文件权限或其他方式,以防止您的密钥被盗。
if__name__=="__main__":#Command-lineArgumentParserparser=argparse.ArgumentParser(description=__description__,epilog="Developedby{}on{}".format(",".join(__authors__),__date__),formatter_class=argparse.ArgumentDefaultsHelpFormatter)parser.add_argument("INPUT_FILE",help="INPUTFILEwithMACAddresses")parser.add_argument("OUTPUT_CSV",help="OutputCSVFile")parser.add_argument("-t",help="Inputtype:CellebriteXMLreportorTXTfile",choices=('xml','txt'),default="xml")parser.add_argument('--api',help="PathtoAPIkeyfile",default=os.path.expanduser("~/.wigle_api"),type=argparse.FileType('r'))args=parser.parse_args()我们执行标准的数据验证步骤,并检查输入文件是否存在且为文件,否则退出脚本。我们使用os.path.dirname()来提取目录路径并检查其是否存在。如果目录不存在,我们使用os.makedirs()函数来创建目录。在调用main()函数之前,我们还读取并拆分API名称和密钥:
ifnotos.path.exists(args.INPUT_FILE)or\notos.path.isfile(args.INPUT_FILE):print("[-]{}doesnotexistorisnotafile".format(args.INPUT_FILE))sys.exit(1)directory=os.path.dirname(args.OUTPUT_CSV)ifdirectory!=''andnotos.path.exists(directory):os.makedirs(directory)api_key=args.api.readline().strip().split(":")在我们执行参数验证之后,我们将所有参数传递给main()函数:
main(args.INPUT_FILE,args.OUTPUT_CSV,args.t,api_key)在main()函数中,我们首先确定我们正在处理的输入类型。默认情况下,type变量是"xml",除非用户另有指定。根据文件类型,我们将其发送到适当的解析器,该解析器将以字典形式返回提取的Wi-Fi数据元素。然后将此字典与输出CSV一起传递给query_wigle()函数。此函数负责查询、处理并将查询结果写入CSV文件。首先,让我们来看看解析器,从parse_xml()函数开始:
defmain(in_file,out_csv,type,api_key):iftype=='xml':wifi=parse_xml(in_file)else:wifi=parse_txt(in_file)query_wigle(wifi,out_csv,api_key)我们使用xml.etree.ElementTree解析CellebriteXML报告,我们已将其导入为ET。
解析由取证工具生成的报告可能是棘手的。这些报告的格式可能会发生变化,并破坏您的脚本。因此,我们不能假设此脚本将继续在未来的CellebritePhysicalAnalyzer软件版本中运行。正因为如此,我们已包含了一个选项,可以使用此脚本与包含MAC地址的文本文件一起使用。
与任何XML文件一样,我们需要首先访问文件并使用ET.parse()函数对其进行解析。然后我们使用getroot()方法返回XML文件的根元素。我们将此根元素作为文件中搜索报告中的Wi-Fi数据标记的初始立足点:
forfieldinchild.findall(xmlns+"field"):iffield.get("name")=="TimeStamp":ts_value=field.find(xmlns+"value")try:ts=ts_value.textexceptAttributeError:continue类似地,我们检查字段的名称是否与"Description"匹配。此字段包含Wi-Fi网络的BSSID和SSID,以制表符分隔的字符串。我们尝试访问此值的文本,并在没有文本时引发AttributeError:
iffield.get("name")=="Description":value=field.find(xmlns+"value")try:value_text=value.textexceptAttributeError:continue因为Cellebrite报告中可能存在其他类型的"Location"工件,我们检查值的文本中是否存在字符串"SSID"。如果是,我们使用制表符特殊字符将字符串拆分为两个变量。我们从值的文本中提取的这些字符串包含一些不必要的字符,我们使用字符串切片将其从字符串中删除:
ifbssidinwifi.keys():wifi[bssid]["Timestamps"].append(ts)wifi[bssid]["SSID"].append(ssid)else:wifi[bssid]={"Timestamps":[ts],"SSID":[ssid],"Wigle":{}}returnwifi与XML解析器相比,TXT解析器要简单得多。我们遍历文本文件的每一行,并将每一行设置为一个MAC地址,作为一个空字典的键。在处理文件中的所有行之后,我们将字典返回给main()函数:
defparse_txt(txt_file):wifi={}print("[+]ExtractingMACaddressesfrom{}".format(txt_file))withopen(txt_file)asmac_file:forlineinmac_file:wifi[line.strip()]={"Timestamps":["N/A"],"SSID":["N/A"],"Wigle":{}}returnwifi有了MAC地址的字典,我们现在可以转到query_wigle()函数,并使用requests进行WiGLEAPI调用。首先,我们在控制台打印一条消息,通知用户当前的执行状态。接下来,我们遍历字典中的每个MAC地址,并使用query_mac_addr()函数查询BSSID的站点:
defquery_wigle(wifi_dictionary,out_csv,api_key):print("[+]QueryingWigle.netthroughPythonAPIfor{}""APs".format(len(wifi_dictionary)))formacinwifi_dictionary:wigle_results=query_mac_addr(mac,api_key)query_mac_addr()函数接受我们的MAC地址和API密钥,并构造请求的URL。我们使用API的基本URL,并在其末尾插入MAC地址。然后将此URL提供给requests.get()方法,以及authkwarg来提供API名称和密钥。requests库处理形成并发送带有正确HTTP基本身份验证的数据包到API。req对象现在已准备好供我们解释,因此我们可以调用json()方法将数据返回为字典:
由于初始字典的复杂性,我们创建了一个名为shortres的变量,用作输出字典的更深部分的快捷方式。这样可以防止我们在每次需要访问字典的那部分时不必要地写入整个目录结构。shortres变量的第一个用法可以看作是我们从WiGLE结果中提取此网络的纬度和经度,并将其附加到GoogleMaps查询中:
我们可以遍历第二个字典中的每个键,并逐个添加其键值对。但是,使用Python3.5中引入的一个特性会更快,我们可以通过在每个字典之前放置两个*符号来合并这两个字典。这将合并两个字典,并且如果有任何重名的键,它将用第二个字典中的数据覆盖第一个字典中的数据。在这种情况下,我们没有任何键重叠,所以这将简单地合并字典。
请参阅以下StackOverflow帖子以了解更多关于字典合并的信息:
在合并了所有字典之后,我们继续使用write_csv()函数最终写入输出:
csv_data["{}-{}-{}".format(x,y,z)]={**{"BSSID":mac,"SSID":data[mac]["SSID"][y],"CellebriteConnectionTime":ts,"GoogleMapURL":g_map_url},**shortres}write_csv(output,csv_data)在这个示例中,我们重新介绍了csv.DictWriter类,它允许我们轻松地将字典写入CSV文件。这比我们之前使用的csv.writer类更可取,因为它为我们提供了一些好处,包括对列进行排序。为了利用这一点,我们需要知道我们使用的所有字段。由于WiGLE是动态的,报告的结果可能会改变,我们选择动态查找输出字典中所有键的名称。通过将它们添加到一个集合中,我们确保只有唯一的键:
defwrite_csv(output,data):print("[+]Writingdatato{}".format(output))field_list=set()forrowindata:forfieldindata[row]:field_list.add(field)一旦我们确定了输出中所有的键,我们就可以创建CSV对象。请注意,使用csv.DictWriter对象时,我们使用了两个关键字参数。如前所述,第一个是字典中所有键的列表,我们已经对其进行了排序。这个排序后的列表就是结果CSV中列的顺序。如果csv.DictWriter遇到一个不在提供的field_list中的键,由于我们的预防措施,它会忽略错误而不是引发异常,这是由extrasactionkwarg中的配置决定的:
withopen(output,"w",newline="")ascsvfile:csv_writer=csv.DictWriter(csvfile,fieldnames=sorted(field_list),extrasaction='ignore')一旦我们设置好写入器,我们可以使用writeheader()方法根据提供的字段名称自动写入列。之后,只需简单地遍历数据中的每个字典,并使用writerow()函数将其写入CSV文件。虽然这个函数很简单,但想象一下,如果我们没有先简化原始数据结构,我们会有多大的麻烦:
csv_writer.writeheader()forcsv_rowindata:csv_writer.writerow(data[csv_row])运行此脚本后,我们可以在CSV报告中看到各种有用的信息。前几列包括BSSID、Google地图URL、城市和县:
最后,我们可以了解到SSID、坐标、网络类型和使用的认证方式:
示例难度:困难
在本章的前面,我们开发了一个从数据库中识别缺失记录的示例。在这个示例中,我们将利用该示例的输出,识别可恢复的记录及其在数据库中的偏移量。这是通过了解SQLite数据库的一些内部机制,并利用这种理解来实现的。
通过这种技术,我们将能够快速审查数据库并识别可恢复的消息。
我们不会深入讨论SQLite结构;可以说每个条目由四个元素组成:有效载荷长度、ROWID、有效载荷头和有效载荷本身。前面的配方识别了缺失的ROWID值,我们将在这里使用它来查找数据库中所有这样的ROWID出现。我们将使用其他数据,例如已知的标准有效载荷头值,与iPhone短信数据库一起验证任何命中。虽然这个配方专注于从iPhone短信数据库中提取数据,但它可以修改为适用于任何数据库。我们稍后将指出需要更改的几行代码,以便将其用于其他数据库。
此脚本中使用的所有库都包含在Python的标准库中。如果您想跟着操作,请获取iPhone短信数据库。如果数据库不包含任何已删除的条目,请使用SQLite连接打开它并删除一些条目。这是一个很好的测试,可以确认脚本是否按预期在您的数据集上运行。
这个配方由以下步骤组成:
首先,我们导入所需的库来处理参数解析、操作十六进制和二进制数据、编写电子表格、创建笛卡尔积的元组、使用正则表达式进行搜索以及与SQLite数据库交互:
from__future__importprint_functionimportargparseimportbinasciiimportcsvfromitertoolsimportproductimportosimportreimportsqlite3importsys这个配方的命令行处理程序有三个位置参数和一个可选参数。这与本章前面的在SQLite数据库中识别间隙配方基本相同;但是,我们还添加了一个用于输出CSV文件的参数:
if__name__=="__main__":#Command-lineArgumentParserparser=argparse.ArgumentParser(description=__description__,epilog="Developedby{}on{}".format(",".join(__authors__),__date__))parser.add_argument("SQLITE_DATABASE",help="InputSQLitedatabase")parser.add_argument("TABLE",help="Tabletoqueryfrom")parser.add_argument("OUTPUT_CSV",help="OutputCSVFile")parser.add_argument("--column",help="Optionalcolumnargument")args=parser.parse_args()在解析参数后,我们将提供的参数传递给main()函数。如果用户提供了可选的列参数,我们将使用col关键字参数将其传递给main()函数:
ifargs.columnisnotNone:main(args.SQLITE_DATABASE,args.TABLE,args.OUTPUT_CSV,col=args.column)else:main(args.SQLITE_DATABASE,args.TABLE,args.OUTPUT_CSV)因为这个脚本利用了我们之前构建的内容,main()函数在很大程度上是重复的。我们不会重复关于代码的注释(对于一行代码,只能说这么多),我们建议您参考在SQLite数据库中识别间隙配方,以了解代码的这部分内容。
为了让大家回忆起来,以下是该配方的摘要:main()函数执行基本的输入验证,从给定表中识别潜在的主键(除非用户提供了列),并调用find_gaps()函数。find_gaps()函数是前一个脚本的另一个保留部分,几乎与前一个相同,只有一行不同。这个函数现在不再打印所有已识别的间隙,而是将已识别的间隙返回给main()函数。main()函数的其余部分和此后涵盖的所有其他代码都是新的。这是我们继续理解这个配方的地方。
识别了间隙后,我们调用一个名为varint_converter()的函数来处理每个间隙,将其转换为其varint对应项。Varint,也称为可变长度整数,是大小为1到9个字节的大端整数。SQLite使用Varint,因为它们所占的空间比存储ROWID整数本身要少。因此,为了有效地搜索已删除的ROWID,我们必须首先将其转换为varint,然后再进行搜索:
print("[+]CarvingformissingROWIDs")varints=varint_converter(list(gaps))对于小于或等于127的ROWID,它们的varint等价物就是整数的十六进制表示。我们使用内置的hex()方法将整数转换为十六进制字符串,并使用字符串切片来删除前置的0x。例如,执行hex(42)返回字符串0x2a;在这种情况下,我们删除了前导的0x十六进制标识符,因为我们只对值感兴趣:
else:combos=[xforxinrange(0,256)]counter=1whileTrue:counter+=1print("[+]Generatingandfindingall{}byte""varints..".format(counter))varint_combos=list(product(combos,repeat=counter))varint_combos=[xforxinvarint_combosifx[0]>=128]创建了n字节varints列表后,我们循环遍历每个组合,并将其传递给integer_converter()函数。这个函数将这些数字视为varint的一部分,并将它们解码为相应的ROWID。然后,我们可以将返回的ROWID与缺失的ROWID进行比较。如果匹配,我们将一个键值对添加到varints字典中,其中键是varint的十六进制表示,值是缺失的ROWID。此时,我们将i变量增加1,并尝试获取下一个行元素。如果成功,我们处理该ROWID,依此类推,直到我们已经到达将生成IndexError的ROWIDs的末尾。我们捕获这样的错误,并将varints字典返回给main()函数。
关于这个函数需要注意的一件重要的事情是,因为输入是一个排序过的ROWIDs列表,我们只需要计算n字节varint组合一次,因为下一个ROWID只能比前一个更大而不是更小。另外,由于我们知道下一个ROWID至少比前一个大一,我们继续循环遍历我们创建的varint组合,而不重新开始,因为下一个ROWID不可能更小。这些技术展示了while循环的一个很好的用例,因为它们大大提高了该方法的执行速度:
forvarint_comboinvarint_combos:varint=integer_converter(varint_combo)ifvarint==row:varints["".join([hex(v)[2:].zfill(2)forvinvarint_combo])]=rowi+=1try:row=rows[i]exceptIndexError:returnvarintsinteger_converter()函数相对简单。这个函数使用内置的bin()方法,类似于已经讨论过的hex()方法,将整数转换为其二进制等价物。我们遍历建议的varint中的每个值,首先使用bin()进行转换。这将返回一个字符串,这次前缀值为0b,我们使用字符串切片去除它。我们再次使用zfill()来确保字节具有所有位,因为bin()方法默认会去除前导的0位。之后,我们移除每个字节的第一位。当我们遍历我们的varint中的每个数字时,我们将处理后的位添加到一个名为binary的变量中。
这个过程可能听起来有点混乱,但这是解码varints的手动过程。
有关如何手动将varints转换为整数和其他SQLite内部的更多详细信息,请参阅Forensicsfromthesausagefactory上的这篇博文:
在我们完成对数字列表的迭代后,我们使用lstrip()来去除二进制字符串中的任何最左边的零值。如果结果字符串为空,我们返回0;否则,我们将处理后的二进制数据转换并返回为从二进制表示的基数2的整数:
definteger_converter(numbs):binary=""fornumbinnumbs:binary+=bin(numb)[2:].zfill(8)[1:]binvar=binary.lstrip("0")ifbinvar!='':returnint(binvar,2)else:return0回到main()函数,我们将varints字典和数据库文件的路径传递给find_candidates()函数:
search_results=find_candidates(database,varints)我们搜索的两个候选者是"350055"和"360055"。如前所述,在数据库中,跟随单元格的ROWID是有效载荷头长度。iPhone短信数据库中的有效载荷头长度通常是两个值中的一个:要么是0x35,要么是0x36。在有效载荷头长度之后是有效载荷头本身。有效载荷头的第一个序列类型将是0x00,表示为NULL值,数据库的主键--第一列,因此第一个序列类型--将始终被记录为。接下来是序列类型0x55,对应于表中的第二列,消息GUID,它始终是一个21字节的字符串,因此将始终由序列类型0x55表示。任何经过验证的命中都将附加到结果列表中。
通过搜索ROWIDvarint和这三个附加字节,我们可以大大减少误报的数量。请注意,如果您正在处理的数据库不是iPhone短信数据库,则需要更改这些候选者的值,以反映表中ROWID之前的任何静态内容:
deffind_candidates(database,varints):results=[]candidate_a="350055"candidate_b="360055"我们以rb模式打开数据库以搜索其二进制内容。为了做到这一点,我们必须首先读取整个数据库,并使用binascii.hexlify()函数将这些数据转换为十六进制。由于我们已经将varints存储为十六进制,因此现在可以轻松地搜索这些数据集以查找varint和其他周围的数据。我们通过循环遍历每个varint并创建两个不同的搜索字符串来开始搜索过程,以考虑iPhone短信数据库中的两个静态支点之一:
withopen(database,"rb")asinfile:hex_data=str(binascii.hexlify(infile.read()))forvarintinvarints:search_a=varint+candidate_asearch_b=varint+candidate_b然后,我们使用re.finditer()方法基于search_a和search_b关键字来迭代每个命中。对于每个结果,我们附加一个包含ROWID、使用的搜索词和文件内的偏移量的列表。我们必须除以2来准确报告字节数,而不是十六进制数字的数量。在完成搜索数据后,我们将结果返回给main()函数:
forresultinre.finditer(search_a,hex_data):results.append([varints[varint],search_a,result.start()/2])forresultinre.finditer(search_b,hex_data):results.append([varints[varint],search_b,result.start()/2])returnresults最后一次,我们回到main()函数。这次我们检查是否有搜索结果。如果有,我们将它们与CSV输出一起传递给csvWriter()方法。否则,我们在控制台上打印状态消息,通知用户没有识别到完整可恢复的ROWID:
ifsearch_results!=[]:print("[+]Writing{}potentialcandidatesto{}".format(len(search_results),out_csv))write_csv(out_csv,["ROWID","SearchTerm","Offset"],search_results)else:print("[-]NosearchresultsfoundformissingROWIDs")write_csv()方法一如既往地简单。我们打开一个新的CSV文件,并为嵌套列表结构中存储的三个元素创建三列。然后,我们使用writerows()方法将结果数据列表中的所有行写入文件:
defwrite_csv(output,cols,msgs):withopen(output,"w",newline="")ascsvfile:csv_writer=csv.writer(csvfile)csv_writer.writerow(cols)csv_writer.writerows(msgs)当我们查看导出的报告时,我们可以清楚地看到我们的行ID、搜索的十六进制值以及记录被发现的数据库内的偏移量: