Socket to Stealth [4/4]: Raw Sockets and Stealth Injection

Welcome back to Raw Ptr Insights. In the previous chapters of our network programming series, we operated strictly within the boundaries of the Linux kernel’s network stack. We scaled our synchronous listener using Tokio’s epoll reactor in Part 2, and we wrapped our payloads in Mutually Authenticated TLS 1.3 tunnels in Part 3. By all standard metrics, we have built a resilient, highly concurrent, and encrypted Command and Control (C2) architecture. However, we are still completely subjugated by the operating system. When you use standard AF_INET and SOCK_STREAM sockets, the Linux kernel dictates the TCP handshake, handles the sequencing, manages congestion control, and explicitly logs connection states.

To achieve true operational stealth—whether you are designing an asynchronous SYN scanner or a covert data exfiltration mechanism—you cannot let the kernel manage your state machine. The kernel is noisy. It attempts complete handshakes, and complete handshakes generate application-level logs on the target. Furthermore, host-based firewalls (like iptables or nftables via netfilter) heavily scrutinize standard socket traffic traversing the INPUT and OUTPUT chains.

To disappear from standard logging mechanisms, we must bypass the OSI Transport and Network layers of the host OS entirely. We must drop down to the Data Link layer. On GNU/Linux platforms, this is achieved using the AF_PACKET address family. By opening a raw socket, we instruct the kernel to step aside, allowing our Rust binary to manually construct raw Ethernet frames, inject forged IP headers, and craft custom TCP segments byte-by-byte. A common approach in the Rust ecosystem is to reach for a high-level crate like pnet to do this. I disagree with this approach for our purposes. If your goal is to truly understand system-level networking and minimize your binary’s signature, relying on a massive third-party abstraction defeats the point. We are going to build this directly against the libc FFI boundary.

In this final installment of our series, we will bridge the gap between low-level systems programming and raw packet injection. First, we will explore the theory behind AF_PACKET and the Foreign Function Interface (FFI) bindings required to access it. Next, we will dive into compiler theory, utilizing the Rustonomicon to understand why Rust’s default memory layout is fundamentally incompatible with packet crafting, and how to rectify it with repr(C). Finally, we will deliver the blueprint for a custom packet injector capable of firing hand-crafted frames directly onto the wire.

Bypassing the Kernel with AF_PACKET

When you invoke a standard socket via libc::socket(libc::AF_INET, libc::SOCK_STREAM, 0), the kernel assigns you a file descriptor mapped to its internal TCP/IP stack. If you send the byte array [0x41, 0x42, 0x43], the kernel intercepts it, prepends a TCP header (calculating sequence numbers and checksums), prepends an IPv4 header, determines the routing, prepends an Ethernet header, and finally hands it to the Network Interface Card (NIC) driver.

This is convenient, but fatal for stealth tasks like a SYN scan. A SYN scan (half-open scanning) works by sending only the initial SYN packet of the three-way handshake. If the target port is open, it responds with a SYN-ACK. Our scanner notes the open port and immediately responds with an RST (Reset) to tear down the connection before it fully establishes, thereby preventing the target application from logging the connection. The Linux kernel’s TCP stack will not let you do this via SOCK_STREAM.

To assert absolute control, we must request an AF_PACKET socket:

// The conceptual FFI call to bypass the network stack
let fd = unsafe {
libc::socket(
libc::AF_PACKET, // Address Family: Low level packet interface
libc::SOCK_RAW, // Socket Type: Raw packets including link level
3768u16.to_be() // Protocol: ETH_P_ALL (Network byte order)
)
};

By requesting AF_PACKET with SOCK_RAW, we are telling the kernel: “Give me direct access to the NIC driver. I will provide the raw Ethernet frame, the IP header, the TCP header, and the payload. Do not alter my bytes.” Because this allows arbitrary spoofing of MAC and IP addresses, the Linux kernel strictly enforces that this system call can only be executed by a process holding the CAP_NET_RAW capability (typically requiring root privileges).

Memory Layouts and the Rust Compiler

If we are providing the raw headers, we must define them as structs in Rust. However, this introduces a catastrophic collision between network protocol standards and compiler optimization.

Network protocols like IPv4 and TCP are rigorously defined by RFCs. The fields must appear in a highly specific, contiguous byte order on the wire. For example, an IPv4 header begins with the Version/IHL byte, followed by the DSCP/ECN byte, followed by the Total Length (16 bits), and so on.

As explicitly detailed in the Rustonomicon’s chapter on Data Layout, Rust’s default representation (repr(Rust)) makes zero guarantees about field ordering, padding, or alignment. To minimize memory footprint, the rustc compiler actively reorders struct fields to eliminate padding bytes. If you cast a repr(Rust) struct to a byte array and blast it over an AF_PACKET socket, the target kernel will receive garbage data and drop the frame instantly.

To interface with strictly defined memory layouts, we must constrain the compiler using the repr attribute.

// Defining the IPv4 Header according to RFC 791
#[repr(C, packed)]
pub struct Ipv4Header {
pub version_ihl: u8,
pub type_of_service: u8,
pub total_length: u16,
pub identification: u16,
pub flags_fragment_offset: u16,
pub time_to_live: u8,
pub protocol: u8,
pub header_checksum: u16,
pub source_address: u32,
pub destination_address: u32,
}
  • repr(C): Instructs the compiler to layout the struct exactly as a C/C++ compiler would, preserving the top-to-bottom field ordering.
  • packed: Instructs the compiler to strip all alignment padding. Network headers are inherently packed; inserting empty padding bytes to align a u32 on a 4-byte boundary will corrupt the protocol structure.

The Mathematics of Forgery (Checksums)

When bypassing the kernel, you inherit all of its responsibilities. One of those responsibilities is cryptographic integrity. Both the IP and TCP protocols require checksums to validate that data was not corrupted in transit. If you inject a crafted SYN packet with an invalid checksum, the target OS will silently discard it.

The checksum algorithm used by TCP/IP is the 16-bit one’s complement of the one’s complement sum of all 16-bit words in the header and text. If the total length is odd, a zero byte is padded at the end for the calculation.

Mathematically, if our packet is a sequence of 16-bit words w1,w2,,wNw_1, w_2, \dots, w_N, the checksum CC is computed as:

C=(i=1Nwi)C = \sim \left( \sum_{i=1}^{N} w_i \right)

Where the summation is performed using one’s complement arithmetic (meaning any overflow carries wrapped around and added back to the least significant bit). Implementing this algorithm efficiently is a rite of passage for systems programmers, requiring precise bitwise manipulation in Rust to ensure the injected payload mathematically validates on the receiving end.

4. The Raw Injector Deliverable

Below is the foundational blueprint for a raw packet injector in Rust. This snippet demonstrates how to safely cross the FFI boundary, bind a raw socket to a specific network interface (e.g., eth0), and inject a constructed byte array directly onto the physical wire.

use libc::{c_int, c_void, sockaddr_ll, sendto, socket, AF_PACKET, SOCK_RAW};
use std::ffi::CString;
use std::io::Error;
use std::mem;
/// Resolves a network interface name (e.g., "eth0") to its kernel interface index.
///
/// Under the hood, this crosses the FFI boundary to invoke `libc::if_nametoindex`,
/// which issues a SIOCGIFINDEX ioctl to the Linux kernel.
pub fn get_interface_index(if_name: &str) -> std::io::Result<i32> {
// 1. Convert the Rust &str to a null-terminated C string.
// The CString::new constructor will fail if the provided string
// already contains an interior null byte, preventing FFI vulnerabilities.
let c_if_name = CString::new(if_name).map_err(|_| {
Error::new(
std::io::ErrorKind::InvalidInput,
"Interface name contains a null byte"
)
})?;
// 2. Cross the FFI boundary.
// We pass the raw pointer of our null-terminated string to the C library.
// This is marked `unsafe` because the Rust compiler cannot guarantee
// what the C function will do with the pointer.
let if_index = unsafe { libc::if_nametoindex(c_if_name.as_ptr()) };
// 3. Error Handling
// The POSIX standard states that if_nametoindex returns 0 on error
// and sets the global `errno`.
if if_index == 0 {
// Error::last_os_error() automatically reads the C `errno`
// and translates it into a Rust std::io::Error.
return Err(Error::last_os_error());
}
// 4. Return the index.
// The kernel index is always a positive integer, so casting the
// unsigned integer (c_uint) to a signed 32-bit integer (i32) is safe here.
Ok(if_index as i32)
}
pub fn inject_frame(interface: &str, frame: &[u8]) -> std::io::Result<()> {
// 1. Open the raw socket. Requires root privileges.
let sockfd = unsafe {
socket(AF_PACKET, SOCK_RAW, (libc::ETH_P_ALL as u16).to_be() as c_int)
};
if sockfd < 0 {
return Err(Error::last_os_error());
}
// 2. Resolve the interface index
let if_index = get_interface_index(interface)?;
// 3. Construct the Link-Level Socket Address structure
// This tells the kernel exactly which physical NIC to blast the frame out of.
let mut addr: sockaddr_ll = unsafe { mem::zeroed() };
addr.sll_family = AF_PACKET as u16;
addr.sll_ifindex = if_index;
addr.sll_halen = 6; // MAC address length
// 4. Inject the frame
let bytes_sent = unsafe {
sendto(
sockfd,
frame.as_ptr() as *const c_void,
frame.len(),
0,
&addr as *const sockaddr_ll as *const libc::sockaddr,
mem::size_of::<sockaddr_ll>() as u32,
)
};
if bytes_sent < 0 {
let err = Error::last_os_error();
unsafe { libc::close(sockfd) };
return Err(err);
}
println!("[+] Successfully injected {} bytes onto {}.", bytes_sent, interface);
// 5. Clean up the file descriptor
unsafe { libc::close(sockfd) };
Ok(())
}

Technical Reality Check: Look at the sheer amount of unsafe blocks required here. When you abandon the standard library’s network abstractions, you lose all compile-time guarantees regarding memory safety and resource lifecycle. We are manually passing raw pointers (frame.as_ptr()) into a C function, manually managing the sockaddr_ll struct layout, and manually ensuring we call libc::close(sockfd) on both success and error paths.

If this function were to panic before libc::close() was called, the raw file descriptor would leak—a severe operational vulnerability we solved in Part 1 using the Drop trait. A robust, production-grade offensive tool would encapsulate this raw sockfd inside a custom Rust struct and implement Drop to guarantee safe teardown, effectively writing a bespoke version of the standard library tailored for raw access.

Aside: Resolving Interface Indices via FFI with our implementation of “get_interface_index”

When you configure a network tool to listen or inject on eth0 or wlan0, you are using a human convenience. The Linux kernel’s networking subsystem does not route packets using strings. Internally, every network interface is represented by a massive C structure called net_device (defined in include/linux/netdevice.h within the Linux kernel source). To quickly reference these structures during high-speed packet processing, the kernel assigns each interface a unique integer: the interface index (ifindex). When we craft our sockaddr_ll struct for raw injection, the kernel demands this integer, not the string.

To bridge this gap from userland, we must query the kernel. The structure of this supplementary article is straightforward. First, we will examine the ioctl system call and the SIOCGIFINDEX request code, which form the historical and functional bedrock of this query. Second, we will analyze how the standard C library (glibc) encapsulates this ugly system call. Finally, we will write the exact Rust implementation using the libc crate to safely cross the Foreign Function Interface (FFI) boundary and extract our index.

1. The Raw Mechanics: ioctl and SIOCGIFINDEX

If you were writing this from absolute scratch in raw C without helper functions, you would have to use the ioctl (input/output control) system call. ioctl is the kernel’s “catch-all” function for device operations that don’t fit cleanly into standard read/write paradigms.

To get the index of eth0, a userland program must:

  1. Open a dummy socket. (The kernel requires a file descriptor tied to the networking subsystem to process network-related ioctl commands).
  2. Allocate an ifreq (interface request) struct.
  3. Copy the string “eth0” into the ifr_name field of this struct.
  4. Execute ioctl(dummy_fd, SIOCGIFINDEX, &ifr).

When the system call traps into the kernel, the networking stack looks up the net_device associated with “eth0”, extracts the ifindex, writes it into the ifr_ifindex field of the struct you provided, and returns control to userland.

This approach is highly manual, requires opening and closing temporary file descriptors, and involves nasty C unions that are notoriously difficult to map safely into Rust.

2. The Standard Library Wrapper: if_nametoindex

Because looking up an interface index is such a ubiquitous requirement for low-level networking, POSIX environments and glibc provide a dedicated wrapper function: if_nametoindex(3).

If you look at the glibc source code, if_nametoindex does exactly what I described above. It silently opens a standard AF_INET socket, populates the ifreq struct, fires the SIOCGIFINDEX ioctl, extracts the integer, closes the dummy socket, and hands you the result. It is a highly optimized, battle-tested implementation.

Instead of reinventing the ioctl wheel and wrestling with C unions in Rust, the most robust, operationally sound approach is to use Rust’s libc crate to bind directly to if_nametoindex.

3. The Rust Implementation

Notice how we meticulously handle the string conversion. Rust strings (&str or String) are UTF-8 encoded and are not null-terminated. C functions expect a null-terminated array of characters (*const c_char). If you attempt to pass a raw pointer from a Rust &str directly into libc::if_nametoindex, the C function will read past the end of your string into uninitialized memory until it happens to hit a zero byte, resulting in a segmentation fault or a failed lookup. We must use std::ffi::CString to safely enforce the null byte.

4. Integration into the Packet Injector

With this function implemented, the inject_frame function from our Part 4 blueprint is fully weaponized. You can now pass "eth0", "lo", or "wlan0" into your Rust binary. The program will allocate the C-string, query the kernel via FFI, retrieve the correct ifindex, construct the sockaddr_ll struct, and blast your forged bytes directly out of the Network Interface Card.

This is the reality of systems programming. Safe, zero-cost abstractions in userland are inevitably powered by raw pointers, null-terminated strings, and kernel-level ioctl traps. By utilizing CString and Error::last_os_error(), we maintain Rust’s rigorous safety guarantees right up to the absolute edge of the FFI boundary.

Series Conclusion

We have completed our journey. We began with simple POSIX file descriptors, ascended to the heights of asynchronous Tokio event loops, armored our payloads in military-grade cryptography, and finally plummeted back down to the bare metal, bypassing the kernel entirely to forge raw Ethernet frames.

Rust is arguably the most potent language available today for engineering offensive security tools. It grants you the low-level, absolute memory control of C, paired with a modern compiler that structurally eradicates the memory corruption vulnerabilities that have historically plagued exploit developers. Master the std::net primitives, understand the underlying Linux system calls, respect the FFI boundaries, and you can engineer infrastructure capable of surviving the most hostile network environments on earth.

Stay vigilant, and keep studying the internals.

Comments

Leave a Reply

Check also

View Archive [ -> ]

Discover more from Raw Ptr Insights

Subscribe now to keep reading and get access to the full archive.

Continue reading