eBPF 深度探索: 高效 DNS 監控實現

eBPF 可以靈活擴展 Linux 內核機制,本文通過實現一個 DNS 監控工具爲例,介紹了怎樣開發實際的 eBPF 應用。原文: A Deep Dive into eBPF: Writing an Efficient DNS Monitoring

eBPF[1] 是內核內置的虛擬機,在 Linux 內核內部提供了高層庫、指令集以及執行環境,被用於諸多 Linux 內核子系統,特別是網絡、跟蹤、調試和安全領域。其功能即支持改變內核對數據包的處理,也允許對網絡設備 (如智能網卡) 進行編程。

eBPF 實現的用例。

已經有大量各種語言的關於 eBPF 的介紹文章 [2],所以本文不會過多涉及 eBPF 的細節。儘管許多文章都提供了相當多的信息,但都沒有回答最重要的問題: eBPF 是如何處理數據包並監視從主機發送給用戶的數據包的?本文將從頭開始創建一個實際的應用程序,逐步豐富其功能,特別是監控 DNS 請求、響應及其過程,並提供所有這些過程的解釋、評論以及源代碼鏈接。因爲想多舉幾個例子,而不僅僅只是單一問題的解決方案,因此有時候我們會稍微有點偏題。最終希望那些想要熟悉 eBPF 的人可以花更少的時間研究有用的材料,並更快的開始編程。

簡介

假設主機可以發送合法的 DNS 請求,但發送這些請求的 IP 地址是未知的。在網絡過濾器日誌中,可以看到不斷受到請求,但不清楚這是合法請求,還是信息已經泄露給了攻擊者?如果發送數據的服務器所在的域是已知的,那就容易了。不幸的是,PTR 已經過時,SecurityTrails 顯示這個 IP 要麼什麼都沒有,要麼有太多亂七八糟的東西。

我們可以執行 tcpdump[3] 命令,但是誰願意一直盯着顯示器呢?如果有多個服務器又怎麼辦呢?ELK 技術棧裏有 packetbeat[4],這是一個可以喫掉服務器上所有處理器處理能力的怪物。Osquery[5] 也是一個很好的工具,它非常瞭解網絡連接,但不瞭解 DNS 查詢,相關支持已經不再提供了。Zeek[6] 是一個我在尋找如何跟蹤 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_)
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[7] 中,確保下一層協議是 IP(該代碼以及其他可能的值都由 IEEE[8] 描述)。

我們繼續檢查 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[9]。一般來說,這個例子並不完全正確。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。

從數據包到進程

要獲取關於 eBPF 中的進程信息,可以使用以下函數: bpf_get_current_pid_tgid()bpf_get_current_uid_gid()bpf_get_current_comm(char *buf, int size_of_buf)。當程序被綁定到對某個內核函數調用時 (如第一個示例所示),就可以使用它們。UID/GID 應該比較明確,但對於那些以前沒有接觸過內核操作細節的人來說,還是需要解釋一下。在內核中被視爲 PID 的東西在用戶空間中顯示爲進程的 thread ID。內核認爲用戶空間中的 thread group ID 是 PID。類似的,bpf_get_current_comm()返回的不是通常的進程名 (可以通過ps命令查看),而是線程名。

好吧,我們總歸會拿到進程數據,那怎麼將數據傳遞到用戶空間?Table 就是用於此目的,通過BPF_PERF_OUTPUT(event)創建,通過方法event.perf_submit(ctx, data, data_size)傳遞,並通過b.perf_buffer_poll()輪詢接收。在此之後,只要數據可用,就會調用callback()函數,即b["event"].open_perf_buffer(callback)

下面將詳細介紹這一機制,但現在,我們繼續從理論上進行分析。我們既可以傳輸數據,也可以傳輸數據包本身。但要做到這一點,必須爲傳輸的數據選擇一個特定長度的變量。怎麼選?直接回答是 512 字節,但並不正確。這一長度並沒有考慮 EDNS,而且我們還想正確跟蹤基於 TCP 的 DNS 報文。因此我們不得不分配大量的預留空間,而更大的包將會被丟棄,大多數情況下,我們將分配比所需更多的內存。我不喜歡這種方法,幸運的是,還有另一個方法: perf_submit_skb()。除了數據外,它還從緩衝區傳輸指定字節的數據包。但需要注意,該方法僅適用於網絡程序 eBPF: 套接字,XDP。也就是說,我們無法獲得有關進程的信息。

幸運的是,可以使用多個 eBPF 程序並互相交換數據!這也可以通過 Table 來實現。聲明如下:

BPF_TABLE_PUBLIC("hash", key, val, name, max_elements);

這是爲了使其對其他 eBPF 程序可用。在另一個程序中,通過如下代碼訪問:

BPF_TABLE("extern", key, val, name, max_elements);

因此,即使 5 元組 (協議、源地址、源端口、目的地址和目的端口) 都一樣,也不會丟失數據包,鍵將是以下結構:

struct port_key {
     u8 proto;
     u32 saddr;
     u32 daddr;
     u16 sport;
     u16 dport;
 };

值是我們想知道的關於這個進程的所有信息:

struct port_val {
     u32 ifindex;
     u32 pid;
     u32 tgid;
     u32 uid;
     u32 gid;
     char comm[64];
 };

ifindex是網絡設備,我們將在套接字上運行的另一個程序中填充這個值。在這裏,我們用它來將整個結構轉移到未來的用戶空間。

總結: 當調用內核函數發送數據包時,存儲涉及到的進程信息。當數據包出現在網絡接口上時 (不管是傳出的還是傳入),檢查是否在目的地之間通過這樣或那樣的協議傳輸包的任何信息。如果有,就將其與包一起傳遞給 Python,在那裏完成其餘工作。

好了,我們已經討論程序的基本邏輯,接下來開始編程吧!

我的名字是進程

我們從獲取相關進程的信息開始。udp_sendmsg()[10] 和 tcp_sendmsg()[11] 函數用於發送數據包,兩者都將 sock[12] 結構作爲第一個參數。在 eBPF 中有兩種方法可以訪問所研究函數的實參: 將其指定爲函數的形參,或者使用宏PT_REGS_PARMx,其中 x 是實參號。下面將展示這兩個選項,這是第一個程序,C_BPF_KPROBE:

// The structure that will be used as the key for 
// eBPF table 'proc_ports':
struct port_key {
    u8 proto;
    u32 saddr;
    u32 daddr;
    u16 sport;
    u16 dport;
};
// The structure that will be stored in the eBPF table 'proc_ports' 
// contains information about the process:
struct port_val {
    u32 ifindex;
    u32 pid;
    u32 tgid;
    u32 uid;
    u32 gid;
    char comm[64];
};
// Public (accessible from other eBPF programs) eBPF table in which 
// information about the process is written. 
// It'read when a packet appears on the socket:
BPF_TABLE_PUBLIC("hash", struct port_key, struct port_val, proc_ports, 20480);
// These are two ways to get access to the function arguments:
//int trace_udp_sendmsg(struct pt_regs *ctx) {
// struct sock *sk = (struct sock *)PT_REGS_PARM1(ctx);
int trace_udp_sendmsg(struct pt_regs *ctx, struct sock *sk) {
    u16 sport = sk->sk_num;
    u16 dport = sk->sk_dport;
  
    // Processing packets only on port 53.
    // 13568 = ntohs(53);
    if (sport == 13568 || dport == 13568) {
        // Preparing the data:
        u32 saddr = sk->sk_rcv_saddr;
        u32 daddr = sk->sk_daddr;
        u64 pid_tgid = bpf_get_current_pid_tgid();
        u64 uid_gid = bpf_get_current_uid_gid();
        // Forming the key structure.
        // These strange transformations will be explained below.
        struct port_key key = {.proto = 17};
        key.saddr = htonl(saddr);
        key.daddr = htonl(daddr);
        key.sport = sport;
        key.dport = htons(dport);
        // Forming a structure with the process properties:
        struct port_val val = {};
        val.pid = pid_tgid >> 32;
        val.tgid = (u32)pid_tgid;
        val.uid = (u32)uid_gid;
        val.gid = uid_gid >> 32;
        bpf_get_current_comm(val.comm, 64);
        //Writing the value into the eBPF table:
        proc_ports.update(&key, &val);
    }
    return 0;
}

使用tcp_sendmsg也完全一樣,唯一的區別是,在結構port_key中,字段proto將等於 6,這兩個值 (17 和 6) 分別是 UDP 和 TCP 的協議號,可以在/etc/protocols文件中查看這些值。

兩個bpf_get_current_*函數都返回 64 比特,因此我們分別獲取高低 32 比特來提取數據。此外,對於 PID/TGID,我們可以立即以常見的形式獲取 (例如,對於 PID,寫入字段的高 32 位,其中包含內核認爲是 TGID 的內容)。

我們接下來看看關鍵數據結構的轉換。在下一節中,我們將在程序中創建一個類似的結構。但我們不是從原子結構sock中獲取數據,而是從 eBPF 的 __sk_buff[13] 中,數據的存儲形式爲:

__u32 remote_ip4; /* Stored in network byte order */
__u32 local_ip4; /* Stored in network byte order */
__u32 remote_port; /* Stored in network byte order */
__u32 local_port; /* stored in host byte order */
提取到用戶空間

我們的第二個程序BPF_SOCK_TEXT將 "掛起 (hang)" 在套接字上,爲每個包檢查對應進程的信息,並將其和包本身一起傳輸到用戶空間:

// The structure that will be used as the key for
// eBPF table 'proc_ports':
struct port_key {
    u8 proto;
    u32 saddr;
    u32 daddr;
    u16 sport;
    u16 dport;
};
// The structure that will be stored in the eBPF table 'proc_ports',
// Contains information about the process:
struct port_val {
    u32 ifindex;
    u32 pid;
    u32 tgid;
    u32 uid;
    u32 gid;
    char comm[64];
};
// eBPF table from which information about the process is extracted.
// Filled when calling kernel functions udp_sendmsg()/tcp_sendmsg():
BPF_TABLE("extern", struct port_key, struct port_val, proc_ports, 20480);
// Table for transferring data to the user space:
BPF_PERF_OUTPUT(dns_events);
// Look for DNS packets among the data passing through the socket and 
// check if there is any information about the process:
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) {
        struct ip_t *ip = cursor_advance(cursor, sizeof(*ip));
        u8 proto;
        u16 sport;
        u16 dport;
        // Checking the transport layer protocol:
        if (ip->nextp == IPPROTO_UDP) {
            struct udp_t *udp = cursor_advance(cursor, sizeof(*udp));
            proto = 17;
            // Getting the data about the ports:
            sport = udp->sport;
            dport = udp->dport;
        } else if (ip->nextp == IPPROTO_TCP) {
            struct tcp_t *tcp = cursor_advance(cursor, sizeof(*tcp));
            // We don't need packets where no data is transmitted:
            if (!tcp->flag_psh) {
                return 0;
            }
            proto = 6;
            // Getting the data about the ports:
            sport = tcp->src_port;
            dport = tcp->dst_port;
        } else {
            return 0;
        }
        // If it's a DNS query:
        if (dport == 53 || sport == 53) {
            // Form a key structure:
            struct port_key key = {};
            key.proto = proto;
            if (skb->ingress_ifindex == 0) {
                key.saddr = ip->src;
                key.daddr = ip->dst;
                key.sport = sport;
                key.dport = dport;
            } else {
                key.saddr = ip->dst;
                key.daddr = ip->src;
                key.sport = dport;
                key.dport = sport;
            }
            // By the key, look for a value in the eBPF table:
            struct port_val *p_val;
            p_val = proc_ports.lookup(&key);
            // If no value is found, then we have no information about the 
            // process and there is no point in continuing:
            if (!p_val) {
                return 0;
            }
            // Network device index:
            p_val->ifindex = skb->ifindex;
            // Transmit the structure with the process information along with 
            // skb->len bytes sent to the socket:
            dns_events.perf_submit_skb(skb, skb->len, p_val,
                                       sizeof(struct port_val));
            return 0;
        } //dport == 53 || sport == 53
    } //ethernet->type == ETH_P_IP
return 0;
}

該程序的啓動方式與第一個示例相同。我們在數據包中移動指針,從不同級別的協議中收集信息。當前仍然不考慮 IP 頭的實際長度,但還是添加了一些新的東西,對於 TCP 包,我們將檢查其標誌,過濾掉不攜帶數據的包 (SYNACK 等)。

但我們必須恢復鍵,從而從proc_ports表中獲取數據。同時,必須區分流量的方向,畢竟,當我們在表中輸入數據時,意味着我們是源。但是對於傳入的數據包,源將是遠程服務器。爲了理解數據包的移動方向,我將ingress_ifindex標識爲 0 用於標識輸出流量。

提供服務

我們需要通過 Python 做三件事: 將程序加載到內核中,從內核中獲取數據,並對其進行處理。

前兩個任務很簡單。此外,我們已經在第一個例子中考慮了使用 eBPF 的兩種方法:

# BPF initialization:
bpf_kprobe = BPF(text=C_BPF_KPROBE)
bpf_sock = BPF(text=BPF_SOCK_TEXT)
# Send UDP:
bpf_kprobe.attach_kprobe(event="udp_sendmsg", fn_)
# Send TCP:
bpf_kprobe.attach_kprobe(event="tcp_sendmsg", fn_)
# Socket:
function_dns_matching = bpf_sock.load_func("dns_matching", BPF.SOCKET_FILTER)
BPF.attach_raw_socket(function_dns_matching, '')

獲取數據的代碼甚至更短:

bpf_sock["dns_events"].open_perf_buffer(print_dns)
while True:
    try:
        bpf_sock.perf_buffer_poll()
    except KeyboardInterrupt:
        exit()

但數據處理將更加繁瑣。儘管有現成模塊,我們還是決定自己解析協議頭。首先,我想自己弄清楚這是如何發生的 (最後,儘管在當前情況下正確處理 IP 包頭的長度沒有意義,因爲頭域有額外選項的包將在 eBPF 中被丟棄),其次是減少對模塊的依賴。然而,對於直接解析 DNS,我仍然(到目前爲止)使用現成模塊,DNS 結構比 IP/TCP 稍微複雜一些,需要另一個模塊 (ctypes) 來處理 C 數據類型。

def print_dns(cpu, data, size):
    import ctypes as ct
    class SkbEvent(ct.Structure):
        _fields_ = [
            ("ifindex", ct.c_uint32),
            ("pid", ct.c_uint32),
            ("tgid", ct.c_uint32),
            ("uid", ct.c_uint32),
            ("gid", ct.c_uint32),
            ("comm", ct.c_char * 64),
            ("raw", ct.c_ubyte * (size - ct.sizeof(ct.c_uint32 * 5) - ct.sizeof(ct.c_char * 64)))
        ]
    # We get our 'port_val' structure and also the packet itself in the 'raw' field:
    sk = ct.cast(data, ct.POINTER(SkbEvent)).contents
    # Protocols:
    NET_PROTO = {6: "TCP", 17: "UDP"}
    # eBPF operates on thread names.
    # Sometimes they coincide with process names, but often not.
    # So we try to get the process name by its PID:
    try:
        with open(f'/proc/{sk.pid}/comm''r') as proc_comm:
            proc_name = proc_comm.read().rstrip()
    except:
        proc_name = sk.comm.decode()
    # Get the name of the network interface by index:
    ifname = if_indextoname(sk.ifindex)
    # The length of the Ethernet frame header is 14 bytes:
    ip_packet = bytes(sk.raw[14:])
    # The length of the IP packet header is not fixed due to the arbitrary
    # number of parameters.
    # Of all the possible IP header we are only interested in 20 bytes:
    (length, _, _, _, _, proto, _, saddr, daddr) = unpack('!BBHLBBHLL', ip_packet[:20])
    # The direct length is written in the second half of the first byte (0b00001111 = 15):
    # len_iph = length & 15
    # Length is written in 32-bit words, convert it to bytes:
    # len_iph = len_iph * 4
    # Convert addresses from numbers into IPs, assembling it into octets:
    saddr = ".".join(map(str, [saddr >> 24 & 0xff, saddr >> 16 & 0xff, saddr >> 8 & 0xff, saddr & 0xff]))
    daddr = ".".join(map(map(str, [daddr >> 24 & 0xff, daddr >> 16 & 0xff, daddr >> 8 & 0xff, daddr & 0xff]))
    # If the transport layer protocol is UDP:
    if proto == 17:
        udp_packet = ip_packet[len_iph:]
        (sport, dport) = unpack('!HH', udp_packet[:4])
        # UDP datagram header length is 8 bytes:
        dns_packet = udp_packet[8:]
    # If the transport layer protocol is TCP:
    elif proto == 6:
        tcp_packet = ip_packet[len_iph:]
        # TCP packet header length is also not fixed due to the optional
        # options. Of the entire TCP header, we are only interested in the data up to the 13th
        # byte (header length):
        (sport, dport, _, length) = unpack('!HHQB', tcp_packet[:13])
        # The direct length is written in the first half (4 bits):
        len_tcph = length >> 4
        # Length is written in 32-bit words, converted to bytes:
        len_tcph = len_tcph * 4
        # That's the tricky part.
        # I don't know where I went wrong or why I need a 2 byte offset,
        # but it's necessary because the DNS packet doesn't start until after it:
        dns_packet = tcp_packet[len_tcph + 2:]
    # other protocols are not handled:
    else:
        return
    # DNS data decoding:
    dns_data = dnslib.DNSRecord.parse(dns_packet)
    # Resource record types:
    DNS_QTYPE = {1: "A", 28: "AAAA"}
    # Query:
    If dns_data.header.qr == 0:
        # We are only interested in A (1) and AAAA (28) records:
        for q in dns_data.questions:
            If q.qtype == 1 or q.qtype == 28:
                print(f'COMM={proc_name} PID={sk.pid} TGID={sk.tgid} DEV={ifname} PROTO={NET_PROTO[proto]} SRC={saddr} DST={daddr} SPT={sport} DPT={dport} UID={sk.uid} GID={sk.gid} DNS_QR=0 DNS_NAME={q.qname} DNS_TYPE={DNS_QTYPE[q.qtype]}')
    # Response:
    elif dns_data.header.qr == 1:
        # We are only interested in A (1) and AAAA (28) records:
        For rr in dns_data.rr:
            If rr.rtype == 1 or rr.rtype == 28:
                print(f'COMM={proc_name} PID={sk.pid} TGID={sk.tgid} DEV={ifname} PROTO={NET_PROTO[proto]} SRC={saddr} DST={daddr} SPT={sport} DPT={dport} UID={sk.uid} GID={sk.gid} DNS_QR=1 DNS_NAME={rr.rname} DNS_TYPE={DNS_QTYPE[rr.rtype]} DNS_DATA={rr.rdata}')
    else:
        print('Invalid DNS query type.')
最後

啓動應用程序 Python 代碼,在另一個控制檯中用 dig[14] 工具發起請求。

# dig @1.1.1.1 google.com +tcp

如果正確執行,程序輸出應該是這樣的:

# python3 final_code_eBPF_dns.py
The program is running. Press Ctrl-C to abort.
COMM=dig PID=10738 TGID=10739 DEV=ens18 PROTO=TCP SRC=192.168.44.3 DST=1.1.1.1 SPT=57915 DPT=53 UID=GID=DNS_QR=DNS_NAME=google.com. DNS_TYPE=A
COMM=dig PID=10738 TGID=10739 DEV=ens18 PROTO=TCP SRC=1.1.1.1 DST=192.168.44.3 SPT=53 DPT=57915 UID=GID=DNS_QR=DNS_NAME=google.com. DNS_TYPE=DNS_DATA=142.251.12.101
COMM=dig PID=10738 TGID=10739 DEV=ens18 PROTO=TCP SRC=1.1.1.1 DST=192.168.44.3 SPT=53 DPT=57915 UID=GID=DNS_QR=DNS_NAME=google.com. DNS_TYPE=DNS_DATA=142.251.12.113
COMM=dig PID=10738 TGID=10739 DEV=ens18 PROTO=TCP SRC=1.1.1.1 DST=192.168.44.3 SPT=53 DPT=57915 UID=GID=DNS_QR=DNS_NAME=google.com. DNS_TYPE=DNS_DATA=142.251.12.102
COMM=dig PID=10738 TGID=10739 DEV=ens18 PROTO=TCP SRC=1.1.1.1 DST=192.168.44.3 SPT=53 DPT=57915 UID=GID=DNS_QR=DNS_NAME=google.com. DNS_TYPE=DNS_DATA=142.251.12.139
COMM=dig PID=10738 TGID=10739 DEV=ens18 PROTO=TCP SRC=1.1.1.1 DST=192.168.44.3 SPT=53 DPT=57915 UID=GID=DNS_QR=DNS_NAME=google.com. DNS_TYPE=DNS_DATA=142.251.12.100
COMM=dig PID=10738 TGID=10739 DEV=ens18 PROTO=TCP SRC=1.1.1.1 DST=192.168.44.3 SPT=53 DPT=57915 UID=GID=DNS_QR=DNS_NAME=google.com. DNS_TYPE=DNS_DATA=142.251.12.138

到此爲止,我們已經創建了一個有用的應用程序,可以顯示系統中所有的 DNS 查詢。希望上面的解釋足夠詳細,這樣如果你對編寫 eBPF 程序感興趣,可以更容易開始。這段代碼已經幫助我更好的瞭解服務器上發生的事情,以下鏈接可以獲取完整代碼。

完整代碼 [15]

結論

這段代碼還可以做得更好嗎?當然可以!首先,應該增加對 IPv6 的支持。其次,不要再依賴 IP 頭的固定長度,而是要對其進行解析。我拒絕使用 Python 庫來處理數據包,不是沒有原因的,在 C 語言中,仍然需要手動操作。第三,用 C 語言重寫代碼也很好,可以完全放棄 Python,當然還要增加幾行 JSON 輸出的代碼,這樣在以後開發 UI 儀表盤時會更方便。這將導致第四點,對 DNS 數據包的手動分析。最後,最誘人的一點是停止查看端口 (因爲也許 DNS 數據包並不總是通過 53 端口),並嘗試分析每個數據包,在其中尋找那些符合 DNS 格式的數據包,這將使我們即使在非標準的端口上也能檢測到數據包。

你好,我是俞凡,在 Motorola 做過研發,現在在 Mavenir 做技術工作,對通信、網絡、後端架構、雲原生、DevOps、CICD、區塊鏈、AI 等技術始終保持着濃厚的興趣,平時喜歡閱讀、思考,相信持續學習、終身成長,歡迎一起交流學習。
微信公衆號:DeepNoMind

參考資料

[1]

eBPF: https://docs.kernel.org/bpf/classic_vs_extended.html

[2]

Awesome eBPF: https://github.com/zoidbergwill/awesome-ebpf

[3]

tcpdump: https://www.tcpdump.org

[4]

packetbeat: https://www.elastic.co/beats/packetbeat

[5]

Osquery: https://osquery.io

[6]

Zeek: https://zeek.org

[7]

if_ether.h: https://kernel.googlesource.com/pub/scm/linux/kernel/git/nico/archive/+/d9cc76127bcc137e3214b9166c439e02d2060cda/include/linux/if_ether.h#32

[8]

IEEE: https://standards-oui.ieee.org/ethertype/eth.txt

[9]

in.h: https://github.com/torvalds/linux/blob/master/include/uapi/linux/in.h#L43

[10]

udp_sendmsg(): https://github.com/torvalds/linux/blob/master/net/ipv4/udp.c#L1045

[11]

tcp_sendmsg(): https://github.com/torvalds/linux/blob/master/net/ipv4/tcp.c#L1478

[12]

sock: https://github.com/torvalds/linux/blob/master/include/net/sock.h#L352

[13]

__sk_buff: https://github.com/iovisor/bcc/blob/master/src/cc/compat/linux/virtual_bpf.h#L5746

[14]

dig: https://linux.die.net/man/1/dig

[15]

final_code_eBPF_dns.py: https://gist.github.com/oghie/b4e3accf1f87afcb939f884723e2b462

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/LudIel2FT2RBlxUF-1z5sw