首页
社区
课程
招聘
[翻译]野蛮fuzz - part1:梦开始的地方
发表于: 2024-8-28 10:17 1846

[翻译]野蛮fuzz - part1:梦开始的地方

2024-8-28 10:17
1846

介绍

在过去的几个月里,我一直在被动地吸收大量与模糊测试相关的材料,因为我主要尝试将我的 Windows 利用技能从菜鸟级别提升到略微高级的水平,我发现它非常有趣。在这篇文章中,我将向你展示如何创建一个非常简单的变异模糊测试器,并希望我们能用它在一些开源项目中找到一些崩溃。

我们将要创建的模糊测试器是通过跟随 @gynvaelYouTube 上的模糊测试教程来实现的。我之前不知道 Gynvael 有视频流,现在我有了更多的内容可以添加到永无止境的观看/阅读列表中。

我还必须提到 Brandon Faulk模糊测试视频流非常棒。我大约不理解 Brandon 所说的 99% 的内容,但这些视频流非常吸引人。我个人最喜欢的是他对 calc.exec-tags 的模糊测试。他还有一个非常棒的模糊测试概念介绍视频:NYU Fuzzing Talk

选择一个目标

我想找到一个用 C 或 C++ 编写并从文件中解析数据的二进制文件。我首先接触到的是解析图像中 Exif 数据的二进制文件。我们还希望选择一个几乎没有安全隐患的目标,因为我会实时发布这些发现。

根据 https://www.media.mit.edu/pia/Research/deepview/exif.html 的描述:基本上,Exif 文件格式与 JPEG 文件格式相同。Exif 根据 JPEG 规范将一些图像/数码相机信息数据和缩略图图像插入到 JPEG 中。因此,你可以像查看普通 JPEG 图像文件一样,通过符合 JPEG 规范的互联网浏览器/图片查看器/照片修饰软件等查看 Exif 格式图像文件。

所以,Exif 根据 JPEG 规范将元数据类型信息插入到图像中,并且有很多程序/工具可以解析这些数据。

开始

我们将使用 Python3 构建一个基本的变异模糊测试器,它会微妙地(或不那么微妙地)修改有效的包含 Exif 的 JPEG,并将其提供给解析器,希望引发崩溃。我们还将在 x86 Kali Linux 发行版上进行操作。

首先,我们需要一个有效的包含 Exif 的 JPEG。谷歌搜索“带有 Exif 的样本 JPEG”会将我们引导到这个仓库。我将使用 Canon_40D.jpg 图像进行测试。

了解 JPEG 和 EXIF 规范

在我们开始随便在 Sublime Text 中编写 Python 之前,让我们先花点时间了解 JPEG 和 Exif 规范,这样我们可以避免一些更明显的陷阱,比如将图像损坏到解析器不尝试解析它并浪费宝贵的模糊测试周期。

从之前引用的规范概述中可以知道,所有 JPEG 图像都以字节值 0xFFD8 开头,以字节值 0xFFD9 结尾。这前几个字节被称为“magic bytes”。这使得在 *Nix 系统上可以直接进行文件类型识别。

1
2
root@kali:~# file Canon_40D.jpg
Canon_40D.jpg: JPEG image data, JFIF standard 1.01, resolution (DPI), density 72x72, segment length 16, Exif Standard: [TIFF image data, little-endian, direntries=11, manufacturer=Canon, model=Canon EOS 40D, orientation=upper-left, xresolution=166, yresolution=174, resolutionunit=2, software=GIMP 2.4.5, datetime=2008:07:31 10:38:11, GPS-Data], baseline, precision 8, 100x68, components 3

我们可以去掉 .jpg 扩展名,得到相同的输出。

1
2
root@kali:~# file Canon
Canon: JPEG image data, JFIF standard 1.01, resolution (DPI), density 72x72, segment length 16, Exif Standard: [TIFF image data, little-endian, direntries=11, manufacturer=Canon, model=Canon EOS 40D, orientation=upper-left, xresolution=166, yresolution=174, resolutionunit=2, software=GIMP 2.4.5, datetime=2008:07:31 10:38:11, GPS-Data], baseline, precision 8, 100x68, components 3

如果我们对图像进行十六进制转储,可以看到第一个和最后一个字节实际上是 0xFFD80xFFD9

1
2
3
4
root@kali:~# hexdump Canon
0000000 d8ff e0ff 1000 464a 4649 0100 0101 4800
------SNIP------
0001f10 5aed 5158 d9ff

在规范概述中,另一个有趣的信息是“标记”以 0xFF 开头。有几个已知的静态标记,例如:

  • “图像开始”(SOI)标记:0xFFD8
  • APP1 标记:0xFFE1
  • 通用标记:0xFFXX
  • “图像结束”(EOI)标记:0xFFD9

由于我们不想更改图像长度或文件类型,让我们尽量保持 SOI 和 EOI 标记不变。例如,我们不希望在图像中间插入 0xFFD9,因为这会截断图像或导致解析器以非崩溃的方式出现异常。“非崩溃”是一个真实的词。此外,这样的做法可能是错误的,也许我们应该随机地在字节流中插入 EOI 标记?让我们看看。

启动我们的模糊测试器

我们首先需要做的是从我们要用作“有效”输入样本的 JPEG 中提取所有字节,当然我们会对其进行变异。

我们的代码将如下开始:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#!/usr/bin/env python3
import sys
 
# read bytes from our valid JPEG and return them in a mutable bytearray
def get_bytes(filename):
 
    f = open(filename, "rb").read()
 
    return bytearray(f)
 
if len(sys.argv) < 2:
    print("Usage: JPEGfuzz.py <valid_jpg>")
 
else:
    filename = sys.argv[1]
    data = get_bytes(filename)

如果我们想看看这些数据的样子,可以打印数组中的前 10 个左右的字节值,看看我们将如何与它们交互。我们可以临时添加类似以下的代码:

1
2
3
4
5
6
7
8
else:
    filename = sys.argv[1]
    data = get_bytes(filename)
    counter = 0
    for x in data:
        if counter < 10:
            print(x)
        counter += 1

运行这段代码显示我们在处理整齐转换的十进制整数,这让我觉得一切都变得更加容易。

1
2
3
4
5
6
7
8
9
10
11
root@kali:~# python3 fuzzer.py Canon_40D.jpg
255
216
255
224
0
16
74
70
73
70

让我们快速验证一下是否可以从我们的字节数组创建一个新的有效 JPEG 文件。我们会将这个函数添加到代码中并运行它。

1
2
3
4
5
def create_new(data):
 
    f = open("mutated.jpg", "wb+")
    f.write(data)
    f.close()

现在我们在目录中有了一个 mutated.jpg 文件,让我们对两个文件进行哈希处理,看看它们是否匹配。

1
2
3
root@kali:~# shasum Canon_40D.jpg mutated.jpg
c3d98686223ad69ea29c811aaab35d343ff1ae9e  Canon_40D.jpg
c3d98686223ad69ea29c811aaab35d343ff1ae9e  mutated.jpg

太棒了,我们有两个相同的文件。现在我们可以在创建 mutated.jpg 之前开始变异数据了。

变异

我们将保持我们的模糊测试器相对简单,并只实现两种不同的变异方法。这些方法是:

  • 位翻转
  • 用 Gynvael 的“Magic Numbers”覆盖字节序列

让我们从位翻转开始。255(或 0xFF)在二进制中是 11111111,如果我们随机翻转这个数字中的一个位,例如在索引号 2 处,我们会得到 11011111。这个新数字将是 2230xDF

我不完全确定这种变异方法与从 0255 随机选择一个值并用它覆盖一个随机字节有多大的不同。我的直觉告诉我,位翻转与随机用任意字节覆盖字节非常相似。

让我们假设我们只想在 1% 的字节中翻转一个位。我们可以在 Python 中通过以下方式得到这个数字:

1
num_of_flips = int((len(data) - 4) * .01)

我们希望从 bytearray 的长度中减去 4,因为我们不希望计算数组中的前 2 个字节或最后 2 个字节,因为它们是 SOI 和 EOI 标记,我们希望保持这些标记不变。

接下来,我们需要随机选择这么多的索引,并针对这些索引进行位翻转。我们将创建一个可以更改的可能索引范围,然后选择 num_of_flips 个索引进行随机位翻转。

1
2
3
4
5
6
7
8
9
indexes = range(4, (len(data) - 4))
 
chosen_indexes = []
 
# iterate selecting indexes until we've hit our num_of_flips number
counter = 0
while counter < num_of_flips:
    chosen_indexes.append(random.choice(indexes))
    counter += 1

让我们在脚本中添加 import random,并添加这些调试打印语句,以确保一切正常工作。

1
2
print("Number of indexes chosen: " + str(len(chosen_indexes)))
print("Indexes chosen: " + str(chosen_indexes))

我们现在的函数看起来像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def bit_flip(data):
 
    num_of_flips = int((len(data) - 4) * .01)
 
    indexes = range(4, (len(data) - 4))
 
    chosen_indexes = []
 
    # iterate selecting indexes until we've hit our num_of_flips number
    counter = 0
    while counter < num_of_flips:
        chosen_indexes.append(random.choice(indexes))
        counter += 1
 
    print("Number of indexes chosen: " + str(len(chosen_indexes)))
    print("Indexes chosen: " + str(chosen_indexes))

如果我们运行这个函数,我们会得到一个预期的漂亮输出:

1
2
3
root@kali:~# python3 fuzzer.py Canon_40D.jpg
Number of indexes chosen: 79
Indexes chosen: [6580, 930, 6849, 6007, 5020, 33, 474, 4051, 7722, 5393, 3540, 54, 5290, 2106, 2544, 1786, 5969, 5211, 2256, 510, 7147, 3370, 625, 5845, 2082, 2451, 7500, 3672, 2736, 2462, 5395, 7942, 2392, 1201, 3274, 7629, 5119, 1977, 2986, 7590, 1633, 4598, 1834, 445, 481, 7823, 7708, 6840, 1596, 5212, 4277, 3894, 2860, 2912, 6755, 3557, 3535, 3745, 1780, 252, 6128, 7187, 500, 1051, 4372, 5138, 3305, 872, 6258, 2136, 3486, 5600, 651, 1624, 4368, 7076, 1802, 2335, 3553]

接下来我们需要实际对这些索引处的字节进行变异。我们需要对它们进行位翻转。我选择了一种非常简单的方法来实现这一点,您可以自由实现自己的解决方案。我们将把这些索引处的字节转换为二进制字符串,并填充它们,使其长度为 8 位。让我们添加这段代码,看看我在说什么。我们将把字节值(记住是十进制的)转换为二进制字符串,然后如果长度小于 8 位,则用前导零填充。最后一行是用于调试的临时打印语句。

1
2
3
4
for x in chosen_indexes:
        current = data[x]
        current = (bin(current).replace("0b",""))
        current = "0" * (8 - len(current)) + current

如您所见,我们得到了漂亮的二进制数字字符串输出。

1
2
3
4
5
6
7
8
9
root@kali:~# python3 fuzzer.py Canon_40D.jpg
10100110
10111110
10010010
00110000
01110001
00110101
00110010
-----SNIP-----

现在对于每一个,我们将随机选择一个索引并翻转它。以第一个 10100110 为例,如果选择索引 0,我们有一个 1,我们将其翻转为 0

最后要考虑的是,这些是字符串而不是整数。所以我们需要做的最后一件事是将翻转后的二进制字符串转换为整数。

我们将创建一个空列表,将每个数字添加到列表中,翻转我们随机选择的数字,然后从所有列表成员构造一个新的字符串。(我们必须使用这个中间列表步骤,因为字符串是不可变的)。最后,我们将其转换为整数,并将数据返回到我们的 create_new() 函数,以创建一个新的 JPEG。

我们的脚本现在总体上看起来是这样的:

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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
#!/usr/bin/env python3
 
import sys
import random
 
# read bytes from our valid JPEG and return them in a mutable bytearray
def get_bytes(filename):
 
    f = open(filename, "rb").read()
 
    return bytearray(f)
 
def bit_flip(data):
 
    num_of_flips = int((len(data) - 4) * .01)
 
    indexes = range(4, (len(data) - 4))
 
    chosen_indexes = []
 
    # iterate selecting indexes until we've hit our num_of_flips number
    counter = 0
    while counter < num_of_flips:
        chosen_indexes.append(random.choice(indexes))
        counter += 1
 
    for x in chosen_indexes:
        current = data[x]
        current = (bin(current).replace("0b",""))
        current = "0" * (8 - len(current)) + current
         
        indexes = range(0,8)
 
        picked_index = random.choice(indexes)
 
        new_number = []
 
        # our new_number list now has all the digits, example: ['1', '0', '1', '0', '1', '0', '1', '0']
        for i in current:
            new_number.append(i)
 
        # if the number at our randomly selected index is a 1, make it a 0, and vice versa
        if new_number[picked_index] == "1":
            new_number[picked_index] = "0"
        else:
            new_number[picked_index] = "1"
 
        # create our new binary string of our bit-flipped number
        current = ''
        for i in new_number:
            current += i
 
        # convert that string to an integer
        current = int(current,2)
 
        # change the number in our byte array to our new number we just constructed
        data[x] = current
 
    return data
 
 
# create new jpg with mutated data
def create_new(data):
 
    f = open("mutated.jpg", "wb+")
    f.write(data)
    f.close()
 
if len(sys.argv) < 2:
    print("Usage: JPEGfuzz.py <valid_jpg>")
 
else:
    filename = sys.argv[1]
    data = get_bytes(filename)
    mutated_data = bit_flip(data)
    create_new(mutated_data)

分析变异

如果我们运行脚本,可以对输出文件进行 shasum 并与原始 JPEG 进行比较。

1
2
3
root@kali:~# shasum Canon_40D.jpg mutated.jpg
c3d98686223ad69ea29c811aaab35d343ff1ae9e  Canon_40D.jpg
a7b619028af3d8e5ac106a697b06efcde0649249  mutated.jpg

这看起来很有希望,因为它们现在有不同的哈希值。我们可以通过使用名为 Beyond Comparebcompare 的程序进一步分析它们。我们将得到两个十六进制转储,其中差异部分会被突出显示。

正如你所见,仅在这一屏幕分享中,我们有 3 个不同的字节被翻转了位。原始文件在左边,变异样本在右边。

这种变异方法似乎有效。让我们继续实现第二种变异方法。

Gynvael 的 Magic Numbers

在前面提到的 GynvaelColdwind 的“模糊测试基础”直播中,他列举了几种可能对程序产生破坏性影响的“Magic Numbers”。通常,这些数字与数据类型大小和算术引发的错误有关。讨论的数字包括:

  • 0xFF
  • 0x7F
  • 0x00
  • 0xFFFF
  • 0x0000
  • 0xFFFFFFFF
  • 0x00000000
  • 0x80000000 <—- 最小的 32 位整数
  • 0x40000000 <—- 这个值的一半
  • 0x7FFFFFFF <—- 最大的 32 位整数

如果在 malloc() 或其他类型的操作过程中对这些类型的值执行任何算术运算,溢出是常见的。例如,如果在一个字节寄存器上将 0x1 加到 0xFF,它会滚动到 0x00,这可能是意外行为。HEVD 实际上有一个类似概念的整数溢出漏洞。

假设我们的模糊测试器选择了 0x7FFFFFFF 作为它想要使用的Magic Numbers,该值是 4 个字节长,所以我们需要在数组中找到一个字节索引,并覆盖该字节及其后的三个字节。让我们开始在我们的模糊测试器中实现这一点。

实现变异方法 #2

首先,我们需要像 Gynvael 那样创建一个元组列表,其中元组的第一个数字是Magic Numbers的字节大小,第二个数字是第一个字节的十进制值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
def magic(data):
 
    magic_vals = [
    (1, 255),
    (1, 255),
    (1, 127),
    (1, 0),
    (2, 255),
    (2, 0),
    (4, 255),
    (4, 0),
    (4, 128),
    (4, 64),
    (4, 127)
    ]
 
    picked_magic = random.choice(magic_vals)
 
    print(picked_magic)

如果我们运行这个脚本,可以看到它随机选择一个Magic Numbers元组。

1
2
3
4
5
6
7
8
9
10
root@kali:~# python3 fuzzer.py Canon_40D.jpg
(4, 64)
root@kali:~# python3 fuzzer.py Canon_40D.jpg
(4, 128)
root@kali:~# python3 fuzzer.py Canon_40D.jpg
(4, 0)
root@kali:~# python3 fuzzer.py Canon_40D.jpg
(2, 255)
root@kali:~# python3 fuzzer.py Canon_40D.jpg
(4, 0)

现在我们需要在 JPEG 文件中用这个新的 1 到 4 字节的Magic Numbers值覆盖一个随机的 1 到 4 字节的值。我们将像之前的方法一样设置可能的索引,选择一个索引,然后用我们picked_magic覆盖该索引处的字节。
例如,如果我们得到 (4, 128),我们知道它是 4 个字节长,Magic Numbers是 0x80000000。因此,我们会做类似以下的操作:

1
2
3
4
byte[x] = 128
byte[x+1] = 0
byte[x+2] = 0
byte[x+3] = 0

总体来说,我们的函数看起来像这样:

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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
def magic(data):
 
    magic_vals = [
    (1, 255),
    (1, 255),
    (1, 127),
    (1, 0),
    (2, 255),
    (2, 0),
    (4, 255),
    (4, 0),
    (4, 128),
    (4, 64),
    (4, 127)
    ]
 
    picked_magic = random.choice(magic_vals)
 
    length = len(data) - 8
    index = range(0, length)
    picked_index = random.choice(index)
 
    # here we are hardcoding all the byte overwrites for all of the tuples that begin (1, )
    if picked_magic[0] == 1:
        if picked_magic[1] == 255:          # 0xFF
            data[picked_index] = 255
        elif picked_magic[1] == 127:            # 0x7F
            data[picked_index] = 127
        elif picked_magic[1] == 0:          # 0x00
            data[picked_index] = 0
 
    # here we are hardcoding all the byte overwrites for all of the tuples that begin (2, )
    elif picked_magic[0] == 2:
        if picked_magic[1] == 255:          # 0xFFFF
            data[picked_index] = 255
            data[picked_index + 1] = 255
        elif picked_magic[1] == 0:          # 0x0000
            data[picked_index] = 0
            data[picked_index + 1] = 0
 
    # here we are hardcoding all of the byte overwrites for all of the tuples that being (4, )
    elif picked_magic[0] == 4:
        if picked_magic[1] == 255:          # 0xFFFFFFFF
            data[picked_index] = 255
            data[picked_index + 1] = 255
            data[picked_index + 2] = 255
            data[picked_index + 3] = 255
        elif picked_magic[1] == 0:          # 0x00000000
            data[picked_index] = 0
            data[picked_index + 1] = 0
            data[picked_index + 2] = 0
            data[picked_index + 3] = 0
        elif picked_magic[1] == 128:            # 0x80000000
            data[picked_index] = 128
            data[picked_index + 1] = 0
            data[picked_index + 2] = 0
            data[picked_index + 3] = 0
        elif picked_magic[1] == 64:         # 0x40000000
            data[picked_index] = 64
            data[picked_index + 1] = 0
            data[picked_index + 2] = 0
            data[picked_index + 3] = 0
        elif picked_magic[1] == 127:            # 0x7FFFFFFF
            data[picked_index] = 127
            data[picked_index + 1] = 255
            data[picked_index + 2] = 255
            data[picked_index + 3] = 255
         
    return data

分析变异 #2

现在运行我们的脚本并在 Beyond Compare 中分析结果,可以看到一个两个字节的值 0xA6 0x760xFF 0xFF 覆盖了。

这正是我们想要实现的目标。

开始模糊测试

现在我们有了两种可靠的数据变异方法,我们需要:

  • 使用我们的方法之一变异数据,
  • 用变异的数据创建新的图片,
  • 将变异后的图片输入到我们的二进制文件进行解析,
  • 捕获任何Segmentation faults并记录导致错误的图片。

受害者程序?

对于我们的受害者程序,我们将在 Google 上搜索 site:github.com "exif" language:c 以查找用 C 语言编写并引用了‘exif’的 Github 项目。

快速浏览一下,我们找到了这个项目:https://github.com/mkttanabe/exif。

我们可以通过 git 克隆该仓库,并按照 README 中包含的 gcc 构建说明进行安装。(为了方便,我将编译后的二进制文件放在 /usr/bin 中。)

让我们先看看该程序如何处理我们有效的 JPEG。

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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
root@kali:~# exif Canon_40D.jpg -verbose
system: little-endian
  data: little-endian
[Canon_40D.jpg] createIfdTableArray: result=5
 
{0TH IFD} tags=11
tag[00] 0x010F Make
        type=2 count=6 val=[Canon]
tag[01] 0x0110 Model
        type=2 count=14 val=[Canon EOS 40D]
tag[02] 0x0112 Orientation
        type=3 count=1 val=1
tag[03] 0x011A XResolution
        type=5 count=1 val=72/1
tag[04] 0x011B YResolution
        type=5 count=1 val=72/1
tag[05] 0x0128 ResolutionUnit
        type=3 count=1 val=2
tag[06] 0x0131 Software
        type=2 count=11 val=[GIMP 2.4.5]
tag[07] 0x0132 DateTime
        type=2 count=20 val=[2008:07:31 10:38:11]
tag[08] 0x0213 YCbCrPositioning
        type=3 count=1 val=2
tag[09] 0x8769 ExifIFDPointer
        type=4 count=1 val=214
tag[10] 0x8825 GPSInfoIFDPointer
        type=4 count=1 val=978
 
{EXIF IFD} tags=30
tag[00] 0x829A ExposureTime
        type=5 count=1 val=1/160
tag[01] 0x829D FNumber
        type=5 count=1 val=71/10
tag[02] 0x8822 ExposureProgram
        type=3 count=1 val=1
tag[03] 0x8827 PhotographicSensitivity
        type=3 count=1 val=100
tag[04] 0x9000 ExifVersion
        type=7 count=4 val=0 2 2 1
tag[05] 0x9003 DateTimeOriginal
        type=2 count=20 val=[2008:05:30 15:56:01]
tag[06] 0x9004 DateTimeDigitized
        type=2 count=20 val=[2008:05:30 15:56:01]
tag[07] 0x9101 ComponentsConfiguration
        type=7 count=4 val=0x01 0x02 0x03 0x00
tag[08] 0x9201 ShutterSpeedValue
        type=10 count=1 val=483328/65536
tag[09] 0x9202 ApertureValue
        type=5 count=1 val=368640/65536
tag[10] 0x9204 ExposureBiasValue
        type=10 count=1 val=0/1
tag[11] 0x9207 MeteringMode
        type=3 count=1 val=5
tag[12] 0x9209 Flash
        type=3 count=1 val=9
tag[13] 0x920A FocalLength
        type=5 count=1 val=135/1
tag[14] 0x9286 UserComment
        type=7 count=264 val=0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 (omitted)
tag[15] 0x9290 SubSecTime
        type=2 count=3 val=[00]
tag[16] 0x9291 SubSecTimeOriginal
        type=2 count=3 val=[00]
tag[17] 0x9292 SubSecTimeDigitized
        type=2 count=3 val=[00]
tag[18] 0xA000 FlashPixVersion
        type=7 count=4 val=0 1 0 0
tag[19] 0xA001 ColorSpace
        type=3 count=1 val=1
tag[20] 0xA002 PixelXDimension
        type=4 count=1 val=100
tag[21] 0xA003 PixelYDimension
        type=4 count=1 val=68
tag[22] 0xA005 InteroperabilityIFDPointer
        type=4 count=1 val=948
tag[23] 0xA20E FocalPlaneXResolution
        type=5 count=1 val=3888000/876
tag[24] 0xA20F FocalPlaneYResolution
        type=5 count=1 val=2592000/583
tag[25] 0xA210 FocalPlaneResolutionUnit
        type=3 count=1 val=2
tag[26] 0xA401 CustomRendered
        type=3 count=1 val=0
tag[27] 0xA402 ExposureMode
        type=3 count=1 val=1
tag[28] 0xA403 WhiteBalance
        type=3 count=1 val=0
tag[29] 0xA406 SceneCaptureType
        type=3 count=1 val=0
 
{Interoperability IFD} tags=2
tag[00] 0x0001 InteroperabilityIndex
        type=2 count=4 val=[R98]
tag[01] 0x0002 InteroperabilityVersion
        type=7 count=4 val=0 1 0 0
 
{GPS IFD} tags=1
tag[00] 0x0000 GPSVersionID
        type=1 count=4 val=2 2 0 0
 
{1ST IFD} tags=6
tag[00] 0x0103 Compression
        type=3 count=1 val=6
tag[01] 0x011A XResolution
        type=5 count=1 val=72/1
tag[02] 0x011B YResolution
        type=5 count=1 val=72/1
tag[03] 0x0128 ResolutionUnit
        type=3 count=1 val=2
tag[04] 0x0201 JPEGInterchangeFormat
        type=4 count=1 val=1090
tag[05] 0x0202 JPEGInterchangeFormatLength
        type=4 count=1 val=1378
 
0th IFD : Model = [Canon EOS 40D]
Exif IFD : DateTimeOriginal = [2008:05:30 15:56:01]

我们看到程序正在解析标签并显示与其关联的字节值。这正是我们想要找到的。

追踪段错误

理想情况下,我们希望向这个二进制文件输入一些变异数据并让它发生段错误,这意味着我们发现了一个漏洞。我遇到的问题是,当我监控 stdout 和 stderr 以获取Segmentation fault消息时,它从未出现。这是因为Segmentation fault消息来自我们的命令 shell 而不是二进制文件。这意味着 shell 收到了一个 SIGSEGV 信号,并响应打印了该消息。

我找到的一种监控方法是使用 pexpect Python 模块中的 run() 方法和 pipes Python 模块中的 quote() 方法。

我们将添加一个新函数,该函数将接收一个计数器参数,表示我们处于哪个模糊测试迭代中,还将接收另一个参数中的变异数据。如果我们在 run() 命令的输出中看到 Segmentation,我们将把变异数据写入文件并保存,以便我们拥有导致二进制文件崩溃的 JPEG 图像。

让我们创建一个名为 crashes 的新文件夹,并将导致崩溃的 JPEG 图像保存在该文件夹中,格式为 crash.<fuzzing iteration (counter)>.jpg。因此,如果模糊测试迭代 100 导致崩溃,我们应该得到一个类似 /crashes/crash.100.jpg 的文件。

我们将在终端的同一行上打印,以便每 100 次模糊测试迭代进行计数。我们的函数如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
def exif(counter,data):
 
    command = "exif mutated.jpg -verbose"
 
    out, returncode = run("sh -c " + quote(command), withexitstatus=1)
 
    if b"Segmentation" in out:
        f = open("crashes/crash.{}.jpg".format(str(counter)), "ab+")
        f.write(data)
 
    if counter % 100 == 0:
        print(counter, end="\r")

接下来,我们将在脚本底部修改我们的执行存根,使其在计数器上运行。一旦达到 1000 次迭代,我们将停止模糊测试。我们还将让我们的模糊测试器随机选择一种变异方法。因此,它可能会进行比特翻转,也可能会使用Magic Numbers。让我们运行它,然后在完成后检查我们的 crashes 文件夹。

模糊测试器完成后,你可以看到我们大约有 30 次崩溃!

1
2
3
4
5
6
7
root@kali:~/crashes# ls
crash.102.jpg  crash.317.jpg  crash.52.jpg   crash.620.jpg  crash.856.jpg
crash.129.jpg  crash.324.jpg  crash.551.jpg  crash.694.jpg  crash.861.jpg
crash.152.jpg  crash.327.jpg  crash.559.jpg  crash.718.jpg  crash.86.jpg
crash.196.jpg  crash.362.jpg  crash.581.jpg  crash.775.jpg  crash.984.jpg
crash.252.jpg  crash.395.jpg  crash.590.jpg  crash.785.jpg  crash.985.jpg
crash.285.jpg  crash.44.jpg   crash.610.jpg  crash.84.jpg   crash.987.jpg

我们现在可以使用一个简单的单行命令来确认结果:root@kali:~/crashes# for i in *.jpg; do exif "$i" -verbose > /dev/null 2>&1; done。记住,我们可以将 STDOUT 和 STDERR 都重定向到 /dev/null,因为“Segmentation fault”来自 shell,而不是二进制文件。

我们运行这个命令,输出如下:

1
2
3
4
5
6
7
8
9
root@kali:~/crashes# for i in *.jpg; do exif "$i" -verbose > /dev/null 2>&1; done
Segmentation fault
Segmentation fault
Segmentation fault
Segmentation fault
Segmentation fault
Segmentation fault
Segmentation fault
-----SNIP-----

你无法看到所有的崩溃,但确实有 30 次段错误,所以一切似乎都按计划进行!

分类崩溃

现在我们有大约 30 次崩溃和导致这些崩溃的 JPEG 文件,下一步是分析这些崩溃并确定它们中有多少是独特的。这时我们将利用我在观看 Brandon Faulk 的直播时学到的一些东西。快速查看 Beyond Compare 中的崩溃样本告诉我,大多数崩溃是由我们的 bit_flip() 变异引起的,而不是 magic() 变异方法。有趣。作为测试,在我们继续的过程中,我们可以关闭函数选择的随机性,并仅使用 magic() 变异器运行 100,000 次迭代,看看是否会有任何崩溃。

使用 ASan 分析崩溃

ASan 是“Address Sanitizer”,它是较新版本的 gcc 附带的一个工具,允许用户使用 -fsanitize=address 开关编译二进制文件,并在发生内存访问错误时获取非常详细的信息,甚至包括那些导致崩溃的错误。显然,我们在这里已经预先选择了导致崩溃的输入,所以我们将错过这个工具的部分功能,但或许我们可以留到下次使用。

为了使用 ASan,我按照 Fuzzing Project 的步骤,使用 cc -fsanitize=address -ggdb -o exifsan sample_main.c exif.c 命令重新编译了 exif

然后我将 exifsan 移动到 /usr/bin 以便于使用。如果我们在崩溃样本上运行这个新编译的二进制文件,让我们看看输出。

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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
root@kali:~/crashes# exifsan crash.252.jpg -verbose
system: little-endian
  data: little-endian
=================================================================
==18831==ERROR: AddressSanitizer: heap-buffer-overflow on address 0xb4d00758 at pc 0x00415b9e bp 0xbf8c91f8 sp 0xbf8c91ec
READ of size 4 at 0xb4d00758 thread T0                                                                                             
    #0 0x415b9d in parseIFD /root/exif/exif.c:2356
    #1 0x408f10 in createIfdTableArray /root/exif/exif.c:271
    #2 0x4076ba in main /root/exif/sample_main.c:63
    #3 0xb77d0ef0 in __libc_start_main ../csu/libc-start.c:308
    #4 0x407310 in _start (/usr/bin/exifsan+0x2310)
 
0xb4d00758 is located 0 bytes to the right of 8-byte region [0xb4d00750,0xb4d00758)
allocated by thread T0 here:                                                                                                       
    #0 0xb7aa2097 in __interceptor_malloc (/lib/i386-linux-gnu/libasan.so.5+0x10c097)
    #1 0x415a9f in parseIFD /root/exif/exif.c:2348
    #2 0x408f10 in createIfdTableArray /root/exif/exif.c:271
    #3 0x4076ba in main /root/exif/sample_main.c:63
    #4 0xb77d0ef0 in __libc_start_main ../csu/libc-start.c:308
 
SUMMARY: AddressSanitizer: heap-buffer-overflow /root/exif/exif.c:2356 in parseIFD
Shadow bytes around the buggy address:
  0x369a0090: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x369a00a0: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x369a00b0: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x369a00c0: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x369a00d0: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
=>0x369a00e0: fa fa fa fa fa fa fa fa fa fa 00[fa]fa fa 04 fa
  0x369a00f0: fa fa 00 06 fa fa 06 fa fa fa fa fa fa fa fa fa
  0x369a0100: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x369a0110: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x369a0120: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x369a0130: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
Shadow byte legend (one shadow byte represents 8 application bytes):
  Addressable:           00
  Partially addressable: 01 02 03 04 05 06 07
  Heap left redzone:       fa
  Freed heap region:       fd
  Stack left redzone:      f1
  Stack mid redzone:       f2
  Stack right redzone:     f3
  Stack after return:      f5
  Stack use after scope:   f8
  Global redzone:          f9
  Global init order:       f6
  Poisoned by user:        f7
  Container overflow:      fc
  Array cookie:            ac
  Intra object redzone:    bb
  ASan internal:           fe
  Left alloca redzone:     ca
  Right alloca redzone:    cb
  Shadow gap:              cc
==18831==ABORTING

这真是太好了。我们不仅得到了详细的信息,ASan 还为我们分类了错误类型,告诉我们崩溃地址,并提供了一个漂亮的堆栈跟踪。如你所见,我们在 exif.c 文件的 parseIFD 函数中执行了一个 4 字节的读取操作。

1
2
3
4
5
6
READ of size 4 at 0xb4d00758 thread T0                                                                                             
    #0 0x415b9d in parseIFD /root/exif/exif.c:2356
    #1 0x408f10 in createIfdTableArray /root/exif/exif.c:271
    #2 0x4076ba in main /root/exif/sample_main.c:63
    #3 0xb77d0ef0 in __libc_start_main ../csu/libc-start.c:308
    #4 0x407310 in _start (/usr/bin/exifsan+0x2310)

由于这些现在都是标准的二进制输出,我们实际上可以对这些崩溃进行分类并尝试理解它们。让我们首先尝试去重这些崩溃。这里有可能我们所有的 30 次崩溃都是同一个错误。也有可能我们有 30 个独特的崩溃(不太可能,哈哈)。所以我们需要整理清楚。

让我们再次求助于一个 Python 脚本,我们将遍历这个文件夹,针对每个崩溃运行启用 ASan 的二进制文件,并记录每个崩溃的地址。我们还会尝试捕捉它是 'READ' 还是 'WRITE' 操作。例如,对于 crash.252.jpg,我们将日志文件格式化为:crash.252.HBO.b4f00758.READ,并将 ASan 输出写入日志。这样我们就知道导致崩溃的图像、错误类型、地址和操作,而无需打开日志。(我会在最后发布分类脚本,它真的很糟糕,呃,我讨厌它。)

在我们的crashes文件夹上运行分类脚本后,我们现在可以看到我们已经对崩溃进行了分类,并且有一些非常有趣的发现。

1
2
3
4
5
6
7
8
9
10
11
crash.102.HBO.b4f006d4.READ
crash.102.jpg
crash.129.HBO.b4f005dc.READ
crash.129.jpg
crash.152.HBO.b4f005dc.READ
crash.152.jpg
crash.317.HBO.b4f005b4.WRITE
crash.317.jpg
crash.285.SEGV.00000000.READ
crash.285.jpg
------SNIP-----

经过一大段省略后,在我的 30 次崩溃中,我只有一个是 WRITE 操作。你无法从省略的输出中看出,但我也有很多 SEGV 错误,其中引用了 NULL 地址(0x00000000)。

让我们也检查一下仅使用 magic() 变异器运行了 100,000 次迭代的修改后的模糊测试器,看看它是否发现了任何错误。
这真是大量的崩溃!

认真起来,总结

这个模糊测试器可以进行大量优化,目前它真的很粗糙,只是为了演示非常基础的变异模糊测试。错误分类过程也是一团糟,整个过程感觉非常临时,我想我需要多看一些 @gamozolabs 的直播。也许下次我们进行模糊测试时,我们会尝试一个更难的目标,用 Rust 或 Go 这样的酷语言编写模糊测试器,并真正改进分类过程/利用其中一个错误!

感谢博客文章中提到的每一个人,非常感谢。

下次见!

代码

JPEGfuzz.py

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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
#!/usr/bin/env python3
 
import sys
import random
from pexpect import run
from pipes import quote
 
# read bytes from our valid JPEG and return them in a mutable bytearray
def get_bytes(filename):
 
    f = open(filename, "rb").read()
 
    return bytearray(f)
 
def bit_flip(data):
 
    num_of_flips = int((len(data) - 4) * .01)
 
    indexes = range(4, (len(data) - 4))
 
    chosen_indexes = []
 
    # iterate selecting indexes until we've hit our num_of_flips number
    counter = 0
    while counter < num_of_flips:
        chosen_indexes.append(random.choice(indexes))
        counter += 1
 
    for x in chosen_indexes:
        current = data[x]
        current = (bin(current).replace("0b",""))
        current = "0" * (8 - len(current)) + current
         
        indexes = range(0,8)
 
        picked_index = random.choice(indexes)
 
        new_number = []
 
        # our new_number list now has all the digits, example: ['1', '0', '1', '0', '1', '0', '1', '0']
        for i in current:
            new_number.append(i)
 
        # if the number at our randomly selected index is a 1, make it a 0, and vice versa
        if new_number[picked_index] == "1":
            new_number[picked_index] = "0"
        else:
            new_number[picked_index] = "1"
 
        # create our new binary string of our bit-flipped number
        current = ''
        for i in new_number:
            current += i
 
        # convert that string to an integer
        current = int(current,2)
 
        # change the number in our byte array to our new number we just constructed
        data[x] = current
 
    return data
 
def magic(data):
 
    magic_vals = [
    (1, 255),
    (1, 255),
    (1, 127),
    (1, 0),
    (2, 255),
    (2, 0),
    (4, 255),
    (4, 0),
    (4, 128),
    (4, 64),
    (4, 127)
    ]
 
    picked_magic = random.choice(magic_vals)
 
    length = len(data) - 8
    index = range(0, length)
    picked_index = random.choice(index)
 
    # here we are hardcoding all the byte overwrites for all of the tuples that begin (1, )
    if picked_magic[0] == 1:
        if picked_magic[1] == 255:          # 0xFF
            data[picked_index] = 255
        elif picked_magic[1] == 127:        # 0x7F
            data[picked_index] = 127
        elif picked_magic[1] == 0:          # 0x00
            data[picked_index] = 0
 
    # here we are hardcoding all the byte overwrites for all of the tuples that begin (2, )
    elif picked_magic[0] == 2:
        if picked_magic[1] == 255:          # 0xFFFF
            data[picked_index] = 255
            data[picked_index + 1] = 255
        elif picked_magic[1] == 0:          # 0x0000
            data[picked_index] = 0
            data[picked_index + 1] = 0
 
    # here we are hardcoding all of the byte overwrites for all of the tuples that being (4, )
    elif picked_magic[0] == 4:
        if picked_magic[1] == 255:          # 0xFFFFFFFF
            data[picked_index] = 255
            data[picked_index + 1] = 255
            data[picked_index + 2] = 255
            data[picked_index + 3] = 255
        elif picked_magic[1] == 0:          # 0x00000000
            data[picked_index] = 0
            data[picked_index + 1] = 0
            data[picked_index + 2] = 0
            data[picked_index + 3] = 0
        elif picked_magic[1] == 128:        # 0x80000000
            data[picked_index] = 128
            data[picked_index + 1] = 0
            data[picked_index + 2] = 0
            data[picked_index + 3] = 0
        elif picked_magic[1] == 64:         # 0x40000000
            data[picked_index] = 64
            data[picked_index + 1] = 0
            data[picked_index + 2] = 0
            data[picked_index + 3] = 0
        elif picked_magic[1] == 127:        # 0x7FFFFFFF
            data[picked_index] = 127
            data[picked_index + 1] = 255
            data[picked_index + 2] = 255
            data[picked_index + 3] = 255
         
    return data
 
# create new jpg with mutated data
def create_new(data):
 
    f = open("mutated.jpg", "wb+")
    f.write(data)
    f.close()
 
def exif(counter,data):
 
    command = "exif mutated.jpg -verbose"
 
    out, returncode = run("sh -c " + quote(command), withexitstatus=1)
 
    if b"Segmentation" in out:
        f = open("crashes2/crash.{}.jpg".format(str(counter)), "ab+")
        f.write(data)
 
    if counter % 100 == 0:
        print(counter, end="\r")
 
if len(sys.argv) < 2:
    print("Usage: JPEGfuzz.py <valid_jpg>")
 
else:
    filename = sys.argv[1]
    counter = 0
    while counter < 100000:
        data = get_bytes(filename)
        functions = [0, 1]
        picked_function = random.choice(functions)
        if picked_function == 0:
            mutated = magic(data)
            create_new(mutated)
            exif(counter,mutated)
        else:
            mutated = bit_flip(data)
            create_new(mutated)
            exif(counter,mutated)
 
        counter += 1

triage.py

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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
#!/usr/bin/env python3
 
import os
from os import listdir
 
def get_files():
 
    files = os.listdir("/root/crashes/")
 
    return files
 
def triage_files(files):
 
    for x in files:
 
        original_output = os.popen("exifsan " + x + " -verbose 2>&1").read()
        output = original_output
         
        # Getting crash reason
        crash = ''
        if "SEGV" in output:
            crash = "SEGV"
        elif "heap-buffer-overflow" in output:
            crash = "HBO"
        else:
            crash = "UNKNOWN"
         
 
        if crash == "HBO":
            output = output.split("\n")
            counter = 0
            while counter < len(output):
                if output[counter] == "=================================================================":
                    target_line = output[counter + 1]
                    target_line2 = output[counter + 2]
                    counter += 1
                else:
                    counter += 1
            target_line = target_line.split(" ")
            address = target_line[5].replace("0x","")
             
 
            target_line2 = target_line2.split(" ")
            operation = target_line2[0]
             
 
        elif crash == "SEGV":
            output = output.split("\n")
            counter = 0
            while counter < len(output):
                if output[counter] == "=================================================================":
                    target_line = output[counter + 1]
                    target_line2 = output[counter + 2]
                    counter += 1
                else:
                    counter += 1
            if "unknown address" in target_line:
                address = "00000000"
            else:
                address = None
 
            if "READ" in target_line2:
                operation = "READ"
            elif "WRITE" in target_line2:
                operation = "WRITE"
            else:
                operation = None
 
        log_name = (x.replace(".jpg","") + "." + crash + "." + address + "." + operation)
        f = open(log_name,"w+")
        f.write(original_output)
        f.close()
 
 
 
files = get_files()
triage_files(files)

译者言

文章目前一共有6篇,我会陆续翻译
内容使用chatGPT-4o翻译,如有错误之处请斧正
原文链接:https://h0mbre.github.io/Fuzzing-Like-A-Caveman/


[课程]Android-CTF解题方法汇总!

最后于 2024-9-2 21:10 被pureGavin编辑 ,原因: 修改内容
收藏
免费 0
支持
分享
最新回复 (0)
游客
登录 | 注册 方可回帖
返回
//