Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <TOPIC> Single topic to probe (mutually exclusive with --wordlist)
-w, --wordlist <FILE> File with one topic per line (mutually exclusive with --topic)
--no-subscribe Skip subscribe permission tests
--no-publish Skip publish permission tests
--payload <TEXT> Payload used for publish probes [default: mqattack]
-q, --qos <QOS> QoS for publish probes (1 or 2) [default: 1]
--wait <SECS> 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
Expand Down
1 change: 1 addition & 0 deletions src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
214 changes: 214 additions & 0 deletions src/commands/probe_anon.rs
Original file line number Diff line number Diff line change
@@ -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<String>,

/// File with one topic per line to probe ACLs on (mutually exclusive with --topic)
#[arg(short = 'w', long, conflicts_with = "topic")]
pub wordlist: Option<String>,

/// 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<Vec<String>> {
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<Vec<_>> = 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))
}
3 changes: 3 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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,
}
}
Loading