zucchini-scanner 0.1.2

Blazing-fast TCP port scanner for Linux
zucchini-scanner-0.1.2 is not a library.

singsing-rs

build

"Then, we got a modem."

-- Matt Harrigan

The singsing-rs library crate is my modern Rust reimplementation of the original singsing project, written in C by my old friend and longtime packet wizard inode. It's a blazing-fast ⚡️ yet very reliable IPv4/TCP port scanning library for Linux.

The zucchini-scanner binary crate distributes the standalone command-line port scanner zucchini based on singsing-rs, inspired by the original zucca scanner from the singsing project.

How it works

The scanner creates raw IPv4/TCP packets, sends bandwidth-limited SYN probes, and asynchronously validates response acknowledgement numbers before reporting SYN/ACK responses as open and, optionally, RST responses as closed. Target host/port pairs that do not reply are treated as filtered or unreachable and are not printed in the output.

[!NOTE] Creating the raw transport socket requires root or the CAP_NET_RAW capability.

See below for the main differences from the original singsing and other implementation details.

Features

  • Support for IPv4 hosts and CIDR ranges to scan.
  • Support for comma-separated ports and inclusive port ranges to scan.
  • Support for getting TCP ports from /etc/services if target ports are not specified.
  • Optional reporting of closed ports.
  • Configurable bandwidth and response timeout.
  • Progress statistics printed while scanning.
  • Immediate per-result feedback with -v/--verbose.
  • Partial results preserved when probe transmission fails.

See also

Installing

Install the latest release of the scanner from crates.io (this installs the zucchini binary):

cargo install zucchini-scanner --locked

To use the scanning library in another Rust project:

cargo add singsing-rs

Compiling

Alternatively, you can build from source:

git clone https://github.com/0xdea/singsing-rs
cd singsing-rs
cargo build --release --locked

Configuration

The zucchini scanner uses a raw transport socket. Either run it as root, or grant the installed binary the capability it needs:

sudo setcap cap_net_raw=eip "$(command -v zucchini)"

Choose a network interface with -i/--interface whose IPv4 address can route to the targets. You can list available interfaces with ip -brief address.

Usage

[!WARNING] Only scan systems you own or have explicit permission to test.

Scan the selected ports on one host:

zucchini -i eth0 -h 192.168.2.10 -p 21-23,80,443

Scan all ports on a /24 subnet, and report also closed ports:

zucchini -i eth0 -h 192.168.2.0/24 -p 1-65535 -c

Scan one port on a /8 subnet, with a send rate bandwidth of 40 KiB/s:

zucchini -i eth0 -h 192.168.0.0/8 -p 22 -b 40

Scan TCP port entries from /etc/services on one host, and wait only five seconds for late replies:

zucchini -i eth0 -h 192.168.2.10 -t 5

Progress statistics with a local date/time ETA are printed every minute for the first ten minutes, every ten minutes through the first hour, and every thirty minutes thereafter. Use -v/--verbose to additionally print responses as soon as they arrive. The complete sorted results are always printed under a separate Scan results: heading when the scan finishes.

Run zucchini --help for the complete command-line reference.

Library users can construct a ScanConfig and call scan. See the API documentation for more details.

Performance

At a bandwidth of 40 KiB/s (a reasonable, if conservative, value for most source and target networks), zucchini can scan a /29 network (6 hosts) on all ports in under 7 minutes. At the same bandwidth setting, it can fully scan a /24 network (254 hosts) in approximately 4 hours and 30 minutes.

Compatibility

The scanner is intentionally Linux-only. The release build and test suite have been verified to work on Ubuntu Linux 24.04 (x86_64 and aarch64).

Testing

Run the unit tests and unprivileged integration tests normally:

cargo test --workspace --locked

Ignored privileged loopback integration tests exercise live raw-socket scanning, open and closed ports, callbacks, timeout handling, sorting, and complete zucchini output. They require root or CAP_NET_RAW and must run serially because concurrent raw receivers could observe each other's packets. Run them manually as follows:

sudo --preserve-env=PATH,CARGO_HOME,RUSTUP_HOME \
  env CARGO_TARGET_DIR=/tmp/singsing-rs-privileged-target \
  cargo test --workspace --locked -- --ignored --test-threads=1

The separate target directory prevents Cargo from leaving root-owned build artifacts in the repository's normal target/ directory. The ignored tests are still compiled by ordinary test and CI runs, so API changes cannot silently break them.

Credits

This project is a Rust port and modernization of Maurizio Agazzini 🧙‍♂️'s singsing library and its zucca SYN scanner example.

It belongs to the broader family of asynchronous scanners that includes scanrand, unicornscan, zmap, and masscan.

Changelog

TODO

  • Add optional serde support (Serialize/Deserialize) for ScanResult, ScanProgress, and PortState, behind a feature flag, to support structured (e.g. JSON) output.
  • Maybe port to macOS if there's interest.

Implementation details

Packet I/O

Unlike the original singsing, which sent probes with a raw socket and captured responses through libpcap, this implementation uses pnet for interface discovery, IPv4/TCP packet construction and parsing, and Layer-3 raw-socket sending and receiving. It therefore does not require libpcap or expose link-layer headers. Responses are correlated and filtered in Rust rather than with a libpcap BPF capture filter.

Bandwidth pacing

The default bandwidth is 15 KiB/s. With the Rust scanner's 40-byte IPv4/TCP header accounting, this corresponds to approximately 384 SYN probes per second. Override it with -b/--bandwidth.

Unlike the original singsing, this implementation does not send calibration traffic: instead, it schedules each probe against an absolute deadline. This approach is smoother and automatically accounts for ordinary send overhead, and the same bandwidth value permits about 45% more SYNs per second than the original 58-byte calculation.

Transmission order

The original zucca used a deterministic target traversal. This implementation, instead, stores exact host/port pairs in a randomly seeded HashMap and sends them in an unspecified iteration order. Consequently, hosts and ports are interleaved differently between runs rather than following a predictable sequence. This improves scan stealthiness by avoiding an obvious sequential pattern.

Packet fingerprint

Rust probes use TTL 64, a 64,240-byte TCP window, and an IP ID derived from the probe sequence; the original singsing used TTL 100, a 32,768-byte window, and incrementing IP IDs. These values should not change normal open/closed results: TTL 64 is sufficient for typical paths, the window matters only after a handshake, and these small packets are not normally fragmented. They do produce a different observable fingerprint and may be treated differently by unusual middlebox rules.

Response validation

The original singsing primarily trusted TCP flags and a destination-port range. This implementation accepts a response only when its source host and port match an actual probe, its destination matches the scanner address and source port, and its acknowledgement number matches the transmitted sequence number. It then treats SYN/ACK as open and, when requested, RST as closed. This stricter correlation reduces false positives from unrelated TCP traffic, but ignores unusual responses without the expected acknowledgement number.

Source port behavior

Each scan selects one TCP source-port number from the 49152–65535 range and reuses it for every raw SYN probe. The scanner writes this number directly into the TCP headers; it does not bind or reserve a local TCP socket.

The selected port number can therefore overlap a port used by another local connection. TCP connections are identified by their complete local and remote address/port tuple, so interference additionally requires the scan to target the same remote address and port. This is deemed unlikely in typical use.

Scan size limit and memory usage

A single scan is limited to 16,777,214 host/port pairs. This accommodates either one TCP port across all usable addresses of an IPv4 /8 subnet, or all 65,535 TCP ports across the 254 usable addresses of a /24 subnet. Full-port scans of networks larger than /24 exceed the limit and must be split into /24 subnets or smaller scans. Larger networks can be scanned when the selected port count keeps the total number of host/port pairs within the limit.

Unlike the original singsing, which generated probes incrementally, this implementation expands all targets and builds an expected-response hash-table entry for every host/port pair before sending. Memory use therefore grows with the total number of targets, not only with the number of responses. On a typical 64-bit build, a one-port scan of a full usable /8 subnet consumes roughly 500 MiB when few hosts answer. If every host returns an accepted response, the expected-response table, duplicate set, target list, and buffered results together require approximately 832 MiB; allocator and operating-system overhead can bring peak memory close to or above 1 GiB. The exact amount depends on the Rust toolchain and allocator. Split large scans when memory is constrained even if they are below the host/port pair hard limit.

Networks larger than /8 are rejected before their addresses are expanded, preventing oversized CIDRs such as /7 or /0 from exhausting memory before the scan limit can be checked.

Library callers constructing ScanConfig directly must provide unique target and port vectors. ScanConfig::new does not validate this itself; duplicates are instead rejected once scanning starts and the expected-response table above is built, rather than silently producing inaccurate probe and progress counts.

Target handling

For networks from /8 through /30, zucchini skips the network and broadcast addresses. A /31 subnet, instead, is treated as a point-to-point network, so both its addresses are scanned. Finally, if a /32 subnet is specified as target, its single address is scanned. In summary:

192.168.2.0/30 -> 192.168.2.1, 192.168.2.2
192.168.2.0/31 -> 192.168.2.0, 192.168.2.1
192.168.2.7/32 -> 192.168.2.7

Error handling

singsing-rs reports failures through typed, matchable error enums (InterfaceError, TargetsError, PortsError, ScanError) rather than an opaque error type, so library callers can match on the specific failure instead of parsing message text.

If transmission stops after an individual probe error, zucchini prints how many probes were sent before stopping, then the results received from those probes, then reports the incomplete scan and exits with a failure status. Library callers get this as ScanError::Incomplete, whose IncompleteScanError payload exposes partial results and the sent-probe count. IncompleteScanError's underlying cause is available via std::error::Error::source() and can be downcast to SendError for finer detail (packet construction, a send I/O failure, or a failed progress callback).