Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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.

PluginCommandSummary
platformiottack platformReport details about the host/platform environment
amqp - scaniottack amqp scanDetect an AMQP broker and test for default credentials
http - banneriottack http bannerChecks HTTP banner and common settings
mqtt-Work with MQTT
network - mdns-scaniottack network mdns-scanDiscover mDNS/DNS-SD services on the local network
network - telnetiottack telnetConnect to a Telnet service and detect banners, login prompts and default credentials
network - tcp-banneriottack network tcp-bannerConnect to a TCP service and capture the initial banner, identifying the protocol and flagging cleartext services
uart - baud-scaniottack uart baud-scanProbe common UART baud rates to identify the active configuration
uart - port-scaniottack uart port-scanDiscover serial ports available on this host
uart - fuzziottack uart fuzzSend malformed and custom payloads to a UART interface and report anomalous responses
uart - shell-detectiottack uart shell-detectDetect interactive shell or login prompt on a UART interface
uart - terminaliottack uart terminalOpen an interactive terminal session over a UART interface

Global options

Every plugin accepts these flags in addition to its own arguments:

FlagShortDescription
--verbose-vIncrease log verbosity; repeat for more detail (-v, -vv, -vvv)
--output <FILE>-oSave the result to a file
--format <FORMAT>-fOutput 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

FlagShortDefaultDescription
--output <FILE>-o-Save result to a file
--format <FORMAT>-ftexttext 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

UsernamePassword
guestguest
adminadmin
adminpassword
adminrabbitmq
rabbitmqrabbitmq
useruser
rootroot
adminguest

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

FindingSeverity
AMQP broker detectedInfo
Cleartext AMQP on port 5672High
Default credentials acceptedCritical
No default credentials acceptedInfo
Management UI hint (port 15672)Info
PortService
5671AMQPS — TLS-encrypted AMQP
5672AMQP — plaintext (this plugin)
15672RabbitMQ management HTTP API and web UI

Use the http/banner plugin to probe port 15672 for the management interface.

Options

FlagShortDefaultDescription
--host <HOST>-H(required)Target hostname or IP address
--port <PORT>-p5672AMQP port
--vhost <VHOST>-V/Virtual host to authenticate against
--timeout-ms <MS>-t3000Connection timeout (ms)
--try-creds-offProbe common default credentials
--output <FILE>-o-Save result to a file
--format <FORMAT>-ftexttext 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

CheckFindingSeverity
Server: header with version numberVersion disclosureMedium
Server: header without versionSoftware identityInfo
X-Powered-By: header presentFramework/runtime disclosureMedium
Port 80 with no HTTPS redirectCleartext HTTPHigh
Port 80 redirecting to HTTPSHTTPS redirect in placeInfo
Missing Strict-Transport-Security (port 443)No HSTSMedium
Missing X-Frame-Options / CSP frame-ancestorsNo clickjacking protectionLow
Missing X-Content-Type-OptionsMIME sniffing not disabledLow
Missing Content-Security-PolicyNo CSPLow
HTTP 401 with Basic schemeCredentials over cleartextHigh
HTTP 401 with other schemeAuth requiredInfo
Default installation page in bodyServer not customisedMedium
Cookie missing Secure or HttpOnly flagInsecure cookieMedium

Options

FlagShortDefaultDescription
--host <HOST>-H(required)Target hostname or IP address
--port <PORT>-p80TCP port
--path <PATH>-u/Request path
--timeout-ms <MS>-t5000Connect and read timeout (ms)
--output <FILE>-o-Save result to a file
--format <FORMAT>-ftexttext 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

CheckFindingSeverity
HTTP status 401 or 403Access deniedInfo
HTTP status other than 200Unexpected statusMedium
Content-Type is not text/event-streamWrong content typeMedium
Endpoint on port 80SSE stream over cleartext HTTPHigh
Endpoint reachable without credentialsOpen accessMedium
Access-Control-Allow-Origin: *Wildcard CORS policyMedium
Access-Control-Allow-Origin set to specific originCORS restrictedInfo
No Access-Control-Allow-Origin headerNo CORS headerInfo
Events receivedEvent count and rateInfo
Unique event type namesEvent typesInfo
Payload contains sensitive keywordsSensitive data in streamHigh
First event contentEvent sampleInfo

Sensitive keywords checked in event payloads: password, token, secret, key, auth, credential, private, serial.

Options

FlagShortDefaultDescription
--host <HOST>-H(required)Target hostname or IP address
--port <PORT>-p80TCP port
--path <PATH>-u/eventsEventSource endpoint path
--timeout-ms <MS>-t5000Connection timeout (ms)
--duration-s <SECS>-d10How long to collect events (seconds)
--max-events <COUNT>-n100Maximum number of events to collect
--output <FILE>-o-Save result to a file
--format <FORMAT>-ftexttext 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 typeLabelSeverity
_telnet._tcp.localTelnetHigh
_ftp._tcp.localFTPMedium
_http._tcp.localHTTPLow
_https._tcp.localHTTPSInfo
_ssh._tcp.localSSHInfo
_mqtt._tcp.localMQTTInfo
_coap._udp.localCoAPInfo
_hap._tcp.localHomeKit (HAP)Info
_matter._tcp.localMatterInfo
_googlecast._tcp.localChromecastInfo

Options

FlagShortDefaultDescription
--timeout-secs <SECS>-t5Seconds to listen for responses
--output <FILE>-o-Save result to a file
--format <FORMAT>-ftexttext 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

ProtocolDetection methodSeverity
SSHBanner prefix SSH-Info
FTP220 + server stringsHigh
TelnetIAC option negotiation bytesHigh
SMTP220 + ESMTP keywordsMedium
POP3+OK prefixMedium
IMAP* OK prefixMedium
VNCRFB 0 prefixHigh
HTTPActive probe - GET / HTTP/1.0Low
RedisActive probe - PINGHigh
MQTTActive probe - CONNECT packetHigh
Modbus/TCPActive probe - Read Holding RegistersHigh

Unrecognized banners that contain version strings are reported as Medium (information disclosure); all other unknown services are Info.

Severity mapping

FindingSeverity
FTP, Telnet, VNC, Redis, MQTT, Modbus/TCPHigh
SMTP, POP3, IMAPMedium
Unknown service with version disclosureMedium
HTTP serviceLow
SSH serviceInfo
Unknown service, no version infoInfo
No banner receivedInfo

Options

FlagShortDefaultDescription
--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>-p80Single 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-AoffSweep all TCP ports 1-65535; overrides --port and --ports
--timeout-ms <MS>-t3000Connect 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>-ftexttext 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

FlagShortDefaultDescription
--port <PATH>-p(required)Serial port device, e.g. /dev/ttyUSB0
--timeout-ms <MS>-500Read timeout per baud rate in milliseconds
--output <FILE>-o-Save result to a file
--format <FORMAT>-ftexttext 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

ResultMeaning
passAt least one payload produced an interesting response (Medium severity)
failA High/Critical pattern matched (crash, shell prompt) or no response matched at all

Severity classification

SeverityMatched patterns
Criticalfault, panic, exception, stack, crash, abort, segfault, overflow
Highroot@, # , $ , login:, password:
MediumEverything else (error, invalid, user --pattern strings)

Options

FlagShortDefaultDescription
--port <PATH>-p(required)Serial port device, e.g. /dev/ttyUSB0
--baud-rate <BAUD>-b115200Baud rate
--timeout-ms <MS>-500Read timeout per payload in milliseconds
--iterations <N>-1Times 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>-l16Maximum 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>-d0Delay between payloads in milliseconds
--output <FILE>-o-Save result to a file
--format <FORMAT>-ftexttext 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:

PrefixInterface
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

FlagShortDefaultDescription
--verify-offTry to open each port to confirm accessibility
--output <FILE>-o-Save result to a file
--format <FORMAT>-ftexttext 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:

FindingSeverityCondition
Unauthenticated root shellCritical# or root@ present in output
Login prompt exposedHighlogin:, Password:, etc. present
Interactive promptMediumOther shell patterns matched
Boot messagesInfoBoot-time keywords detected
No response-No output after all attempts

Options

FlagShortDefaultDescription
--port <PATH>-p(required)Serial port device, e.g. /dev/ttyUSB0
--baud-rate <BAUD>-b115200Baud rate to connect at
--attempts <N>-3Number of stimulus attempts
--timeout-ms <MS>-500Read timeout per attempt in milliseconds
--output <FILE>-o-Save result to a file
--format <FORMAT>-ftexttext 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

FlagShortDefaultDescription
--port <PATH>-p(required)Serial port device, e.g. /dev/ttyUSB0
--baud-rate <BAUD>-b115200Baud rate
--timeout-ms <MS>-50Read 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-scan first if you do not know the baud rate.
  • Run uart shell-detect to 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\n line 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.

PluginDescription
ghostespGhostESP 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:

FindingSeverityCondition
GhostESP device detectedHighghost> prompt found on port
GhostESP chip informationInfochipinfo returned output
Enabled peripheralsInfodevices returned peripheral list
Prompt not found / port errorInfoDevice absent or wrong firmware

Usage

iottack ghostesp device --port <PORT> [OPTIONS]

Options

FlagShortDefaultDescription
--port <PORT>-p(required)Serial port, e.g. /dev/ttyACM0
--baud-rate <BAUD>-b115200Serial baud rate
--timeout-ms <MS>-t5000Per-command response timeout in ms
--output <FILE>-o-Save result to a file
--format <FORMAT>-ftexttext 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.

DeviceFirmwareNotes
WEMOS LOLIN S2 MiniESPHomeWi-Fi target for the network plugins
Seeed Studio XIAO RP2040CircuitPythonSerial 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 80
  • api: the native ESPHome API on port 6053
  • ota: 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 pathDerived name
iottack::protocols::http::bannerhttp/banner
iottack::protocols::network::tcp_bannernetwork/tcp-banner
iottack::protocols::example::exampleexample/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.

MethodWhen 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.