netget/server/reverse_shell/
actions.rs1use 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#[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 Vec::new()
40 }
41
42 fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
43 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 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 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 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 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 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 if let Some(params) = ctx.startup_params.as_ref() {
200 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 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 "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
265pub const DEFAULT_PROMPT: &str = "$ ";
267
268fn 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
376pub 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
389pub 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 .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
425pub 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
477pub 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}