eBPF 深度探索: 高效 DNS 監控實現
eBPF 可以靈活擴展 Linux 內核機制,本文通過實現一個 DNS 監控工具爲例,介紹了怎樣開發實際的 eBPF 應用。原文: A Deep Dive into eBPF: Writing an Efficient DNS Monitoring
eBPF[1] 是內核內置的虛擬機,在 Linux 內核內部提供了高層庫、指令集以及執行環境,被用於諸多 Linux 內核子系統,特別是網絡、跟蹤、調試和安全領域。其功能即支持改變內核對數據包的處理,也允許對網絡設備 (如智能網卡) 進行編程。
已經有大量各種語言的關於 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_t
和udp_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's 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 包,我們將檢查其標誌,過濾掉不攜帶數據的包 (SYN、ACK 等)。
但我們必須恢復鍵,從而從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=0 GID=0 DNS_QR=0 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=0 GID=0 DNS_QR=1 DNS_NAME=google.com. DNS_TYPE=A 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=0 GID=0 DNS_QR=1 DNS_NAME=google.com. DNS_TYPE=A 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=0 GID=0 DNS_QR=1 DNS_NAME=google.com. DNS_TYPE=A 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=0 GID=0 DNS_QR=1 DNS_NAME=google.com. DNS_TYPE=A 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=0 GID=0 DNS_QR=1 DNS_NAME=google.com. DNS_TYPE=A 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=0 GID=0 DNS_QR=1 DNS_NAME=google.com. DNS_TYPE=A 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