1use crate::core::module::{Module, ModuleError, ModuleResult};
2use async_trait::async_trait;
3use icebox_macro::module;
4use sha1::{Digest, Sha1};
5use std::sync::Arc;
6use tokio::sync::Semaphore;
7
8use crate::modules::hex_encode;
9
10#[module(
11 name = "mysql_scanner",
12 kind = "Scanner",
13 description = "MySQL service scanner - version detection and default credential check",
14 author = "ICEBOX",
15 sandbox_image = "mysql:5.7"
16)]
17pub struct MysqlScanner {
18 #[option(required = true, help = "Target IP or hostname")]
19 pub host: String,
20 #[option(help = "MySQL port (default 3306)")]
21 pub port: u16,
22 #[option(help = "Timeout in milliseconds (default 5000)")]
23 pub timeout_ms: u64,
24 #[option(help = "Check default/built-in credentials")]
25 pub check_defaults: bool,
26 #[option(help = "Custom creds as user:pass,user:pass,... (overrides built-in when set)")]
27 pub wordlist: String,
28}
29
30fn mysql_parse_greeting(data: &[u8]) -> Option<(String, Vec<u8>, u8, Vec<u8>)> {
31 if data.len() < 5 {
32 return None;
33 }
34 let payload =
35 if data.len() > 4 && (data[0] as usize + 1 + data[1] as usize * 256 + 1) <= data.len() {
36 &data[4..]
37 } else {
38 data
39 };
40 if payload.is_empty() {
41 return None;
42 }
43 let proto_ver = payload[0];
44 let mut pos = 1;
45 let mut version_bytes = Vec::new();
46 while pos < payload.len() && payload[pos] != 0 {
47 version_bytes.push(payload[pos]);
48 pos += 1;
49 }
50 pos += 1;
51 if pos + 4 > payload.len() {
52 return None;
53 }
54 let _conn_id = u32::from_le_bytes([
55 payload[pos],
56 payload[pos + 1],
57 payload[pos + 2],
58 payload[pos + 3],
59 ]);
60 pos += 4;
61 if pos + 8 > payload.len() {
62 return None;
63 }
64 let auth_plugin_part1 = payload[pos..pos + 8].to_vec();
65 pos += 8;
66 pos += 1;
67 if pos + 2 > payload.len() {
68 return None;
69 }
70 let _caps_lower = u16::from_le_bytes([payload[pos], payload[pos + 1]]);
71 pos += 2;
72 pos += 1;
73 pos += 2;
74 let _caps_upper = u16::from_le_bytes([payload[pos], payload[pos + 1]]);
75 pos += 2;
76 let auth_plugin_len = if pos < payload.len() { payload[pos] } else { 0 };
77 pos += 1;
78 pos += 10;
79 let part2_len = if auth_plugin_len > 8 {
80 auth_plugin_len as usize - 8
81 } else {
82 12
83 };
84 let auth_plugin_part2 = if pos + part2_len <= payload.len() {
85 payload[pos..pos + part2_len].to_vec()
86 } else {
87 vec![]
88 };
89 let mut scramble = auth_plugin_part1.clone();
90 scramble.extend_from_slice(&auth_plugin_part2);
91 let version = String::from_utf8_lossy(&version_bytes).to_string();
92 Some((version, scramble, proto_ver, auth_plugin_part1))
93}
94
95fn mysql_native_password(password: &str, scramble: &[u8]) -> Vec<u8> {
96 let mut hasher1 = Sha1::new();
97 hasher1.update(password.as_bytes());
98 let stage1 = hasher1.finalize();
99
100 let mut hasher2 = Sha1::new();
101 hasher2.update(stage1);
102 let stage2 = hasher2.finalize();
103
104 let mut hasher3 = Sha1::new();
105 hasher3.update(scramble);
106 hasher3.update(stage2);
107 let stage3 = hasher3.finalize();
108
109 stage1
110 .iter()
111 .zip(stage3.iter())
112 .map(|(a, b)| a ^ b)
113 .collect()
114}
115
116fn mysql_build_handshake(username: &str, auth_response: &[u8]) -> Vec<u8> {
117 let caps: u32 = 0x08820F;
118 let mut pkt = Vec::new();
119 pkt.extend_from_slice(&caps.to_le_bytes());
120 pkt.extend_from_slice(&[0x00, 0x00, 0x00, 0x01]);
121 pkt.push(0x2D);
122 pkt.extend_from_slice(&[0x00; 23]);
123
124 pkt.extend_from_slice(username.as_bytes());
125 pkt.push(0x00);
126
127 if auth_response.is_empty() {
128 pkt.push(0x00);
129 } else {
130 pkt.push(auth_response.len() as u8);
131 pkt.extend_from_slice(auth_response);
132 }
133
134 pkt.push(0x00);
135
136 pkt
137}
138
139fn mysql_prepend_header(payload: &[u8], seq: u8) -> Vec<u8> {
140 let len = payload.len() as u32;
141 let mut pkt = Vec::new();
142 pkt.extend_from_slice(&len.to_le_bytes()[..3]);
143 pkt.push(seq);
144 pkt.extend_from_slice(payload);
145 pkt
146}
147
148fn mysql_parse_response(data: &[u8]) -> Result<serde_json::Value, String> {
149 if data.len() < 4 {
150 return Err("response too short".into());
151 }
152 let payload = &data[4..];
153 if payload.is_empty() {
154 return Err("empty payload".into());
155 }
156 match payload[0] {
157 0x00 => Ok(
158 serde_json::json!({"status": "ok", "affected_rows": payload.get(1).copied().unwrap_or(0)}),
159 ),
160 0xFF => {
161 let code = if payload.len() > 3 {
162 u16::from_le_bytes([payload[1], payload[2]])
163 } else {
164 0
165 };
166 let msg = if payload.len() > 9 {
167 String::from_utf8_lossy(&payload[9..]).to_string()
168 } else if payload.len() > 3 {
169 String::from_utf8_lossy(&payload[3..]).to_string()
170 } else {
171 "unknown error".into()
172 };
173 Ok(serde_json::json!({"status": "error", "code": code, "message": msg}))
174 }
175 0xFE => Ok(serde_json::json!({"status": "eof"})),
176 _ => Ok(serde_json::json!({"status": "unknown", "first_byte": payload[0]})),
177 }
178}
179
180#[async_trait]
181impl Module for MysqlScanner {
182 fn options_json(&self) -> serde_json::Value {
183 serde_json::to_value(&MysqlScannerOptions {
184 host: self.host.clone(),
185 port: self.port,
186 timeout_ms: self.timeout_ms,
187 check_defaults: self.check_defaults,
188 wordlist: self.wordlist.clone(),
189 })
190 .unwrap_or(serde_json::Value::Null)
191 }
192
193 fn set_option(&mut self, name: &str, value: &str) -> Result<(), ModuleError> {
194 let mut o = MysqlScannerOptions {
195 host: self.host.clone(),
196 port: self.port,
197 timeout_ms: self.timeout_ms,
198 check_defaults: self.check_defaults,
199 wordlist: self.wordlist.clone(),
200 };
201 o.set(name, value)?;
202 self.host = o.host;
203 self.port = o.port;
204 self.timeout_ms = o.timeout_ms;
205 self.check_defaults = o.check_defaults;
206 self.wordlist = o.wordlist;
207 Ok(())
208 }
209
210 fn validate(&self) -> Result<(), ModuleError> {
211 MysqlScannerOptions {
212 host: self.host.clone(),
213 port: self.port,
214 timeout_ms: self.timeout_ms,
215 check_defaults: self.check_defaults,
216 wordlist: self.wordlist.clone(),
217 }
218 .validate()
219 }
220
221 async fn run(&self) -> Result<ModuleResult, ModuleError> {
222 let port = if self.port > 0 { self.port } else { 3306 };
223 let timeout = std::time::Duration::from_millis(if self.timeout_ms > 0 {
224 self.timeout_ms
225 } else {
226 5000
227 });
228 let addr = crate::core::proxy::resolve_dial(&self.host, port);
229
230 let mut stream =
231 match tokio::time::timeout(timeout, tokio::net::TcpStream::connect(&addr)).await {
232 Ok(Ok(s)) => s,
233 Ok(Err(e)) => return Err(ModuleError::Other(format!("connection failed: {e}"))),
234 Err(_) => return Err(ModuleError::Other("connection timed out".into())),
235 };
236
237 let mut buf = vec![0u8; 8192];
238 let n = match tokio::time::timeout(timeout, stream.readable()).await {
239 Ok(Ok(_)) => stream.try_read(&mut buf).unwrap_or(0),
240 _ => 0,
241 };
242 if n < 5 {
243 return Ok(ModuleResult {
244 success: true,
245 finding: Some(format!("MySQL on tcp/{port}: no greeting received")),
246 data: serde_json::json!({"host": self.host, "port": port}),
247 ..Default::default()
248 });
249 }
250
251 let (version, _scramble, _proto_ver, _part1) = match mysql_parse_greeting(&buf[..n]) {
252 Some(v) => v,
253 None => {
254 return Ok(ModuleResult {
255 success: true,
256 finding: Some(format!("MySQL on tcp/{port}: unrecognized response")),
257 data: serde_json::json!({"host": self.host, "port": port, "raw": hex_encode(&buf[..n.min(128)])}),
258 ..Default::default()
259 })
260 }
261 };
262
263 let mut findings = vec![format!("version: {version}")];
264 let mut evidence = vec![format!("mysql/{version}")];
265
266 let mut found_creds: Vec<(String, String)> = Vec::new();
267
268 let creds_to_try: Vec<(&str, &str)> = if !self.wordlist.is_empty() {
269 self.wordlist
270 .split(',')
271 .filter_map(|pair| {
272 let pair = pair.trim();
273 if let Some((u, p)) = pair.split_once(':') {
274 let u = u.trim();
275 let p = p.trim();
276 if !u.is_empty() {
277 Some((u, p))
278 } else {
279 None
280 }
281 } else {
282 None
283 }
284 })
285 .collect()
286 } else if self.check_defaults {
287 vec![
288 ("root", ""),
289 ("root", "root"),
290 ("root", "admin"),
291 ("admin", ""),
292 ("mysql", ""),
293 ("test", ""),
294 ]
295 } else {
296 vec![]
297 };
298
299 for (user, pass) in &creds_to_try {
300 let addr = addr.clone();
301 match tokio::time::timeout(timeout, tokio::net::TcpStream::connect(&addr)).await {
302 Ok(Ok(s)) => {
303 stream = s;
304 }
305 _ => continue,
306 };
307 let mut buf2 = vec![0u8; 8192];
308 let n2 = match tokio::time::timeout(timeout, stream.readable()).await {
309 Ok(Ok(_)) => stream.try_read(&mut buf2).unwrap_or(0),
310 _ => 0,
311 };
312 if n2 < 5 {
313 continue;
314 }
315 let (_v2, scramble2, _pv, _p1) = match mysql_parse_greeting(&buf2[..n2]) {
316 Some(v) => v,
317 None => continue,
318 };
319 let auth_resp = mysql_native_password(pass, &scramble2);
320 let login = mysql_build_handshake(user, &auth_resp);
321 let login_pkt = mysql_prepend_header(&login, 1);
322 if tokio::time::timeout(timeout, stream.writable())
323 .await
324 .is_ok()
325 {
326 let _ = stream.try_write(&login_pkt);
327 }
328 let mut buf3 = vec![0u8; 1024];
329 let n3 = match tokio::time::timeout(timeout, stream.readable()).await {
330 Ok(Ok(_)) => stream.try_read(&mut buf3).unwrap_or(0),
331 _ => 0,
332 };
333 if n3 > 4 {
334 if let Ok(resp) = mysql_parse_response(&buf3[..n3]) {
335 if resp.get("status").and_then(|s| s.as_str()) == Some("ok") {
336 let label = if pass.is_empty() {
337 format!("{user}:\"\"")
338 } else {
339 format!("{user}:{pass}")
340 };
341 findings.push(format!("cred: {label} - SUCCESS"));
342 evidence.push(format!("mysql/cred:{label}"));
343 found_creds.push((user.to_string(), pass.to_string()));
344 }
345 }
346 }
347 }
348
349 let finding = if found_creds.is_empty() {
350 format!(
351 "MySQL on tcp/{port}: {version} (no valid credentials found, tried {} pair(s))",
352 creds_to_try.len()
353 )
354 } else {
355 let labels: Vec<String> = found_creds
356 .iter()
357 .map(|(u, p)| {
358 if p.is_empty() {
359 format!("{u}:\"\"")
360 } else {
361 format!("{u}:{p}")
362 }
363 })
364 .collect();
365 format!(
366 "MySQL on tcp/{port}: {version} - {} credential(s) work! {}",
367 found_creds.len(),
368 labels.join(", ")
369 )
370 };
371
372 Ok(ModuleResult {
373 success: true,
374 finding: Some(finding),
375 evidence,
376 data: serde_json::json!({
377 "host": self.host,
378 "port": port,
379 "version": version,
380 "found_credentials": found_creds,
381 "tried_count": creds_to_try.len(),
382 }),
383 ..Default::default()
384 })
385 }
386}
387
388const DEFAULT_WORDLIST: &[&str] = &[
389 "admin",
390 "login",
391 "wp-admin",
392 "wp-content",
393 "backup",
394 ".git",
395 ".env",
396 "config",
397 "robots.txt",
398 "sitemap.xml",
399 "api",
400 "test",
401 "dev",
402 "uploads",
403 "images",
404 "css",
405 "js",
406 "includes",
407 "assets",
408 "private",
409 "restricted",
410 "bak",
411 "old",
412 "src",
413 "data",
414 "console",
415 "dashboard",
416 "manager",
417 "phpmyadmin",
418 "adminer",
419 "setup",
420 "install",
421 "readme",
422 "changelog",
423 "LICENSE",
424 ".htaccess",
425 ".htpasswd",
426 "server-status",
427 "crossdomain.xml",
428 "clientaccesspolicy.xml",
429 "web.config",
430 "phpinfo.php",
431 "info.php",
432 "status",
433 "health",
434 "healthcheck",
435 "actuator",
436 "swagger",
437 "api-docs",
438 "openapi.json",
439 "graphql",
440 "v2",
441 "v1",
442 "api/v1",
443 "api/v2",
444 "ws",
445 "websocket",
446 "sockjs",
447 "sockjs-node",
448 ".well-known",
449];
450
451#[module(
452 name = "web_path_scanner",
453 kind = "Scanner",
454 description = "HTTP path/directory brute-forcer - discover hidden files and web resources",
455 author = "ICEBOX"
456)]
457pub struct WebPathScanner {
458 #[option(
459 required = true,
460 help = "Target base URL (e.g. http://10.0.0.1:80 or http://example.com)"
461 )]
462 pub target: String,
463 #[option(help = "Path wordlist, comma-separated (default: built-in common paths)")]
464 pub wordlist: String,
465 #[option(help = "Timeout per request in milliseconds (default 3000)")]
466 pub timeout_ms: u64,
467 #[option(help = "Concurrent requests (default 20)")]
468 pub concurrency: usize,
469 #[option(help = "Interesting status codes, comma-separated (default: 200,301,302,401,403)")]
470 pub filter_codes: String,
471}
472
473fn parse_url(target: &str) -> Result<(String, u16, bool), ModuleError> {
474 let target = target.trim();
475 let (rest, default_port, tls) = if let Some(r) = target.strip_prefix("https://") {
476 (r, 443u16, true)
477 } else if let Some(r) = target.strip_prefix("http://") {
478 (r, 80u16, false)
479 } else {
480 (target, 80u16, false)
481 };
482 let rest = rest.trim_end_matches('/');
483 if let Some((host, ps)) = rest.rsplit_once(':') {
484 let p: u16 = ps
485 .parse()
486 .map_err(|_| ModuleError::Parse("bad port".into()))?;
487 Ok((host.to_string(), p, tls))
488 } else {
489 Ok((rest.to_string(), default_port, tls))
490 }
491}
492
493#[async_trait]
494impl Module for WebPathScanner {
495 fn options_json(&self) -> serde_json::Value {
496 serde_json::to_value(&WebPathScannerOptions {
497 target: self.target.clone(),
498 wordlist: self.wordlist.clone(),
499 timeout_ms: self.timeout_ms,
500 concurrency: self.concurrency,
501 filter_codes: self.filter_codes.clone(),
502 })
503 .unwrap_or(serde_json::Value::Null)
504 }
505
506 fn set_option(&mut self, name: &str, value: &str) -> Result<(), ModuleError> {
507 let mut o = WebPathScannerOptions {
508 target: self.target.clone(),
509 wordlist: self.wordlist.clone(),
510 timeout_ms: self.timeout_ms,
511 concurrency: self.concurrency,
512 filter_codes: self.filter_codes.clone(),
513 };
514 o.set(name, value)?;
515 self.target = o.target;
516 self.wordlist = o.wordlist;
517 self.timeout_ms = o.timeout_ms;
518 self.concurrency = o.concurrency;
519 self.filter_codes = o.filter_codes;
520 Ok(())
521 }
522
523 fn validate(&self) -> Result<(), ModuleError> {
524 WebPathScannerOptions {
525 target: self.target.clone(),
526 wordlist: self.wordlist.clone(),
527 timeout_ms: self.timeout_ms,
528 concurrency: self.concurrency,
529 filter_codes: self.filter_codes.clone(),
530 }
531 .validate()?;
532 parse_url(&self.target).map(|_| ())
533 }
534
535 async fn run(&self) -> Result<ModuleResult, ModuleError> {
536 let (host, port, _tls) = parse_url(&self.target)?;
537 let timeout = std::time::Duration::from_millis(if self.timeout_ms > 0 {
538 self.timeout_ms
539 } else {
540 3000
541 });
542 let max_concurrency = if self.concurrency > 0 {
543 self.concurrency
544 } else {
545 20
546 };
547
548 let paths: Vec<String> = if self.wordlist.is_empty() {
549 DEFAULT_WORDLIST.iter().map(|s| s.to_string()).collect()
550 } else {
551 self.wordlist
552 .split(',')
553 .map(|s| s.trim().to_string())
554 .filter(|s| !s.is_empty())
555 .collect()
556 };
557
558 let filter_codes: Vec<u16> = if self.filter_codes.is_empty() {
559 vec![200, 301, 302, 401, 403]
560 } else {
561 self.filter_codes
562 .split(',')
563 .filter_map(|s| s.trim().parse::<u16>().ok())
564 .collect()
565 };
566
567 let semaphore = Arc::new(Semaphore::new(max_concurrency));
568 let mut handles = Vec::new();
569
570 for path in &paths {
571 let permit = match semaphore.clone().acquire_owned().await {
572 Ok(p) => p,
573 Err(_) => continue,
574 };
575 let path = path.clone();
576 let host = host.clone();
577 let filter_codes = filter_codes.clone();
578 handles.push(tokio::spawn(async move {
579 let _permit = permit;
580 let addr = crate::core::proxy::resolve_dial(&host, port);
581 let stream = match tokio::time::timeout(timeout, tokio::net::TcpStream::connect(&addr)).await {
582 Ok(Ok(s)) => s,
583 _ => return None,
584 };
585 let request = format!(
586 "GET /{} HTTP/1.1\r\nHost: {}\r\nConnection: close\r\nUser-Agent: ICEBOX/1.0\r\nAccept: */*\r\n\r\n",
587 path, host
588 );
589 if tokio::time::timeout(timeout, stream.writable()).await.is_err() {
590 return None;
591 }
592 let _ = stream.try_write(request.as_bytes());
593 let mut buf = vec![0u8; 4096];
594 let n = match tokio::time::timeout(timeout, stream.readable()).await {
595 Ok(Ok(_)) => stream.try_read(&mut buf).unwrap_or(0),
596 _ => 0,
597 };
598 if n == 0 { return None; }
599 let resp = String::from_utf8_lossy(&buf[..n.min(4000)]);
600 let first_line = resp.lines().next().unwrap_or("");
601 let status_code = first_line.split(' ').nth(1)
602 .and_then(|s| s.parse::<u16>().ok())
603 .unwrap_or(0);
604 if status_code > 0 && (filter_codes.is_empty() || filter_codes.contains(&status_code)) {
605 let size_hint = resp.find("\r\n\r\n")
606 .map(|i| resp.len() - i - 4)
607 .unwrap_or(0);
608 Some((path, status_code, first_line.to_string(), size_hint))
609 } else {
610 None
611 }
612 }));
613 }
614
615 let mut discovered: Vec<serde_json::Value> = Vec::new();
616 for h in handles {
617 if let Ok(Some((path, code, line, size))) = h.await {
618 discovered.push(serde_json::json!({
619 "path": format!("/{path}"),
620 "status": code,
621 "reason": line,
622 "size": size,
623 }));
624 }
625 }
626
627 discovered.sort_by(|a, b| {
628 let pa = a["path"].as_str().unwrap_or("");
629 let pb = b["path"].as_str().unwrap_or("");
630 pa.cmp(pb)
631 });
632
633 let finding = if discovered.is_empty() {
634 "No interesting web paths discovered".to_string()
635 } else {
636 let paths_str: Vec<String> = discovered
637 .iter()
638 .map(|d| {
639 let p = d["path"].as_str().unwrap_or("");
640 let s = d["status"].as_u64().unwrap_or(0);
641 format!("{p} [{s}]")
642 })
643 .collect();
644 format!(
645 "Discovered {} path(s): {}",
646 discovered.len(),
647 paths_str.join(", ")
648 )
649 };
650
651 Ok(ModuleResult {
652 success: true,
653 finding: Some(finding),
654 evidence: discovered
655 .iter()
656 .map(|d| {
657 let p = d["path"].as_str().unwrap_or("");
658 let s = d["status"].as_u64().unwrap_or(0);
659 format!("http{s}{p}")
660 })
661 .collect(),
662 data: serde_json::json!({
663 "target": self.target,
664 "paths": discovered,
665 "count": discovered.len(),
666 }),
667 ..Default::default()
668 })
669 }
670}
671
672fn dns_encode_domain(domain: &str) -> Vec<u8> {
673 let mut buf = Vec::new();
674 for label in domain.split('.') {
675 if !label.is_empty() {
676 buf.push(label.len() as u8);
677 buf.extend_from_slice(label.as_bytes());
678 }
679 }
680 buf.push(0x00);
681 buf
682}
683
684fn dns_build_axfr_query(domain: &str, id: u16) -> Vec<u8> {
685 let mut msg = Vec::new();
686 msg.extend_from_slice(&id.to_be_bytes());
687 msg.extend_from_slice(&[0x01, 0x00]);
688 msg.extend_from_slice(&[0x00, 0x01]);
689 msg.extend_from_slice(&[0x00, 0x00]);
690 msg.extend_from_slice(&[0x00, 0x00]);
691 msg.extend_from_slice(&[0x00, 0x00]);
692 msg.extend_from_slice(&dns_encode_domain(domain));
693 msg.extend_from_slice(&[0x00, 0xFC]);
694 msg.extend_from_slice(&[0x00, 0x01]);
695
696 let len = msg.len() as u16;
697 let mut pkt = Vec::new();
698 pkt.extend_from_slice(&len.to_be_bytes());
699 pkt.extend_from_slice(&msg);
700 pkt
701}
702
703fn dns_decode_name(data: &[u8], pos: &mut usize) -> Result<String, ModuleError> {
704 let mut labels = Vec::new();
705 loop {
706 if *pos >= data.len() {
707 return Err(ModuleError::Other("DNS name: unexpected EOF".into()));
708 }
709 let byte = data[*pos];
710 if byte == 0x00 {
711 *pos += 1;
712 break;
713 }
714 if byte & 0xC0 == 0xC0 {
715 let offset = ((byte as usize & 0x3F) << 8) | data[*pos + 1] as usize;
716 *pos += 2;
717 let mut p = offset;
718 if let Ok(name) = dns_decode_name(data, &mut p) {
719 labels.push(name);
720 }
721 break;
722 }
723 let len = byte as usize;
724 *pos += 1;
725 if *pos + len > data.len() {
726 return Err(ModuleError::Other("DNS name: label too long".into()));
727 }
728 labels.push(String::from_utf8_lossy(&data[*pos..*pos + len]).to_string());
729 *pos += len;
730 }
731 Ok(labels.join("."))
732}
733
734fn dns_parse_response(data: &[u8]) -> Result<Vec<serde_json::Value>, ModuleError> {
735 if data.len() < 14 {
736 return Err(ModuleError::Other("response too short".into()));
737 }
738 let _id = u16::from_be_bytes([data[0], data[1]]);
739 let flags = u16::from_be_bytes([data[2], data[3]]);
740 let qr = (flags >> 15) & 1;
741 let rcode = flags & 0x0F;
742 if qr == 0 {
743 return Err(ModuleError::Other("not a response".into()));
744 }
745 if rcode != 0 {
746 let rcode_names = [
747 "NOERROR", "FORMERR", "SERVFAIL", "NXDOMAIN", "NOTIMP", "REFUSED",
748 ];
749 let name = rcode_names.get(rcode as usize).unwrap_or(&"UNKNOWN");
750 return Err(ModuleError::Other(format!(
751 "DNS error: {name} (rcode={rcode})"
752 )));
753 }
754
755 let _qdcount = u16::from_be_bytes([data[4], data[5]]);
756 let ancount = u16::from_be_bytes([data[6], data[7]]);
757 let _nscount = u16::from_be_bytes([data[8], data[9]]);
758 let _arcount = u16::from_be_bytes([data[10], data[11]]);
759
760 let mut pos = 12;
761 dns_decode_name(data, &mut pos)?;
762 pos += 4;
763
764 let mut records = Vec::new();
765 for _ in 0..ancount {
766 let name = match dns_decode_name(data, &mut pos) {
767 Ok(n) => n,
768 Err(_) => "?".to_string(),
769 };
770 if pos + 10 > data.len() {
771 break;
772 }
773 let rtype = u16::from_be_bytes([data[pos], data[pos + 1]]);
774 let _rclass = u16::from_be_bytes([data[pos + 2], data[pos + 3]]);
775 let ttl = u32::from_be_bytes([data[pos + 4], data[pos + 5], data[pos + 6], data[pos + 7]]);
776 let rdlength = u16::from_be_bytes([data[pos + 8], data[pos + 9]]) as usize;
777 pos += 10;
778 if pos + rdlength > data.len() {
779 break;
780 }
781
782 let rdata = match rtype {
783 1 => {
784 if rdlength >= 4 {
785 Some(serde_json::Value::String(format!(
786 "{}.{}.{}.{}",
787 data[pos],
788 data[pos + 1],
789 data[pos + 2],
790 data[pos + 3]
791 )))
792 } else {
793 None
794 }
795 }
796 28 => {
797 if rdlength >= 16 {
798 let ip = std::net::Ipv6Addr::from(
799 <[u8; 16]>::try_from(&data[pos..pos + 16]).unwrap_or([0u8; 16]),
800 );
801 Some(serde_json::Value::String(ip.to_string()))
802 } else {
803 None
804 }
805 }
806 5 => {
807 let mut p = pos;
808 dns_decode_name(data, &mut p)
809 .ok()
810 .map(serde_json::Value::String)
811 }
812 2 => {
813 let mut p = pos;
814 dns_decode_name(data, &mut p)
815 .ok()
816 .map(serde_json::Value::String)
817 }
818 15 => {
819 let pref = u16::from_be_bytes([data[pos], data[pos + 1]]);
820 let mut p = pos + 2;
821 let target = dns_decode_name(data, &mut p).unwrap_or_default();
822 Some(serde_json::json!({"preference": pref, "target": target}))
823 }
824 6 => {
825 let mut p = pos;
826 let mname = dns_decode_name(data, &mut p).unwrap_or_default();
827 let rname = dns_decode_name(data, &mut p).unwrap_or_default();
828 if p + 20 <= data.len() {
829 let serial =
830 u32::from_be_bytes([data[p], data[p + 1], data[p + 2], data[p + 3]]);
831 Some(serde_json::json!({"mname": mname, "rname": rname, "serial": serial}))
832 } else {
833 None
834 }
835 }
836 16 => {
837 if rdlength > 0 {
838 let txt_len = data[pos] as usize;
839 let txt = String::from_utf8_lossy(
840 &data[pos + 1..pos + 1 + txt_len.min(rdlength - 1)],
841 )
842 .to_string();
843 Some(serde_json::Value::String(txt))
844 } else {
845 None
846 }
847 }
848 33 => {
849 let mut p = pos;
850 if p + 6 <= data.len() {
851 let priority = u16::from_be_bytes([data[p], data[p + 1]]);
852 let weight = u16::from_be_bytes([data[p + 2], data[p + 3]]);
853 let port = u16::from_be_bytes([data[p + 4], data[p + 5]]);
854 p += 6;
855 let target = dns_decode_name(data, &mut p).unwrap_or_default();
856 Some(
857 serde_json::json!({"priority": priority, "weight": weight, "port": port, "target": target}),
858 )
859 } else {
860 None
861 }
862 }
863 _ => None,
864 };
865
866 let type_names = [
867 "", "A", "NS", "MD", "MF", "CNAME", "SOA", "MB", "MG", "MR", "NULL", "WKS", "PTR",
868 "HINFO", "MINFO", "MX", "TXT",
869 ];
870 let type_name = if (rtype as usize) < type_names.len() {
871 type_names[rtype as usize]
872 } else {
873 "TYPE?"
874 };
875
876 records.push(serde_json::json!({
877 "name": name,
878 "type": type_name,
879 "type_code": rtype,
880 "ttl": ttl,
881 "value": rdata,
882 }));
883 pos += rdlength;
884 }
885
886 Ok(records)
887}
888
889#[module(
890 name = "dns_zone_transfer",
891 kind = "Auxiliary",
892 description = "Attempt DNS zone transfer (AXFR) to enumerate all DNS records for a domain",
893 author = "ICEBOX"
894)]
895pub struct DnsZoneTransfer {
896 #[option(required = true, help = "Domain to query (e.g. example.com)")]
897 pub domain: String,
898 #[option(help = "DNS server to query (IP, default: use configured resolver)")]
899 pub server: String,
900 #[option(help = "Timeout in milliseconds (default 10000)")]
901 pub timeout_ms: u64,
902}
903
904#[async_trait]
905impl Module for DnsZoneTransfer {
906 fn options_json(&self) -> serde_json::Value {
907 serde_json::to_value(&DnsZoneTransferOptions {
908 domain: self.domain.clone(),
909 server: self.server.clone(),
910 timeout_ms: self.timeout_ms,
911 })
912 .unwrap_or(serde_json::Value::Null)
913 }
914
915 fn set_option(&mut self, name: &str, value: &str) -> Result<(), ModuleError> {
916 let mut o = DnsZoneTransferOptions {
917 domain: self.domain.clone(),
918 server: self.server.clone(),
919 timeout_ms: self.timeout_ms,
920 };
921 o.set(name, value)?;
922 self.domain = o.domain;
923 self.server = o.server;
924 self.timeout_ms = o.timeout_ms;
925 Ok(())
926 }
927
928 fn validate(&self) -> Result<(), ModuleError> {
929 DnsZoneTransferOptions {
930 domain: self.domain.clone(),
931 server: self.server.clone(),
932 timeout_ms: self.timeout_ms,
933 }
934 .validate()
935 }
936
937 async fn run(&self) -> Result<ModuleResult, ModuleError> {
938 let domain = self.domain.trim().to_lowercase();
939 let timeout = std::time::Duration::from_millis(if self.timeout_ms > 0 {
940 self.timeout_ms
941 } else {
942 10000
943 });
944
945 let nameservers: Vec<&str> = if self.server.is_empty() {
946 vec!["8.8.8.8", "1.1.1.1", "208.67.222.222"]
947 } else {
948 vec![self.server.as_str()]
949 };
950
951 let mut all_records = Vec::new();
952 let mut errors = Vec::new();
953
954 for ns in &nameservers {
955 let addr = format!("{ns}:53");
956 let stream =
957 match tokio::time::timeout(timeout, tokio::net::TcpStream::connect(&addr)).await {
958 Ok(Ok(s)) => s,
959 Ok(Err(e)) => {
960 errors.push(format!("{ns}: connect error: {e}"));
961 continue;
962 }
963 Err(_) => {
964 errors.push(format!("{ns}: connect timeout"));
965 continue;
966 }
967 };
968
969 let query = dns_build_axfr_query(&domain, 0x1337);
970 if tokio::time::timeout(timeout, stream.writable())
971 .await
972 .is_err()
973 {
974 errors.push(format!("{ns}: write timeout"));
975 continue;
976 }
977 let _ = stream.try_write(&query);
978
979 let mut buf = vec![0u8; 65536];
980 let n = match tokio::time::timeout(timeout, stream.readable()).await {
981 Ok(Ok(_)) => stream.try_read(&mut buf).unwrap_or(0),
982 _ => {
983 errors.push(format!("{ns}: read timeout"));
984 continue;
985 }
986 };
987
988 if n < 4 {
989 errors.push(format!("{ns}: response too short ({n} bytes)"));
990 continue;
991 }
992
993 let dns_msg = &buf[2..n];
994 match dns_parse_response(dns_msg) {
995 Ok(records) => {
996 if !records.is_empty() {
997 all_records.extend(records);
998 break;
999 }
1000 errors.push(format!("{ns}: no records returned"));
1001 }
1002 Err(e) => {
1003 errors.push(format!("{ns}: parse error: {e}"));
1004 }
1005 }
1006 }
1007
1008 let evidence: Vec<String> = all_records
1009 .iter()
1010 .map(|r| {
1011 let name = r["name"].as_str().unwrap_or("");
1012 let rtype = r["type"].as_str().unwrap_or("?");
1013 let value = r.get("value").and_then(|v| v.as_str()).unwrap_or("");
1014 format!("dns/{name} {rtype} {value}")
1015 })
1016 .collect();
1017
1018 let ns_used_str = all_records
1019 .first()
1020 .and_then(|_| nameservers.first())
1021 .unwrap_or(&"none");
1022
1023 let finding = if all_records.is_empty() {
1024 format!(
1025 "DNS zone transfer failed for {domain}: {}",
1026 errors.join("; ")
1027 )
1028 } else {
1029 let count = all_records.len();
1030 let soa_count = all_records.iter().filter(|r| r["type"] == "SOA").count();
1031 format!("Zone transfer SUCCESS for {domain} via {ns_used_str}: {count} records ({soa_count} SOA)")
1032 };
1033
1034 Ok(ModuleResult {
1035 success: !all_records.is_empty(),
1036 finding: Some(finding),
1037 evidence,
1038 data: serde_json::json!({
1039 "domain": domain,
1040 "nameserver": ns_used_str,
1041 "records": all_records,
1042 "record_count": all_records.len(),
1043 "errors": errors,
1044 }),
1045 ..Default::default()
1046 })
1047 }
1048}
1049
1050fn whois_server_for(query: &str) -> &'static str {
1051 let q = query.trim().to_lowercase();
1052 if q.contains('.') {
1053 if q.ends_with(".edu") {
1054 return "whois.educause.edu";
1055 }
1056 if q.ends_with(".gov") {
1057 return "whois.nic.gov";
1058 }
1059 if q.ends_with(".mil") {
1060 return "whois.nic.mil";
1061 }
1062 if q.ends_with(".org") || q.ends_with(".ngo") || q.ends_with(".ong") {
1063 return "whois.publicinterestregistry.net";
1064 }
1065 if q.ends_with(".info") {
1066 return "whois.afilias.net";
1067 }
1068 if q.ends_with(".biz") {
1069 return "whois.nic.biz";
1070 }
1071 if q.ends_with(".io") {
1072 return "whois.nic.io";
1073 }
1074 if q.ends_with(".co") {
1075 return "whois.nic.co";
1076 }
1077 if q.ends_with(".int") {
1078 return "whois.iana.org";
1079 }
1080 "whois.verisign-grs.com"
1081 } else {
1082 "whois.arin.net"
1083 }
1084}
1085
1086#[module(
1087 name = "whois_lookup",
1088 kind = "Auxiliary",
1089 description = "Perform whois lookup on a domain or IP address",
1090 author = "ICEBOX"
1091)]
1092pub struct WhoisLookup {
1093 #[option(required = true, help = "Domain or IP address to query")]
1094 pub query: String,
1095 #[option(help = "Whois server (default: auto-detected)")]
1096 pub server: String,
1097 #[option(help = "Timeout in milliseconds (default 15000)")]
1098 pub timeout_ms: u64,
1099}
1100
1101#[async_trait]
1102impl Module for WhoisLookup {
1103 fn options_json(&self) -> serde_json::Value {
1104 serde_json::to_value(&WhoisLookupOptions {
1105 query: self.query.clone(),
1106 server: self.server.clone(),
1107 timeout_ms: self.timeout_ms,
1108 })
1109 .unwrap_or(serde_json::Value::Null)
1110 }
1111
1112 fn set_option(&mut self, name: &str, value: &str) -> Result<(), ModuleError> {
1113 let mut o = WhoisLookupOptions {
1114 query: self.query.clone(),
1115 server: self.server.clone(),
1116 timeout_ms: self.timeout_ms,
1117 };
1118 o.set(name, value)?;
1119 self.query = o.query;
1120 self.server = o.server;
1121 self.timeout_ms = o.timeout_ms;
1122 Ok(())
1123 }
1124
1125 fn validate(&self) -> Result<(), ModuleError> {
1126 WhoisLookupOptions {
1127 query: self.query.clone(),
1128 server: self.server.clone(),
1129 timeout_ms: self.timeout_ms,
1130 }
1131 .validate()
1132 }
1133
1134 async fn run(&self) -> Result<ModuleResult, ModuleError> {
1135 let query = self.query.trim().to_string();
1136 let timeout = std::time::Duration::from_millis(if self.timeout_ms > 0 {
1137 self.timeout_ms
1138 } else {
1139 15000
1140 });
1141 let server = if self.server.is_empty() {
1142 whois_server_for(&query)
1143 } else {
1144 self.server.as_str()
1145 };
1146
1147 let addr = format!("{server}:43");
1148 let stream =
1149 match tokio::time::timeout(timeout, tokio::net::TcpStream::connect(&addr)).await {
1150 Ok(Ok(s)) => s,
1151 Ok(Err(e)) => return Err(ModuleError::Other(format!("connect to {server}: {e}"))),
1152 Err(_) => return Err(ModuleError::Other(format!("connect to {server}: timeout"))),
1153 };
1154
1155 let request = format!("{query}\r\n");
1156 match tokio::time::timeout(timeout, stream.writable()).await {
1157 Ok(Ok(_)) => {
1158 stream
1159 .try_write(request.as_bytes())
1160 .map_err(|e| ModuleError::Other(format!("write: {e}")))?;
1161 }
1162 _ => return Err(ModuleError::Other("write timeout".into())),
1163 }
1164
1165 let mut all_data = Vec::new();
1166 let mut buf = vec![0u8; 8192];
1167 loop {
1168 let n = match tokio::time::timeout(timeout, stream.readable()).await {
1169 Ok(Ok(_)) => match stream.try_read(&mut buf) {
1170 Ok(n) => n,
1171 Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => 0,
1172 Err(_) => break,
1173 },
1174 _ => break,
1175 };
1176 if n == 0 {
1177 break;
1178 }
1179 all_data.extend_from_slice(&buf[..n]);
1180 if all_data.len() > 65536 {
1181 break;
1182 }
1183 }
1184
1185 let raw_text = String::from_utf8_lossy(&all_data).to_string();
1186 let summary_lines: Vec<&str> = raw_text
1187 .lines()
1188 .filter(|l| {
1189 let l = l.trim().to_lowercase();
1190 l.starts_with("domain")
1191 || l.starts_with("registrar")
1192 || l.starts_with("creation")
1193 || l.starts_with("expir")
1194 || l.starts_with("name server")
1195 || l.starts_with("nserver")
1196 || l.starts_with("org")
1197 || l.starts_with("netrange")
1198 || l.starts_with("cidr")
1199 || l.starts_with("orgname")
1200 })
1201 .collect();
1202 let summary = summary_lines.join("\n");
1203
1204 Ok(ModuleResult {
1205 success: !raw_text.is_empty(),
1206 finding: Some(format!(
1207 "Whois lookup for {query} via {server}: {} lines",
1208 raw_text.lines().count()
1209 )),
1210 evidence: if summary.is_empty() {
1211 vec![format!("whois/{query} -> {} bytes", all_data.len())]
1212 } else {
1213 summary_lines.iter().map(|l| format!("whois/{l}")).collect()
1214 },
1215 data: serde_json::json!({
1216 "query": query,
1217 "server": server,
1218 "raw_length": all_data.len(),
1219 "summary": summary,
1220 "raw": if raw_text.len() > 10000 { format!("{}...", &raw_text[..10000]) } else { raw_text },
1221 }),
1222 ..Default::default()
1223 })
1224 }
1225}
1226
1227const DEFAULT_SUBDOMAINS: &[&str] = &[
1228 "www",
1229 "mail",
1230 "admin",
1231 "ftp",
1232 "ssh",
1233 "api",
1234 "dev",
1235 "test",
1236 "blog",
1237 "shop",
1238 "portal",
1239 "remote",
1240 "vpn",
1241 "webmail",
1242 "dns",
1243 "ns1",
1244 "ns2",
1245 "smtp",
1246 "pop3",
1247 "imap",
1248 "owa",
1249 "cpanel",
1250 "whm",
1251 "phpmyadmin",
1252 "jenkins",
1253 "gitlab",
1254 "jira",
1255 "wiki",
1256 "docs",
1257 "status",
1258 "support",
1259 "chat",
1260 "demo",
1261 "stage",
1262 "staging",
1263 "backup",
1264 "monitor",
1265 "logs",
1266 "db",
1267 "data",
1268 "sql",
1269 "mysql",
1270 "redis",
1271 "mongo",
1272 "cdn",
1273 "assets",
1274 "static",
1275 "download",
1276 "upload",
1277 "app",
1278 "apps",
1279 "server",
1280 "crm",
1281 "erp",
1282 "intranet",
1283 "biz",
1284 "info",
1285 "partner",
1286 "partners",
1287 "client",
1288 "clients",
1289 "user",
1290 "users",
1291 "account",
1292 "accounts",
1293 "login",
1294 "signin",
1295 "auth",
1296 "sso",
1297 "identity",
1298 "api-v1",
1299 "api-v2",
1300 "v1",
1301 "v2",
1302 "graphql",
1303 "rest",
1304 "service",
1305 "services",
1306 "mq",
1307 "kafka",
1308 "rabbitmq",
1309 "elasticsearch",
1310 "kibana",
1311 "grafana",
1312 "prometheus",
1313 "nagios",
1314 "zabbix",
1315 "mx",
1316 "mail2",
1317 "mail1",
1318 "ns",
1319 "ns3",
1320 "ns4",
1321 "direct",
1322 "direct-connect",
1323 "web",
1324 "webmail2",
1325 "forum",
1326 "help",
1327 "helpdesk",
1328 "knowledgebase",
1329 "kb",
1330 "tickets",
1331 "git",
1332 "svn",
1333 "cloud",
1334 "office",
1335 "outlook",
1336 "exchange",
1337 "lync",
1338 "skype",
1339];
1340
1341#[module(
1342 name = "subdomain_enum",
1343 kind = "Scanner",
1344 description = "Enumerate subdomains via DNS resolution",
1345 author = "ICEBOX"
1346)]
1347pub struct SubdomainEnum {
1348 #[option(required = true, help = "Domain to enumerate (e.g. example.com)")]
1349 pub domain: String,
1350 #[option(help = "Subdomain wordlist, comma-separated (default: built-in common subdomains)")]
1351 pub wordlist: String,
1352 #[option(help = "Timeout per lookup in milliseconds (default 3000)")]
1353 pub timeout_ms: u64,
1354 #[option(help = "Concurrent lookups (default 50)")]
1355 pub concurrency: usize,
1356}
1357
1358#[async_trait]
1359impl Module for SubdomainEnum {
1360 fn options_json(&self) -> serde_json::Value {
1361 serde_json::to_value(&SubdomainEnumOptions {
1362 domain: self.domain.clone(),
1363 wordlist: self.wordlist.clone(),
1364 timeout_ms: self.timeout_ms,
1365 concurrency: self.concurrency,
1366 })
1367 .unwrap_or(serde_json::Value::Null)
1368 }
1369
1370 fn set_option(&mut self, name: &str, value: &str) -> Result<(), ModuleError> {
1371 let mut o = SubdomainEnumOptions {
1372 domain: self.domain.clone(),
1373 wordlist: self.wordlist.clone(),
1374 timeout_ms: self.timeout_ms,
1375 concurrency: self.concurrency,
1376 };
1377 o.set(name, value)?;
1378 self.domain = o.domain;
1379 self.wordlist = o.wordlist;
1380 self.timeout_ms = o.timeout_ms;
1381 self.concurrency = o.concurrency;
1382 Ok(())
1383 }
1384
1385 fn validate(&self) -> Result<(), ModuleError> {
1386 SubdomainEnumOptions {
1387 domain: self.domain.clone(),
1388 wordlist: self.wordlist.clone(),
1389 timeout_ms: self.timeout_ms,
1390 concurrency: self.concurrency,
1391 }
1392 .validate()
1393 }
1394
1395 async fn run(&self) -> Result<ModuleResult, ModuleError> {
1396 let domain = self.domain.trim().to_lowercase();
1397 let timeout = std::time::Duration::from_millis(if self.timeout_ms > 0 {
1398 self.timeout_ms
1399 } else {
1400 3000
1401 });
1402 let max_concurrency = if self.concurrency > 0 {
1403 self.concurrency
1404 } else {
1405 50
1406 };
1407
1408 let subdomains: Vec<String> = if self.wordlist.is_empty() {
1409 DEFAULT_SUBDOMAINS.iter().map(|s| s.to_string()).collect()
1410 } else {
1411 self.wordlist
1412 .split(',')
1413 .map(|s| s.trim().to_string())
1414 .filter(|s| !s.is_empty())
1415 .collect()
1416 };
1417
1418 let semaphore = Arc::new(Semaphore::new(max_concurrency));
1419 let mut handles = Vec::new();
1420
1421 for sub in &subdomains {
1422 let permit = match semaphore.clone().acquire_owned().await {
1423 Ok(p) => p,
1424 Err(_) => continue,
1425 };
1426 let sub = sub.clone();
1427 let domain = domain.clone();
1428 handles.push(tokio::spawn(async move {
1429 let _permit = permit;
1430 let fqdn = format!("{sub}.{domain}");
1431 let result =
1432 tokio::time::timeout(timeout, tokio::net::lookup_host((fqdn.clone(), 0))).await;
1433 match result {
1434 Ok(Ok(addrs)) => {
1435 let ips: Vec<String> = addrs.map(|a| a.ip().to_string()).collect();
1436 if !ips.is_empty() {
1437 Some((sub, fqdn, ips))
1438 } else {
1439 None
1440 }
1441 }
1442 _ => None,
1443 }
1444 }));
1445 }
1446
1447 let mut resolved: Vec<serde_json::Value> = Vec::new();
1448 for h in handles {
1449 if let Ok(Some((sub, fqdn, ips))) = h.await {
1450 resolved.push(serde_json::json!({
1451 "subdomain": sub,
1452 "fqdn": fqdn,
1453 "ips": ips,
1454 }));
1455 }
1456 }
1457
1458 resolved.sort_by(|a, b| {
1459 a["subdomain"]
1460 .as_str()
1461 .unwrap_or("")
1462 .cmp(b["subdomain"].as_str().unwrap_or(""))
1463 });
1464
1465 let evidence: Vec<String> = resolved
1466 .iter()
1467 .map(|r| {
1468 let f = r["fqdn"].as_str().unwrap_or("");
1469 let ips = r["ips"]
1470 .as_array()
1471 .map(|a| {
1472 a.iter()
1473 .filter_map(|v| v.as_str().map(|s| s.to_string()))
1474 .collect::<Vec<_>>()
1475 .join(",")
1476 })
1477 .unwrap_or_default();
1478 format!("dns/{f} -> {ips}")
1479 })
1480 .collect();
1481
1482 let finding = if resolved.is_empty() {
1483 "No subdomains found".to_string()
1484 } else {
1485 let list: Vec<String> = resolved
1486 .iter()
1487 .map(|r| {
1488 let s = r["subdomain"].as_str().unwrap_or("");
1489 let ips = r["ips"]
1490 .as_array()
1491 .map(|a| {
1492 a.iter()
1493 .filter_map(|v| v.as_str())
1494 .collect::<Vec<_>>()
1495 .join(",")
1496 })
1497 .unwrap_or_default();
1498 format!("{s} ({ips})")
1499 })
1500 .collect();
1501 format!("Found {} subdomain(s): {}", resolved.len(), list.join(", "))
1502 };
1503
1504 Ok(ModuleResult {
1505 success: true,
1506 finding: Some(finding),
1507 evidence,
1508 data: serde_json::json!({
1509 "domain": domain,
1510 "subdomains": resolved,
1511 "count": resolved.len(),
1512 "tried": subdomains.len(),
1513 }),
1514 ..Default::default()
1515 })
1516 }
1517}
1518
1519fn ber_tag(tag: u8, contents: &[u8]) -> Vec<u8> {
1520 let mut buf = vec![tag];
1521 let lbytes = if contents.len() < 128 {
1522 vec![contents.len() as u8]
1523 } else if contents.len() <= 0xFF {
1524 vec![0x81, contents.len() as u8]
1525 } else {
1526 let bytes = (contents.len() as u16).to_be_bytes();
1527 vec![0x82, bytes[0], bytes[1]]
1528 };
1529 buf.extend_from_slice(&lbytes);
1530 buf.extend_from_slice(contents);
1531 buf
1532}
1533
1534fn ber_integer(value: i32) -> Vec<u8> {
1535 let bytes = if value == 0 {
1536 vec![0x00]
1537 } else if value > 0 {
1538 if value <= 0x7F {
1539 vec![value as u8]
1540 } else {
1541 value.to_be_bytes().to_vec()
1542 }
1543 } else {
1544 value.to_be_bytes().to_vec()
1545 };
1546 ber_tag(0x02, &bytes)
1547}
1548
1549fn ber_octet_string(s: &[u8]) -> Vec<u8> {
1550 ber_tag(0x04, s)
1551}
1552
1553fn ber_null() -> Vec<u8> {
1554 vec![0x05, 0x00]
1555}
1556
1557fn ber_sequence(contents: &[u8]) -> Vec<u8> {
1558 ber_tag(0x30, contents)
1559}
1560
1561fn ber_oid(components: &[u32]) -> Vec<u8> {
1562 let mut encoded = Vec::new();
1563 if components.len() < 2 {
1564 return ber_tag(0x06, &[]);
1565 }
1566 encoded.push((40 * components[0] + components[1]) as u8);
1567 for &c in &components[2..] {
1568 if c < 128 {
1569 encoded.push(c as u8);
1570 } else {
1571 let mut bytes = Vec::new();
1572 let mut v = c;
1573 while v > 0 {
1574 bytes.push((v & 0x7F) as u8);
1575 v >>= 7;
1576 }
1577 bytes.reverse();
1578 for (i, &b) in bytes.iter().enumerate() {
1579 if i < bytes.len() - 1 {
1580 encoded.push(b | 0x80);
1581 } else {
1582 encoded.push(b);
1583 }
1584 }
1585 }
1586 }
1587 ber_tag(0x06, &encoded)
1588}
1589
1590fn ber_context_specific(tag: u8, contents: &[u8]) -> Vec<u8> {
1591 ber_tag(0xA0 | tag, contents)
1592}
1593
1594fn snmp_build_get_request(community: &str, oid: &[u32], request_id: i32) -> Vec<u8> {
1595 let mut varbind_inner = Vec::new();
1596 varbind_inner.extend_from_slice(&ber_oid(oid));
1597 varbind_inner.extend_from_slice(&ber_null());
1598 let varbind = ber_sequence(&varbind_inner);
1599
1600 let mut varbind_list_inner = Vec::new();
1601 varbind_list_inner.extend_from_slice(&varbind);
1602 let varbind_list = ber_sequence(&varbind_list_inner);
1603
1604 let mut pdu_inner = Vec::new();
1605 pdu_inner.extend_from_slice(&ber_integer(request_id));
1606 pdu_inner.extend_from_slice(&ber_integer(0));
1607 pdu_inner.extend_from_slice(&ber_integer(0));
1608 pdu_inner.extend_from_slice(&varbind_list);
1609 let pdu = ber_context_specific(0, &pdu_inner);
1610
1611 let mut msg_inner = Vec::new();
1612 msg_inner.extend_from_slice(&ber_integer(1));
1613 msg_inner.extend_from_slice(&ber_octet_string(community.as_bytes()));
1614 msg_inner.extend_from_slice(&pdu);
1615 ber_sequence(&msg_inner)
1616}
1617
1618fn asn1_skip_tlv(data: &[u8], pos: &mut usize) -> Result<(), ModuleError> {
1619 if *pos >= data.len() {
1620 return Err(ModuleError::Other("ASN.1: unexpected EOF at tag".into()));
1621 }
1622 let _tag = data[*pos];
1623 *pos += 1;
1624 if *pos >= data.len() {
1625 return Err(ModuleError::Other("ASN.1: unexpected EOF at length".into()));
1626 }
1627 let len_byte = data[*pos];
1628 *pos += 1;
1629 let length: usize = if len_byte < 0x80 {
1630 len_byte as usize
1631 } else if len_byte == 0x81 {
1632 if *pos >= data.len() {
1633 return Err(ModuleError::Other("ASN.1: EOF in long length".into()));
1634 }
1635 let l = data[*pos] as usize;
1636 *pos += 1;
1637 l
1638 } else if len_byte == 0x82 {
1639 if *pos + 2 > data.len() {
1640 return Err(ModuleError::Other("ASN.1: EOF in long length".into()));
1641 }
1642 let l = u16::from_be_bytes([data[*pos], data[*pos + 1]]) as usize;
1643 *pos += 2;
1644 l
1645 } else {
1646 return Err(ModuleError::Other(
1647 "ASN.1: unsupported length encoding".into(),
1648 ));
1649 };
1650 *pos += length;
1651 Ok(())
1652}
1653
1654fn asn1_get_value(data: &[u8], pos: &mut usize) -> Result<Vec<u8>, ModuleError> {
1655 if *pos >= data.len() {
1656 return Err(ModuleError::Other("ASN.1: EOF at tag".into()));
1657 }
1658 *pos += 1;
1659 if *pos >= data.len() {
1660 return Err(ModuleError::Other("ASN.1: EOF at length".into()));
1661 }
1662 let len_byte = data[*pos];
1663 *pos += 1;
1664 let length: usize = if len_byte < 0x80 {
1665 len_byte as usize
1666 } else if len_byte == 0x81 {
1667 if *pos >= data.len() {
1668 return Err(ModuleError::Other("ASN.1: EOF".into()));
1669 }
1670 let l = data[*pos] as usize;
1671 *pos += 1;
1672 l
1673 } else if len_byte == 0x82 {
1674 if *pos + 2 > data.len() {
1675 return Err(ModuleError::Other("ASN.1: EOF".into()));
1676 }
1677 let l = (data[*pos] as usize) << 8 | data[*pos + 1] as usize;
1678 *pos += 2;
1679 l
1680 } else {
1681 return Err(ModuleError::Other("ASN.1: unsupported length".into()));
1682 };
1683 if *pos + length > data.len() {
1684 return Err(ModuleError::Other("ASN.1: value exceeds data".into()));
1685 }
1686 let val = data[*pos..*pos + length].to_vec();
1687 *pos += length;
1688 Ok(val)
1689}
1690
1691fn snmp_parse_response(data: &[u8]) -> Result<String, ModuleError> {
1692 let mut pos = 0;
1693 if pos >= data.len() || data[pos] != 0x30 {
1694 return Err(ModuleError::Other("not an ASN.1 SEQUENCE".into()));
1695 }
1696 let _outer_val = asn1_get_value(data, &mut pos)?;
1697
1698 let mut p = 0;
1699 asn1_skip_tlv(data, &mut p)?;
1700 asn1_skip_tlv(data, &mut p)?;
1701 asn1_skip_tlv(data, &mut p)?;
1702 if p >= data.len() || data[p] != 0xA2 {
1703 return Err(ModuleError::Other("expected GetResponse PDU".into()));
1704 }
1705 asn1_get_value(data, &mut p)?;
1706 let mut pp = 0;
1707 asn1_skip_tlv(data, &mut pp)?;
1708 asn1_skip_tlv(data, &mut pp)?;
1709 asn1_skip_tlv(data, &mut pp)?;
1710 if pp >= data.len() || (data[pp] != 0xA2 && data[pp] != 0xA0) {
1711 return Err(ModuleError::Other("expected SNMP PDU".into()));
1712 }
1713 let pdu_val = asn1_get_value(data, &mut pp)?;
1714 let mut pdu_pos = 0;
1715 asn1_skip_tlv(&pdu_val, &mut pdu_pos)?;
1716 asn1_skip_tlv(&pdu_val, &mut pdu_pos)?;
1717 asn1_skip_tlv(&pdu_val, &mut pdu_pos)?;
1718 if pdu_pos >= pdu_val.len() || pdu_val[pdu_pos] != 0x30 {
1719 return Err(ModuleError::Other("expected var-bind-list".into()));
1720 }
1721 let vb_list = asn1_get_value(&pdu_val, &mut pdu_pos)?;
1722 let mut vb_pos = 0;
1723 if vb_pos >= vb_list.len() || vb_list[vb_pos] != 0x30 {
1724 return Err(ModuleError::Other("expected var-bind".into()));
1725 }
1726 let vb_val = asn1_get_value(&vb_list, &mut vb_pos)?;
1727 let mut vv = 0;
1728 asn1_skip_tlv(&vb_val, &mut vv)?;
1729 if vv >= vb_val.len() {
1730 return Err(ModuleError::Other("no value in var-bind".into()));
1731 }
1732 let value = asn1_get_value(&vb_val, &mut vv)?;
1733 if value
1734 .iter()
1735 .all(|&b| (0x20..=0x7E).contains(&b) || b == b' ')
1736 {
1737 Ok(String::from_utf8_lossy(&value).to_string())
1738 } else if !value.is_empty() {
1739 Ok(format!("0x{}", hex_encode(&value)))
1740 } else {
1741 Ok("(null/empty)".into())
1742 }
1743}
1744
1745fn asn1_parse_for_text(data: &[u8]) -> Option<String> {
1746 for window in data.windows(4) {
1747 if window == [0x04, 0x81] || window == [0x04, 0x82] {
1748 break;
1749 }
1750 }
1751 let text = String::from_utf8_lossy(data);
1752 let mut candidates: Vec<String> = text
1753 .split(|c: char| !c.is_ascii_graphic() && c != ' ')
1754 .filter(|s| s.len() > 5 && s.is_ascii())
1755 .map(|s| s.trim().to_string())
1756 .collect();
1757 candidates.dedup();
1758 candidates.retain(|s| !s.contains('\0') && s.len() < 256);
1759 candidates.first().cloned()
1760}
1761
1762#[module(
1763 name = "snmp_scanner",
1764 kind = "Scanner",
1765 description = "SNMP service scanner - enumerate community strings and system information",
1766 author = "ICEBOX"
1767)]
1768pub struct SnmpScanner {
1769 #[option(required = true, help = "Target IP or hostname")]
1770 pub host: String,
1771 #[option(help = "SNMP port (default 161)")]
1772 pub port: u16,
1773 #[option(help = "Timeout in milliseconds (default 3000)")]
1774 pub timeout_ms: u64,
1775 #[option(help = "Community strings, comma-separated (default: public,private)")]
1776 pub communities: String,
1777}
1778
1779#[async_trait]
1780impl Module for SnmpScanner {
1781 fn options_json(&self) -> serde_json::Value {
1782 serde_json::to_value(&SnmpScannerOptions {
1783 host: self.host.clone(),
1784 port: self.port,
1785 timeout_ms: self.timeout_ms,
1786 communities: self.communities.clone(),
1787 })
1788 .unwrap_or(serde_json::Value::Null)
1789 }
1790
1791 fn set_option(&mut self, name: &str, value: &str) -> Result<(), ModuleError> {
1792 let mut o = SnmpScannerOptions {
1793 host: self.host.clone(),
1794 port: self.port,
1795 timeout_ms: self.timeout_ms,
1796 communities: self.communities.clone(),
1797 };
1798 o.set(name, value)?;
1799 self.host = o.host;
1800 self.port = o.port;
1801 self.timeout_ms = o.timeout_ms;
1802 self.communities = o.communities;
1803 Ok(())
1804 }
1805
1806 fn validate(&self) -> Result<(), ModuleError> {
1807 SnmpScannerOptions {
1808 host: self.host.clone(),
1809 port: self.port,
1810 timeout_ms: self.timeout_ms,
1811 communities: self.communities.clone(),
1812 }
1813 .validate()
1814 }
1815
1816 async fn run(&self) -> Result<ModuleResult, ModuleError> {
1817 let port = if self.port > 0 { self.port } else { 161 };
1818 let timeout = std::time::Duration::from_millis(if self.timeout_ms > 0 {
1819 self.timeout_ms
1820 } else {
1821 3000
1822 });
1823 let addr = crate::core::proxy::resolve_dial(&self.host, port);
1824
1825 let communities: Vec<&str> = if self.communities.is_empty() {
1826 vec![
1827 "public",
1828 "private",
1829 "community",
1830 "snmp",
1831 "manager",
1832 "admin",
1833 "read",
1834 "write",
1835 ]
1836 } else {
1837 self.communities
1838 .split(',')
1839 .map(|s| s.trim())
1840 .filter(|s| !s.is_empty())
1841 .collect()
1842 };
1843
1844 let sysdescr_oid: &[u32] = &[1, 3, 6, 1, 2, 1, 1, 1, 0];
1845 let mut found_communities: Vec<(String, String)> = Vec::new();
1846
1847 for &community in &communities {
1848 let sock = match tokio::net::UdpSocket::bind("0.0.0.0:0").await {
1849 Ok(s) => s,
1850 Err(_) => continue,
1851 };
1852 let request = snmp_build_get_request(community, sysdescr_oid, 1);
1853 if tokio::time::timeout(timeout, sock.send_to(&request, &addr))
1854 .await
1855 .is_err()
1856 {
1857 continue;
1858 }
1859 let mut buf = vec![0u8; 65536];
1860 let n = match tokio::time::timeout(timeout, sock.recv_from(&mut buf)).await {
1861 Ok(Ok((n, _))) => n,
1862 _ => continue,
1863 };
1864 if n < 10 {
1865 continue;
1866 }
1867
1868 let response = &buf[..n];
1869 match snmp_parse_response(response) {
1870 Ok(description) => {
1871 found_communities.push((community.to_string(), description));
1872 }
1873 Err(_) => {
1874 if let Some(text) = asn1_parse_for_text(response) {
1875 found_communities.push((community.to_string(), text));
1876 }
1877 }
1878 }
1879 }
1880
1881 let evidence: Vec<String> = found_communities
1882 .iter()
1883 .map(|(c, desc)| format!("snmp/community:{c} -> {desc}"))
1884 .collect();
1885
1886 let finding = if found_communities.is_empty() {
1887 format!("SNMP on udp/{port}: no working community string found")
1888 } else {
1889 let summaries: Vec<String> = found_communities
1890 .iter()
1891 .map(|(c, desc)| format!("{c}={desc}"))
1892 .collect();
1893 format!(
1894 "SNMP on udp/{port}: {} working community string(s): {}",
1895 found_communities.len(),
1896 summaries.join("; ")
1897 )
1898 };
1899
1900 Ok(ModuleResult {
1901 success: !found_communities.is_empty(),
1902 finding: Some(finding),
1903 evidence,
1904 data: serde_json::json!({
1905 "host": self.host,
1906 "port": port,
1907 "found_communities": found_communities,
1908 "tried": communities,
1909 }),
1910 ..Default::default()
1911 })
1912 }
1913}
1914
1915fn bson_encode_is_master() -> Vec<u8> {
1916 let mut doc = Vec::new();
1917 doc.push(0x10);
1918 doc.extend_from_slice(b"isMaster\x00");
1919 doc.extend_from_slice(&1i32.to_le_bytes());
1920 doc.push(0x00);
1921 let mut pkt = Vec::with_capacity(4 + doc.len());
1922 let len = (4 + doc.len()) as i32;
1923 pkt.extend_from_slice(&len.to_le_bytes());
1924 pkt.extend_from_slice(&doc);
1925 pkt
1926}
1927
1928fn build_mongo_query(db_cmd: &str, query: &[u8]) -> Vec<u8> {
1929 let msg_len = 16 + 4 + db_cmd.len() as u32 + 1 + 4 + 4 + query.len() as u32;
1930 let mut pkt = Vec::with_capacity(msg_len as usize);
1931 pkt.extend_from_slice(&msg_len.to_le_bytes());
1932 pkt.extend_from_slice(&1i32.to_le_bytes());
1933 pkt.extend_from_slice(&0i32.to_le_bytes());
1934 pkt.extend_from_slice(&2004i32.to_le_bytes());
1935 pkt.extend_from_slice(&0i32.to_le_bytes());
1936 pkt.extend_from_slice(db_cmd.as_bytes());
1937 pkt.push(0x00);
1938 pkt.extend_from_slice(&0i32.to_le_bytes());
1939 pkt.extend_from_slice(&(-1i32).to_le_bytes());
1940 pkt.extend_from_slice(query);
1941 pkt
1942}
1943
1944fn bson_find_string(doc: &[u8], field: &str) -> Option<String> {
1945 if doc.len() < 5 {
1946 return None;
1947 }
1948 let _total_len = i32::from_le_bytes([doc[0], doc[1], doc[2], doc[3]]) as usize;
1949 let mut pos = 4;
1950 while pos + 2 < doc.len() {
1951 let etype = doc[pos];
1952 pos += 1;
1953 let name_end = doc[pos..].iter().position(|&b| b == 0)?;
1954 let name = std::str::from_utf8(&doc[pos..pos + name_end]).ok()?;
1955 pos += name_end + 1;
1956 if name == field {
1957 return match etype {
1958 0x02 => {
1959 if pos + 4 > doc.len() {
1960 return None;
1961 }
1962 let slen =
1963 i32::from_le_bytes([doc[pos], doc[pos + 1], doc[pos + 2], doc[pos + 3]])
1964 as usize;
1965 if pos + 4 + slen <= doc.len() {
1966 Some(String::from_utf8_lossy(&doc[pos + 4..pos + 4 + slen - 1]).to_string())
1967 } else {
1968 None
1969 }
1970 }
1971 0x08 => {
1972 if pos < doc.len() {
1973 Some(if doc[pos] != 0 {
1974 "true".into()
1975 } else {
1976 "false".into()
1977 })
1978 } else {
1979 None
1980 }
1981 }
1982 _ => None,
1983 };
1984 }
1985 pos = match etype {
1986 0x01 => {
1987 if pos + 8 <= doc.len() {
1988 pos + 8
1989 } else {
1990 return None;
1991 }
1992 }
1993 0x02 => {
1994 if pos + 4 > doc.len() {
1995 return None;
1996 }
1997 let slen = i32::from_le_bytes([doc[pos], doc[pos + 1], doc[pos + 2], doc[pos + 3]])
1998 as usize;
1999 if pos + 4 + slen <= doc.len() {
2000 pos + 4 + slen
2001 } else {
2002 return None;
2003 }
2004 }
2005 0x03 | 0x04 => {
2006 if pos + 4 > doc.len() {
2007 return None;
2008 }
2009 let dlen = i32::from_le_bytes([doc[pos], doc[pos + 1], doc[pos + 2], doc[pos + 3]])
2010 as usize;
2011 if pos + dlen <= doc.len() {
2012 pos + dlen
2013 } else {
2014 return None;
2015 }
2016 }
2017 0x08 => pos + 1,
2018 0x09 | 0x12 => {
2019 if pos + 8 <= doc.len() {
2020 pos + 8
2021 } else {
2022 return None;
2023 }
2024 }
2025 0x10 if pos + 4 <= doc.len() => pos + 4,
2026 _ => {
2027 return None;
2028 }
2029 };
2030 }
2031 None
2032}
2033
2034fn bson_find_int32(doc: &[u8], field: &str) -> Option<i32> {
2035 if doc.len() < 5 {
2036 return None;
2037 }
2038 let mut pos = 4;
2039 while pos + 2 < doc.len() {
2040 let etype = doc[pos];
2041 pos += 1;
2042 let name_end = doc[pos..].iter().position(|&b| b == 0)?;
2043 let name = std::str::from_utf8(&doc[pos..pos + name_end]).ok()?;
2044 pos += name_end + 1;
2045 if name == field && etype == 0x10 && pos + 4 <= doc.len() {
2046 return Some(i32::from_le_bytes([
2047 doc[pos],
2048 doc[pos + 1],
2049 doc[pos + 2],
2050 doc[pos + 3],
2051 ]));
2052 }
2053 pos = match etype {
2054 0x01 => pos + 8,
2055 0x02 => {
2056 if pos + 4 > doc.len() {
2057 return None;
2058 }
2059 pos + 4
2060 + i32::from_le_bytes([doc[pos], doc[pos + 1], doc[pos + 2], doc[pos + 3]])
2061 as usize
2062 }
2063 0x03 | 0x04 => {
2064 if pos + 4 > doc.len() {
2065 return None;
2066 }
2067 pos + i32::from_le_bytes([doc[pos], doc[pos + 1], doc[pos + 2], doc[pos + 3]])
2068 as usize
2069 }
2070 0x08 => pos + 1,
2071 0x09 | 0x12 => pos + 8,
2072 0x10 => pos + 4,
2073 _ => {
2074 return None;
2075 }
2076 };
2077 }
2078 None
2079}
2080
2081fn mongo_body(response: &[u8]) -> Option<&[u8]> {
2082 if response.len() < 16 {
2083 return None;
2084 }
2085 let _msg_len = i32::from_le_bytes([response[0], response[1], response[2], response[3]]);
2086 let _req_id = i32::from_le_bytes([response[4], response[5], response[6], response[7]]);
2087 let _resp_to = i32::from_le_bytes([response[8], response[9], response[10], response[11]]);
2088 let op_code = i32::from_le_bytes([response[12], response[13], response[14], response[15]]);
2089 if op_code != 1 {
2090 return None;
2091 }
2092 if response.len() < 36 {
2093 return None;
2094 }
2095 let _flags = i32::from_le_bytes([response[16], response[17], response[18], response[19]]);
2096 let _cursor = i64::from_le_bytes([
2097 response[20],
2098 response[21],
2099 response[22],
2100 response[23],
2101 response[24],
2102 response[25],
2103 response[26],
2104 response[27],
2105 ]);
2106 let _start = i32::from_le_bytes([response[28], response[29], response[30], response[31]]);
2107 let _num = i32::from_le_bytes([response[32], response[33], response[34], response[35]]);
2108 if response.len() > 36 {
2109 Some(&response[36..])
2110 } else {
2111 None
2112 }
2113}
2114
2115#[module(
2116 name = "mongo_scanner",
2117 kind = "Scanner",
2118 description = "MongoDB service scanner - version detection and no-auth access check",
2119 author = "ICEBOX"
2120)]
2121pub struct MongoScanner {
2122 #[option(required = true, help = "Target IP or hostname")]
2123 pub host: String,
2124 #[option(help = "MongoDB port (default 27017)")]
2125 pub port: u16,
2126 #[option(help = "Timeout in milliseconds (default 5000)")]
2127 pub timeout_ms: u64,
2128}
2129
2130#[async_trait]
2131impl Module for MongoScanner {
2132 fn options_json(&self) -> serde_json::Value {
2133 serde_json::to_value(&MongoScannerOptions {
2134 host: self.host.clone(),
2135 port: self.port,
2136 timeout_ms: self.timeout_ms,
2137 })
2138 .unwrap_or(serde_json::Value::Null)
2139 }
2140
2141 fn set_option(&mut self, name: &str, value: &str) -> Result<(), ModuleError> {
2142 let mut o = MongoScannerOptions {
2143 host: self.host.clone(),
2144 port: self.port,
2145 timeout_ms: self.timeout_ms,
2146 };
2147 o.set(name, value)?;
2148 self.host = o.host;
2149 self.port = o.port;
2150 self.timeout_ms = o.timeout_ms;
2151 Ok(())
2152 }
2153
2154 fn validate(&self) -> Result<(), ModuleError> {
2155 MongoScannerOptions {
2156 host: self.host.clone(),
2157 port: self.port,
2158 timeout_ms: self.timeout_ms,
2159 }
2160 .validate()
2161 }
2162
2163 async fn run(&self) -> Result<ModuleResult, ModuleError> {
2164 let port = if self.port > 0 { self.port } else { 27017 };
2165 let timeout_ms = if self.timeout_ms > 0 {
2166 self.timeout_ms
2167 } else {
2168 5000
2169 };
2170 let timeout = std::time::Duration::from_millis(timeout_ms);
2171 let addr = crate::core::proxy::resolve_dial(&self.host, port);
2172
2173 let stream =
2174 match tokio::time::timeout(timeout, tokio::net::TcpStream::connect(&addr)).await {
2175 Ok(Ok(s)) => s,
2176 Ok(Err(e)) => return Err(ModuleError::Other(format!("connection failed: {e}"))),
2177 Err(_) => return Err(ModuleError::Other("connection timed out".into())),
2178 };
2179
2180 let query = bson_encode_is_master();
2181 let pkt = build_mongo_query("admin.$cmd", &query);
2182 if tokio::time::timeout(timeout, stream.writable())
2183 .await
2184 .is_err()
2185 {
2186 return Err(ModuleError::Other("write timeout".into()));
2187 }
2188 let _ = stream.try_write(&pkt);
2189
2190 let mut buf = vec![0u8; 65536];
2191 let n = match tokio::time::timeout(timeout, stream.readable()).await {
2192 Ok(Ok(_)) => stream.try_read(&mut buf).unwrap_or(0),
2193 _ => 0,
2194 };
2195
2196 if n < 36 {
2197 return Ok(ModuleResult {
2198 success: true,
2199 finding: Some(format!("MongoDB on tcp/{port}: no valid response")),
2200 data: serde_json::json!({"host": self.host, "port": port}),
2201 ..Default::default()
2202 });
2203 }
2204
2205 let body = match mongo_body(&buf[..n]) {
2206 Some(b) => b,
2207 None => {
2208 return Ok(ModuleResult {
2209 success: true,
2210 finding: Some(format!("MongoDB on tcp/{port}: unparseable response")),
2211 data: serde_json::json!({"host": self.host, "port": port, "hex": hex_encode(&buf[..n.min(128)])}),
2212 ..Default::default()
2213 })
2214 }
2215 };
2216
2217 let is_master = bson_find_string(body, "ismaster").unwrap_or_default();
2218 let version = bson_find_string(body, "version").unwrap_or_default();
2219 let ok = bson_find_int32(body, "ok").unwrap_or(0);
2220 let max_bson = bson_find_int32(body, "maxBsonObjectSize").unwrap_or(0);
2221 let wire_ver = bson_find_int32(body, "minWireVersion")
2222 .map(|v| format!("{v}"))
2223 .or_else(|| bson_find_int32(body, "maxWireVersion").map(|v| format!("{v}")))
2224 .unwrap_or_default();
2225
2226 let mut findings = vec![];
2227 let mut evidence = vec![];
2228
2229 if ok == 1 {
2230 findings.push("MongoDB accessible (no auth triggered)".into());
2231 evidence.push("mongo/no_auth".into());
2232 }
2233 if !version.is_empty() {
2234 findings.push(format!("version: {version}"));
2235 evidence.push(format!("mongo/version:{version}"));
2236 }
2237 if !is_master.is_empty() {
2238 findings.push(format!("ismaster: {is_master}"));
2239 }
2240 if max_bson > 0 {
2241 evidence.push(format!("mongo/maxBsonSize:{max_bson}"));
2242 }
2243
2244 Ok(ModuleResult {
2245 success: true,
2246 finding: Some(format!("MongoDB on tcp/{port}: {}", findings.join("; "))),
2247 evidence,
2248 data: serde_json::json!({
2249 "host": self.host, "port": port,
2250 "version": version, "is_master": is_master,
2251 "ok": ok, "max_bson_size": max_bson,
2252 "wire_version": wire_ver,
2253 }),
2254 ..Default::default()
2255 })
2256 }
2257}
2258
2259#[module(
2260 name = "es_scanner",
2261 kind = "Scanner",
2262 description = "Elasticsearch service scanner - cluster info, version, and open access detection",
2263 author = "ICEBOX"
2264)]
2265pub struct EsScanner {
2266 #[option(required = true, help = "Target IP or hostname")]
2267 pub host: String,
2268 #[option(help = "Elasticsearch port (default 9200)")]
2269 pub port: u16,
2270 #[option(help = "Timeout in milliseconds (default 5000)")]
2271 pub timeout_ms: u64,
2272 #[option(help = "Check cluster health in addition to root endpoint (default true)")]
2273 pub check_health: bool,
2274}
2275
2276async fn http_get_line(stream: &tokio::net::TcpStream, timeout: std::time::Duration) -> Vec<u8> {
2277 let mut all_data = Vec::new();
2278 let mut buf = vec![0u8; 8192];
2279 loop {
2280 let n = match tokio::time::timeout(timeout, stream.readable()).await {
2281 Ok(Ok(_)) => match stream.try_read(&mut buf) {
2282 Ok(n) => n,
2283 Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => 0,
2284 Err(_) => break,
2285 },
2286 _ => break,
2287 };
2288 if n == 0 {
2289 break;
2290 }
2291 all_data.extend_from_slice(&buf[..n]);
2292 if all_data.len() > 65536 {
2293 break;
2294 }
2295 }
2296 all_data
2297}
2298
2299fn http_extract_body(raw: &[u8]) -> Option<&[u8]> {
2300 let text = String::from_utf8_lossy(raw);
2301 if let Some(pos) = text.find("\r\n\r\n") {
2302 let body_start = pos + 4;
2303 Some(&raw[body_start..])
2304 } else if let Some(pos) = text.find("\n\n") {
2305 let body_start = pos + 2;
2306 Some(&raw[body_start..])
2307 } else {
2308 None
2309 }
2310}
2311
2312async fn es_http_request(
2313 host: &str,
2314 port: u16,
2315 path: &str,
2316 timeout: std::time::Duration,
2317) -> Result<Vec<u8>, ModuleError> {
2318 let addr = crate::core::proxy::resolve_dial(host, port);
2319 let stream = match tokio::time::timeout(timeout, tokio::net::TcpStream::connect(&addr)).await {
2320 Ok(Ok(s)) => s,
2321 Ok(Err(e)) => return Err(ModuleError::Other(format!("connect: {e}"))),
2322 Err(_) => return Err(ModuleError::Other("connect timeout".into())),
2323 };
2324 let request = format!(
2325 "GET {path} HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\nUser-Agent: ICEBOX/1.0\r\nAccept: application/json\r\n\r\n"
2326 );
2327 if tokio::time::timeout(timeout, stream.writable())
2328 .await
2329 .is_err()
2330 {
2331 return Err(ModuleError::Other("write timeout".into()));
2332 }
2333 let _ = stream.try_write(request.as_bytes());
2334 Ok(http_get_line(&stream, timeout).await)
2335}
2336
2337fn es_extract_json_string(body: &[u8], field: &str) -> Option<String> {
2338 let text = String::from_utf8_lossy(body);
2339 let search = format!("\"{field}\":\"");
2340 if let Some(start) = text.find(&search) {
2341 let val_start = start + search.len();
2342 if let Some(end) = text[val_start..].find('\"') {
2343 return Some(text[val_start..val_start + end].to_string());
2344 }
2345 }
2346 None
2347}
2348
2349#[async_trait]
2350impl Module for EsScanner {
2351 fn options_json(&self) -> serde_json::Value {
2352 serde_json::to_value(&EsScannerOptions {
2353 host: self.host.clone(),
2354 port: self.port,
2355 timeout_ms: self.timeout_ms,
2356 check_health: self.check_health,
2357 })
2358 .unwrap_or(serde_json::Value::Null)
2359 }
2360
2361 fn set_option(&mut self, name: &str, value: &str) -> Result<(), ModuleError> {
2362 let mut o = EsScannerOptions {
2363 host: self.host.clone(),
2364 port: self.port,
2365 timeout_ms: self.timeout_ms,
2366 check_health: self.check_health,
2367 };
2368 o.set(name, value)?;
2369 self.host = o.host;
2370 self.port = o.port;
2371 self.timeout_ms = o.timeout_ms;
2372 self.check_health = o.check_health;
2373 Ok(())
2374 }
2375
2376 fn validate(&self) -> Result<(), ModuleError> {
2377 EsScannerOptions {
2378 host: self.host.clone(),
2379 port: self.port,
2380 timeout_ms: self.timeout_ms,
2381 check_health: self.check_health,
2382 }
2383 .validate()
2384 }
2385
2386 async fn run(&self) -> Result<ModuleResult, ModuleError> {
2387 let port = if self.port > 0 { self.port } else { 9200 };
2388 let timeout_ms = if self.timeout_ms > 0 {
2389 self.timeout_ms
2390 } else {
2391 5000
2392 };
2393 let timeout = std::time::Duration::from_millis(timeout_ms);
2394
2395 let raw = es_http_request(&self.host, port, "/", timeout).await?;
2396 let body = http_extract_body(&raw).unwrap_or(&raw);
2397 let text = String::from_utf8_lossy(body);
2398
2399 let mut findings = Vec::new();
2400 let mut evidence = Vec::new();
2401
2402 let cluster_name = es_extract_json_string(body, "cluster_name");
2403 if let Some(ref cn) = cluster_name {
2404 findings.push(format!("cluster: {cn}"));
2405 evidence.push(format!("es/cluster:{cn}"));
2406 }
2407
2408 let version = es_extract_json_string(body, "number");
2409 if let Some(ref v) = version {
2410 findings.push(format!("version: {v}"));
2411 evidence.push(format!("es/version:{v}"));
2412 }
2413
2414 let tagline = es_extract_json_string(body, "tagline");
2415 if let Some(ref t) = tagline {
2416 evidence.push(format!("es/tagline:{t}"));
2417 }
2418
2419 let mut health_status = String::new();
2420 if self.check_health {
2421 if let Ok(health_raw) =
2422 es_http_request(&self.host, port, "/_cluster/health", timeout).await
2423 {
2424 if let Some(health_body) = http_extract_body(&health_raw) {
2425 if let Some(status) = es_extract_json_string(health_body, "status") {
2426 health_status = status.clone();
2427 findings.push(format!("health: {status}"));
2428 evidence.push(format!("es/health:{status}"));
2429 }
2430 }
2431 }
2432 }
2433
2434 let finding = if findings.is_empty() {
2435 format!("ES on tcp/{port}: service detected (unrecognized response)")
2436 } else {
2437 format!("ES on tcp/{port}: {}", findings.join("; "))
2438 };
2439
2440 Ok(ModuleResult {
2441 success: true,
2442 finding: Some(finding),
2443 evidence,
2444 data: serde_json::json!({
2445 "host": self.host, "port": port,
2446 "cluster_name": cluster_name,
2447 "version": version,
2448 "health_status": health_status,
2449 "raw": text.chars().take(2000).collect::<String>(),
2450 }),
2451 ..Default::default()
2452 })
2453 }
2454}
2455
2456fn pg_startup_message(user: &str) -> Vec<u8> {
2457 let user_param = format!("\x00user\x00{user}\x00");
2458 let body = format!("\x00\x03\x00\x00{user_param}\x00");
2459 let len = (4 + body.len()) as u32;
2460 let mut pkt = Vec::new();
2461 pkt.extend_from_slice(&len.to_le_bytes());
2462 pkt.extend_from_slice(body.as_bytes());
2463 pkt
2464}
2465
2466fn pg_md5_auth(user: &str, password: &str, salt: &[u8]) -> String {
2467 let inner = md5::compute(format!("{password}{user}").as_bytes());
2468 let mut combined = format!("{inner:x}").into_bytes();
2469 combined.extend_from_slice(salt);
2470 let outer = md5::compute(&combined);
2471 format!("md5{outer:x}")
2472}
2473
2474const PG_DEFAULT_CREDS: &[(&str, &str)] = &[
2475 ("postgres", "postgres"),
2476 ("postgres", "admin"),
2477 ("postgres", "password"),
2478 ("postgres", ""),
2479 ("admin", "admin"),
2480 ("admin", "password"),
2481 ("root", "root"),
2482 ("root", "admin"),
2483 ("root", "password"),
2484];
2485
2486#[module(
2487 name = "postgres_scanner",
2488 kind = "Scanner",
2489 description = "PostgreSQL service scanner - version detection and default credential check",
2490 author = "ICEBOX"
2491)]
2492pub struct PostgresScanner {
2493 #[option(required = true, help = "Target IP or hostname")]
2494 pub host: String,
2495 #[option(help = "PostgreSQL port (default 5432)")]
2496 pub port: u16,
2497 #[option(help = "Timeout in milliseconds (default 5000)")]
2498 pub timeout_ms: u64,
2499 #[option(help = "Check default credentials (default true)")]
2500 pub check_defaults: bool,
2501}
2502
2503fn pg_auth_type_name(t: i32) -> &'static str {
2504 match t {
2505 0 => "OK",
2506 2 => "KerberosV5",
2507 3 => "CleartextPassword",
2508 5 => "MD5Password",
2509 6 => "SCMCredential",
2510 7 => "GSS",
2511 9 => "SSPI",
2512 10 => "SASL",
2513 11 => "SASLContinue",
2514 12 => "SASLFinal",
2515 _ => "Unknown",
2516 }
2517}
2518
2519#[async_trait]
2520impl Module for PostgresScanner {
2521 fn options_json(&self) -> serde_json::Value {
2522 serde_json::to_value(&PostgresScannerOptions {
2523 host: self.host.clone(),
2524 port: self.port,
2525 timeout_ms: self.timeout_ms,
2526 check_defaults: self.check_defaults,
2527 })
2528 .unwrap_or(serde_json::Value::Null)
2529 }
2530
2531 fn set_option(&mut self, name: &str, value: &str) -> Result<(), ModuleError> {
2532 let mut o = PostgresScannerOptions {
2533 host: self.host.clone(),
2534 port: self.port,
2535 timeout_ms: self.timeout_ms,
2536 check_defaults: self.check_defaults,
2537 };
2538 o.set(name, value)?;
2539 self.host = o.host;
2540 self.port = o.port;
2541 self.timeout_ms = o.timeout_ms;
2542 self.check_defaults = o.check_defaults;
2543 Ok(())
2544 }
2545
2546 fn validate(&self) -> Result<(), ModuleError> {
2547 PostgresScannerOptions {
2548 host: self.host.clone(),
2549 port: self.port,
2550 timeout_ms: self.timeout_ms,
2551 check_defaults: self.check_defaults,
2552 }
2553 .validate()
2554 }
2555
2556 async fn run(&self) -> Result<ModuleResult, ModuleError> {
2557 let port = if self.port > 0 { self.port } else { 5432 };
2558 let timeout_ms = if self.timeout_ms > 0 {
2559 self.timeout_ms
2560 } else {
2561 5000
2562 };
2563 let timeout = std::time::Duration::from_millis(timeout_ms);
2564 let addr = crate::core::proxy::resolve_dial(&self.host, port);
2565
2566 async fn pg_read_response(
2567 stream: &tokio::net::TcpStream,
2568 timeout: std::time::Duration,
2569 ) -> Result<Vec<u8>, ModuleError> {
2570 let mut buf = vec![0u8; 8192];
2571 let n = match tokio::time::timeout(timeout, stream.readable()).await {
2572 Ok(Ok(_)) => stream.try_read(&mut buf).unwrap_or(0),
2573 _ => return Err(ModuleError::Other("read timeout".into())),
2574 };
2575 Ok(buf[..n].to_vec())
2576 }
2577
2578 let creds_to_try: Vec<(&str, &str)> = if self.check_defaults {
2579 PG_DEFAULT_CREDS.to_vec()
2580 } else {
2581 Vec::new()
2582 };
2583
2584 let mut found_creds: Vec<(String, String)> = Vec::new();
2585 let mut auth_type = String::new();
2586 let mut server_version = String::new();
2587
2588 for (user, pass) in &creds_to_try {
2589 let stream =
2590 match tokio::time::timeout(timeout, tokio::net::TcpStream::connect(&addr)).await {
2591 Ok(Ok(s)) => s,
2592 _ => continue,
2593 };
2594 let startup = pg_startup_message(user);
2595 if tokio::time::timeout(timeout, stream.writable())
2596 .await
2597 .is_err()
2598 {
2599 continue;
2600 }
2601 let _ = stream.try_write(&startup);
2602
2603 let response = match pg_read_response(&stream, timeout).await {
2604 Ok(r) => r,
2605 Err(_) => continue,
2606 };
2607
2608 if response.is_empty() || response[0] == b'E' {
2609 continue;
2610 }
2611
2612 if response[0] == b'R' {
2613 if response.len() < 9 {
2614 continue;
2615 }
2616 let auth_t =
2617 i32::from_be_bytes([response[5], response[6], response[7], response[8]]);
2618 let auth_name = pg_auth_type_name(auth_t);
2619 if server_version.is_empty() {
2620 auth_type = auth_name.to_string();
2621 }
2622
2623 if auth_t == 0 {
2624 found_creds.push((user.to_string(), pass.to_string()));
2625 tokio::time::timeout(timeout, stream.readable()).await.ok();
2626 let mut buf2 = vec![0u8; 4096];
2627 if let Ok(Ok(_)) = tokio::time::timeout(timeout, stream.readable()).await {
2628 let n = stream.try_read(&mut buf2).unwrap_or(0);
2629 if n > 0 {
2630 let text = String::from_utf8_lossy(&buf2[..n.min(4000)]);
2631 for line in text.split('\x00') {
2632 if line.starts_with("server_version") {
2633 server_version = line
2634 .trim_start_matches("server_version")
2635 .trim_start_matches('\x00')
2636 .to_string();
2637 }
2638 }
2639 }
2640 }
2641 } else if auth_t == 3 {
2642 let pass_pkt = format!("p{}\x00", pass);
2643 let len = (4 + pass_pkt.len()) as u32;
2644 let mut auth_msg = Vec::new();
2645 auth_msg.extend_from_slice(&len.to_le_bytes());
2646 auth_msg.extend_from_slice(pass_pkt.as_bytes());
2647 if tokio::time::timeout(timeout, stream.writable())
2648 .await
2649 .is_ok()
2650 {
2651 let _ = stream.try_write(&auth_msg);
2652 }
2653 let resp2 = pg_read_response(&stream, timeout).await.unwrap_or_default();
2654 if resp2.len() > 5 && resp2[0] == b'R' {
2655 let auth_t2 = i32::from_be_bytes([resp2[5], resp2[6], resp2[7], resp2[8]]);
2656 if auth_t2 == 0 {
2657 found_creds.push((user.to_string(), pass.to_string()));
2658 }
2659 }
2660 } else if auth_t == 5 {
2661 let salt = &response[9..13];
2662 let md5_hash = pg_md5_auth(user, pass, salt);
2663 let pass_bytes = md5_hash.as_bytes();
2664 let len = (4 + pass_bytes.len() + 1) as u32;
2665 let mut auth_msg = Vec::new();
2666 auth_msg.extend_from_slice(&len.to_le_bytes());
2667 auth_msg.extend_from_slice(pass_bytes);
2668 auth_msg.push(0x00);
2669 if tokio::time::timeout(timeout, stream.writable())
2670 .await
2671 .is_ok()
2672 {
2673 let _ = stream.try_write(&auth_msg);
2674 }
2675 let resp2 = pg_read_response(&stream, timeout).await.unwrap_or_default();
2676 if resp2.len() > 5 && resp2[0] == b'R' {
2677 let auth_t2 = i32::from_be_bytes([resp2[5], resp2[6], resp2[7], resp2[8]]);
2678 if auth_t2 == 0 {
2679 found_creds.push((user.to_string(), pass.to_string()));
2680 }
2681 }
2682 }
2683 } else if response[0] == b'N' || response[0] == b'S' {
2684 }
2685 }
2686
2687 let evidence: Vec<String> = found_creds
2688 .iter()
2689 .map(|(u, p)| format!("postgres/cred:{u}:{p}"))
2690 .collect();
2691
2692 let finding = if !found_creds.is_empty() {
2693 let labels: Vec<String> = found_creds
2694 .iter()
2695 .map(|(u, p)| format!("{u}:{p}"))
2696 .collect();
2697 format!(
2698 "PostgreSQL on tcp/{port}: FOUND {} credential(s): {}",
2699 found_creds.len(),
2700 labels.join(", ")
2701 )
2702 } else {
2703 format!("PostgreSQL on tcp/{port}: detected (auth={auth_type}, version={server_version}), no valid creds found", )
2704 };
2705
2706 Ok(ModuleResult {
2707 success: !found_creds.is_empty(),
2708 finding: Some(finding),
2709 evidence,
2710 data: serde_json::json!({
2711 "host": self.host, "port": port,
2712 "version": server_version,
2713 "auth_type": auth_type,
2714 "found_credentials": found_creds,
2715 "tried_count": creds_to_try.len(),
2716 }),
2717 ..Default::default()
2718 })
2719 }
2720}
2721
2722#[module(
2723 name = "smtp_scanner",
2724 kind = "Scanner",
2725 description = "SMTP service scanner - banner grabbing, open relay check, and user enumeration",
2726 author = "ICEBOX"
2727)]
2728pub struct SmtpScanner {
2729 #[option(required = true, help = "Target IP or hostname")]
2730 pub host: String,
2731 #[option(help = "SMTP port (default 25)")]
2732 pub port: u16,
2733 #[option(help = "Timeout in milliseconds (default 5000)")]
2734 pub timeout_ms: u64,
2735 #[option(help = "Check open relay (default true)")]
2736 pub check_relay: bool,
2737 #[option(help = "Check VRFY user enumeration (default true)")]
2738 pub check_vrfy: bool,
2739}
2740
2741async fn smtp_read(stream: &tokio::net::TcpStream, timeout: std::time::Duration) -> String {
2742 let mut all = Vec::new();
2743 let mut buf = vec![0u8; 4096];
2744 for _ in 0..3 {
2745 let n = match tokio::time::timeout(timeout, stream.readable()).await {
2746 Ok(Ok(_)) => match stream.try_read(&mut buf) {
2747 Ok(n) => n,
2748 Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => 0,
2749 Err(_) => break,
2750 },
2751 _ => break,
2752 };
2753 if n == 0 {
2754 break;
2755 }
2756 all.extend_from_slice(&buf[..n]);
2757 if all.len() > 8192 {
2758 break;
2759 }
2760 }
2761 String::from_utf8_lossy(&all).to_string()
2762}
2763
2764async fn smtp_write(
2765 stream: &tokio::net::TcpStream,
2766 cmd: &str,
2767 timeout: std::time::Duration,
2768) -> Result<(), ModuleError> {
2769 match tokio::time::timeout(timeout, stream.writable()).await {
2770 Ok(Ok(_)) => stream
2771 .try_write(cmd.as_bytes())
2772 .map_err(|e| ModuleError::Other(format!("write: {e}")))
2773 .map(|_| ()),
2774 _ => Err(ModuleError::Other("write timeout".into())),
2775 }
2776}
2777
2778#[async_trait]
2779impl Module for SmtpScanner {
2780 fn options_json(&self) -> serde_json::Value {
2781 serde_json::to_value(&SmtpScannerOptions {
2782 host: self.host.clone(),
2783 port: self.port,
2784 timeout_ms: self.timeout_ms,
2785 check_relay: self.check_relay,
2786 check_vrfy: self.check_vrfy,
2787 })
2788 .unwrap_or(serde_json::Value::Null)
2789 }
2790
2791 fn set_option(&mut self, name: &str, value: &str) -> Result<(), ModuleError> {
2792 let mut o = SmtpScannerOptions {
2793 host: self.host.clone(),
2794 port: self.port,
2795 timeout_ms: self.timeout_ms,
2796 check_relay: self.check_relay,
2797 check_vrfy: self.check_vrfy,
2798 };
2799 o.set(name, value)?;
2800 self.host = o.host;
2801 self.port = o.port;
2802 self.timeout_ms = o.timeout_ms;
2803 self.check_relay = o.check_relay;
2804 self.check_vrfy = o.check_vrfy;
2805 Ok(())
2806 }
2807
2808 fn validate(&self) -> Result<(), ModuleError> {
2809 SmtpScannerOptions {
2810 host: self.host.clone(),
2811 port: self.port,
2812 timeout_ms: self.timeout_ms,
2813 check_relay: self.check_relay,
2814 check_vrfy: self.check_vrfy,
2815 }
2816 .validate()
2817 }
2818
2819 async fn run(&self) -> Result<ModuleResult, ModuleError> {
2820 let port = if self.port > 0 { self.port } else { 25 };
2821 let timeout_ms = if self.timeout_ms > 0 {
2822 self.timeout_ms
2823 } else {
2824 5000
2825 };
2826 let timeout = std::time::Duration::from_millis(timeout_ms);
2827 let addr = crate::core::proxy::resolve_dial(&self.host, port);
2828
2829 let stream =
2830 match tokio::time::timeout(timeout, tokio::net::TcpStream::connect(&addr)).await {
2831 Ok(Ok(s)) => s,
2832 Ok(Err(e)) => return Err(ModuleError::Other(format!("connection failed: {e}"))),
2833 Err(_) => return Err(ModuleError::Other("connection timed out".into())),
2834 };
2835
2836 let banner = smtp_read(&stream, timeout).await;
2837 let banner_line = banner.lines().next().unwrap_or("").trim().to_string();
2838
2839 let mut findings = Vec::new();
2840 let mut evidence = Vec::new();
2841 findings.push(format!("banner: {banner_line}"));
2842 evidence.push(format!("smtp/banner:{banner_line}"));
2843
2844 smtp_write(&stream, "EHLO icebox\r\n", timeout).await.ok();
2845 let ehlo_resp = smtp_read(&stream, timeout).await;
2846 let ehlo_lines: Vec<&str> = ehlo_resp.lines().collect();
2847 let ehlo_ok = ehlo_lines
2848 .first()
2849 .map(|l| l.starts_with("250"))
2850 .unwrap_or(false);
2851
2852 if ehlo_ok {
2853 let caps: Vec<String> = ehlo_lines
2854 .iter()
2855 .filter_map(|l| {
2856 let l = l.strip_prefix("250-").or_else(|| l.strip_prefix("250 "))?;
2857 Some(l.trim().to_string())
2858 })
2859 .collect();
2860 if !caps.is_empty() {
2861 findings.push(format!("capabilities: {}", caps.join(", ")));
2862 }
2863
2864 if caps.iter().any(|c| c.to_uppercase().contains("AUTH")) {
2865 evidence.push("smtp/auth_available".into());
2866 }
2867
2868 if self.check_relay {
2869 smtp_write(&stream, "MAIL FROM:<test@icebox.local>\r\n", timeout)
2870 .await
2871 .ok();
2872 let mfrom = smtp_read(&stream, timeout).await;
2873 if mfrom.starts_with("250") {
2874 smtp_write(&stream, "RCPT TO:<test@example.com>\r\n", timeout)
2875 .await
2876 .ok();
2877 let rcpt = smtp_read(&stream, timeout).await;
2878 if rcpt.starts_with("250") {
2879 findings.push("open relay: YES (RCPT TO succeeded)".into());
2880 evidence.push("smtp/open_relay".into());
2881 } else {
2882 findings.push("open relay: NO (relay denied)".into());
2883 }
2884 }
2885 }
2886
2887 if self.check_vrfy {
2888 smtp_write(&stream, "VRFY root\r\n", timeout).await.ok();
2889 let vrfy = smtp_read(&stream, timeout).await;
2890 if vrfy.starts_with("250") || vrfy.starts_with("252") {
2891 findings.push("user enumeration via VRFY: possible".into());
2892 evidence.push("smtp/vrfy_enabled".into());
2893 }
2894
2895 smtp_write(&stream, "EXPN postmaster\r\n", timeout)
2896 .await
2897 .ok();
2898 let expn = smtp_read(&stream, timeout).await;
2899 if expn.starts_with("250") {
2900 findings.push("user enumeration via EXPN: possible".into());
2901 evidence.push("smtp/expn_enabled".into());
2902 }
2903 }
2904 } else {
2905 findings.push("EHLO not recognized".into());
2906 }
2907
2908 let _ = smtp_write(&stream, "QUIT\r\n", timeout).await;
2909
2910 Ok(ModuleResult {
2911 success: true,
2912 finding: Some(format!("SMTP on tcp/{port}: {}", findings.join("; "))),
2913 evidence,
2914 data: serde_json::json!({
2915 "host": self.host, "port": port,
2916 "banner": banner_line,
2917 "findings": findings,
2918 }),
2919 ..Default::default()
2920 })
2921 }
2922}