Relay to Release [1/6]: TCP Streams and the Duplex Relay Problem

Before the frameworks, before the C2 suites, before the staged payloads and encrypted channels — there was nc. Netcat has remained a fixture of systems work and offensive security for over thirty years not because it is complex, but because it does exactly one thing without ceremony: it opens a TCP connection and steps aside. No protocol parsing, no application logic, no handshake ritual beyond what the kernel enforces. Just a bidirectional relay between a socket and a terminal. That simplicity is the source of its power and, more importantly for our purposes, its instructive value. A system that makes bidirectionality look trivial is hiding real machinery beneath the surface.

This series — Relay to Release — builds that machinery from scratch in Rust. We will trace a complete implementation arc: a naive blocking prototype in this installment, hardened error handling and socket semantics in the next, an async port to Tokio after that, a concurrent multi-session server, and finally a refactored crate with a real CLI, documentation, and packaging for release. By Article 6, we will have produced a presentable, embeddable relay engine. But none of that matters if the foundation is wrong — and in Rust, the foundation collides immediately with the ownership model in a way that is both surprising and, once understood, illuminating.

The naive implementation fails at the borrow checker before it fails at runtime. Any developer arriving from Go, Python, or C will expect to open a socket and hand it to two concurrent execution contexts — one reading, one writing. In Go, two goroutines sharing a net.Conn is idiomatic; the runtime handles the coordination invisibly behind an interface, and the garbage collector ensures the connection is not closed while either goroutine holds a reference. In Python, a socket passed to two threads is a runtime concern, not a language one — the interpreter will not stop you. Rust rejects the naive version structurally: a value has exactly one owner, and two threads simultaneously demanding mutable access to the same TcpStream is not something the borrow checker will negotiate on. What looks like boilerplate network code turns out to be a precise collision between the duplex nature of TCP and Rust’s ownership semantics. Resolving that collision correctly — and understanding exactly why Rust forces the issue — is the entire substance of this installment.

Most Rust networking tutorials sidestep the collision by staying in simplex territory. They show a client that writes once and reads once, or a server that reads a request and writes a response — always in sequence, always on a single thread, always in a mode where the socket moves in one direction at a time. That model cannot implement a relay. Both directions must be active simultaneously, which means the structural choice of how to split socket ownership is not a detail to be deferred. It is the first real design decision, and it forces a direct confrontation with how Rust models kernel file descriptor ownership at the type level. The two mechanisms Rust offers for solving this — try_clone() and Arc<TcpStream> — are not interchangeable, and understanding why requires tracing both down to the kernel data structures they interact with differently.

The context is not incidental. The relay pattern we are building is mechanically identical to the pattern powering interactive reverse shell sessions. When a compromised host executes nc -e /bin/bash, what runs underneath is precisely what we are about to construct: the stdin and stdout of a child process wired to both halves of a TCP stream, with I/O flowing in both directions simultaneously. Understanding this mechanism at the ownership level — not as a black-box binary, but as a composition of kernel file descriptors, thread ownership, and blocking I/O — is what separates a practitioner who uses the tool from one who can build, modify, and embed it.

This installment covers four sections:

  • The Netcat Contract — defines the behavioral specification we are implementing against: listen mode, connect mode, and the stdio relay contract, including the exact constraints those modes impose on any correct implementation.
  • TcpStream and the Ownership Wall — traces TcpStream from the Rust type down through the kernel fd table to the TCP state machine, examines the ownership wall the borrow checker erects, and compares the two valid approaches for splitting ownership across threads.
  • Listening: TcpListener and the Accept Loop — builds the server side, examining the listen()/accept() syscall pair and the two-queue kernel state machine that backs them, including what happens under SYN flood conditions.
  • Wiring Stdio to the Socket: Two Threads, One Session — delivers the first fully working duplex prototype, with an honest account of where it fails under asymmetric teardown conditions.

The Netcat Contract

Before writing Rust, write the specification. The behavioral contract we are implementing is deliberately narrow — stripped to the two primitives that make netcat useful as both an administrative tool and an offensive primitive.

Connect mode (nc <host> <port>): establish an outbound TCP connection to a target, then relay all data from our stdin to the socket and all data from the socket to our stdout, simultaneously, until either side closes.

Listen mode (nc -l -p <port>): bind a local port, block until a remote host connects, then apply the same bidirectional relay.

Both modes reduce to the same relay contract:

DirectionSourceDestination
OutboundOur process stdinRemote TCP peer
InboundRemote TCP peerOur process stdout

The constraints this contract imposes on the implementation are exact, and violating any of them silently produces a tool that is not actually a relay:

  • Both directions must run concurrently. A sequential implementation that drains stdin to the socket and then reads the socket to stdout is two consecutive half-duplex pipes. It will deadlock the moment the remote peer waits for our output before sending any of its own.
  • EOF on stdin should terminate the outbound relay but must not immediately destroy the inbound relay. The remote peer may still have data in flight when we stop transmitting.
  • EOF on the socket — the remote peer closing its write end — should terminate the inbound relay but must not prevent a final flush of any outbound data we have already buffered.

That third constraint is where naive implementations silently fail. Article 2 addresses it with TcpStream::shutdown() and proper half-close signaling. For this installment, we establish the mechanism and document the limitation explicitly rather than paper over it.


TcpStream and the Ownership Wall

The Mechanism

A TcpStream is an owned handle to a kernel file descriptor. On Linux, the standard library backs it with a single OwnedFd — an integer index into the calling process’s file descriptor table. When the TcpStream is dropped, Rust’s Drop implementation calls close(2) on that integer, which decrements the kernel’s reference count on the underlying open file description. When the count reaches zero, the socket tears down.

This is RAII applied to kernel resources — and in Rust, it is enforced deterministically at compile time rather than left to destructors that may or may not run. In C, forgetting to call close(fd) on an error path leaks the fd for the lifetime of the process. In C++, a unique_ptr-style socket wrapper closes it on destruction, but only if the destructor actually executes — exception handling and early returns create escape hatches that require discipline to close. Rust offers no such escape hatch under safe code: when TcpStream goes out of scope, the fd is closed, unconditionally, regardless of how the scope was exited. There is no code path through which a Rust TcpStream leaks its kernel resource under normal ownership.

The Kernel Data Structure Chain

“TcpStream wraps an fd” is accurate but not yet useful. The precise picture requires following that integer index down through the kernel to understand what try_clone() and Arc<TcpStream> actually share and what they leave independent.

On Linux, every process carries a struct task_struct, which holds a pointer to struct files_struct. That structure contains the file descriptor table — an array of pointers to struct file objects, indexed by file descriptor number. The integer we call an fd is an index into this array.

The struct file is the open file description. It is the kernel object that represents a single call to open(2) or socket(2). It holds the file’s current status flags (O_RDONLY, O_NONBLOCK, etc.), an operation vtable (f_op), a reference count (f_count), and a private_data pointer. For a socket, that pointer leads to struct socket, which in turn holds the protocol-specific sk pointer — for TCP, a struct tcp_sock embedded inside a struct sock containing the full TCP state machine: sequence numbers, the congestion window, the retransmission queue, and the TCP state (ESTABLISHED, CLOSE_WAIT, etc.).

Kernel layerStructureKey fields relevant to our relay
Fd table entryfd_array[N] in struct files_structPointer to struct file; FD_CLOEXEC flag
Open file descriptionstruct filef_count (reference count); f_flags; f_op vtable; private_data
Socket abstractionstruct socketsk pointer; socket state (SS_CONNECTED); type (SOCK_STREAM)
TCP state machinestruct tcp_socksnd_nxt, rcv_nxt, send/receive buffers, congestion window, TCP FSM state

When dup(2) is called — which is what try_clone() does under the hood — the kernel allocates a new entry in fd_array pointing at the same struct file and increments f_count. That is the entire operation. Nothing below struct file is touched. The struct socket and struct tcp_sock are singular and shared. This has a direct consequence: socket options such as TCP_NODELAY and SO_RCVBUF live on struct sock, which is below struct file. Setting them through one fd affects the other fd equally, because both fds point at the same struct file, which points at the same struct socket, which points at the same struct sock. The fd table is the only layer where the two handles diverge.

This also explains the FD_CLOEXEC (O_CLOEXEC) flag’s placement: it lives at the fd table entry level, not on struct file. When we fork() and exec(), the kernel walks the fd table and closes any entry where FD_CLOEXEC is set. Entries without it are inherited by the child process — granting it access to every open socket, file, and pipe whose fd was not explicitly marked for close-on-exec. Rust’s standard library sets O_CLOEXEC when creating sockets on Linux via SOCK_CLOEXEC in the socket(2) call, which is the correct default. Fds created by try_clone() also carry this flag. The inherited fd vulnerability class — of which CVE-2019-5736 is the canonical container-escape example — exists precisely in environments where this flag is absent or bypassed.

The Ownership Wall

The kernel data structure chain is now clear. The Rust type system problem follows directly from it. TcpStream implements Read and Write with &mut self — mutable receiver — because the standard library models the fd as a resource that only one caller should be actively reading or writing through at a time from Rust’s perspective. The borrow checker enforces that at most one &mut TcpStream can exist at any moment. Moving the value into two threads — each needing its own &mut TcpStream — is categorically rejected:

// NOTE: Illustrative — this does not compile. The compiler error is
// error[E0382]: use of moved value: `relay_conn`.
use std::io;
use std::net::TcpStream;
use std::thread;
fn broken_relay(relay_conn: TcpStream) {
// Moving relay_conn into this closure gives the spawned thread sole
// ownership. The value no longer exists in the calling scope.
let inbound = thread::spawn(move || {
let mut socket = relay_conn; // relay_conn moved here
io::copy(&mut socket, &mut io::stdout()).unwrap();
});
// error[E0382]: use of moved value: `relay_conn`
// The compiler tracked the move. relay_conn is gone.
io::copy(&mut io::stdin(), &mut relay_conn).unwrap(); // rejected
}

Rust offers two structurally different solutions to this. They interact with the kernel data structure chain differently, and the distinction matters.

Splitting Ownership: try_clone() vs. Arc<TcpStream>

try_clone() calls dup(2). A new fd table entry is created, pointing at the same struct file, with f_count incremented to 2. We get two independently owned TcpStream values — each can be moved into a separate thread, and each will call close(2) on its own fd when dropped. Dropping one decrements f_count to 1; the socket stays alive. Only when the second is dropped does f_count reach 0 and the kernel tear the socket down.

use std::io;
use std::net::TcpStream;
use std::thread;
fn relay_with_try_clone(relay_conn: TcpStream) -> io::Result<()> {
// try_clone() issues dup(2). The kernel creates a new fd_array entry
// pointing at the same struct file. f_count increments from 1 to 2.
// relay_rx and relay_conn are now independently owned TcpStream values —
// each thread will take one, and the borrow checker is satisfied.
let mut relay_rx = relay_conn.try_clone()?;
let mut relay_tx = relay_conn;
let inbound_handle = thread::spawn(move || -> io::Result<u64> {
// relay_rx is moved exclusively into this thread. No lock needed:
// ownership transfer is the synchronization mechanism.
io::copy(&mut relay_rx, &mut io::stdout())
});
let outbound_result = io::copy(&mut io::stdin(), &mut relay_tx);
let inbound_result = inbound_handle
.join()
.map_err(|_| io::Error::new(io::ErrorKind::Other, "inbound relay panicked"))?;
outbound_result?;
inbound_result?;
Ok(())
}

Arc<TcpStream> takes a different path. It does not call dup(2). Instead, it wraps the single TcpStream in a heap-allocated reference-counted box managed entirely by Rust’s runtime. The kernel fd count stays at 1. Both threads hold an Arc clone — a pointer to the same TcpStream, with Rust’s atomic refcount managing the lifetime. This works because the standard library also provides impl Read for &TcpStream and impl Write for &TcpStream (in addition to the &mut self variants), enabling shared-reference access to the socket when the OS-level synchronization on the socket buffer is sufficient:

use std::io;
use std::net::TcpStream;
use std::sync::Arc;
use std::thread;
fn relay_with_arc(relay_conn: TcpStream) -> io::Result<()> {
// Arc::new() places relay_conn on the heap with a reference count of 1.
// No dup(2) is issued — the kernel sees one fd throughout.
let arc_conn = Arc::new(relay_conn);
let arc_rx = Arc::clone(&arc_conn); // refcount → 2
let inbound_handle = thread::spawn(move || -> io::Result<u64> {
// &*arc_rx dereferences the Arc to &TcpStream.
// impl Read for &TcpStream allows io::copy to read without &mut self.
// The OS serializes concurrent socket reads at the kernel buffer level.
io::copy(&mut &*arc_rx, &mut io::stdout())
});
// impl Write for &TcpStream allows writing through the shared reference.
let outbound_result = io::copy(&mut io::stdin(), &mut &*arc_conn);
let inbound_result = inbound_handle
.join()
.map_err(|_| io::Error::new(io::ErrorKind::Other, "inbound relay panicked"))?;
outbound_result?;
inbound_result?;
Ok(())
}

Both compile. Both relay correctly in the happy path. The differences become visible when the implementation needs to do something more precise:

Propertytry_clone() / dup(2)Arc<TcpStream>
Kernel fd count2 after clone1 throughout
struct file instances1 shared (both fds point to it)1 (one fd)
Socket option propagationAffects both handles — shared struct sockAffects both uses — same struct sock
shutdown(Shutdown::Write)Affects the shared socket — both handles observe itSame
Independent close(2) per handleYes — each drop() closes one fdNo — close(2) only when Arc refcount hits 0
try_clone() failure modeReturns Err on fd limit exhaustionN/A (no kernel call)

The decisive difference is the last column. With try_clone(), dropping relay_tx calls close(2) on its fd immediately, decrementing f_count to 1. The kernel socket is not yet torn down — relay_rx still holds it open — but the fd is gone. With Arc<TcpStream>, dropping one Arc clone merely decrements Rust’s refcount. No kernel call happens until the count reaches zero. For our relay, this distinction matters specifically in the teardown path: Article 2 needs to call shutdown(Shutdown::Write) on the write half to signal FIN to the peer while leaving the read half operational. Both approaches propagate that shutdown() to the same underlying socket, so the signal is identical. But try_clone() models the two-half structure of a TCP stream more honestly at the fd level, and its semantics compose more cleanly with the half-close pattern we introduce next. We will use it throughout this series.

The Security Correlation

The fd duplication mechanic is not merely implementation background. The fd-leak and fd-confusion vulnerability class — of which CVE-2019-5736 is the canonical container-escape example — arises from exactly this machinery. In that vulnerability, a spawned container process inherited a writable file descriptor to the host runc binary (via /proc/self/fd/N) because the fd was not marked O_CLOEXEC. The container process overwrote the host binary through that fd, achieving a full host escape. The root mechanism: an fd duplicated (or kept open) across exec(2) without the close-on-exec flag, granting a child process access to a kernel resource it was never intended to hold.

Every try_clone() in our relay creates an independently surviving fd handle. When relay_tx is dropped, the kernel socket stays alive because relay_rx‘s fd still holds f_count above zero. An fd that outlives its intended scope is the common ancestor of an entire family of privilege-escalation and container-escape vulnerabilities. Understanding that try_clone() produces a reference-counted kernel resource — not a copy of the socket state — is the prerequisite for reasoning about any of them.

Technical Reality Check

Because both try_clone() handles share the same struct filestruct socketstruct tcp_sock chain, socket options set through one are immediately visible through the other. Setting TCP_NODELAY on relay_tx disables Nagle’s algorithm for relay_rx as well — there is no per-fd configuration below the fd table entry. This is not a bug; it is a correct reflection of the kernel’s model. But it means there is no path to configure the two halves of the relay independently using socket options. If asymmetric configuration is needed (a pattern that appears in TLS split-pipe architectures), the correct tool is two entirely separate sockets on two entirely separate connections — not two fds into the same socket.


Listening: TcpListener and the Accept Loop

The Mechanism

On the passive side, TcpListener::bind(addr) is a single call that executes three distinct kernel operations in sequence:

  1. socket(AF_INET, SOCK_STREAM, SOCK_CLOEXEC) — allocates a new TCP socket fd, marked close-on-exec from the moment of creation.
  2. setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, 1) — enables address reuse. Rust’s standard library sets this automatically on non-Windows platforms. Without it, killing and immediately restarting the process on the same port would fail with EADDRINUSE while the kernel holds the previous connection’s fd in TIME_WAIT.
  3. listen(fd, 128) — transitions the socket to the passive-open state and configures the accept queue depth. The value 128 is hardcoded in the standard library; the effective depth is capped by net.core.somaxconn regardless of what we request.

The Two-Queue State Machine

The listen(2) call instructs the kernel to maintain two separate queues for incoming connections, and the distinction between them has direct operational consequences for our relay’s behavior under load.

The SYN queue — formally the incomplete connection queue — holds connections that have received a SYN from the remote peer, completed the kernel’s SYN-ACK response, but have not yet received the final ACK. These are half-open connections: the kernel has allocated state for them and committed to the handshake, but the three-way exchange is not yet complete. The depth of this queue is governed by the kernel parameter net.ipv4.tcp_max_syn_backlog, entirely independently of the 128 we pass to listen(2).

The accept queue — the completed connection queue — holds connections where the three-way handshake is fully complete. When our application calls accept(2), the kernel dequeues the front entry from this queue, allocates a new fd for it, and hands it to us. The 128 backlog argument to listen(2) controls this queue’s depth, not the SYN queue.

Remote SYN arrives
┌─────────────┐
│ SYN queue │ ← governed by tcp_max_syn_backlog
│ (incomplete │
│ conns) │
└──────┬──────┘
│ ACK received
┌─────────────┐
│ accept queue│ ← governed by listen(128) ∩ somaxconn
│ (complete │
│ conns) │
└──────┬──────┘
│ accept(2) dequeues
New TcpStream

SYN flood behavior exposes the difference between these queues directly. A SYN flood — a class of denial-of-service attack that exhausts the SYN queue by sending SYN packets without completing handshakes — fills the SYN queue with half-open connections and prevents legitimate clients from getting a SYN-ACK at all. The Linux kernel’s mitigation is SYN cookies: when the SYN queue is full, the kernel encodes the connection parameters into the initial sequence number of the SYN-ACK instead of allocating queue space. If the client completes the handshake with the correct ACK, the kernel reconstructs the connection state from the cookie and places it directly in the accept queue, bypassing the SYN queue entirely. Enabled via net.ipv4.tcp_syncookies, SYN cookies are the reason our prototype remains connectable under moderate SYN flood pressure even with a shallow queue depth.

Accept queue exhaustion is a different failure mode. If our application stops calling accept() — say, because it is blocked on processing a previous session — completed connections accumulate in the accept queue. Once it fills to depth 128 (or somaxconn, whichever is smaller), the kernel begins dropping the ACK that would complete new handshakes, or in some configurations sending RST. From the remote client’s perspective, the connection was established at the TCP level and then immediately reset — a fingerprint distinct from a port that is simply closed.

listener.accept() wraps accept4(2) on Linux, with SOCK_CLOEXEC set on the returned fd by default. It blocks the calling thread until the accept queue is non-empty, then dequeues the front entry and returns a (TcpStream, SocketAddr) pair.

use std::io;
use std::net::TcpListener;
fn accept_one(bind_addr: &str) -> io::Result<()> {
// bind() executes socket(SOCK_CLOEXEC) + setsockopt(SO_REUSEADDR) + listen(128).
// The kernel begins processing SYN packets and completing handshakes
// the moment this returns. Completed connections queue at depth min(128, somaxconn)
// before the kernel begins dropping or RST-ing new arrivals.
let listener = TcpListener::bind(bind_addr)?;
eprintln!("[*] Accept queue active on {}", bind_addr);
// accept4(2) blocks here. The kernel has potentially completed multiple
// handshakes while we waited — they queue up. We dequeue exactly one.
let (session_stream, peer_addr) = listener.accept()?;
eprintln!("[+] Session established: {}", peer_addr);
// session_stream is a fully owned TcpStream for this connection.
// listener remains valid — calling accept() again dequeues the next.
// Articles 3 and 4 build exactly that loop.
drop(session_stream);
drop(listener);
Ok(())
}

The Security Correlation

A listening TCP socket’s behavior under probe conditions is more revealing than most practitioners account for. A service that completes the three-way handshake and immediately emits a protocol banner — SSH SSH-2.0-OpenSSH_9.x, HTTP HTTP/1.1 200 OK — is trivially fingerprinted by that banner. A service that completes the handshake and emits nothing — our relay prototype, a raw netcat listener, a bare bind shell — presents a different signature: the port is open, the handshake succeeds, and the connection sits in silence until the connecting party speaks first.

Nmap’s version detection (-sV) classifies the latter as tcpwrapped when no response is elicited by standard probes. It is not invisible — it is a fingerprint of a specific implementation class. A sufficiently patient adversary can determine the accept queue depth by saturating it with connections and measuring when new ones begin to RST or time out, leaking the net.core.somaxconn configuration of the target host. A hardcoded listen(128) call is advertising a kernel configuration detail. This is one of the behavioral characteristics Articles 5 and 6 address when we expose socket configuration through a proper CLI, making the backlog tunable rather than constant.


Wiring Stdio to the Socket: Two Threads, One Session

The Mechanism

We now have all the components. A TcpStream from either TcpStream::connect() or TcpListener::accept(), a try_clone() call to split ownership across two threads, and io::copy() to drive each relay direction. The prototype below wires them together into both operating modes and constitutes the first fully working duplex relay.

std::io::copy reads from its source in a loop using an 8 KiB stack-allocated buffer, writing each chunk to the destination until the source returns Ok(0) — the Read trait’s signal for EOF — or either side returns Err. It returns the total bytes transferred as io::Result<u64>. It does not distinguish between a clean EOF and a peer reset at the return type level; both terminate the copy loop and both may surface as errors depending on which syscall failed underneath.

use std::io;
use std::net::{TcpListener, TcpStream};
use std::thread;
/// Runs a bidirectional relay between `relay_conn` and process stdio.
///
/// The inbound direction (socket → stdout) runs in a spawned thread.
/// The outbound direction (stdin → socket) runs on the calling thread.
/// Both directions proceed concurrently from the moment the thread spawns.
fn duplex_relay(relay_conn: TcpStream) -> io::Result<()> {
// try_clone() issues dup(2). Two fd_array entries in our process's
// file descriptor table now point at the same struct file.
// f_count increments to 2. The socket stays alive until both are dropped.
let mut relay_rx = relay_conn.try_clone()?;
let mut relay_tx = relay_conn;
// Inbound relay: socket → stdout.
//
// io::copy will block inside read(2) until the remote peer sends data or
// closes its write end (FIN). If the peer never closes, this thread parks
// indefinitely — even after the outbound relay has finished on the calling
// thread. That is the blocking trap described in the Technical Reality
// Check below.
let inbound_handle = thread::spawn(move || -> io::Result<u64> {
io::copy(&mut relay_rx, &mut io::stdout())
});
// Outbound relay: stdin → socket.
//
// Runs on the calling thread. When the operator presses Ctrl+D, or the
// calling process closes its stdin pipe, io::copy receives Ok(0) on the
// reader side and returns. Everything stdin had to offer has been sent.
let outbound_result = io::copy(&mut io::stdin(), &mut relay_tx);
// join() blocks until the inbound thread returns. If the remote peer
// closed its connection, the inbound thread has already returned and this
// is instant. If the peer is still connected, we wait. If the peer never
// closes, we wait forever. Article 2 resolves this with
// shutdown(Shutdown::Write) before the join().
let inbound_result = inbound_handle
.join()
.map_err(|_| io::Error::new(io::ErrorKind::Other, "inbound relay panicked"))?;
outbound_result?;
inbound_result?;
Ok(())
}
fn connect_mode(target_addr: &str) -> io::Result<()> {
eprintln!("[*] Connecting to {}", target_addr);
// connect() executes the three-way handshake synchronously, blocking
// until the kernel receives SYN-ACK and sends the final ACK.
let relay_conn = TcpStream::connect(target_addr)?;
eprintln!("[+] Relay established: {}", target_addr);
duplex_relay(relay_conn)
}
fn listen_mode(bind_addr: &str) -> io::Result<()> {
let listener = TcpListener::bind(bind_addr)?;
eprintln!("[*] Waiting for session on {}", bind_addr);
// accept() blocks until the kernel dequeues a completed connection.
// We handle exactly one session here — the loop that manages multiple
// sessions arrives in Article 4.
let (session_stream, peer_addr) = listener.accept()?;
eprintln!("[+] Session from {}", peer_addr);
duplex_relay(session_stream)
}
fn main() -> io::Result<()> {
// Mode is hardcoded for this prototype. Article 5 replaces this with a
// clap-derived CLI that maps canonical nc flags (-l, -p, -v, -k) onto a
// typed Mode enum, making invalid flag combinations unrepresentable.
listen_mode("0.0.0.0:4444")
}

The threading model is intentionally minimal: one thread::spawn for the inbound direction, the calling thread for the outbound. No mutexes, no channels, no shared state. Each thread owns exactly one TcpStream handle via try_clone(). The only coordination point is the join() on the inbound handle after the outbound relay finishes. This is not a coincidence — the ownership split via try_clone() is the synchronization. Two threads with no shared mutable state require no locks.

The Security Correlation

What we have built is the core relay mechanism of an interactive reverse shell, expressed at the Rust type level. Keystrokes flow stdinrelay_tx → remote socket → remote process stdin. Remote output flows remote process stdout → socket → relay_rx → our stdout. The remote process is entirely invisible to duplex_relay — it could be a shell, a database client, a port forwarder, or another relay. The function does not care. This indifference to the nature of what it is relaying is precisely what makes netcat, and this pattern, operationally significant.

The lower-level equivalent in a raw exploit payload uses dup2(2) rather than try_clone() — the same kernel primitive, accessed directly. After fork() and before execve("/bin/bash"), the payload issues:

// Redirect the shell's standard streams to the relay socket.
// dup2(old_fd, new_fd) closes new_fd if open, then duplicates old_fd into it.
// After these three calls, the shell's stdio ARE the TCP socket fd.
dup2(sockfd, STDIN_FILENO); // fd 0 → socket's struct file
dup2(sockfd, STDOUT_FILENO); // fd 1 → socket's struct file
dup2(sockfd, STDERR_FILENO); // fd 2 → socket's struct file
execve("/bin/bash", args, env);

Our Rust implementation reaches the same structural result without execve — we are the process doing the reading and writing, not a child shell. The kernel mechanism is identical: dup(2) / dup2(2) creates additional fd table entries pointing at the same struct file, and both directions of the duplex relay flow through that shared kernel socket object.

Technical Reality Check

The prototype has two failure modes that any live session will expose.

The first is the join-after-EOF trap. When the operator presses Ctrl+D, the outbound io::copy returns and the calling thread reaches join(). If the remote peer is a live shell with its write end still open — which it will be, because bash does not exit merely because we stopped sending input — the inbound thread is blocked in read(2). The join() blocks. The process hangs. The only resolution is to call relay_tx.shutdown(Shutdown::Write) before the join(). This instructs the kernel to send a FIN to the peer, signaling that our write end is done, without closing relay_tx‘s fd — which would prematurely decrement f_count and potentially affect the inbound direction. The half-close leaves the inbound relay operational until the peer responds with its own FIN. Article 2 adds this call and explains exactly where in the teardown sequence it belongs.

The second is the silent write-error window. If the remote peer resets the connection abruptly — RST rather than FIN — the next write through relay_tx fails with ECONNRESET. io::copy returns Err, propagated via outbound_result?. But the join() happens before that ? propagates the error. We wait for the inbound thread to finish, which it may have already done (having received the RST on its read(2)), and only then do we surface the error. Any bytes the inbound thread flushed to stdout before detecting the RST are already delivered to the terminal. The error arrives after the data it should have gated. For an interactive session this ordering is acceptable. For a file transfer relay where integrity matters, it is not — and the distinction between RST and clean EOF needs to be surfaced explicitly to the caller.


Looking Ahead

The prototype is functional. It opens connections in either mode, splits stream ownership with a single try_clone() call, and relays bytes in both directions concurrently. What it does not do is exit cleanly under the one scenario that matters most in practice: the operator is done typing but the remote session is still alive. That is not an edge case — it is the normal operating mode of any interactive shell session. The hang-on-join behavior means this prototype requires the remote peer to close the connection before we can exit, which is not how netcat behaves and not what an operator expects. Article 2 resolves this with TcpStream::shutdown(), wires the first real argument parser to switch between listen and connect modes at runtime, and sets the socket options — TCP_NODELAY in particular — that make the tool behave correctly under interactive conditions rather than only in the controlled conditions of a tutorial.


Further Reading & Deep Dives:

  • For a rigorous treatment of Rust’s ownership model as a formal system — including the exact rules prohibiting aliased mutable access across threads — see Programming Rust (Blandy et al., Chapters 4 and 5).
  • For the kernel-side mechanics of dup(2), the struct file reference count, and the open file description lifecycle, see The Linux Programming Interface (Kerrisk, Chapter 5).
  • For fd inheritance across exec(2), the O_CLOEXEC / FD_CLOEXEC mechanism, and the vulnerability class that arises when fds survive into child processes unintentionally, see The Linux Programming Interface (Kerrisk, Chapter 27).
  • For the Rustonomicon’s framing of where Rust’s compile-time safety guarantees end and what the programmer takes sole responsibility for at the FFI boundary, see Safe and Unsafe Code.

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