Raw Sockets and Packet Parsing in Rust for Network Tools
Network tools read bytes off the wire and interpret headers. Rust makes the parsing safe with slices and explicit offsets, and crates handle the platform socket details.

Network tools that work below TCP, such as ping, traceroute, packet sniffers, port scanners and custom protocol experiments, read and write packets that the operating system normally hides behind TcpStream. Doing it in Rust has two separate halves, and mixing them up is the usual source of confusion. One half is getting packets: opening a socket of a kind that delivers raw bytes, which the operating system guards behind privileges. The other half is parsing them safely: turning attacker-controlled bytes into typed values without a single out-of-bounds index, which is where Rust earns its keep. This guide covers both. You will send a real ICMP echo and parse the reply without root, see exactly how the privilege wall looks when you hit it, build and verify an IPv4 and UDP packet by hand, write a parser that rejects every truncated input cleanly, cross-check it against the etherparse crate, and write the Linux packet sniffer you would run with the right capability. Everything compiled with Rust 1.99; the ping, parser and crate comparison were run on macOS, and the Linux sniffer was compile-checked for aarch64-unknown-linux-gnu but not run.
Three ways to get packets, from least to most privilege
| Socket | What you get | Privilege | Use it for |
|---|---|---|---|
Datagram ICMP ("ping socket"): SOCK_DGRAM with IPPROTO_ICMP | Send and receive ICMP messages; the kernel builds the IP header | None on macOS; on Linux needs net.ipv4.ping_group_range to include your group | Unprivileged ping and reachability checks |
Raw IP: SOCK_RAW with a protocol number | Whole IP packets for one protocol, optionally including the header you wrote | Root, or CAP_NET_RAW on Linux | Traceroute, custom protocols, port scanners |
Link layer: AF_PACKET (Linux), BPF devices (macOS, BSD), or a capture library | Complete Ethernet frames, exactly as on the wire | Root, or CAP_NET_RAW | Sniffers, packet capture, user-space network stacks |
Start at the top of the table whenever it is enough. A ping tool does not need root on a modern system, and avoiding root is a security win in itself: a process that parses hostile bytes should not hold more power than it needs.
Step 1: an unprivileged ping
The socket2 crate exposes the BSD socket API with Rust types. Create a datagram socket for ICMPv4, build an echo request (type 8, code 0, a checksum, an identifier, a sequence number and some data), send it to localhost and read the reply:
use socket2::{Domain, Protocol, Socket, Type};
use std::mem::MaybeUninit;
use std::net::SocketAddr;
use std::time::Duration;
fn checksum(data: &[u8]) -> u16 {
let mut sum = 0u32;
for chunk in data.chunks(2) {
sum += u32::from(u16::from_be_bytes([chunk[0], *chunk.get(1).unwrap_or(&0)]));
}
while sum > 0xffff {
sum = (sum & 0xffff) + (sum >> 16);
}
!(sum as u16)
}
fn main() -> std::io::Result<()> {
let sock = Socket::new(Domain::IPV4, Type::DGRAM, Some(Protocol::ICMPV4))?;
sock.set_read_timeout(Some(Duration::from_secs(2)))?;
let mut request = vec![8u8, 0, 0, 0, 0x12, 0x34, 0, 1];
request.extend_from_slice(b"rusthacks");
let sum = checksum(&request);
request[2..4].copy_from_slice(&sum.to_be_bytes());
let dest: SocketAddr = "127.0.0.1:0".parse().unwrap();
sock.send_to(&request, &dest.into())?;
let mut buf = [MaybeUninit::<u8>::uninit(); 128];
let (n, from) = sock.recv_from(&mut buf)?;
let data: Vec<u8> = buf[..n].iter().map(|b| unsafe { b.assume_init() }).collect();
// macOS delivers the IP header too; skip it using the header length field.
let icmp = if data[0] >> 4 == 4 { &data[usize::from(data[0] & 0x0f) * 4..] } else { &data[..] };
println!("reply from {:?}", from.as_socket().map(|a| a.ip()));
println!("type {} code {} id {:#06x} seq {} payload {:?}", icmp[0], icmp[1], u16::from_be_bytes([icmp[4], icmp[5]]), u16::from_be_bytes([icmp[6], icmp[7]]), String::from_utf8_lossy(&icmp[8..]));
Ok(())
}reply from Some(127.0.0.1)
type 0 code 0 id 0x1234 seq 1 payload "rusthacks"Type 0 is an echo reply, and the identifier, sequence number and payload came back unchanged, which is how a ping tool matches replies to requests. Several details are worth noting. The checksum is the Internet checksum (RFC 1071): add the data as 16-bit big-endian words with end-around carry, then invert; the sender computes it with the checksum field set to zero. MaybeUninit appears because socket2 reads into possibly uninitialised memory to avoid zeroing the buffer first, and the assume_init is sound only for the first n bytes the kernel wrote. And there is a platform difference: macOS hands you the IP header in front of the ICMP message (the code above skips it using the header-length field), whereas Linux ping sockets deliver the ICMP message alone (see icmp(7)), so keep the version check. I ran this on macOS only; test on Linux before you rely on it.
Step 2: the privilege wall
If you ask for a true raw socket, the operating system refuses unless you are root:
use socket2::{Domain, Protocol, Socket, Type};
fn main() {
match Socket::new(Domain::IPV4, Type::RAW, Some(Protocol::ICMPV4)) {
Ok(_) => println!("raw socket opened"),
Err(e) => println!("cannot open a raw socket: {e} (kind {:?})", e.kind()),
}
}cannot open a raw socket: Operation not permitted (os error 1) (kind PermissionDenied)Handle this as the normal case, not an exception. A tool should print what it needs ("run as root or grant CAP_NET_RAW") and exit with a clear message, not a stack trace. On Linux the least-privilege way to run a sniffer is sudo setcap cap_net_raw,cap_net_admin+eip ./target/release/sniffer on the binary, which gives it just the capability it needs and nothing else. Never ship a setuid-root packet parser.
Step 3: the parser that panics
With bytes in hand, the fastest way to write a parser is to index into the slice, and it is a vulnerability. Here is the version every tutorial starts with:
fn naive_total_length(bytes: &[u8]) -> usize {
let header_len = usize::from(bytes[0] & 0x0f) * 4;
let total = usize::from(u16::from_be_bytes([bytes[2], bytes[3]]));
total - header_len
}
fn main() {
let packet = [0x45u8, 0x00, 0x00, 0x21, 0, 0, 0, 0];
println!("payload length: {}", naive_total_length(&packet));
println!("payload length: {}", naive_total_length(&packet[..3]));
}payload length: 13
thread 'main' panicked at src/main.rs:3:59:
index out of bounds: the len is 3 but the index is 3
note: run with `RUST_BACKTRACE=1` environment variable to display a backtraceA complete packet works. A packet truncated to three bytes, which any host on the network can send, crashes the process with an index out of bounds. Rust turned what would be an out-of-bounds read in C into a panic, which is better, and in a network daemon a panic is still a remote denial of service. A subtler bug sits two lines down: total - header_len underflows (a panic in debug, a wrapped huge number in release) whenever the header claims a total length smaller than its own header length. Network input is untrusted in every field, not only in length.
Step 4: a parser that cannot panic on any input
The discipline is: check lengths and consistency before any indexing, return a typed error for each failure, and borrow from the input instead of copying. This parser handles IPv4 (RFC 791); the full file is about 110 lines, and the core is below.
use std::fmt;
use std::net::Ipv4Addr;
#[derive(Debug, PartialEq)]
pub enum ParseError {
TooShort { need: usize, have: usize },
NotIpv4(u8),
BadHeaderLength(usize),
BadTotalLength { header: usize, actual: usize },
}
impl fmt::Display for ParseError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
ParseError::TooShort { need, have } => write!(f, "need {need} bytes, have {have}"),
ParseError::NotIpv4(v) => write!(f, "IP version {v}, expected 4"),
ParseError::BadHeaderLength(n) => write!(f, "header length {n} is below the 20 byte minimum"),
ParseError::BadTotalLength { header, actual } => write!(f, "total length {header} but {actual} bytes present"),
}
}
}
pub struct Ipv4Packet<'a> {
bytes: &'a [u8],
header_len: usize,
}
impl<'a> Ipv4Packet<'a> {
pub fn parse(bytes: &'a [u8]) -> Result<Self, ParseError> {
let first = *bytes.first().ok_or(ParseError::TooShort { need: 20, have: 0 })?;
let version = first >> 4;
if version != 4 {
return Err(ParseError::NotIpv4(version));
}
let header_len = usize::from(first & 0x0f) * 4;
if header_len < 20 {
return Err(ParseError::BadHeaderLength(header_len));
}
if bytes.len() < header_len {
return Err(ParseError::TooShort { need: header_len, have: bytes.len() });
}
let total = usize::from(u16::from_be_bytes([bytes[2], bytes[3]]));
if total < header_len || total > bytes.len() {
return Err(ParseError::BadTotalLength { header: total, actual: bytes.len() });
}
Ok(Ipv4Packet { bytes: &bytes[..total], header_len })
}
pub fn protocol(&self) -> u8 { self.bytes[9] }
pub fn ttl(&self) -> u8 { self.bytes[8] }
pub fn src(&self) -> Ipv4Addr { Ipv4Addr::new(self.bytes[12], self.bytes[13], self.bytes[14], self.bytes[15]) }
pub fn dst(&self) -> Ipv4Addr { Ipv4Addr::new(self.bytes[16], self.bytes[17], self.bytes[18], self.bytes[19]) }
pub fn payload(&self) -> &'a [u8] { &self.bytes[self.header_len..] }
pub fn checksum_ok(&self) -> bool { internet_checksum(&self.bytes[..self.header_len]) == 0 }
}The design choices explain why this cannot panic. parse is the only constructor and does every check: after it succeeds, the struct holds a slice that is known to be at least header_len (at least 20) bytes long and no longer than the stated total, so the accessors can index bytes[9] or bytes[16] without bounds worries, and the unchecked-looking indexing is justified by an invariant, not by hope. The slice is cut to the stated total length, which drops Ethernet padding (frames are padded to 46 bytes of payload, so a short IP packet arrives with trailing zeros that must not be parsed as payload). The struct borrows from the input with lifetime 'a, so nothing is copied, and payload() returns a slice that outlives the packet view. Errors are a typed enum with the numbers that explain them, rather than a string, so a caller can count rejections by kind.
Step 5: build a packet, parse it, break it
To test a parser you need packets, and building one by hand also teaches the layout. A function that assembles an IPv4 plus UDP packet with a correct header checksum:
pub fn internet_checksum(data: &[u8]) -> u16 {
let mut sum = 0u32;
for chunk in data.chunks(2) {
sum += u32::from(u16::from_be_bytes([chunk[0], *chunk.get(1).unwrap_or(&0)]));
}
while sum > 0xffff {
sum = (sum & 0xffff) + (sum >> 16);
}
!(sum as u16)
}
pub fn build_udp_packet(src: Ipv4Addr, dst: Ipv4Addr, sport: u16, dport: u16, data: &[u8]) -> Vec<u8> {
let udp_len = 8 + data.len();
let total = 20 + udp_len;
let mut p = vec![0u8; total];
p[0] = 0x45; // version 4, header length 5 words
p[2..4].copy_from_slice(&(total as u16).to_be_bytes());
p[8] = 64; // TTL
p[9] = 17; // protocol: UDP
p[12..16].copy_from_slice(&src.octets());
p[16..20].copy_from_slice(&dst.octets());
let c = internet_checksum(&p[..20]);
p[10..12].copy_from_slice(&c.to_be_bytes());
p[20..22].copy_from_slice(&sport.to_be_bytes());
p[22..24].copy_from_slice(&dport.to_be_bytes());
p[24..26].copy_from_slice(&(udp_len as u16).to_be_bytes());
p[28..].copy_from_slice(data);
p
}Now run it through the parser, corrupt a field and feed it every possible truncation, which is the cheapest fuzzing there is:
fn main() {
let raw = build_udp_packet(Ipv4Addr::new(10, 0, 0, 2), Ipv4Addr::new(10, 0, 0, 1), 5353, 53, b"query");
println!("{:02x?}", &raw[..28]);
let ip = Ipv4Packet::parse(&raw).unwrap();
println!("{} -> {} proto {} ttl {} checksum ok: {}", ip.src(), ip.dst(), ip.protocol(), ip.ttl(), ip.checksum_ok());
let udp = ip.payload();
println!("udp ports {} -> {}, data {:?}", u16::from_be_bytes([udp[0], udp[1]]), u16::from_be_bytes([udp[2], udp[3]]), std::str::from_utf8(&udp[8..]).unwrap());
let mut bad = raw.clone();
bad[8] = 1;
println!("after changing the TTL without fixing the checksum: ok = {}", Ipv4Packet::parse(&bad).unwrap().checksum_ok());
let (mut short, mut bad_total) = (0, 0);
for len in 0..raw.len() {
match Ipv4Packet::parse(&raw[..len]) {
Err(ParseError::TooShort { .. }) => short += 1,
Err(ParseError::BadTotalLength { .. }) => bad_total += 1,
other => panic!("unexpected result for a {len} byte prefix: {:?}", other.map(|p| p.header_len)),
}
}
println!("all {} truncated prefixes rejected: {short} too short, {bad_total} with an inconsistent total length", raw.len());
println!("{}", Ipv4Packet::parse(&[0x65, 0, 0, 20]).err().unwrap());
}[45, 00, 00, 21, 00, 00, 00, 00, 40, 11, 66, ca, 0a, 00, 00, 02, 0a, 00, 00, 01, 14, e9, 00, 35, 00, 0d, 00, 00]
10.0.0.2 -> 10.0.0.1 proto 17 ttl 64 checksum ok: true
udp ports 5353 -> 53, data "query"
after changing the TTL without fixing the checksum: ok = false
all 33 truncated prefixes rejected: 20 too short, 13 with an inconsistent total length
IP version 6, expected 4The checksum verifies when the header is intact, because summing the header including its checksum field gives all ones, whose complement is zero. Change the TTL without recomputing and verification fails, which is how routers detect corruption. Every prefix of the packet, from zero to 32 bytes, was rejected cleanly: 20 as too short and 13 whose stated total length exceeded the bytes present. No prefix panicked, and the final line shows the version check. This loop is a unit test worth keeping: for any parser, parse every prefix of every sample packet. It finds the missed length check in a minute. For stronger assurance run a coverage-guided fuzzer, as described in fuzzing security parsers.
Layer below IP: Ethernet frames and VLAN tags
A sniffer sees frames, so the first parser is the Ethernet one. It looks trivial (14 bytes) and hides the case that breaks hard-coded offsets: VLAN tags. A frame on a tagged network carries a 4-byte 802.1Q tag between the source address and the real EtherType, and double-tagged ("QinQ") traffic carries two. The parser has to loop on the EtherType field until it finds one that is not a tag, and cap the loop, because an attacker can stack tags.
struct Frame<'a> {
dst: Mac,
src: Mac,
vlans: Vec<u16>,
ethertype: EtherType,
payload: &'a [u8],
}
fn parse_frame(bytes: &[u8]) -> Option<Frame<'_>> {
let dst = Mac(bytes.get(0..6)?.try_into().ok()?);
let src = Mac(bytes.get(6..12)?.try_into().ok()?);
let mut offset = 12;
let mut vlans = Vec::new();
loop {
let t = u16::from_be_bytes(bytes.get(offset..offset + 2)?.try_into().ok()?);
match t {
0x8100 | 0x88a8 if vlans.len() < 2 => {
let tci = u16::from_be_bytes(bytes.get(offset + 2..offset + 4)?.try_into().ok()?);
vlans.push(tci & 0x0fff);
offset += 4;
}
_ => {
return Some(Frame { dst, src, vlans, ethertype: t.into(), payload: bytes.get(offset + 2..)? });
}
}
}
}
fn main() {
let plain: Vec<u8> = [&[0xff; 6][..], &[0x02, 0, 0, 0, 0, 0x01], &[0x08, 0x00], &[0x45, 0, 0, 20]].concat();
let tagged: Vec<u8> = [&[0x02, 0, 0, 0, 0, 0x02][..], &[0x02, 0, 0, 0, 0, 0x01], &[0x81, 0x00, 0x00, 0x64], &[0x08, 0x06], &[0; 28]].concat();
for (name, bytes) in [("plain", &plain), ("vlan 100", &tagged)] {
let f = parse_frame(bytes).expect("valid frame");
println!("{name}: {} -> {} vlans {:?} {:?} payload {} bytes", f.src, f.dst, f.vlans, f.ethertype, f.payload.len());
}
println!("truncated frames rejected: {}", (0..14).all(|n| parse_frame(&plain[..n]).is_none()));
}
plain: 02:00:00:00:00:01 -> ff:ff:ff:ff:ff:ff vlans [] Ipv4 payload 4 bytes
vlan 100: 02:00:00:00:00:01 -> 02:00:00:00:00:02 vlans [100] Arp payload 28 bytes
truncated frames rejected: trueNotice the style, which is the same as the IP parser's: bytes.get(range)? returns None when the range is out of bounds, so every read is checked without a single index expression, and the question mark propagates the failure. The 6-byte arrays come from try_into on a slice whose length was just checked by get. Both frames parse, the tagged one reports VLAN 100 and the real EtherType (ARP), and every truncation shorter than the 14-byte header is rejected. This is the pattern to prefer in parsers over manual length arithmetic, since a missed check becomes impossible to write: there is nothing to index with.
Layer above IP: TCP headers and flags
Once the IP layer says the protocol number is 6, the payload is a TCP segment. The header is at least 20 bytes: ports, 32-bit sequence and acknowledgment numbers, a 4-bit data offset (in 32-bit words, like the IP header length), flag bits (FIN, SYN, RST, PSH, ACK, URG), a window size, a checksum and an urgent pointer. Options follow, which is why the data offset is mandatory to read. A parser and a builder for the segment that opens every TCP connection, a SYN with a maximum segment size option:
const FIN: u8 = 0x01;
const SYN: u8 = 0x02;
const RST: u8 = 0x04;
const PSH: u8 = 0x08;
const ACK: u8 = 0x10;
fn flag_names(flags: u8) -> String {
let names = [(FIN, "FIN"), (SYN, "SYN"), (RST, "RST"), (PSH, "PSH"), (ACK, "ACK")];
let set: Vec<&str> = names.iter().filter(|(bit, _)| flags & bit != 0).map(|(_, n)| *n).collect();
set.join("|")
}
fn parse_tcp(bytes: &[u8]) -> Option<TcpHeader> {
if bytes.len() < 20 {
return None;
}
let data_offset = usize::from(bytes[12] >> 4) * 4;
if data_offset < 20 || data_offset > bytes.len() {
return None;
}
Some(TcpHeader {
src_port: u16::from_be_bytes([bytes[0], bytes[1]]),
dst_port: u16::from_be_bytes([bytes[2], bytes[3]]),
seq: u32::from_be_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]),
ack: u32::from_be_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]),
flags: bytes[13],
window: u16::from_be_bytes([bytes[14], bytes[15]]),
data_offset,
})
}
fn build_syn(src_port: u16, dst_port: u16, seq: u32) -> [u8; 24] {
let mut h = [0u8; 24];
h[0..2].copy_from_slice(&src_port.to_be_bytes());
h[2..4].copy_from_slice(&dst_port.to_be_bytes());
h[4..8].copy_from_slice(&seq.to_be_bytes());
h[12] = 6 << 4;
h[13] = SYN;
h[14..16].copy_from_slice(&64240u16.to_be_bytes());
h[20..24].copy_from_slice(&[2, 4, 0x05, 0xb4]);
h
}
fn main() {
let syn = build_syn(50000, 443, 1_000);
let h = parse_tcp(&syn).unwrap();
println!("{h:?}");
println!("flags: {}, header {} bytes, mss option {:?}", flag_names(h.flags), h.data_offset, u16::from_be_bytes([syn[22], syn[23]]));
let mut synack = syn;
synack[13] = SYN | ACK;
println!("reply flags: {}", flag_names(parse_tcp(&synack).unwrap().flags));
println!("garbage data offset rejected: {}", parse_tcp(&{ let mut g = syn; g[12] = 0x20; g }).is_none());
}
TcpHeader { src_port: 50000, dst_port: 443, seq: 1000, ack: 0, flags: 2, window: 64240, data_offset: 24 }
flags: SYN, header 24 bytes, mss option 1460
reply flags: SYN|ACK
garbage data offset rejected: trueThe SYN is 24 bytes: 20 for the base header and a 4-byte option (kind 2, length 4, value 1460, the MSS that fits an Ethernet MTU of 1500 after 40 bytes of IP and TCP headers). The data offset of 6 words is what makes it 24, and the parser reads the offset from the packet, so the option is skipped correctly. Flipping the flags to SYN | ACK gives the reply a server would send, and the flag decoder reports both. Setting the data offset to 2 words (8 bytes, less than the 20-byte minimum) is rejected, which is the same kind of consistency check as the IP header length. Sequence numbers are 32-bit and wrap, so any code that compares them must use wrapping arithmetic (a.wrapping_sub(b) as i32 < 0 is the usual "a is before b" test), not a plain <.
The TCP checksum covers a pseudo-header made of the source and destination IP addresses, the protocol and the segment length, plus the segment itself. That is why a TCP or UDP checksum cannot be computed or verified from the transport header alone, and why a library that builds segments takes the IP addresses as parameters. Writing it by hand is a good exercise; use etherparse or smoltcp when the packets must be right on a real network.
Putting the layers together
A working sniffer is a loop over three parsers. Read a frame; parse Ethernet and note VLANs; if the EtherType is IPv4, parse the IP packet from the payload; if the protocol is TCP or UDP, parse the transport header from the IP payload; print or count. Each stage hands the next a borrowed slice, so a packet is parsed with no allocation in the hot path (the Vec of VLAN ids in the example is the only exception, and can be a fixed-size array if you cap the tags at two). Because each parser returns an error type instead of panicking, a malformed packet becomes a counter increment (bad_ip_header += 1) and the loop continues. That counter belongs in your output: a capture tool that silently drops what it cannot parse hides exactly the traffic a security analyst wants to see.
Performance follows from the design. Zero-copy parsing means the work per packet is a few dozen bytes read and compared, which is small next to the cost that dominates a real sniffer, the system call per packet, which is why high-rate capture uses memory-mapped ring buffers (PACKET_MMAP on Linux) or kernel-bypass frameworks instead of one recv per frame. Measure with your own traffic before optimising, as in performance basics.
Cross-check against a mature library
A hand-written parser is for learning, for tiny tools and for formats nobody has implemented. For real work, use a library that is fuzzed and used widely, and use your hand-built packets to check it. The etherparse crate provides zero-copy slice views just like ours:
use etherparse::{Ipv4HeaderSlice, UdpHeaderSlice};
fn main() {
let raw: [u8; 33] = [
0x45, 0x00, 0x00, 0x21, 0, 0, 0, 0, 0x40, 0x11, 0x66, 0xca, 10, 0, 0, 2, 10, 0, 0, 1,
0x14, 0xe9, 0x00, 0x35, 0x00, 0x0d, 0x00, 0x00, b'q', b'u', b'e', b'r', b'y',
];
let ip = Ipv4HeaderSlice::from_slice(&raw).unwrap();
let udp = UdpHeaderSlice::from_slice(&raw[usize::from(ip.ihl()) * 4..]).unwrap();
println!("{} -> {} ttl {} protocol {:?}", ip.source_addr(), ip.destination_addr(), ip.ttl(), ip.protocol());
println!("udp {} -> {} length {}", udp.source_port(), udp.destination_port(), udp.length());
println!("short input: {:?}", Ipv4HeaderSlice::from_slice(&raw[..10]).is_err());
}10.0.0.2 -> 10.0.0.1 ttl 64 protocol 17 (UDP - User Datagram)
udp 5353 -> 53 length 13
short input: trueThe same bytes give the same addresses, ports and TTL, and truncated input is an error value there too. That agreement is the check. When your parser and the library disagree on a captured packet, one of them has a bug, and a hand-built packet is how you find out which. The wider choice among crates is: etherparse for parsing and building headers, pnet for parsing plus cross-platform capture, pcap for libpcap bindings, smoltcp for a complete no-allocation TCP/IP stack you can embed, and socket2 for socket control.
Step 6: capturing frames on Linux
For a full sniffer you need link-layer frames, which on Linux means an AF_PACKET socket with the ETH_P_ALL protocol. This needs CAP_NET_RAW and was not run here; it was compile-checked for aarch64-unknown-linux-gnu:
#[cfg(target_os = "linux")]
mod sniff {
use std::io;
pub fn run(limit: usize) -> io::Result<()> {
let proto = (libc::ETH_P_ALL as u16).to_be() as i32;
let fd = unsafe { libc::socket(libc::AF_PACKET, libc::SOCK_RAW, proto) };
if fd < 0 {
return Err(io::Error::last_os_error());
}
let mut buf = [0u8; 2048];
for _ in 0..limit {
let n = unsafe { libc::recv(fd, buf.as_mut_ptr().cast(), buf.len(), 0) };
if n < 0 {
return Err(io::Error::last_os_error());
}
let frame = &buf[..n as usize];
if frame.len() >= 34 && frame[12..14] == [0x08, 0x00] {
println!("ipv4 {} bytes, protocol {}", frame.len(), frame[14 + 9]);
} else {
println!("other frame, ethertype {:02x}{:02x}", frame.get(12).unwrap_or(&0), frame.get(13).unwrap_or(&0));
}
}
unsafe { libc::close(fd) };
Ok(())
}
}The Ethernet header is 14 bytes: destination MAC (6), source MAC (6), and a 2-byte EtherType. EtherType 0x0800 is IPv4, 0x86DD is IPv6, 0x0806 is ARP, and 0x8100 marks a VLAN tag that shifts everything after it by four bytes, a case that breaks the hard-coded offset 14 above and is why real parsers read the EtherType in a loop. The socket delivers frames to every program on the machine that opens the same kind of socket; it observes and does not consume. Check n before slicing and never trust the length to be sane, exactly as with the parser.
Traps that surprise people
- Byte order. Every multi-byte field on the wire is big-endian. Use
from_be_bytes, never a cast of a pointer, and keep host-order values out of packet structs. - Header length is variable. IPv4 options make the header up to 60 bytes; never assume 20. The parser above uses the IHL field for exactly this reason.
- Checksum offload. Captured outgoing packets often show a wrong checksum because the network card fills it in after the capture point. Do not treat a bad checksum on locally generated traffic as corruption.
- Fragmentation. A UDP datagram larger than the path MTU arrives as several IP fragments, and only the first contains the UDP header. A parser that reads ports from every fragment reads garbage.
- Ethernet padding. Short frames are padded to a 60-byte minimum before the checksum; use the IP total length to find the end of the data, not the frame length.
- IPv6 is different. Fixed 40-byte header, no header checksum, and a chain of extension headers that can nest; a loop that follows
next_headermust bound the chain length.
A testing recipe that keeps the parser honest
Keep three kinds of test next to the parser. A table of hand-built packets, one per header variant you claim to support (options present, VLAN tagged, fragmented, minimum size), with the expected fields. The truncation loop shown above, applied to each of those samples, asserting that no prefix panics. And a differential test against a reference implementation: parse the same bytes with etherparse and with your code, and assert that the fields match whenever both accept the packet and that you never accept something the library rejects. When you capture real traffic with tcpdump -w sample.pcap and add the odd packets (those that your parser rejected in production) to the table, the suite becomes a record of every malformed packet your network has produced, and each future change is checked against all of them.
When this is the wrong approach
If you only need to talk to a service, use TcpStream and let the kernel handle ordering, retransmission and congestion. Hand-built packets are right for diagnostics, security tools and protocol research, not for application traffic. If you need to parse captured files, read pcap files with a crate instead of reading the socket. And if the parser will face the public internet, add resource limits (cap the number of options, fragments and nested headers processed per packet), run it under a fuzzer, and keep it in a process that has dropped its privileges after opening the socket. The memory-safety guarantees of Rust remove one class of bug; they do not remove logic errors, integer underflows in release builds or denial of service by crafted input, which is why the parser above validates every length relation instead of trusting the sender.


