Python数字取证实战:逆向解析二进制文件结构

本文详细介绍了如何使用Python解析自定义二进制文件格式,通过逆向工程分析联系人记录结构,并编写脚本提取索引号、姓名、电话号码和时间戳等字段数据。

Monkey解包Python

解包Python…现在加入了猴子的元素!

一些取证专家建议,为社区中初学Python的程序员提供关于如何读取/打印二进制数据类型的Python教程可能会有所帮助。因此,在本文中,我们将模拟逆向工程一个虚构的联系人文件格式,然后编写一个Python脚本来提取/打印这些值。

为简洁起见,本文假设读者具备Python基础知识(即能够启动脚本并了解函数/变量赋值等)。网上有大量入门教程 - 如果你是初学者,在继续之前可能需要查看Google Developer Python课程。

该脚本(unpack-tute.py)已在Win7x64 PC上使用Python v2.7.12和Python 3.4.1进行测试。

历史上,Python 2有更多支持的第三方库。因此,这是这只猴子学习的第一个Python版本,我们实际上更熟悉Python 2。Python 2的生命周期终止目前计划在2020年4月,所以还有几年时间。然而,由于此脚本不依赖第三方库,我们已将其调整为可在Python 2和3上运行。

影响此脚本的主要区别是Python 3默认将字符串视为Unicode,因此在搜索数据文件时我们必须添加encode(‘utf-8’)调用。

编码解决方案有多种方式。我们试图使这段代码易于理解,而不是使其"Pythonic"(无论那意味着什么)或添加大量错误检查代码(如果你编写脚本,你应该知道如何使用它!)。

Python脚本(unpack-tute.py)和示例二进制文件(testctx.bin)将发布到我的全新GitHub Python教程文件夹中。

那么,这里是我们想要读取的"testctx.bin"文件的截图:

testctx.bin截图(由WinHex提供!)

注意:第一个联系人记录已高亮显示,偏移量以十进制列出。好奇的乔治…好奇吗?

使用我们之前在此处写的一些逆向工程策略,我们可以对每个联系人记录的结构进行一些观察…

我们可以看到在每个联系人记录之前有一个重复的"ctx!“字符串。 在每个记录的"ctx!“字段之后,有一个小端2字节字段,似乎随着每个后续记录而增加(例如,十进制偏移68处的0x0100,十进制偏移516处的0x02FF,十进制偏移804处的0xFFFF)。对于初始分类,我们将称其为索引记录号。 每个记录都有一个包含名称(例如George)的UTF16LE(即每个字符2字节)字符串。 每个记录都有一个包含电话号码(例如5551234)的UTF8/ASCII(即每个字符1字节)字符串。 在每个字符串之前,有一个一字节整数对应字符串的字节大小。 最后一个字段似乎是一个4字节字段。通过观察哪些字节变化哪些字节保持不变(即最左边的字节比最右边的字节变化更快),我们怀疑最后一个字段是一个小端时间戳字段。将第一个记录的最后4个字节(即0x26CDDB56)输入DCode会得到一个有效的Unix 32位小端时间戳的日期/时间。

解码联系人时间戳

所以这是我们的联系人记录格式:

联系人记录数据结构

这是我们希望脚本执行的操作的摘要:

  1. 打开"testctx.bin"文件(只读)

  2. 存储文件内容

  3. 在文件内容中搜索ctx!标记

  4. 对于每个命中: 4a. 打印命中偏移量 4b. 提取索引号字段并打印 4c. 提取名称长度字段并打印 4d. 提取名称字符串(UTF16LE)字段并打印 4e. 提取电话长度字段并打印 4f. 提取电话字符串(UTF8)字段并打印 4g. 提取Unix时间戳字段并打印(ISO格式)

  5. 关闭文件

简单!

脚本

好的,既然我们知道我们想要做什么,以下是如何在代码中实现每个步骤…

步骤1和2:打开文件并存储文件内容(参见"unpack-tute.py"第25-33行):

  1. 打开"testctx.bin"文件(只读)
  2. 存储文件内容

对于步骤1,我们以只读二进制模式(“rb"代表的意思)打开"textctx.bin"文件:

1
fb = open(filename, "rb")

我们选择只读模式是因为我们不想更改文件内容,我们选择二进制模式是因为我们将文件解释为原始字节(而不是文本)。 然后为了读取/存储文件内容,我们调用:

1
filecontent = fb.read()

所以"filecontent"变量现在将包含来自"testctx.bin"文件的每个字节,并且可以使用"切片"表示法直接访问各个字节。 例如,filecontent[0:3]是3字节长,包括偏移0、1和2处的字节。它不包括偏移3处的字节。 如果我们将切片示例的开始/结束位置替换为名为startoffset的变量,我们得到:

1
filecontent[startoffset:(startoffset+3)]

这将仅包括开始、开始+1、开始+2处的3个字节。 读者可能想记住那个小符号,因为猴子感觉它稍后会再次出现…(呵呵,大便笑话在2017年仍然流行!)

步骤3:在文件内容中搜索"ctx!“标记(参见"unpack-tute.py"第35-49行): 知道"ctx!“在ASCII/UTF8中编码为x63 x74 x78 x21,我们可以使用变量"searchstring"以十六进制表示我们的搜索词:

1
searchstring = "\x63\x74\x78\x21"

我们现在将"filecontent"变量视为一个大的字节字符串… Python字符串类型有一个find()方法,它在父字符串中搜索子字符串。如果未找到子字符串,find()方法返回-1,否则返回找到子字符串的第一个偏移量。find()方法还可以接受一个起始偏移量参数,因此我们可以使用while循环重复调用find(),并递增起始偏移量,直到没有更多命中。因此,我们可以在父字符串中找到每个子字符串命中的偏移量,然后将其存储在名为"hitlist"的Python列表中。 这是代码:

1
2
3
4
5
nexthit = filecontent.find(searchstring.encode('utf-8'), 0)
hitlist = []
while nexthit >= 0:
    hitlist.append(nexthit)
    nexthit = filecontent.find(searchstring.encode(), nexthit + 1)

我们使用searchstring.encode(‘utf-8’)是因为Python 3兼容性问题。Python 3默认将所有字符串视为Unicode,而我们需要以UTF8(即逐字节)进行搜索。因此,在运行搜索之前,我们必须将searchstring编码为UTF8。 默认的Python 2字符串表示为原始字节序列,因此在Python 2中调用searchstring.encode(‘utf-8’)没有实际效果 - 我们可以使用Python 2行,如:

1
nexthit = filecontent.find(searchstring, 0)

1
nexthit = filecontent.find(searchstring, nexthit + 1)

这是Python2和Python 3兼容性所需的唯一主要脚本更改。

步骤4:循环遍历每个命中(参见"unpack-tute.py"第50-88行): 现在我们有了"ctx!“标记的偏移量命中列表,并且我们知道每个联系人记录的结构,因此我们可以使用for循环遍历filecontent变量,并使用我们之前讨论的切片表示法提取/打印我们需要的数据。

4a. 我们以十进制和十六进制打印出每个命中偏移量。

1
print("\nHit found at offset: " + str(hit) + " decimal = " + hex(hit) + " hex")

我们使用str()函数将"hit"偏移量变量转换为十进制字符串进行打印,使用hex()函数将命中偏移量变量转换为十六进制字符串。

4b. “ctx!“标记之后的第一个字段(“索引号”)将从命中偏移量之后4字节开始。要计算偏移量,我们可以使用如下代码:

1
indexnum_offset = hit + 4

由于我们已经将整个文件读入filecontent,我们可以访问2字节的"索引号"字段,并将其解释为小端2字节整数,如下所示:

1
indexnum = struct.unpack("<H", filecontent[indexnum_offset:(indexnum_offset+2)])[0]

我们使用struct模块的"unpack"函数对给定的filecontent切片进行解释,将其解释为LE 2字节整数,并将其存储在"indexnum"变量中。 “<H"参数告诉unpack如何解释原始字节,即”<“表示小端,“H"表示无符号2字节整数。 unpack函数返回一个元组(有点像变量序列),因此我们在末尾指定”[0]“以检索第一个转换后的值。这看起来有点奇怪,直到你发现可以在同一个unpack调用中链接类型。例如,"<HH"指定2个连续的LE无符号2字节整数。不幸的是,由于联系人记录中名称/电话字符串的可变长度,我们无法在此处使用链接。 Python帮助文档中定义了一堆其他unpack类型(搜索"pack unpack”)。

我们现在可以打印出解释后的"indexnum"值,但我们需要使用str()函数将索引号整数转换为可打印字符串。我们可以使用如下代码:

1
print("indexnum = " + str(indexnum))

我们可以对记录中的剩余字段重用类似的代码模式。 也就是说,我们计算字段X的偏移量,解释这些切片字节,然后打印。 因为我们知道记录字段大小(或者可以通过例如"名称长度"大小字节读取它们),计算偏移量就变成了将字段大小添加到先前字段偏移量以获取下一个偏移地址的练习。

4c. 因此,对于第二个字段(“名称长度”),我们可以使用:

1
2
3
4
namelength_offset = indexnum_offset + 2
print("namelength_offset = " + str(namelength_offset))
namelength = struct.unpack("B", filecontent[namelength_offset:(namelength_offset+1)])[0]
print("namelength = " + str(namelength))

对于"名称长度"字段(一字节长),我们使用起始偏移量(“namelength_offset”),它比"索引号"偏移量(“索引号"字段是2字节长)多2字节。 我们使用带有"B"参数的unpack,因为我们将filecontent[name_length_offset:(namelength_offset+1)]处的一个字节解释为无符号1字节整数,并将其存储在"namelength"变量中。

4d. 对于第三个字段(“名称字符串”),我们可以使用:

1
2
3
namestring_offset = namelength_offset + 1
namestring = filecontent[namestring_offset:(namestring_offset+namelength)].decode('utf-16-le')
print("namestring = " + namestring)

在计算"名称字符串"字段偏移量(应比"名称长度"字段多一字节)后,我们可以使用string.decode(‘utf-16-le’)方法将filecontent[namestring_offset:(namestring_offset+namelength)]切片解释为UTF16LE字符串,并将其存储在"namestring"变量中。

4e. 对于第四个字段(“电话长度”),我们可以使用:

1
2
3
phonelength_offset = namestring_offset + namelength
phonelength = struct.unpack("B", filecontent[phonelength_offset:(phonelength_offset+1)])[0]
print("phonelength = " + str(phonelength))

在计算"电话长度"字段偏移量(应比"名称字符串"偏移量多"名称长度"字节)后,我们使用带有"B"参数的unpack,因为我们将filecontent[phonelength_offset:(phonelength_offset+1)]处的一个字节解释为无符号1字节整数,并将其存储在"phonelength"变量中。

4f. 对于第五个字段(“电话字符串”),我们可以使用:

1
2
3
4
phonestring_offset = phonelength_offset + 1
print("phonestring_offset = " + str(phonestring_offset))
phonestring = filecontent[phonestring_offset:(phonestring_offset+phonelength)].decode('utf-8')
print("phonestring = " + phonestring)

在计算"电话字符串"字段偏移量(应比"电话长度"字段多一字节)后,我们可以使用string.decode(‘utf-8’)方法将filecontent[phonestring_offset:(phonestring_offset+phonelength)]切片解释为UTF8字符串,并将其存储在"phonestring"变量中。

4g. 对于第六个也是最后一个字段(“Unix时间戳”),我们可以使用:

1
2
3
4
5
6
timestamp_offset = phonestring_offset + phonelength
print("timestamp_offset = " + str(timestamp_offset))
timestamp = struct.unpack("<I", filecontent[timestamp_offset:(timestamp_offset+4)])[0]
print("raw timestamp decimal value = " + str(timestamp))
timestring = datetime.datetime.utcfromtimestamp(timestamp).strftime("%Y-%m-%dT%H:%M:%S")
print("timestring = " + timestring)

我们计算时间戳偏移量比"电话字符串"字段多"电话长度"字节,并打印时间戳偏移量以帮助调试。 我们使用带有”<I"参数的unpack将4字节filecontent[timestamp_offset:(timestamp_offset+4)]切片解释为LE无符号4字节整数,然后将整数值存储在"timestamp"变量中。 例如,将0x26CDDB56 LE解释为0x56DBCD26 BE = 1457245478十进制 = 自1JAN1970以来的秒数。 然后我们调用datetime.datetime.utcfromtimestamp()方法,使用自1JAN1970以来的秒数创建一个Python"datetime"对象。返回的datetime对象有一个我们可以调用的"strftime"方法,以获得人类可读的ISO格式字符串。strftime()的”%Y-%m-%dT%H:%M:%S"参数指定我们希望日期时间字符串格式化为年-月-日T时:分:秒。

步骤5:处理完所有"ctx!“命中后,我们关闭文件(参见"unpack-tute.py"第89行):

1
fb.close()

为了好玩,我们还在第91行在脚本完成之前打印出命中列表中的命中数。

1
print("\nProcessed " + str(len(hitlist)) + " ctx! hits. Exiting ...\n")

运行脚本

对于Python v2.7.12: 在Win7x64命令终端窗口中,将"unpack-tute.py"和"testctx.bin"复制到"c:":

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
C:\>c:\Python27\python.exe unpack-tute.py
Running unpack-tute.py v2017-08-19
Hit found at offset: 64 decimal = 0x40 hex
indexnum = 1
namelength_offset = 70
namelength = 12
namestring = George
phonelength = 7
phonestring_offset = 84
phonestring = 5551234
timestamp_offset = 91
raw timestamp decimal value = 1457245478
timestring = 2016-03-06T06:24:38
Hit found at offset: 512 decimal = 0x200 hex
indexnum = 65282
namelength_offset = 518
namelength = 18
namestring = King Kong
phonelength = 9
phonestring_offset = 538
phonestring = +15554321
timestamp_offset = 547
raw timestamp decimal value = 1457245695
timestring = 2016-03-06T06:28:15
Hit found at offset: 800 decimal = 0x320 hex
indexnum = 65535
namelength_offset = 806
namelength = 30
namestring = Magilla Gorilla
phonelength = 10
phonestring_offset = 838
phonestring = +445552468
timestamp_offset = 848
raw timestamp decimal value = 1457258495
timestring = 2016-03-06T10:01:35
Processed 3 ctx! hits. Exiting ...
C:\>

对于Python 3.4.1: 在Win7x64命令终端窗口中,将"unpack-tute.py"和"testctx.bin"复制到"c:":

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
C:\>c:\Python34\python.exe unpack-tute.py
Running unpack-tute.py v2017-08-19
Hit found at offset: 64 decimal = 0x40 hex
indexnum = 1
namelength_offset = 70
namelength = 12
namestring = George
phonelength = 7
phonestring_offset = 84
phonestring = 5551234
timestamp_offset = 91
raw timestamp decimal value = 1457245478
timestring = 2016-03-06T06:24:38
Hit found at offset: 512 decimal = 0x200 hex
indexnum = 65282
namelength_offset = 518
namelength = 18
namestring = King Kong
phonelength = 9
phonestring_offset = 538
phonestring = +15554321
timestamp_offset = 547
raw timestamp decimal value = 1457245695
timestring = 2016-03-06T06:28:15
Hit found at offset: 800 decimal = 0x320 hex
indexnum = 65535
namelength_offset = 806
namelength = 30
namestring = Magilla Gorilla
phonelength = 10
phonestring_offset = 838
phonestring = +445552468
timestamp_offset = 848
raw timestamp decimal value = 1457258495
timestring = 2016-03-06T10:01:35
Processed 3 ctx! hits. Exiting ...
C:\>

我们可以看到所有名称和电话字符串都是完整的/如十六进制视图图片中所示。 我们还使用Dcode验证了每个"timestring"值与其原始LE十六进制值相对应。

最终想法

在你掌握了一门语言的基础知识后,编程是一项通过实际项目(而不是阅读书籍或博客文章)来磨练的技能。 在研究如何在Python中编码常见任务时,Google和StackOverflow是你的朋友。 这使得打印语句成为调试时不说废话、实话实说的最好朋友(例如,打印偏移地址和/或值以进行调试)。一个放置得当的打印语句可能是发现你的第五杯可乐/咖啡对你没有任何好处的最简单方法。

此脚本中的代码旨在用于可以装入内存的文件(即0 MB到可能数百MB)。 更大的文件可能需要在读取/处理之前将文件分成块。

在编写此脚本时,我们使用了Notepad++(v6.7.9.2),语言设置为Python以获得时髦的语法高亮(例如,注释为绿色,自动缩进)。通过设置、首选项、Tab设置菜单将TAB大小设置为4个空格。我们禁用了"自动换行”(在视图菜单下)并启用了行号(在设置、首选项、编辑菜单下),因此如果/当你遇到运行时错误时,你可以更容易地找到相关行。

如果你在取证社区并觉得这篇文章有帮助,或者你在取证社区并对代码有一些问题/想法,请留下评论或发送电子邮件给我(不,我不会做你的家庭作业/作业!但如果是为了案件的新工件,猴子可能会被说服;)。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计