Understanding Network Packet Offsets & Safe Parsing in eBPF

Continuing our series on systems programming, last time we built a syscall logger. Today, we’re going deeper into the network stack with a network packet parser using eBPF and Rust. This logic is the backbone of modern network observability, security, and performance tools. By the end, you’ll understand how to safely extract protocol fields from raw packets at kernel speed—and how this foundation can be extended to build firewalls, traffic monitors, or even protocol analyzers.


What is a Network Packet Parser?

A network packet parser is a piece of code that inspects the raw bytes of a network packet as it moves through the system. Its job is to:

  • Identify protocol headers (Ethernet, IP, TCP/UDP, etc.)
  • Extract fields (like source/destination IPs and ports)
  • Make decisions (log, drop, redirect, etc.) based on packet contents

Uses:
Packet parsers are used in firewalls, intrusion detection systems, traffic monitoring, and performance analytics. In this tutorial, we’ll focus on parsing Ethernet, IPv4, TCP, and UDP headers safely in eBPF. This logic can be extended to support more protocols, build advanced filters, or even modify packets in flight.


Some Basic Concepts

Let’s break down the core ideas behind packet parsing in eBPF, as simply as possible:

1. 📦 What is a packet?

A network packet is just a chunk of bytes moving through your network card. It usually looks like this:

1
2
3
4
5
+----------------+----------------+--------------------+
| Ethernet Header| IP Header | TCP/UDP Header | |
+----------------+----------------+--------------------+
0 14 34 ~54 |
+----------------+----------------+--------------------+

Each protocol has a fixed-size header, and the data starts at a certain offset (position) from the beginning of the packet.


2. 🧮 What is an offset?

An offset is “how many bytes from the start of the packet do we want to look at?”

  • Ethernet header is 14 bytes → IP starts at offset 14
  • IP header is 20 bytes → TCP starts at offset 14 + 20 = 34

So:

1
offset = 14 (Ethernet) + 20 (IP) = 34

3. 🧷 Accessing via pointers

In kernel space (like eBPF), we don’t get structs like Ipv4Hdr directly.
Instead, we get access to the raw memory (bytes) of the packet.
We have to manually cast bytes at the right offset into the right struct (EthHdr, Ipv4Hdr, etc.).

But we can’t just blindly read memory.
The eBPF verifier wants to make sure we don’t read beyond packet bounds.


4. 🧾 The packet memory range

The packet is in memory between two pointers:

1
2
ctx.data() // start of packet
ctx.data_end() // end of packet

So, to safely read T bytes from offset N, we must check:

1
ctx.data() + N + size_of::<T>() <= ctx.data_end()

This ensures we’re not reading beyond the memory the packet provides.


✅ Full Explanation of ptr_at()

Here’s the safe pointer helper:

1
2
3
4
5
6
7
8
9
10
11
#[inline(always)]
fn ptr_at<T>(ctx: &XdpContext, offset:usize) -> Result<*const T, ()> {
let start = ctx.data();
let end = ctx.data_end();
let len = mem::size_of::<T>();

if start + offset + len > end {
return Err(());
}
Ok((start + offset) as *const T)
}

What it does:

  • Gets start and end of the packet memory
  • Gets the size of the struct you want to read (e.g., Ethernet header = 14 bytes)
  • Checks if it’s safe to read that struct from the given offset
  • If safe, returns a pointer to that location

This pointer is still unsafe to read, but now it’s verifier-approved!


💡 Example: Reading the IPv4 Header

1
let ipv4hdr: *const Ipv4Hdr = ptr_at(&ctx, EthHdr::LEN)?;

This means: “start reading an Ipv4Hdr at offset 14”.
If it’s safe, you get a pointer to the IPv4 header.


🔒 Why all this checking?

In kernel-space, safety is critical.
If your program tries to read memory it shouldn’t, the eBPF verifier will reject it.
So ptr_at makes sure you only read what’s inside the actual packet.


In simple words:
ptr_at<T>(ctx, offset) = “Give me a pointer to a struct T located at offset bytes from the start of the packet — but only if that memory is inside the packet’s bounds.”


What is XDP?

XDP (eXpress Data Path) is a high-performance packet processing framework in the Linux kernel, powered by eBPF. It allows you to run custom programs at the earliest possible point in the network stack—right as packets arrive from the NIC.
This means you can inspect, filter, or redirect packets with minimal latency, making XDP ideal for firewalls, DDoS mitigation, and advanced monitoring.


Setting Up the Codebase

If you haven’t set up your development environment yet, please refer to the previous blog:
Track Linux Syscalls with Rust and eBPF

To start, follow the Rust eBPF development setup instructions provided by the Aya project.

Generate a new eBPF project template:

1
cargo generate -a aya-rs/aya-template -n xdp_packet_parser
  • Select xdp as the type of eBPF program when prompted.

Main Packet Parsing Logic

Let’s walk through the key steps of our XDP packet parser.

1. Get the IPv4 packets from the input packet stream

1
2
3
4
5
let ethhdr: *const EthHdr = ptr_at(&ctx, 0)?;
match unsafe { (*ethhdr).ether_type } {
EtherType::Ipv4 => {},
_ => return Ok(xdp_action::XDP_PASS),
}
  • Extract the Ethernet header at offset 0.
  • If the EtherType is IPv4, continue parsing; otherwise, let the packet pass.

2. Extracting and Logging Parameters

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
fn try_xdp_firewall(ctx: XdpContext) -> Result<u32, ()> {
// Extract Ethernet header
let ethhdr: *const EthHdr = ptr_at(&ctx, 0)?;
// Available Ethernet parameters:
// let src_mac = unsafe { (*ethhdr).src_addr }; // Source MAC address [u8; 6]
// let dst_mac = unsafe { (*ethhdr).dst_addr }; // Destination MAC address [u8; 6]
match unsafe { (*ethhdr).ether_type} {
EtherType::Ipv4 => {},
_ => return Ok(xdp_action::XDP_PASS),
}

// Extract IPv4 header
let ipc4hdr: *const Ipv4Hdr = ptr_at(&ctx, EthHdr::LEN)?;
let source_addr = u32::from_be_bytes(unsafe { (*ipc4hdr).src_addr });
let dest_addr = u32::from_be_bytes(unsafe { (*ipc4hdr).dst_addr });
// Available IPv4 parameters:
// let ttl = unsafe { (*ipc4hdr).ttl }; // Time To Live
// let tos = unsafe { (*ipc4hdr).tos }; // Type of Service
// let tot_len = u16::from_be(unsafe { (*ipc4hdr).tot_len }); // Total Length
// let id = u16::from_be(unsafe { (*ipc4hdr).id }); // Identification
// let frag_off = unsafe { (*ipc4hdr).frag_off }; // Fragment Offset

let (source_port, dest_port) = match unsafe { (*ipc4hdr).proto} {
IpProto::Tcp => {
let tcphdr: *const TcpHdr = ptr_at(&ctx, EthHdr::LEN + Ipv4Hdr::LEN)?;
// Available TCP parameters:
// let seq = u32::from_be(unsafe { (*tcphdr).seq }); // Sequence Number
// let ack = u32::from_be(unsafe { (*tcphdr).ack_seq }); // Acknowledgment Number
// let window = u16::from_be(unsafe { (*tcphdr).window }); // Window Size
// let flags = unsafe { (*tcphdr).flags }; // TCP Flags (SYN, ACK, etc)
(
u16::from_be(unsafe { (*tcphdr).source }),
u16::from_be(unsafe { (*tcphdr).dest })
)
}
IpProto::Udp => {
let udphdr: *const UdpHdr = ptr_at(&ctx, EthHdr::LEN + Ipv4Hdr::LEN)?;
// Available UDP parameters:
// let length = u16::from_be(unsafe { (*udphdr).len }); // UDP datagram length
// let checksum = u16::from_be(unsafe { (*udphdr).check }); // Checksum
(
u16::from_be_bytes(unsafe { (*udphdr).source }),
u16::from_be_bytes(unsafe { (*udphdr).dest })
)
}
_ => return Err(()),
};

info!(
&ctx,
"SRC IP: {:i}, SRC PORT: {}, DST IP: {:i}, DST PORT: {}",
source_addr,
source_port,
dest_addr,
dest_port
);

Ok(xdp_action::XDP_PASS)
}
  • Extract and log Ethernet, IP, and TCP/UDP header fields.
  • This is the foundation for building firewalls, traffic counters, or protocol analyzers.

3. The ptr_at Helper

1
2
3
4
5
6
7
8
9
10
11
#[inline(always)]
fn ptr_at<T>(ctx: &XdpContext, offset:usize) -> Result<*const T, ()> {
let start = ctx.data();
let end = ctx.data_end();
let len = mem::size_of::<T>();

if start + offset + len > end {
return Err(());
}
Ok((start + offset) as *const T)
}
  • Ensures you only read memory within the packet’s bounds.
  • Returns a pointer to the struct at the given offset, or an error if out-of-bounds.

4. The XDP Firewall Entry Point

1
2
3
4
5
6
7
#[xdp]
pub fn xdp_firewall(ctx: XdpContext) -> u32 {
match try_xdp_firewall(ctx) {
Ok(ret) => ret,
Err(_) => xdp_action::XDP_ABORTED,
}
}
  • Calls the parser and returns either the XDP action or aborts on error.

5. Putting It All Together

Here’s the complete eBPF program:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
// inside xdp_packet_parser/xdp_packet_parser-ebpf/src/main.rs
#![no_std]
#![no_main]

use aya_ebpf::{bindings::xdp_action, macros::xdp, programs::XdpContext};
use aya_log_ebpf::info;

use core::mem;
use network_types::{
eth::{EthHdr, EtherType},
ip::{IpProto, Ipv4Hdr},
tcp::TcpHdr,
udp::UdpHdr,
};

#[cfg(not(test))]
#[panic_handler]
fn panic(_info: &core::panic::PanicInfo) -> !{
loop{}
}

#[xdp]
pub fn xdp_firewall(ctx: XdpContext) -> u32 {
match try_xdp_firewall(ctx) {
Ok(ret) => ret,
Err(_) => xdp_action::XDP_ABORTED,
}
}

#[inline(always)]
fn ptr_at<T>(ctx: &XdpContext, offset:usize) -> Result<*const T, ()> {
let start = ctx.data();
let end = ctx.data_end();
let len = mem::size_of::<T>();

if start + offset + len > end {
return Err(());
}
Ok((start + offset) as *const T)
}

fn try_xdp_firewall(ctx: XdpContext) -> Result<u32, ()> {
// Extract Ethernet header
let ethhdr: *const EthHdr = ptr_at(&ctx, 0)?;
// Available Ethernet parameters:
// let src_mac = unsafe { (*ethhdr).src_addr }; // Source MAC address [u8; 6]
// let dst_mac = unsafe { (*ethhdr).dst_addr }; // Destination MAC address [u8; 6]
match unsafe { (*ethhdr).ether_type} {
EtherType::Ipv4 => {},
_ => return Ok(xdp_action::XDP_PASS),
}

// Extract IPv4 header
let ipc4hdr: *const Ipv4Hdr = ptr_at(&ctx, EthHdr::LEN)?;
let source_addr = u32::from_be_bytes(unsafe { (*ipc4hdr).src_addr });
let dest_addr = u32::from_be_bytes(unsafe { (*ipc4hdr).dst_addr });
// Available IPv4 parameters:
// let ttl = unsafe { (*ipc4hdr).ttl }; // Time To Live
// let tos = unsafe { (*ipc4hdr).tos }; // Type of Service
// let tot_len = u16::from_be(unsafe { (*ipc4hdr).tot_len }); // Total Length
// let id = u16::from_be(unsafe { (*ipc4hdr).id }); // Identification
// let frag_off = unsafe { (*ipc4hdr).frag_off }; // Fragment Offset

let (source_port, dest_port) = match unsafe { (*ipc4hdr).proto} {
IpProto::Tcp => {
let tcphdr: *const TcpHdr = ptr_at(&ctx, EthHdr::LEN + Ipv4Hdr::LEN)?;
// Available TCP parameters:
// let seq = u32::from_be(unsafe { (*tcphdr).seq }); // Sequence Number
// let ack = u32::from_be(unsafe { (*tcphdr).ack_seq }); // Acknowledgment Number
// let window = u16::from_be(unsafe { (*tcphdr).window }); // Window Size
// let flags = unsafe { (*tcphdr).flags }; // TCP Flags (SYN, ACK, etc)
(
u16::from_be(unsafe { (*tcphdr).source }),
u16::from_be(unsafe { (*tcphdr).dest })
)
}
IpProto::Udp => {
let udphdr: *const UdpHdr = ptr_at(&ctx, EthHdr::LEN + Ipv4Hdr::LEN)?;
// Available UDP parameters:
// let length = u16::from_be(unsafe { (*udphdr).len }); // UDP datagram length
// let checksum = u16::from_be(unsafe { (*udphdr).check }); // Checksum
(
u16::from_be_bytes(unsafe { (*udphdr).source }),
u16::from_be_bytes(unsafe { (*udphdr).dest })
)
}
_ => return Err(()),
};

info!(
&ctx,
"SRC IP: {:i}, SRC PORT: {}, DST IP: {:i}, DST PORT: {}",
source_addr,
source_port,
dest_addr,
dest_port
);

Ok(xdp_action::XDP_PASS)
}

Loading and Attaching the Program

Here’s a minimal Rust user-space loader to attach your XDP program to a network interface:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// inside inside xdp_packet_parser/xdp_packet_parser/src/main.rs
use anyhow::Context;
use aya::programs::{Xdp, XdpFlags};
use clap::Parser;
use log::{info, warn};
use tokio::signal;
use aya_log::EbpfLogger;

#[derive(Debug, Parser)]
struct Opt {
#[clap(short, long, default_value = "eth0")]
iface: String,
}

#[tokio::main]
async fn main() -> anyhow::Result<()> {
let opt = Opt::parse();

env_logger::init();

let mut bpf = aya::Ebpf::load(aya::include_bytes_aligned!(concat!(
"../../target/bpfel-unknown-none/release",
"/packet-logger"
)))?;

if let Err(e) = EbpfLogger::init(&mut bpf) {
warn!("failed to initialize eBPF logger: {}",e);
}
let program: &mut Xdp = bpf.program_mut("xdp_firewall").unwrap().try_into()?;
program.load()?;


program.attach(&opt.iface, XdpFlags::default())
.context("failed to attach the XDP program with default flags - try changing XdpFlags::default() to XdpFlags::SKB_MODE")?;

info!("Waiting for Ctrl-C...");
signal::ctrl_c().await?;
info!("Exiting....");

Ok(())
}
  • iface is the network interface you want to monitor (e.g., eth0, enp2s0, etc.).

Building and Running

  1. Build the eBPF program:

    1
    2
    cd xdp_packet_parser/xdp_packet_parser-ebpf
    cargo +nightly build --release -Z build-std=core
  2. Run the user-space loader:

    1
    2
    3
    cd xdp_packet_parser
    RUST_LOG=info sudo -E ~/.cargo/bin/cargo run -- -i <network-interface>
    # Example: RUST_LOG=info sudo -E ~/.cargo/bin/cargo run -- -i enx7e0f7f73b679
  3. If you don’t see output, try a different network interface:

    1
    ip link show

    This will list all available interfaces.

  4. You can also use tcpdump to see if packets are arriving:

    1
    sudo tcpdump -i <network-interface>
  5. If everything is working, you should see logs like:

    alt text


Congratulations!

You now have access to all the critical parameters from network packets at the lowest level possible.
From here, you can add custom logic to block, redirect, or analyze traffic—building your own high-performance network tools.


Follow ,like ,repost ,comment

![alt text](../images/comment.jpeg)

References


In future blogs, we’ll continue exploring system tracers and Linux trace calls. Stay tuned!