Lucid梦境II:nftables模糊测试工具链开发全解析

本文详细介绍了为Linux内核nftables子系统开发模糊测试工具链的技术细节,包括自定义系统调用实现、内核模块初始化、输入格式设计、Netlink消息处理机制以及快照管理策略等核心内容。

背景

在上篇博客中,我们浅显地探讨了模糊测试多个Netlink子系统(如Netfilter、Route、Crypto和Xfrm)的方法。这并非认真的漏洞挖掘任务,主要是想了解使用Lucid模糊测试真实目标的效果以及需要调整的地方。我们最终修改了大量核心模糊测试器特性,特别是Redqueen相关问题,显著改进了模糊测试器。我们将Lucid的变异器组件模块化,现在为Lucid编写自己的模糊测试器只需实现自己的变异器即可。我们还可以进一步扩展这一点,让用户能够直接通过命令行参数传递给定制变异器。

现在你可以将主要Lucid核心组件视为模糊测试引擎,而变异器则是"模糊测试器",因为它负责所有目标特定的特性。例如,如果我们要在Lucid中模糊测试Chrome,你可以通过为Chrome实现自己的模糊测试工具链,然后实现自己的变异器来生成和变异输入,从而编写一个"Chrome模糊测试器"。

我们现在转向更认真的漏洞挖掘操作模式。我决定在本系列中专注于模糊测试nftables,原因如下:

  • nftables不再有那么多关注,至少公开如此,因为kCTF改变了关于非特权用户命名空间的规则,严重降低了在这些命名空间后面的可利用漏洞的价值,因此竞争较少
  • nftables极其复杂。存在多个分层结构和可能出现的状态,此外,代码存在于两个层面:负责创建这些嵌套复杂资源的配置平面和负责与这些创建结构交互的数据平面。在早期阶段,我们将专注于控制平面,计划稍后实现数据平面交互
  • nftables有漏洞历史,以至于在kCTF的赏金计划中明确禁用了它
  • Syzkaller已经可以模糊测试nftables,但如果你查看它能够生成的消息类型,它倾向于语法有效但语义无效的输入。例如,它会发送格式良好的消息来创建资源,但参数值本身可能是无意义的。此外,syzkaller目前无法跟踪资源成功创建后的状态
  • 最后,这代表了一个有趣的工程挑战。创建一个能够实现nftables深度有状态覆盖的变异器/生成器,就公开研究而言将是独特的

添加自定义系统调用

首先我们需要一种与nftables子系统交互的方式。我的首选策略是创建一个通常接受用户态缓冲区指针和数据长度的自定义系统调用。这使我们能够从用户态发送输入,让其遍历工具链,然后命中目标子系统。这不是我想要模糊测试的方式,但对于调试、收集覆盖率指标以进行可视化以及复现崩溃很有用。理想情况下流程如下:

  1. 通过系统调用发送数据缓冲区
  2. 上下文切换到内核模式,工具链即将解析输入
  3. [仅模糊测试] 拍摄快照
  4. 工具链解析输入并分派到目标子系统
  5. [仅模糊测试] 重置快照
  6. 返回到用户态

这种设置让我们两全其美,我们可以轻松地从用户态调试和测试工具链,也可以完全在内核上下文中进行模糊测试,而无需为每个测试用例模拟昂贵的内容切换。

要添加新的系统调用,我们必须编辑syscall_64.tbl文件,在最后一个系统调用条目后添加新条目:

1
2
3
4
5
6
...
466	common	removexattrat		sys_removexattrat
467	common	open_tree_attr		sys_open_tree_attr
468	common	file_getattr		sys_file_getattr
469	common	file_setattr		sys_file_setattr
470 common  lucid_fuzz          sys_lucid_fuzz

现在我们必须在linux_version/include/linux/syscalls.h文件中定义它:

1
2
3
4
5
...
asmlinkage long sys_geteuid16(void);
asmlinkage long sys_getgid16(void);
asmlinkage long sys_getegid16(void);
asmlinkage long sys_lucid_fuzz(const void __user *data, size_t len);

因为我们想要模糊测试nftables,我决定在名为lucid_fuzz.c的新文件中实现系统调用本身,并将其放在linux_version/net/netfilter文件夹中:

1
2
3
4
5
6
7
8
9
#include <linux/kernel.h>
#include <linux/syscalls.h>
#include <linux/uaccess.h>

SYSCALL_DEFINE2(lucid_fuzz, const void __user *, data, size_t, len)
{
    printk("Inside lucid fuzz!\n");
	return 0;
}

现在我们必须告诉内核编译这个源文件。这是通过编辑文件夹的Makefile来完成的,以确保我们的lucid_fuzz.c文件用于创建目标文件。我将内核版本6.17中的Makefile顶行更改为:

1
netfilter-objs := core.o nf_log.o nf_queue.o nf_sockopt.o utils.o lucid_fuzz.o

当我们构建内核时,应该在输出中看到:

1
CC      net/netfilter/lucid_fuzz.o

要与系统调用交互,我们需要一个用户态程序。这是一个小型程序,从标准输入读取数据(将来易于用于复现崩溃或重放模糊测试输入),然后通过系统调用将数据发送到内核:

  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
// gcc harness.c -o harness -static
#define _GNU_SOURCE
#include <unistd.h>
#include <sys/syscall.h>
#include <stdio.h>
#include <errno.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#include <limits.h>

#ifndef __NR_lucid_fuzz
#define __NR_lucid_fuzz 470 // Our syscall number
#endif

int main(void) {
    // Start at a page, we'll double this if we need more memory
    size_t cap = 4096;
    size_t len = 0;
    const size_t MAX_CAP = 64 * 1024 * 1024; // Shouldn't need more than this?

    // Create a buffer to hold data
    uint8_t *buf = malloc(cap);
    if (!buf) {
        perror("malloc");
        return 1;
    }

    // Read until we can't
    while (1) {
        // Grab data from standard in, taking into account the offset as determined
        // by `len`
        ssize_t n = read(STDIN_FILENO, buf + len, cap - len);

        // If we got bytes...
        if (n > 0) {
            // Adjust offset
            len += (size_t)n;

            // See if we hit the current cap
            if (len == cap) {

                // Hit sanity check, bail
                if (cap >= MAX_CAP) {
                    fprintf(stderr, "refusing to grow beyond %zu bytes\n", MAX_CAP);
                    free(buf);
                    return 1;
                }

                // Create new backing buffer
                size_t ncap = cap * 2;

                // Lol 
                if (ncap <= cap) {
                    fprintf(stderr, "size overflow\n");
                    free(buf);
                    return 1;
                }

                // Make sure we didn't do an oopsie
                if (ncap > MAX_CAP) ncap = MAX_CAP;
                uint8_t *tmp = realloc(buf, ncap);
                if (!tmp) {
                    perror("realloc");
                    free(buf);
                    return 1;
                }

                // Update 
                buf = tmp;
                cap = ncap;
            }
            continue;
        }

        // Done reading: EOF
        if (n == 0) break;

        // Failed to read but just because of an interrupt, try again
        if (n < 0 && errno == EINTR) continue;
        
        // Bail on any other errors
        if (n < 0) {
            perror("read");
            free(buf);
            return 1;
        }
    }

    // Call our custom syscall 
    long ret = syscall(__NR_lucid_fuzz, buf, (size_t)len);

    // Need to make sure that our syscall returns meaningful data on error
    if (ret == -1) {
        int e = errno;
        fprintf(stderr, "lucid_fuzz failed: %s\n", strerror(e));
        free(buf);
        return 1;
    }

    printf("lucid_fuzz returned %ld\n", ret);
    free(buf);
    return 0;
}

现在我们可以在qemu-system中测试:

1
2
3
4
root@syzkaller:~# echo "lol" | harness
[  256.492957] Inside lucid fuzz!
lucid_fuzz returned 0
root@syzkaller:~# 

所以系统调用一切正常,现在是时候让它成为真正的模糊测试工具链了。

决定输入格式

我们希望能够为nftables创建有状态的输入。这显然意味着我们需要在输入中有足够的初始空间来构建复杂状态!这看起来简单明显,但我认为正确实现实际上很困难。我们必须考虑各种事情:

  • 并非所有"状态"都是"好状态":仅仅因为输入可以创建4096个nft_table数据结构,并不意味着从漏洞研究的角度来看这很有趣
  • 短输入不太可能创建复杂状态:我们需要有一定长度的输入来构建状态
  • 极大的输入可能毫无意义:当短输入足够长以创建"好状态"时,短输入和长输入之间可能没有有意义的区别,我们可能最终花费大量CPU周期做无趣的事情和处理巨大的输入

考虑到这些事情,让我们首先采取谨慎的方法,确保我们有时可以生成长输入,但大多数时间专注于相对正常大小的输入。

nftables消息

nftables期望以某种方式格式化的Netlink消息。据我所知,它有两种消息模式:独立消息,这是简单的消息,如"对象获取器";批处理消息,用于对象创建/修改/删除。他们采用了一种设计,任何可以修改状态的东西都要进行批处理,而所有只读的东西可以是独立消息。在批处理操作模式下,nftables将有一个类似"暂存"的阶段,在那里解析批处理中的消息并验证它们。在验证每个单独的批处理消息时,它确保正在创建/操作的资源是合理的、实际存在且可修改的。nftables将暂存所有更改,然后如果批处理中的单个消息失败,将尝试回滚所有这些暂存的更改。然而,如果批处理消息解析成功,它将进入"提交"阶段并进行更改。

所以基本上,我们的输入生成器需要能够发送nftables请求批次,偶尔撒入一些简单的只读请求。我决定遵循与上一篇博客文章类似的高级输入形状。我们将执行以下操作:

  • 让Lucid在Bochs内存中的某个位置注入字节缓冲区。这是标准的,也是你希望分离Lucid模糊测试引擎和Lucid变异器/生成器职责的方式。让Lucid模糊测试引擎注入字节blob,让工具链/变异器/生成器理解这个blob
  • 我们将在内核中预分配套接字缓冲区结构skb,这样我们就不会在模糊测试循环中进行任何大的分配
  • 工具链将解析字节blob,并将变异器中的每个输入系列打包到skb中,然后将skb发送到nftables进行解析
  • 我们将把nftables消息系列分成所谓的"信封"。上一篇博客文章中我们称它们为"消息",但因为Netlink也操作"消息",这个命名令人困惑

然后我们的输入将包含两个不同的数据结构,如工具链所见:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// 输入结构
struct lf_input {
	u32 total_len;
	u32 num_envs;
	u8 data[];
};

// 信封结构
struct lf_envelope {
	u32 len;
	u8 data[];
};

这与我们上一篇博客文章非常相似,但对信封结构进行了一些关键更改。实际上,输入将始终在其开头有一个struct lf_input结构,描述整个输入,然后是最多最大数量的信封结构,其中包含实际的nftables Netlink消息在其数据成员中。所以输入可能看起来像:

1
2
3
4
5
[
	[lf_input: total_len=4096, num_msgs=2]
		[lf_envelope: len=2048, <data>]
		[lf_envelope: len=2048, <data>]
]

记住:核心Lucid组件对此结构一无所知,Lucid只负责将输入及其长度注入到目标内存中的某个位置。由变异器和工具链来理解这个结构。

所以现在让我们基于这个实现工具链。它将需要接收字节,解析它们,将每个信封的数据包装在skb中,并将skb发送到nftables。

到达nftables

用户输入到达nftables的正常路径类似于:

  1. 用户态进程创建NETLINK_NETFILTER Netlink套接字
  2. 用户态进程通过sendmsg系统调用或类似方式(可能是sendto)通过Netlink套接字发送请求
  3. 这些字节在netlink_sendmsg中被包装在skb中
  4. 基于套接字的协议类型,netlink_sendmsg将找到在内核启动时注册的Netfilter的内核套接字,该套接字附加了一个名为.input的回调,当有数据准备好时调用
  5. 指向nfnetlink_rcv的回调被调用,并接收保存我们从用户态数据的skb

我们可以做类似的事情,但使其更直接,因为我们知道工具链中的目的地是nftables。我们可以:

  1. 预分配skb结构来保存我们的信封
  2. 解析lf_input,并按包含的lf_envelope:
  3. 将信封的数据填入skb
  4. 直接将skb发送到nfnetlink_rcv
  5. 重复,回到第3步

工具链初始化代码

让我们继续填写自定义系统调用的初始化例程逻辑,这是在开始模糊测试之前调用一次的代码,不会在模糊测试循环中发生。这段代码旨在设置工具链正常工作所需的一切。这是我们将设置skb的地方,为此,我们需要定义一些描述最大输入形状的常量。我们需要设置的第一个常量是MAX_NUM_ENVELOPES,这将告诉我们一个struct lf_input中可以存在多少个struct lf_envelope结构。我们还需要知道MAX_ENVELOPE_LEN,这显然将描述这些信封的数据负载可以有多大。最后,作为最大信封结构数量及其最大长度的副产品,我们将推导出MAX_INPUT_LEN,这是lf_input->total_len值可以达到的最大可能大小。

现在,让我们继续说我们最多可以有24个信封,每个最多8192字节。在变异器中,我们将定义最小/最大阈值,我们主要在这两个阈值之间均匀分布大小选择,有很小的可能性低于或高于它们。所以大多数时候我们至少做8个信封,少于或正好16个信封。类似这样的事情。我们将使1-7和17-24非常罕见。大小也是如此,我们尽量不发送每个信封的疯狂数量的nftables消息并接近8k最大值。但这是以后关于变异器的博客文章的内容。

考虑到这些常量,我们可以构建。我们可以在/net/netlink中的af_netlink.c中完成所有这些,因为它拥有我们需要访问的所有东西,并使一切变得简单。所以我们将在那里实现lf_init,这意味着我们需要在我们的独立源文件lucid_fuzz.c中访问lucid_init,所以我们将其更改为:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
#include <linux/kernel.h>
#include <linux/syscalls.h>
#include <linux/uaccess.h>

// These will be defined in /include/net/lucid_fuzz.h
extern int lucid_fuzz_init(const void __user *data, size_t len);

SYSCALL_DEFINE2(lucid_fuzz, const void __user *, data, size_t, len)
{
    int ret = 0;

    printk("Inside lucid fuzz!\n");
    printk("Calling lucid_fuzz_init...\n");
    ret = lucid_fuzz_init(data, len);
    if (ret)
        goto done;

done:
	return ret;
}

现在我们需要在/include/net/lucid_fuzz.h中创建那个头文件:

1
2
3
4
5
6
7
/* SPDX-License-Identifier: GPL-2.0 */
#ifndef _NET_LUCID_FUZZ_H
#define _NET_LUCID_FUZZ_H

int lucid_fuzz_init(const void __user *data, size_t len);

#endif /* _NET_LUCID_FUZZ_H */

现在我们可以在af_netlink.c中包含那个头文件。我们开始在该源文件中定义我们讨论的常量:

1
2
3
4
5
6
7
/*************** Start of Lucid Fuzzing Harness *****************************/
#define LF_MAX_NUM_ENVS 24UL // Number of envelopes in an input
#define LF_MAX_ENV_LEN 8192UL // Number of bytes in an envelope payload 
#define LF_INPUT_HDR_SIZE (sizeof(u32) * 2) // lf_input->total_len, num_envs
#define LF_ENV_HDR_SIZE (sizeof(u32)) // lf_envelope->len
#define LF_MAX_TOTAL_ENV ((LF_MAX_ENV_LEN + LF_ENV_HDR_SIZE) * LF_MAX_NUM_ENVS)
#define LF_MAX_INPUT_LEN (LF_MAX_TOTAL_ENV + LF_INPUT_HDR_SIZE)

接下来,我定义了LUCID_SIGNATURE,当Lucid尝试决定在哪里注入输入时会扫描它。它知道struct lf_fuzzcase的布局,所以它知道在签名部分之后直接有一个长度字段,然后是可变长度的数据字段,在那里插入原始字节:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// Structure that describes an input as Lucid sees it
struct lf_fuzzcase {
	unsigned char signature[16];
	size_t input_len;
	u8 input[LF_MAX_INPUT_LEN];
};

// Create instance of the struct
struct lf_fuzzcase fc = {
	.signature = LUCID_SIGNATURE,
	.input_len = 0,
	.input = { 0 }	/* Where Lucid injects an input */
};

然后我们定义一些需要初始化的全局变量:

  • handler:这基本上是指向nfnetlink_rcv函数的函数指针,我们通过init命名空间中的协议查找它
  • kern_sock:这是在内核启动期间注册的struct sock,用于Netfilter子系统从用户态(我猜还有内核线程?)接收数据
  • skbs:只是我们需要用于包装信封数据的skb结构的平面缓冲区,工具链通过skb结构交换信封

最后初始化例程如下:

 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
// The function pointer we send the skbs to, the netlink rcv handler for
// netfilter nfnetlink_rcv
void *handler = NULL;

// The kernel-registered socket waiting for input from us
struct sock *kern_sock = NULL;

// Pool of skbs we use to store data in envelopes
struct sk_buff *skbs[LF_MAX_NUM_ENVS] = { 0 }; 

// Our initialization function, called before we do any fuzzing
int lucid_fuzz_init(const void __user *data, size_t len) {
	int err = 0;
	int i = 0;
	struct sk_buff *skb = NULL;

	printk("Hello from lucid_fuzz_init\n");
	printk("LF_MAX_INPUT_LEN is: %lu\n", LF_MAX_INPUT_LEN);

	// Copy the user data over to the fuzzcase instance if there is any
	if (len > 0 && len <= LF_MAX_INPUT_LEN) {
		if (copy_from_user(
			fc.input, data, len
		))
		{
			err = -EFAULT;
			goto done;
		}
		fc.input_len = len;
	}

	// Doing this how other kernel code does it, lock the global table
	netlink_table_grab();

	// Pre-set the err as if we failed to find the handler for NETFILTER
	err = -ENOENT;

	// Check to see if the handler is registered
	if (!nl_table[NETLINK_NETFILTER].registered) {
		netlink_table_ungrab();
		goto done;
	}

	// Grab the kernel socket
	kern_sock = netlink_lookup(&init_net, NETLINK_NETFILTER, 0);
	if (!kern_sock) {
		netlink_table_ungrab();
		goto done;
	}

	// Grab that .input handler
	handler = nlk_sk(kern_sock)->netlink_rcv;
	if (!handler) {
		netlink_table_ungrab();
		goto done;
	}

	// Ungrab the table we're done with it
	netlink_table_ungrab();

	// Pre-set
	err = -ENOMEM;

	// Create all of the socket buffers we need and store them
	for (i = 0; i < LF_MAX_NUM_ENVS; i++) {
		skb = alloc_skb(LF_MAX_ENV_LEN, GFP_KERNEL);
		// If we failed, unroll all the previous allocations
		if (!skb) {
			while (--i >= 0) {
				kfree_skb(skbs[i]);
				skbs[i] = NULL;
			}
			goto done;
		}

		// Initialize what we need to look legit
		skb->pkt_type = PACKET_HOST;
		skb->sk = kern_sock;
		NETLINK_CB(skb).portid = 0x1337;
		NETLINK_CB(skb).dst_group = 0;
		NETLINK_CB(skb).creds.uid = GLOBAL_ROOT_UID;
		NETLINK_CB(skb).creds.gid = GLOBAL_ROOT_GID;
		NETLINK_CB(skb).flags = NETLINK_SKB_DST;

		// Store the skb
		skbs[i] = skb;
	}

	// We are so done dude, it worked
	err = 0;

done:
	return err;
}

这应该初始化我们实际开始解析输入并在主工具链函数中分派它们所需的所有结构。

主解析例程

我们现在已经到达了输入缓冲区全局加载了数据的地步,并且我们知道调用以将数据分派到Netfilter的函数的地址。我们还初始化了我们将用于传输的套接字缓冲区。我们需要描述输入的样子,所以让我们定义我们的输入结构。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// Define our input structures
struct lf_input {
	u32 total_len;
	u32 num_envs;
	u8 data[];
};

struct lf_envelope {
	u32 len;
	u8 data[];
};

我们在主循环中做的第一件事是拍摄Bochs将保存到磁盘的快照。Lucid工作流程类似于:

  1. 开发环境,工具链
  2. 在想要从那里快照模糊测试的地方放置一个特殊的NOP操作(xchg dx, dx)
  3. 在gui-bochs中运行环境/工具链。这是一个相对正常的Bochs二进制文件,构建了GUI支持,应该用户友好,并允许你将这个Bochs快照转储到磁盘
  4. Rust模糊测试器二进制文件lucid-fuzz然后可以获取磁盘上的那个Bochs快照,并使用特制的lucid-bochs二进制文件恢复其执行。这将在模拟第一条指令之前调用到Lucid模糊测试器,并创建一种Lucid可以理解并在每个模糊测试迭代中恢复的新快照。

下面是我添加到Bochs中的代码,当遇到xchg dx, dx NOP时将Bochs快照保存到磁盘,其中i是指令结构的变量名:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
#if BX_SNAPSHOT
  // Check for take snapshot instruction `xchg dx, dx`
  if ((i->src() == i->dst()) && (i->src() == 2)) {
    BX_COMMIT_INSTRUCTION(i);
    if (BX_CPU_THIS_PTR async_event)
      return;
    ++i;
    char save_dir[] = "/tmp/lucid_snapshot";
    mkdir(save_dir, 0777);
    printf("Saving Lucid snapshot to '%s'...\n", save_dir);
    if (SIM->save_state(save_dir)) {
      printf("Successfully saved snapshot\n");
      sleep(2);
      exit(0);
    }
    else {
      printf("Failed to save snapshot\n");
    }
    BX_EXECUTE_INSTRUCTION(i);
  }
#endif

然后我们确保有足够的字节来形成元数据结构(lf_input),并在继续处理嵌套信封之前健全性检查其值。你会注意到所有错误路径都是return 1;,这是为了在模糊测试和变异器开发期间,我们跳过工具链中模糊测试循环末尾的快照恢复NOP指令。这种级联超时将让我们知道变异器中有错误。这是主循环:

 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
// Main input processing logic
int lucid_fuzz_handle_input(void) {
	struct lf_input *input = NULL;
	struct lf_envelope *env = NULL;
	struct sk_buff *fuzz_skb = NULL;
	u32 remaining = 0;
	u32 offset = 0;

	printk("Hello from lucid_fuzz_handle_input\n");

	/** LUCID TAKES SNAPSHOT HERE **/
	// This special NOP instruction, when interpreted by Bochs will cause
	// Bochs to save a snapshot of its state to disk that Lucid will be able
	// to resume in its purpose built version of Bochs called `lucid_bochs`
	asm volatile("xchgw %dx, %dx");

	// Make sure we enough bytes to construct the input metadata
	if (fc.input_len < sizeof(lf_input))
		return 1;

	// Cast the data to our metadata struct
	input = (struct lf_input *)fc.input;

	// Sanity check the values
	if (input->total_len != fc.input_len || input->total_len > LF_MAX_INPUT_SIZE)
		return 1;

	// Sanity check the number of messages
	if (input->num_msgs > LF_MAX_NUM_ENVS || input->num_msgs == 0)
		return 1;

	// Check how many remaining bytes we have, and subtract what we already
	// consumed with the input metadata
	remaining = input->total_len;
	remaining -= LF_INPUT_HDR_SIZE;

	// Start tracking an offset into the byte buffer where we're reading from
	offset = LF_INPUT_HDR_SIZE;

然后我们可以开始遍历信封并解析它们。每个成功解析的信封都被转换为skb并分派到nftables:

 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
// Iterate through the envelopes and parse each one
	for (i = 0; i < input->num_envs; i++) {
		// Make sure we have enough data remaining to parse an envelope metadata
		if (remaining < LF_ENV_HDR_SIZE)
			return 1;

		// We can at least read the length field, and sanity check it
		env = (struct lf_envelope *)(fc.input + offset);
		if (env->len > LF_MAX_MSG_SIZE || env->len == 0)
			return 1;

		// Consume those bytes
		remaining -= LF_ENV_HDR_SIZE;

		// Make sure we can read that much data
		if (remaining < env->len)
			return 1;

		// We have enough data left, create the skb for this envelope
		fuzz_skb = create_fuzz_skb(env, i);
		if (!fuzz_skb)
			return 1;

		// Dispatch the fuzz_skb to nftables!
		dispatch_skb(fuzz_skb);

		// Update our offset
		offset += (LF_ENV_HDR_SIZE + env->len);

		// Update remaining
		remaining -= env->len;

	}

我们在这个函数中初始化fuzz_skb。这是我们用所有需要的信息设置套接字缓冲区的地方,以成功被nftables接收和解析。我们用套接字缓冲区包装器交换"信封"包装器:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// Creates a socket buffer filled with fuzz message
static struct sk_buff *create_fuzz_skb(struct lf_envelope *env, int idx) {
	struct sk_buff *skb = NULL;

	// Sanity check
	if (idx >= LF_MAX_NUM_ENVS)
		return NULL;

	// Grab socket buffer from global buf
	skb = skbs[idx];

	// Set the socket buffer's sock to the kernel sock for Netfilter
	skb->sk = kern_sock;

	// Inject fuzz data and set sizes
	memcpy(skb_put(skb, env->len), env->data, env->len);

	return skb;
}

skb的分派很简单,我们只是将处理程序转换为正确的函数指针签名,然后用skb调用它:

1
2
3
4
5
6
7
8
// Dispatches the skb to the appropriate netlink recv handler
static void dispatch_skb(struct sk_buff *skb) {
	// Create function pointer, msg->protocol already sane
	void (*rcv)(struct sk_buff *) = handler;

	// Dispatch!
	rcv(skb);
}

主模糊测试循环当然在我们完成解析信封后恢复快照:

1
2
3
4
5
6
7
8
9
// Done parsing envelopes, check if we have remaining bytes
	if (remaining)
		return 1;

	/** LUCID RESTORES SNAPSHOT **/
	asm_volatile("xchgw %bx, %bx");

	// Finally done
	return 0;

我们将为最后发布的源文件保存其余的代码片段。

测试工具链

一切都连接好了,所以现在我们可以通过我们编译的工具链用户态二进制文件发送输入。让我们检查nft用户态实用程序上的strace,看看创建nft_table的Netlink消息在Netlink套接字上的发送位置。我们的nft命令是:nft add table inet fuzz

1
2
3
4
5
6
// Create the Netlink socket of the protocol type NETLINK_NETFILTER
socket(AF_NETLINK, SOCK_RAW, NETLINK_NETFILTER) = 3

// Send the Netlink message to create a table over that socket fd we just created
sendto(3, [{nlmsg_len=20, nlmsg_type=NFNL_SUBSYS_NFTABLES<<8|NFT_MSG_GETGEN, nlmsg_flags=NLM_F_REQUEST, nlmsg_seq=0, nlmsg_pid=0}, {nfgen_family=AF_UNSPEC, version=NFNETLINK_V0, res_id=htons(0)}], 20, 0, {sa_family=AF_NETLINK, nl_pid=0, nl_groups=00000000}, 12) = 20
recvmsg(3, {msg_name={sa_family=AF_NETLINK, nl_pid=0, nl_groups=00000000}, msg_namelen=12, msg_iov=[{iov_base=[{nlmsg_len=44, nlmsg_type=NFNL_SUBSYS_NFTABLES<<8|NFT_MSG_NEWGEN, nlmsg_flags=0, nlmsg_seq=0, nlmsg_pid=125392}, {nfgen_family=AF_UNSPEC, version=NFNETLINK_V0, res_id=htons(103)}, [[{nla_len=8, nla_type=0x1}, "\x00\x00\x00\x67"], [{nla_len=8, nla_type=0x2}, "\x00\x01\xe9\xd0"], [{nla_len=8, nla_type=0x3}, "\x6e\x66\x74\x00"]]], iov_len=69631}], msg_iovlen=1, msg_controllen=0, msg_flags=0}, 0) = 44

所以它发生在sendmsg系统调用期间,所以我所做的只是编写一个LD_PRELOAD共享对象来hexdump通过sendmsg发送的iovec数据。所以现在我可以为nft消息获取hexdump -C风格的输出:

1
2
3
4
5
6
LD_PRELOAD=$PWD/hexdump_netlink.so nft add table inet fuzz
00000000  14 00 00 00 10 00 01 00  00 00 00 00 00 00 00 00 |................|
00000010  00 00 0a 00 28 00 00 00  00 0a 01 00 01 00 00 00 |....(...........|
00000020  00 00 00 00 01 00 00 00  09 00 01 00 66 75 7a 7a |............fuzz|
00000030  00 00 00 00 08 00 02 00  00 00 00 00 14 00 00 00 |................|
00000040  11 00 01 00 02 00 00 00  00 00 00 00 00 00 0a 00 |................|

现在我们知道了合法的nftables消息的样子,我们可以将它包装在我们的lf_input和lf_envelope结构中并测试工具链!我获取了那个输出,只是将其硬编码到一个粗糙的Python脚本中,将二进制文件转储到终端:

 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
import struct
import sys

# Dumped message
msg_str = [
    "00000000  14 00 00 00 10 00 01 00  00 00 00 00 00 00 00 00 |................|",
    "00000010  00 00 0a 00 28 00 00 00  00 0a 01 00 01 00 00 00 |....(...........|",
    "00000020  00 00 00 00 01 00 00 00  09 00 01 00 66 75 7a 7a |............fuzz|",
    "00000030  00 00 00 00 08 00 02 00  00 00 00 00 14 00 00 00 |................|",
    "00000040  11 00 01 00 02 00 00 00  00 00 00 00 00 00 0a 00 |................|"
]

# Byte string we'll fill
all_bytes = b''
for line in msg_str:
    # Skip the offset stuff
    hex_start = line[10:]

    # Cut off the back ascii stuff
    hex_str = hex_start[:len(hex_start) - 18]

    # Remove the spaces
    hex_str = hex_str.replace(" ", "")

    # Start appending
    all_bytes += bytes.fromhex(hex_str)

# Now with bytes, wrap that in envelope
envelope_len = len(all_bytes)
envelope = struct.pack('<I', envelope_len) + all_bytes

# Now wrap that in an lf_input
num_envs = 1
total_len = 8 # Metadata for lf_input
total_len += len(envelope)
lf_input = struct.pack('<II', total_len, num_envs) + envelope

# Write that to stdout
sys.stdout.buffer.write(lf_input)

我们现在可以将其管道传输到base64,然后管道传输到工具链进行测试:

1
2
[devbox:~/nft_fuzzing]$ python3 wrapper.py | base64
XAAAAAEAAABQAAAAFAAAABAAAQAAAAAAAAAAAAAACgAoAAAAAAoBAAEAAAAAAAAAAQAAAAkAAQBmdXp6AAAAAAgAAgAAAAAAFAAAABEAAQACAAAAAAAAAAAACgA=

然后当我们在运行自定义内核的qemu-system上运行echo "<base64>" | harness时,我们得到以下内核日志:

1
2
3
4
5
6
7
[   23.347957] Inside lucid fuzz!
[   23.349015] Calling lucid_fuzz_init...
[   23.350233] Hello from lucid_fuzz_init
[   23.351399] LF_MAX_INPUT_LEN is: 196712
[   23.355266] Hello from lucid_fuzz_handle_input
[   23.359789] Calling lucid_fuzz_cleanup...
lucid_fuzz returned 0

所以工具链工作了!

结论

希望这能帮助你理解如何为Lucid编写工具链。我们需要:

  1. 确定一种将原始输入字节注入内核内存的方法
  2. 使用特殊的NOP指令拍摄快照
  3. 实现自定义协议,我们的工具链可以理解,以便将原始输入字节解析为可以发送到目标的内容
  4. 使用特殊的NOP指令重置快照
  5. 清理工具链中的所有资源,以便我们也可以用于调试

我已经在下面粘贴了我添加到af_netlink.c中的完整工具链代码,干杯:

1
/*************** Start of Lucid Fuzzing Harness
comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计