[PT-BR] DNS Query Detection via Direct Packet Inspection
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 .

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.


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.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