Python 取证学习指南第二版(四)
原文:
annas-archive.org/md5/46c71d4b3d6fceaba506eebc55284aa5译者:飞龙
第十章:快速筛查系统
在今天这个充满挑战的新时代里,事件往往会在没有快速有效响应的情况下迅速失控,因此,DFIR 专业人士必须能够查询主机的相关信息,如系统上运行的进程和服务,从而做出明智的调查决策,快速遏制事件。虽然我们通常可以在机器的取证镜像上收集这些信息,但其中一些信息是易失性的,或者可能需要快速收集,而不是等待创建取证镜像。
在本章中,我们将开发一个与现代操作系统兼容的单一脚本,并使用各种第一方和第三方库,提取脚本所运行系统的有用信息。通过一些修改,这个脚本可以在一个环境中使用,通过将其部署到多个主机,收集可能对调查有价值的基本系统信息。例如,在涉及恶意软件的事件中,如果恶意软件在成功感染主机时创建了一个新进程,那么可以使用这些信息快速确定被感染的主机范围,并在进一步调查时找出最早被感染的机器。
为了实现一个跨操作系统兼容的脚本,我们将依赖一个名为psutil的第三方模块来获取运行中进程的信息,而对于 Windows 操作系统的更多操作系统特定情报,将使用Windows 管理界面(WMI)进行提取。
在本章中,我们将涵盖以下主题:
-
使用
psutil提取与操作系统无关的进程信息 -
使用 Python 及
wmi和pywin32模块通过查询 WMI 与 Windows 系统交互 -
创建一个多平台的初步筛查文档收集脚本
本章的代码是使用 Python 2.7.15 和 Python 3.7.1 开发和测试的。
理解系统信息的价值
那么,为什么要收集系统信息呢?并非所有的调查都围绕用户及其在系统上采取的行动展开,而是关注系统本身以及它的行为。例如,在上一节中,我们讨论了运行中的进程和创建的服务如何根据特定场景的妥协指示符提供信息。然而,正如 DFIR 专业人士所知,系统信息的来源也可以为用户活动提供洞察,比如当前连接到机器的磁盘或查询事件日志中的用户登录信息。
在本书的第一版中,本章最初展示了一个我们开发的 keylogger 脚本,目的主要是演示如何使用操作系统 API。对于第二版,我们决定保持这一重点不变,但以一种更具法医相关性的方式应用它。让我们深入探讨并讨论第三方库。我们将需要从 psutil 开始开发这个脚本。
使用 psutil 查询操作系统无关的进程信息
psutil 模块(版本 5.4.5)是一个跨平台库,能够收集不同操作系统的各种系统信息,适用于 32 位和 64 位架构。虽然我们使用此库从运行脚本的主机系统中提取进程信息,但请注意,这个库能够提取的系统信息远不止运行中的进程。
让我们通过一些示例来了解,虽然其中一些我们在脚本中不会利用,但首先使用 pip 安装该库:
pip install psutil==5.4.5
我们可以使用 pids() 函数获取活动进程 ID 的列表,然后使用 PID 收集该进程的更多信息。例如,在以下代码块中,我们选择 PID 列表中的第一个 PID,PID 为 62,创建一个 PID 为 62 的进程对象,并使用各种函数来显示其名称、父 PID 和打开的文件。
请注意,对于某些函数,例如 open_files() 方法,您需要在提升权限的命令提示符下运行命令:
>>> import psutil
>>> pids = psutil.pids()
>>> pids[0]
62
>>> proc = psutil.Process(pids[0])
>>> proc.is_running()
True
>>> proc.name()
syslogd
>>> proc.ppid()
1
>>> proc.parent().name()
launchd
>>> proc.open_files()[0]
popenfile(path='/private/var/run/utmpx', fd=3)
虽然我们使用此库打印进程的详细信息,但我们也可以用它来执行其他任务。
例如,我们可以使用 disk_partitions() 函数收集有关连接磁盘的信息:
>>> for part in psutil.disk_partitions():
... print("Device: {}, Filesystem: {}, Mount: {},"
... " Size: {}, Disk Used: {}%".format(
... part[0], part[2], part[1],
... psutil.disk_usage(part[1])[0],
... psutil.disk_usage(part[1])[3]))
...
Device: /dev/disk1s1, Filesystem: apfs, Mount: /, Size: 500068036608, Disk Used: 82.9%
此外,我们还可以使用 users() 函数识别系统上的用户配置文件以及用户会话的启动时间:
>>> psutil.users()[0].name
PyForensics
>>> psutil.users()[0].started
1548086912.0
>>> from datetime import datetime
>>> print(datetime.utcfromtimestamp(psutil.users()[0].started))
2019-01-21 16:08:32
您可以通过阅读文档页面了解更多关于此库的信息:pypi.org/project/psutil/。
使用 WMI
wmi 库由 Tim Golden 维护,是对下一个部分中将介绍的 pywin32 模块的封装,允许程序员与 WMI API 进行交互,并为程序员提供大量与 Windows 系统相关的重要信息。您甚至可以使用此库查询网络上的其他 Windows 系统。
首先,在命令提示符下执行以下命令使用 pip 安装 WMI:
pip install WMI==1.4.9
不言而喻,我们将在这里讨论的示例只适用于 Windows 系统,因此应在 Windows 系统上执行。让我们首先看看如何查询正在运行的服务。
我们需要创建一个 WMI 对象,然后使用 query() 方法来识别正在运行的服务:
>>> import wmi
>>> conn = wmi.WMI()
>>> for service in conn.query(
... "SELECT * FROM Win32_Service WHERE State='Running'"):
... print("Service: {}, Desc: {}, Mode: {}".format(
... service.Name, service.Description, service.StartMode))
...
Service: PlugPlay, Desc: Enables a computer to recognize and adapt to hardware changes with little or no user input. Stopping or disabling this service will result in system instability., Mode: Manual
例如,我们可以使用该模块识别与系统关联的已安装打印机。
以下示例中的部分输出,表示为字符串 [...],已被清除:
>>> for printer in conn.Win32_Printer():
... print(printer.Name)
...
Microsoft XPS Document Writer
Microsoft Print to PDF
HP[...] (HP ENVY Photo 6200 series)
Fax
最后,本文中使用的这个库的一个非常有用的功能是,它允许我们查询 Windows 事件日志。
在下面的示例中,我们查询OAlerts.evtx文件,这是一个存储 Microsoft Office 警报的事件日志,并打印出每个事件的消息和事件生成的时间。为了简洁起见,这里仅展示一条这样的消息:
>>> for event in conn.query(
"SELECT * FROM Win32_NTLogEvent WHERE Logfile='OAlerts'"):
... print(event.Message, event.TimeGenerated)
...
Microsoft Excel
Want to save your changes to 'logonevent.csv'?
P1: 153042
P2: 15.0.5101.1000
P3:
P4:
20190121031627.589966-000
我们可以讨论这个库的许多其他功能;然而,我们邀请你探索并尝试它的功能。在本章的脚本中,我们将介绍更多这个库的示例。
本模块需要pywin32库,它是一个非常强大的库,能够让开发者访问多个不同的 Windows API,相关内容将在下一节简要介绍。需要理解的是,我们只是初步接触这些库的表面,重点是我们的脚本目标。花些时间阅读这些库的文档并尝试它们的功能,因为在任何与 Windows 操作系统交互的脚本中,你可能会发现这些库非常有用。
在文档页面pypi.org/project/WMI/上了解更多关于wmi库及其功能的信息。使用wmi库的示例食谱可以在此处找到:timgolden.me.uk/python/wmi/cookbook.html。
pywin32模块的功能是什么?
对 Python 来说,最通用的 Windows API 库之一是pywin32(版本 224)。该项目由 Mark Hammond 托管在 GitHub(之前托管在 SourceForge)上,是一个开源项目,社区成员共同贡献。通过这个库,Windows 提供了许多不同的 API。这些功能允许开发者为应用程序构建 GUI,利用内置的身份验证方法,并与硬盘和其他外部设备进行交互。
pywin32模块可以通过在命令提示符中执行以下命令使用pip安装:
pip install pywin32==224
Windows 定义了一个组件对象模型(COM),它允许应用程序之间共享信息。COM 可以是动态链接库(DLL)或其他二进制文件格式。这些模块的设计使得任何编程语言都可以解读这些信息。例如,这一套指令可以让基于 C++的程序和基于 Java 的程序共享同一资源,而无需为每种语言提供一个单独的版本。COM 通常只存在于 Windows 平台上,尽管如果需要,它们也可以移植到 UNIX 平台上。win32com库是pywin32库的一部分,允许我们在 Windows 中与 COM 交互,并由wmi库用于获取我们请求的信息。
pywin32库可以在 GitHub 上找到:github.com/mhammond/pywin32。
快速检查系统状态 – pysysinfo.py
在我们已经介绍了收集易失性信息的重要性以及我们将使用的库之后,现在我们准备深入本章的重点——pysysinfo.py 脚本。该脚本由多个函数组成,其中大多数与 psutil 库相关,但其核心首先识别它运行的系统类型,如果该系统使用的是 Windows 操作系统,则会运行一个额外的函数,使用之前讨论过的 WMI API。您可以在下图中看到各个函数是如何相互作用并组成本章剩余部分讨论的代码:
该脚本是在 Python 2.7.15 和 3.7.1 上开发和测试的。和我们开发的任何脚本一样,我们必须从导入必要的库开始,才能成功执行我们编写的代码。你会注意到一些常见的导入;然而,有一些特别引人注目——尤其是第 5 行和第 8 行的 platform 模块和 psutil。你可能还会注意到,这组导入中缺少了 wmi。你将在脚本的后面几段理解为什么稍后会导入这个库。该脚本包含七个不同的函数,其中大多数用于处理来自 psutil 库的数据。
请注意,return_none() 函数将在下一个代码块中介绍,而不是新开一个小节,因为它是一个一行的函数,简单地返回 None 给调用代码:
002 from __future__ import print_function
003 import argparse
004 import os
005 import platform
006 import sys
007
008 import psutil
009 if sys.version_info[0] == 2:
010 import unicodecsv as csv
011 elif sys.version_info[0] == 3:
012 import csv
...
050 def return_none():
051 """
052 Returns a None value, but is callable.
053 :return: None.
054 """
055 return None
...
058 def read_proc_connections(proc):
...
081 def read_proc_files(proc):
...
101 def get_pid_details(pid):
...
158 def get_process_info():
...
172 def wmi_info(outdir):
...
279 def csv_writer(data, outdir, name, headers, **kwargs):
platform 模块是我们之前没有涉及的,它是标准库的一部分,也提供了一些关于运行该脚本的系统的信息。在本例中,我们仅使用此库来确定执行脚本的主机系统的操作系统。
通过阅读文档页面了解更多关于 platform 模块的信息:docs.python.org/3/library/platform.html。
接下来是脚本设置,我们有参数解析器,与其他章节相比,它显得相当简单,仅包含一个位置参数 OUTPUT_DIR,用于指定写入处理后数据的输出目录。
如果目标输出目录不存在,我们将在第 323 行使用 os.makedirs() 函数创建它:
313 if __name__ == '__main__':
314 parser = argparse.ArgumentParser(description=__description__,
315 epilog='Developed by ' +
316 __author__ + ' on ' +
317 __date__)
318 parser.add_argument('OUTPUT_DIR',
319 help="Path to output directory. Will create if not found.")
320 args = parser.parse_args()
321
322 if not os.path.exists(args.OUTPUT_DIR):
323 os.makedirs(args.OUTPUT_DIR)
这里的做法与常规稍有不同。在第 325 行,使用 platform.system() 函数,我们检查脚本是否在 Windows 系统上执行。如果是,我们尝试导入 wmi 模块,如果导入成功,则调用 wmi_info() 方法。正如之前提到的,我们在这里导入 wmi 库是有原因的。当导入 wmi 库时,它还会加载 pywin32 模块,特别是 win32com.client 模块。在非 Windows 系统上,由于没有安装 pywin32 库,这可能会导致 ImportError 异常。因此,我们只有在知道脚本在 Windows 机器上执行时才会尝试导入 wmi。仅在需要时导入库也是个不错的主意:
325 if 'windows' in platform.system().lower():
326 try:
327 import wmi
328 except ImportError:
329 print("Install the wmi and pywin32 modules. "
330 "Exiting...")
331 sys.exit(1)
332 wmi_info(args.OUTPUT_DIR)
无论系统是否为 Windows,我们都会运行下一个代码块中的代码。在第 336 行,我们调用 get_process_info() 方法,最终以字典的形式返回进程数据。在第 337 行,我们创建了一个列表,包含所需的列名和 pid_data 字典的键。最后,在第 341 行,我们调用 csv_writer() 方法,传入数据、期望的输出目录、输出名称、fields 列表以及一个关键字参数。
稍后我们将看到该关键字参数的作用:
334 # Run data gathering function
335 print("[+] Gathering current active processes information")
336 pid_data = get_process_info()
337 fields = ['pid', 'name', 'exe', 'ppid', 'cmdline',
338 'username', 'cwd', 'create_time', '_errors']
339
340 # Generate reports from gathered details
341 csv_writer(pid_data, args.OUTPUT_DIR, 'pid_summary.csv',
342 fields, type='DictWriter')
正如你可能已经注意到的,我们并没有为这个脚本编写 main() 函数,而是直接跳入了对 get_process_info() 方法的回顾。我们将在本章的最后讨论特定于 Windows 的函数 wmi_info()。
理解 get_process_info() 函数
就功能而言,get_process_info() 函数相对简单,主要用于设置其余代码的执行。在第 166 行,我们创建了 pid_info 字典,最终将在第 336 行返回给调用函数,并包含提取的进程数据。接下来,我们使用 psutil.pids() 方法作为迭代器,正如我们在之前展示该库时所展示的那样,我们将每个进程 ID 传递给 get_pid_details() 方法,并将返回的数据存储在 pid_info 字典中,PID 作为字典的键。
接下来,让我们看一下 get_pid_details() 函数:
158 def get_process_info():
159 """
160 Gather details on running processes within the system.
161 :return pid_info: A dictionary containing details of
162 running processes.
163 """
164
165 # List of PIDs
166 pid_info = {}
167 for pid in psutil.pids():
168 pid_info[pid] = get_pid_details(pid)
169 return pid_info
了解 get_pid_details() 函数
get_pid_details() 方法开始收集每个传递给它的 PID 的信息。对于每个 PID,我们创建一个字典 details,该字典预先填充了我们可以使用 psutil 库提取值的相关键。字典的键被初始化为占位符值,大多数是空字符串和空列表:
101 def get_pid_details(pid):
102 """
103 Gather details on a specific pid.
104 :param pid: an integer value of a pid to query for
105 additional details.
106 :return details: a dictionary of gathered information
107 about the pid.
108 """
109 details = {'name': '', 'exe': '', 'cmdline': '', 'pid': pid,
110 'ppid': 0, 'status': '', 'username': '',
111 'terminal': '', 'cwd': '', 'create_time': '',
112 'children': [], # list of pid ints
113 'threads': [], # list of thread ints
114 'files': [], # list of open files
115 'connections': [], # list of network connections
116 '_errors': []
117 }
接下来,在第 118 行,我们进入一个try和except块,尝试为每个提供的 PID 创建一个Process对象。在这种情况下,第 120 行和第 124 行有两个不同的异常处理子句,分别处理没有与提供的 PID 匹配的进程(例如,如果进程在脚本执行后立即关闭)或操作系统错误的情况。在发生这种异常时,错误会被附加到details字典中,并将该字典返回给调用函数。
与其因某个进程的问题导致脚本崩溃或停止,脚本会继续执行,并将在脚本生成的 CSV 报告中提供这些错误作为一列。
118 try:
119 proc = psutil.Process(pid)
120 except psutil.NoSuchProcess:
121 details['_errors'].append(
122 (pid, 'Process no longer found'))
123 return details
124 except OSError:
125 details['_errors'].append((pid, 'OSError'))
126 return details
如果为提供的 PID 创建了一个Process对象,我们接着会遍历第 128 行中details字典中的每个键,如果该键不是pid或_errors,我们会尝试使用第 144 行的getattr()函数获取与该键关联的值。不过,这里有一些例外情况;例如,我们为children、threads、connections或files这些键编写了特定的elif语句。在处理children和threads键时,我们在第 134 行和第 138 行使用了列表推导式,分别将子进程的 PID 和线程的 ID 与children和threads键相关联。
对于connections和files这两个键,我们开发了单独的函数来提取所需的信息,并将返回的数据存储到details字典中的相应键下。最后,在第 145、148 和 151 行,我们创建了可能在条件语句中出现的异常,处理例如缺少足够权限的情况(例如,如果脚本在非提升权限的提示符下运行)、进程不存在或操作系统发生错误的情况:
128 for key in details:
129 try:
130 if key in ('pid', '_errors'):
131 continue
132 elif key == 'children':
133 children = proc.children()
134 details[key] = [c.pid for c in children]
135
136 elif key == 'threads':
137 threads = proc.threads()
138 details[key] = [t.id for t in threads]
139 elif key == 'connections':
140 details[key] = read_proc_connections(proc)
141 elif key == 'files':
142 details[key] = read_proc_files(proc)
143 else:
144 details[key] = getattr(proc, key, return_none)()
145 except psutil.AccessDenied:
146 details[key] = []
147 details['_errors'].append((key, 'AccessDenied'))
148 except OSError:
149 details[key] = []
150 details['_errors'].append((key, 'OSError'))
151 except psutil.NoSuchProcess:
152 details['_errors'].append(
153 (pid, 'Process no longer found'))
154 break
如前所述,对于connections和files这两个键,我们调用了单独的函数来处理它们。现在我们来看第一个函数的实现。
使用read_proc_connections()函数提取进程连接属性
read_proc_connections()函数定义在第 58 行,首先创建一个空的列表conn_details,该列表将存储每个 PID 连接的详细信息:
058 def read_proc_connections(proc):
059 """
060 Read connection properties from a process.
061 :param proc: An object representing a running process.
062 :return conn_details: A list of process connection
063 properties.
064 """
065 conn_details = []
对于提供的每个连接,我们会创建一个conn_items字典,并在其中存储每个连接的详细信息,包括连接状态、本地和远程的 IP 地址和端口。如前所述,我们使用getattr()方法,查询指定对象的命名属性,并将返回的值存储到我们的字典中。如果命名对象不存在,我们使用None或空字符串作为默认值,这些默认值被定义为getattr()函数的第三个输入。
然后,我们将每个连接的详细信息字典追加到conn_details列表中,在此过程完成后,conn_details列表本身将被返回给调用函数:
066 for conn in proc.connections():
067 conn_items = {}
068 conn_items['fd'] = getattr(conn, 'fd', None)
069 conn_items['status'] = getattr(conn, 'status', None)
070 conn_items['local_addr'] = "{}:{}".format(
071 getattr(conn.laddr, 'ip', ""), getattr(
072 conn.laddr, 'port', ""))
073 conn_items['remote_addr'] = "{}:{}".format(
074 getattr(conn.raddr, 'ip', ""), getattr(
075 conn.raddr, 'port', ""))
076
077 conn_details.append(conn_items)
078 return conn_details
使用 read_proc_files()函数获取更多的进程信息
在 81 行定义的read_proc_files()方法遵循了与前面讨论的类似模式。基本上,在 88 行,我们遍历与进程相关的所有打开的文件,并使用getattr()方法尝试提取每个打开文件的信息,如其路径和模式。
我们在提取每个打开文件的所有值并将数据插入到file_details列表后返回该列表:
081 def read_proc_files(proc):
082 """
083 Read file properties from a process.
084 :param proc: An object representing a running process.
085 :return file_details: a list containing process details.
086 """
087 file_details = []
088 for handle in proc.open_files():
089 handle_items = {}
090 handle_items['fd'] = getattr(handle, 'fd', None)
091 handle_items['path'] = getattr(handle, 'path', None)
092 handle_items['position'] = getattr(
093 handle, 'position', None)
094 handle_items['mode'] = getattr(handle, 'mode', None)
095
096 file_details.append(handle_items)
097
098 return file_details
使用 wmi_info()函数提取 Windows 系统信息
在 172 行定义的wmi_info()函数,首先定义了一个字典,用来存储通过 WMI API 查询到的各种信息类型。
类似地,在 185 行,我们创建了 WMI 对象并将其赋值给变量conn,这就是我们将要进行查询的对象:
172 def wmi_info(outdir):
173 """
174 Gather information available through Windows Management
175 Interface. We recommend extending this script by adding
176 support for other WMI modules -- Win32_PrintJob,
177 Win32_NetworkAdapterConfiguration, Win32_Printer,
178 Win32_PnpEntity (USB).
179 :param outdir: The directory to write CSV reports to.
180 :return: Nothing.
181 """
182
183 wmi_dict = {"Users": [], "Shares": [], "Services": [],
184 "Disks": [], "Event Log": []}
185 conn = wmi.WMI()
在这些代码块中,你会注意到我们调用了conn对象的特定方法,但在其他地方,我们使用了query()方法。请注意,在某些情况下,两者都是可行的。例如,我们可以调用conn.Win32_UserAccount(),也可以使用conn.query("SELECT * from Win32_UserAccount")。query()方法为我们提供了额外的灵活性,因为我们可以为查询提供更多的逻辑,这将在查询特定事件日志条目时看到。
从 190 行的print语句开始,我们开始使用wmi库收集信息。在 191 行通过遍历每个用户配置文件时,我们将用户帐户的各种属性追加到wmi_dict的users列表中:
187 # See attributes for a given module like so: for user in
188 # conn.Win32_UserAccount(); user._getAttributeNames()
189
190 print("[+] Gathering information on Windows user profiles")
191 for user in conn.Win32_UserAccount():
192 wmi_dict["Users"].append({
193 "Name": user.Name, "SID": user.SID,
194 "Description": user.Description,
195 "InstallDate": user.InstallDate,
196 "Domain": user.Domain,
197 "Local Account": user.LocalAccount,
198 "Password Changeable": user.PasswordChangeable,
199 "Password Required": user.PasswordRequired,
200 "Password Expires": user.PasswordExpires,
201 "Lockout": user.Lockout
202 })
我们在下面的代码块中开始使用query()方法,在 205 行列出所有的(*)共享。对于每个共享,我们将其各种详细信息追加到wmi_dict字典中的相应列表。在 213 行,我们再次使用query()方法,这次是针对服务,但仅捕获当前正在运行的服务。
希望你能理解query()方法的价值,因为它为开发者提供了很大的灵活性,可以只返回符合指定标准的数据,从而过滤掉大量无用数据:
204 print("[+] Gathering information on Windows shares")
205 for share in conn.query("SELECT * from Win32_Share"):
206 wmi_dict["Shares"].append({
207 "Name": share.Name, "Path": share.Path,
208 "Description": share.Description,
209 "Status": share.Status,
210 "Install Date": share.InstallDate})
211
212 print("[+] Gathering information on Windows services")
213 for service in conn.query(
214 "SELECT * FROM Win32_Service WHERE State='Running'"):
215 wmi_dict["Services"].append({
216 "Name": service.Name,
217 "Description": service.Description,
218 "Start Mode": service.StartMode,
219 "State": service.State,
220 "Path": service.PathName,
221 "System Name": service.SystemName})
在 224 行,我们开始通过使用conn.Win32_DiskDrive()函数迭代每个驱动器来收集已连接驱动器的详细信息。为了收集我们想提取的所有信息,我们还需要迭代每个磁盘的每个分区和逻辑卷;因此,225 行和 227 行的额外for循环。
一旦我们获得了disk、partition和logical_disk对象,我们就使用它们,并将一个字典追加到wmi_dict字典中相应的列表中,字典包含每个磁盘、分区和卷的各种属性:
223 print("[+] Gathering information on connected drives")
224 for disk in conn.Win32_DiskDrive():
225 for partition in disk.associators(
226 "Win32_DiskDriveToDiskPartition"):
227 for logical_disk in partition.associators(
228 "Win32_LogicalDiskToPartition"):
229 wmi_dict["Disks"].append({
230 "Physical Disk Name": disk.Name,
231 "Bytes Per Sector": disk.BytesPerSector,
232 "Sectors": disk.TotalSectors,
233 "Physical S/N": disk.SerialNumber,
234 "Disk Size": disk.Size,
235 "Model": disk.Model,
236 "Manufacturer": disk.Manufacturer,
237 "Media Type": disk.MediaType,
238 "Partition Name": partition.Name,
239 "Partition Desc.": partition.Description,
240 "Primary Partition": partition.PrimaryPartition,
241 "Bootable": partition.Bootable,
242 "Partition Size": partition.Size,
243 "Logical Name": logical_disk.Name,
244 "Volume Name": logical_disk.VolumeName,
245 "Volume S/N": logical_disk.VolumeSerialNumber,
246 "FileSystem": logical_disk.FileSystem,
247 "Volume Size": logical_disk.Size,
248 "Volume Free Space": logical_disk.FreeSpace})
接下来,在 253 行,我们创建了一个变量wmi_query,用来存储我们将用来从Security事件日志中提取事件 ID 为 4624 的所有事件的字符串。
请注意,在测试中观察到,脚本需要从提升的命令提示符运行,以便能够从Security事件日志中提取信息。
与其他查询类似,我们迭代返回的结果并将各种属性追加到wmi_dict字典中的相应列表中:
250 # Query for logon events type 4624
251 print("[+] Querying the Windows Security Event Log "
252 "for Event ID 4624")
253 wmi_query = ("SELECT * from Win32_NTLogEvent WHERE Logfile="
254 "'Security' AND EventCode='4624'")
255 for logon in conn.query(wmi_query):
256 wmi_dict["Event Log"].append({
257 "Event Category": logon.CategoryString,
258 "Event ID": logon.EventIdentifier,
259 "Time Generated": logon.TimeGenerated,
260 "Message": logon.Message})
最后,在提取所有信息并将其存储在wmi_dict字典中后,我们开始调用csv_writer()函数,将每种数据类型的电子表格写入输出目录。传递给csv_writer()的大部分值不言自明,包括特定工件的数据(即Users键下的用户配置文件)、输出目录和输出文件名。最后一个参数是一个按字母顺序排序的工件特定数据的键列表,作为我们 CSV 文件的列标题。
你还会注意到,我们有一个try和except块来处理写入事件日志数据。这是因为,如前所述,如果脚本没有从提升的命令提示符运行,Event Log键可能会包含一个空列表:
262 csv_writer(wmi_dict["Users"], outdir, "users.csv",
263 sorted(wmi_dict["Users"][0].keys()))
264 csv_writer(wmi_dict["Shares"], outdir, "shares.csv",
265 sorted(wmi_dict["Shares"][0].keys()))
266 csv_writer(wmi_dict["Services"], outdir, "services.csv",
267 sorted(wmi_dict["Services"][0].keys()))
268 csv_writer(wmi_dict["Disks"], outdir, "disks.csv",
269 sorted(wmi_dict["Disks"][0].keys()))
270 try:
271 csv_writer(wmi_dict["Event Log"],outdir, "logonevent.csv",
272 sorted(wmi_dict["Event Log"][0].keys()))
273 except IndexError:
274 print("No Security Event Log Logon events (Event ID "
275 "4624). Make sure to run the script in an escalated "
276 "command prompt")
使用csv_writer()函数写入我们的结果
我们定义的csv_writer(),在第 279 行正常开始,通过根据正在执行脚本的 Python 版本创建一个csvfile文件对象。不同之处在于,函数定义中列出了**kwargs参数。该参数的**部分表示这个函数接受关键字参数。在 Python 中,按惯例,关键字参数被称为kwargs。
我们在这个函数中使用关键字参数,以区分使用常规的csv.writer()方法和csv.DictWriter()方法。这是必要的,因为来自wmi_info()和get_process_info()函数的 CSV 调用分别传递了列表和字典。
在csv_writer()方法中使用额外的逻辑解决了我们的问题,我们也可以通过让wmi_info()和get_process_info()函数返回结构相似的对象来解决这个问题:
279 def csv_writer(data, outdir, name, headers, **kwargs):
280 """
281 The csv_writer function writes WMI or process information
282 to a CSV output file.
283 :param data: The dictionary or list containing the data to
284 write to the CSV file.
285 :param outdir: The directory to write the CSV report to.
286 :param name: the name of the output CSV file.
287 :param headers: the CSV column headers.
288 :return: Nothing.
289 """
290 out_file = os.path.join(outdir, name)
291
292 if sys.version_info[0] == 2:
293 csvfile = open(out_file, "wb")
294 elif sys.version_info[0] == 3:
295 csvfile = open(out_file, "w", newline='',
296 encoding='utf-8')
正如你在第 298 行看到的,我们检查是否传入了名为type的关键字参数。由于我们只在第 341 行调用此函数时才这样做,因此我们知道这意味着什么。我们应该使用csv.DictWriter方法。在第 341 行,你会注意到我们将type关键字参数分配给了DictWriter字符串。然而,在这种情况下,我们本可以传递任何任意的字符串,因为我们在这里根本没有使用它的值。实际上,我们只需要知道type关键字参数已经被赋值即可。
对于get_process_info()函数返回的字典,我们可以使用列表推导式来写出字典中每个条目的值。对于wmi_info()函数,我们需要首先遍历提供的列表中的每个条目,然后将每个提供的表头相关联的值写入 CSV 文件:
298 if 'type' in kwargs:
299 with csvfile:
300 csvwriter = csv.DictWriter(csvfile, fields,
301 extrasaction='ignore')
302 csvwriter.writeheader()
303 csvwriter.writerows([v for v in data.values()])
304
305 else:
306 with csvfile:
307 csvwriter = csv.writer(csvfile)
308 csvwriter.writerow(headers)
309 for row in data:
310 csvwriter.writerow([row[x] for x in headers])
执行 pysysinfo.py
在下面的截图中,你可以看到在 Windows 系统上运行此脚本时输出的结果:
此外,在 Windows 系统上执行脚本后,连接的驱动器、共享、服务、进程、用户和登录事件的 CSV 文件将被创建在指定的输出目录中。以下是其中一个电子表格——用户配置文件电子表格的内容截图:
挑战
如使用 WMI部分所提到的,考虑通过能够查询远程 Windows 主机来扩展脚本的功能。类似地,wmi和psutil都提供了可以访问的附加信息,值得收集。尝试这两个库并收集更多信息,尤其是专注于收集非 Windows 系统的系统信息,这在当前版本的脚本中得到了更好的支持,感谢wmi库。
最后,对于一个更具挑战性的任务,考虑开发一个更有用的存储库来收集和查询数据。以我们为少数几个系统收集和展示数据的方式来说,效果很好,但当在数百台系统上运行时,这种方式的扩展性如何?想象一下,在一个网络上针对多个主机部署并运行修改版的此脚本,并将处理后的信息存储在一个集中式数据库中进行存储,更重要的是,作为更高效的查询收集数据的手段。
总结
在本章中,我们确认了系统信息的价值以及如何在实时系统上提取这些信息。通过使用psutil库,我们学习了如何以操作系统无关的方式提取进程信息。我们还简要介绍了如何使用 WMI API 从 Windows 操作系统获取更多信息。本项目的代码可以从 GitHub 或 Packt 下载,具体信息请参考前言部分。
在下一章中,我们将学习如何使用 Python 处理 Outlook 归档的.pst文件并创建其内容列表。
第十一章:解析 Outlook PST 容器
电子邮件(email)继续是工作场所中最常见的通信方式之一,在当今世界的新通信服务中生存下来。电子邮件可以从计算机、网站和遍布全球口袋的手机发送。这种媒介可以可靠地以文本、HTML、附件等形式传输信息。因此,毫不奇怪,电子邮件在特别是涉及工作场所的调查中扮演了重要角色。在本章中,我们将处理一种常见的电子邮件格式,个人存储表(PST),由 Microsoft Outlook 用于将电子邮件内容存储在单个文件中。
我们将在本章中开发的脚本介绍了一系列通过 Joachim Metz 开发的libpff库可用的操作。这个库允许我们以 Pythonic 方式打开 PST 文件并探索其内容。此外,我们构建的代码演示了如何创建动态的基于 HTML 的图形,以提供电子表格报告的附加背景。对于这些报告,我们将利用第五章中引入的 Jinja2 模块,Python 中的数据库,以及 D3.js 框架来生成我们的动态基于 HTML 的图表。
D3.js 项目是一个 JavaScript 框架,允许我们设计信息丰富且动态的图表而不需要太多努力。本章使用的图表是框架的开源示例,与社区共享在github.com/d3/d3。由于本书不专注于 JavaScript,也不介绍该语言,因此我们不会详细介绍创建这些图表的实现细节。相反,我们将演示如何将我们的 Python 结果添加到预先存在的模板中。
最后,我们将使用一个示例 PST 文件,该文件跨时间包含大量数据,用于测试我们的脚本。与往常一样,我们建议在案件中使用任何代码之前针对测试文件运行以验证逻辑和功能覆盖范围。本章使用的库处于活跃开发状态,并由开发者标记为实验性。
本章涵盖以下主题:
-
理解 PST 文件的背景
-
利用
libpff及其 Python 绑定pypff来解析 PST 文件 -
利用 Jinja2 和 D3.js 创建信息丰富且专业的图表
本章的代码是使用 Python 2.7.15 开发和测试的。
PST 文件格式
PST 格式是一种个人文件格式(PFF)的类型。PFF 文件的另外两种类型包括用于存储联系人的个人通讯录(PAB)和存储离线电子邮件、日历和任务的脱机存储表(OST)。默认情况下,Outlook 将缓存的电子邮件信息存储在 OST 文件中,这些文件可以在下表中指定的位置找到。如果归档了 Outlook 中的项目,它们将存储在 PST 文件中:
| Windows 版本 | Outlook 版本 | OST 位置 |
|---|---|---|
| Windows XP | Outlook 2000/2003/2007 | C:\Documents and Settings\USERPROFILE%\Local Settings\Application Data\Microsoft\Outlook |
| Windows Vista/7/8 | Outlook 2007 | C:\Users\%USERPROFILE%\AppData\Local\Microsoft\Outlook |
| Windows XP | Outlook 2010 | C:Documents and Settings\%USERPROFILE%\My Documents\Outlook Files |
| Windows Vista/7/8 | Outlook 2010/2013 | C:\Users\%USERPROFILE%\Documents\Outlook Files |
来自: forensicswiki.org/wiki/Personal_Folder_File_(PAB,_PST,_OST)。OST 文件的默认位置。
%USERPROFILE%字段是动态的,会被计算机上的用户账户名称替换。PFF 文件可以通过十六进制文件签名0x2142444E或 ASCII 中的!BDN来识别。在文件签名之后,PFF 文件的类型由偏移量 8 处的 2 个字节表示:
| 类型 | 十六进制签名 | ASCII 签名 |
|---|---|---|
| PST | 534D | SM |
| OST | 534F | SO |
| PAB | 4142 | AB |
来自 www.garykessler.net/library/file_sigs.html
内容类型(例如 32 位或 64 位)在字节偏移量 10 处定义。PFF 文件格式的结构已经由 Joachim Metz 在多个文献中详细描述,这些文献记录了技术结构以及如何在 GitHub 上的项目代码库中手动解析这些文件:github.com/libyal/libpff。
本章我们只处理 PST 文件,可以忽略 OST 和 PAB 文件的差异。默认情况下,PST 归档有一个根区域,包含一系列文件夹和消息,具体取决于归档时如何创建。例如,用户可能会将视图中的所有文件夹归档,或者只归档某些特定的文件夹。所有选定内容中的项目将导出到 PST 文件中。
除了用户手动归档内容,Outlook 还具有一个自动归档功能,它将在指定时间后将项目存储在 PST 文件中,具体时间根据以下表格定义。一旦达到这个过期时间,项目将会被包括在下一个创建的归档中。自动归档默认将 PST 文件存储在 Windows 7 中的%USERPROFILE%\Documents\Outlook、Vista 中的%APPDATA%\Local\Microsoft\Outlook,以及 XP 中的%APPDATA%\Local Settings\Microsoft\Outlook。这些默认位置可以由用户或在域环境中的组策略设置。这个自动归档功能为调查人员提供了大量的通讯信息,可以在我们的调查中访问和解读:
| 文件夹 | 默认老化周期 |
|---|---|
| 收件箱和草稿箱 | 6 个月 |
| 已发送项目和已删除项目 | 2 个月 |
| 发件箱 | 3 个月 |
| 日历 | 6 个月 |
| 任务 | 6 个月 |
| 备注 | 6 个月 |
| 日志 | 6 个月 |
表 11.1:Outlook 项目的默认老化(support.office.com/en-us/artic…
libpff 简介
libpff库允许我们以编程方式引用和浏览 PST 对象。root_folder()函数允许我们引用RootFolder,它是 PST 文件的基础,也是我们递归分析电子邮件内容的起点。在RootFolder中包含文件夹和消息。文件夹可以包含其他子文件夹或消息。文件夹有一些属性,包括文件夹名称、子文件夹数量和子消息数量。消息是表示消息的对象,并具有包括主题行、所有参与者的名称以及若干时间戳等属性。
如何安装 libpff 和 pypff
安装一些第三方库比运行pip install <library_name>更为复杂。在libpff和pypff绑定的情况下,我们需要采取一些步骤并遵循 GitHub 项目仓库中列出的指示。libpff的 wiki(位于github.com/libyal/libpff/wiki/Building)描述了我们需要采取的步骤来构建libpff。
我们将简要介绍如何在 Ubuntu 18.04 系统上构建这个库。在下载并安装 Ubuntu 18.04(最好是在虚拟机中)后,你需要通过运行以下命令来安装依赖项:
sudo apt-get update
sudo apt-get install git python-dev python-pip autoconf automake \
autopoint libtool pkg-config
这将安装我们脚本和pypff绑定所需的包。接下来,我们需要通过运行以下命令来下载libpff代码:
git clone https://github.com/libyal/libpff
一旦git clone命令完成,我们将进入新的libpff目录,并运行以下命令来下载其他依赖项,配置并安装我们需要的库组件:
cd libpff
./synclibs.ps1
./autogen.ps1
./configure --enable-python
make
make install
额外的构建选项在libpff的 wiki 页面中有更详细的描述。
到此为止,你应该能够运行以下语句并获得相同的输出,尽管你的版本可能会有所不同:
python
>>> import pypff
>>> pypff.get_version()
u'20180812'
为了简化这个过程,我们已经预构建了pypff绑定,并创建了一个 Dockerfile 来为你运行整个设置。如果你不熟悉 Docker,它是一个虚拟化环境,可以让我们以最小的努力运行虚拟机。虽然 Docker 通常用于托管应用程序,但我们将更多地将它作为传统的虚拟机使用。对我们来说,这种方式的优势在于,我们可以分发一个配置文件,你可以在系统上运行它,从而生成与我们测试的环境相同的环境。
首先,请按照docs.docker.com/install/上的说明在你的系统上安装 Docker。安装并运行后,导航到你系统上的Chapter 11代码文件夹,并运行docker build命令。该命令将根据一系列预配置的步骤生成一个系统:
这将创建一个名为lpff-ch11、版本号为 20181130 的新镜像。在 Docker 中,镜像就是它的字面意思:一个基本安装,您可以用它来创建运行中的机器。这样,您可以拥有多个基于相同镜像的机器。每个机器称为容器,为了从这个镜像创建容器,我们将使用docker run语句:
docker run命令中的-it标志要求 Docker 在创建容器后连接到 bash shell。-P参数要求 Docker 为我们提供网络连接,在我们的案例中,就是运行在容器中的 Web 服务器。最后,--name参数允许我们为容器指定一个熟悉的名称。然后,我们传入镜像名称和版本并运行该命令。如你所见,一旦 Docker 实例完成,我们就会获得一个 root shell。
关于之前提到的 Web 服务器,我们已经包含了lighttpd,以便我们能够将 HTML 生成的报告作为网页提供。这不是必需的,不过我们希望强调如何使这些报告在内部系统上可访问。
请不要在公共网络上运行此 Docker 容器,因为它将允许任何能够访问您机器 IP 地址的人查看您的 HTML 报告。
在前面的截图中,我们通过运行server lighttpd start启动了 Web 服务器,然后列出了当前目录的内容。如您所见,我们有两个文件,一个是我们即将构建的pst_indexer.py脚本,另一个是我们将用来生成报告的stats_template.html。现在让我们开始构建 Python 脚本。
探索 PST 文件 – pst_indexer.py
在这个脚本中,我们将收集 PST 文件的信息,记录每个文件夹中的邮件,并生成关于词汇使用、频繁发送者以及所有邮件活动的热图统计数据。通过这些指标,我们可以超越初步的邮件收集和报告,探索使用的语言趋势或与特定人员的沟通模式。统计部分展示了如何利用原始数据并构建信息图表以帮助审查员。我们建议根据您的具体调查定制逻辑,以提供尽可能有用的报告。例如,对于词汇统计,我们只查看字母数字且长度大于四个字符的前十个词汇,以减少常见的词汇和符号。这可能不适用于您的调查,可能需要根据您的具体情况进行调整。
概览
本章的脚本是为 Python 2.7.15 版本编写的,并且需要上一节中提到的第三方库。请考虑在使用此脚本时同时使用 Docker 镜像。
与我们其他章节一样,本脚本通过导入我们在顶部使用的库开始。在本章中,我们使用了两个新的库,其中一个是第三方库。我们之前已经介绍过 pypff,它是 libpff 库的 Python 绑定。pypff 模块指定了允许我们访问已编译代码的 Python 绑定。在第 8 行,我们引入了 unicodecsv,这是一个我们在第五章《Python 中的数据库》中曾使用过的第三方库。这个库允许我们将 Unicode 字符写入 CSV 文件,因为原生的 CSV 库对 Unicode 字符的支持并不理想。
在第 6 行,我们导入了一个名为 collections 的标准库,它提供了一系列有用的接口,包括 Counter。Counter 模块允许我们向其提供值,并处理计数和存储对象的逻辑。除此之外,collections 库还提供了 OrderedDict,当你需要按指定顺序创建键的字典时,它非常有用。尽管在本书中没有利用 OrderedDict 模块,但当你希望以有序的方式使用键值对时,它在 Python 中确实有其用武之地:
001 """Index and summarize PST files"""
002 import os
003 import sys
004 import argparse
005 import logging
006 from collections import Counter
007
008 import jinja2
009 import pypff
010 import unicodecsv as csv
在设定了许可和脚本元数据后,我们将设置一些全局变量。这些变量将帮助我们减少需要传递到函数中的变量数量。第一个全局变量是第 46 行定义的 output_directory,它将存储用户设置的字符串路径。第 47 行定义的 date_dictionary 使用字典推导式创建了键 1 到 24,并将它们映射到整数 0。然后,我们在第 48 行使用列表推导式将这个字典的七个实例附加到 date_list。这个列表被用来构建热图,显示在 PST 文件中按七天 24 小时列划分的活动信息:
040 __authors__ = ["Chapin Bryce", "Preston Miller"]
041 __date__ = 20181027
042 __description__ = '''This scripts handles processing and
043 output of PST Email Containers'''
044 logger = logging.getLogger(__name__)
045
046 output_directory = ""
047 date_dict = {x:0 for x in range(1, 25)}
048 date_list = [date_dict.copy() for x in range(7)]
这个热图将建立基线趋势,并帮助识别异常活动。例如,它可以显示在工作日午夜时段活动的激增,或者在星期三业务日开始前的过度活动。date_list 包含七个字典,每个字典代表一天,它们是完全相同的,包含一个小时的键值对,默认值为 0。
date_dict.copy() 在第 48 行的调用是必需的,以确保我们可以在单个日期内更新小时数。如果省略了 copy() 方法,所有的日期都会被更新。这是因为字典通过对原始对象的引用相互关联,而在没有使用 copy() 方法的情况下,我们生成的是对象的引用列表。当我们使用此函数时,它允许我们通过创建一个新对象来复制值,从而可以创建不同对象的列表。
构建了这些变量后,我们可以在其他函数中引用并更新它们的值,而不需要再次传递它们。全局变量默认是只读的,必须使用特殊的 global 命令才能在函数中进行修改。
以下函数概述了我们脚本的操作。像往常一样,我们有main()函数来控制行为。接下来是make_path()函数,这是一个帮助我们收集输出文件完整路径的工具。folder_traverse()和check_for_msgs()函数用于迭代可用项并开始处理:
051 def main():
...
078 def make_path():
...
089 def folder_traverse():
...
103 def check_for_msgs():
我们的其余函数专注于处理 PST 中的数据并生成报告。process_message()函数读取消息并返回报告所需的属性。第一个报告函数是folder_report()函数。此代码为 PST 中找到的每个文件夹创建 CSV 输出,并描述每个文件夹中的内容。
这个函数还通过将消息主体写入单一文本文件来处理其余报告的数据,存储每组日期,并保存发送者列表。通过将这些信息缓存到文本文件中,接下来的函数可以轻松读取文件,而不会对内存产生重大影响。
我们的word_stats()函数读取并将信息导入到一个集合中。Counter()对象在我们的word_report()函数中使用。当生成单词计数报告时,我们将集合的Counter()对象读取到 CSV 文件中,该文件将被我们的 JavaScript 代码读取。sender_report()和date_report()函数也将数据刷新到分隔文件中,供 JavaScript 在报告中进行解释。最后,我们的html_report()函数打开报告模板,并将自定义报告信息写入输出文件夹中的 HTML 文件:
118 def process_msg():
...
138 def folder_report():
...
193 def word_stats():
...
208 def word_report():
...
235 def sender_report():
...
260 def date_report():
...
277 def html_report():
与我们所有的脚本一样,我们在第 302 行的if __name__ == "__main__":条件语句下处理参数、日志和main()函数调用。我们定义了必需的参数PST_FILE和OUTPUT_DIR,用户可以指定可选参数--title和-l,用于自定义报告标题和日志路径:
302 if __name__ == "__main__":
303 parser = argparse.ArgumentParser(
304 description=__description__,
305 epilog='Built by {}. Version {}'.format(
306 ", ".join(__authors__), __date__),
307 formatter_class=argparse.ArgumentDefaultsHelpFormatter
308 )
309 parser.add_argument('PST_FILE',
310 help="PST File Format from Microsoft Outlook")
311 parser.add_argument('OUTPUT_DIR',
312 help="Directory of output for temporary and report files.")
313 parser.add_argument('--title', default="PST Report",
314 help='Title of the HTML Report.')
315 parser.add_argument('-l',
316 help='File path of log file.')
317 args = parser.parse_args()
在定义了我们的参数后,我们开始处理它们,以便以标准化和安全的方式将它们传递给main()函数。在第 319 行,我们将输出位置转换为绝对路径,以确保在脚本中访问正确的位置。注意,我们正在调用output_directory全局变量并为其分配一个新值。这只有在我们不在函数内时才可能。如果我们在函数内部修改全局变量,就需要在第 318 行写上global output_directory:
319 output_directory = os.path.abspath(args.OUTPUT_DIR)
320
321 if not os.path.exists(output_directory):
322 os.makedirs(output_directory)
在修改 output_directory 变量后,我们确保路径存在(如果不存在,则创建),以避免后续代码出现错误。完成后,我们在第 331 到 339 行使用标准的日志记录代码片段来配置脚本的日志记录。在第 341 到 345 行,我们记录执行脚本的系统的调试信息,然后再调用 main() 函数。在第 346 行,我们调用 main() 函数,并传入 args.PST_FILE 和 args.title 参数。我们无需传递 output_directory 值,因为可以全局引用它。在传递参数并且 main() 函数执行完成后,我们在第 347 行记录脚本已完成执行。
331 logger.setLevel(logging.DEBUG)
332 msg_fmt = logging.Formatter("%(asctime)-15s %(funcName)-20s"
333 "%(levelname)-8s %(message)s")
334 strhndl = logging.StreamHandler(sys.stderr) # Set to stderr
335 strhndl.setFormatter(fmt=msg_fmt)
336 fhndl = logging.FileHandler(log_path, mode='a')
337 fhndl.setFormatter(fmt=msg_fmt)
338 logger.addHandler(strhndl)
339 logger.addHandler(fhndl)
340
341 logger.info('Starting PST Indexer v. {}'.format(__date__))
342 logger.debug('System ' + sys.platform)
343 logger.debug('Version ' + sys.version.replace("\n", " "))
344
345 logger.info('Starting Script')
346 main(args.PST_FILE, args.title)
347 logger.info('Script Complete')
以下流程图展示了各个函数之间的交互方式。这个流程图可能看起来有些复杂,但它概括了我们脚本的基本结构。
main() 函数调用递归的 folder_traverse() 函数,该函数依次查找、处理并汇总根文件夹中的消息和文件夹。之后,main() 函数生成包含单词、发送者和日期的报告,并通过 html_report() 函数生成一个 HTML 报告进行显示。需要注意的是,虚线代表返回值的函数,而实线代表没有返回值的函数:
开发 main() 函数
main() 函数控制脚本的主要操作,从打开和初步处理文件、遍历 PST 文件,到生成报告。在第 62 行,我们使用 os.path 模块从路径中分离出 PST 文件名。
如果用户没有提供自定义标题,我们将使用 pst_name 变量。在下一行,我们使用 pypff.open() 函数创建一个 PST 对象。通过 get_root_folder() 方法获取 PST 的根文件夹,从而开始迭代过程,发现文件夹中的项:
051 def main(pst_file, report_name):
052 """
053 The main function opens a PST and calls functions to parse
054 and report data from the PST
055 :param pst_file: A string representing the path to the PST
056 file to analyze
057 :param report_name: Name of the report title
058 (if supplied by the user)
059 :return: None
060 """
061 logger.debug("Opening PST for processing...")
062 pst_name = os.path.split(pst_file)[1]
063 opst = pypff.open(pst_file)
064 root = opst.get_root_folder()
提取根文件夹后,我们在第 67 行调用 folder_traverse() 函数,开始遍历 PST 容器中的目录。我们将在下一部分讨论该函数的具体内容。遍历文件夹后,我们开始使用 word_stats()、sender_report() 和 date_report() 函数生成报告。在第 74 行,我们传入报告名称、PST 名称以及包含最常见单词和发送者的列表,为 HTML 仪表板提供统计数据,如下所示:
066 logger.debug("Starting traverse of PST structure...")
067 folder_traverse(root)
068
069 logger.debug("Generating Reports...")
070 top_word_list = word_stats()
071 top_sender_list = sender_report()
072 date_report()
073
074 html_report(report_name, pst_name, top_word_list,
075 top_sender_list)
评估 make_path() 辅助函数
为了简化操作,我们开发了一个辅助函数make_path(),定义在第 78 行。辅助函数允许我们在脚本中重复利用通常需要多次编写的代码,只需一次函数调用即可。通过这段代码,我们接受一个表示文件名的输入字符串,并根据用户提供的output_directory值返回文件在操作系统中的绝对路径。在第 85 行,进行了两项操作;首先,我们使用os.path.join()方法将file_name与output_directory值按正确的路径分隔符连接起来。
接下来,这个值将通过os.path.abspath()方法进行处理,该方法提供操作系统环境中的完整文件路径。然后我们将此值返回给最初调用它的函数。如我们在流程图中所见,许多函数会调用make_path()函数:
078 def make_path(file_name):
079 """
080 The make_path function provides an absolute path between the
081 output_directory and a file
082 :param file_name: A string representing a file name
083 :return: A string representing the path to a specified file
084 """
085 return os.path.abspath(os.path.join(output_directory,
086 file_name))
使用folder_traverse()函数进行迭代
这个函数递归地遍历文件夹,以解析消息项,并间接地生成文件夹的摘要报告。该函数最初通过根目录提供,经过通用开发,可以处理传递给它的任何文件夹项。这使得我们可以在每次发现子文件夹时重用该函数。在第 97 行,我们使用for循环递归遍历从我们的pypff.folder对象生成的sub_folders迭代器。在第 98 行,我们检查文件夹对象是否有任何额外的子文件夹,如果有,则在检查当前文件夹中的新消息之前再次调用folder_traverse()函数。只有在没有新子文件夹的情况下,我们才会检查是否有新消息:
089 def folder_traverse(base):
090 """
091 The folder_traverse function walks through the base of the
092 folder and scans for sub-folders and messages
093 :param base: Base folder to scan for new items within
094 the folder.
095 :return: None
096 """
097 for folder in base.sub_folders:
098 if folder.number_of_sub_folders:
099 folder_traverse(folder) # Call new folder to traverse
100 check_for_msgs(folder)
这是一个递归函数,因为我们在函数内部调用了相同的函数(某种形式的循环)。这个循环可能会无限运行,因此我们必须确保数据输入有一个结束点。PST 应该有有限数量的文件夹,因此最终会退出递归循环。这基本上是我们 PST 特定的os.walk()函数,它遍历文件系统目录。由于我们处理的是文件容器中的文件夹和消息,我们必须自己实现递归。递归可能是一个难以理解的概念;为了帮助你理解,在阅读接下来的解释时,请参考以下图示:
在上面的图示中,PST 层次结构中有五个级别,每个级别包含蓝色文件夹和绿色消息的混合。在第 1级,我们有根文件夹,这是folder_traverse()循环的第一次迭代。由于此文件夹有一个子文件夹个人文件夹顶部,如第 2级所示,我们在探索消息内容之前重新运行该函数。当我们重新运行该函数时,我们现在评估个人文件夹顶部文件夹,并发现它也有子文件夹。
在每个子文件夹上再次调用 folder_traverse() 函数时,我们首先处理第3级的 Deleted Items 文件夹。在第 4 级的 Deleted Items 文件夹中,我们发现这里只包含消息,并首次调用 check_for_msgs() 函数。
在 check_for_msgs() 函数返回后,我们回到第 3 级的 folder_traverse() 函数的上一调用,并评估 Sent Items 文件夹。由于 Sent Items 文件夹也没有子文件夹,我们在返回第 3 级之前处理它的消息。
然后,我们到达第 3 级的 Inbox 文件夹,并在第 4 级的 Completed Cases 子文件夹上调用 folder_traverse() 函数。现在我们进入第 5 级,处理 Completed Cases 文件夹中的两条消息。处理完这两条消息后,我们返回到第 4 级,处理 Inbox 文件夹中的两条消息。完成这些消息的处理后,我们就完成了第 3、4 和 5 级的所有项目,最终可以返回到第 2 级。在 Root Folder 中,我们可以处理那里的三条消息项,之后函数执行结束。我们的递归在这种情况下是自下而上的。
这四行代码允许我们遍历整个 PST 并对每个文件夹中的每条消息执行额外的处理。虽然这种功能通常通过 os.walk() 等方法提供,但有些库原生不支持递归,要求开发者使用库中的现有功能来实现。
使用 check_for_msgs() 函数识别消息
该函数会为每个发现的文件夹在 folder_traverse() 函数中调用,并处理消息。第 110 行,我们记录文件夹的名称,以提供已处理内容的记录。接下来,我们在第 111 行创建一个列表来附加消息,并在第 112 行开始迭代文件夹中的消息。
在这个循环中,我们调用 process_msg() 函数,将相关字段提取到字典中。在每个消息字典被附加到列表后,我们调用 folder_report() 函数,该函数将生成该文件夹内所有消息的汇总报告:
103 def check_for_msgs(folder):
104 """
105 The check_for_msgs function reads folder messages if
106 present and passes them to the report function
107 :param folder: pypff.Folder object
108 :return: None
109 """
110 logger.debug("Processing Folder: " + folder.name)
111 message_list = []
112 for message in folder.sub_messages:
113 message_dict = process_msg(message)
114 message_list.append(message_dict)
115 folder_report(message_list, folder.name)
在 process_msg() 函数中处理消息
这个函数是调用最频繁的函数,因为它会为每个发现的消息执行。当你考虑如何提高代码库的效率时,这些就是需要关注的函数。即使是对频繁调用的函数进行微小的效率优化,也能对脚本产生很大的影响。
在这种情况下,函数很简单,主要用于去除另一个函数中的杂乱内容。此外,它将消息处理封装在一个函数中,使得排查与消息处理相关的错误更加容易。
第 126 行的返回语句将一个字典传递给调用函数。该字典为每个pypff.message对象的属性提供一个键值对。请注意,subject、sender、transport_headers和plain_text_body属性是字符串类型。creation_time、client_submit_time和delivery_time属性是 Python 的datetime.datetime对象,而number_of_attachments属性是整数类型。
subject属性包含消息中的主题行,sender_name包含发送消息的发件人名称的单一字符串。发件人名称可能反映电子邮件地址或联系人名称,具体取决于接收者是否解析了该名称。
transport_headers包含与任何消息一起传输的电子邮件头数据。由于新数据会被添加到头部的顶部,因此应该从底部向上读取这些数据,以便随着消息在邮件服务器之间的移动,我们能够追踪消息的路径。我们可以利用这些信息,通过主机名和 IP 地址可能追踪消息的流动。plain_text_body属性返回纯文本形式的正文,虽然我们也可以使用rtf_body和html_body属性分别以 RTF 或 HTML 格式显示消息。
creation_times和delivery_times反映了消息的创建时间和接收到的消息被交付到正在检查的 PST 的时间。client_submit_time值是消息发送的时间戳。最后显示的属性是number_of_attachments属性,它用于查找要提取的额外数据。
118 def process_msg(message):
119 """
120 The process_msg function processes multi-field messages
121 to simplify collection of information
122 :param message: pypff.Message object
123 :return: A dictionary with message fields (values) and
124 their data (keys)
125 """
126 return {
127 "subject": message.subject,
128 "sender": message.sender_name,
129 "header": message.transport_headers,
130 "body": message.plain_text_body,
131 "creation_time": message.creation_time,
132 "submit_time": message.client_submit_time,
133 "delivery_time": message.delivery_time,
134 "attachment_count": message.number_of_attachments,
135 }
此时,pypff模块不支持与附件的交互,尽管libpff库可以使用其pffexport和pffinfo工具提取相关数据。要构建这些工具,我们必须在构建时运行./configure命令时,在命令行中包含--enable-static-executables参数。
使用这些选项构建后,我们可以运行前面提到的工具,将 PST 附件导出到一个结构化的目录中。开发人员已表示将会在未来的版本中添加pypff对附件的支持。如果该功能发布,我们将能够与消息附件进行交互,并对发现的文件执行额外的处理。如果分析需要此功能,我们可以通过os或subprocess库在 Python 中调用pffexport工具来增加支持。
在folder_report()函数中汇总数据
到此为止,我们已经收集了大量关于消息和文件夹的信息。我们使用此代码块将数据导出为一个简单的报告以供审查。为了创建这个报告,我们需要message_list和folder_name变量。在 146 行,我们检查message_list中是否有条目;如果没有,我们记录一个警告并返回该函数,以防止剩余的代码继续执行。
如果message_list中有内容,我们开始创建 CSV 报告。我们首先通过将所需的文件名传入make_path()函数来生成输出目录中的文件名,从而获取我们希望写入的文件的绝对路径。使用该文件路径,我们以wb模式打开文件,以便写入 CSV 文件,并防止在报告的行与行之间添加额外的空行(见第 152 行)。在接下来的行中,我们定义了输出文档的头部列表。
此列表应反映我们希望报告的列的顺序列表。可以自由修改第 153 行和 154 行,以反映首选顺序或额外的行。所有附加的行必须是message_list变量中所有字典的有效键。
在写入头部后,我们在第 155 行启动csv.DictWriter类。如果你记得我们脚本开始时导入了unicodecsv库,以处理在写入 CSV 时的 Unicode 字符。在这个导入过程中,我们使用as关键字将模块从unicodecsv重命名为csv,以便在脚本中使用。该模块提供与标准库相同的方法,因此我们可以继续使用我们在csv库中见过的熟悉的函数调用。在初始化DictWriter()时,我们传递了打开的文件对象、字段名称以及一个参数,告诉该类如何处理message_list字典中未使用的信息。由于我们并未使用message_list列表中字典的所有键,因此我们需要告诉DictWriter()类忽略这些值,如下所示:
138 def folder_report(message_list, folder_name):
139 """
140 The folder_report function generates a report per PST folder
141 :param message_list: A list of messages discovered
142 during scans
143 :folder_name: The name of an Outlook folder within a PST
144 :return: None
145 """
146 if not len(message_list):
147 logger.warning("Empty message not processed")
148 return
149
150 # CSV Report
151 fout_path = make_path("folder_report_" + folder_name + ".csv")
152 fout = open(fout_path, 'wb')
153 header = ['creation_time', 'submit_time', 'delivery_time',
154 'sender', 'subject', 'attachment_count']
155 csv_fout = csv.DictWriter(fout, fieldnames=header,
156 extrasaction='ignore')
157 csv_fout.writeheader()
158 csv_fout.writerows(message_list)
159 fout.close()
初始化并配置好csv_fout变量后,我们可以开始使用第 157 行的writeheaders()方法调用来写入头部数据。接下来,我们使用writerows()方法将感兴趣的字典字段写入文件。写入所有行后,我们关闭fout文件,将其写入磁盘,并释放对象的句柄(见第 159 行)。
在第 119 行到第 141 行之间,我们准备了来自message_list的字典,用于生成 HTML 报告统计数据。我们需要调用第 162 行中的global语句,以便我们可以编辑date_list全局变量。然后我们打开两个文本文件,记录所有主体内容和发件人名称的原始列表。这些文件将在后续部分用于生成我们的统计数据,并以不会消耗大量内存的方式收集这些数据。这两个文本文件(见第 163 和第 164 行)以a模式打开,如果文件不存在则会创建该文件,如果文件存在,则会将数据追加到文件末尾。
在第 165 行,我们启动一个for循环,遍历message_list中的每个消息m。如果消息体键有值,则将其值写入输出文件,并使用两个换行符分隔此内容。接着,在第 168 和 169 行,我们对发件人键及其值执行类似的过程。在这种情况下,我们只使用一个换行符,以便稍后在另一个函数中更方便地迭代:
162 global date_list # Allow access to edit global variable
163 body_out = open(make_path("message_body.txt"), 'a')
164 senders_out = open(make_path("senders_names.txt"), 'a')
165 for m in message_list:
166 if m['body']:
167 body_out.write(m['body'] + "\n\n")
168 if m['sender']:
169 senders_out.write(m['sender'] + '\n')
在收集完消息内容和发件人信息后,我们开始收集日期信息。为了生成热力图,我们将所有三个活动日期合并为一个总计数,形成一个单一的图表。在确认有有效的日期值后,我们获取星期几的信息,以确定在date_list列表中的哪个字典需要更新。
Python 的datetime.datetime库有一个weekday()方法和一个.hour属性,它们允许我们以整数形式访问这些值,并处理繁琐的转换。weekday()方法返回一个从 0 到 6 的整数,其中 0 代表星期一,6 代表星期天。.hour属性返回一个 0 到 23 之间的整数,表示 24 小时制的时间,尽管我们用于热力图的 JavaScript 要求一个 1 到 24 之间的整数才能正确处理。因此,我们在第 175、181 和 187 行中对每个小时值加 1。
现在我们拥有了更新date_list中值所需的正确星期几和时间段键。在完成循环后,我们可以在第 189 和 190 行关闭两个文件对象:
171 # Creation Time
172 c_time = m['creation_time']
173 if c_time isn't None:
174 day_of_week = c_time.weekday()
175 hour_of_day = c_time.hour + 1
176 date_list[day_of_week][hour_of_day] += 1
177 # Submit Time
178 s_time = m['submit_time']
179 if s_time isn't None:
180 day_of_week = s_time.weekday()
181 hour_of_day = s_time.hour + 1
182 date_list[day_of_week][hour_of_day] += 1
183 # Delivery Time
184 d_time = m['delivery_time']
185 if d_time isn't None:
186 day_of_week = d_time.weekday()
187 hour_of_day = d_time.hour + 1
188 date_list[day_of_week][hour_of_day] += 1
189 body_out.close()
190 senders_out.close()
理解word_stats()函数
在将消息内容写入文件后,我们现在可以使用它来计算单词使用频率。我们使用从 collections 库中导入的Counter模块,以高效的方式生成单词计数。
我们将word_list初始化为一个Counter()对象,这使得我们可以在调用时给它分配新单词,并跟踪每个单词的总体计数。在初始化后,我们在第 200 行启动一个for循环,打开文件,并使用readlines()方法逐行迭代:
193 def word_stats(raw_file="message_body.txt"):
194 """
195 The word_stats function reads and counts words from a file
196 :param raw_file: The path to a file to read
197 :return: A list of word frequency counts
198 """
199 word_list = Counter()
200 for line in open(make_path(raw_file), 'r').readlines():
此时,我们需要使用split()方法将行拆分成单个单词的列表,以生成正确的计数。通过不向split()传递参数,我们将按所有空白字符进行拆分,在这种情况下,这对我们有利。在第 201 行的拆分之后,我们使用条件语句确保只有长度大于四个字符的单词被包含在我们的列表中,以去除常见的填充词或符号。此逻辑可以根据您的环境进行调整,例如,您可能希望包括少于四个字母的单词或其他过滤标准。
如果条件判断为真,我们将单词添加到计数器中。在第 204 行,我们将单词在列表中的值增加 1。遍历完message_body.txt文件的每一行和每个单词后,我们将这个单词列表传递给word_report()函数:
201 for word in line.split():
202 # Prevent too many false positives/common words
203 if word.isalnum() and len(word) > 4:
204 word_list[word] += 1
205 return word_report(word_list)
创建 word_report() 函数
一旦 word_list 从 word_stats() 函数传递过来,我们就可以使用提供的数据生成报告。为了更好地控制数据的展示方式,我们将手动生成 CSV 报告,而不依赖 csv 模块。首先,在第 216 行,我们需要确保 word_list 中有值。如果没有,函数会记录一个警告并返回。在第 220 行,我们以 wb 模式打开一个新文件对象以创建 CSV 报告。在第 221 行,我们将 Count 和 Word 表头写入第一行,并使用换行符确保所有其他数据写入下面的行:
208 def word_report(word_list):
209 """
210 The word_report function counts a list of words and returns
211 results in a CSV format
212 :param word_list: A list of words to iterate through
213 :return: None or html_report_list, a list of word
214 frequency counts
215 """
216 if not word_list:
217 logger.debug('Message body statistics not available')
218 return []
219
220 fout = open(make_path("frequent_words.csv"), 'wb')
221 fout.write("Count,Word\n")
然后,我们使用 for 循环和 most_common() 方法调用每个单词及其对应的计数值。如果元组的长度大于 1,我们就将这些值按相反的顺序写入 CSV 文档,以便正确对齐列与值,并加上换行符。在这个循环完成后,我们关闭文件并将结果刷新到磁盘,正如第 225 行所示:
222 for e in word_list.most_common():
223 if len(e) > 1:
224 fout.write(str(e[1]) + "," + str(e[0]) + "\n")
225 fout.close()
紧接着这个循环,我们会生成前 10 个单词的列表。通过将整数 10 传递给 most_common() 方法,我们只选择 Counter 中最常见的前 10 项。我们将结果的字典追加到临时列表中,该列表返回给 word_stats() 函数,并随后用于我们的 HTML 报告:
227 html_report_list = []
228 for e in word_list.most_common(10):
229 html_report_list.append(
230 {"word": str(e[0]), "count": str(e[1])})
231
232 return html_report_list
构建 sender_report() 函数
sender_report() 函数类似于 word_report(),它为发送电子邮件的个人生成 CSV 和 HTML 报告。这个函数展示了另一种将值读取到 Counter() 方法中的方式。在第 242 行,我们打开并读取文件的行到 Counter() 方法中。
我们可以这样实现,因为输入文件的每一行代表一个单独的发件人。以这种方式统计数据简化了代码,并且通过简化写作,也为我们节省了几行代码。
word_stats() 函数并不适用这种方法,因为我们必须将每一行拆分成单独的单词,然后在计数之前执行额外的逻辑操作。如果我们想对发件人统计信息应用逻辑,我们需要创建一个类似于 word_stats() 中的循环。例如,我们可能想排除所有来自 Gmail 的项,或是发件人姓名或地址中包含 noreply 字样的项:
235 def sender_report(raw_file="senders_names.txt"):
236 """
237 The sender_report function reports the most frequent_senders
238 :param raw_file: The file to read raw information
239 :return: html_report_list, a list of the most
240 frequent senders
241 """
242 sender_list = Counter(
243 open(make_path(raw_file), 'r').readlines())
在生成发件人计数之后,我们可以打开 CSV 报告并将表头写入其中。此时,我们将在第 247 行看到的 for 循环中迭代每一个最常见的项,如果元组包含多个元素,我们就将其写入文件。
这是另一个可以根据发件人姓名过滤值的地方。写入后,文件被关闭并刷新到磁盘。在第 252 行,我们通过生成一个包含元组值的字典列表来为最终报告生成前五名发件人的统计数据。为了在 HTML 报告功能中访问它,我们返回这个列表。请参见以下代码:
245 fout = open(make_path("frequent_senders.csv"), 'wb')
246 fout.write("Count,Sender\n")
247 for e in sender_list.most_common():
248 if len(e) > 1:
249 fout.write(str(e[1]) + "," + str(e[0]))
250 fout.close()
251
252 html_report_list = []
253 for e in sender_list.most_common(5):
254 html_report_list.append(
255 {"label": str(e[0]), "count": str(e[1])})
256
257 return html_report_list
使用 date_report() 函数完善热力图
本报告提供了生成活动热力图的数据。为了确保其正常运行,文件名和路径必须与 HTML 模板中指定的相同。该文件的默认模板名为heatmap.tsv,并与输出的 HTML 报告位于同一目录下。
打开文件并加载第 267 行中的默认设置后,我们写入标题,使用制表符分隔日期、小时和值列,并以换行符结尾。此时,我们可以通过两个 for 循环开始遍历我们的字典列表,访问每个包含字典的列表。
在第一个 for 循环中,我们使用 enumerate() 方法捕获循环的迭代次数。这个数字恰好对应我们正在处理的日期,使我们能够使用该值写入日期值:
260 def date_report():
261 """
262 The date_report function writes date information in a
263 TSV report. No input args as the filename
264 is static within the HTML dashboard
265 :return: None
266 """
267 csv_out = open(make_path("heatmap.tsv"), 'w')
268 csv_out.write("day\thour\tvalue\n")
269 for date, hours_list in enumerate(date_list):
在第二个 for 循环中,我们遍历每个字典,使用 items() 方法分别提取小时和计数值,返回的键值对作为元组。通过这些值,我们可以将日期、小时和计数赋值给制表符分隔的字符串,并写入文件。
在第 271 行,我们将日期值加 1,因为热力图图表使用的是 1 到 7 的范围,而我们的列表使用的是 0 到 6 的索引来表示一周的七天。
在遍历小时数据后,我们将数据刷新到磁盘,然后继续处理下一个小时的数据字典。完成七天的数据遍历后,我们可以关闭此文档,它已准备好与我们的热力图图表一起在 html_report() 函数中使用:
270 for hour, count in hours_list.items():
271 to_write = "{}\t{}\t{}\n".format(date+1, hour, count)
272 csv_out.write(to_write)
273 csv_out.flush()
274 csv_out.close()
编写 html_report() 函数
html_report() 函数是将从 PST 中收集的所有信息组合成最终报告的地方,充满期待地生成此报告。为了生成该报告,我们需要传入指定报告标题、PST 名称以及最常见单词和发件人的计数等参数:
277 def html_report(report_title, pst_name, top_words, top_senders):
278 """
279 The html_report function generates the HTML report from a
280 Jinja2 Template
281 :param report_title: A string representing the title of
282 the report
283 :param pst_name: A string representing the file name of
284 the PST
285 :param top_words: A list of the top 10 words
286 :param top_senders: A list of the top 10 senders
287 :return: None
288 """
首先,我们打开模板文件,并将其内容读取到一个变量中,作为字符串传入我们的 jinja2.Template 引擎,处理成模板对象 html_template,该操作发生在第 290 行。
接下来,我们创建一个字典,将值传入模板的占位符,并在第 292 行使用 context 字典保存这些值。字典创建完毕后,我们在第 295 行渲染模板并提供 context 字典。渲染后的数据是 HTML 数据字符串,正如你在网页中看到的一样,所有占位符逻辑都被评估并转化为静态 HTML 页面。
我们将渲染后的 HTML 数据写入用户指定目录中的输出文件,如第 297 到 299 行所示。HTML 报告写入输出目录后,报告完成,并可以在输出文件夹中查看:
289 open_template = open("stats_template.html", 'r').read()
290 html_template = jinja2.Template(open_template)
291
292 context = {"report_title": report_title, "pst_name": pst_name,
293 "word_frequency": top_words,
294 "percentage_by_sender": top_senders}
295 new_html = html_template.render(context)
296
297 html_report_file = open(make_path("pst_report.html"), 'w')
298 html_report_file.write(new_html)
299 html_report_file.close()
HTML 模板
本书重点介绍 Python 在法医学中的应用。尽管 Python 提供了许多很棒的方法来操作和应用逻辑于数据,但我们仍然需要依赖其他资源来支持我们的脚本。在本章中,我们构建了一个 HTML 仪表板来展示关于这些 PST 文件的统计信息。
在本节中,我们将回顾 HTML 的各个部分,重点关注数据插入模板的部分,而不是 HTML、JavaScript 和其他 Web 语言的复杂细节。如需更多关于 HTML、JavaScript、D3.js 和其他 Web 资源的使用和实现的信息,请访问packtpub.com查找相关书籍,或访问w3schools.com查阅入门教程。由于我们不会深入探讨 HTML、CSS 或其他 Web 设计方面的问题,我们的重点将主要放在 Python 脚本与这些部分的交互上。
这个模板利用了几个常见的框架,允许快速设计专业外观的网页。第一个是 Bootstrap 3,它是一个 CSS 样式框架,能够将 HTML 组织和样式化,使其无论在哪种设备上查看页面,都能保持一致和整洁。第二个是 D3.js 框架,它是一个用于图形可视化的 JavaScript 框架。
如前所示,我们将数据插入的模板项包含在双括号{{ }}中。我们将在第 39 行和第 44 行插入 HTML 仪表板的报告标题。此外,我们将在第 48 行、第 55 行和第 62 行插入 PST 文件的名称。第 51 行、第 58 行和第 65 行的div id标签作为图表的变量名,可以在模板的后续部分通过 JavaScript 插入这些图表,一旦代码处理了输入:
...
038 </style>
039 <title>{{ report_title }}</title>
040 </head>
041 <body>
042 <div class="container">
043 <div class="row">
044 <h1>{{ report_title }}</h1>
045 </div>
046 <div class="row">
047 <div class="row">
048 <h3>Top 10 words in {{ pst_name }}</h3>
049 </div>
050 <div class="row">
051 <div id="wordchart">
052 </div>
053 </div>
054 <div class="row">
055 <h3>Top 5 Senders in {{ pst_name }}</h3>
056 </div>
057 <div class="row">
058 <div id="piechart">
059 </div>
060 </div>
061 <div class="row">
062 <h3>Heatmap of all date activity in {{ pst_name }}</h3>
063 </div>
064 <div class="row">
065 <div id="heatmap"></div>
066 </div>
067 </div>
068 </div>
...
在div占位符元素就位后,第 69 行到 305 行的 JavaScript 将提供的数据处理为图表。第 92 行放置了第一个位置数据,在该行,{{ word_frequency }}短语被字典列表替换。例如,可以替换为[{'count': '175', 'word': 'message'}, {'count': '17', 'word': 'iPhone'}]。这个字典列表被转换为图表值,形成 HTML 报告中的垂直条形图:
...
088 .attr("transform", "translate(" + margin.left + "," + margin.top + ")");
089
090 data = {{ word_frequency }}
091
092 function processData(data) {x.domain(data.map(function(d) {
093 return d;
094 }
...
在第 132 行,我们将上下文字典中的percentage_by_sender值插入到 JavaScript 中。此替换将与word_frequency插入的例子类似。通过这些信息,甜甜圈图表将在 HTML 报告中生成:
...
129 (function(d3) {
130 'use strict';
131
132 var dataset = {{ percentage_by_sender }};
133
134 var width = 960;
...
我们将使用一种新的方式来插入热图数据。通过提供上一节中讨论的文件名,我们可以提示代码在与此 HTML 报告相同的目录中查找heatmap.tsv文件。这样做的好处是,我们能够一次生成报告,并在像 Excel 这样的程序中以及在仪表板中使用 TSV 文件,尽管缺点是该文件必须与 HTML 报告一起传输,以便正确显示,因为图表将在重新加载时重新生成。
这个图表在某些浏览器上可能无法正常渲染,因为不同浏览器对 JavaScript 的解释方式不同。测试表明,Chrome、Firefox 和 Safari 都能正常查看该图形。请确保浏览器插件不会干扰 JavaScript,并且浏览器没有阻止 JavaScript 与本地文件的交互。如果您的浏览器不允许这样做,可以考虑在 Docker 实例中运行脚本,启动 lighttpd 服务,并将输出放置在 /var/www/html 中。当您访问 Docker 实例的 IP 地址时,您将能够浏览报告,因为服务器将为您提供对资源的访问:
174 times = ["1a", "2a", "3a", "4a", "5a", "6a", "7a", "8a", "9a", "10a", "11a", "12a", "1p", "2p", "3p", "4p", "5p", "6p", "7p", "8p", "9p", "10p", "11p", "12p"];
175
176 datasets = ["heatmap.tsv"];
177
178 var svg = d3.select("#heatmap").append("svg")
模板的其余部分可以在代码库中找到,如果 Web 编程语言是您的强项,或者值得进一步探索,它可以轻松地被引用和修改。D3.js 库使我们能够创建更多的信息图形,并为我们的报告工具箱添加了另一个相对简单且便于移植的工具。以下图形展示了我们创建的三个图表中每个图表的数据示例。
第一个图表表示 PST 文件中使用频率最高的单词。频率绘制在 y 轴上,单词则绘制在 x 轴上:
以下图表显示了向用户发送电子邮件的前五个账户。请注意,圆形图表有助于识别数据集中最频繁的参与者。此外,文本标签提供了地址的名称和该地址接收到的电子邮件数量:
最后,以下热力图将所有电子邮件聚合为每小时的单元格,按天分组。这对于识别数据集中的趋势非常有用。
例如,在这种情况下,我们可以快速识别出大多数电子邮件是在清晨时分发送或接收的,特别是在每周二的早上 6 点。图形底部的条形图表示电子邮件的数量。例如,周二早上 6 点的单元格颜色表示在该时段内发送或接收了超过 1,896 封电子邮件:
运行脚本
代码已经完成,包括脚本和 HTML 模板,我们准备好执行代码了!在我们的 Ubuntu 环境中,我们需要运行以下命令并提供 PST 进行分析。如果您的 Ubuntu 机器已配置了 Web 服务器,则可以将输出放置在 Web 目录中,并作为网站提供给其他用户浏览。
如果您打算使用 Docker 容器方法来运行此代码,则需要使用以下命令将 PST 文件复制到容器中。请注意,以下语法为 docker cp src_file container_name:/path/on/container,更多功能请参见 docker cp --help:
$ docker cp sample.pst pst_parser:/opt/book
现在我们的 PST 文件已位于容器中,我们可以按如下方式运行脚本:
上面的截图显示我们使用/var/www/html作为输出目录。这意味着如果我们在 Docker 容器中运行lighttpd服务,我们将能够浏览到容器的 IP 地址,并在系统的浏览器中查看内容。你需要运行docker container ls pst_parser来获取 Web 服务器所在的正确端口。
额外的挑战
对于这个项目,我们邀请你实现一些改进,使我们的脚本更加多功能。如本章前面提到的,pypff目前并不原生支持提取或直接与附件交互。然而,我们可以在 Python 脚本中调用pffexport和pffinfo工具来实现这一功能。我们建议查看subprocess模块以实现这一目标。进一步扩展这个问题,我们如何将其与上一章中介绍的代码连接起来?一旦我们访问了附件,可能会有哪些数据可用?
考虑一些方法,允许用户提供过滤选项,以便收集感兴趣的特定邮件,而不是整个 PST 文件。一个可能帮助用户提供更多配置选项的库是ConfigParser,可以通过pip安装。
最后,另一个挑战是通过添加更多的图表和图形来改进 HTML 报告。一个例子是解析transit_headers并提取 IP 地址。通过这些 IP 地址,你可以进行地理定位,并使用 D3.js 库将其绘制在地图上。这种信息可以通过从所有潜在数据点提取尽可能多的信息,提升报告的实用性。
总结
电子邮件文件包含大量有价值的信息,使得法医检查员能够更深入地了解通讯内容以及用户随时间变化的活动。利用开源库,我们能够探索 PST 文件并提取其中关于邮件和文件夹的信息。我们还检查了邮件的内容和元数据,以收集关于频繁联系人、常用词汇和活动的异常热点的额外信息。通过这一自动化过程,我们可以更好地理解我们审查的数据,并开始识别隐藏的趋势。该项目的代码可以从 GitHub 或 Packt 下载,具体请参见前言。
识别隐藏信息在所有调查中都非常重要,这是数据恢复成为法医调查过程重要基石的众多原因之一。
在下一章中,我们将介绍如何从一个难以处理的来源——数据库中恢复数据。通过使用多个 Python 库,我们将能够恢复本来可能丢失的数据,并获得有关那些数据库不再跟踪的记录的宝贵见解。
第十二章:恢复临时数据库记录
在本章中,我们将重新审视 SQLite 数据库,并检查一种叫做 预写日志 (WAL) 的日志文件类型。由于其底层结构的复杂性,解析 WAL 文件比我们之前处理 SQLite 数据库时的任务要更具挑战性。没有现成的模块可以像我们使用 sqlite3 或 peewee 与 SQLite 数据库那样直接与 WAL 文件交互。相反,我们将依赖 struct 库以及理解二进制文件的能力。
一旦成功解析 WAL 文件,我们将利用 Python 中的正则表达式库 re 来识别潜在的相关取证数据。最后,我们将简要介绍使用第三方 tqdm 库创建进度条的另一种方法。通过几行代码,我们将实现一个功能齐全的进度条,能够向用户反馈程序执行情况。
WAL 文件可以包含已不再存在或尚未添加到 SQLite 数据库中的数据。它还可能包含修改记录的前一个副本,并为取证调查员提供有关数据库如何随时间变化的线索。
本章我们将探讨以下主题:
-
解析复杂的二进制文件
-
学习并利用正则表达式来定位指定的数据模式
-
通过几行代码创建一个简单的进度条
-
使用内建的 Python 调试器
pdb快速排除代码故障
本章的代码在 Python 2.7.15 和 Python 3.7.1 中开发和测试。
SQLite WAL 文件
在分析 SQLite 数据库时,检查员可能会遇到额外的临时文件。SQLite 有九种类型的临时文件:
-
回滚日志
-
主日志
-
语句日志
-
WAL
-
共享内存文件
-
TEMP 数据库
-
视图和子查询物化
-
临时索引
-
临时数据库
更多关于这些文件的详细信息,请参考 www.sqlite.org/tempfiles.html,该页面对这些文件进行了更详细的描述。WAL 是这些临时文件之一,并且参与原子提交和回滚场景。只有设置了 WAL 日志模式的数据库才会使用预写日志方法。配置数据库使用 WAL 日志模式的 SQLite 命令如下:
PRAGMA journal_mode=WAL;
WAL 文件与 SQLite 数据库位于同一目录中,并且文件名会在原始 SQLite 数据库文件名后附加 -wal。当连接到 SQLite 数据库时,会临时创建一个 WAL 文件。此 WAL 文件将包含对数据库所做的任何更改,而不会影响原始的 SQLite 数据库。使用 WAL 文件的优点包括并发和更快速的读/写操作。有关 WAL 文件的具体信息,请参见 www.sqlite.org/wal.html:
默认情况下,当 WAL 文件达到 1000 个页面或最后一个连接关闭时,WAL 文件中的记录会被提交到原始数据库。
WAL 文件在取证中具有相关性,原因有二:
-
审查数据库活动的时间线
-
恢复删除或更改的记录
Epilog 的创建者们写了一篇详细的文章,讲解了 WAL 文件在取证中的具体意义,文章可以在 digitalinvestigation.wordpress.com/2012/05/04/the-forensic-implications-of-sqlites-write-ahead-log/ 阅读。通过了解 WAL 文件的重要性、为什么使用它们以及它们在取证中的相关性,让我们一起来分析其底层结构。
WAL 格式和技术规格
WAL 文件是由包含嵌入式 B 树页面的帧组成,这些 B 树页面对应于实际数据库中的页面。我们不会深入讨论 B 树的工作原理。相反,我们将关注一些重要字节偏移量,以便更好地理解代码,并进一步展示 WAL 文件的取证相关性。
WAL 文件的主要组件包括以下内容:
-
WAL 头部(32 字节)
-
WAL 帧(页面大小)
-
帧头部(24 字节)
-
页面头部(8 字节)
-
WAL 单元格(可变长度)
请注意,WAL 帧的大小由页面大小决定,该页面大小可以从 WAL 头部提取。
以下图表展示了 WAL 文件的高层次结构:
让我们来看看 WAL 文件的每个高层次类别。一些结构的描述可以参考 www.sqlite.org/fileformat2.html。
WAL 头部
32 字节的 WAL 头部包含诸如页面大小、检查点数量、WAL 文件大小,以及间接地,WAL 文件中帧的数量等属性。以下表格详细列出了头部中存储的 8 个大端 32 位整数的字节偏移量和描述:
| 字节偏移量 | 值 | 描述 |
|---|---|---|
| 0-3 | 文件签名 | 这是 0x377F0682 或 0x377F0683。 |
| 4-7 | 文件版本 | 这是 WAL 格式的版本,当前版本为 3007000。 |
| 8-11 | 数据库页面大小 | 这是数据库中页面的大小,通常为 1024 或 4096。 |
| 12-15 | 检查点编号 | 这是已发生的提交数量。 |
| 16-19 | 盐值-1 | 这是一个随每次提交递增的随机整数。 |
| 20-23 | 盐值-2 | 这是一个随每次提交而变化的随机整数。 |
| 24-27 | 校验和-1 | 这是头部校验和的第一部分。 |
| 28-31 | 校验和-2 | 这是头部校验和的第二部分。 |
文件签名应该始终是 0x377F0682 或 0x377F0683。数据库页面大小是一个非常重要的值,因为它允许我们计算 WAL 文件中有多少个帧。例如,在使用 4,096 字节页面的 20,632 字节 WAL 文件中,有 5 个帧。为了正确计算帧的数量,我们需要在以下公式中考虑 32 字节的 WAL 头部和 24 字节的 WAL 帧头部:
(WAL File Size - 32) / (WAL Page Size + 24)
20,600 / 4,120 = 5 frames
检查点编号表示触发了多少次提交,可能是自动触发的,也可能是通过执行 PRAGMA wal_checkpoint 手动触发的。现在,让我们关注 Salt-1 值。在创建数据库活动时间线时,这是头部中最重要的值。Salt-1 值会随着每次提交而增加。除此之外,每个帧在提交时会在自己的头部存储当前的盐值。如果记录被修改并重新提交,较新的记录会有比前一个版本更大的 Salt-1 值。因此,我们可能会在 WAL 文件中看到某个记录在不同时间点的多个快照。
假设我们有一个包含一个表的数据库,存储与员工姓名、职位、薪水等相关的数据。早期,我们有一个记录,记载了 23 岁的自由职业摄影师彼得·帕克,薪水为 45,000 美元。几次提交后,帕克的薪水变为 150,000 美元,而且在同一次提交中,帕克的名字更新为蜘蛛侠:
| 帧 | Salt-1 | 行 ID | 员工姓名 | 职位 | 薪水 |
|---|---|---|---|---|---|
| 0 | -977652151 | 123 | 蜘蛛侠? | 自由职业 | 150,000 |
| 1 | -977652151 | 123 | 彼得·帕克 | 自由职业 | 150,000 |
| 2 | -977652150 | 123 | 彼得·帕克 | 自由职业 | 45,000 |
因为这些条目共享相同的行 ID,我们知道这是在主表中记录 123 的三个不同版本。为了识别该记录的最新版本,我们需要检查 Salt-1 值。根据之前的讨论和记录的 Salt-1 值,我们知道帧 0 和 1 中的记录是最新的记录,并且自从该记录第一次添加到数据库后,已经进行了两次提交。
我们如何知道帧 0 和帧 1 中哪个记录是最新的?如果我们处理的是同一次提交中有两个记录的情况,较早的帧中的记录被认为是最新的。这是因为 WAL 文件会将新帧添加到文件的开头,而不是结尾。因此,帧 0 中的记录是最新的,而帧 2 中的记录是最旧的。
请注意,每个帧中可以有多个记录。较新的记录位于帧的开头。
在数据库中,我们只会看到该记录的最新版本,但在 WAL 文件中,我们可以看到之前的版本。只要 WAL 文件存在,我们仍然可以看到这些信息,即使带有行 ID 123 的记录已经从主数据库中删除。
WAL 帧
WAL 帧本质上是一个 B 树结构的页面,包含一个帧头。帧头包含 6 个大端 32 位整数:
| 字节偏移量 | 值 | 描述 |
|---|---|---|
| 0-3 | 页面编号 | 这是 WAL 文件中的帧或页面编号。 |
| 4-7 | 数据库大小 | 这是提交记录中数据库的页面数大小。 |
| 8-11 | Salt-1 | 这是从 WAL 头部在写入帧时复制过来的。 |
| 12-15 | Salt-2 | 这是从 WAL 头部在写入帧时复制过来的。 |
| 16-19 | 校验和-1 | 这是包括此帧在内的累计校验和。 |
| 20-23 | 校验和-2 | 这是校验和的第二部分。 |
Salt-1 值只是创建帧时从 WAL 头部复制的 Salt-1 值。我们使用存储在帧中的这个值来确定前一个示例中的事件时间。页面编号是从零开始的整数,其中零是 WAL 文件中的第一个帧。
在帧头之后是数据库中单个页面的内容,从页面头部开始。页面头部由两个 8 位和三个 16 位的大端整数组成:
| 字节偏移量 | 值 | 描述 |
|---|---|---|
| 0 | B-树标志 | 这是 B 树节点的类型 |
| 1-2 | 自由块 | 这是页面中的自由块数量。 |
| 3-4 | 单元格数量 | 这是页面中的单元格数量。 |
| 5-6 | 单元格偏移量 | 这是相对于该头部开始位置的第一个单元格的字节偏移量。 |
| 7 | 碎片 | 这些是页面中碎片化的自由块数量。 |
有了这些信息,我们现在知道了我们处理的单元格数量和第一个单元格的偏移量。在该头部之后,是N个大端 16 位整数,指定每个单元格的偏移量。单元格的偏移量是相对于页面头部的开始位置的。
WAL 单元格和 varints
每个单元格由以下组件组成:
-
负载长度(varint)
-
行 ID(varint)
-
负载头部:
-
负载头部长度(varint)
-
序列类型数组(varints)
-
-
负载
负载长度描述了单元格的总体长度。行 ID 是实际数据库中对应该记录的唯一键。负载头部中的序列类型数组包含负载中数据的长度和类型。我们可以通过减去负载头部长度来确定单元格中实际记录的数据的字节数。
请注意,这些值大多数是变长整数(varints),即可变长度整数。SQLite 中的变长整数是一种根据每个字节的第一个位大小变化,范围从 1 到 9 字节不等的整数。如果第一个位被设置为 1,则下一个字节是变长整数的一部分。这个过程会持续,直到你得到一个 9 字节的变长整数,或者字节的第一个位没有被设置。对于所有小于 128 的 8 位整数,第一个位没有被设置。这使得在这种文件格式中,较大的数字能够灵活地存储。关于变长整数的更多细节可以参考www.sqlite.org/src4/doc/trunk/www/varint.wiki。
例如,如果处理的第一个字节是0x03或0b00000011,我们知道变长整数仅为一个字节,值为 3。如果处理的第一个字节是0x9A或0b10011010,则第一个位被设置,变长整数至少为两个字节长,具体取决于下一个字节,使用相同的决策过程。对于我们的用途,我们只支持长度为 2 字节的变长整数。关于如何解析 WAL 文件的详细教程可以阅读www.forensicsfromthesausagefactory.blogspot.com/2011/05/analysis-of-record-structure-within.html。强烈建议在尝试开发代码之前,使用十六进制编辑器手动解析页面。通过在十六进制编辑器中检查变长整数,能更轻松地理解数据库结构,并帮助巩固你的理解。
大多数变长整数(varint)都可以在序列类型数组中找到。该数组紧接在有效负载头长度之后,值为 1。变长整数值的表格决定了单元格的大小和数据类型:
| 变长整数值 | 大小(字节) | 数据类型 |
|---|---|---|
| 0 | 0 | Null |
| 1 | 1 | 8 位整数 |
| 2 | 2 | 大端 16 位整数 |
| 3 | 3 | 大端 24 位整数 |
| 4 | 4 | 大端 32 位整数 |
| 5 | 6 | 大端 48 位整数 |
| 6 | 8 | 大端 64 位整数 |
| 7 | 8 | 大端 64 位浮点数 |
| 8 | 0 | 整数常量:0 |
| 9 | 0 | 整数常量:1 |
| 10, 11 | 未使用 | |
| X >= 12 且为偶数 | (X-12)/2 | 长度为(X-12)/2 的 BLOB |
| X >= 13 且为奇数 | (X-13)/2 | 长度为(X-13)/2 的字符串 |
有效负载紧接着最后一个序列类型开始。我们来看一下如何使用变长整数正确地解析有效负载的内容。例如,假设给定以下序列类型:0、2、6、8 和 25,我们期望得到一个 16 字节的有效负载,包含一个Null值、一个 2 字节的 16 位整数、一个 8 字节的 64 位整数、一个常量 0 和一个 6 字节的字符串。字符串的大小是通过公式(25-13)/2 计算的。以下伪代码演示了这个过程:
Serial Types = 0, 2, 6, 8, and 25
Payload = 0x166400000009C5BA3649737069646572
Split_Payload = N/A , 0x1664, 0x00000009C5BA3649, N/A, 0x737069646572
Converted_Payload = Null, 5732, 163953206, 0, "spider"
上述示例说明了如何使用已知的序列类型解码 16 字节的有效载荷。我们将在开发程序时采用相同的方法。注意,序列类型 0、8 和 9 不需要在有效载荷中占用空间,因为它们的值是静态的。
在 Python 中操作大型对象
在开发任何脚本之前,尤其是处理大型复杂结构的脚本时,选择合适的数据类型至关重要。对于我们的解决方案,我们将使用字典和有序字典。字典和有序字典的区别在于,有序字典会保留添加项的顺序。这个功能对于我们的脚本并不重要,只是作为一种方便的功能使用。
字典允许我们将 WAL 文件的结构映射为键值对。最终,我们将创建一个大的嵌套字典对象,它可以轻松保存为 JSON 文件,供其他程序使用。这个数据类型的另一个优点是,我们可以通过描述性键来遍历多个字典。这可以用来在 WAL 文件的不同部分之间进行分区,并帮助保持处理过的数据有序。这涵盖了我们编写 WAL 文件解析脚本所需了解的所有高级细节。在此之前,让我们简要介绍正则表达式和TQDM进度条模块。
Python 中的正则表达式
正则表达式允许我们通过使用通用的搜索模式来识别数据模式。例如,查找文档中所有可能的XXX-XXX-XXXX类型的电话号码,可以通过一个正则表达式轻松完成。我们将创建一个正则表达式模块,它将对处理过的 WAL 数据运行一组默认的表达式或用户提供的表达式。默认表达式的目的是识别相关的取证信息,如 URL 或个人身份信息(PII)。
虽然本节并不是正则表达式的入门教程,但我们将简要介绍其基础知识,以便理解其优势和代码中使用的正则表达式。在 Python 中,我们使用re模块对字符串进行正则表达式匹配。首先,我们必须编译正则表达式,然后检查字符串中是否有匹配项:
>>> import re
>>> phone = '214-324-5555'
>>> expression = r'214-324-5555'
>>> re_expression = re.compile(expression)
>>> if re_expression.match(phone): print(True)
...
True
使用相同的字符串作为我们的表达式会得到一个正匹配。然而,这样做并不能捕获其他电话号码。正则表达式可以使用各种特殊字符,这些字符要么表示一组字符,要么定义前面的元素如何解释。我们使用这些特殊字符来引用多个字符集,并创建一个通用的搜索模式。
方括号[]用于表示字符范围,例如0到9或a到z。在正则表达式后使用大括号{n}表示必须匹配前面正则表达式的 n 个副本,才能认为是有效的。通过这两个特殊字符,我们可以创建一个更通用的搜索模式:
>>> expression = r'[0-9]{3}-[0-9]{3}-[0-9]{4}'
这个正则表达式匹配任何符合XXX-XXX-XXXX模式的内容,且仅包含 0 到 9 之间的整数。它不会匹配像+1 800.234.5555这样的电话号码。我们可以构建更复杂的表达式来包括这些类型的模式。
另一个我们要看的例子是匹配信用卡号码。幸运的是,已经存在一些主要卡片(如 Visa、万事达卡、美国运通卡等)的标准正则表达式。以下是我们可以用来识别任何 Visa 卡的表达式。变量expression_1匹配以四开始,后跟任何 15 个数字(0-9)的数字。第二个表达式expression_2匹配以 4 开始,后跟任何 15 个数字(0-9),这些数字可选地由空格或破折号分隔:
>>> expression_1 = r'⁴\d{15}$'
>>> expression_2 = r'⁴\d{3}([\ \ -]?)\d{4}\1\d{4}\1\d{4}$'
对于第一个表达式,我们引入了三个新的特殊字符:^、d和$。插入符号(^)表示字符串的起始位置位于开头。同样,$要求模式的结束位置位于字符串或行的末尾。结合起来,这个模式只有在我们的信用卡是该行中唯一的元素时才会匹配。d字符是[0-9]的别名。这个表达式可以捕获像 4111111111111111 这样的信用卡号码。请注意,在正则表达式中,我们使用r前缀来创建一个原始字符串,这样反斜杠就不会被当作 Python 的转义字符来处理。由于正则表达式使用反斜杠作为转义字符,我们必须在每个反斜杠出现的地方使用双反斜杠,以避免 Python 将其解释为自己的转义字符。
在第二个表达式中,我们使用圆括号和方括号来可选地匹配四位数字之间的空格或破折号。注意反斜杠,它作为空格和破折号的转义字符,而空格和破折号本身是正则表达式中的特殊字符。如果我们没有在这里使用反斜杠,解释器将无法理解我们是想使用字面意义上的空格和破折号,而不是它们在正则表达式中的特殊含义。在定义了圆括号中的模式后,我们可以使用 1,而不是每次都重新编写它。同样,由于^和$,这个模式只有在它是行或整个字符串中唯一的元素时才会匹配。这个表达式将匹配诸如 4111-1111-1111-1111 的 Visa 卡,并捕获expression_1会匹配的任何内容。
掌握正则表达式可以让用户创建非常彻底和全面的模式。为了本章的目的,我们将坚持使用相对简单的表达式来完成我们的任务。与任何模式匹配一样,将大量数据集应用于模式可能会生成误报。
TQDM – 一个更简单的进度条
tqdm模块(版本 4.23.2)可以为任何 Python 迭代器创建进度条:
在前面的例子中,我们将由range(100)创建的迭代器包装在tqdm中。仅此就能创建显示在图片中的进度条。另一种方法是使用trange()函数,它使我们的任务更加简单。我们将使用该模块为处理每个 WAL 帧创建进度条。
以下代码创建了与前面截图中相同的进度条。trange()是 tqdm(xrange())的别名,使得创建进度条更加简单:
>>> from tqdm import trange
>>> from time import sleep
>>> for x in trange(100):
... sleep(1)
解析 WAL 文件 – wal_crawler.py
现在我们理解了 WAL 文件的结构以及用于存储数据的数据类型,我们可以开始规划脚本。由于我们处理的是一个大型二进制对象,我们将大力使用struct库。我们在第六章《从二进制文件提取数据》中首次介绍了struct,并且在处理二进制文件时多次使用它。因此,我们不会在本章重复struct的基础内容。
我们的wal_crawler.py脚本的目标是解析 WAL 文件的内容,提取并将单元格内容写入 CSV 文件,并可选择性地对提取的数据运行正则表达式模块。由于我们正在解析的底层对象的复杂性,这个脚本被认为是更高级的。然而,我们在这里所做的,只是将之前章节中学到的知识应用于更大规模的任务:
002 from __future__ import print_function
003 import argparse
004 import binascii
005 import logging
006 import os
007 import re
008 import struct
009 import sys
010 from collections import namedtuple
011 if sys.version_info[0] == 2:
012 import unicodecsv as csv
013 elif sys.version_info[0] == 3:
014 import csv
015
016 from tqdm import trange
与我们开发的任何脚本一样,在第 1-11 行我们导入了脚本中将使用的所有模块。我们在前几章中已经遇到过大部分这些模块,并且在相同的上下文中使用它们。我们将使用以下模块:
-
binascii:用于将从 WAL 文件读取的数据转换为十六进制格式 -
tqdm:用于创建一个简单的进度条 -
namedtuple:这个来自 collections 模块的数据结构,将在使用struct.unpack()函数时简化创建多个字典键和值的过程。
main()函数将验证 WAL 文件输入,解析 WAL 文件头部,然后遍历每一帧并使用frame_parser()函数处理它。所有帧处理完毕后,main()函数可选择运行正则表达式regular_search()函数,并通过csv_writer()函数将处理后的数据写入 CSV 文件:
055 def main()
...
133 def frame_parser():
...
173 def cell_parser():
...
229 def dict_helper():
...
243 def single_varint():
...
273 def multi_varint():
...
298 def type_helper():
...
371 def csv_writer():
...
428 def regular_search():
frame_parser() 函数解析每个帧,并通过识别 B-tree 类型执行进一步的验证。在数据库中有四种类型的 B-tree:0x0D、0x05、0x0A 和 0x02。在这个脚本中,我们只关注 0x0D 类型的帧,其他类型的帧将不进行处理。因为 0x0D 类型的 B-tree 同时包含行 ID 和负载,而其他类型的 B-tree 只包含其中之一。验证完帧后,frame_parser() 函数会通过 cell_parser() 函数处理每个单元格。
cell_parser() 函数负责处理每个单元格及其所有组件,包括负载长度、行 ID、负载头和负载。frame_parser() 和 cell_parser() 函数都依赖于各种辅助函数来完成它们的任务。
dict_helper() 辅助函数从元组返回 OrderedDictionary。这个函数允许我们在一行中处理和存储结构结果到数据库中。single_varint() 和 multi_varint() 函数分别用于处理单个和多个 varint。最后,type_helper() 函数处理序列类型数组并将原始数据解释为适当的数据类型:
481 if __name__ == '__main__':
482
483 parser = argparse.ArgumentParser(description=__description__,
484 epilog='Developed by ' +
485 __author__ + ' on ' +
486 __date__)
487
488 parser.add_argument('WAL', help='SQLite WAL file')
489 parser.add_argument('OUTPUT_DIR', help='Output Directory')
490 parser.add_argument('-r', help='Custom regular expression')
491 parser.add_argument('-m', help='Run regular expression module',
492 action='store_true')
493 parser.add_argument('-l', help='File path of log file')
494 args = parser.parse_args()
在第 483 行,我们创建了参数解析器,指定了必需的输入值,包括 WAL 文件和输出目录,以及可选的输入值,执行预构建的或自定义的正则表达式和日志输出路径。在第 496 到 508 行,我们执行了与前几章相同的日志设置:
496 if args.l:
497 if not os.path.exists(args.l):
498 os.makedirs(args.l)
499 log_path = os.path.join(args.l, 'wal_crawler.log')
500 else:
501 log_path = 'wal_crawler.log'
502 logging.basicConfig(filename=log_path, level=logging.DEBUG,
503 format=('%(asctime)s | %(levelname)s | '
504 '%(message)s'), filemode='a')
505
506 logging.info('Starting Wal_Crawler')
507 logging.debug('System ' + sys.platform)
508 logging.debug('Version ' + sys.version)
在执行 main() 函数之前,我们进行一些基本检查并验证输入。第 510 行,我们检查并(可选)创建输出目录,如果它不存在的话。在执行 main() 函数之前,我们通过使用 os.path.exists() 和 os.path.isfile() 函数来验证输入文件,检查文件是否存在且是否为文件。否则,我们在退出程序之前,将错误信息写入控制台和日志中。在 main() 函数中,我们将进一步验证 WAL 文件:
510 if not os.path.exists(args.OUTPUT_DIR):
511 os.makedirs(args.OUTPUT_DIR)
512
513 if os.path.exists(args.WAL) and os.path.isfile(args.WAL):
514 main(args.WAL, args.OUTPUT_DIR, r=args.r, m=args.m)
515 else:
516 msg = 'Supplied WAL file does not exist or isn't a file'
517 print('[-]', msg)
518 logging.error(msg)
519 sys.exit(1)
以下流程图突出显示了不同函数之间的交互,并展示了我们的代码如何处理 WAL 文件:
理解 main() 函数
这个函数比我们通常的 main() 函数复杂,它开始解析 WAL 文件,而不是作为脚本的控制器。在这个函数中,我们将执行文件验证,解析 WAL 文件头,识别文件中的帧数量,并调用函数处理这些帧:
055 def main(wal_file, output_dir, **kwargs):
056 """
057 The main function parses the header of the input file and
058 identifies the WAL file. It then splits the file into the
059 appropriate frames and send them for processing. After
060 processing, if applicable, the regular expression modules are
061 ran. Finally the raw data output is written to a CSV file.
062 :param wal_file: The filepath to the WAL file to be processed
063 :param output_dir: The directory to write the CSV report to.
064 :return: Nothing.
065 """
在第 70 行,我们创建了wal_attributes字典,它是我们在解析 WAL 文件时会扩展的字典。初始时,它存储了文件大小,以及两个空字典分别用于文件头和帧。接下来,我们以rb模式(即二进制读取模式)打开输入文件,并读取前 32 个字节作为文件头。在第 79 行,我们尝试解析文件头,并将所有键及其值添加到文件头字典中。此操作执行了另一个有效性检查,因为如果文件小于 32 字节,struct 会抛出错误。我们使用>4s7i作为我们的格式字符串,解析出一个 4 字节的字符串和七个 32 位大端整数(>在格式字符串中指定了字节序):
066 msg = 'Identifying and parsing file header'
067 print('[+]', msg)
068 logging.info(msg)
069
070 wal_attributes = {'size': os.path.getsize(wal_file),
071 'header': {}, 'frames': {}}
072 with open(wal_file, 'rb') as wal:
073
074 # Parse 32-byte WAL header.
075 header = wal.read(32)
076
077 # If file is less than 32 bytes long: exit wal_crawler.
078 try:
079 wal_attributes['header'] = dict_helper(header,'>4s7i',
080 namedtuple('struct',
081 'magic format pagesize checkpoint '
082 'salt1 salt2 checksum1 checksum2'))
083 except struct.error as e:
084 logging.error('STRUCT ERROR:', e.message)
085 print('[-]', e.message + '. Exiting..')
086 sys.exit(2)
请注意dict_helper()函数的使用。我们将在后续章节中解释这个函数的具体工作原理,但它允许我们使用 struct 解析从 WAL 文件中读取的数据,并返回包含键值对的OrderedDict。这大大减少了必须将返回的 struct 元组中的每个值添加到字典中的代码量。
在解析完 WAL 头后,我们可以将文件魔数或签名与已知值进行比较。我们使用binascii.hexlify将原始数据转换为十六进制。在第 92 行,我们使用if语句来比较magic_hex值。如果它们不匹配,我们停止程序执行。如果匹配,我们会在日志中记录,并继续处理 WAL 文件:
088 # Do not proceed in the program if the input file isn't a
089 # WAL file.
090 magic_hex = binascii.hexlify(
091 wal_attributes['header']['magic']).decode('utf-8')
092 if magic_hex != "377f0682" and magic_hex != "377f0683":
093 logging.error(('Magic mismatch, expected 0x377f0682 '
094 'or 0x377f0683 | received {}'.format(magic_hex)))
095 print(('[-] File does not have appropriate signature '
096 'for WAL file. Exiting...'))
097 sys.exit(3)
098
099 logging.info('File signature matched.')
100 logging.info('Processing WAL file.')
使用文件大小,我们可以在第 103 行计算帧的数量。请注意,我们需要考虑 32 字节的 WAL 头和 24 字节的帧头,以及每个帧内的页面大小:
102 # Calculate number of frames.
103 frames = int((
104 wal_attributes['size'] - 32) / (
105 wal_attributes['header']['pagesize'] + 24))
106 print('[+] Identified', frames, 'Frames.')
在第 111 行,我们使用来自tqdm的trange创建进度条,并开始处理每一帧。我们首先在第 114 行创建一个索引键,表示为x,并为我们的帧创建一个空字典。这个索引最终会指向处理过的帧数据。接下来,我们读取 24 字节的帧头。在第 116 行,我们解析从帧头读取的六个 32 位大端整数,并通过调用我们的dict_helper()函数将适当的键值对添加到字典中:
108 # Parse frames in WAL file. Create progress bar using
109 # trange(frames) which is an alias for tqdm(xrange(frames)).
110 print('[+] Processing frames...')
111 for x in trange(frames):
112
113 # Parse 24-byte WAL frame header.
114 wal_attributes['frames'][x] = {}
115 frame_header = wal.read(24)
116 wal_attributes['frames'][x]['header'] = dict_helper(
117 frame_header, '>6i', namedtuple('struct',
118 'pagenumber commit salt1'
119 ' salt2 checksum1'
120 ' checksum2'))
在解析完帧头之后,我们在第 122 行读取 WAL 文件中的整个帧。然后,我们将这个帧传递给frame_parser()函数,同时传入wal_attributes字典和x,后者表示当前帧的索引:
121 # Parse pagesize WAL frame.
122 frame = wal.read(wal_attributes['header']['pagesize'])
123 frame_parser(wal_attributes, x, frame)
frame_parser()函数调用内部的其他函数,而不是返回数据并让main()调用下一个函数。一旦 WAL 文件的解析完成,主函数会在用户提供m或r开关的情况下调用regular_search()函数,并调用csv_writer()函数将解析后的数据写入 CSV 文件以供审查:
125 # Run regular expression functions.
126 if kwargs['m'] or kwargs['r']:
127 regular_search(wal_attributes, kwargs)
128
129 # Write WAL data to CSV file.
130 csv_writer(wal_attributes, output_dir)
开发frame_parser()函数
frame_parser()函数是一个中间函数,它继续解析帧,识别帧内的单元格数量,并调用cell_parser()函数完成解析工作:
133 def frame_parser(wal_dict, x, frame):
134 """
135 The frame_parser function processes WAL frames.
136 :param wal_dict: The dictionary containing parsed WAL objects.
137 :param x: An integer specifying the current frame.
138 :param frame: The content within the frame read from the WAL
139 file.
140 :return: Nothing.
141 """
如前所述,WAL 页面头是帧头之后的前 8 个字节。页面头包含两个 8 位和三个 16 位的大端整数。在 struct 字符串中,>b3hb,b解析 8 位整数,h解析 16 位整数。解析了这个头之后,我们现在知道页面内有多少个单元格:
143 # Parse 8-byte WAL page header
144 page_header = frame[0:8]
145 wal_dict['frames'][x]['page_header'] = dict_helper(
146 page_header, '>b3hb', namedtuple('struct',
147 'type freeblocks cells offset'
148 ' fragments'))
在第 150 行,我们检查帧的类型是否为0x0D(该值在解释为 16 位整数时等于 13)。如果帧不是适当类型,我们记录此信息,并在返回函数之前使用pop()从字典中移除该帧。我们返回函数,以防止继续处理我们不感兴趣的帧:
149 # Only want to parse 0x0D B-Tree Leaf Cells
150 if wal_dict['frames'][x]['page_header']['type'] != 13:
151 logging.info(('Found a non-Leaf Cell in frame {}. Popping '
152 'frame from dictionary').format(x))
153 wal_dict['frames'].pop(x)
154 return
无论如何,在第 156 行,我们创建了一个新的嵌套字典,名为 cells,并用它来跟踪单元格,方式与我们跟踪帧的方式完全相同。我们还打印每个帧中识别到的单元格数量,以便向用户提供反馈:
155 # Parse offsets for "X" cells
156 cells = wal_dict['frames'][x]['page_header']['cells']
157 wal_dict['frames'][x]['cells'] = {}
158 print('[+] Identified', cells, 'cells in frame', x)
159 print('[+] Processing cells...')
最后,在第 161 行,我们遍历每个单元格并解析它们的偏移量,然后将其添加到字典中。我们知道N 2 字节单元格偏移量紧跟在 8 字节的页面头之后。我们使用第 162 行计算出的 start 变量来识别每个单元格的偏移量起始位置:
161 for y in range(cells):
162 start = 8 + (y * 2)
163 wal_dict['frames'][x]['cells'][y] = {}
164
165 wal_dict['frames'][x]['cells'][y] = dict_helper(
166 frame[start: start + 2], '>h', namedtuple(
167 'struct', 'offset'))
在第 163 行,我们创建一个索引键和一个空字典来存储单元格。然后,我们使用dict_helper()函数解析单元格偏移量,并将内容存储到特定的单元格字典中。一旦偏移量被识别,我们调用cell_parser()函数来处理单元格及其内容。我们将wal_attributes字典、frame 和单元格索引x和y,以及 frame 数据传递给它:
169 # Parse cell content
170 cell_parser(wal_dict, x, y, frame)
使用cell_parser()函数处理单元格
cell_parser()函数是我们程序的核心。它负责实际提取存储在单元格中的数据。正如我们将看到的,varints 给代码增加了额外的复杂性;然而,大部分情况下,我们仍然是在使用 struct 解析二进制结构,并根据这些值做出决策:
173 def cell_parser(wal_dict, x, y, frame):
174 """
175 The cell_parser function processes WAL cells.
176 :param wal_dict: The dictionary containing parsed WAL objects.
177 :param x: An integer specifying the current frame.
178 :param y: An integer specifying the current cell.
179 :param frame: The content within the frame read from the WAL
180 file.
181 :return: Nothing.
182 """
在开始解析单元格之前,我们实例化几个变量。我们在第 183 行创建的 index 变量用于跟踪当前单元格的位置。请记住,我们不再处理整个文件,而是处理表示单元格的文件子集。frame 变量是从数据库中读取的与页面大小相对应的数据量。例如,如果页面大小为 1,024,那么 frame 变量就是 1,024 字节的数据,对应于数据库中的一个页面。struct 模块要求解析的数据长度必须与 struct 字符串中指定的数据类型长度完全一致。基于这两个事实,我们需要使用字符串切片来提供仅我们想要解析的数据:
183 index = 0
在第 186 行,我们创建了 cell_root,它本质上是指向 wal_attributes 字典中嵌套单元字典的快捷方式。这不仅仅是为了懒惰;它有助于提高代码可读性,并通过引用指向嵌套字典的变量,减少每次都要打出完整路径的冗余。出于同样的原因,我们在第 187 行创建了 cell_offset 变量:
184 # Create alias to cell_root to shorten navigating the WAL
185 # dictionary structure.
186 cell_root = wal_dict['frames'][x]['cells'][y]
187 cell_offset = cell_root['offset']
从第 191 行开始,我们遇到了单元有效载荷长度中的第一个 varint。这个 varint 将决定单元的整体大小。为了提取这个 varint,我们调用 single_varint() 辅助函数,传入 9 字节的数据切片。这个函数,稍后我们将解释,会检查第一个字节是否大于或等于 128;如果是,它会处理第二个字节。除了 varint 外,single_varint() 辅助函数还会返回 varint 占用的字节数。这样,我们就可以跟踪当前在帧数据中的位置。我们使用返回的索引以类似的方式解析行 ID 的 varint:
189 # Parse the payload length and rowID Varints.
190 try:
191 payload_len, index_a = single_varint(
192 frame[cell_offset:cell_offset + 9])
193 row_id, index_b = single_varint(
194 frame[cell_offset + index_a: cell_offset + index_a + 9])
195 except ValueError:
196 logging.warn(('Found a potential three-byte or greater '
197 'varint in cell {} from frame {}').format(y, x))
198 return
处理完前两个 varint 后,我们将键值对添加到 wal_attributes 字典中。在第 204 行,我们更新了索引变量,以保持当前在帧数据中的位置。接下来,我们手动提取 8 位有效载荷头长度值,而不使用 dict_helper() 函数。我们这样做有两个原因:
-
我们只处理一个值
-
将
cell_root设置为dict_helper()输出的结果,发现它会清除cell_root所描述的单元嵌套字典中的所有其他键,这显然不是理想的做法。
以下代码块展示了此功能:
200 # Update the index. Following the payload length and rowID is
201 # the 1-byte header length.
202 cell_root['payloadlength'] = payload_len
203 cell_root['rowid'] = row_id
204 index += index_a + index_b
205 cell_root['headerlength'] = struct.unpack('>b',
206 frame[cell_offset + index: cell_offset + index + 1])[0]
解析了有效载荷长度、行 ID 和有效载荷头长度后,我们现在可以解析序列类型数组。提醒一下,序列类型数组包含 N 个 varint,长度为 1 字节的 headerlength。在第 210 行,我们通过加 1 更新索引,以考虑在第 205 行解析的 1 字节头。接下来,我们通过调用 multi_varint() 函数提取位于适当范围内的所有 varint。该函数返回一个元组,包含序列类型列表和当前索引。在第 218 行和第 219 行,我们分别更新 wal_attributes 和 index 对象:
208 # Update the index with the 1-byte header length. Next process
209 # each Varint in "headerlength" - 1 bytes.
210 index += 1
211 try:
212 types, index_a = multi_varint(
213 frame[cell_offset + index:cell_offset+index+cell_root['headerlength']-1])
214 except ValueError:
215 logging.warn(('Found a potential three-byte or greater '
216 'varint in cell {} from frame {}').format(y, x))
217 return
218 cell_root['types'] = types
219 index += index_a
一旦序列类型数组解析完毕,我们就可以开始提取单元中存储的实际数据。回想一下,单元有效载荷是有效载荷长度与有效载荷头长度之间的差值。第 224 行计算出的这个值用于将单元的其余内容传递给 type_helper() 辅助函数,后者负责解析数据:
221 # Immediately following the end of the Varint headers begins
222 # the actual data described by the headers. Process them using
223 # the typeHelper function.
224 diff = cell_root['payloadlength'] - cell_root['headerlength']
225 cell_root['data'] = type_helper(cell_root['types'],
226 frame[cell_offset + index: cell_offset + index + diff])
编写 dict_helper() 函数
dict_helper() 函数是一个单行函数,且文档少于六行。它利用了 named_tuple 数据结构,keys 变量传入其中,并调用 _make() 和 _asdict() 函数,在结构体解析值后创建我们的有序字典:
229 def dict_helper(data, format, keys):
230 """
231 The dict_helper function creates an OrderedDictionary from
232 a struct tuple.
233 :param data: The data to be processed with struct.
234 :param format: The struct format string.
235 :param keys: A string of the keys for the values in the struct
236 tuple.
237 :return: An OrderedDictionary with descriptive keys of
238 struct-parsed values.
239 """
240 return keys._asdict(keys._make(struct.unpack(format, data)))
与大多数紧凑的单行代码一样,当在一行中调用更多函数时,代码的可读性会降低,从而可能使函数的含义变得模糊。我们将在这里引入并使用内置的 Python 调试器,以便查看发生了什么。
Python 调试器 – pdb
Python 有很多优点,我们现在不需要再赘述其中的细节。其中一个非常优秀的功能是内置的调试模块pdb。这个模块虽然简单,但在识别麻烦的 bug 或在执行过程中查看变量时非常有用。如果你使用的是集成开发环境(强烈推荐)来开发脚本,那么很可能已经内置了调试支持。然而,如果你在简单的文本编辑器中编写代码,不用担心;你依然可以使用pdb来调试你的代码。
在这个例子中,我们将检查dict_helper()的每个组件,以便充分理解这个函数。我们不会覆盖pdb的所有用法和命令,而是通过示例进行说明,若需要更多信息,可以参考docs.python.org/3/library/pdb.html。
首先,我们需要修改现有代码,并在希望检查的代码处创建一个调试点。在第 240 行,我们导入pdb并在同一行调用pdb.set_trace():
240 import pdb; pdb.set_trace()
241 return keys._asdict(keys._make(struct.unpack(format, data)))
使用分号可以让我们在一行中分隔多个语句。通常我们不会这样做,因为这会影响可读性。然而,这只是为了测试,最终代码中会去除这一部分。
现在,当我们执行代码时,会看到pdb提示符,下面的截图显示了这一点。pdb提示符类似于 Python 解释器。我们可以访问当前作用域中的变量,例如data、format和keys。我们也可以创建自己的变量并执行简单的表达式:
pdb提示符的第一行包含文件的位置、当前文件中的行号和正在执行的当前函数。第二行是即将执行的下一行代码。Pdb提示符与 Python 解释器中的>>>提示符具有相同的意义,是我们可以输入自己命令的地方。
在这个例子中,我们正在解析文件头,因为这是第一次调用dict_helper()。回忆一下,我们使用的结构字符串是>4s7i。正如我们在下面的示例中看到的,unpack()返回的是一个元组结果。然而,我们希望返回一个字典,将所有值与其相关的键匹配,以便不必手动执行此任务:
(Pdb) struct.unpack(format, data)
('7x7fx06x82', 3007000, 32768, 9, -977652151, 1343711549, 670940632, 650030285)
请注意,keys._make会创建一个对象,其中为每个值设置了适当的字段名称。它通过将我们在第 41 行创建keys变量时提供的字段名称与结构元组中的每个值相关联来实现这一点:
(Pdb) keys._make(struct.unpack(format, data))
struct(magic='7x7fx06x82', format=3007000, pagesize=32768, checkpoint=9, salt1=-977652151, salt2=1343711549, checksum1=670940632, checksum2=650030285)
最后,我们可以使用pdb验证keys._asdict()函数是否将我们的namedtuple转换为OrderedDict,这也是我们返回的内容:
(Pdb) keys._asdict(keys._make(struct.unpack(format, data)))
OrderedDict([('magic', '7x7fx06x82'), ('format', 3007000), ('pagesize', 32768), ('checkpoint', 9), ('salt1', -977652151), ('salt2', 1343711549), ('checksum1', 670940632), ('checksum2', 650030285)])
以这种方式使用pdb可以帮助我们查看当前变量的状态,并逐个执行函数。当程序在某个特定函数中遇到错误时,这非常有用,因为你可以逐行和逐函数地执行,直到找到问题所在。我们建议你熟悉pdb,因为它能加速调试过程,并且比使用打印语句进行故障排除更有效。按下 q 和Enter退出pdb,并确保始终从最终代码中移除调试语句。
使用 single_varint()函数处理 varint
single_varint函数在提供的数据中找到第一个 varint,并使用索引跟踪其当前位置。当它找到 varint 时,它会返回该值以及索引。这告诉调用函数 varint 的字节数,并用于更新它自己的索引:
243 def single_varint(data, index=0):
244 """
245 The single_varint function processes a Varint and returns the
246 length of that Varint.
247 :param data: The data containing the Varint (maximum of 9
248 bytes in length as that is the maximum size of a Varint).
249 :param index: The current index within the data.
250 :return: varint, the processed varint value,
251 and index which is used to identify how long the Varint was.
252 """
对于此脚本,我们做了一个简化假设,即 varint 永远不会超过 2 个字节。这个假设是简化的,并不适用于所有情况。这有两个可能的情形:
-
第一个字节的十进制值小于 128
-
第一个字节大于或等于 128
根据结果,将会发生以下两种情况之一。如果字节大于或等于 128,则 varint 长度为 2 字节。否则,长度为 1 字节。在第 256 行,我们使用ord()函数将字节的值转换为整数:
254 # If the decimal value is => 128 -- then first bit is set and
255 # need to process next byte.
256 if ord(data[index:index+1]) >= 128:
257 # Check if there is a three or more byte varint
258 if ord(data[index + 1: index + 2]) >= 128:
259 raise ValueError
如果值大于 128,我们知道第二个字节也是必需的,并且必须应用以下通用公式,其中x是第一个字节,y是第二个字节:
Varint = ((x - 128) * 128) + y
我们在将索引加 2 后返回这个值:
260 varint = (ord(data[index:index+1]) - 128) * 128 + ord(
261 data[index + 1: index + 2])
262 index += 2
263 return varint, index
如果第一个字节小于 128,我们只需返回该字节的整数值并将索引加 1:
265 # If the decimal value is < 128 -- then first bit isn't set
266 # and is the only byte of the Varint.
267 else:
268 varint = ord(data[index:index+1])
269 index += 1
270 return varint, index
使用 multi_varint()函数处理 varint
multi_varint()函数是一个循环函数,它会重复调用single_varint(),直到提供的数据中没有更多的 varint。它返回一个 varint 的列表和一个指向父函数的索引。在第 282 和 283 行,我们初始化了 varint 的列表,并将本地索引变量设置为零:
273 def multi_varint(data):
274 """
275 The multi_varint function is similar to the single_varint
276 function. The difference is that it takes a range of data
277 and finds all Varints within it.
278 :param data: The data containing the Varints.
279 :return: varints, a list containing the processed varint
280 values, and index which is used to identify how long the
281 Varints were.
282 """
283 varints = []
284 index = 0
我们使用while循环直到数据的长度为 0。在每次循环中,我们调用single_varint(),将得到的 varint 附加到列表中,更新索引,并使用字符串切片缩短数据。通过执行第 293 行,使用single_varint()函数返回的 varint 大小,我们可以逐渐缩短数据,直到长度为 0。到达这一点时,我们可以确认已经提取了字符串中的所有 varint:
286 # Loop forever until all Varints are found by repeatedly
287 # calling singleVarint.
288 while len(data) != 0:
289 varint, index_a = single_varint(data)
290 varints.append(varint)
291 index += index_a
292 # Shorten data to exclude the most recent Varint.
293 data = data[index_a:]
294
295 return varints, index
使用 type_helper()函数转换序列类型
type_helper()函数负责根据数据中值的类型提取有效负载。尽管它由许多行代码组成,但实际上不过是一系列条件语句,如果某一条语句为True,则决定数据如何处理:
298 def type_helper(types, data):
299 """
300 The type_helper function decodes the serial type of the
301 Varints in the WAL file.
302 :param types: The processed values of the Varints.
303 :param data: The raw data in the cell that needs to be
304 properly decoded via its varint values.
305 :return: cell_data, a list of the processed data.
306 """
在第 307 行和 308 行,我们创建了一个列表,用来存储提取的有效负载数据和索引。索引用于表示数据中的当前位置。在第 313 行,我们开始遍历每种序列类型,检查每种类型应该如何处理:
307 cell_data = []
308 index = 0
前十种类型相对简单。我们使用序列类型表来识别数据类型,然后使用struct进行解包。某些类型,如 0、8 和 9 是静态的,不需要我们解析数据或更新索引值。类型 3 和 5 是struct不支持的数据类型,需要使用其他方法提取。让我们看一下支持和不支持的类型,确保我们理解发生了什么:
310 # Value of type dictates how the data should be processed.
311 # See serial type table in chapter for list of possible
312 # values.
313 for type in types:
314
315 if type == 0:
316 cell_data.append('NULL (RowId?)')
317 elif type == 1:
318 cell_data.append(struct.unpack('>b',
319 data[index:index + 1])[0])
320 index += 1
321 elif type == 2:
322 cell_data.append(struct.unpack('>h',
323 data[index:index + 2])[0])
324 index += 2
325 elif type == 3:
326 # Struct does not support 24-bit integer
327 cell_data.append(int(binascii.hexlify(
328 data[index:index + 3]).decode('utf-8'), 16))
329 index += 3
330 elif type == 4:
331 cell_data.append(struct.unpack(
332 '>i', data[index:index + 4])[0])
333 index += 4
334 elif type == 5:
335 # Struct does not support 48-bit integer
336 cell_data.append(int(binascii.hexlify(
337 data[index:index + 6]).decode('utf-8'), 16))
338 index += 6
从序列类型表中我们知道,类型 6(第 339 行)是一个 64 位大端整数。struct中的q字符用于解析 64 位整数,这使得我们的工作相对简单。我们必须确保只将组成 64 位整数的数据传递给struct。我们可以通过使用当前索引的字符串切片,截取前 8 个字节来实现。之后,我们需要将索引递增 8,以便下一个类型能够从正确的位置开始:
如果struct不支持某个变量类型,比如类型 3(一个 24 位整数),我们需要以更迂回的方式提取数据。这需要我们使用binascii.hexlify()函数将数据字符串转换为十六进制。然后,我们简单地将int()对象构造函数包裹在十六进制值上,将其转换为整数值。请注意,我们需要明确告诉int函数值转换的进制,在本例中是 16 进制,因为该值是十六进制的。
339 elif type == 6:
340 cell_data.append(struct.unpack(
341 '>q', data[index:index + 8])[0])
342 index += 8
343 elif type == 7:
344 cell_data.append(struct.unpack(
345 '>d', data[index:index + 8])[0])
346 index += 8
347 # Type 8 == Constant 0 and Type 9 == Constant 1\. Neither of these take up space in the actual data.
348 elif type == 8:
349 cell_data.append(0)
350 elif type == 9:
351 cell_data.append(1)
352 # Types 10 and 11 are reserved and currently not implemented.
对于类型 12 和 13,我们必须首先通过应用适当的公式来确定值的实际长度。接下来,我们可以将提取的字符串直接追加到cell_data列表中。我们还需要根据计算出的字符串大小递增索引:
353 elif type > 12 and type % 2 == 0:
354 b_length = int((type - 12) / 2)
355 cell_data.append(data[index:index + b_length])
356 index += b_length
357 elif type > 13 and type % 2 == 1:
358 s_length = int((type - 13) / 2)
359 cell_data.append(
360 data[index:index + s_length].decode('utf-8'))
361 index += s_length
在第 363 行,我们创建了一个else分支来捕获任何意外的序列类型,并打印和记录错误。所有类型处理完毕后,cell_data列表会在第 368 行返回:
363 else:
364 msg = 'Unexpected serial type: {}'.format(type)
365 print('[-]', msg)
366 logging.error(msg)
367
368 return cell_data
使用csv_writer()函数写入输出
csv_writer()函数与我们之前的许多 CSV 写入器类似。由于写入文件的数据比较复杂,因此需要做一些特殊处理。此外,我们只将部分数据写入文件,其他数据会被丢弃。将数据转储到一个序列化的数据结构(如 JSON)中留给读者作为挑战。像任何csv_writer一样,我们首先创建一个包含标题的列表,打开csvfile,创建写入对象,然后将标题写入第一行:
371 def csv_writer(data, output_dir):
372 """
373 The csv_writer function writes frame, cell, and data to a CSV
374 output file.
375 :param data: The dictionary containing the parsed WAL file.
376 :param output_dir: The directory to write the CSV report to.
377 :return: Nothing.
378 """
379 headers = ['Frame', 'Salt-1', 'Salt-2', 'Frame Offset',
380 'Cell', 'Cell Offset', 'ROWID', 'Data']
381
382 out_file = os.path.join(output_dir, 'wal_crawler.csv')
383
384 if sys.version_info[0] == 2:
385 csvfile = open(out_file, "wb")
386 elif sys.version_info[0] == 3:
387 csvfile = open(out_file, "w", newline='',
388 encoding='utf-8')
389
390 with csvfile:
391 writer = csv.writer(csvfile)
392 writer.writerow(headers)
由于我们的结构是嵌套的,我们需要创建两个for循环来遍历该结构。在第 399 行,我们检查单元格是否实际包含任何数据。在开发过程中我们注意到,有时会生成空单元格并且它们会被丢弃在输出中。然而,在某些特定的调查中,可能需要包括空单元格,在这种情况下,我们将删除条件语句:
394 for frame in data['frames']:
395
396 for cell in data['frames'][frame]['cells']:
397
398 # Only write entries for cells that have data.
399 if ('data' in data['frames'][frame]['cells'][cell].keys() and
400 len(data['frames'][frame]['cells'][cell]['data']) > 0):
如果有数据,我们计算相对于文件开头的frame_offset和cell_offset。我们之前解析的偏移量是相对于文件中当前位置的。这种相对值对于需要回溯以查找相对偏移位置的检查人员来说不会很有帮助。
对于我们的帧偏移,我们需要加上文件头大小(32 字节)、总页大小(帧数 * 页大小)和总帧头大小(帧数 * 24 字节)。单元格偏移则稍微简单些,是帧偏移加上帧头大小,再加上从wal_attributes字典中解析出的单元格偏移:
401 # Convert relative frame and cell offsets to
402 # file offsets.
403 frame_offset = 32 + (
404 frame * data['header']['pagesize']) + (
405 frame * 24)
406 cell_offset = frame_offset + 24 + data['frames'][frame]['cells'][cell]['offset']
接下来,我们在第 411 行创建一个列表cell_identifiers,用于存储要写入的行数据。该列表包含帧编号、salt-1、salt-2、帧偏移、单元格编号、单元格偏移和行 ID:
408 # Cell identifiers include the frame #,
409 # salt-1, salt-2, frame offset,
410 # cell #, cell offset, and cell rowID.
411 cell_identifiers = [frame, data['frames'][frame]['header']['salt1'],
412 data['frames'][frame]['header']['salt2'],
413 frame_offset, cell, cell_offset,
414 data['frames'][frame]['cells'][cell]['rowid']]
最后,在第 418 行,我们将行数据和负载数据一起写入 CSV 文件:
416 # Write the cell_identifiers and actual data
417 # within the cell
418 writer.writerow(
419 cell_identifiers + data['frames'][frame]['cells'][cell]['data'])
如果单元格没有负载,则执行继续块并进入下一个单元格。一旦外层的for循环执行完成,也就是所有的帧已写入 CSV 文件,我们将刷新所有剩余的缓冲内容到 CSV,并关闭文件句柄:
421 else:
422 continue
423
424 csvfile.flush()
425 csvfile.close()
从 WAL 文件生成的 CSV 输出示例在下图中可以看到:
在regular_search()函数中使用正则表达式
regular_search()函数是一个可选函数。如果用户提供了-m或-r开关,则会执行该函数。该函数使用正则表达式在 WAL 文件中识别相关信息,并且如果识别到,则将数据打印到终端:
428 def regular_search(data, options):
429 """
430 The regular_search function performs either default regular
431 expression searches for personal information or custom
432 searches based on a supplied regular expression string.
433 :param data: The dictionary containing the parsed WAL file.
434 :param options: The options dictionary contains custom or
435 pre-determined regular expression searching
436 :return: Nothing.
437 """
我们将使用一个包含正则表达式模式的字典来运行。这将使得识别哪个类别的表达式(例如 URL 或电话号码)与数据匹配并打印出来提供上下文变得更加容易。
首先,我们必须识别用户指定的开关。如果仅指定了args.r,那么我们只需要使用提供的自定义正则表达式创建正则字典。因为args.r或args.m至少有一个是提供的才能进入此函数,所以如果第一个if为False,那么至少args.m必须已被提供:
438 msg = 'Initializing regular expression module.'
439 print('\n{}\n[+]'.format('='*20), msg)
440 logging.info(msg)
441 if options['r'] and not options['m']:
442 regexp = {'Custom': options['r']}
443 else:
444 # Default regular expression modules include: Credit card
445 # numbers, SSNs, Phone numbers, URLs, IP Addresses.
446 regexp = {'Visa Credit Card': r'⁴\d{3}([\ \-]?)\d{4}\1\d{4}\1\d{4}$',
447 'SSN': r'^\d{3}-\d{2}-\d{4}$',
448 'Phone Number': r'^\d{3}([\ \. \-]?)\d{3}\1\d{4}$',
449 'URL': r"(http[s]?://)|(www.)(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+",
450 'IP Address': r'^\d{1,3}.\d{1,3}.\d{1,3}.\d{1,3}$'}
如果是这种情况,我们需要构建包含正则表达式模式的正则表达式字典。默认情况下,我们已经包括了之前的信用卡和电话号码示例,以及 SSN、URL 和 IP 地址的模式。此外,在第 452 行,我们需要检查是否同时传递了args.r和args.m。如果传递了,我们将自定义表达式添加到我们的字典中,该字典已经包含了args.m表达式:
452 if options['r']:
453 regexp['Custom'] = options['r']
在我们的字典中,对于每个表达式,我们需要在使用匹配函数之前进行编译。当我们编译每个表达式时,我们会使用更多的循环来遍历wal_attributes字典,并检查每个单元格是否存在匹配项:
455 # Must compile each regular expression before seeing if any
456 # data "matches" it.
457 for exp in regexp.keys():
458 reg_exp = re.compile(regexp[exp])
从第 457 行开始,我们创建了一个三重for循环来获取每个数据点。在csv_writer()中,我们只使用了两个for循环,因为我们不需要与每个数据点交互。然而,在这种情况下,我们需要这样做才能成功地使用正则表达式识别匹配项。
请注意,match 函数周围的 try 和 except。match 函数期望一个字符串或缓冲区。如果它尝试将表达式匹配到一个整数时,它会出错。因此,我们决定捕获这个错误,并在遇到错误时跳到下一个数据点。我们也可以通过使用str()函数将数据转换为字符串来解决这个问题:
460 for frame in data['frames']:
461
462 for cell in data['frames'][frame]['cells']:
463
464 for datum in range(len(
465 data['frames'][frame]['cells'][cell]['data'])):
466 # TypeError will occur for non-string objects
467 # such as integers.
468 try:
469 match = reg_exp.match(
470 data['frames'][frame]['cells'][cell]['data'][datum])
471 except TypeError:
472 continue
473 # Print any successful match to user.
474 if match:
475 msg = '{}: {}'.format(exp,
476 data['frames'][frame]['cells'][cell]['data'][datum])
477 print('[*]', msg)
478 print('='*20)
执行 wal_crawler.py
现在我们已经编写了脚本,接下来是实际运行它。最简单的方式是提供输入 WAL 文件和输出目录:
可选地,我们可以使用-m或-r开关来启用正则表达式模块。以下截图显示了正则表达式输出的示例:
请注意,在通过-r开关提供自定义正则表达式时,请用双引号将表达式括起来。如果没有这样做,由于正则表达式中的特殊字符引发的混乱,可能会遇到错误。
挑战
这个脚本有几个可能的发展方向。正如我们之前提到的,有大量潜在有用的数据我们并没有写入文件。将整个字典结构存储到一个 JSON 文件中可能会很有用,这样其他人可以轻松导入并操作数据。这将允许我们在一个单独的程序中利用解析后的结构,并从中创建额外的报告。
我们可以开发的另一个有用功能是为用户提供时间线报告或图形。该报告会列出每个记录的当前内容,然后显示从当前记录内容到其旧版本甚至不存在的记录的演变过程。树形图或流程图可能是可视化特定数据库记录变化的一个好方法。
最后,添加一个支持处理大于 2 字节的变长整数(varint)的功能。在我们的脚本中,我们做出了一个简化假设,认为不太可能遇到大于 2 字节的变长整数。然而,遇到更大变长整数并非不可能,因此可能值得添加这个功能。
总结
在本章中,我们学习了 WAL 文件的取证意义以及如何解析它。我们还简要介绍了如何在 Python 中使用re模块通过正则表达式创建通用的搜索模式。最后,我们利用tqdm模块通过一行代码创建了一个进度条。该项目的代码可以从 GitHub 或 Packt 下载,如前言所述。
在下一章中,我们将把本书中所学的所有知识结合成一个框架。我们将设计一个框架,用于对我们已经涵盖的常见数据进行基本的预处理。我们将展示框架设计和开发过程,并揭示你在本书中默默构建的框架。