eBPF 深度探索: 高效 DNS 监控实现(上)

本文涉及的产品
云解析 DNS,旗舰版 1个月
全局流量管理 GTM,标准版 1个月
公共DNS(含HTTPDNS解析),每月1000万次HTTP解析
简介: eBPF 深度探索: 高效 DNS 监控实现(上)

eBPF 可以灵活扩展 Linux 内核机制,本文通过实现一个 DNS 监控工具为例,介绍了怎样开发实际的 eBPF 应用。原文: A Deep Dive into eBPF: Writing an Efficient DNS Monitoring


eBPF是内核内置的虚拟机,在 Linux 内核内部提供了高层库、指令集以及执行环境,被用于诸多 Linux 内核子系统,特别是网络、跟踪、调试和安全领域。其功能即支持改变内核对数据包的处理,也允许对网络设备(如智能网卡)进行编程。


image.png

eBPF 实现的用例。


已经有大量各种语言的关于eBPF的介绍文章,所以本文不会过多涉及 eBPF 的细节。尽管许多文章都提供了相当多的信息,但都没有回答最重要的问题: eBPF 是如何处理数据包并监视从主机发送给用户的数据包的?本文将从头开始创建一个实际的应用程序,逐步丰富其功能,特别是监控 DNS 请求、响应及其过程,并提供所有这些过程的解释、评论以及源代码链接。因为想多举几个例子,而不仅仅只是单一问题的解决方案,因此有时候我们会稍微有点偏题。最终希望那些想要熟悉 eBPF 的人可以花更少的时间研究有用的材料,并更快的开始编程。


简介


假设主机可以发送合法的 DNS 请求,但发送这些请求的 IP 地址是未知的。在网络过滤器日志中,可以看到不断受到请求,但不清楚这是合法请求,还是信息已经泄露给了攻击者?如果发送数据的服务器所在的域是已知的,那就容易了。不幸的是,PTR 已经过时,SecurityTrails 显示这个 IP 要么什么都没有,要么有太多乱七八糟的东西。


我们可以执行tcpdump命令,但是谁愿意一直盯着显示器呢?如果有多个服务器又怎么办呢?ELK 技术栈里有packetbeat,这是一个可以吃掉服务器上所有处理器处理能力的怪物。Osquery也是一个很好的工具,它非常了解网络连接,但不了解 DNS 查询,相关支持已经不再提供了。Zeek是一个我在寻找如何跟踪 DNS 查询时了解到的工具,看起来还不错,但有两点让人感到困惑: 它不仅仅监视 DNS,这意味着资源还将花在我不需要的工作上(也许尽管可以在设置中选择协议),它也不知道是哪个进程发送了请求。


我们将用 Python 并从最简单的部分开始编写代码,从而理解 Python 是如何与 eBPF 交互的。首先安装这些包:


#apt install python3-bpfcc bpfcc-tools libbpfcc linux-headers-$(uname -r)



这是在 Ubuntu 下的命令,但是如果想要深入内核,为其他发行版找到必要的包应该也不是问题。现在让我们开始吧:


#!/usr/bin/env python3
from bcc import BPF
FIRST_BPF = r"""
int first(void *ctx) {
  bpf_trace_printk("Hello world! execve() is calling\n");
  return 0;
}
"""
bpf = BPF(text=FIRST_BPF)
bpf.attach_kprobe(event=bpf.get_syscall_fnname("execve"), fn_name="first")
while True:
    try:
        (_, _, _, _, _, event_b) = bpf.trace_fields()
        events = event_b.decode('utf8')
        if 'Hello world' in events:
            print(events)
    except ValueError:
        continue
    except KeyboardInterrupt:
        break


注意: 在 Ubuntu 20.04 LTS 和 18.04 LTS 中,默认情况下允许无特权用户加载 eBPF 程序,但在最近的 Ubuntu 版本(21.10 和 22.04 LTS)中,出于安全考虑,默认禁用了这一功能。通过以下命令可以重启此能力:

$ sudo sysctl kernel.unprivileged_bpf_disabled=0


与所有 hello-world 示例一样,它没有做任何有用的事情,只是向我们介绍了基础知识。当主机上的任何程序调用 execve()系统调用时,first()函数就会被执行。可以在另一个控制台上运行命令ls|cat|grep|clear或任何包含execve()的命令来触发,然后执行我们的代码。也可以在内核中发生的各种事件时调用 eBPF 程序,attach_kprobe()表示在调用特定内核函数时触发。但我们更习惯于处理系统调用,谁会知道对应函数的名字呢?因此,助手函数get_syscall_fnname()可以帮助我们将系统调用名转换为内核函数名。


eBPF 中最简单的输出选项是函数bpf_trace_printk(),但这只是用于调试的输出。传递给这个函数的所有东西都可以通过 /sys/kernel/debug/tracing/trace_pipe 文件获得。为了避免在另一个控制台中读取这个文件,我们使用函数trace_fields(),它可以读取这个文件,并在程序中为我们提供其内容。


代码的其余部分比较明确,在一个能够被 Ctrl-C 中断的无限循环中,读取调试输出,如果出现"Hello world"字符串,就将其完整输出。


注意:bpf_trace_printk()可以实现类似printf()的格式化文本,但有重要限制: 不能超过 3 个参数,并且只有一个参数是%s


现在我们已经大致了解了如何使用 eBPF,接下来我们开始构建一个实际的应用程序,监视所有 DNS 请求和响应,并记录谁问了什么以及收到了什么响应。


开始


我们从 eBPF 开始,处理数据包最简单的方法是将它们附加到网络套接字上。在本例中,每个包都将触发我们的程序。稍后我们将详细说明这是如何完成的,但现在我们需要在所有数据包中捕获端口为 53 的 UDP 包。要做到这一点,必须自己拆解包结构,并在 C 中分离所有嵌套的协议。cursor_advance宏可以在包的范围内移动光标(指针),返回其当前位置并移动到指定位置,从而帮助我们做到这一点:


#include <linux/if_ether.h>
#include <linux/in.h>
#include <bcc/proto.h>
int dns_matching(struct __sk_buff *skb) {
 u8 *cursor = 0;
// Checking the IP protocol::
 struct ethernet_t *ethernet = cursor_advance(cursor, sizeof(*ethernet));
if (ethernet->type == ETH_P_IP) {


proto.h文件中描述的结构ethernet_t:


struct ethernet_t {
  unsigned long long  dst:48;
  unsigned long long  src:48;
  unsigned int        type:16;
} BPF_PACKET_HEADER;


以太帧格式本身非常简单,包含 6 个字节(48 位)的目地地址,相同大小的源地址,然后是两个字节(16 位)的负载类型。


负载类型由一个等于 0x0800 的常量ETH_P_IP编码,定义在文件if_ether.h中,确保下一层协议是 IP(该代码以及其他可能的值都由IEEE描述)。


我们继续检查 IP 内部是否是端口为 53 的 UDP:


// Checking the UDP protocol:
struct ip_t *ip = cursor_advance(cursor, sizeof(*ip));
if (ip->nextp == IPPROTO_UDP) {
    // Checking port 53:
    struct udp_t *udp = cursor_advance(cursor, sizeof(*udp));
    if (udp->dport == 53) {
        // Request
        return -1;
    }
    if (udp->sport == 53) {
        // Respose
        return -1;
    }
}


ip_tudp_t仍然定义在proto.h中,但IPPROTO_UDP来自于in.h。一般来说,这个例子并不完全正确。IP 结构已经有点复杂了,它有可选字段,因此头部长度有可能不一样。正确做法是首先从头部获取其长度值,然后执行偏移,但我们才刚刚开始,不需要搞得太复杂。


这就很简单的找到了 DNS 包,接下来需要分析它的结构。为了简单起见,我们把包传递给用户空间(为此返回-1,而返回码 0 意味着不需要复制包)。


回到 Python,我们首先仍然将程序附加到套接字上:


#!/usr/bin/env python3
import dnslib
import sys
from bcc import BPF
...
bpf = BPF(text=BPF_PROGRAM)
function_dns_matching = bpf.load_func("dns_matching", BPF.SOCKET_FILTER)
BPF.attach_raw_socket(function_dns_matching, '')


与上一个例子不同,现在程序不是在调用任何函数时被调用,而是被每个包调用。attach_raw_socket中的空参数意味着"所有网络接口",如果我们需要监控特定网络接口,那么就填入对应的名字。


将 socket 设置为阻塞模式:


import fcntl
import os
socket_fd = function_dns_matching.sock
fl = fcntl.fcntl(socket_fd, fcntl.F_GETFL)
fcntl.fcntl(socket_fd, fcntl.F_SETFL, fl & ~os.O_NONBLOCK)


剩下的就很简单了,使用类似的无限循环,从套接字读取数据,去掉所有头域,直接获得 DNS 包并解码。


完整代码如下:


#!/usr/bin/env python3
import dnslib
import fcntl
import os
import sys
from bcc import BPF
BPF_APP = r'''
#include <linux/if_ether.h>
#include <linux/in.h>
#include <bcc/proto.h>
int dns_matching(struct __sk_buff *skb) {
    u8 *cursor = 0;
     // Checking the IP protocol:
    struct ethernet_t *ethernet = cursor_advance(cursor, sizeof(*ethernet));
    if (ethernet->type == ETH_P_IP) {
         // Checking the UDP protocol:
        struct ip_t *ip = cursor_advance(cursor, sizeof(*ip));
        if (ip->nextp == IPPROTO_UDP) {
             // Check the port 53:
            struct udp_t *udp = cursor_advance(cursor, sizeof(*udp));
            if (udp->dport == 53 || udp->sport == 53) {
                return -1;
            }
        }
    }
    return 0;
}
'''
bpf = BPF(text=BPF_APP)
function_dns_matching = bpf.load_func("dns_matching", BPF.SOCKET_FILTER)
BPF.attach_raw_socket(function_dns_matching, '')
socket_fd = function_dns_matching.sock
fl = fcntl.fcntl(socket_fd, fcntl.F_GETFL)
fcntl.fcntl(socket_fd, fcntl.F_SETFL, fl & ~os.O_NONBLOCK)
while True:
    try:
        packet_str = os.read(socket_fd, 2048)
    except KeyboardInterrupt:
        sys.exit(0)
    packet_bytearray = bytearray(packet_str)
    ETH_HLEN = 14
    UDP_HLEN = 8
    # IP header length
    ip_header_length = packet_bytearray[ETH_HLEN]
    ip_header_length = ip_header_length & 0x0F
    ip_header_length = ip_header_length << 2
    # Starting the DNS packet
    payload_offset = ETH_HLEN + ip_header_length + UDP_HLEN
    payload = packet_bytearray[payload_offset:]
    dnsrec = dnslib.DNSRecord.parse(payload)
    # If it’s the response:
    if dnsrec.rr:
        print(f'Resp: {dnsrec.rr[0].rname} {dnslib.QTYPE.get(dnsrec.rr[0].rtype)} {", ".join([repr(dnsrec.rr[i].rdata) for i in range(0, len(dnsrec.rr))])}')
    # If it’s the request:
    else:
        print(f'Request: {dnsrec.questions[0].qname} {dnslib.QTYPE.get(dnsrec.questions[0].qtype)}')



该示例展示了哪些 DNS 请求/响应会通过我们的网络接口,但通过这种方式,我们还是不知道是什么进程在处理。也就是说,只有有限的信息,由于缺乏信息,我没有选择 Zeek。

目录
相关文章
|
6月前
|
移动开发 JSON 监控
网络协议解析:在员工上网监控软件中实现HTTP流量分析
随着企业对员工网络活动的监控需求不断增加,开发一套能够实现HTTP流量分析的网络协议解析系统变得愈发重要。本文将深入探讨如何在员工上网监控软件中实现HTTP流量分析,通过代码示例演示关键步骤。
279 0
|
Prometheus 监控 Kubernetes
【K8S系列】深入解析K8S监控
【K8S系列】深入解析K8S监控
677 0
|
5月前
|
存储 JSON 监控
Elasticsearch索引监控全面解析
Elasticsearch索引监控全面解析
110 0
|
4月前
|
传感器 数据采集 运维
ERP系统中的生产线监控与异常处理解析
【7月更文挑战第25天】 ERP系统中的生产线监控与异常处理解析
162 8
|
4月前
|
传感器 数据采集 监控
ERP系统中的生产过程监控与质量管理解析
【7月更文挑战第25天】 ERP系统中的生产过程监控与质量管理解析
204 0
ERP系统中的生产过程监控与质量管理解析
|
5月前
|
消息中间件 监控 Java
「布道师系列文章」宝兰德徐清康解析 Kafka 和 AutoMQ 的监控
本文由北京宝兰德公司解决方案总监徐清康撰写,探讨了Kafka和AutoMQ集群的监控。
228 2
「布道师系列文章」宝兰德徐清康解析 Kafka 和 AutoMQ 的监控
|
5月前
|
监控 NoSQL MongoDB
深入MongoDB监控:全面解析命令、实用示例与最佳实践
深入MongoDB监控:全面解析命令、实用示例与最佳实践
128 0
|
6月前
|
监控 API 数据安全/隐私保护
屏幕监控软件开发指南:C++实现原理解析
在当今数字化时代,屏幕监控软件成为了企业管理和个人隐私保护的重要工具。本文将深入探讨如何使用C++语言实现屏幕监控软件,并解析其实现原理。我们将通过多个代码示例来说明其工作方式,最后将介绍如何将监控到的数据自动提交到网站。
191 3
|
机器学习/深度学习 监控 算法
蝶形算法优势解析:提升办公室电脑监控软件性能
蝶形算法,又称为快速傅里叶变换(FFT),是一种数学工具,专用于计算序列的离散傅里叶变换。这一算法在信号处理、图像处理以及控制系统中拥有广泛的应用。
210 2

相关产品

  • 云解析DNS
  • 推荐镜像

    更多