Skip to main content

netget/server/reverse_shell/
actions.rs

1//! Reverse-shell listener actions.
2//!
3//! This protocol *emulates* the operator side of a reverse shell for authorized security
4//! testing, CTF and lab work: NetGet is the listener an operator connects back to, and the LLM
5//! plays the role of the shell on the far end, deciding what each command "prints". **NetGet
6//! never executes the operator's commands on this host** — the model supplies the pretend
7//! output, which is the whole NetGet premise and also the safe default. See
8//! `src/server/reverse_shell/CLAUDE.md`.
9
10use crate::llm::actions::{
11    protocol_trait::{ActionResult, Protocol, Server},
12    ActionDefinition, Parameter,
13};
14use crate::protocol::log_template::LogTemplate;
15use crate::protocol::EventType;
16use crate::state::app_state::AppState;
17use anyhow::{Context, Result};
18use serde_json::json;
19use std::sync::LazyLock;
20
21/// Reverse-shell listener protocol handler.
22///
23/// Stateless: it only describes actions/events and executes sync actions. The running listener
24/// lives in [`crate::server::reverse_shell::ReverseShellServer`].
25#[derive(Default)]
26pub struct ReverseShellProtocol;
27
28impl ReverseShellProtocol {
29    pub fn new() -> Self {
30        Self
31    }
32}
33
34impl Protocol for ReverseShellProtocol {
35    fn get_startup_parameters(&self) -> Vec<crate::llm::actions::ParameterDefinition> {
36        // No startup parameters: the banner, the prompt and every command's output are decided
37        // by the model per event, so declaring anything here would be a dead parameter the
38        // model would try to use.
39        Vec::new()
40    }
41
42    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
43        // No user-triggered actions: the server keeps no addressable connection registry, so an
44        // async action would have nothing to talk to. Declaring one would be a promise the code
45        // does not keep.
46        Vec::new()
47    }
48
49    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
50        vec![
51            send_shell_output_action(),
52            send_shell_prompt_action(),
53            end_shell_session_action(),
54            no_shell_output_action(),
55        ]
56    }
57
58    fn protocol_name(&self) -> &'static str {
59        "Reverse Shell"
60    }
61
62    fn get_event_types(&self) -> Vec<EventType> {
63        get_reverse_shell_event_types()
64    }
65
66    fn stack_name(&self) -> &'static str {
67        "ETH>IP>TCP>REVSHELL"
68    }
69
70    fn keywords(&self) -> Vec<&'static str> {
71        // Deliberately multi-word / distinctive: never a bare "shell" or "remote", which would
72        // collide with SSH and the Bluetooth "remote" profile.
73        vec![
74            "reverse_shell",
75            "reverse shell",
76            "revshell",
77            "reverse-shell listener",
78        ]
79    }
80
81    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
82        use crate::protocol::metadata::{
83            DevelopmentState, PrivilegeRequirement, ProtocolMetadataV2,
84        };
85
86        ProtocolMetadataV2::builder()
87            .state(DevelopmentState::Experimental)
88            .privilege_requirement(PrivilegeRequirement::None)
89            .implementation(
90                "Raw TCP listener; line-buffered operator input; the model role-plays the shell",
91            )
92            .llm_control(
93                "Shell banner, per-command output, the prompt, and when to end the session",
94            )
95            .e2e_testing("Raw TCP client (nc/socat equivalent) asserting model-supplied output")
96            .notes(
97                "Emulation only for authorized red-team/CTF/lab use: NetGet NEVER runs the \
98                 operator's commands on this host, the model supplies fictional output. Real \
99                 command execution is available only through the separate, opt-in, unsandboxed \
100                 scripting layer (see CLAUDE.md). Verified with a raw TCP client; no framing, so \
101                 nc/socat connect directly.",
102            )
103            .max_inbound_bytes(crate::server::reverse_shell::MAX_LINE_LEN)
104            .build()
105    }
106
107    fn description(&self) -> &'static str {
108        "Reverse-shell listener emulation (red-team/CTF): the model role-plays the shell"
109    }
110
111    fn example_prompt(&self) -> &'static str {
112        "Act as a reverse-shell listener on port 4444 emulating a compromised Ubuntu box for a CTF"
113    }
114
115    fn group_name(&self) -> &'static str {
116        "Network Services"
117    }
118
119    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
120        use crate::llm::actions::StartupExamples;
121
122        // Deterministic: emulate a minimal shell — answer every command with a
123        // canned line and a fresh prompt, no LLM call.
124        let script = r#"import json, sys
125data = json.load(sys.stdin)
126event = data["event"]
127if data["event_type_id"] == "reverse_shell_command":
128    actions = [{"type": "send_shell_output", "output": "command not found\n",
129                "append_prompt": True, "prompt": "$ "}]
130else:
131    actions = []
132print(json.dumps({"actions": actions}))"#;
133
134        StartupExamples::new(
135            // LLM mode
136            json!({
137                "type": "open_server",
138                "port": 4444,
139                "base_stack": "reverse-shell",
140                "instruction": "Emulate a shell on a compromised Ubuntu host for a CTF lab"
141            }),
142            // Script mode
143            json!({
144                "type": "open_server",
145                "port": 4444,
146                "base_stack": "reverse-shell",
147                "event_handlers": [{
148                    "event_pattern": "reverse_shell_command",
149                    "handler": {
150                        "type": "script",
151                        "language": "python",
152                        "code": script
153                    }
154                }]
155            }),
156            // Static mode
157            json!({
158                "type": "open_server",
159                "port": 4444,
160                "base_stack": "reverse-shell",
161                "event_handlers": [
162                    {
163                        "event_pattern": "reverse_shell_session_opened",
164                        "handler": {
165                            "type": "static",
166                            "actions": [{
167                                "type": "send_shell_prompt",
168                                "prompt": "www-data@web01:/var/www$ "
169                            }]
170                        }
171                    },
172                    {
173                        "event_pattern": "reverse_shell_command",
174                        "handler": {
175                            "type": "static",
176                            "actions": [{
177                                "type": "send_shell_output",
178                                "output": "bash: command not found\n",
179                                "append_prompt": true,
180                                "prompt": "www-data@web01:/var/www$ "
181                            }]
182                        }
183                    }
184                ]
185            }),
186        )
187    }
188}
189
190impl Server for ReverseShellProtocol {
191    fn spawn(
192        &self,
193        ctx: crate::protocol::SpawnContext,
194    ) -> std::pin::Pin<
195        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
196    > {
197        Box::pin(async move {
198            // Validate that no undeclared startup parameter was supplied.
199            if let Some(params) = ctx.startup_params.as_ref() {
200                // No parameters are declared; construction already rejected unknown keys, but
201                // touch it so an accidental future param is not silently ignored.
202                let _ = params.allowed_parameters();
203            }
204
205            use crate::server::reverse_shell::ReverseShellServer;
206            let listen_addr = ctx.legacy_listen_addr();
207            ReverseShellServer::spawn_with_llm_actions(
208                listen_addr,
209                ctx.llm_client,
210                ctx.state,
211                ctx.status_tx,
212                ctx.server_id,
213            )
214            .await
215        })
216    }
217
218    fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
219        let action_type = action
220            .get("type")
221            .and_then(|v| v.as_str())
222            .context("Missing 'type' field in action")?;
223
224        match action_type {
225            "send_shell_output" => {
226                let output = action
227                    .get("output")
228                    .and_then(|v| v.as_str())
229                    .context("Missing 'output' parameter")?;
230
231                let mut bytes = output.as_bytes().to_vec();
232
233                // Optional trailing prompt, so one action can print output and re-prompt.
234                let append_prompt = action
235                    .get("append_prompt")
236                    .and_then(|v| v.as_bool())
237                    .unwrap_or(false);
238                if append_prompt {
239                    let prompt = action
240                        .get("prompt")
241                        .and_then(|v| v.as_str())
242                        .unwrap_or(DEFAULT_PROMPT);
243                    bytes.extend_from_slice(prompt.as_bytes());
244                }
245                Ok(ActionResult::Output(bytes))
246            }
247            "send_shell_prompt" => {
248                let prompt = action
249                    .get("prompt")
250                    .and_then(|v| v.as_str())
251                    .unwrap_or(DEFAULT_PROMPT);
252                Ok(ActionResult::Output(prompt.as_bytes().to_vec()))
253            }
254            "end_shell_session" => Ok(ActionResult::CloseConnection),
255            // Injected by the dashboard's [ disconnect this peer ] row (not an LLM verb).
256            // Without this arm the generic peer task would answer "Unknown action" instead of
257            // half-closing the connection. Same effect as end_shell_session: FIN, then teardown.
258            "close_connection" => Ok(ActionResult::CloseConnection),
259            "no_shell_output" => Ok(ActionResult::WaitForMore),
260            other => Err(anyhow::anyhow!("Unknown reverse-shell action: {other}")),
261        }
262    }
263}
264
265/// Prompt used when the model asks for a prompt without supplying its own text.
266pub const DEFAULT_PROMPT: &str = "$ ";
267
268// ============================================================================
269// Action definitions
270// ============================================================================
271
272fn send_shell_output_action() -> ActionDefinition {
273    ActionDefinition {
274        name: "send_shell_output".to_string(),
275        description: "Send the text a command would print back to the operator's terminal. This \
276            is fictional output you decide as the role-played shell — NetGet does not run the \
277            command. Include a trailing newline. Set append_prompt=true to re-print the shell \
278            prompt after the output so the operator sees a fresh prompt line."
279            .to_string(),
280        parameters: vec![
281            Parameter {
282                name: "output".to_string(),
283                type_hint: "string".to_string(),
284                description: "The command's fictional stdout/stderr as plain text, e.g. a \
285                    directory listing or 'bash: foo: command not found'. Sent verbatim as UTF-8; \
286                    include \\n line endings yourself."
287                    .to_string(),
288                required: true,
289            },
290            Parameter {
291                name: "append_prompt".to_string(),
292                type_hint: "boolean".to_string(),
293                description:
294                    "When true, the shell prompt is written after the output. Default false."
295                        .to_string(),
296                required: false,
297            },
298            Parameter {
299                name: "prompt".to_string(),
300                type_hint: "string".to_string(),
301                description:
302                    "Prompt text to append when append_prompt is true. Defaults to \"$ \"."
303                        .to_string(),
304                required: false,
305            },
306        ],
307        example: json!({
308            "type": "send_shell_output",
309            "output": "uid=33(www-data) gid=33(www-data) groups=33(www-data)\n",
310            "append_prompt": true,
311            "prompt": "www-data@web01:/var/www$ "
312        }),
313        log_template: Some(
314            LogTemplate::new()
315                .with_info("-> shell {output_bytes}B")
316                .with_debug("reverse-shell output {output_bytes}B")
317                .with_trace("reverse-shell output: {preview(output,200)}"),
318        ),
319    }
320}
321
322fn send_shell_prompt_action() -> ActionDefinition {
323    ActionDefinition {
324        name: "send_shell_prompt".to_string(),
325        description: "Write just the shell prompt (no command output). Use it on \
326            reverse_shell_session_opened to greet the operator with an initial prompt."
327            .to_string(),
328        parameters: vec![Parameter {
329            name: "prompt".to_string(),
330            type_hint: "string".to_string(),
331            description: "Prompt text, e.g. \"www-data@web01:/var/www$ \". Defaults to \"$ \"."
332                .to_string(),
333            required: false,
334        }],
335        example: json!({
336            "type": "send_shell_prompt",
337            "prompt": "www-data@web01:/var/www$ "
338        }),
339        log_template: Some(
340            LogTemplate::new()
341                .with_info("-> shell prompt")
342                .with_debug("reverse-shell prompt {output_bytes}B"),
343        ),
344    }
345}
346
347fn end_shell_session_action() -> ActionDefinition {
348    ActionDefinition {
349        name: "end_shell_session".to_string(),
350        description: "Close the connection to the operator (the emulated session exited, or the \
351            operator ran 'exit'/Ctrl-D). Emit any farewell with send_shell_output first, in the \
352            same response."
353            .to_string(),
354        parameters: vec![],
355        example: json!({ "type": "end_shell_session" }),
356        log_template: Some(
357            LogTemplate::new()
358                .with_info("reverse-shell session ended")
359                .with_debug("reverse-shell end_shell_session"),
360        ),
361    }
362}
363
364fn no_shell_output_action() -> ActionDefinition {
365    ActionDefinition {
366        name: "no_shell_output".to_string(),
367        description: "Produce no output for this command (as a real command that prints nothing \
368            would). The session stays open for the next command."
369            .to_string(),
370        parameters: vec![],
371        example: json!({ "type": "no_shell_output" }),
372        log_template: Some(LogTemplate::new().with_debug("reverse-shell no output")),
373    }
374}
375
376// ============================================================================
377// Action constants
378// ============================================================================
379
380pub static SEND_SHELL_OUTPUT_ACTION: LazyLock<ActionDefinition> =
381    LazyLock::new(send_shell_output_action);
382pub static SEND_SHELL_PROMPT_ACTION: LazyLock<ActionDefinition> =
383    LazyLock::new(send_shell_prompt_action);
384pub static END_SHELL_SESSION_ACTION: LazyLock<ActionDefinition> =
385    LazyLock::new(end_shell_session_action);
386pub static NO_SHELL_OUTPUT_ACTION: LazyLock<ActionDefinition> =
387    LazyLock::new(no_shell_output_action);
388
389// ============================================================================
390// Event types — both are emitted by mod.rs, and both declare actions.
391// ============================================================================
392
393/// Raised once when an operator connects to the listener.
394pub static REVERSE_SHELL_SESSION_OPENED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
395    EventType::new(
396        "reverse_shell_session_opened",
397        "An operator connected to the reverse-shell listener (like a red-teamer's `nc` catching \
398         a callback). Greet them with an initial shell prompt, or stay silent and wait for their \
399         first command.",
400        json!({
401            "type": "send_shell_prompt",
402            "prompt": "www-data@web01:/var/www$ "
403        }),
404    )
405    // NO_SHELL_OUTPUT belongs here because the description above offers "stay silent and wait
406    // for their first command" as a choice, and without this action there was no way to say it:
407    // `call_llm` builds the tool list from the event, so a model taking the description at its
408    // word answered with zero actions — which `consult` reads as FailClosed::NoUsableAnswer and
409    // `apply` turns into a failure notice and a FIN. Following the documentation dropped the
410    // session. The alternative example below named the same verb the model had not been given.
411    .with_actions(vec![
412        SEND_SHELL_PROMPT_ACTION.clone(),
413        SEND_SHELL_OUTPUT_ACTION.clone(),
414        NO_SHELL_OUTPUT_ACTION.clone(),
415        END_SHELL_SESSION_ACTION.clone(),
416    ])
417    .with_alternative_example(json!({ "type": "no_shell_output" }))
418    .with_log_template(
419        LogTemplate::new()
420            .with_info("reverse-shell operator connected from {client_ip}:{client_port}")
421            .with_debug("reverse-shell session opened from {client_ip}:{client_port}"),
422    )
423});
424
425/// Raised for each newline-terminated command line the operator sends.
426pub static REVERSE_SHELL_COMMAND_EVENT: LazyLock<EventType> = LazyLock::new(|| {
427    EventType::new(
428        "reverse_shell_command",
429        "The operator typed a command line (terminated by Enter). Answer with send_shell_output \
430         containing the fictional output that command would print on the emulated host. This is \
431         role-play: NetGet does not execute anything.",
432        json!({
433            "type": "send_shell_output",
434            "output": "total 8\ndrwxr-xr-x 2 www-data www-data 4096 config.php\n",
435            "append_prompt": true,
436            "prompt": "www-data@web01:/var/www$ "
437        }),
438    )
439    .with_parameters(vec![
440        Parameter {
441            name: "command".to_string(),
442            type_hint: "string".to_string(),
443            description: "The command line the operator typed, without the trailing newline."
444                .to_string(),
445            required: true,
446        },
447        Parameter {
448            name: "first_command".to_string(),
449            type_hint: "boolean".to_string(),
450            description: "True for the first command of this session.".to_string(),
451            required: true,
452        },
453        Parameter {
454            name: "empty".to_string(),
455            type_hint: "boolean".to_string(),
456            description: "True when the operator pressed Enter on an empty line. Usually answer \
457                with no_shell_output or just a prompt."
458                .to_string(),
459            required: true,
460        },
461    ])
462    .with_actions(vec![
463        SEND_SHELL_OUTPUT_ACTION.clone(),
464        SEND_SHELL_PROMPT_ACTION.clone(),
465        NO_SHELL_OUTPUT_ACTION.clone(),
466        END_SHELL_SESSION_ACTION.clone(),
467    ])
468    .with_alternative_example(json!({ "type": "end_shell_session" }))
469    .with_log_template(
470        LogTemplate::new()
471            .with_info("reverse-shell cmd: {command}")
472            .with_debug("reverse-shell command '{command}' from {client_ip}:{client_port}")
473            .with_trace("reverse-shell command: {json_pretty(.)}"),
474    )
475});
476
477/// All event types this protocol can emit.
478pub fn get_reverse_shell_event_types() -> Vec<EventType> {
479    vec![
480        REVERSE_SHELL_SESSION_OPENED_EVENT.clone(),
481        REVERSE_SHELL_COMMAND_EVENT.clone(),
482    ]
483}