[PT-BR] DNS Query Detection via Direct Packet Inspection

Escrito por  Carlos G.

Com o conhecimento sobre o funcionamento de sistemas operacionais, é sabido que os sistemas comuns são compostos de um kernel (parte central do sistema que realiza a comunicação com o hardware, gerenciamento de memória, separação dos níveis de privilégios, interfaces de comunicação e outros) e o espaço de usuário, contendo um conjunto de utilitários (essa é uma descrição abstrata, portanto não significa que todos os sistemas operacionais funcionam dessa maneira). O artigo arbodará a utilização do eBPF (extended Beckerley Packet Filter), em específico XDP, para o processamento de pacotes no kernel Linux, facilitando a contagem de requisições DNS em userland. Será demonstrado o entendimento dessa tecnologia e como utilizá-la para obter dados de consultas DNS. O Linux representa um dos maiores projetos opensource do planeta, com mais de 40 milhões de linhas de código. Ao decorrer do tempo, foram desenvolvidas tecnologias para o kernel Linux que ajudaram no processo de expansão de suas funcionalidades, sem a necessidade de realizar modificação do código kernel propriamente dito e compilação, demandando esforço, tempo e conhecimento técnico do ambiente que está sendo modificado.

Com os problemas destacados, surgiu a possibilidade de gerenciar pacotes de rede e expandir as funcionalidades já existentes em tempo de execução. O eBPF é uma atualização do BPF (Beckerley Packet Filter), ou também conhecido como classic BPF, teve a passagem para o modelo extendido em 2014 no kernel Linux.

A primeira versão (classic), era amplamente utilizada para o processamento de pacotes e filtragem. Na versão extendida, houve um avanço nas ações que podem ser realizadas com o eBPF,  além de operações de rede, como tracing, hook de funções, interceptação de chamadas de sistema e também ações mais generalistas, como implementações relacionadas à segurança, observabilidade, load balancing, debugging, firewalling, entre outros.

Para ter uma compreensão ampla do artigo, é importante entender o conceito básico sobre o Express Data Path (XDP). O XDP é um tecnologia do eBPF que possibilita o gerenciamento de pacotes de forma expressa atrelado diretamente à interface de rede, ou seja, podemos dizer que ele faz o processamento dos pacotes mais cedo no kernel (diferente do fluxo de rede comum), o que justifica prefixo “express“.

No conteúdo que será abordado neste post, para cada pacote que sai do sistema, será implementada uma lógica para estruturar e classificar o pacote, sendo possível realizar diferentes ações dependendo do protocolo trafegado. O foco será o protocolo UDP, no qual cada pacote é coletado para possibilitar a criação de um histórico das consultas DNS trafegadas pelo, bem como a quantificação dessas requisições, identificando comportamentos fora do comum. A ideia central é ter uma observabilidade de forma leve e simplificada. Vale ressaltar que por se tratar de um programa para fins didáticos, questões de eficiência não serão levadas tanto em consideração, o conteúdo serve como fonte de ideias para a criação de projetos.

Packet parsing

A codificação do projeto foi realizada com a biblioteca libbpf, pois ela implementa interfaces para utilizar o eBPF, tanto em user space quanto em kernel space.

Uma maneira de se comunicar com as funções eBPF no kernel é por meio da system call bpf, permitindo a criação de mapas, localizar elementos, atualizar elementos, fazer operações de fixação, atrelar um programa BPF, obter informações, entre outras operações .

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

Porém um ponto importante é que, realizar essas chamadas puramente pode apresentar uma barreira de complexidade na codificação. Portanto, como a biblioteca abstrai esse nível de complexidade, basta ter conhecimento das funções e de como utilizá-las durante o desenvolvimento. Isso é válido para desenvolver programas eBPF em C, Python, go e outras linguagens de programação.

No geral, programas eBPF são considerados seguros. Esse comportamento se dá devido à existência do verificador, onde ocorre a checagem das instruções e a verificação de todos os caminhos de execução do programa.

Para realizar a captura, é importante seguir um fluxo de interpretação dos pacotes que saem do sistema. Em cada protocolo, será preciso realizar sua verificação e parsing utilizando uma estrutura adequada ao protocolo. O entendimento do programa enquanto o desenvolvimento está sendo realizado pode ser simplificado com a exibição de cada variável das estruturas que foram atreladas. É interessante que se tenha certeza de que uma variável é realmente o valor esperado após sequências de parsing.

No programa criado, a primeira parte do pacote bruto é interpretada em uma estrutura ethernet (ethhdr). Note que, sempre quando ocorre o parsing do pacote, é preciso estabelecer uma verificação para aceito pelo verifier.

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

Outra estrutura utilizada é a iphdr, possibilitando que o protocolo IP seja manuseado.

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

Para exemplificar as ações descritas de como cada protocolo do pacote pode ser inspecionado, será demonstrado um ping para o domínio google.com.

Veja que o parâmetro da função para exibir o endereço em formato hexadecimal é uma estrutura iphdr.

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

No subsistema tracing do Linux, o retorno da função retorno da função responsável pela exibição do endereço de origem foi consultado. Ao inspecionar o trace, são visíveis as mensagens as mensagens emitidas pelo programa eBPF/XDP.

A exibição foi feita em hexadecimal apenas para fins demonstrativos. O endereço IP é lido a partir da ordem inversa dos bytes. Cada byte (dois caracteres no formato hexadecimal) representa um octeto do endereço IP. Segundo pesquisas realizadas, a discrepância da interpretação de pacotes em determinadas camadas é o resultado das tecnologias terem sido criadas separadamente, ou seja, primordialmente não foram feitas para serem utilizadas conforme a combinação atual. Com isso, determinada camada pode ter os dados organizados em little-endian e outras em big-endian.

Até o momento da interpretação do pacote, ocorreu a leitura do protocolo Ethernet e IP. Em seguida, a camada de transporte será interpretada.

Payload access

A interpretação do protocolo de transporte é essencial para funcionar o programa de inspeção, pois ele definirá qual será o destino final do pacote. O objetivo central é obter todas as requisições DNS que estão sendo trafegadas no sistema e o protocolo DNS usa majoritariamente UDP para realizar as consultas por questões de eficiência. O seguinte diagrama foi extraído da RFC do UDP.

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

A interpretação do conteúdo UDP ocorre com a utilização da estrutura udph, permitindo que os valores do diagrama anterior sejam acessados.

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

Veja que, com a exibição do tamanho dos pacotes UDP, os valores variam conforme a resolução dos domínios com tamanhos diferentes, indicando que o processo de parsing está correto.

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

Com a leitura dos protocolos do pacote que está sendo interceptado, resta gerenciar o payload proprimamente dito. Nele, será possível inspecionar os dados úteis transportados pelo pacote UDP.

Segundo o RFC768, o datagrama UDP é transportado em um único pacote IP, possuindo um limite de payload de 64507 bytes para IPv4 e 65527 para IPv6.

Então, para acessar o payload do pacote, basta avançar no dado bruto somando o tamanho das estruturas interpretadas anteriormente durante o 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);

Devido às verificações que o eBPF verifier faz, o payload será enviado para o user space utilizando ring buffers. Os ring buffers são estruturas de memória utilizadas para armazenar dados de forma cicular, onde conceitualmente os dados são consumidos a partir do head do buffer e novos valores são inseridos na extremidade oposta (tail), caracterizando uma estrutura de dados FIFO (first-in, first-out). O buffer é considerado como vazio quando ocorre o encontro do head e tail. Essa estrutura é implementada através dos eBPF maps, que são mecanismos para realizar a comunicação entre programas eBPF com o user space (ou programas em kernel space).

Uma questão que pode ter surgido até o momento é: “Como eu posso consumir esse buffer?“. É necessário que o buffer seja exposto. Sua identificação se dá pelo nome da estrutura que foi declarada no código eBPF, nesse caso “rb“. O pinning é um atributo configurável na estrutura que, quando declarado, realiza o pinning do ring buffer no sistema de arquivos, tornando o map visível.

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

Quando o programa eBPF for carregado, um map com o nome do ring buffer será criado no diretório “/sys/fs/bpf/“. Outra maneira de listar os maps eBPF no sistema é utilizar a ferramenta ebpftool com o comando “map list“.

bpftool map list

O trecho de código abaixo demonstra a reserva do ring buffer no kernel, utilizando a estrutura “udp_data“, e, posteriormente, o envio dos dados para o buffer.

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

__builtin_memcpy(udata->data, "INSERTEDDATA\x00",13);
        
bpf_ringbuf_submit(udata, 0);

Após a compilação, o programa pode ser carregado com o bpftool e anexação a uma interface de rede.

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

Com a programação do ring buffer no programa eBPF/XDP uma opção para consumir dados é utilizando funções de gerenciamento do ring buffer feitas para o user space.

Semelhante à codificação feita no programa eBPF, para o programa responsável pela leitura dos dados no user space, a biblioteca libbpf também foi utilizada, possibilitando a abertura de um handle para o ring buffer. Em conjunto, é realizado o consumo do buffer, repassando todos os eventos para uma função responsável por ler o pacote e interpretar os campos que armazenam os dados da consulta DNS. Note que, na chamada da função “ring_buffer__new“, uma função de callback para gerenciar os eventos do buffer é inserida como argumento.

int handle_event(void *ctx, void *data, unsigned long size){
    printf("Received %d bytes\n", (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 buffer\n");
        return 1;
    }

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

ring_buffer__free(rb);

Com os dados disponíveis para consumo, na leitura direta dos dados recebidos via ring buffer no user space, é observável a presença de valores que representam uma consulta DNS.

A leitura livre do protocolo UDP não é o suficiente para separar as consultas DNS de outros pacotes trafegados pelas aplicações no sistema. É necessário ajustar o gerenciamento para que apenas consultas DNS sejam coletadas. Uma abordagem é limitar a captura para à porta 53.

Captura livre dos pacotes
Captura com filtro de porta 53

Como a captura está acontecendo na interface de rede à qual o eBPF/XDP foi atrelado, toda consulta DNS dos programas em execução no sistema operacional que utilizarem essa interface serão coletadas.

Como os dados foram armazenados em um banco de dados, a inspeção dos eventos pode ser efetuada por meio de consultas simples que permitem destacar um domínio específico, listar todos os registros ou obter a quantidade de vezes que determinado domínio foi consultado. Com opções de data e hora disponíveis, é possível trabalhar com análises em intervalos de tempo.

Para trabalhos futuros sobre o tratamento automático dos dados coletados e de forma complexa, é preciso pensar que não é o suficiente realizar a contagem de consultas DNS em determinados espaços de tempo, porque um atacante pode realizar a extração das informações do ambiente com poucos bytes em períodos prolongados de dias.

O tamanho do subdomínio da consulta DNS também não é o suficiente para destacar uma exfiltração, pois a extração pode ocorrer por um subdomínio extenso (vários carácteres no subdomínio) ou apenas de byte em byte,  por exemplo “41.domain.com“, “42.domain.com“. Sabendo dessas nuâncias, é interessante que ocorra um trabalho de análise dos dados para que seja definido se o domínio analisado é confiável, conhecido e se o comportamento foge do padrão.

Assume-se que uma aplicação comum não contenha diversos subdomínos com alta randomização em seus valores, sem um sentido completo. Caso um comportamento como esse seja identificado, é possível que algum dado esteja sendo extraído do sistema. A implementação do programa foi realizada em um computador de uso comum, mas a abordagem pode ser aplicada para servidores e appliances que oferecem suporte para eBPF.

A abordagem utilizando o protocolo UDP para captura de domínios é uma ação demonstrativa, mas a codificação apresentada pode ser utilizada para outros protocolos ou detecções. Todo o contexto descrito no post tem como objetivo demonstrar como, com uma solução simples, é possível ter visibilidade das atividades que acontecem no sistema operacional, permitindo metrificar tempo, quantidade e aleatoriedade de domínios/subdomínios. O exemplo demonstra que, se um sistema for comprometido e as interações com servidores externos ocorrerem via protocolo DNS, uma investigação mais precisa poderá ser efetuada.

xdp.bpf.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 %ld\n", domain, (long)currTime);
    printf("Domain: %s\n", domain);
    domain[qname_len - 6] = '\0'; // 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 the 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 buffer\n");
        return 1;
    }

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

    ring_buffer__free(rb);
    return 0;
}

Compilação

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

Referências

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.