Skip to main content

netget/server/reverse_shell/
mod.rs

1//! Reverse-shell listener server.
2//!
3//! A raw-TCP listener that **emulates** the operator side of a reverse shell for authorized
4//! security testing, CTF and lab use. An operator connects back to this listener with a plain
5//! TCP client (`nc`, `socat`, `ncat`), and the LLM role-plays the shell on the far end: it
6//! decides the banner, each command's output and the prompt.
7//!
8//! Safety: NetGet does **not** execute the operator's commands on this host. Every byte the
9//! operator sees is fictional output supplied by the model — the same premise as every other
10//! NetGet protocol (impersonating a service without being one). Real command execution is only
11//! reachable through the separate, opt-in, unsandboxed scripting layer documented in the
12//! top-level `CLAUDE.md`; this protocol never touches it. See `src/server/reverse_shell/CLAUDE.md`.
13//!
14//! Wire model: there is no framing. The listener reads raw bytes, buffers them into
15//! newline-terminated lines, and raises one `reverse_shell_command` event per line. One
16//! connection is handled strictly sequentially — the model call for one line finishes before the
17//! next line is read — so there is never a concurrent LLM call on a single connection, and input
18//! that arrives mid-call sits in the socket buffer until the call returns.
19
20pub mod actions;
21
22use crate::llm::action_helper::call_llm;
23use crate::llm::actions::protocol_trait::ActionResult;
24use crate::llm::ollama_client::OllamaClient;
25use crate::protocol::Event;
26use crate::server::connection::ConnectionId;
27use crate::state::app_state::AppState;
28use crate::state::server::{ConnectionState, ConnectionStatus, ProtocolConnectionInfo};
29use crate::{console_debug, console_info, console_warn};
30use actions::{
31    ReverseShellProtocol, REVERSE_SHELL_COMMAND_EVENT, REVERSE_SHELL_SESSION_OPENED_EVENT,
32};
33use anyhow::Result;
34use std::net::SocketAddr;
35use std::sync::Arc;
36use std::time::Duration;
37use tokio::io::{AsyncReadExt, AsyncWriteExt};
38use tokio::net::TcpStream;
39use tokio::sync::{mpsc, Mutex};
40use tracing::{debug, error, info, warn};
41
42/// Largest single line accepted from the operator, in bytes.
43///
44/// The line accumulator grows until a newline arrives; without a cap a peer that never sends one
45/// could grow it without bound. A command line far longer than this is not a real shell command.
46pub const MAX_LINE_LEN: usize = 64 * 1024;
47
48/// Reverse-shell listener.
49/// How long to wait for the first command from an operator who has only connected.
50///
51/// Two minutes rather than a machine protocol's thirty seconds, because the peer here is a
52/// person at `nc`: the session opens with a model-written banner and prompt, and they read it
53/// before typing. The banner sits outside this deadline by construction — it is generated and
54/// written before the read loop starts — so this bound measures only the time from a prompt
55/// being on their screen to the first key.
56const FIRST_COMMAND_READ_TIMEOUT: Duration = Duration::from_secs(120);
57
58/// How long to wait for a *further* command once one has been answered.
59///
60/// Fifteen minutes. A shell's own idle convention is `TMOUT`, which hardening baselines set to
61/// 900 seconds, so this is the number an operator already expects a shell session to be
62/// reclaimed at — and long enough that reading a long listing, or stepping away mid-engagement,
63/// does not cost the session.
64///
65/// The LLM round-trip and a `manual` rule parking a command for a human
66/// (`src/state/intercepts.rs`, 300s by default) both happen after a line has already been read,
67/// so neither can be timed out from under itself.
68const IDLE_BETWEEN_COMMANDS_TIMEOUT: Duration = Duration::from_secs(900);
69
70/// Concurrent connections this listener admits.
71///
72/// [`crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS`]. A reverse-shell listener in real
73/// use holds one or two sessions, so the generous shared default is far above anything
74/// legitimate while still bounding a peer that simply opens sockets.
75const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
76
77/// What a peer over [`MAX_CONNECTIONS`] is told before the socket closes.
78///
79/// There is no framing to refuse in — this is a raw byte stream — but there *is* a terminal on
80/// the other end, so a plain line is read by the one audience that exists. It is deliberately
81/// netget's own voice rather than a shell-looking error: the emulated shell never got as far as
82/// existing, and a fabricated shell message would be a positive assertion about a session that
83/// was declined. Fixed text, so there is no placeholder an internal error could reach.
84const CONNECTION_CAP_REFUSAL: &[u8] = b"\r\n[netget] too many connections\r\n";
85
86pub struct ReverseShellServer;
87
88impl ReverseShellServer {
89    /// Bind the listener and spawn the accept loop.
90    ///
91    /// Awaits the bind so a failure (address in use, permission) is returned as `Err` and the
92    /// server is marked `Error` rather than lying about being `Running`. The accept-loop
93    /// `JoinHandle` is registered so `stop_server` releases the socket.
94    pub async fn spawn_with_llm_actions(
95        listen_addr: SocketAddr,
96        llm_client: OllamaClient,
97        app_state: Arc<AppState>,
98        status_tx: mpsc::UnboundedSender<String>,
99        server_id: crate::state::ServerId,
100    ) -> Result<SocketAddr> {
101        let listener =
102            crate::server::socket_helpers::create_reusable_tcp_listener(listen_addr).await?;
103        let local_addr = listener.local_addr()?;
104
105        // Keep the address last on the line: the E2E harness parses the port from after "on ".
106        console_info!(
107            status_tx,
108            "Reverse-shell listener (emulation) listening on {}",
109            local_addr
110        );
111
112        let task_registrar = app_state.clone();
113        let limiter = crate::server::accept_bounded::ConnectionLimiter::new(MAX_CONNECTIONS);
114        let accept_handle = tokio::spawn(async move {
115            loop {
116                match crate::server::accept_bounded::accept_bounded(
117                    &listener,
118                    &limiter,
119                    CONNECTION_CAP_REFUSAL,
120                    "Reverse-shell",
121                    Some(&status_tx),
122                )
123                .await
124                {
125                    Ok((stream, remote_addr, permit)) => {
126                        let connection_id =
127                            ConnectionId::new(app_state.get_next_unified_id().await);
128                        let local_addr_conn = stream.local_addr().unwrap_or(local_addr);
129
130                        info!("Reverse-shell operator connected from {}", remote_addr);
131                        let _ = status_tx.send(format!(
132                            "[INFO] Reverse-shell operator connected from {}",
133                            remote_addr
134                        ));
135
136                        let llm_clone = llm_client.clone();
137                        let state_clone = app_state.clone();
138                        let status_clone = status_tx.clone();
139                        // Tracked, not detached: stop_server must abort this task too.
140                        let task_owner = app_state.clone();
141                        task_owner
142                            .spawn_server_task(server_id, async move {
143                                // Held for the life of the connection: releasing it here would
144                                // cap the accept rate rather than the number of live
145                                // connections.
146                                let _permit = permit;
147                                if let Err(e) = Self::handle_connection(
148                                    stream,
149                                    connection_id,
150                                    remote_addr,
151                                    local_addr_conn,
152                                    server_id,
153                                    state_clone,
154                                    status_clone,
155                                    llm_clone,
156                                )
157                                .await
158                                {
159                                    error!("Reverse-shell connection error: {}", e);
160                                }
161                            })
162                            .await;
163                    }
164                    Err(e) => {
165                        // Break rather than spin: a persistent accept error (EMFILE, socket
166                        // closed under us) would otherwise loop at full CPU.
167                        error!("Reverse-shell accept failed: {}", e);
168                        let _ = status_tx
169                            .send(format!("✗ Reverse-shell accept failed, stopping: {}", e));
170                        break;
171                    }
172                }
173            }
174        });
175
176        task_registrar
177            .register_server_task(server_id, accept_handle)
178            .await;
179
180        Ok(local_addr)
181    }
182
183    /// Handle one operator connection: session-open event, then a line loop.
184    #[allow(clippy::too_many_arguments)]
185    async fn handle_connection(
186        stream: TcpStream,
187        connection_id: ConnectionId,
188        remote_addr: SocketAddr,
189        local_addr: SocketAddr,
190        server_id: crate::state::ServerId,
191        app_state: Arc<AppState>,
192        status_tx: mpsc::UnboundedSender<String>,
193        llm_client: OllamaClient,
194    ) -> Result<()> {
195        let (mut read_half, write_half) = tokio::io::split(stream);
196        let write_half = Arc::new(Mutex::new(write_half));
197
198        let now = crate::utils::clock::Instant::now();
199        let conn_state = ConnectionState {
200            id: connection_id,
201            remote_addr,
202            local_addr,
203            bytes_sent: 0,
204            bytes_received: 0,
205            packets_sent: 0,
206            packets_received: 0,
207            last_activity: now,
208            status: ConnectionStatus::Active,
209            status_changed_at: now,
210            protocol_info: ProtocolConnectionInfo::empty(),
211        };
212        app_state
213            .add_connection_to_server(server_id, conn_state)
214            .await;
215        let _ = status_tx.send("__UPDATE_UI__".to_string());
216
217        // Register a peer handle so the dashboard can [ message this peer ] /
218        // [ disconnect this peer ] this live connection. The command task shares the same
219        // Arc<Mutex<WriteHalf>> the reader writes through, so injected output is serialized
220        // against the model's. Every wire verb (send_shell_output/prompt, end_shell_session)
221        // returns Output/CloseConnection, so the generic peer task covers the whole vocabulary.
222        let protocol: Arc<ReverseShellProtocol> = Arc::new(ReverseShellProtocol::new());
223        let peer_rx = crate::server::peer_support::register_peer_channel(
224            &app_state,
225            server_id,
226            connection_id.as_u32(),
227        )
228        .await;
229        crate::server::peer_support::spawn_peer_command_task(
230            peer_rx,
231            protocol.clone(),
232            app_state.clone(),
233            server_id,
234            connection_id.as_u32(),
235            write_half.clone(),
236            status_tx.clone(),
237        );
238
239        let mut conn = ShellConnection {
240            connection_id,
241            server_id,
242            app_state: app_state.clone(),
243            llm_client,
244            protocol: ReverseShellProtocol::new(),
245            status_tx: status_tx.clone(),
246            write_half,
247        };
248
249        let result = conn.run(&mut read_half).await;
250
251        // Remove the peer handle on every exit path (EOF, read error, oversize line,
252        // end_shell_session, no-answer fail-closed) — this single cleanup runs after run()
253        // returns however it returned, so the rail never keeps offering a dead peer.
254        app_state
255            .remove_peer_handle(server_id, connection_id.as_u32())
256            .await;
257        app_state
258            .remove_connection_from_server(server_id, connection_id)
259            .await;
260        let _ = status_tx.send("__UPDATE_UI__".to_string());
261
262        result
263    }
264}
265
266/// Per-connection state and behaviour for one operator session.
267struct ShellConnection {
268    connection_id: ConnectionId,
269    server_id: crate::state::ServerId,
270    app_state: Arc<AppState>,
271    llm_client: OllamaClient,
272    protocol: ReverseShellProtocol,
273    status_tx: mpsc::UnboundedSender<String>,
274    write_half: Arc<Mutex<tokio::io::WriteHalf<TcpStream>>>,
275}
276
277/// Why a session is being failed closed, kept distinct from a deliberate model decision.
278///
279/// The two variants exist so the log can tell an operator which happened. Collapsing them —
280/// which this protocol used to do behind a single `no_answer` flag — makes a backend outage
281/// indistinguishable from a model that answered with an unusable action list, and those want
282/// completely different responses from whoever is watching.
283#[derive(Clone, Copy, Debug)]
284enum FailClosed {
285    /// The LLM call itself returned `Err`. Carries only the *category* of the failure — the
286    /// error text is logged, never carried towards the wire (`crate::utils::wire_failure`).
287    LlmError(crate::utils::WireFailure),
288    /// The call succeeded but produced nothing usable: an empty action list, or a batch where
289    /// every action failed. The backend is up; the answer was not usable.
290    NoUsableAnswer,
291}
292
293impl FailClosed {
294    /// The peer-visible category. `&'static str` by construction, so nothing derived from an
295    /// error can reach the socket.
296    fn wire_text(self) -> &'static str {
297        match self {
298            Self::LlmError(failure) => failure.text(),
299            // The backend answered; it is this request that could not be served.
300            Self::NoUsableAnswer => crate::utils::WireFailure::Unavailable.text(),
301        }
302    }
303
304    /// The `decision=` tag written to the log and the status stream. Three cases stay
305    /// distinguishable there: `model_end_session` (a deliberate close), `fail_closed_no_answer`
306    /// (the model said nothing usable) and `fail_closed_llm_error` (the call errored), the last
307    /// split further by overload so a capacity problem is not read as a permanent fault.
308    fn decision_tag(self) -> &'static str {
309        match self {
310            Self::LlmError(crate::utils::WireFailure::Overloaded) => {
311                "fail_closed_llm_error_overloaded"
312            }
313            Self::LlmError(crate::utils::WireFailure::Unavailable) => {
314                "fail_closed_llm_error_unavailable"
315            }
316            Self::NoUsableAnswer => "fail_closed_no_answer",
317        }
318    }
319}
320
321/// What the model decided in answer to one event.
322#[derive(Default)]
323struct Outcome {
324    /// Bytes to write to the operator, in order.
325    output: Vec<u8>,
326    /// The session should close after writing `output`.
327    close: bool,
328    /// Set when nothing usable came back, with the reason kept separate from a model decision.
329    fail_closed: Option<FailClosed>,
330}
331
332impl ShellConnection {
333    /// The session: greet on open, then one event per operator command line.
334    async fn run(&mut self, read_half: &mut tokio::io::ReadHalf<TcpStream>) -> Result<()> {
335        // Session-opened event.
336        let open_event = Event::new(&REVERSE_SHELL_SESSION_OPENED_EVENT, serde_json::json!({}));
337        let outcome = self.consult(&open_event).await;
338        if self.apply(outcome).await? {
339            return Ok(());
340        }
341
342        let mut buffer = vec![0u8; 8192];
343        let mut line: Vec<u8> = Vec::new();
344        let mut first_command = true;
345
346        loop {
347            // The deadline wraps this read and nothing else. Everything that can legitimately
348            // take minutes — the LLM round-trip, and a `manual` rule parking the command for a
349            // human to answer — happens below, after bytes have already been read.
350            let read_timeout = if first_command {
351                FIRST_COMMAND_READ_TIMEOUT
352            } else {
353                IDLE_BETWEEN_COMMANDS_TIMEOUT
354            };
355            let read = match tokio::time::timeout(read_timeout, read_half.read(&mut buffer)).await {
356                Ok(read) => read,
357                Err(_) => {
358                    info!(
359                        "Reverse-shell operator {} sent nothing for {}s; closing idle session",
360                        self.connection_id,
361                        read_timeout.as_secs()
362                    );
363                    break;
364                }
365            };
366            let n = match read {
367                Ok(0) => {
368                    debug!("Reverse-shell operator {} disconnected", self.connection_id);
369                    break;
370                }
371                Ok(n) => {
372                    // Refresh byte/packet counters and last_activity so the rail's ↓ moves and
373                    // connection-scoped task prompts see a fresh timestamp.
374                    self.app_state
375                        .update_connection_stats(
376                            self.server_id,
377                            self.connection_id,
378                            Some(n as u64),
379                            None,
380                            Some(1),
381                            None,
382                        )
383                        .await;
384                    n
385                }
386                Err(e) => {
387                    error!("Reverse-shell read error on {}: {}", self.connection_id, e);
388                    break;
389                }
390            };
391
392            for &byte in &buffer[..n] {
393                if byte == b'\n' {
394                    // Strip a trailing CR so CRLF and LF clients look identical to the model.
395                    if line.last() == Some(&b'\r') {
396                        line.pop();
397                    }
398                    let command = String::from_utf8_lossy(&line).to_string();
399                    let empty = command.trim().is_empty();
400                    line.clear();
401
402                    console_debug!(
403                        self.status_tx,
404                        "Reverse-shell command from {}: {:?}",
405                        self.connection_id,
406                        command
407                    );
408
409                    let event = Event::new(
410                        &REVERSE_SHELL_COMMAND_EVENT,
411                        serde_json::json!({
412                            "command": command,
413                            "first_command": first_command,
414                            "empty": empty,
415                        }),
416                    );
417                    first_command = false;
418
419                    let outcome = self.consult(&event).await;
420                    if self.apply(outcome).await? {
421                        return Ok(());
422                    }
423                } else {
424                    line.push(byte);
425                    if line.len() > MAX_LINE_LEN {
426                        console_warn!(
427                            self.status_tx,
428                            "Reverse-shell line from {} exceeded {} bytes, closing",
429                            self.connection_id,
430                            MAX_LINE_LEN
431                        );
432                        self.shutdown().await;
433                        return Ok(());
434                    }
435                }
436            }
437        }
438
439        Ok(())
440    }
441
442    /// Ask the event handlers (script → static → LLM) what to do.
443    ///
444    /// Never returns an error: a failure has to fail *closed* on the caller side, so the reason
445    /// is carried back in `Outcome::no_answer` and the caller closes the socket. Silence is never
446    /// treated as approval — there is nothing to approve here, but the same discipline keeps the
447    /// "no answer" path distinct from a deliberate "no output" (`no_shell_output`).
448    async fn consult(&self, event: &Event) -> Outcome {
449        let mut outcome = Outcome::default();
450
451        let execution = match call_llm(
452            &self.llm_client,
453            &self.app_state,
454            self.server_id,
455            Some(self.connection_id),
456            event,
457            &self.protocol,
458        )
459        .await
460        {
461            Ok(execution) => execution,
462            Err(e) => {
463                // The full error goes to the log and the status stream, where an operator
464                // looks. Only its category is allowed anywhere near the socket.
465                let failure = crate::utils::WireFailure::classify(&e);
466                error!(
467                    "Reverse-shell event {} decision={} error={}",
468                    event.event_type.id,
469                    FailClosed::LlmError(failure).decision_tag(),
470                    e
471                );
472                let _ = self.status_tx.send(format!(
473                    "[ERROR] Reverse-shell {} decision={}: {}",
474                    event.event_type.id,
475                    FailClosed::LlmError(failure).decision_tag(),
476                    e
477                ));
478                outcome.fail_closed = Some(FailClosed::LlmError(failure));
479                return outcome;
480            }
481        };
482
483        for message in execution.messages {
484            let _ = self.status_tx.send(message);
485        }
486
487        let mut saw_result = false;
488        for result in execution.protocol_results {
489            match result {
490                ActionResult::Output(data) => {
491                    saw_result = true;
492                    outcome.output.extend_from_slice(&data);
493                }
494                ActionResult::CloseConnection => {
495                    saw_result = true;
496                    outcome.close = true;
497                }
498                // no_shell_output: a deliberate decision to print nothing. Structurally distinct
499                // from a missing answer — the session stays open and no placeholder is invented.
500                ActionResult::WaitForMore | ActionResult::NoAction => {
501                    saw_result = true;
502                }
503                other => {
504                    warn!(
505                        "Reverse-shell ignoring unexpected action result: {:?}",
506                        other
507                    );
508                }
509            }
510        }
511
512        if !saw_result {
513            warn!(
514                "Reverse-shell event {} decision={} (model returned no usable action)",
515                event.event_type.id,
516                FailClosed::NoUsableAnswer.decision_tag()
517            );
518            outcome.fail_closed = Some(FailClosed::NoUsableAnswer);
519        }
520
521        outcome
522    }
523
524    /// Write the decided output, then close if asked or if no usable answer came back.
525    ///
526    /// Returns true when the connection has been closed and the caller must stop.
527    ///
528    /// Fail-closed: a missing answer (LLM error, empty/failed action list) shuts the socket down
529    /// with a FIN rather than falling through to any permissive default — there is no fabricated
530    /// output and no fake prompt, so an LLM outage is visible to the operator as a dropped
531    /// session, not as a silently-working shell.
532    async fn apply(&mut self, outcome: Outcome) -> Result<bool> {
533        if !outcome.output.is_empty() {
534            let mut writer = self.write_half.lock().await;
535            writer.write_all(&outcome.output).await?;
536            writer.flush().await?;
537            drop(writer);
538            self.app_state
539                .update_connection_stats(
540                    self.server_id,
541                    self.connection_id,
542                    None,
543                    Some(outcome.output.len() as u64),
544                    None,
545                    Some(1),
546                )
547                .await;
548            console_debug!(
549                self.status_tx,
550                "Reverse-shell sent {} bytes to {}",
551                outcome.output.len(),
552                self.connection_id
553            );
554        }
555
556        if let Some(reason) = outcome.fail_closed {
557            let _ = self.status_tx.send(format!(
558                "✗ Reverse-shell closing {} decision={}",
559                self.connection_id,
560                reason.decision_tag()
561            ));
562            self.write_failure_notice(reason).await;
563            self.shutdown().await;
564            return Ok(true);
565        }
566
567        if outcome.close {
568            let _ = self.status_tx.send(format!(
569                "✗ Reverse-shell session {} decision=model_end_session",
570                self.connection_id
571            ));
572            self.shutdown().await;
573            return Ok(true);
574        }
575
576        Ok(false)
577    }
578
579    /// Tell the operator, on its own line, that the server could not answer — then the caller
580    /// half-closes.
581    ///
582    /// A bare FIN is honest but ambiguous: on a reverse-shell transcript it looks exactly like
583    /// the implant on the far end dying, which sends the operator hunting the wrong problem.
584    /// One line naming the *category* removes that ambiguity while inventing no shell output.
585    ///
586    /// The text is `WireFailure`'s `&'static str` and nothing else. The error itself has
587    /// already been logged; interpolating it here is the defect this whole path exists to
588    /// avoid (see `crate::utils::wire_failure`). The two categories carry different words —
589    /// "retry later" for a saturated backend, "could not be processed" otherwise — so an
590    /// operator (or a script watching the transcript) can tell a transient outage from a
591    /// permanent one even though a raw shell stream has no status code.
592    ///
593    /// CRLF and a leading newline so the notice cannot be mistaken for the output of whatever
594    /// the operator last typed, and so a cooked-mode client does not stair-step it.
595    async fn write_failure_notice(&self, reason: FailClosed) {
596        let notice = format!("\r\n[netget] {}\r\n", reason.wire_text());
597        let mut writer = self.write_half.lock().await;
598        if writer.write_all(notice.as_bytes()).await.is_ok() {
599            let _ = writer.flush().await;
600            drop(writer);
601            self.app_state
602                .update_connection_stats(
603                    self.server_id,
604                    self.connection_id,
605                    None,
606                    Some(notice.len() as u64),
607                    None,
608                    Some(1),
609                )
610                .await;
611        }
612    }
613
614    /// Half-close the write direction so the operator's next read returns EOF.
615    async fn shutdown(&self) {
616        let mut writer = self.write_half.lock().await;
617        let _ = writer.shutdown().await;
618    }
619}