Rust 120 🦀 Low-Level Networking with Raw Sockets and Packet Processing
The standard library’s TcpStream and UdpSocket abstract away the network layer. They handle IP headers, checksums, and routing. But when you need to craft a custom protocol, implement a diagnostic tool, or build a userspace TCP/IP stack, that abstraction becomes a barrier. Raw sockets bypass the kernel’s network stack, giving you direct access to the bytes on the wire. In Rust, this power comes with two distinct approaches: using the socket2 crate to interact with the OS kernel’s raw socket interface, and using freestanding stacks like smoltcp to process packets entirely in userspace. Each requires a different mental model for safety and a different set of tools for packet construction.
Key point: Raw sockets operate at the IP layer (Layer 3) and require elevated privileges. The socket2 crate provides safe bindings to the OS raw socket API, but crafting and parsing the packet headers is your responsibility. smoltcp offers a complete userspace TCP/IP stack that processes raw frames without kernel intervention, making it suitable for embedded systems and deterministic network testing.
Why raw sockets and packet processing exist
The protocol implementation problem. The kernel implements TCP, UDP, and ICMP. If you want to implement a new transport protocol, test a custom header format, or build a network diagnostic tool that inspects IP headers, the kernel’s abstraction is insufficient. Raw sockets allow you to send and receive packets with custom headers, bypassing the kernel’s protocol handlers .
The packet crafting problem. A standard socket sends a payload; the kernel constructs the headers. A raw socket with IP_HDRINCL enabled requires you to construct the entire IP header, including the version, header length, total length, identification, TTL, protocol, and checksum. The socket2 crate exposes the set_header_included method to enable this mode on IPv4 sockets . Without it, the kernel overwrites the header fields you set.
The userspace stack problem. In embedded systems, there may be no kernel to provide a network stack. smoltcp is a standalone, event-driven TCP/IP stack designed for bare-metal and real-time systems. It processes raw Ethernet frames, implements ARP, IPv4, IPv6, TCP, UDP, and DHCP, and requires no operating system support beyond the ability to send and receive raw frames . For testing, it can run in userspace on Linux using a TAP interface or raw sockets .
The safety problem. Raw sockets require CAP_NET_RAW on Linux or Administrator privileges on Windows. The socket2 crate wraps the unsafe system calls in a safe API, but the packet bytes you construct are not validated. A malformed IP header can crash the network stack of the receiving machine or trigger undefined behavior in your own parsing code. The pnet crate provides safe packet construction and parsing with typed headers, reducing the risk of offset errors .
The trade-off. Raw sockets offer maximum control at the cost of responsibility. You construct every header byte, compute every checksum, and handle every malformed response. Freestanding stacks like smoltcp offer a complete protocol implementation without a kernel, but require you to manage the device interface and polling loop yourself.
a. Creating a raw socket with socket2
The socket2 crate provides the Socket::new constructor with raw socket support. A raw socket is created with Domain::IPV4 (or IPV6), Type::RAW, and a protocol number. The protocol determines which packets the socket receives: Protocol::TCP receives only TCP segments, Protocol::UDP receives only UDP datagrams, and Protocol::ICMPV4 receives only ICMP messages .
use socket2::{Domain, Protocol, Socket, Type};
let socket = Socket::new(Domain::IPV4, Type::RAW, Some(Protocol::TCP))?;
socket.set_header_included(true)?;
The set_header_included(true) call enables IP_HDRINCL, which tells the kernel that the application will construct the IP header. Without this option, the kernel overwrites the header fields, and any custom values you set are discarded . On IPv6, this option is not available; IP_HDRINCL is an IPv4-only socket option. A GitHub issue on socket2 documents this limitation and suggests using IPPROTO_IPV6 for IPv6 raw sockets .
Once the socket is configured, sending a packet is a matter of constructing the bytes and calling send_to. The kernel does not validate the packet contents; it forwards the bytes to the network interface driver.
b. Packet construction and parsing with pnet
Constructing a raw packet by hand is error-prone. The IP header has fields at fixed offsets, the TCP header follows immediately after, and the checksum must be computed over the entire segment. The pnet crate provides typed structs for Ethernet, IPv4, TCP, UDP, and ICMP, with setters that write to the correct byte offsets .
use pnet::packet::ipv4::MutableIpv4Packet;
use pnet::packet::tcp::MutableTcpPacket;
use pnet::packet::MutablePacket;
let mut buffer = vec![0u8; 40];
let mut ipv4 = MutableIpv4Packet::new(&mut buffer).unwrap();
ipv4.set_version(4);
ipv4.set_header_length(5);
ipv4.set_total_length(40);
ipv4.set_ttl(64);
ipv4.set_next_level_protocol(pnet::packet::ip::IpNextHeaderProtocols::Tcp);
ipv4.set_source(src_ip);
ipv4.set_destination(dst_ip);
let mut tcp = MutableTcpPacket::new(ipv4.payload_mut()).unwrap();
tcp.set_source(12345);
tcp.set_destination(8080);
tcp.set_sequence(1);
tcp.set_flags(pnet::packet::tcp::TcpFlags::SYN);
tcp.set_window(1024);
The pnet crate also provides parsing: Ipv4Packet::new(&bytes) returns an Option that is None if the buffer is too short or the header is malformed. This pattern forces the programmer to handle malformed packets explicitly, which is essential when processing untrusted network data .
For higher-level packet manipulation, pnet_layers wraps pnet in a Scapy-like API. It allows adding layers to a packet stack, building the packet with automatic checksum calculation, and parsing from a buffer. The build() method sets the EtherType, IP size, IP checksum, UDP size, and UDP checksum automatically .
c. Userspace packet processing with smoltcp
smoltcp is a freestanding TCP/IP stack. It does not use the kernel’s network stack; it processes raw frames directly. In a userspace test environment, this means opening a TAP interface or a raw socket and feeding the received frames into the smoltcp Interface .
use smoltcp::iface::{Config, Interface, SocketSet};
use smoltcp::wire::{EthernetAddress, IpCidr, Ipv4Address};
let mut config = Config::new(EthernetAddress([0x02, 0x00, 0x00, 0x00, 0x00, 0x01]).into());
let mut iface = Interface::new(config, &mut device, Instant::now());
iface.update_ip_addrs(|addrs| {
addrs.push(IpCidr::new(Ipv4Address::new(192, 168, 69, 1), 24)).unwrap();
});
The Interface::poll method advances the stack: it processes incoming frames, runs protocol state machines (TCP handshake, ARP resolution), and produces outgoing frames that the device driver sends. The application creates sockets (TCP, UDP, DHCP, raw) through the SocketSet and interacts with them through the same polling loop .
smoltcp‘s raw socket support is provided through smoltcp::socket::raw. The embassy-net crate wraps this in an async-friendly API, allowing recv and send to be used with async/await on embedded systems . For testing, smoltcp includes examples that implement fault injection: the --drop-chance option randomly drops packets, and --corrupt-chance randomly mutates an octet, allowing the stack’s robustness to be tested against adverse conditions .
Complete Example Session
# ============================================
# PART 1: CREATING A RAW SOCKET
# ============================================
use socket2::{Domain, Protocol, Socket, Type};
// Raw TCP socket (requires root or CAP_NET_RAW)
let socket = Socket::new(Domain::IPV4, Type::RAW, Some(Protocol::TCP))?;
// The application will construct the IP header
socket.set_header_included(true)?;
# ============================================
# PART 2: CONSTRUCTING THE IP HEADER
# ============================================
use pnet::packet::ipv4::MutableIpv4Packet;
use pnet::packet::MutablePacket;
let mut ip_buffer = vec![0u8; 40]; // 20 IP + 20 TCP
let mut ipv4 = MutableIpv4Packet::new(&mut ip_buffer).unwrap();
ipv4.set_version(4);
ipv4.set_header_length(5);
ipv4.set_total_length(40);
ipv4.set_ttl(64);
ipv4.set_next_level_protocol(IpNextHeaderProtocols::Tcp);
ipv4.set_source(src_ip);
ipv4.set_destination(dst_ip);
# ============================================
# PART 3: CONSTRUCTING THE TCP HEADER
# ============================================
use pnet::packet::tcp::{MutableTcpPacket, TcpFlags};
let mut tcp = MutableTcpPacket::new(ipv4.payload_mut()).unwrap();
tcp.set_source(12345);
tcp.set_destination(8080);
tcp.set_sequence(1000);
tcp.set_flags(TcpFlags::SYN);
tcp.set_window(64240);
# ============================================
# PART 4: COMPUTING CHECKSUMS
# ============================================
use pnet::packet::ipv4::checksum;
use pnet::packet::tcp::ipv4_checksum;
// The IP checksum covers only the IP header.
ipv4.set_checksum(checksum(&ipv4.to_immutable()));
// The TCP checksum covers the pseudo-header + TCP segment.
let tcp_checksum = ipv4_checksum(&tcp.to_immutable(), &src_ip, &dst_ip);
tcp.set_checksum(tcp_checksum);
# ============================================
# PART 5: SENDING THE PACKET
# ============================================
use std::net::SocketAddr;
let target: SocketAddr = "192.168.1.2:8080".parse().unwrap();
socket.send_to(ipv4.packet(), &target.into())?;
// The kernel forwards the raw bytes to the network interface.
# ============================================
# PART 6: RECEIVING AND PARSING RESPONSES
# ============================================
let mut recv_buffer = [0u8; 65535];
let (n, addr) = socket.recv_from(&mut recv_buffer)?;
if let Some(response_ip) = Ipv4Packet::new(&recv_buffer[..n]) {
if response_ip.get_next_level_protocol() == IpNextHeaderProtocols::Tcp {
if let Some(response_tcp) = TcpPacket::new(response_ip.payload()) {
println!("Flags: {:?}", response_tcp.get_flags());
}
}
}
# ============================================
# PART 7: SMOLTCP INTERFACE SETUP
# ============================================
use smoltcp::iface::{Config, Interface, SocketSet};
use smoltcp::wire::{EthernetAddress, IpCidr, Ipv4Address};
let mut config = Config::new(EthernetAddress([0x02, 0x00, 0x00, 0x00, 0x00, 0x01]).into());
let mut iface = Interface::new(config, &mut device, Instant::now());
iface.update_ip_addrs(|addrs| {
addrs.push(IpCidr::new(Ipv4Address::new(192, 168, 69, 1), 24)).unwrap();
});
# ============================================
# PART 8: SMOLTCP POLLING LOOP
# ============================================
let mut sockets = SocketSet::new(vec![]);
loop {
let timestamp = Instant::now();
iface.poll(timestamp, &mut device, &mut sockets);
// Handle sockets: TCP accept, UDP recv, DHCP events...
phy_wait(device_fd, iface.poll_delay(timestamp, &sockets))?;
}
# ============================================
# PART 9: SMOLTCP RAW SOCKET
# ============================================
use smoltcp::socket::raw::{Socket, PacketBuffer, PacketMetadata};
let mut rx_buffer = [0u8; 2048];
let mut tx_buffer = [0u8; 2048];
let raw_socket = Socket::new(
Some(IpVersion::Ipv4),
Some(IpProtocol::Icmp),
PacketBuffer::new(&mut rx_meta, &mut rx_buffer),
PacketBuffer::new(&mut tx_meta, &mut tx_buffer),
);
let handle = sockets.add(raw_socket);
The nine parts trace the two approaches: creating a raw socket with socket2, constructing IP and TCP headers with pnet, computing checksums, sending the packet, parsing responses, and then setting up a smoltcp interface, polling loop, and raw socket. The socket2/pnet approach uses the kernel for transmission; smoltcp processes everything in userspace.
Quick Reference
socket2 Raw Socket Configuration
| Method | Purpose |
|---|---|
Socket::new(Domain::IPV4, Type::RAW, Some(Protocol::TCP)) | Create raw TCP socket |
set_header_included(true) | Enable IP_HDRINCL (IPv4 only) |
send_to(&bytes, &addr) | Send raw packet |
recv_from(&mut buf) | Receive raw packet |
pnet Packet Construction
| Type | Purpose |
|---|---|
MutableIpv4Packet | Set IP header fields |
MutableTcpPacket | Set TCP header fields |
checksum(&ipv4) | Compute IP checksum |
ipv4_checksum(&tcp, &src, &dst) | Compute TCP checksum |
smoltcp Components
| Component | Purpose |
|---|---|
Interface::new | Create network interface |
Interface::poll | Process incoming/outgoing frames |
SocketSet | Manage sockets |
raw::Socket | Raw packet socket |
Best Practices
✅ Do This:
socket.set_header_included(true)?;
<!-- Enable IP_HDRINCL for custom IP headers --> # ✅
ipv4.set_checksum(pnet::packet::ipv4::checksum(&ipv4.to_immutable()));
<!-- Compute checksum after setting all fields --> # ✅
if let Some(packet) = Ipv4Packet::new(&buffer[..n]) {
// Handle malformed packets safely
}
<!-- Use Option-returning parsers --> # ✅
❌ Don’t Do This:
# Send packet without checksum
<!-- Kernel may drop or receiver may reject --> # ❌
# Assume recv_from returns a valid packet
<!-- Network data is untrusted --> # ❌
# Use IPv6 raw socket with set_header_included
<!-- IP_HDRINCL is IPv4-only --> # ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
set_header_included fails on IPv6 | IP_HDRINCL is IPv4-only | Use IPv4 or construct IPv6 headers differently |
| Packet dropped by receiver | Checksum incorrect or zero | Compute IP and TCP checksums after all fields are set |
| Permission denied | Raw sockets require root/CAP_NET_RAW | Run with elevated privileges |
| Malformed packet panic | Assuming buffer length without checking | Use Ipv4Packet::new and handle None |
| smoltcp no output | poll not called or device not ready | Call iface.poll in a loop with phy_wait |
Real-World Examples
1. TCP SYN Packet
tcp.set_flags(TcpFlags::SYN);
tcp.set_sequence(rand::random());
2. ICMP Echo Request
let socket = Socket::new(Domain::IPV4, Type::RAW, Some(Protocol::ICMPV4))?;
3. Parse Received IP Header
let ip = Ipv4Packet::new(&buf[..n]).unwrap();
println!("TTL: {}", ip.get_ttl());
4. UDP Raw Packet
let socket = Socket::new(Domain::IPV4, Type::RAW, Some(Protocol::UDP))?;
5. smoltcp TCP Listener
let tcp_socket = tcp::Socket::new(rx_buffer, tx_buffer);
sockets.add(tcp_socket);
6. TAP Interface Setup
sudo ip tuntap add name tap0 mode tap user $USER
7. Fault Injection
cargo run -- --drop-chance 10 --corrupt-chance 5
8. DHCP Client
let mut dhcp_socket = dhcpv4::Socket::new();
sockets.add(dhcp_socket);
9. Raw Socket Receive Filter
// Protocol::TCP filters out non-TCP packets
10. Checksum Verification
let expected = ipv4_checksum(&tcp, &src, &dst);
assert_eq!(tcp.get_checksum(), expected);
Visual
Raw Socket Layering
┌──────────────────────────────────────────────┐
│ Your Application │
│ │
│ Constructs: [IP Header][TCP Header][Data] │
└──────────────────┬───────────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ socket2::Socket (Type::RAW) │
│ │
│ set_header_included(true) │
│ send_to(bytes) │
└──────────────────┬───────────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ Kernel Network Stack │
│ │
│ Forwards raw bytes to interface driver │
│ (No header modification, no checksum) │
└──────────────────┬───────────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ Network Interface Card │
└──────────────────────────────────────────────┘
The kernel does not process the packet; it forwards it. Every byte is the application’s responsibility.
smoltcp Userspace Stack
┌──────────────────────────────────────────────┐
│ Application │
│ │
│ iface.poll() → process frames │
└──────────────────┬───────────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ smoltcp Interface │
│ │
│ ARP, IPv4, TCP, UDP, DHCP state machines │
│ SocketSet (TCP, UDP, raw) │
└──────────────────┬───────────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ Device (TAP interface or raw socket) │
│ │
│ send/receive raw Ethernet frames │
└──────────────────┬───────────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ Host OS kernel (for TAP) or │
│ Physical NIC (embedded) │
└──────────────────────────────────────────────┘
smoltcp implements the protocols the kernel normally provides. The device is just a pipe for raw frames.
Summary
| Item | Value |
|---|---|
| Raw socket creation | Socket::new(Domain::IPV4, Type::RAW, Some(Protocol)) |
| IP header control | set_header_included(true) (IPv4 only) |
| Packet construction | pnet::packet::ipv4::MutableIpv4Packet |
| Checksum | pnet::packet::ipv4::checksum / tcp::ipv4_checksum |
| Userspace stack | smoltcp::iface::Interface |
| TAP interface | ip tuntap add (Linux) |
| Raw socket permission | root or CAP_NET_RAW |
| Fault injection | --drop-chance, --corrupt-chance |
Key takeaways:
- Raw sockets bypass the kernel’s protocol handlers. You construct the IP header and payload; the kernel forwards the bytes without modification .
set_header_included(true)is required for custom IP headers on IPv4. Without it, the kernel overwrites your header fields. This option is not available for IPv6 .pnetprovides safe packet construction and parsing. The typed structs andOption-returning parsers reduce offset errors and force handling of malformed packets .smoltcpimplements a complete TCP/IP stack in userspace. It is designed for embedded systems but can run on Linux via TAP interfaces for testing .- Checksums must be computed after all header fields are set. The IP checksum covers only the IP header; the TCP checksum covers a pseudo-header plus the TCP segment.
- Raw sockets require elevated privileges. On Linux, this is
CAP_NET_RAW; on Windows, it is Administrator. This is a security boundary, not a bug.
Remember: Low-level networking in Rust is a choice between using the kernel’s transmission path (socket2 + pnet) and implementing the protocols yourself (smoltcp). The first gives you control over packet contents while the kernel handles the interface. The second gives you control over the entire stack, including the state machines that the kernel normally provides. Both require constructing and parsing bytes at fixed offsets, and both require handling malformed input safely. The pnet crate provides the typed structs that make this tractable; smoltcp provides the protocol implementations that make it complete.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!