Introduction
iottack is a tool for working with IoT devices and their security. Use
this documentation to learn about its features and how to get started.
Plugins
Plugins extend iottack with individual test and enumeration capabilities.
Each plugin runs a self-contained check and reports zero or more findings with
a severity level and optional evidence string.
| Plugin | Command | Summary |
|---|---|---|
| platform | iottack platform | Report details about the host/platform environment |
| amqp - scan | iottack amqp scan | Detect an AMQP broker and test for default credentials |
| http - banner | iottack http banner | Checks HTTP banner and common settings |
| mqtt | - | Work with MQTT |
| network - mdns-scan | iottack network mdns-scan | Discover mDNS/DNS-SD services on the local network |
| network - telnet | iottack telnet | Connect to a Telnet service and detect banners, login prompts and default credentials |
| network - tcp-banner | iottack network tcp-banner | Connect to a TCP service and capture the initial banner, identifying the protocol and flagging cleartext services |
| uart - baud-scan | iottack uart baud-scan | Probe common UART baud rates to identify the active configuration |
| uart - port-scan | iottack uart port-scan | Discover serial ports available on this host |
| uart - fuzz | iottack uart fuzz | Send malformed and custom payloads to a UART interface and report anomalous responses |
| uart - shell-detect | iottack uart shell-detect | Detect interactive shell or login prompt on a UART interface |
| uart - terminal | iottack uart terminal | Open an interactive terminal session over a UART interface |
Global options
Every plugin accepts these flags in addition to its own arguments:
| Flag | Short | Description |
|---|---|---|
--verbose | -v | Increase log verbosity; repeat for more detail (-v, -vv, -vvv) |
--output <FILE> | -o | Save the result to a file |
--format <FORMAT> | -f | Output format for --output: text (default) or json |
platform
Report details about the host and platform environment.
Usage
iottack platform [OPTIONS]
Description
Collects and reports information about the host running iottack:
- Framework version
- OS name, version, and codename (from
/etc/os-release) - Kernel release (from
/proc/sys/kernel/osrelease) - CPU architecture
- Effective user ID - flags whether the process has root privileges
- Available serial ports (
/dev/ttyUSB*,/dev/ttyACM*, etc.) - USB-serial devices enumerated via sysfs, including vendor/product details
Options
| Flag | Short | Default | Description |
|---|---|---|---|
--output <FILE> | -o | - | Save result to a file |
--format <FORMAT> | -f | text | text or json |
--verbose | -v | - | Increase log verbosity |
Example
$ iottack platform
Plugin name: platform/platform
Execution time: 3ms
Status: pass
Date: 2026-10-17 00:00:00 UTC
Host environment
iottack release: 0.2.6
OS: NixOS 26.11 (zokor)
Kernel: 6.18.54
Architecture: x86_64
Running as unprivileged user (euid=1000)
Some serial ports or USB devices may require elevated privileges.
Run with sudo if access is denied.
Serial ports available (1)
/dev/ttyUSB0
USB-serial device: /dev/ttyUSB0
0403:6001 | FT232 USB-Serial (FTDI) | sysfs: /sys/devices/...
amqp - scan
Detect an AMQP broker and test for default credentials.
Usage
iottack amqp scan --host <HOST> [OPTIONS]
Description
Connects to the target AMQP port using the lapin client library and performs an AMQP 0-9-1 handshake. The outcome of the initial connection attempt distinguishes three cases:
- AMQP protocol error (e.g.,
ACCESS_REFUSED): a broker is running and rejected the probe credentials. The service is flagged as confirmed. - Successful connection: a broker is running and accepted the probe credentials.
- IO error or timeout: no broker is listening on that port.
When --try-creds is set, a list of common default credentials is probed in
sequence. Each pair opens a fresh TCP connection, performs the full handshake,
and closes cleanly on success.
Default credentials probed
| Username | Password |
|---|---|
guest | guest |
admin | admin |
admin | password |
admin | rabbitmq |
rabbitmq | rabbitmq |
user | user |
root | root |
admin | guest |
RabbitMQ ships with guest/guest as the built-in administrator
account. Modern versions restrict it to localhost connections by default,
but misconfigured or older deployments expose it on all interfaces.
Severity mapping
| Finding | Severity |
|---|---|
| AMQP broker detected | Info |
| Cleartext AMQP on port 5672 | High |
| Default credentials accepted | Critical |
| No default credentials accepted | Info |
| Management UI hint (port 15672) | Info |
Related services
| Port | Service |
|---|---|
| 5671 | AMQPS — TLS-encrypted AMQP |
| 5672 | AMQP — plaintext (this plugin) |
| 15672 | RabbitMQ management HTTP API and web UI |
Use the http/banner plugin to probe port 15672 for
the management interface.
Options
| Flag | Short | Default | Description |
|---|---|---|---|
--host <HOST> | -H | (required) | Target hostname or IP address |
--port <PORT> | -p | 5672 | AMQP port |
--vhost <VHOST> | -V | / | Virtual host to authenticate against |
--timeout-ms <MS> | -t | 3000 | Connection timeout (ms) |
--try-creds | - | off | Probe common default credentials |
--output <FILE> | -o | - | Save result to a file |
--format <FORMAT> | -f | text | text or json |
--verbose | -v | - | Increase log verbosity |
Examples
Detect the broker:
$ iottack amqp scan --host 192.168.1.100
[...]
[INFO] AMQP broker on 192.168.1.100:5672
An AMQP 0-9-1 broker is listening on 192.168.1.100:5672.
[HIGH] Cleartext AMQP on port 5672
AMQP 0-9-1 transmits credentials and all message payloads in plaintext.
Use AMQPS (port 5671) with TLS.
Check for default credentials:
$ iottack amqp scan --host 192.168.1.100 --try-creds
[...]
[CRITICAL] Default credentials accepted: guest/guest
The broker on 192.168.1.100:5672 accepted guest/guest for vhost '/'.
Change this password immediately.
Scan a non-default virtual host:
$ iottack amqp scan --host 192.168.1.100 --vhost /production --try-creds
Scan an AMQPS endpoint (port 5671 - no cleartext finding):
$ iottack amqp scan --host 192.168.1.100 --port 5671 --try-creds
Test environment
See AMQP server setup for instructions on running a local RabbitMQ instance for testing.
http-banner
Grab the HTTP banner and check response headers from a web server.
Usage
iottack http http-banner --host <HOST> [OPTIONS]
Description
Sends a GET request to the target and inspects the response for security
issues, version disclosure, and missing hardening headers.
Checks performed
| Check | Finding | Severity |
|---|---|---|
Server: header with version number | Version disclosure | Medium |
Server: header without version | Software identity | Info |
X-Powered-By: header present | Framework/runtime disclosure | Medium |
| Port 80 with no HTTPS redirect | Cleartext HTTP | High |
| Port 80 redirecting to HTTPS | HTTPS redirect in place | Info |
Missing Strict-Transport-Security (port 443) | No HSTS | Medium |
Missing X-Frame-Options / CSP frame-ancestors | No clickjacking protection | Low |
Missing X-Content-Type-Options | MIME sniffing not disabled | Low |
Missing Content-Security-Policy | No CSP | Low |
HTTP 401 with Basic scheme | Credentials over cleartext | High |
| HTTP 401 with other scheme | Auth required | Info |
| Default installation page in body | Server not customised | Medium |
Cookie missing Secure or HttpOnly flag | Insecure cookie | Medium |
Options
| Flag | Short | Default | Description |
|---|---|---|---|
--host <HOST> | -H | (required) | Target hostname or IP address |
--port <PORT> | -p | 80 | TCP port |
--path <PATH> | -u | / | Request path |
--timeout-ms <MS> | -t | 5000 | Connect and read timeout (ms) |
--output <FILE> | -o | - | Save result to a file |
--format <FORMAT> | -f | text | text or json |
--verbose | -v | - | Increase log verbosity |
Examples
Check a device web interface:
$ iottack http http-banner --host 192.168.1.1
[...]
HTTP 200 OK
Target: 192.168.1.1:80/
Server header: Apache/2.4.51 (Debian)
Server header discloses software version. Consider suppressing or generalizing this header.
Cleartext HTTP on port 80
Service responds to plain HTTP without redirecting to HTTPS.
Missing X-Frame-Options / CSP frame-ancestors
No clickjacking protection header found.
Missing X-Content-Type-Options
Missing Content-Security-Policy
Check a non-standard port:
$ iottack http http-banner --host 192.168.1.1 --port 8080 --path /admin
Check HTTPS (port 443) for HSTS and security headers:
$ iottack http http-banner --host 192.168.1.1 --port 443
http-sse
Consume Server-Sent Events (SSE / EventSource) from a device endpoint.
Usage
iottack http http-sse --host <HOST> [OPTIONS]
Description
Connects to an HTTP EventSource endpoint and keeps the connection open for a configurable duration, collecting Server-Sent Events as they arrive. The plugin analyses the stream for security issues including open access, cleartext transmission, CORS policy, event rate, event types and potentially sensitive data disclosed in event payloads.
The SSE/EventSource protocol is commonly used by IoT firmware to push sensor readings, device state, and log messages to browser-based dashboards over a persistent HTTP connection.
Checks performed
| Check | Finding | Severity |
|---|---|---|
| HTTP status 401 or 403 | Access denied | Info |
| HTTP status other than 200 | Unexpected status | Medium |
Content-Type is not text/event-stream | Wrong content type | Medium |
| Endpoint on port 80 | SSE stream over cleartext HTTP | High |
| Endpoint reachable without credentials | Open access | Medium |
Access-Control-Allow-Origin: * | Wildcard CORS policy | Medium |
Access-Control-Allow-Origin set to specific origin | CORS restricted | Info |
No Access-Control-Allow-Origin header | No CORS header | Info |
| Events received | Event count and rate | Info |
| Unique event type names | Event types | Info |
| Payload contains sensitive keywords | Sensitive data in stream | High |
| First event content | Event sample | Info |
Sensitive keywords checked in event payloads: password, token, secret,
key, auth, credential, private, serial.
Options
| Flag | Short | Default | Description |
|---|---|---|---|
--host <HOST> | -H | (required) | Target hostname or IP address |
--port <PORT> | -p | 80 | TCP port |
--path <PATH> | -u | /events | EventSource endpoint path |
--timeout-ms <MS> | -t | 5000 | Connection timeout (ms) |
--duration-s <SECS> | -d | 10 | How long to collect events (seconds) |
--max-events <COUNT> | -n | 100 | Maximum number of events to collect |
--output <FILE> | -o | - | Save result to a file |
--format <FORMAT> | -f | text | text or json |
--verbose | -v | - | Increase log verbosity |
Examples
Probe a device’s default event stream for 10 seconds:
$ iottack http http-sse --host 192.168.1.1
Target a non-standard path and port, collect for 30 seconds:
$ iottack http http-sse --host 192.168.1.1 --port 8080 --path /api/events --duration-s 30
Collect up to 50 events and save the result as JSON:
$ iottack http http-sse --host 192.168.1.1 --max-events 50 --output result.json --format json
Check a device running an ESPHome web server (default port 80, path /events):
$ iottack http http-sse --host esphome-device.local
MQTT
To test MQTT, use mqattack.
This is a dedicated tool to work with MQTT.
network - mdns-scan
Discover mDNS/DNS-SD services on the local network.
Usage
iottack network mdns-scan [OPTIONS]
Description
Browses the local network for mDNS/DNS-SD service announcements and reports resolved instances with hostname, IP address, port, and TXT records.
All service types are queried in parallel. Each query sends a probe every second until the timeout expires. Insecure protocols are flagged at higher severity:
| Service type | Label | Severity |
|---|---|---|
_telnet._tcp.local | Telnet | High |
_ftp._tcp.local | FTP | Medium |
_http._tcp.local | HTTP | Low |
_https._tcp.local | HTTPS | Info |
_ssh._tcp.local | SSH | Info |
_mqtt._tcp.local | MQTT | Info |
_coap._udp.local | CoAP | Info |
_hap._tcp.local | HomeKit (HAP) | Info |
_matter._tcp.local | Matter | Info |
_googlecast._tcp.local | Chromecast | Info |
Options
| Flag | Short | Default | Description |
|---|---|---|---|
--timeout-secs <SECS> | -t | 5 | Seconds to listen for responses |
--output <FILE> | -o | - | Save result to a file |
--format <FORMAT> | -f | text | text or json |
--verbose | -v | - | Increase log verbosity |
Example
$ iottack network mdns-scan --timeout-secs 10
Plugin name: network/mdns-scan
Execution time: 10.01 s
Status: pass
Date: 2026-10-17 00:00:00 UTC
HTTP: mydevice.local
Host: mydevice.local | Address: 192.168.1.42 | Port: 80
SSH: printer.local
Host: printer.local | Address: 192.168.1.55 | Port: 22
network - telnet
tcp-banner
Connect to a TCP service and capture the initial banner, identifying the protocol and flagging cleartext or unauthenticated services.
Usage
iottack network tcp-banner --host <HOST> --port <PORT> [OPTIONS]
Description
Opens a TCP connection to the target and reads the first bytes the service sends on connection (the banner). If the service sends nothing, a set of protocol-specific probes is tried in sequence until one matches.
The banner is compared against known signatures to identify the service. Cleartext and unauthenticated protocols are flagged with an elevated severity.
Identified protocols
| Protocol | Detection method | Severity |
|---|---|---|
| SSH | Banner prefix SSH- | Info |
| FTP | 220 + server strings | High |
| Telnet | IAC option negotiation bytes | High |
| SMTP | 220 + ESMTP keywords | Medium |
| POP3 | +OK prefix | Medium |
| IMAP | * OK prefix | Medium |
| VNC | RFB 0 prefix | High |
| HTTP | Active probe - GET / HTTP/1.0 | Low |
| Redis | Active probe - PING | High |
| MQTT | Active probe - CONNECT packet | High |
| Modbus/TCP | Active probe - Read Holding Registers | High |
Unrecognized banners that contain version strings are reported as Medium (information disclosure); all other unknown services are Info.
Severity mapping
| Finding | Severity |
|---|---|
| FTP, Telnet, VNC, Redis, MQTT, Modbus/TCP | High |
| SMTP, POP3, IMAP | Medium |
| Unknown service with version disclosure | Medium |
| HTTP service | Low |
| SSH service | Info |
| Unknown service, no version info | Info |
| No banner received | Info |
Options
| Flag | Short | Default | Description |
|---|---|---|---|
--host <HOST> | -H | - | Target hostname or IP address |
--net <CIDR> | -n | - | IPv4 CIDR subnet to sweep, e.g., 192.168.1.0/24. Overrides --host |
--port <PORT> | -p | 80 | Single TCP port, used when --ports/--all-ports are absent |
--ports <PORTS> | -P | - | Port list or ranges: 22,80,443, 8080-8090, 22,8000...9000 |
--all-ports | -A | off | Sweep all TCP ports 1-65535; overrides --port and --ports |
--timeout-ms <MS> | -t | 3000 | Connect and read timeout (ms); use 200-500 ms for sweeps |
--probe <PROBE> | - | (empty) | Data to send after connecting. empty means passive read |
--output <FILE> | -o | - | Save result to a file |
--format <FORMAT> | -f | text | text or json |
--verbose | -v | - | Increase log verbosity |
Port option precedence: --all-ports > --ports > --port.
--host and --net are mutually exclusive. --net takes precedence when both are given.
During subnet or port sweeps, active probes (the protocol-specific request/response pairs for HTTP, Redis, MQTT and Modbus) are skipped. Only the passive banner read is performed per host/port combination to keep the sweep fast.
Examples
Grab the banner from an SSH service:
$ iottack network tcp-banner --host 192.168.1.1 --port 22
[...]
SSH on 192.168.1.1:22
Banner: SSH-2.0-OpenSSH_8.9p1
Check for an MQTT broker with no authentication:
$ iottack network tcp-banner --host 192.168.1.100 --port 1883
[...]
MQTT on 192.168.1.100:1883
Banner: ...
Evidence: " \u{2}\0\u{5}"
Sweep a /24 for open port 22 (short timeout to keep the scan fast):
$ iottack network tcp-banner --net 192.168.1.0/24 --port 22 --timeout-ms 300
[...]
SSH on 192.168.1.1:22
Banner: SSH-2.0-OpenSSH_8.9p1
SSH on 192.168.1.42:22
Banner: SSH-2.0-dropbear_2022.83
Open port 192.168.1.100:22
TCP connection accepted but no banner received.
Scan a single host across common IoT ports:
$ iottack network tcp-banner --host 192.168.1.1 \
--ports 22,23,80,443,502,1883,8080,8883
[...]
SSH on 192.168.1.1:22
Banner: SSH-2.0-dropbear
Telnet on 192.168.1.1:23
Banner: ...
Modbus/TCP on 192.168.1.1:502
Banner: ..
Sweep a subnet across multiple ports:
$ iottack network tcp-banner --net 10.0.0.0/24 \
--ports 22,80,502,1883 --timeout-ms 400
Full port sweep of a single target:
$ iottack network tcp-banner --host 192.168.1.1 --all-ports --timeout-ms 300
Send a custom probe string to a single host:
$ iottack network tcp-banner --host 192.168.1.1 --port 9999 \
--probe "HELLO\r\n"
uart - baud-scan
Probe common UART baud rates to identify the active configuration.
Usage
iottack uart baud-scan --port <PATH> [OPTIONS]
Description
Opens the target serial port at each standard baud rate in sequence, sends a
stimulus (\r\n\r\nAT\r\n), and checks whether the response contains
printable ASCII. Reports all baud rates that produce readable output.
Baud rates probed (in order): 115200, 57600, 38400, 19200, 9600, 4800, 2400, 1200, 230400, 460800, 921600, 300
Options
| Flag | Short | Default | Description |
|---|---|---|---|
--port <PATH> | -p | (required) | Serial port device, e.g. /dev/ttyUSB0 |
--timeout-ms <MS> | - | 500 | Read timeout per baud rate in milliseconds |
--output <FILE> | -o | - | Save result to a file |
--format <FORMAT> | -f | text | text or json |
--verbose | -v | - | Increase log verbosity |
Example
$ iottack uart baud-scan --port /dev/ttyUSB0
Plugin name: uart/baud-scan
Execution time: 6.23 s
Status: pass
UART active at 115200 baud
Port /dev/ttyUSB0 responded with readable ASCII at 115200 baud.
Evidence: "OpenWrt login: "
uart - fuzz
Send malformed and custom payloads to a UART interface and report anomalous responses.
Usage
iottack uart fuzz --port <PATH> [OPTIONS]
Description
Sends a sequence of payloads through a serial port and inspects each response
for patterns that indicate crashes, errors, shell exposure, or any user-defined
string. The built-in payload set covers common vulnerability classes (null
bytes, long strings, format strings, shell metacharacters, boundary values,
control characters). When --charset or --wordlist is supplied the built-in
set is replaced so only the specified payloads are sent.
Status
| Result | Meaning |
|---|---|
pass | At least one payload produced an interesting response (Medium severity) |
fail | A High/Critical pattern matched (crash, shell prompt) or no response matched at all |
Severity classification
| Severity | Matched patterns |
|---|---|
| Critical | fault, panic, exception, stack, crash, abort, segfault, overflow |
| High | root@, # , $ , login:, password: |
| Medium | Everything else (error, invalid, user --pattern strings) |
Options
| Flag | Short | Default | Description |
|---|---|---|---|
--port <PATH> | -p | (required) | Serial port device, e.g. /dev/ttyUSB0 |
--baud-rate <BAUD> | -b | 115200 | Baud rate |
--timeout-ms <MS> | - | 500 | Read timeout per payload in milliseconds |
--iterations <N> | - | 1 | Times to repeat the full payload set |
--charset <CHARS> | -c | - | Characters to fuzz — generates one payload per character per length step (replaces built-ins) |
--length <N> | -l | 16 | Maximum repeat length for charset payloads |
--wordlist <PATH> | -w | - | Wordlist file, one payload per line (replaces built-ins) |
--suffix <BYTES> | -s | - | Bytes appended to every payload, e.g. \r\n for line-oriented shells |
--pattern <STRING> | - | - | Extra response pattern to flag as interesting |
--delay-ms <MS> | -d | 0 | Delay between payloads in milliseconds |
--output <FILE> | -o | - | Save result to a file |
--format <FORMAT> | -f | text | text or json |
--verbose | -v | - | Increase log verbosity (repeat for debug/trace) |
Examples
Built-in payload set
iottack uart fuzz --port /dev/ttyUSB0
Charset fuzzing against a line-oriented shell
Generate every combination of the given characters up to length 8
(a, b, …, aa, ab, …, zz, aaa, …), append \r\n so
the shell processes each entry, and flag any response containing
Set command.
Total payloads = |charset|¹ + |charset|² + … + |charset|^length — keep
--length small for large charsets.
iottack uart fuzz --port /dev/ttyUSB0 \
--charset "abcdefghijklmnopqrstuvwxyz0123456789" \
--length 8 \
--suffix '\r\n' \
--pattern 'Set command '
Wordlist fuzzing
iottack uart fuzz --port /dev/ttyUSB0 \
--wordlist /path/to/commands.txt \
--suffix '\r\n' \
--delay-ms 100
Wordlist format
Plain text, one payload per line. Blank lines and lines starting with # are
skipped. The line is sent as-is (without a trailing newline unless --suffix
adds one).
# commands.txt
help
version
debug
admin
reboot
Demo
The XIAO RP2040 target runs a CircuitPython shell
that controls an onboard NeoPixel LED. The shell accepts a handful of commands
(red, green, blue, off, abc, x) and echoes Hit - <command> for
each recognized one. Unknown commands produce Unknown command: '...'.
The commands are not documented. The goal is to discover them via fuzzing.
Step 1: Discover single-character commands
--charset now generates every combination of the given characters up to the
specified length: a, b, …, aa, ab, …, aaa, aab, abc, etc.
Start with --length 1 to find single-character commands quickly:
iottack uart fuzz --port /dev/ttyACM0 \
--charset "abcdefghijklmnopqrstuvwxyz0123456789" \
--length 1 \
--suffix '\r\n' \
--pattern 'Hit -' \
--delay-ms 250
x triggers a Hit - response, so the plugin reports pass:
Plugin name: uart/fuzz
Execution time: 9.25 s
Status: pass
Date: 2026-10-17 12:15:34 UTC
Interesting response to 'x' payload
/dev/ttyACM0 responded with patterns [Hit -] after receiving the 'x' payload.
Evidence: "x\nHit - x\nxiao> "
Step 2: Discover multi-character commands
Extend to --length 3 to reach commands like abc (3 chars) and off
(3 chars). For a 26-letter charset this generates
26 + 676 + 17 576 = 18 278 payloads — use a generous --delay-ms to avoid
overwhelming the device:
iottack uart fuzz --port /dev/ttyACM0 \
--charset "abcdefghijklmnopqrstuvwxyz" \
--length 3 \
--suffix '\r\n' \
--pattern 'Hit -' \
--delay-ms 50
abc, off, red and all other 1-3-letter commands are found. Commands
longer than 3 characters (blue, green) require increasing --length to 5,
or use a wordlist for targeted enumeration.
Step 3: Targeted wordlist
Once candidate commands are known, switch to a wordlist for exact matches:
iottack uart fuzz --port /dev/ttyACM0 \
--wordlist commands.txt \
--suffix '\r\n' \
--pattern 'Hit -' \
--delay-ms 100
commands.txt:
red
green
blue
abc
rainbow
off
reboot
factory-reset
Only red, green, blue, off, and abc produce Hit - responses;
the others return Unknown command:.
uart - port-scan
Discover serial ports available on this host.
Usage
iottack uart port-scan [OPTIONS]
Description
Scans /dev/ for character devices whose names match known UART naming patterns:
| Prefix | Interface |
|---|---|
ttyUSB* | USB-to-serial (FTDI, CH340, CP210x, …) |
ttyACM* | USB CDC ACM (Arduino, modems) |
ttyAMA* | ARM PL011 UART (Raspberry Pi, …) |
ttymxc* | NXP i.MX SoC UART |
ttyO* | TI OMAP UART |
ttyMSM* | Qualcomm UART |
ttySAC* | Samsung SoC UART |
ttyS* | Native 16550 UART |
Pass --verify to attempt opening each discovered port at 9600 baud, confirming
whether it is accessible without elevated privileges.
Options
| Flag | Short | Default | Description |
|---|---|---|---|
--verify | - | off | Try to open each port to confirm accessibility |
--output <FILE> | -o | - | Save result to a file |
--format <FORMAT> | -f | text | text or json |
--verbose | -v | - | Increase log verbosity |
Example
$ iottack uart port-scan --verify
Plugin name: uart/port-scan
Execution time: 12ms
Status: pass
Serial port available: /dev/ttyUSB0
/dev/ttyUSB0 is present and can be opened.
uart - shell-detect
Detect an interactive shell or login prompt on a UART interface.
Usage
iottack uart shell-detect --port <PATH> [OPTIONS]
Description
Connects at the configured baud rate, sends newlines to wake the device, and inspects the response for shell and login prompt patterns. Makes multiple attempts to handle devices that need time to produce output.
Findings are classified as follows:
| Finding | Severity | Condition |
|---|---|---|
| Unauthenticated root shell | Critical | # or root@ present in output |
| Login prompt exposed | High | login:, Password:, etc. present |
| Interactive prompt | Medium | Other shell patterns matched |
| Boot messages | Info | Boot-time keywords detected |
| No response | - | No output after all attempts |
Options
| Flag | Short | Default | Description |
|---|---|---|---|
--port <PATH> | -p | (required) | Serial port device, e.g. /dev/ttyUSB0 |
--baud-rate <BAUD> | -b | 115200 | Baud rate to connect at |
--attempts <N> | - | 3 | Number of stimulus attempts |
--timeout-ms <MS> | - | 500 | Read timeout per attempt in milliseconds |
--output <FILE> | -o | - | Save result to a file |
--format <FORMAT> | -f | text | text or json |
--verbose | -v | - | Increase log verbosity |
Example
$ iottack uart shell-detect --port /dev/ttyUSB0 --baud-rate 115200
Plugin name: uart/shell-detect
Execution time: 1.52 s
Status: pass
Login prompt exposed on UART
A login prompt is accessible on /dev/ttyUSB0 at 115200 baud.
Default or weak credentials should be tested.
Evidence: "OpenWrt login: "
uart - terminal
Open an interactive terminal session over a UART interface.
Usage
iottack uart terminal --port <PATH> [OPTIONS]
Description
Connects to the serial port and bridges your terminal to the device: everything you type is forwarded byte-for-byte to the device, and everything the device sends is printed to stdout.
Press Ctrl+D (EOF) to end the session. The plugin also exits cleanly if the device is reset or unplugged mid-session.
No findings are reported — this plugin is an interactive utility, not an automated check.
Options
| Flag | Short | Default | Description |
|---|---|---|---|
--port <PATH> | -p | (required) | Serial port device, e.g. /dev/ttyUSB0 |
--baud-rate <BAUD> | -b | 115200 | Baud rate |
--timeout-ms <MS> | - | 50 | Read timeout in milliseconds |
Example
$ iottack uart terminal --port /dev/ttyUSB0 --baud-rate 115200
[iottack] connected to /dev/ttyUSB0 at 115200 baud - Ctrl+D to exit
OpenWrt login: root
Password:
BusyBox v1.36.1 (2024-01-12) built-in shell (ash)
root@OpenWrt:~# uname -a
Linux OpenWrt 6.1.74 #0 SMP ...
root@OpenWrt:~#
[iottack] session ended
Tips
- Run
uart baud-scanfirst if you do not know the baud rate. - Run
uart shell-detectto confirm a shell is present before opening a terminal. - If the device echoes your input back, check whether the firmware has local echo enabled; most IoT shells do not.
- For devices that require
\r\nline endings, prefix your commands accordingly or configure your terminal emulator before launching this plugin.
Plugins that require hardware
Some tests depends on dedicated hardware connected to the host/platform that is used.
| Plugin | Description |
|---|---|
ghostesp | GhostESP for BLE-related tasks |
ghostesp device
GhostESP is an ESP32-based wireless security tool capable of Bluetooth and Wi-Fi scanning, deauthentication and credential harvesting. Its presence on a network or in a facility is itself a security finding.
Requirements
An ESP32 that runs GhostESP. Keep an eye the type of ESP32 that you are going to use, not all variants have support for Bluetooth.
Plugin description
Opens a serial connection to a GhostESP device, sends a newline to wake
the interactive shell, and waits for the ghost> prompt. Once the prompt
is confirmed, the plugin runs two commands:
chipinfo: retrieves ESP32 chip identity, revision, and flash size.devices: lists enabled hardware peripherals (GPS, RGB LED, SD card, etc.) and their configuration.
Findings are classified as follows:
| Finding | Severity | Condition |
|---|---|---|
| GhostESP device detected | High | ghost> prompt found on port |
| GhostESP chip information | Info | chipinfo returned output |
| Enabled peripherals | Info | devices returned peripheral list |
| Prompt not found / port error | Info | Device absent or wrong firmware |
Usage
iottack ghostesp device --port <PORT> [OPTIONS]
Options
| Flag | Short | Default | Description |
|---|---|---|---|
--port <PORT> | -p | (required) | Serial port, e.g. /dev/ttyACM0 |
--baud-rate <BAUD> | -b | 115200 | Serial baud rate |
--timeout-ms <MS> | -t | 5000 | Per-command response timeout in ms |
--output <FILE> | -o | - | Save result to a file |
--format <FORMAT> | -f | text | text or json |
--verbose | -v | - | Increase log verbosity |
Example
$ iottack ghostesp device --port /dev/ttyACM1
Plugin name: ghostesp/device
Execution time: 1.23 s
Status: pass
[INFO] GhostESP chip information
ESP32-D0WD-V3 rev 3, 4 MB flash, single core
[INFO] 3 enabled peripheral(s) on GhostESP device
Enabled devices:
GPS: default pin [idle]
RGB: data GPIO0, 0 LEDs
SD Card: SPI (CS GPIO4) [not mounted]
Target devices
iottack is meant to run against real IoT hardware or services. These boards or services
are used as targets or dummy devices throughout the documentation to demonstrate how the
plugins behave against an actual piece of hardware or an actual service.
| Device | Firmware | Notes |
|---|---|---|
| WEMOS LOLIN S2 Mini | ESPHome | Wi-Fi target for the network plugins |
| Seeed Studio XIAO RP2040 | CircuitPython | Serial target for the UART plugins |
| AMQP server | - | Local RabbitMQ server |
WEMOS LOLIN S2 Mini (ESPHome)
The WEMOS LOLIN S2 Mini is used as a
target or dummy device to demonstrate how the iottack plugins behave against a real
piece of IoT hardware. Any other ESP32-S2 based board can be substituted.
The device runs ESPHome as its firmware. ESPHome exposes a well-known set of network services (mDNS, the native API, a web server and OTA updates), which makes the board a convenient target for the network plugins.
Firmware
ESPHome is compiled for the esp32s2 platform and flashed onto the board. The
configuration below enables the services that the iottack network plugins target:
mdns: announces the device on the local network (see mdns-scan)web_server: serves an HTTP interface on port 80api: the native ESPHome API on port 6053ota: over-the-air updates via the ESPHome dashboard
ESPHome configuration
# Board: LOLIN S2 Mini (LOLIN/Wemos)
# Definition: definitions/boards/esp32-s2-mini/manifest.yaml
esphome:
name: iottack-target-device
friendly_name: iottack Target device (ESPHome)
esp32:
variant: esp32s2
flash_size: 4MB
framework:
type: esp-idf
logger:
api:
encryption:
key: "2EXXjaLyzz9n13NZcjuDALwwIli7wyK/mfvLKssxHoA="
ota:
- platform: esphome
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
ap:
ssid: iottack Target Fallback Hotspot
password: "12345678"
captive_portal:
async_tcp:
mdns:
web_server:
text_sensor:
- platform: uptime
name: Uptime Text Sensor
id: text_sensor_uptime_1
- platform: version
name: Version Text Sensor
id: text_sensor_version_1
- platform: wifi_info
ip_address:
name: IP Address
id: wifi_info_ip_address
address_0:
name: Address 0
Testing with iottack
Once flashed and connected to the network, the device can be discovered and enumerated:
# Discover the device via mDNS
iottack network mdns-scan
Seeed Studio XIAO RP2040 (CircuitPython)
Some example in the Plugin documentation are showing how an actual approach could be taken to exploit a device.
The hardware base for this “simulator”, target or dummy device is a Seeed Studio XIAO RP2040. Pretty much every other device based on the RP 2040 could be used.
For simplicity, ciruitpython act as the base firmware. For further details about the usage or how to get started with circuitpython, check their documentation.
Code for UART fuzz examples
This code simulates a IoT bulb which can be controlled over a serial connection. The code accepts a number of command to interact with the build-in LED.
Add this code to code.py and add neopixel.mpy to the libs folder. Then reboot
the device with Ctrl-D.
import board
import digitalio
import neopixel
import supervisor
import sys
import time
# Turn ON the power to the onboard NeoPixel
pixel_power = digitalio.DigitalInOut(board.NEOPIXEL_POWER)
pixel_power.direction = digitalio.Direction.OUTPUT
pixel_power.value = True
# Initialize the NeoPixel (1 pixel built into the board, uses GRB color ordering
pixel = neopixel.NeoPixel(board.NEOPIXEL, 1, brightness=0.3, pixel_order=neopixel.GRB)
# Define some colors (Red, Green, Blue)
BLACK = (0, 0, 0) # Off
BLUE = (0, 0, 255)
GREEN = (0, 255, 0)
RED = (255, 0, 0)
YELLOW = (255, 255, 0)
# Helper function to generate rainbow colors (0-255)
def color_wheel(pos):
if pos < 0 or pos > 255:
return (0, 0, 0)
if pos < 85:
return (255 - pos * 3, pos * 3, 0)
if pos < 170:
pos -= 85
return (0, 255 - pos * 3, pos * 3)
pos -= 170
return (pos * 3, 0, 255 - pos * 3)
# Shell and animation variables
input_buffer = ""
rainbow_active = False
rainbow_wheel_pos = 0
last_rainbow_update = 0
# Print instructions and the initial shell prompt
print("XIAO Shell: Type 'red', 'green', 'blue' or 'off'. Ctrl+C to exit.")
sys.stdout.write("xiao> ")
while True:
# 1. Process standard keyboard inputs
if supervisor.runtime.serial_bytes_available:
char = sys.stdin.read(1)
# Handle Enter key
if char in ('\r', '\n'):
sys.stdout.write("\n")
command = input_buffer.strip().lower()
if command == "green":
rainbow_active = False
pixel[0] = GREEN
sys.stdout.write("Hit - Green\n")
elif command == "red":
rainbow_active = False
pixel[0] = RED
sys.stdout.write("Hit - Red\n")
elif command == "blue":
rainbow_active = False
pixel[0] = BLUE
sys.stdout.write("Hit - Blue\n")
elif command == "abc":
rainbow_active = True
sys.stdout.write("Hit - Rainbow started!\n")
elif command == "x":
rainbow_active = False
pixel[0] = YELLOW
sys.stdout.write("Hit - x\n")
elif command == "off":
rainbow_active = False
pixel[0] = BLACK
sys.stdout.write("Hit - Off\n")
elif command == "":
pass
else:
sys.stdout.write(f"Unknown command: '{command}'\n")
input_buffer = ""
sys.stdout.write("xiao> ")
# Handle Backspace key
elif char in ('\x08', '\x7f'):
if len(input_buffer) > 0:
input_buffer = input_buffer[:-1]
sys.stdout.write("\x08 \x08")
# Handle printable text characters
else:
input_buffer += char
sys.stdout.write(char)
# Update the rainbow animation incrementally (non-blocking)
if rainbow_active:
current_time = time.monotonic()
# Update the color roughly every 0.02 seconds (20ms) for a smooth cycle
if current_time - last_rainbow_update > 0.02:
pixel[0] = color_wheel(rainbow_wheel_pos)
rainbow_wheel_pos = (rainbow_wheel_pos + 1) % 256
last_rainbow_update = current_time
Once the code is uploaded to the device:
XIAO Shell: Type 'red', 'green', 'blue' or 'off'. Ctrl+C to exit.
xiao> green
Hit - Green
xiao> off
Hit - Off
AMQP server
To test and to get yourself familiar with AMQP, a local RabbitMQ server is useful.
On NixOS you can spin-up a AMQP server by enabling it in the configuration.nix
file:
services.rabbitmq = {
enable = true;
managementPlugin.enable = true;
};
For more options, check the options.
After sudo nixos-rebuild switch, the server is available.
$ http http://localhost:15672
HTTP/1.1 200 OK
allow: HEAD, GET, OPTIONS
[...]
server: Cowboy
vary: origin, accept-encoding
Development
Adding a new plugin
Plugins are the unit of work in iottack. Each plugin performs one focused test
and reports zero or more findings with a severity level and optional evidence
string. Adding a plugin requires no changes to main.rs or any central
registry. The framework discovers plugins automatically at startup.
A complete working reference lives at
src/protocols/example/example.rs. The walkthrough below follows that file
step by step.
1. Create the module files
Plugins are grouped by protocol family under src/protocols/. Create a
directory for the protocol if one does not exist yet, then add the plugin
source file and the module declaration:
src/protocols/<protocol>/
├── mod.rs ← declares pub mod <plugin>;
└── <plugin>.rs ← the plugin implementation
Expose the new protocol module from src/protocols/mod.rs:
#![allow(unused)]
fn main() {
pub mod <protocol>;
}
2. Define the plugin struct
The struct holds every parameter the plugin needs between initialize and
execute. Fields should mirror the CLI arguments declared in meta().
#![allow(unused)]
fn main() {
pub struct ExamplePlugin {
message: String,
fail: bool,
}
impl ExamplePlugin {
pub fn new() -> Self {
Self { message: String::new(), fail: false }
}
}
impl Default for ExamplePlugin {
fn default() -> Self { Self::new() }
}
}
3. Implement Plugin::meta
meta() returns a PluginMeta built with the plugin_meta! macro. The macro
derives the plugin name and category from the Rust module path
automatically.
| Module path | Derived name |
|---|---|
iottack::protocols::http::banner | http/banner |
iottack::protocols::network::tcp_banner | network/tcp-banner |
iottack::protocols::example::example | example/example |
The args slice describes every CLI flag. main.rs reads this slice to build
the clap subcommand dynamically.
#![allow(unused)]
fn main() {
fn meta(&self) -> PluginMeta {
plugin_meta!(
summary: "Example plugin, shows the full plugin authoring pattern",
description: "A reference plugin that exercises every part of the Plugin \
API: initialize, execute, teardown, CLI arguments, findings \
at multiple severity levels, and the inventory registration \
macro.",
needs_root: false,
args: &[
CliArg {
name: "message", // config key; becomes --message on the CLI
short: Some('m'),
help: "Text to include in the Info finding",
value_name: "TEXT",
default: Some("hello from example plugin"),
is_flag: false,
},
CliArg {
name: "fail",
short: Some('x'),
help: "Emit a High finding and exit with status Fail",
value_name: "",
default: None,
is_flag: true, // presence sets config key to "true"
},
],
)
}
}
4. Implement Plugin::initialize
initialize is called once before execute. Read every CLI argument from
config and store the values on self.
| Method | When to use |
|---|---|
config.get_or(key, default) | String value with a fallback |
config.parse_or(key, default) | Typed value (u16, u64, …) with a fallback |
config.require(key) | Value that must be present — returns Err if missing |
#![allow(unused)]
fn main() {
fn initialize(&mut self, config: &PluginConfig) -> Result<()> {
self.message = config.get_or("message", "hello from example plugin");
self.fail = config.get("fail") == Some("true");
Ok(())
}
}
5. Implement Plugin::execute
execute contains the core test logic. Create a PluginResult, add
PluginFinding values, and call result.finish(status) before returning.
Severity levels (lowest to highest): Info -> Low -> Medium -> High -> Critical
Use PluginFinding::with_evidence to attach the raw data that triggered a
finding: a banner, a header value, a captured packet and so on. Evidence is
shown in the text output and preserved in JSON results.
#![allow(unused)]
fn main() {
fn execute(&mut self) -> Result<PluginResult> {
let mut result = PluginResult::new(self.meta().name);
// Neutral observation — always emitted.
result.add(PluginFinding::new(
Severity::Info,
format!("Example plugin executed: {}", self.message),
"Informational finding describing an observed property of the target.",
));
// Conditional finding with evidence.
if self.fail {
result.add(
PluginFinding::new(
Severity::High,
"Simulated failure",
"The --fail flag was set.",
)
.with_evidence("flag: --fail was present"),
);
result.finish(PluginStatus::Fail);
return Ok(result);
}
result.finish(PluginStatus::Pass);
Ok(result)
}
}
Early-return with PluginStatus::Fail for expected failure conditions such as
a refused connection or a missing required argument. Reserve Err(...) / ?
for truly unexpected failures.
6. Implement Plugin::teardown (optional)
teardown is always called after execute, even when execute returned an
error. Use it to close sockets, file handles or serial ports. The default
implementation is a no-op. Please omit this method if there is nothing to release.
#![allow(unused)]
fn main() {
fn teardown(&mut self) -> Result<()> {
Ok(())
}
}
7. Register the plugin
Add a single line at the bottom of the file. The framework collects all
registered factories at startup via the inventory crate. Thus, no changes to
main.rs or any registry file are needed.
#![allow(unused)]
fn main() {
inventory::submit! {
crate::PluginFactory(|| Box::new(ExamplePlugin::new()) as Box<dyn crate::Plugin>)
}
}
8. Add documentation
Create a Markdown file at
documentation/src/plugins/<protocol>/<plugin-name>.md and add it to
documentation/src/SUMMARY.md. See
http - banner for an example of the expected
structure: one-liner, usage block, description, checks table with severities,
options table and example commands.
Running the example plugin
# Pass status with default message
iottack example example
# Custom message
iottack example example --message "testing 1 2 3"
# Trigger the High finding and Fail status
iottack example example --fail
# Save JSON output
iottack example example --fail --output result.json --format json
Running the tests
cargo test
The test suite covers the core types (PluginConfig, PluginFinding,
PluginResult, Severity, Category, PluginRegistry) and the
output-formatting helpers in main.rs. Doc-tests embedded in the public API
are run as part of the same cargo test invocation.