diff --git a/README.md b/README.md index 1b025a7..1c15c4c 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ A Rust-based MQTT penetration testing tool for the command line. - **Publish** - send a payload to a topic - **Fingerprinting** - allows to identify the MQTT broker - **Check ACL** - probe subscribe/publish permissions for one topic or a wordlist +- **Anonymous probe** - single-shot check for unauthenticated access and what topics it can reach - **TLS** - broker verification with system or custom CA - **mTLS** - mutual TLS with client certificate and private key - **JWT authentication** - send a JWT token as the MQTT password @@ -239,6 +240,63 @@ $ mqattack check-acl --cafile ca.crt -H broker.example.com -p 8883 -w topics.txt $ mqattack check-acl -H 192.168.1.10 -t 'sensor/temp' --payload 'test' --wait 10 ``` +### Probe anonymous access + +Check in a single shot whether the broker accepts unauthenticated connections. +If it does, optionally probe subscribe and publish permissions on a topic or +wordlist — identical ACL checks to `check-acl` but always without credentials. +Any `-u`/`-P`/`--jwt` flags are silently ignored with a warning. + +```bash +$ mqattack probe-anon [OPTIONS] + + -t, --topic Single topic to probe (mutually exclusive with --wordlist) + -w, --wordlist File with one topic per line (mutually exclusive with --topic) + --no-subscribe Skip subscribe permission tests + --no-publish Skip publish permission tests + --payload Payload used for publish probes [default: mqattack] + -q, --qos QoS for publish probes (1 or 2) [default: 1] + --wait Seconds to wait for a broker response per test [default: 5] +``` + +```bash +# Quick check — does the broker allow anonymous connections at all? +$ mqattack probe-anon -H 192.168.1.10 + +# Check anonymous connectivity and probe a single topic +$ mqattack probe-anon -H 192.168.1.10 -t '#' + +# Check against a full topic wordlist +$ mqattack probe-anon -H 192.168.1.10 -w topics.txt + +# Subscribe-only probe over TLS +$ mqattack probe-anon --cafile ca.crt -H broker.example.com -p 8883 \ + -w topics.txt --no-publish +``` + +Example output: + +```text +[*] Target : 192.168.1.10:1883 +[+] Anonymous access ALLOWED + +[*] 3 topic(s) | subscribe=true | publish=true + + home/lights/1 sub=ALLOWED pub=ALLOWED + home/alarm/status sub=ALLOWED pub=DENIED + $SYS/broker/version sub=INCONCLUSIVE pub=DENIED + +[+] Subscribe : 2 allowed, 0 denied, 1 inconclusive +[+] Publish : 1 allowed, 2 denied, 0 inconclusive + +[+] Topics with subscribe ALLOWED: + home/lights/1 + home/alarm/status + +[+] Topics with publish ALLOWED: + home/lights/1 +``` + ### Enumerate topics ```bash diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 3de05d1..83ba4fc 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -4,6 +4,7 @@ pub mod enum_perms; pub mod enum_topics; pub mod fingerprint; pub mod payload_inject; +pub mod probe_anon; pub mod publish; pub mod subscribe; pub mod sys_info; diff --git a/src/commands/probe_anon.rs b/src/commands/probe_anon.rs new file mode 100644 index 0000000..2b8a685 --- /dev/null +++ b/src/commands/probe_anon.rs @@ -0,0 +1,214 @@ +use crate::commands::check_acl::{test_publish, test_subscribe, AclStatus}; +use crate::mqtt::client::{build_options, parse_qos, ConnectionArgs}; +use anyhow::{bail, Context, Result}; +use clap::Args; +use rumqttc::{AsyncClient, Event, Packet}; +use std::fs::File; +use std::io::{BufRead, BufReader}; +use tokio::time::{timeout, Duration}; + +/// Arguments for the probe-anon command. +#[derive(Args, Debug)] +pub struct ProbeAnonArgs { + #[command(flatten)] + pub connection: ConnectionArgs, + + /// Single topic to probe ACLs on (mutually exclusive with --wordlist) + #[arg(short = 't', long, conflicts_with = "wordlist")] + pub topic: Option, + + /// File with one topic per line to probe ACLs on (mutually exclusive with --topic) + #[arg(short = 'w', long, conflicts_with = "topic")] + pub wordlist: Option, + + /// Skip subscribe permission tests + #[arg(long)] + pub no_subscribe: bool, + + /// Skip publish permission tests + #[arg(long)] + pub no_publish: bool, + + /// Payload used for publish probes + #[arg(long, default_value = "mqattack")] + pub payload: String, + + /// QoS for publish probes (1 or 2 gives broker acknowledgement; 0 is always inconclusive) + #[arg(short = 'q', long, default_value_t = 1)] + pub qos: u8, + + /// Seconds to wait for a broker response per test + #[arg(long, default_value_t = 5)] + pub wait: u64, +} + +/// Run the probe-anon command to test anonymous access and ACLs on specified topics. +pub async fn run(args: ProbeAnonArgs) -> Result<()> { + if args.no_subscribe && args.no_publish { + bail!("--no-subscribe and --no-publish cannot both be set"); + } + + let creds_provided = args.connection.username.is_some() + || args.connection.password.is_some() + || args.connection.jwt.is_some(); + if creds_provided { + eprintln!("[!] Credentials ignored. probe-anon always tests unauthenticated access"); + } + + let anon_conn = ConnectionArgs { + username: None, + password: None, + jwt: None, + ..args.connection.clone() + }; + + eprintln!("[*] Target : {}:{}", anon_conn.host, anon_conn.port); + + if !check_anonymous_connect(&anon_conn, args.wait).await { + println!("[-] Anonymous access DENIED. Broker requires authentication"); + return Ok(()); + } + + println!("[+] Anonymous access ALLOWED"); + + if args.topic.is_none() && args.wordlist.is_none() { + eprintln!("[*] No topics specified. Use --topic or --wordlist to probe ACLs."); + return Ok(()); + } + + let topics = load_topics(&args)?; + if topics.is_empty() { + bail!("no topics to test"); + } + + let qos = parse_qos(args.qos)?; + let payload = args.payload.as_bytes().to_vec(); + let check_sub = !args.no_subscribe; + let check_pub = !args.no_publish; + + eprintln!( + "\n[*] {} topic(s) | subscribe={} | publish={}\n", + topics.len(), + check_sub, + check_pub + ); + + let mut results: Vec<(String, AclStatus, AclStatus)> = Vec::new(); + + for topic in &topics { + let sub_status = if check_sub { + test_subscribe(&anon_conn, topic, args.wait).await + } else { + AclStatus::Skipped + }; + + let pub_status = if check_pub { + test_publish(&anon_conn, topic, &payload, qos, args.wait).await + } else { + AclStatus::Skipped + }; + + eprintln!( + " {:<50} sub={:<13} pub={}", + topic, + sub_status.to_string(), + pub_status + ); + + results.push((topic.clone(), sub_status, pub_status)); + } + + let allowed_sub = results + .iter() + .filter(|(_, s, _)| *s == AclStatus::Allowed) + .count(); + let denied_sub = results + .iter() + .filter(|(_, s, _)| *s == AclStatus::Denied) + .count(); + let allowed_pub = results + .iter() + .filter(|(_, _, p)| *p == AclStatus::Allowed) + .count(); + let denied_pub = results + .iter() + .filter(|(_, _, p)| *p == AclStatus::Denied) + .count(); + + println!(); + if check_sub { + println!( + "[+] Subscribe : {} allowed, {} denied, {} inconclusive", + allowed_sub, + denied_sub, + results.len() - allowed_sub - denied_sub + ); + } + if check_pub { + println!( + "[+] Publish : {} allowed, {} denied, {} inconclusive", + allowed_pub, + denied_pub, + results.len() - allowed_pub - denied_pub + ); + } + + let sub_allowed: Vec<_> = results + .iter() + .filter(|(_, s, _)| *s == AclStatus::Allowed) + .collect(); + let pub_allowed: Vec<_> = results + .iter() + .filter(|(_, _, p)| *p == AclStatus::Allowed) + .collect(); + + if check_sub && !sub_allowed.is_empty() { + println!("\n[+] Topics with subscribe ALLOWED:"); + for (t, _, _) in &sub_allowed { + println!(" {}", t); + } + } + if check_pub && !pub_allowed.is_empty() { + println!("\n[+] Topics with publish ALLOWED:"); + for (t, _, _) in &pub_allowed { + println!(" {}", t); + } + } + + Ok(()) +} + +/// Check if the broker allows anonymous connections within the specified wait time. +async fn check_anonymous_connect(conn: &ConnectionArgs, wait_secs: u64) -> bool { + let opts = match build_options(conn) { + Ok(o) => o, + Err(_) => return false, + }; + let (_client, mut eventloop) = AsyncClient::new(opts, 16); + + timeout(Duration::from_secs(wait_secs), async move { + loop { + match eventloop.poll().await { + Ok(Event::Incoming(Packet::ConnAck(_))) => return true, + Ok(_) => {} + Err(_) => return false, + } + } + }) + .await + .unwrap_or(false) +} + +/// Load topics from the command arguments or a wordlist file. +fn load_topics(args: &ProbeAnonArgs) -> Result> { + if let Some(t) = &args.topic { + return Ok(vec![t.clone()]); + } + let path = args.wordlist.as_deref().unwrap(); + let file = File::open(path).with_context(|| format!("cannot open wordlist '{}'", path))?; + let lines: std::io::Result> = BufReader::new(file) + .lines() + .filter(|l| l.as_ref().map(|s| !s.trim().is_empty()).unwrap_or(true)) + .collect(); + lines.with_context(|| format!("error reading wordlist '{}'", path)) +} diff --git a/src/main.rs b/src/main.rs index d379e2b..1dbcf4e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -34,6 +34,8 @@ enum Commands { PayloadInject(commands::payload_inject::PayloadInjectArgs), /// Register a crafted Last Will and trigger it via ungraceful disconnect WillInject(commands::will_inject::WillInjectArgs), + /// Check whether the broker accepts anonymous connections and probe topic ACLs without credentials + ProbeAnon(commands::probe_anon::ProbeAnonArgs), } #[tokio::main] @@ -51,5 +53,6 @@ async fn main() -> anyhow::Result<()> { Commands::EnumPerms(args) => commands::enum_perms::run(args).await, Commands::PayloadInject(args) => commands::payload_inject::run(args).await, Commands::WillInject(args) => commands::will_inject::run(args).await, + Commands::ProbeAnon(args) => commands::probe_anon::run(args).await, } }