How to Build a Linux Packet Sniffer Using Raw Sockets and BPF
This article walks through creating a Linux packet sniffer that bypasses libpcap, explains PF_PACKET raw sockets, shows how to bind to a specific interface, enable promiscuous mode, attach a BPF filter compiled with tcpdump, and parse Ethernet and IP headers in a continuous receive loop.
PF_PACKET socket creation
PF_PACKET (now AF_PACKET) is the low‑level packet interface that delivers raw Ethernet frames directly to a user‑space socket, bypassing the TCP/IP stack. The socket is created with the socket system call, specifying AF_PACKET as the domain, SOCK_RAW as the type, and htons(ETH_P_IP) as the protocol to receive IPv4 packets.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <unistd.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <sys/ioctl.h>
#include <linux/if_ether.h>
#include <linux/filter.h>
#include <net/if.h>
#include <arpa/inet.h>
int sock = socket(AF_PACKET, SOCK_RAW, htons(ETH_P_IP));
if (sock < 0) {
perror("socket");
exit(1);
}Binding to a specific interface
To restrict capture to a single NIC, setsockopt with SO_BINDTODEVICE is used. The interface name (e.g., "eth0") is passed as a null‑terminated string.
const char *dev = "eth0";
if (setsockopt(sock, SOL_SOCKET, SO_BINDTODEVICE, dev, strlen(dev) + 1) < 0) {
perror("setsockopt bind device");
close(sock);
exit(1);
}Promiscuous (mixed) mode
Promiscuous mode is enabled by toggling the IFF_PROMISC flag with ioctl on the same socket.
struct ifreq ifr;
strncpy(ifr.ifr_name, "eth0", IF_NAMESIZE);
if (ioctl(sock, SIOCGIFFLAGS, &ifr) == -1) {
perror("ioctl get flags");
close(sock);
exit(1);
}
ifr.ifr_flags |= IFF_PROMISC;
if (ioctl(sock, SIOCSIFFLAGS, &ifr) == -1) {
perror("ioctl set promisc");
close(sock);
exit(1);
}Berkeley Packet Filter (BPF) basics
BPF is a register‑based virtual CPU used for in‑kernel packet filtering. It provides an accumulator ( A), an index register ( X), a scratch memory array, and an implicit program counter. The instruction set includes load/store ( ld, ldh, ldx), arithmetic ( add, sub, mul), jumps ( jeq, jgt), and return ( ret) operations. The original BPF paper (1993) describes the design in detail. Classic BPF (cBPF) runs in the kernel as a bytecode interpreter. Since 2011 the kernel also includes a JIT compiler that translates BPF bytecode to native instructions, reducing per‑packet filtering overhead.
Example BPF program (filter IPv4)
The following four‑instruction program accepts only IPv4 packets (Ethernet type 0x0800) and drops everything else.
(000) ldh [12]
(001) jeq #0x800 jt 2 jf 3
(002) ret #262144
(003) ret #0Explanation: ldh [12] loads the 16‑bit Ethernet type field (offset 12) into the accumulator. jeq #0x800 jt 2 jf 3 jumps to instruction 2 if the type equals 0x0800 (IPv4); otherwise it jumps to instruction 3. ret #262144 returns a non‑zero value, causing the kernel to deliver the packet. ret #0 drops the packet.
Generating BPF bytecode with tcpdump
tcpdump can compile a filter expression to C‑style bytecode using the -dd option. For the expression ip the output is:
{ 0x28, 0, 0, 0x0000000c },
{ 0x15, 0, 1, 0x00000800 },
{ 0x6, 0, 0, 0x00040000 },
{ 0x6, 0, 0, 0x00000000 }The bytecode is placed in a struct sock_filter array and wrapped in a struct sock_fprog :
struct sock_filter BPF_code[] = {
{ 0x28, 0, 0, 0x0000000c },
{ 0x15, 0, 1, 0x00000800 },
{ 0x6, 0, 0, 0x00040000 },
{ 0x6, 0, 0, 0x00000000 }
};
struct sock_fprog Filter = {
.len = sizeof(BPF_code)/sizeof(BPF_code[0]),
.filter = BPF_code
};
if (setsockopt(sock, SOL_SOCKET, SO_ATTACH_FILTER, &Filter, sizeof(Filter)) < 0) {
perror("setsockopt attach filter");
close(sock);
exit(1);
}Kernel integration of BPF
When a PF_PACKET socket is created, the kernel function packet_create (in net/packet/af_packet.c ) attaches a hook ( packet_rcv ) to the socket. Incoming packets trigger packet_rcv , which calls run_filter . The filter logic is implemented in sk_run_filter , which interprets the BPF bytecode in a loop, updating A , X , and the scratch memory according to each instruction.
static int packet_rcv(struct sk_buff *skb, struct net_device *dev,
struct packet_type *pt, struct net_device *orig_dev)
{
/* ... */
res = run_filter(skb, sk, snaplen);
if (!res)
goto drop_n_restore; /* packet rejected */
__skb_queue_tail(&sk->sk_receive_queue, skb);
return 0;
}The run_filter helper obtains the attached sock_filter from the socket and invokes sk_run_filter :
static inline unsigned int run_filter(struct sk_buff *skb,
struct sock *sk, unsigned int res)
{
struct sk_filter *filter;
rcu_read_lock_bh();
filter = rcu_dereference(sk->sk_filter);
if (filter)
res = sk_run_filter(skb, filter->insns, filter->len);
rcu_read_unlock_bh();
return res;
}The interpreter walks the instruction array:
for (pc = 0; pc < flen; pc++) {
fentry = &filter[pc];
switch (fentry->code) {
case BPF_ALU|BPF_ADD|BPF_X: A += X; continue;
case BPF_ALU|BPF_ADD|BPF_K: A += fentry->k; continue;
/* ... other ALU cases ... */
case BPF_RET|BPF_K: return fentry->k;
case BPF_RET|BPF_A: return A;
case BPF_ST: mem[fentry->k] = A; continue;
case BPF_STX: mem[fentry->k] = X; continue;
default: WARN_ON(1); return 0;
}
}
return 0;JIT compilation (since 2011)
The kernel JIT translates the BPF bytecode into native CPU instructions at load time, eliminating the interpreter loop for each packet and dramatically improving throughput.
Packet processing loop in user space
After the socket and optional filter are set up, packets are read with recvfrom inside an infinite loop. The example prints Ethernet MAC addresses, verifies that the payload is an IPv4 packet (first byte 0x45), and then prints source/destination IP addresses, ports, and the L4 protocol using a helper function.
while (1) {
int n = recvfrom(sock, buffer, 2048, 0, NULL, NULL);
if (n < 42) {
perror("recvfrom");
close(sock);
exit(0);
}
unsigned char *eth = buffer;
printf("Src MAC %02x:%02x:%02x:%02x:%02x:%02x
",
eth[0], eth[1], eth[2], eth[3], eth[4], eth[5]);
printf("Dst MAC %02x:%02x:%02x:%02x:%02x:%02x
",
eth[6], eth[7], eth[8], eth[9], eth[10], eth[11]);
unsigned char *ip = buffer + 14; /* Ethernet header size */
if (*ip == 0x45) {
printf("Src IP %d.%d.%d.%d
", ip[12], ip[13], ip[14], ip[15]);
printf("Dst IP %d.%d.%d.%d
", ip[16], ip[17], ip[18], ip[19]);
printf("Src port %d, Dst port %d
",
(ip[20] << 8) + ip[21], (ip[22] << 8) + ip[23]);
printf("L4 protocol %s
", transport_protocol(ip[9]));
}
}Summary of the workflow
Use socket(AF_PACKET, SOCK_RAW, htons(ETH_P_IP)) to obtain raw Ethernet frames.
Bind to a specific NIC with SO_BINDTODEVICE and enable promiscuous mode via ioctl and IFF_PROMISC.
Generate a cBPF program (e.g., with tcpdump -dd ip) and attach it to the socket using setsockopt(..., SO_ATTACH_FILTER, ...).
The kernel runs the filter in‑kernel, either via the interpreter or the JIT, ensuring minimal copy overhead.
In user space, read packets with recvfrom and decode Ethernet, IP, and transport headers as needed.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Code DAO
We deliver AI algorithm tutorials and the latest news, curated by a team of researchers from Peking University, Shanghai Jiao Tong University, Central South University, and leading AI companies such as Huawei, Kuaishou, and SenseTime. Join us in the AI alchemy—making life better!
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
