mirror of
https://github.com/CyberSecurityUP/NeuroSploit.git
synced 2026-09-30 21:19:49 +02:00
feat(net): fail-closed egress, self-hosted OOB channel, inbound SMS
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) <noreply@anthropic.com>
This commit is contained in:
1 parent
90b4614d94
commit
8aa776665a
12 files changed
+1901
-2
No files matched your search
@@ -49,6 +49,26 @@ struct Cli {
|
||||
/// Policy profile for an interactive session: web · ot.
|
||||
#[arg(long = "session-policy", global = true)]
|
||||
session_policy: Option<String>,
|
||||
/// 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<String>,
|
||||
/// 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<String>,
|
||||
/// Where the OOB HTTP listener binds (default 0.0.0.0:8080).
|
||||
#[arg(long = "oob-http", global = true)]
|
||||
oob_http: Option<String>,
|
||||
/// Where the OOB DNS listener binds, when the zone is delegated to us.
|
||||
#[arg(long = "oob-dns", global = true)]
|
||||
oob_dns: Option<String>,
|
||||
/// Inbound SMS: twilio:<sid>:<token>:<number> or webhook:<url>:<number>.
|
||||
#[arg(long = "sms", global = true)]
|
||||
sms: Option<String>,
|
||||
}
|
||||
|
||||
#[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::<std::net::SocketAddr>()
|
||||
.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<String> {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for v in vals {
|
||||
|
||||
@@ -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<String>,
|
||||
oob_domain: Option<String>,
|
||||
oob_http: Option<String>,
|
||||
oob_dns: Option<String>,
|
||||
sms: Option<String>,
|
||||
models: Vec<String>,
|
||||
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<String>,
|
||||
pub environment: Option<String>,
|
||||
pub policy: Option<String>,
|
||||
/// 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<String>,
|
||||
pub oob_domain: Option<String>,
|
||||
pub oob_http: Option<String>,
|
||||
pub oob_dns: Option<String>,
|
||||
pub sms: Option<String>,
|
||||
}
|
||||
|
||||
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<RunRecord>) {
|
||||
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(); }
|
||||
|
||||
Reference in new issue
Block a user