[EN] DNS Query Detection via Direct Packet Inspection

Escrito por  Carlos G.

With knowledge of how operating systems work, it is known that common systems are composed of a kernel (the central part of the system that handles communication with the hardware, memory management, separation of privilege levels, communication interfaces, and others) and user space, containing a set of utilities (this is an abstract description, therefore it does not mean that all operating systems work this way). This article will discuss the use of eBPF (extended Beckerley Packet Filter), specifically XDP, for packet processing in the Linux kernel, facilitating the counting of DNS requests in userland. It will demonstrate an understanding of this technology and how to use it to obtain DNS query data. Linux represents one of the largest open-source projects on the planet, with over 40 million lines of code. Over time, technologies have been developed for the Linux kernel that have helped in the process of expanding its functionalities, without the need to modify the kernel code itself and compile it, thus saving effort, time, and technical knowledge of the environment being modified.

With the highlighted problems, the possibility arose of managing network packets and expanding existing functionalities at runtime. eBPF is an update to BPF (Beckerley Packet Filter), also known as classic BPF, which transitioned to the extended model in 2014 in the Linux kernel.

The first version (classic) was widely used for packet processing and filtering. The extended version advanced the actions that can be performed with eBPF, in addition to network operations such as tracing, function hooks, system call interception, and more general actions such as implementations related to security, observability, load balancing, debugging, firewalling, among others.

To fully understand this article, it’s important to grasp the basic concept of Express Data Path (XDP). The XDP is an eBPF technology that enables express packet management directly linked to the network interface, in other words, it processes packets earlier in the kernel (unlike a regular network flow), hence the “express” prefix.

In the content covered in this post, for each packet leaving the system, logic will be implemented to structure and classify the packet, allowing for different actions depending on the protocol used. The focus will be on the UDP protocol, where each packet is collected to enable the creation of a history of DNS queries transmitted, as well as the quantification of these requests, identifying unusual behaviors. The central idea is to have observability in a lightweight and simplified way. It is worth noting that, since this is a program for educational purposes, efficiency issues will not be given as much consideration; the content serves as a source of ideas for the creation of projects.

Packet parsing

The project’s coding was done using the libbpf library, as it implements interfaces for using eBPF in both user space and kernel space.

One way to communicate with eBPF functions in the kernel is through the bpf system call, allowing the creation of maps, locating elements, updating elements, performing pinning operations, attaching a BPF program, obtaining information, among other operations.

https://man7.org/linux/man-pages/man2/bpf.2.html

However, an important point is that performing these calls purely on its own can present a complexity barrier in coding. Therefore, since the library abstracts this level of complexity, it is sufficient to have knowledge of the functions and how to use them during development. This is valid for developing eBPF programs in C, Python, Go, and other programming languages.

Overall, eBPF programs are considered safe. This behavior is due to the existence of the verifier, where instructions are checked and all program execution paths are verified.

To perform the capture, it is important to follow a flow for interpreting the packets leaving the system. For each protocol, it will be necessary to perform its verification and parsing using a structure appropriate to the protocol. Understanding the program while development is being carried out can be simplified by displaying each variable of the structures that have been attached. It is important to be sure that a variable is indeed the expected value after parsing sequences.

In the program created, the first part of the raw packet is interpreted in an Ethernet structure (ethhdr). Note that, whenever packet parsing occurs, a check must be established for acceptance by the verifier.

if(data + sizeof(struct ethhdr) < data_end){
        struct ethhdr *eth = data;

Another structure used is iphdr, which allows the IP protocol to be handled.

if(data + sizeof(struct ethhdr) + sizeof(struct iphdr) < data_end){
    struct iphdr *iph   = data + sizeof(struct ethhdr);
}

To illustrate the described actions of how each protocol in the packet can be inspected, a ping to the google.com domain will be demonstrated.

Note that the function parameter for displaying the address in hexadecimal format is an iphdr structure.

void show_addresses(struct iphdr *i){
        bpf_printk("Source - %x", i->saddr);
}

In the Linux tracing subsystem, the return value of the function responsible for displaying the source address was consulted. Inspecting the trace reveals the messages emitted by the eBPF/XDP program.

The display was done in hexadecimal for demonstration purposes only. The IP address is read from the reverse order of the bytes. Each byte (two characters in hexadecimal format) represents an octet of the IP address. According to research, the discrepancy in packet interpretation at certain layers is a result of the technologies having been created separately, that is, they were not primarily designed to be used in their current combination. Therefore, a given layer may have data organized in little-endian and others in big-endian.

Up to the point of packet interpretation, the Ethernet and IP protocols have been read. Next, the transport layer will be interpreted.

Payload access

Interpreting the transport protocol is essential for the inspection program to function, as it will define the final destination of the packet. The central objective is to obtain all DNS requests being transmitted within the system, and the DNS protocol primarily uses UDP to perform these queries for efficiency reasons. The following diagram was extracted from the UDP RFC.

0      7 8     15 16    23 24    31
+--------+--------+--------+--------+
|          source address           |
+--------+--------+--------+--------+
|        destination address        |
+--------+--------+--------+--------+
|  zero  |protocol|   UDP length    |
+--------+--------+--------+--------+

The interpretation of UDP content occurs using the udph structure, allowing access to the values ​​from the previous diagram.

struct udphdr *udph = data + sizeof(struct ethhdr) + sizeof(struct iphdr);

Notice that, when displaying the UDP packet sizes, the values ​​vary according to the resolution of the domains with different sizes, indicating that the parsing process is correct.

dig A google.com
dig A BBBBBBBBBBBBBBBB.google.com

Once the protocols of the intercepted packet are read, the next step is to manage the payload itself. Within the payload, it will be possible to inspect the useful data transported by the UDP packet.

According to RFC768, the UDP datagram is transported in a single IP packet, with a payload limit of 64507 bytes for IPv4 and 65527 bytes for IPv6.

So, to access the packet payload, simply advance through the raw data by adding the size of the structures interpreted earlier during parsing.

if(data + sizeof(struct ethhdr) + sizeof(struct iphdr) + sizeof(struct udphdr) < data_end){
        if(udph->len >= MAX_UDP_SIZE){
            udph->len = MAX_UDP_SIZE;
        }
        
        udata->len = udph->len;
        
        char *payload = data + sizeof(struct ethhdr) + sizeof(struct iphdr) + sizeof(struct udphdr);

Due to the checks performed by the eBPF verifier, the payload will be sent to user space using ring buffers. Ring buffers are memory structures used to store data in a circular fashion, where conceptually data is consumed starting from the head of the buffer and new values ​​are inserted at the opposite end (tail), characterizing a FIFO (first-in, first-out) data structure. The buffer is considered empty when the head and tail meet. This structure is implemented through eBPF maps, which are mechanisms for communication between eBPF programs and user space (or kernel space programs).

One question that may have arisen so far is: “How can I consume this buffer?“. It is necessary that the buffer be exposed. Its identification is given by the name of the structure that was declared in the eBPF code, in this case “rb“. Pinning is a configurable attribute in the structure that, when declared, pins the ring buffer to the file system, making the map visible.

struct {
    __uint(type, BPF_MAP_TYPE_RINGBUF);
    __uint(max_entries, 256 * 1024);
    __uint(pinning, LIBBPF_PIN_BY_NAME);
} rb SEC(".maps");

When the eBPF program is loaded, a map with the ring buffer name will be created in the “/sys/fs/bpf/” directory. Another way to list the eBPF maps in the system is to use the ebpftool with the “map list” command.

bpftool map list

The code snippet below demonstrates reserving the ring buffer in the kernel using the “udp_data” structure, and subsequently sending the data to the buffer.

struct udp_data *udata = bpf_ringbuf_reserve(&rb, sizeof(struct udp_data), 0);

__builtin_memcpy(udata->data, "INSERTEDDATAx00",13);
        
bpf_ringbuf_submit(udata, 0);

After compilation, the program can be loaded with bpftool and attached to a network interface.

bpftool prog load exfiltration.bpf.o /sys/fs/bpf/name
bpftool net attach xdp pinned /sys/fs/bpf/name dev eth0

User space packet analysis

With the ring buffer programming in the eBPF/XDP program, one option for consuming data is by using ring buffer management functions designed for user space.

Similar to the coding done in the eBPF program, the libbpf library was also used for the program responsible for reading data in user space, enabling the opening of a handle for the ring buffer. In conjunction, the buffer is consumed, passing all events to a function responsible for reading the packet and interpreting the fields that store the DNS query data. Note that, in the call to the “ring_buffer__new” function, a callback function to manage buffer events is inserted as an argument.

int handle_event(void *ctx, void *data, unsigned long size){
    printf("Received %d bytesn", (int)size);

    for(int i=0; i < 150; i++){
        printf("%c - ", *((char *)data+i));
        if(i != 0 && i % 8 == 0){
                puts("n");
        }
    }
    printf("n");
    return 0;
}

map_fd = bpf_obj_get("/sys/fs/bpf/rb");
if (map_fd < 0) {
    printf("Invalid ring buffer");
    return 1;
}

struct ring_buffer *rb = ring_buffer__new(map_fd, handle_event, NULL, NULL);
    if (!rb) {
        printf("Failed to acquire ring buffern");
        return 1;
    }

while (!quit) {
    ring_buffer__poll(rb, 100);
}

ring_buffer__free(rb);

With the data available for consumption, by directly reading the data received via the ring buffer in user space, it is possible to observe the presence of values ​​that represent a DNS query.

Free reading of the UDP protocol is not sufficient to separate DNS queries from other packets transmitted by applications on the system. It is necessary to adjust the management so that only DNS queries are collected. One approach is to limit the capture to port 53.

free packet capture
capture with port 53 filter

Since the capture is happening on the network interface to which eBPF/XDP was attached, all DNS queries from programs running on the operating system that use that interface will be collected.

Since the data was stored in a database, event inspection can be performed using simple queries that allow you to highlight a specific domain, list all records, or obtain the number of times a given domain was queried. With date and time options available, it is possible to work with analyses at time intervals.

For future work on the automated processing of collected data in a complex way, it is necessary to consider that simply counting DNS queries at specific time intervals is not enough, because an attacker could extract information from the environment with just a few bytes over extended periods of days.

The size of the DNS query subdomain is also not enough to highlight an exfiltration, as extraction can occur through a long subdomain (many characters in the subdomain) or just byte by byte, for example “41.domain.com“, “42.domain.com“. Knowing these nuances, it is interesting to perform data analysis to determine if the analyzed domain is trustworthy, known, and if the behavior deviates from the norm.

It is assumed that a typical application does not contain numerous subdomains with highly random values ​​lacking complete meaning. If such behavior is identified, it is possible that some data is being extracted from the system. The program implementation was performed on a standard computer, but the approach can be applied to servers and appliances that support eBPF.

The approach using the UDP protocol for domain capture is a demonstration, but the presented coding can be used for other protocols or detections. The entire context described in this post aims to demonstrate how, with a simple solution, it’s possible to gain visibility into the activities occurring in the operating system, allowing for the measurement of time, quantity, and randomness of domains/subdomains. The example demonstrates that if a system is compromised and interactions with external servers occur via the DNS protocol, a more precise investigation can be carried out.

ebpf_xdp.c

#include <linux/bpf.h>
#include <linux/ip.h>
#include <linux/if_ether.h>
#include <linux/string.h>
#include <linux/udp.h>
#include <bpf/bpf_endian.h>
#include <bpf/bpf_helpers.h>

#define PROTO_ICMP 1
#define PROTO_UDP  17
#define PROTO_TCP  6
#define PROTO_RDP  27
#define PROTO_IPV6 41

#define MAX_UDP_SIZE 65527


struct udp_data {
	int len;
	char data[MAX_UDP_SIZE];
};

struct {
	__uint(type, BPF_MAP_TYPE_RINGBUF);
	__uint(max_entries, 256 * 1024);
	__uint(pinning, LIBBPF_PIN_BY_NAME);
} rb SEC(".maps");

int mac_confusion(char *mac, char *newmac){
	char old_mac[ETH_ALEN];
		
	if(!mac){
		return -1;
	}
	
	for(int i=0; i < ETH_ALEN; i++){
		old_mac[i] = mac[i];
		mac[i] = newmac[i];
	}
	return 0;
}

int parse_udp(void *data, void *data_end){
	struct udphdr *udph = data + sizeof(struct ethhdr) + sizeof(struct iphdr);
	
	if ((void *)(udph+1) > data_end){
	    return XDP_PASS;
	}

	char port = bpf_ntohs(udph->source);
	if(port != 53){
                return XDP_PASS;
        };

	bpf_printk("UDP packet port %d", port);
        struct udp_data *udata = bpf_ringbuf_reserve(&rb, sizeof(struct udp_data), 0);

       	if(!udata){
                return XDP_PASS;
        }

	if(data + sizeof(struct ethhdr) + sizeof(struct iphdr) + sizeof(struct udphdr) < data_end){
		if(udph->len >= MAX_UDP_SIZE){
			udph->len = MAX_UDP_SIZE;
		}
		udata->len = udph->len;
		
		char *payload = data + sizeof(struct ethhdr) + sizeof(struct iphdr) + sizeof(struct udphdr);
		if((void *)payload < data_end){
			bpf_probe_read_kernel(udata->data, udph->len, payload);
		}
	}
	bpf_ringbuf_submit(udata, 0);
	return 0;

}

void parse_tcp(void *x){
}

void parse_icmp(void *x){
}

int prepare_packet(struct xdp_md *ctx){

	void *data = (void *)(long)ctx->data;
	void *data_end = (void *)(long)ctx->data_end;
	
	if(data + sizeof(struct ethhdr) < data_end){
		struct ethhdr *eth = data;
		if(data + sizeof(struct ethhdr) + sizeof(struct iphdr) < data_end){
			struct iphdr *iph   = data + sizeof(struct ethhdr);
			unsigned char proto = iph->protocol;
			int iph_len         = iph->ihl * 4;

			if(iph_len > sizeof(struct iphdr)){
				return XDP_PASS;
			}

			switch(proto){
				case PROTO_ICMP:
					parse_icmp(data);
					break;
				case PROTO_UDP:
					return parse_udp(data, data_end);
				case PROTO_TCP:
					parse_tcp(data);
					break;
				default:
					break;
			}
       		}
	}
	return 0;
}
SEC("xdp")
int exfiltration(struct xdp_md *ctx){
	
	int ret = prepare_packet(ctx);
	if(ret != 0){
		return XDP_PASS;
	}

	return XDP_PASS;
}
char LICENSE[] SEC("license") = "Dual BSD/GPL";

user_code.c

#include <stdio.h>
#include <signal.h>
#include <bpf/bpf.h>
#include <bpf/libbpf.h>
#include <sqlite3.h>
#include <stdlib.h>
#include <time.h>

sqlite3 *DB;

struct dns_header {
    short id;
    short flags;
    short qdcount;
    short ancount;
    short nscount;
    short arcount;
};

int parse_qname(char *data, char *out, int out_len) {
    int i = 4, j = 0;

    /*
    for(int i =0; i < 150; i++){
    	printf("%c - ", *((char *)data+i));
    	if(i != 0 && i % 8 == 0){
        puts("n");
   	 }
    }*/

    while (data[i] != 0) {
        char len = data[i++];
        if (j + len + 1 >= out_len){
		return -1;
	}

        for (int k = 0; k < len; k++) {
            out[j++] = data[i++];
        }
        out[j++] = '.';
    }

    return i + 1;     
}

int evt = 0;

void handle(int sig) {
    evt = 1;
}

static int handle_event(void *ctx, void *data, size_t size) {
    time_t currTime;
    void *ptr = data + sizeof(struct dns_header);
    char domain[150];
    memset(domain, 0, sizeof(domain));
    int qname_len = parse_qname(ptr, domain, sizeof(domain));
    time(&currTime);

    if(qname_len < 0){
	    printf("Invalid packet!n");
	    return 0;
    }
    //printf("Domain: %s %ldn", domain, (long)currTime);
    printf("Domain: %sn", domain);
    domain[qname_len - 6] = ''; // adjust with the parse alignment

    char query[230];
    sprintf(query, "INSERT INTO ddata (domain,time) values ('%s','%ld');",domain, currTime);
    sqlite3_exec(DB, query, NULL, NULL, NULL);

    return 0;
}


int main() {
    int err, map_fd;
    err = sqlite3_open("domaindata.db", &DB);

    if(err){
	    printf("Error opening database\n");
	    exit(1);
    }else{
	    printf("Database opened\n");
    }

    signal(SIGINT, handle);
    map_fd = bpf_obj_get("/sys/fs/bpf/rb");
    if (map_fd < 0) {
	    printf("Invalid ring buffer");
        return 1;
    }

    struct ring_buffer *rb = ring_buffer__new(map_fd, handle_event, NULL, NULL);
    if (!rb) {
        printf("Failed to acquire ring buffern");
        return 1;
    }

    printf("To quit press Ctrl+Cn");
    while (!evt) {
        ring_buffer__poll(rb, 100);
    }

    ring_buffer__free(rb);
    return 0;
}

Compiling

clang -target bpf -g -O2 -c xdp.bpf.c -o xdp.bpf.o
clang user_code.c -lbpf -o user_space

References

https://docs.ebpf.io/ebpf-library/libbpf

https://ebpf.hamza-megahed.com/docs/chapter2/2-maps

https://www.linuxtoday.com/blog/mythtv-36-0-open-source-media-center-is-out-now-with-support-for-ffmpeg-8

https://www.rfc-editor.org/rfc/rfc768

https://mostlynerdless.de/blog/2024/03/12/hello-ebpf-ring-buffers-in-libbpf-6/

https://docs.ebpf.io/ebpf-library/libbpf/userspace/ring_buffer__consume_n

https://www.cloudns.net/blog/dns-use-udp

Logo da Hakai.