Welcome back to Raw Ptr Insights. In the previous installment we established the duplex relay primitive: a TcpStream split via try_clone() into two independently owned halves, each driven by io::copy() on separate threads. The prototype works, but it has a documented flaw we named without fixing — the join-after-EOF trap, where closing stdin leaves the process blocked in join() indefinitely because the remote peer’s write end is still open. That gap is this installment’s starting point.
A relay that hangs on the most common termination path is not a tool. It is a prototype — and the distinction is not cosmetic. In an operator workflow, a blocked process means a terminal that cannot be reclaimed without a kill, which means signal noise, potential log entries, and a session that did not close on the terms the operator intended. The hang also has a subtler implication: if the relay is embedded inside a larger pipeline — reading from a file or feeding another process — a blocked join is a deadlock in that pipeline, not just an inconvenience. The same code that works in a terminal demonstration fails silently when composed.
Most Rust networking articles that acknowledge shutdown() at all treat it as a minor cleanup detail — mentioned in a footnote, applied without explanation. The distinction between shutdown(2) and close(2) at the kernel level is precise and consequential: one modifies the socket state machine, the other releases a file descriptor. Getting that wrong produces teardown behavior that is observable on the wire, and observable teardown behavior is fingerprintable teardown behavior. The same gap applies to socket options: Nagle’s algorithm has a 200-millisecond timer that makes an interactive relay feel broken under certain conditions, and the fix is a single flag that most implementations either miss or apply without explaining why it exists.
The operational framing is direct. A tool that hangs, panics on unexpected input, or produces RST-terminated sessions instead of proper FIN exchanges is not operationally reliable — and in an adversarial context, unreliability is a liability that lands on the operator. A reverse shell that drops unexpectedly because the relay panicked on a write error to a closed pipe is not a stable primitive. Building stability into the relay at this stage — before we port to async, before we add multi-session handling — means that each subsequent layer inherits a correctly-behaved foundation.
This installment covers four sections:
- CLI Surface and Mode Dispatch — replaces the hardcoded
listen_mode()call with a type-safeRelayModeenum parsed from arguments, establishing the structural pattern that Article 5’sclapCLI will extend. - Half-Close and the TCP State Machine — traces the difference between
shutdown(2)andclose(2)at the kernel level, appliesShutdown::Writeat the correct point in the teardown sequence, and resolves the join-after-EOF trap. - Socket Options That Matter — explains Nagle’s algorithm,
TCP_NODELAY, andSO_KEEPALIVEat the mechanism level, and applies them via both the standard library and thesocket2crate. - Error Handling Without Panicking — addresses the nested
Resultproblem fromJoinHandle::join(), introduces a properRelayErrortype, and eliminates the panic-swallowing that the Article 1 prototype inherited by necessity.
CLI Surface and Mode Dispatch
The Mechanism
The Article 1 prototype hardcoded its mode:
fn main() -> io::Result<()> {
// Mode is hardcoded for this prototype. Article 5 replaces this.
listen_mode("0.0.0.0:4444")
}
That is a single call with two problems: the mode is not selectable at runtime, and the address is not configurable without recompilation. Fixing both starts with encoding mode as a sum type — a Rust enum where each variant carries exactly the data that variant needs and nothing else.
use std::env;
use std::io;
/// The two operating modes of the relay, with their required parameters.
/// Encoding mode as an enum rather than a boolean flag means the compiler
/// enforces that listen mode always has a bind address and connect mode
/// always has a peer address — invalid combinations are unrepresentable.
#[derive(Debug)]
enum RelayMode {
Listen { bind_addr: String },
Connect { peer_addr: String },
}
fn parse_relay_mode() -> Result<RelayMode, String> {
let argv: Vec<String> = env::args().collect();
match argv.as_slice() {
// Listen mode: -l <addr:port>
[_, flag, addr] if flag == "-l" => {
Ok(RelayMode::Listen { bind_addr: addr.clone() })
}
// Connect mode: <addr:port>
[_, peer_addr] => {
Ok(RelayMode::Connect { peer_addr: peer_addr.clone() })
}
_ => Err(format!("usage: {} [-l <addr:port>] <addr:port>", argv[0]))
}
}
The match on argv.as_slice() uses Rust’s slice pattern syntax to destructure the argument vector by length and content simultaneously. The guard if flag == "-l" narrows the three-argument case to listen mode without a nested conditional. The result is that the compiler statically knows which variant is active at each callsite — calling .bind_addr on a Connect variant is a compile error, not a runtime panic.
The main() becomes:
fn main() {
let relay_mode = match parse_relay_mode() {
Ok(mode) => mode,
Err(usage_msg) => {
eprintln!("{}", usage_msg);
std::process::exit(1);
}
};
let result = match relay_mode {
RelayMode::Listen { bind_addr } => listen_mode(&bind_addr),
RelayMode::Connect { peer_addr } => connect_mode(&peer_addr),
};
if let Err(e) = result {
eprintln!("relay error: {}", e);
std::process::exit(1);
}
}
We do not use io::Result<()> as the return type of main() here — that form prints the debug representation of an error on failure, which includes the error kind and OS message but no usage context. Explicit error handling with eprintln! and process::exit(1) gives us control over what the operator sees.
The Security Correlation
The flag surface of a network tool is itself a fingerprint. Netcat exists in at least four major implementations — GNU netcat, OpenBSD nc, ncat (Nmap’s variant), and socat — with non-trivial behavioral differences in how they parse flags. In GNU netcat, -p sets the source port of an outbound connection. In ncat, -p is the listen port. A script written against one implementation can silently misbehave against another, connecting to the wrong endpoint or failing to bind at all.
Our RelayMode enum makes these ambiguities impossible at the type level within our implementation — the mode and its required parameter are a single inseparable unit. This does not fix the user-interface ambiguity (Article 5’s clap CLI addresses that by matching canonical nc flag semantics explicitly), but it ensures the implementation cannot enter a state where, for example, it is in listen mode without a bind address. Invalid operating states are unrepresentable, which means an entire class of misrouting bugs cannot compile.
Half-Close and the TCP State Machine
The Mechanism
TCP is a full-duplex protocol: each end of a connection maintains an independent send stream and receive stream. Either end can close its send stream while leaving its receive stream open. This is half-close, and it is the mechanism through which we signal to the remote peer that we are done sending without tearing down the entire connection.
The Linux syscalls that interact with connection teardown are meaningfully different:
| Syscall | What it does | fd still open? |
|---|---|---|
close(2) | Decrements the fd’s reference count; closes the socket when count reaches 0 | No |
shutdown(2) | Modifies the socket state machine; sends FIN if SHUT_WR or SHUT_RDWR | Yes |
close(2) is an fd-level operation. shutdown(2) is a socket-level operation. The difference: after shutdown(SHUT_WR), the fd still exists and can still be read through — the kernel has merely sent a FIN to the peer to signal that no more data will be sent in this direction. The remote peer receives EOF on its read(2). Our receive stream remains operational; we can still read data the peer sends before it closes its own write end.
The TCP state machine formalises this. RFC 793 defines the full connection lifecycle:
ESTABLISHED
│
(shutdown SHUT_WR)
│ send FIN
▼
FIN-WAIT-1
│
(peer ACKs our FIN)
│
▼
FIN-WAIT-2 ← we can still read here; peer's write end is open
│
(peer sends FIN)
│ send ACK
▼
TIME-WAIT
│ (2×MSL timer expires)
▼
CLOSED
In Article 1’s prototype, the inbound thread blocks in read(2) inside io::copy(). When the outbound io::copy() returns (stdin closed), we call join() on the inbound handle. If the peer has not yet closed its write end, the inbound thread is still in FIN-WAIT-2 — we have no outstanding FIN to the peer, so the peer has no reason to close its write end, so the inbound read(2) never returns, so the join() never returns. The process hangs.
The fix is to call shutdown(Shutdown::Write) on the write half before join(). In Rust, TcpStream::shutdown() wraps shutdown(2) directly. We call it on relay_tx — the write half from try_clone() — which issues the SHUT_WR syscall on the underlying socket, sending FIN to the peer. Because both relay_tx and relay_rx share the same struct file → struct socket chain (per our Article 1 kernel data structure trace), this shutdown() affects the entire socket, not just the relay_tx fd. The peer receives EOF on its read, closes its write end, and sends its own FIN — which the inbound thread receives as Ok(0) from io::copy(), causing it to return. The join() then completes immediately.
use std::io;
use std::net::{Shutdown, TcpStream};
use std::thread;
fn duplex_relay(relay_conn: TcpStream) -> io::Result<()> {
let mut relay_rx = relay_conn.try_clone()?;
let mut relay_tx = relay_conn;
let inbound_handle = thread::spawn(move || -> io::Result<u64> {
io::copy(&mut relay_rx, &mut io::stdout())
});
let outbound_result = io::copy(&mut io::stdin(), &mut relay_tx);
// shutdown(SHUT_WR) sends FIN on the socket without closing the fd.
// Because relay_rx and relay_tx share the same struct socket (via dup(2)),
// this shuts down the write direction of the entire connection, not just
// relay_tx's fd. The peer receives EOF on its read(2), closes its write
// end, and sends FIN — which unblocks the inbound io::copy() and allows
// join() to return instead of blocking indefinitely.
//
// We explicitly ignore ENOTCONN: if the peer already closed the connection
// before we reached this point, the socket may already be in CLOSE_WAIT
// and shutdown(SHUT_WR) returns ENOTCONN. That is not an error we need
// to propagate — the inbound thread will already be returning.
if let Err(shutdown_err) = relay_tx.shutdown(Shutdown::Write) {
if shutdown_err.kind() != io::ErrorKind::NotConnected {
return Err(shutdown_err);
}
}
let inbound_result = inbound_handle
.join()
.map_err(|_| io::Error::new(io::ErrorKind::Other, "inbound relay thread panicked"))?;
outbound_result?;
inbound_result?;
Ok(())
}
The placement matters. shutdown(Shutdown::Write) must come after the outbound io::copy() returns and before join(). Calling it before the outbound copy finishes would send FIN prematurely, telling the peer we are done sending before we have actually finished. Calling it after join() is too late — join() would already be blocking by then.
Technical Reality Check
SO_REUSEADDR — already applied automatically by Rust’s TcpListener::bind() on non-Windows platforms — interacts with TIME_WAIT in a way worth stating explicitly here. When a connection closes, the active closer enters TIME_WAIT for 2×MSL (Maximum Segment Lifetime, typically 60 seconds on Linux, yielding a 60-second TIME_WAIT). During that period, the kernel refuses to bind the same local port for a new connection on the same four-tuple, to prevent delayed segments from a dead connection from corrupting a new one. Without SO_REUSEADDR, a relay process that exits and immediately restarts on the same port receives EADDRINUSE and cannot bind at all until the timer expires.
SO_REUSEADDR permits rebinding a port that has connections in TIME_WAIT. It does not bypass the four-tuple check for active connections — it only relaxes it for TIME_WAIT state. The practical effect: killing and restarting our relay on the same port works immediately. Without it, a listener restarted mid-operation — after catching a signal, for example — would fail to bind for up to 60 seconds.
The Security Correlation
The teardown pattern of a TCP session is observable in a network capture, and different teardown patterns are associated with different tool categories. A connection that closes with a proper four-way FIN exchange — FIN, FIN-ACK, FIN, FIN-ACK — is indistinguishable from any normal session termination. A connection that closes with RST (which is what close(2) without prior shutdown(SHUT_WR) can produce when data is still pending in the receive buffer) produces a different pattern. Some IDS rulesets explicitly flag RST-terminated sessions with asymmetric data volumes as potential exfiltration or C2 traffic.
Our shutdown(Shutdown::Write) before close(2) (which Rust’s Drop calls when relay_tx goes out of scope) produces the standard four-way exchange. A relay that tears down cleanly looks like any other TCP session on the wire. A relay that RSTs because the implementation hung and was killed looks like a tool.
Socket Options That Matter
The Mechanism
Two socket options determine whether our relay is usable for interactive sessions: TCP_NODELAY and SO_KEEPALIVE. They address different failure modes, and both are absent from Article 1’s prototype.
TCP_NODELAY and Nagle’s Algorithm
Nagle’s algorithm — defined in RFC 896 and implemented in every TCP stack since 1984 — is a throughput optimization that coalesces small writes before transmission. The algorithm is simple: if there is unacknowledged data in flight, buffer any new small writes until either the outstanding data is acknowledged or the buffer accumulates enough for a full-size segment. If no data is in flight, send immediately regardless of size.
For bulk transfer — sending a file, streaming a log — Nagle’s algorithm is exactly correct. Without it, a 1 MB file might generate 1 MB of 1-byte segments instead of ~700 full-size segments, saturating the network with per-packet overhead. With it, the kernel batches writes into maximum-size segments, maximising throughput.
For an interactive relay — proxying a shell session, relaying keystrokes — Nagle’s algorithm is a latency disaster. The kernel buffers the first keystroke until either the previous write is acknowledged or a timer fires, typically 200 milliseconds. On a loopback connection this is invisible. On a WAN link with 50ms RTT, the effective latency between a keystroke and its arrival at the remote process is 50ms + 200ms buffer + 50ms return = 300ms. The shell feels broken.
TcpStream::set_nodelay(true) sets TCP_NODELAY via setsockopt(2), which disables Nagle’s algorithm on the socket. The kernel then sends each write immediately, regardless of outstanding unacknowledged data. The throughput cost on a bulk transfer is measurable but acceptable for interactive use.
Note that TCP_NODELAY affects both halves of the connection via the shared struct sock, exactly as with try_clone(). Setting it on relay_tx is sufficient — relay_rx observes the same option because both fds point at the same underlying socket.
SO_KEEPALIVE
A relay that sits idle — waiting for the operator to type, or waiting for the remote process to produce output — is an idle TCP connection. Many NAT devices and stateful firewalls expire idle TCP state after 30 to 300 seconds, silently dropping the connection without sending RST or FIN. The application never receives EOF or an error. The next write attempt returns EPIPE or ECONNRESET — but if there is no write pending (only a blocking read() in the inbound thread), the application hangs indefinitely.
SO_KEEPALIVE instructs the kernel to send periodic probe segments on idle connections. If the peer does not acknowledge a probe, the kernel closes the connection and delivers an error to any pending read or write. The parameters that govern keepalive behavior — idle time before the first probe, interval between subsequent probes, number of unanswered probes before declaring the connection dead — are available via TCP_KEEPIDLE, TCP_KEEPINTVL, and TCP_KEEPCNT. Rust’s standard library exposes SO_KEEPALIVE as a boolean through TcpStream, but the per-connection timer configuration requires the socket2 crate.
SockRef::from() in socket2 borrows an existing socket fd without duplicating it — no dup(2), no new fd. It provides a &SockRef that exposes the full setsockopt(2) surface Rust’s standard library does not. When the SockRef is dropped, the borrow ends and the original TcpStream remains the sole owner of the fd.
use socket2::{SockRef, TcpKeepalive};
use std::io;
use std::net::TcpStream;
use std::time::Duration;
// NOTE: Illustrative — requires socket2 = "0.5" in Cargo.toml.
fn configure_relay_socket(session_stream: &TcpStream) -> io::Result<()> {
// TCP_NODELAY: disable Nagle's algorithm so keystrokes are sent immediately
// rather than buffered up to 200ms waiting for the previous ACK.
// Without this, any interactive session over a non-loopback link is unusable.
session_stream.set_nodelay(true)?;
// SO_KEEPALIVE + TCP_KEEPIDLE/INTVL: keep NAT and firewall state alive
// for idle relay sessions. Without keepalive, a session that goes quiet
// for more than ~60 seconds is silently dropped by most NAT devices, and
// the application hangs rather than receiving an error.
let socket_ref = SockRef::from(session_stream);
let keepalive_config = TcpKeepalive::new()
// First probe after 60 seconds of idle. Most NAT timeouts are 30-120s;
// 60s ensures we probe before the state expires.
.with_time(Duration::from_secs(60))
// Interval between subsequent probes if the first goes unanswered.
.with_interval(Duration::from_secs(10));
socket_ref.set_tcp_keepalive(&keepalive_config)?;
Ok(())
}
Applying these options after a connection is established but before the relay threads spawn is correct — socket options on an established connection take effect immediately for the lifetime of the socket.
The Security Correlation
Packet-level traffic classification distinguishes interactive sessions from bulk transfers by the size distribution and timing of segments. An interactive shell over a relay without TCP_NODELAY exhibits an anomalous pattern: segments arrive in clusters rather than individually, because Nagle’s algorithm is coalescing multiple keystrokes or output lines before transmission. A shell over a relay with TCP_NODELAY produces the single-byte, uniformly-spaced timing pattern characteristic of real interactive human input.
This cuts both ways. TCP_NODELAY makes the session feel and look interactive, which means it matches the expected signature of legitimate SSH traffic and other interactive protocols. Network monitoring tools that classify traffic by packet-size distribution and inter-arrival timing — commercial IDS platforms, ML-based traffic classifiers — are less likely to flag it as anomalous than the coalesced-segment pattern. Correct socket configuration is not just about usability; it is about the traffic signature the tool produces.
Error Handling Without Panicking
The Mechanism
Article 1’s error handling has two weaknesses inherited from its prototype status. First, JoinHandle::join() returns thread::Result<T>, which is Result<T, Box<dyn Any + Send + 'static>> — a Result whose error type is a type-erased panic payload. We were discarding the panic value entirely:
// Article 1 approach — swallows the panic payload entirely.
.map_err(|_| io::Error::new(io::ErrorKind::Other, "inbound relay thread panicked"))?;
This is acceptable for a prototype, but it means that if the inbound thread panics with a meaningful message — say, "write to stdout failed: Broken pipe" — that message is silently dropped. The operator sees only the generic "inbound relay thread panicked" string.
Second, the relay currently uses io::Result<()> everywhere. That type works for single-source error propagation, but our relay has two independent error sources (the two threads) that we need to report together, and we are shoe-horning both into io::Error via .map_err(). A purpose-built error type is cleaner and makes the error taxonomy explicit.
We define RelayError as a simple enum:
use std::fmt;
use std::io;
/// Error type for the relay engine. Separates I/O errors from thread panics
/// so callers can distinguish between infrastructure failures and code bugs.
#[derive(Debug)]
pub enum RelayError {
/// An I/O error from the relay itself — socket read/write, bind, connect.
Io(io::Error),
/// The inbound relay thread panicked. The string is a best-effort
/// extraction of the panic message; not all panic payloads are strings.
InboundPanic(String),
}
impl fmt::Display for RelayError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
RelayError::Io(e) => write!(f, "I/O error: {}", e),
RelayError::InboundPanic(msg) => write!(f, "inbound relay panicked: {}", msg),
}
}
}
impl From<io::Error> for RelayError {
// The From impl enables `?` on io::Result values inside functions that
// return Result<_, RelayError>. No explicit conversion at each callsite.
fn from(e: io::Error) -> Self {
RelayError::Io(e)
}
}
Go’s errors.As / errors.Is pattern, where error wrapping is done by convention via fmt.Errorf("%w", err), has no type-level enforcement — callers may or may not unwrap, and nothing stops the wrong type check. Rust’s From impl combined with ? is the type-safe equivalent: the compiler enforces the conversion at the point of propagation, and the error’s variant is inspectable by pattern-matching without any casting.
To extract a meaningful message from a panic payload, we use Box<dyn Any>‘s downcast_ref:
fn extract_panic_message(payload: Box<dyn std::any::Any + Send>) -> String {
// Panic payloads are most commonly &str (string literals) or String
// (from panic!("{}", ...) with runtime formatting). We try both.
// If neither applies, we fall back to a generic message.
if let Some(s) = payload.downcast_ref::<&str>() {
return s.to_string();
}
if let Some(s) = payload.downcast_ref::<String>() {
return s.clone();
}
"unknown panic payload (not a string type)".to_string()
}
The fully refactored relay, incorporating all the changes from this installment:
use socket2::{SockRef, TcpKeepalive};
use std::io;
use std::net::{Shutdown, TcpListener, TcpStream};
use std::thread;
use std::time::Duration;
fn configure_relay_socket(session_stream: &TcpStream) -> Result<(), RelayError> {
// Disable Nagle — 200ms coalescing latency breaks interactive sessions.
session_stream.set_nodelay(true)?;
// Keepalive — prevents NAT/firewall from silently expiring idle state.
let socket_ref = SockRef::from(session_stream);
socket_ref.set_tcp_keepalive(
&TcpKeepalive::new()
.with_time(Duration::from_secs(60))
.with_interval(Duration::from_secs(10)),
)?;
Ok(())
}
fn duplex_relay(relay_conn: TcpStream) -> Result<(), RelayError> {
configure_relay_socket(&relay_conn)?;
let mut relay_rx = relay_conn.try_clone()?;
let mut relay_tx = relay_conn;
let inbound_handle = thread::spawn(move || -> io::Result<u64> {
io::copy(&mut relay_rx, &mut io::stdout())
});
let outbound_result = io::copy(&mut io::stdin(), &mut relay_tx);
// Half-close: signal FIN to the peer so the inbound thread's read(2)
// returns rather than blocking until the peer closes independently.
if let Err(e) = relay_tx.shutdown(Shutdown::Write) {
if e.kind() != io::ErrorKind::NotConnected {
return Err(RelayError::Io(e));
}
}
// join() now returns promptly because the peer received our FIN and
// closed its write end. Extract the panic message if the thread panicked.
let inbound_result = inbound_handle
.join()
.map_err(|payload| RelayError::InboundPanic(extract_panic_message(payload)))?;
outbound_result?;
inbound_result?;
Ok(())
}
fn connect_mode(peer_addr: &str) -> Result<(), RelayError> {
eprintln!("[*] Connecting to {}", peer_addr);
let relay_conn = TcpStream::connect(peer_addr)?;
eprintln!("[+] Relay established: {}", peer_addr);
duplex_relay(relay_conn)
}
fn listen_mode(bind_addr: &str) -> Result<(), RelayError> {
let listener = TcpListener::bind(bind_addr)?;
eprintln!("[*] Waiting for session on {}", bind_addr);
let (session_stream, peer_addr) = listener.accept()?;
eprintln!("[+] Session from {}", peer_addr);
duplex_relay(session_stream)
}
The ? operator on io::Result values inside Result<_, RelayError> functions works because of the From<io::Error> for RelayError impl. No explicit .map_err() call is required at each site — the compiler inserts the conversion automatically.
The Security Correlation
A process that panics writes a backtrace to stderr. On a system where stderr is visible to the operator’s terminal — or captured in a log — that backtrace leaks the memory layout of the binary: function names, stack frame addresses, and potentially the address space layout. In a local privilege escalation context where an attacker can trigger a panic in a setuid binary, the backtrace output can provide ASLR offsets. For a relay binary running in a network-accessible context, a panic triggered by malformed input from the peer is a controlled crash — the attacker learns the layout without writing a single byte of shellcode.
Converting panics to controlled RelayError::InboundPanic values means the error is surfaced through our own formatting path, not the runtime’s default panic handler. A binary that degrades gracefully under adversarial input is strictly harder to probe than one that panics with a full backtrace. The error type here is not aesthetic — it is a reduction in the information the binary leaks to an adversary who is deliberately trying to break it.
Looking Ahead
The relay is now a tool rather than a prototype. It exits cleanly when either end closes, configures its socket for interactive use, and surfaces errors through a typed hierarchy rather than swallowing them. What it still cannot do is handle more than one connection at a time. The blocking model we have built — accept() returns one session, the relay runs to completion, the process exits — is structurally incapable of multi-session handling. Each session monopolises the OS thread it runs on, and a second accept() cannot be reached until the first session ends.
Article 3 replaces the blocking model entirely. We port the relay to async Rust with Tokio, tracing exactly what the #[tokio::main] macro expands to, how Tokio’s epoll-based reactor handles readiness rather than blocking, and why tokio::select! replaces the two-thread duplex pattern with a single task — while introducing a class of cancellation-safety bugs that naive ports consistently fall into.
Further Reading & Deep Dives:
- For the formal TCP state machine, the half-close mechanism, and the precise semantics of
shutdown(2)vsclose(2)at the POSIX level, see The Linux Programming Interface (Kerrisk, Chapter 61).- For Nagle’s original analysis and the formal argument for
TCP_NODELAYin interactive applications, see RFC 896 (Nagle, 1984).- For Rust’s error handling model — custom error types, the
Fromtrait, and the?operator’s desugaring — see Programming Rust (Blandy et al., Chapter 7).- For
socket2‘s fd-borrowing model and the full keepalive configuration surface, see the socket2 crate documentation.
Leave a Reply