daemon-sec-cheatsheet

The cheatsheet vault for operators: AD, enumeration, exploitation, priv-esc, web, DFIR
git clone https://git.daemon-sec.xyz/daemon-sec-cheatsheet.git
Log | Files | Refs | README | LICENSE

commit 2f7c4ba42a31e93a7b0b92a517fb3bdea14658d0
parent ddd84af895b5ee1faf7aa2d979c1fab3365d85f2
Author: DAEMON <zer0sec.xp@icloud.com>
Date:   Fri,  9 Oct 2026 13:45:51 +0100

added a better rustscan cheatsheet

Diffstat:
Msrc/content/sheets/enumeration/rustscan.md | 1225+++++++++++++++++++++++++++++++++++++++++++++++--------------------------------
Msrc/styles/flow.css | 2++
Msrc/styles/global.css | 2+-
3 files changed, 729 insertions(+), 500 deletions(-)

diff --git a/src/content/sheets/enumeration/rustscan.md b/src/content/sheets/enumeration/rustscan.md @@ -1,709 +1,936 @@ --- title: "RustScan" -description: "RustScan fast port discovery, scripting engine, config and Nmap hand-off patterns." +description: "RustScan 2.4.1 in depth: how the connect sweep works, the batch/timeout/ulimit maths, the Nmap hand-off and its traps, config precedence, the scripting engine, and HTB/CPTS workflows. Every behaviour checked against the source and a live lab." category: enumeration -tags: [enumeration, port-scanning, network] +tags: [enumeration, port-scanning, network, nmap, htb, cpts] tools: [RustScan, Nmap] -difficulty: beginner -updated: "2026-08-09" -source: "vault:Enumeration/rustscan.md" +difficulty: intermediate +updated: "2026-10-09" +references: + - name: "RustScan source, tag 2.4.1" + url: "https://github.com/bee-san/RustScan/tree/2.4.1" + author: "RustScan contributors" + relation: inspired + note: "Flag semantics, config merge order, batch-size inference, target parsing and the script-header parser on this page were read from src/main.rs, src/input.rs, src/address.rs, src/scanner and src/scripts at this tag, then reproduced live." + - name: "RustScan wiki" + url: "https://github.com/bee-san/RustScan/wiki" + author: "RustScan contributors" + relation: link-only + note: "Upstream usage docs. Several pages (trigger_port, tag matching, config value case, adaptive learning) describe behaviour the 2.4.1 code does not have; where they disagree, this page follows the code." + - name: "Nmap Reference Guide: Host Discovery" + url: "https://nmap.org/book/man-host-discovery.html" + author: "Gordon Lyon" + relation: link-only + note: "What Nmap sends to decide a host is up, and so why the Nmap stage needs -Pn." + - name: "Docker Hub: rustscan/rustscan" + url: "https://hub.docker.com/r/rustscan/rustscan" + relation: link-only + note: "Tag list checked 2026-10-09: the newest image is 2.3.0, amd64 only." + - name: "Kali package tracker: rustscan" + url: "https://pkg.kali.org/pkg/rustscan" + relation: link-only + note: "2.4.1-0kali1, built for amd64, arm64, armhf and i386." --- -# RustScan +## What RustScan is, and what it isn't -## What is RustScan +RustScan answers one question fast: which TCP ports on these addresses finish a handshake? It +asks with plain `connect()` calls, thousands in flight at once on an async runtime, so it needs no +root and no raw sockets. Then, host by host, it runs a script. The default script is Nmap, pointed +at exactly the ports that answered. That split is where the speed comes from. The 65,535-port sweep +is cheap, and Nmap's expensive work (`-sC`, `-sV`) only ever touches ports already known to be open. -RustScan is a modern, high-speed port scanner written in Rust. It can scan all 65,535 ports in as little as **3 seconds** and automatically pipes results to Nmap for detailed analysis. Key features: +Everything else you might expect from a scanner, it either hands to Nmap or doesn't do at all: -* **Speed** — scans all ports in seconds (vs minutes with Nmap) -* **Adaptive Learning** — automatically fine-tunes batch size based on your usage patterns -* **Scripting Engine** — supports Python, Lua, Perl, and Shell scripts -* **Nmap Integration** — automatically passes discovered ports to Nmap -* **IPv6, CIDR, and file input support** +| | RustScan 2.4.1 | What that means for you | +|---|---|---| +| Scan type | TCP connect only (full handshake, then shutdown) | No root needed. Every open port gets a real connection, which the service may log | +| Port states | open, or not open | Closed (RST) and filtered (silence) look identical. Nmap is what tells them apart | +| Host discovery | none | Every address you give it gets every port, alive or not | +| Retransmits | none unless `--tries` is above 1 | One dropped SYN or SYN-ACK is one missed port, with no warning | +| Versions, OS, NSE | none, delegated | `-sV`, `-sC` and `-O` happen in the Nmap stage, after `--` | +| UDP | `--udp`: one probe per port, open only if something replies | See [UDP mode](#udp-mode) | +| Stealth | none: no SYN, decoys, fragmentation or source-port options | `--scan-order random` shuffles the port order, and that's all | +| "Adaptive learning" | batch size is sized against your open-file limit at startup | Nothing is learned or saved between runs. 2.4.1 writes no state files | + +> [!info] **How this page was checked.** Written against **RustScan 2.4.1** (the current release, February 2025) and **Nmap 7.99** on macOS arm64, on 2026-10-09. Every flag, message and gotcha below was read from the 2.4.1 source and then reproduced against throwaway listeners on 127.0.0.1, with `HOME` pointed at an empty directory so no stray config could interfere. Output blocks are real captures from those runs. Upstream's wiki disagrees with the code in several places. Where they differ, this page follows the code and says so. --- -## Installation Methods +## Quick start -### Docker (Recommended) ```bash -# Pull the latest image -docker pull rustscan/rustscan:latest +mkdir -p recon # Nmap's -oA fails, and takes the whole run with it, if the directory is missing -# Run a scan -docker run -it --rm --name rustscan rustscan/rustscan:latest -a 192.168.1.1 +# One box: every TCP port, then default scripts + versions on exactly those ports, saved +rustscan -a $IP -u 5000 -- -Pn -sCV -oA recon/$NAME -# Create an alias for convenience -alias rustscan='docker run -it --rm --name rustscan rustscan/rustscan:latest' +# Port list only, no Nmap. Prints one line per host: 10.10.11.5 -> [22,80,443] +rustscan -a $IP -u 5000 -g -# With increased file descriptor limit -docker run -it --rm --ulimit nofile=5000:5000 --name rustscan rustscan/rustscan:latest -a 192.168.1.1 +# Lossy VPN or a Windows firewall: smaller window, longer timeout, one retry +rustscan -a $IP -u 5000 -b 1000 -t 3000 --tries 2 -- -Pn -sCV -oA recon/$NAME ``` -**Why Docker?** High open-file-descriptor limit (solves most common errors), works on all systems including Windows, always the latest version, and no need to install Rust, Cargo, or Nmap. +What each piece is doing: -### Cargo (Rust Package Manager) -```bash -cargo install rustscan -``` - -### Debian/Ubuntu (.deb package) -```bash -# Check the releases page for the current version number -wget https://github.com/RustScan/RustScan/releases/download/2.3.0/rustscan_2.3.0_amd64.deb -sudo dpkg -i rustscan_2.3.0_amd64.deb -``` - -### Arch Linux (AUR) -```bash -yay -S rustscan -# or -paru -S rustscan -``` - -### Homebrew (macOS) -```bash -brew install rustscan -``` +- **`-u 5000`** raises the open-file limit for this run. Without it, a stock macOS shell (soft limit + 256) silently cuts the batch to 128 sockets, and a typical Debian/Kali shell (1024) cuts it to 512. + See [the batch maths](#how-rustscan-sizes-the-batch). +- **`--`** ends RustScan's flags. Everything after it is appended to the Nmap command line, which is + run through `sh -c`. That matters for quoting, see [quoting through `--`](#quoting-through---). +- **`-Pn`** stops Nmap from running its own ping and deciding the host is down. RustScan has already + proved it's up. See [why `-Pn`, every time](#-pn-every-time). +- **`-oA recon/$NAME`** is your record. RustScan holds Nmap's output back until Nmap exits, and if + Nmap fails its output is thrown away. The files survive either way. --- -## Basic Syntax & Flags Reference - -```bash -rustscan [FLAGS] [OPTIONS] -a <addresses> [-- <nmap_args>...] -``` - -### Flags Table - -| Flag | Long Form | Description | Default | -|------|-----------|-------------|---------| -| `-a` | `--addresses` | Target IP(s), hostname(s), CIDR, or file | Required | -| `-p` | `--ports` | Specific ports to scan | All ports | -| `-r` | `--range` | Port range (e.g. 1-1000) | 1-65535 | -| `-e` | `--exclude-ports` | Ports to exclude | None | -| `-x` | `--exclude-addresses` | Addresses to exclude | None | -| `-b` | `--batch-size` | Concurrent connections | 4500 | -| `-t` | `--timeout` | Timeout per port (ms) | 1500 | -| `-u` | `--ulimit` | Set file descriptor limit | OS default | -| | `--tries` | Number of retries | 1 | -| | `--scan-order` | Order: `serial` or `random` | serial | -| | `--scripts` | Script level: `none`, `default`, `custom` | default | -| | `--top` | Scan top 1000 ports only | false | -| | `--udp` | Enable UDP scanning | false | -| `-q` | `--quiet` | Quiet mode — ports only | false | -| `-g` | `--greppable` | Greppable output format | false | -| | `--accessible` | Screen reader friendly mode | false | -| `-n` | `--no-config` | Ignore config file | false | -| `-h` | `--help` | Show help | | -| `-V` | `--version` | Show version | | +## What happens when you press enter + +<figure class="flow plate corners"> + <figcaption class="flow__cap"><span class="flow__kind">One RustScan run, start to exit</span><span class="flow__dir">TD</span></figcaption> + <div class="flow__body"> + <div class="flow__diagram" data-dir="td"> + <div class="flow-rank"><div class="flow-node is-entry">FLAGS + CONFIG<span class="sub">~/.rustscan.toml overrides the CLI</span></div></div> + <div class="flow-edge"></div> + <div class="flow-rank"><div class="flow-node">TARGETS<span class="sub">CIDR · first DNS answer · file · minus -x</span></div></div> + <div class="flow-edge"></div> + <div class="flow-rank"><div class="flow-node">BATCH SIZE<span class="sub">-b, capped by your soft ulimit</span></div></div> + <div class="flow-edge"></div> + <div class="flow-rank"><div class="flow-node">CONNECT SWEEP<span class="sub">B sockets in flight · -t per try · --tries</span></div></div> + <div class="flow-edge"></div> + <div class="flow-rank"><div class="flow-node is-decision">-g or --scripts none?</div></div> + <div class="flow-branches"> + <div class="flow-lane"><div class="flow-edge"><span class="flow-edge__label">yes</span></div><div class="flow-node">PRINT<span class="sub">ip -> [ports]</span></div></div> + <div class="flow-lane"><div class="flow-edge"><span class="flow-edge__label">no</span></div><div class="flow-node">SCRIPTS, HOST BY HOST<span class="sub">default: nmap -vvv -p PORTS + your -- args</span></div></div> + </div> + <div class="flow-join"></div> + <div class="flow-rank"><div class="flow-node is-goal">EXIT 0<span class="sub">even if nothing was open or Nmap failed</span></div></div> + </div> + </div> +</figure> + +1. **Flags are merged with `~/.rustscan.toml`, and the file wins.** If the config sets `addresses`, + `batch_size`, `timeout`, `command` or most other keys, your command-line value is replaced. This is + the single most confusing behaviour in the tool. See [the config file](#the-config-file). +2. **Targets become a flat list of IPs.** A CIDR expands to every address in it, network and + broadcast included. A hostname becomes its *first* resolved address only. A filename is read + line by line. Duplicates and `-x` exclusions are dropped. +3. **The batch size is checked against your soft open-file limit**, and cut if it doesn't fit. +4. **Sockets are ordered port-major:** port 1 on every host, then port 2 on every host, and so on. + On a subnet the load spreads across hosts instead of piling onto one. +5. **A sliding window of B connects runs.** Each socket gets up to `--tries` attempts of `-t` + milliseconds each, and the moment one finishes the next one starts. A completed handshake means + open. Anything else (RST, timeout, unreachable) means not open, and the reason is only visible + with `RUST_LOG=debug`. +6. **Results are grouped per host.** Ports come out in the order they answered, not sorted. +7. **Scripts run per host, one after another, after the whole sweep has finished.** The default + script is `nmap -vvv -p <ports> -4|-6 <ip>` with your `--` arguments appended. Its output is + captured and printed only when it exits. +8. **The exit status is 0** whether anything was found or not, and whether Nmap worked or not. It's + only non-zero for a config parse error, no resolvable target, or a broken custom-scripts setup. --- -## Target Specification +## Install + +| Source | Version (2026-10-09) | Command | Notes | +|---|---|---|---| +| Kali repo | 2.4.1 | `sudo apt install rustscan` | amd64, arm64, armhf and i386, so it works on Apple Silicon Kali VMs | +| Homebrew | 2.4.1 | `brew install rustscan` | Pulls in `nmap` as a dependency | +| Arch (extra) | 2.4.1 | `sudo pacman -S rustscan` | Install `nmap` separately | +| Cargo | 2.4.1 | `cargo install rustscan` | The only method upstream officially supports. Install `nmap` separately | +| GitHub release | 2.4.1 | download `rustscan.deb.zip`, `unzip`, then `sudo dpkg -i rustscan_2.4.1-1_amd64.deb` | The 2.4.1 assets are zip-wrapped, and the `.deb` is amd64 only | +| Docker Hub | **2.3.0** | `docker run -it --rm rustscan/rustscan:2.3.0 -a $IP` | Stale (last pushed September 2024) and amd64 only, so it's emulated on Apple Silicon and arm64 Kali. 2.4.1 ships no Dockerfile | -### Single Target ```bash -rustscan -a 192.168.1.1 -rustscan -a scanme.nmap.org +rustscan --version # rustscan 2.4.1 +nmap --version # the default script runs whatever `nmap` is first on PATH ``` -### Multiple Targets -```bash -# Comma-separated -rustscan -a 192.168.1.1,192.168.1.2,192.168.1.3 +> [!tip] **Skip the Docker image.** Older guides call Docker "recommended" because the container's open-file limit is high. A native package with `-u 5000` solves the same problem, runs the current version, and doesn't add an emulation layer or a NAT hop between you and the target. If you do use the container, remember that `--rm` deletes anything Nmap wrote inside it, so mount a directory for `-oA`. -# Mixed IPs and hostnames -rustscan -a google.com,192.168.1.1,example.com -``` +--- -### CIDR Notation -```bash -# Scan entire subnet -rustscan -a 192.168.1.0/24 +## Flag reference (2.4.1, complete) + +| Flag | Default | What it really does | +|---|---|---| +| `-a, --addresses <list>` | (required) | Comma list of IPs, CIDRs and hostnames, or a path to a file. See [Targets](#targets) | +| `-p, --ports <list>` | | Comma list only. Ranges are rejected (`-p 80-90` errors). Can't be combined with `-r` | +| `-r, --range <start-end>` | `1-65535` | Used automatically when neither `-p` nor `-r` is given | +| `-e, --exclude-ports <list>` | | Removed from whatever `-p`, `-r` or `--top` produced | +| `-x, --exclude-addresses <list>` | | IPs, CIDRs or hostnames, removed after expansion | +| `--top` | off | Uses the `[ports]` table from your config. **With no table, it silently scans all 65,535.** See [`--top`](#--top-does-nothing-without-a-config) | +| `-b, --batch-size <n>` | 4500 | Maximum sockets in flight. Capped by your soft ulimit | +| `-t, --timeout <ms>` | 1500 | Per attempt, not per port | +| `--tries <n>` | 1 | Attempts per port. `0` is corrected to `1` | +| `-u, --ulimit <n>` | | Sets the soft and hard open-file limit for this process before the batch is sized | +| `--scan-order serial\|random` | serial | Order of the sweep. Changes nothing about what's found | +| `--scripts none\|default\|custom` | default | `default` runs Nmap. `none` prints `ip -> [ports]`. `custom` uses `~/.rustscan_scripts/` | +| `--udp` | off | UDP probe mode. See [UDP mode](#udp-mode) | +| `-g, --greppable` | off | Prints only `ip -> [ports]`. No Nmap, no scripts, no warnings | +| `--accessible` | off | No banner, no colour, plain `Open ip:port` lines. Good for logs too | +| `--no-banner` | off | Hides the ASCII banner only | +| `-c, --config-path <file>` | `~/.rustscan.toml` | Read a different config | +| `-n, --no-config` | off | Ignore the config file completely | +| `--resolver <list\|file>` | system | DNS servers used **only if the system resolver fails** the name | +| `-- <args>` | | Appended to every script's command line, then re-parsed by `sh -c` | +| `RUST_LOG=info\|debug` (env var) | | `info` adds a timing summary. `debug` dumps merged options, batch size and socket errors | + +> [!danger] **There is no `-q`.** Plenty of guides, including the previous version of this page, show `rustscan -q` for "ports only". 2.4.1 has no such flag: +> ```text +> error: unexpected argument '-q' found +> +> tip: to pass '-q' as a value, use '-- -q' +> ``` +> The flag you want is `-g`. -# Smaller subnet -rustscan -a 10.0.0.0/28 -``` +--- + +## Targets -### File Input ```bash -# targets.txt contains one IP/hostname per line -rustscan -a targets.txt +rustscan -a 10.10.11.5 +rustscan -a 10.10.11.5,10.10.11.6,dc01.corp.htb +rustscan -a 10.10.110.0/24 -x 10.10.110.1,10.10.110.254 +rustscan -a 2001:db8::15 # IPv6 works; the Nmap stage gets -6 automatically +rustscan -a scope.txt # one IP, CIDR or hostname per line ``` -**targets.txt example:** -```text -192.168.1.1 -192.168.1.2 -scanme.nmap.org -10.0.0.5 -``` +What bites: + +- **A hostname becomes one address.** RustScan takes the first answer the system resolver gives + back. In the lab, `-a localhost` scanned `::1` only, so a service bound to `127.0.0.1` came back as + "no open ports". Dual-stack names and round-robin DNS hide hosts this way. Resolve them yourself + (`dig +short`, `getent ahosts`) and pass IPs. +- **The system resolver goes first.** Your `/etc/hosts` entries work, which is what you want for + `*.htb` names. `--resolver` is a fallback for names the system can't resolve, not an override. +- **A CIDR includes the network and broadcast addresses.** A `/30` is 4 targets, not 2. +- **Bad lines in a target file are skipped without a word.** A `# comment` or a typo just fails to + resolve. Only a top-level `-a` value that can't be resolved prints `Host "x" could not be resolved.` + If nothing resolves at all, you get `No IPs could be resolved, aborting scan.` and exit status 1. +- **Nmap is handed the IP, not the name.** HTTP scripts in the Nmap stage send the IP as the Host + header. On a vhost box that's useful, because `http-title` reports the redirect target + (`Did not follow redirect to http://box.htb/`) and gives you the name. Once you have it, point + web tools at the name. +- **There is no host discovery.** Every address gets every port. On a dead address, each probe burns + the full timeout, so a mostly-empty /24 is very slow (see the [runtime table](#the-only-formula-you-need)). + Find live hosts first: -### Excluding Targets ```bash -# Exclude specific IPs from scan -rustscan -a 192.168.1.0/24 -x 192.168.1.1,192.168.1.254 +# Live hosts first. Unprivileged -sn only tries TCP 80/443, so run it as root, +# and add -PS22,445,3389 when Windows firewalls swallow ping. +sudo nmap -sn -PE -PS22,80,443,445,3389 10.10.110.0/24 -oG - | awk '/Up$/{print $2}' > live.txt +rustscan -a live.txt -u 5000 -g > open.txt ``` --- -## Port Scanning Options +## Ports -### Scan All Ports (Default) ```bash -rustscan -a 192.168.1.1 -# Scans ports 1-65535 +rustscan -a $IP # 1-65535 (the default range) +rustscan -a $IP -r 1-10000 +rustscan -a $IP -p 22,80,443,445,3389,5985 +rustscan -a $IP -e 21,23 # whatever the rules of engagement put off-limits ``` -### Top 1000 Ports -```bash -rustscan -a 192.168.1.1 --top -``` +### `--top` does nothing without a config -### Specific Ports -```bash -rustscan -a 192.168.1.1 -p 22 -rustscan -a 192.168.1.1 -p 22,80,443,8080 -rustscan -a 192.168.1.1 -p 21,22,23,25,53,80,110,443 -``` +`--top` reads the top-1000 list from the `[ports]` table of your config file. Upstream ships that +table in its example `config.toml`. Homebrew, apt and cargo don't install it. With no table, `--top` +is ignored and you get the full range: -### Port Range -```bash -rustscan -a 192.168.1.1 -r 1-1000 -rustscan -a 192.168.1.1 -r 8000-9000 +```text +$ RUST_LOG=debug rustscan -a 127.0.0.1 --top -g # no ~/.rustscan.toml + Number of ports 65535 +$ RUST_LOG=debug rustscan -a 127.0.0.1 --top -g # with the [ports] table installed + Number of ports 1000 ``` -### Exclude Ports +Two ways to get a real top-N: + ```bash -# Scan range but exclude specific ports -rustscan -a 192.168.1.1 -r 1-1000 -e 21,22,23 +# 1. Install upstream's [ports] table. If you already have a ~/.rustscan.toml, append instead of overwriting. +curl -fsSL https://raw.githubusercontent.com/bee-san/RustScan/2.4.1/config.toml \ + | sed -n '/^\[ports\]/,$p' > ~/.rustscan.toml +rustscan -a $IP --top -# Useful for avoiding honeypots or known services -rustscan -a 192.168.1.1 -e 80,443 +# 2. No config: build Nmap's current top-N from nmap-services and pass it with -p. +# Kali: /usr/share/nmap/nmap-services Homebrew: "$(brew --prefix)/share/nmap/nmap-services" +top=$(awk '$2 ~ /\/tcp$/ {print $3, $2}' /usr/share/nmap/nmap-services \ + | sort -rn | head -1000 | cut -d' ' -f2 | cut -d/ -f1 | paste -sd, -) +rustscan -a $IP -p "$top" ``` -### Scan Order -```bash -# Sequential (default) - ports in ascending order -rustscan -a 192.168.1.1 --scan-order serial +The `[ports]` table only ever applies when `--top` is passed. A normal run still scans the full +range, so it's safe to leave in place. -# Random - helps evade firewall detection -rustscan -a 192.168.1.1 --scan-order random -rustscan -a 192.168.1.1 -r 1-10000 --scan-order random -``` +### `--scan-order random` + +This shuffles the port list (a shuffle for `-p` lists, a linear congruential sequence for ranges). +The set of ports found doesn't change, and the target still sees tens of thousands of connections in +seconds. It's not evasion. If you need to be gentle, lower `-b`. --- -## UDP Scanning +## Speed, accuracy and the batch maths -RustScan supports UDP scanning with the `--udp` flag. Note that UDP scanning is inherently slower than TCP due to protocol differences. +### The only formula you need -```bash -# Basic UDP scan -rustscan --udp -a 192.168.1.1 - -# UDP on specific ports -rustscan --udp -a 192.168.1.1 -p 53,67,68,69,123,161,162,500 - -# UDP with port range -rustscan --udp -a 192.168.1.1 -r 1-1000 -``` - -### Common UDP Ports to Scan -| Port | Service | -|------|---------| -| 53 | DNS | -| 67/68 | DHCP | -| 69 | TFTP | -| 123 | NTP | -| 137-139 | NetBIOS | -| 161/162 | SNMP | -| 500 | IKE/IPsec | -| 514 | Syslog | -| 1194 | OpenVPN | -| 1900 | SSDP/UPnP | - -### Pass UDP Flags to Nmap -```bash -# Let Nmap handle detailed UDP analysis -rustscan -a 192.168.1.1 -- -sU +Each socket in the window either finishes quickly (open, or RST for closed) or burns its whole +timeout (filtered, or a dead host). In the worst case, where nothing answers: -# Combined TCP and UDP via Nmap -rustscan -a 192.168.1.1 -- -sS -sU -sV +```text +runtime ≈ (hosts × ports × tries ÷ batch) × timeout ``` -> **Important —** RustScan's native UDP mode (`--udp`) identifies ports that send back responses. For comprehensive UDP scanning, pipe results to Nmap with `-sU` for deeper analysis. +| Case (nothing answers) | Settings | Worst case | +|---|---|---| +| 1 host, all ports | `-b 4500 -t 1500` (defaults) | ~22 s | +| 1 host, all ports | `-b 4500 -t 1500 --tries 2` | ~44 s | +| 1 host, all ports | `-b 1000 -t 3000` | ~3.3 min | +| 1 host, all ports | `-b 500 -t 3000` | ~6.6 min | +| 1 host, all ports, stock macOS shell | batch silently cut to 128, `-t 1500` | ~12.8 min | +| /24, all ports | `-b 4500 -t 1500` | ~93 min | ---- +The floor is set by RSTs. 65,535 ports on loopback took **2.5 s** in the lab at `-b 500`, `4500` +and `10000` alike, because every closed port refused instantly. A real target lands somewhere +between that floor and the table, depending on how much of it is filtered. -## Performance Tuning +### Why a big batch misses ports -### Key Parameters +RustScan sends one SYN per try and never retransmits. With thousands of handshakes in flight, +anything that drops packets under load loses some SYN-ACKs, and those ports quietly report as not +open: -| Parameter | Effect | Trade-off | -|-----------|--------|-----------| -| **Batch Size** (`-b`) | Ports scanned simultaneously | Higher = faster but more resource-intensive | -| **Timeout** (`-t`) | Wait time per port (ms) | Lower = faster but may miss slow ports | -| **Ulimit** (`-u`) | Open file descriptor limit | Higher = allows larger batch sizes | -| **Tries** (`--tries`) | Retry attempts | Higher = more accurate but slower | +- **your side:** VM NAT engines (VMware, VirtualBox), the VPN client (OpenVPN on HTB and OffSec labs), + home-router connection tables +- **their side:** SYN-flood protection, per-source rate limits, host firewalls that drop rather than + reject -### Speed Profiles +The fix costs less than the problem: lower `-b`, raise `-t`, add `--tries 2`. A retry only costs +extra time on ports that don't answer. When it matters, run a gentle pass and diff it against the +fast one: ```bash -# Maximum speed (aggressive - CTF/lab only) -rustscan -a 192.168.1.1 -b 65535 -t 1000 -ulimit -n 70000 -rustscan -a 192.168.1.1 -b 65535 -t 500 +fast=$(rustscan -a $IP -u 5000 -g | sed -E 's/.*\[(.*)\]/\1/' | tr , '\n' | sort -n) +slow=$(rustscan -a $IP -u 5000 -b 1000 -t 3000 --tries 2 -g | sed -E 's/.*\[(.*)\]/\1/' | tr , '\n' | sort -n) +diff <(echo "$fast") <(echo "$slow") && echo "same ports both runs" +``` + +### How RustScan sizes the batch -# Fast (default-ish) -rustscan -a 192.168.1.1 -b 4500 -t 1500 +Before the sweep starts, RustScan compares the batch you asked for (B, default 4500) with your soft +open-file limit (U, from `ulimit -Sn`, or the value you gave `-u`): -# Balanced (recommended for real networks) -rustscan -a 192.168.1.1 -b 2500 -t 2000 --tries 2 +| Condition | Batch actually used | Seen in the lab | +|---|---|---| +| U ≥ B | B, unchanged | U=5000, B=4500 → 4500 | +| U < B and U < 3000 | U ÷ 2 | U=256 (macOS) → **128**; U=1024 (common on Kali) → **512** | +| U < B and 3000 ≤ U ≤ 8000 | U − 100 | U=4000, B=4500 → 3900 | +| U < B and U > 8000 | 3000 | U=10000, `-b 65535` → **3000** | -# Slow/stealth (avoid detection) -rustscan -a 192.168.1.1 -b 100 -t 5000 -rustscan -a 192.168.1.1 -b 10 -t 10000 --scan-order random +The last row is the trap. Asking for more than your limit when the limit is already high gets you +3000, not "as many as fit". If you want 9,900, ask for 9,900. -# Reliable (high latency networks) -rustscan -a 192.168.1.1 -b 1000 -t 4000 --tries 3 +What a cut batch looks like: + +```text +$ ulimit -n 256; rustscan -a 127.0.0.1 -r 18000-18500 --no-banner --scripts none +[!] File limit is lower than default batch size. Consider upping with --ulimit. May cause harm to sensitive servers +[!] Your file limit is very small, which negatively impacts RustScan's speed. Use the Docker image, or up the Ulimit with '--ulimit 5000'. ``` -### Setting Ulimit -```bash -# Check current limits -ulimit -a -ulimit -Hn # Hard limit -ulimit -Sn # Soft limit +When your limit is comfortably above the batch, you get a hint instead, and it can be ignored: +`[~] File limit higher than batch size. Can increase speed by increasing batch size '-b 1048476'.` -# Temporarily increase limit -ulimit -n 5000 +### Raising the limit -# Use RustScan's built-in ulimit flag -rustscan -a 192.168.1.1 -u 5000 +```bash +ulimit -Sn; ulimit -Hn # soft and hard limits for this shell +rustscan -a $IP -u 5000 # per run: the simplest fix, no system changes +ulimit -n 5000 # per shell, if you'd rather set it once (put it in ~/.zshrc or ~/.bashrc) ``` -### Platform-Specific Limits +`-u` sets both limits for RustScan and the Nmap it spawns. It works without root as long as the +value is at or below your hard limit. If the kernel refuses, the source prints +`[!] ERROR. Failed to set ulimit value.` and carries on with the old limit and a smaller batch. -| Platform | Default Limit | Recommended | -|----------|---------------|-------------| -| Ubuntu/Debian | ~8800 | 5000-10000 | -| macOS | ~255 | Increase to 1000+ | -| WSL | Not supported | Use Docker | -| Docker | High | No changes needed | +### Profiles + +| Situation | Flags | Why | +|---|---|---| +| Loopback, or a lab VM on the same host | defaults + `-u 5000` | RSTs come back instantly | +| HTB / OffSec VPN, one box | `-u 5000`, then compare against a gentler pass if anything looks thin | The VPN is usually what drops packets | +| Windows box with the firewall on | `-u 5000 -b 1000 -t 3000 --tries 2 -- -Pn` | Filtered ports eat full timeouts, and drops are common | +| Through a ligolo-ng tunnel | `-b 200 -t 3000 --tries 2` | The tunnel's userland TCP stack is the bottleneck | +| Fragile or production kit (printers, OT, old appliances) | `-b 100 -t 3000` | Gentle, not stealthy. The tool's own help warns these may not cope | +| A subnet | host discovery first, then `-a live.txt` | Dead addresses cost full timeouts on every port | --- -## Nmap Integration +## The Nmap hand-off -RustScan automatically runs `nmap -vvv -p $PORTS $IP` after finding open ports. Use `--` to separate RustScan flags from Nmap flags: +### What actually runs -```bash -rustscan -a <IP> -- <nmap_flags> +The built-in default script is one line: + +```text +nmap -vvv -p {{port}} -{{ipversion}} {{ip}} ``` -### Common Nmap Combinations +Your `--` arguments are appended to the end, `{{port}}` becomes the comma-joined open ports, and +`{{ipversion}}` becomes `4` or `6`. Captured from the lab, with `-- -Pn -sV`: + +```text +Open 127.0.0.1:18021 +Open 127.0.0.1:18022 +Open 127.0.0.1:18080 +Open 127.0.0.1:18445 +[~] Starting Script(s) +[>] Running script "nmap -vvv -p {{port}} -{{ipversion}} {{ip}} -Pn -sV" on ip 127.0.0.1 +Depending on the complexity of the script, results may take some time to appear. +[~] Starting Nmap 7.991 ( https://nmap.org ) at 2026-10-09 12:59 +0100 +... +PORT STATE SERVICE REASON VERSION +18021/tcp open ftp syn-ack +18022/tcp open ssh syn-ack (protocol 2.0) +``` + +The `Running script` line prints the template with its placeholders unfilled, and it only appears +when you passed `--` arguments. What follows from that one line: + +- **`-vvv` is always on.** Only a custom script can remove it. +- **One Nmap per host, one after another**, and only after the whole sweep. A /24 with 40 live hosts + means 40 sequential Nmap runs. +- **Output is held until Nmap exits.** On a domain controller with `-sC`, that can be minutes of + silence. Use `-oA`, or run Nmap yourself (see [two-stage](#two-stage-when-you-want-control)). +- **Nmap's stderr is discarded, and a non-zero exit throws its stdout away too.** You get + `[!] Error Exit code = 1` and nothing else. +- **Nmap missing from PATH** shows up as `[!] Error Exit code = 127`. + +### `-Pn`, every time + +Given a port list, Nmap still runs host discovery first. Unprivileged, that's a TCP connect to ports +80 and 443. As root, it's an ICMP echo, a SYN to 443, an ACK to 80 and an ICMP timestamp request. +If none of those get an answer, which is normal for a Windows box with its firewall on and no web +server, Nmap marks the host down and scans nothing, even though RustScan just found it open ports. +`-Pn` skips that check. RustScan has already done the proving. + +### Root-only Nmap flags fail quietly + +RustScan runs Nmap as you. Tested as a normal user: + +| `--` arguments | Result | +|---|---| +| `-sS`, `-O`, `-sU` | Nmap refuses to start. RustScan prints `[!] Error Exit code = 1` and that's all you get | +| `-A` | Runs, but unprivileged Nmap skips OS detection without any warning (it does the same when you run it directly) | +| `-sCV`, `--script ...`, `-oA` | Fine | + +You can `sudo rustscan ...`, but then RustScan reads the config and scripts from root's home, and +your output files belong to root. The cleaner fix is to only give Nmap root: +[two-stage](#two-stage-when-you-want-control). + +### Quoting through `--` + +RustScan joins your `--` arguments with spaces and hands the result to `sh -c`. The shell splits it +again, so any argument containing a space falls apart. This is a scope problem, not just a typo: + +```text +$ rustscan -a 127.0.0.1 -p 18080 --accessible -- -Pn --script 'banner or http-title' +Running script "nmap -vvv -p {{port}} -{{ipversion}} {{ip}} -Pn --script banner or http-title" on ip 127.0.0.1 +NSE: Loaded 1 scripts for scanning. +Performing system-dns for 2 domain names that were deferred +Nmap scan report for localhost (127.0.0.1) +Nmap scan report for or (0.0.0.0) +``` + +Nmap ran one script, then treated `or` and `http-title` as extra **targets** and resolved them. +With a DNS search domain configured, a word like that can resolve to a real host you were never +authorised to scan. Wrap the inner value a second time: ```bash -# Aggressive scan — runs: nmap -vvv -p $PORTS -A $IP -rustscan -a 192.168.1.1 -- -A +rustscan -a $IP -- -Pn --script "'banner or http-title'" +rustscan -a $IP -- -Pn --script "'(default or vuln) and not intrusive'" +``` -# Service version detection -rustscan -a 192.168.1.1 -- -sV -rustscan -a 192.168.1.1 -- -sV --version-intensity 5 +The same goes for `--script-args` values with spaces in them. Don't pass `-p` after `--` either: +the hand-off already sets `-p`, and Nmap quits with `Only 1 -p option allowed`. The same `sh -c` also expands `$(...)`, backticks, `;`, `|` and globs inside your `--` arguments. +Treat everything after `--` as a shell command line. If an Nmap argument needs quotes, it's usually +easier to run Nmap yourself. -# Default scripts + version -rustscan -a 192.168.1.1 -- -sC -sV +### Two-stage: when you want control -# OS detection -rustscan -a 192.168.1.1 -- -O +```bash +mkdir -p recon +ports=$(rustscan -n -a $IP -u 5000 -g | sed -E 's/.*\[(.*)\]/\1/') +echo "$ports" +sudo nmap -Pn -sS -sCV -O -p "$ports" -oA recon/$NAME $IP +``` -# Vulnerability scan -rustscan -a 192.168.1.1 -- --script vuln +You get live Nmap output, root-only flags, a real exit code, normal quoting, and `-n` keeps a stray +config from changing the sweep. Worth wrapping in a function (tested in bash and zsh): -# Single / multiple NSE scripts -rustscan -a 192.168.1.1 -- --script http-title -rustscan -a 192.168.1.1 -- --script "http-*" +```bash +# rs <ip> [name]: every TCP port with RustScan, then a live nmap -sCV on exactly those ports. +rs() { + local ip=$1 name=${2:-$1} ports v= + case $ip in *:*) v=-6 ;; esac # Nmap needs -6 for IPv6 targets + mkdir -p recon + ports=$(rustscan -n -a "$ip" -u 5000 -g | sed -E 's/.*\[(.*)\]/\1/' | tr , '\n' | sort -n | paste -sd, -) + [ -n "$ports" ] || { echo "rs: no open TCP ports on $ip" >&2; return 1; } + echo "rs: $ip -> $ports" + nmap $v -Pn -sCV -p "$ports" -oA "recon/$name" "$ip" +} +``` -# Script categories (use quotes for complex expressions) -rustscan -a 192.168.1.1 -- --script '"(vuln and safe) or default"' +--- -# Full enumeration -rustscan -a 192.168.1.1 -- -A -sC -sV -O --script=default,vuln +## Output, parsing and logging -# Stealth with Nmap -rustscan -a 192.168.1.1 -- -sS -T2 +### Normal output -# Output to file -rustscan -a 192.168.1.1 -- -oN scan.txt -rustscan -a 192.168.1.1 -- -oX scan.xml -rustscan -a 192.168.1.1 -- -oA scan_results +```text +[~] File limit higher than batch size. Can increase speed by increasing batch size '-b 1048476'. +Open 127.0.0.1:18021 +Open 127.0.0.1:18022 +[~] Starting Script(s) +[>] Running script "nmap -vvv -p {{port}} -{{ipversion}} {{ip}} -Pn" on ip 127.0.0.1 ``` ---- +`[~]` is information, `[>]` is an action, `[!]` is a warning. `Open ip:port` lines print live as +ports answer, so you can start reading the attack surface before the sweep finishes. The banner (and +its random quote) also prints the config path it looked for: +`[~] The config file is expected to be at "/home/kali/.rustscan.toml"`. -## Scripting Engine +### Greppable rules -RustScan has a built-in scripting engine supporting **Python**, **Shell**, **Perl**, and **Lua**. +- One line per host **that had open ports**: `10.10.11.5 -> [22,80,443]`. Hosts with nothing open + print nothing. +- With `-g`, no Nmap or scripts run, even if you passed `--` arguments. +- Ports are listed in the order they answered, and hosts in no particular order. Sort before you diff. +- Warnings are suppressed too. An empty result can mean "nothing open" or "something's wrong". When in + doubt, run once without `-g`. -### Script Levels ```bash -# No scripts - just port discovery -rustscan -a 192.168.1.1 --scripts none +# Comma list of ports for one host (portable: BSD and GNU sed, no grep -P) +rustscan -a $IP -g | sed -E 's/.*\[(.*)\]/\1/' -# Default scripts (includes Nmap) -rustscan -a 192.168.1.1 --scripts default +# Sorted, one per line +rustscan -a $IP -g | sed -E 's/.*\[(.*)\]/\1/' | tr , '\n' | sort -n -# Custom scripts -rustscan -a 192.168.1.1 --scripts custom +# Many hosts: save the sweep, then run Nmap per host, 4 at a time +mkdir -p recon +rustscan -a live.txt -u 5000 -g > open.txt +sed -E 's/^(.*) -> \[(.*)\]$/\1 \2/' open.txt \ + | xargs -P 4 -n 2 sh -c 'case $0 in *:*) v=-6 ;; *) v= ;; esac; nmap $v -Pn -sCV -p "$1" -oA "recon/$0" "$0"' ``` -### Custom Scripts Config (`rustscan_scripts.toml`) -```toml -# Location: same directory as rustscan binary or ~/.rustscan_scripts.toml +### Exit codes -[scripts] -ports_separator = "," -``` +| Situation | Exit status | +|---|---| +| Ports found, Nmap succeeded | 0 | +| No open ports at all | 0 | +| Nmap failed (root-only flag, bad argument, missing binary) | 0 | +| TOML error in the config | 1 | +| No target could be resolved | 1 | +| `--scripts custom` without `~/.rustscan_scripts.toml` | 1 | -### Python Script Example -```python -#!/usr/bin/python3 -#tags = ["core_approved", "example"] -#developer = ["yourname", "https://yoursite.com"] -#trigger_port = "80" -#call_format = "python3 {{script}} {{ip}} {{port}}" +So `rustscan ... && next-step` tells you nothing about the scan. Test the output instead, as `rs` +does above. -# Code below this point runs when port 80 is found -import sys +### Logging -ip = sys.argv[1] -port = sys.argv[2] +RustScan has no `-v`. Verbosity comes from the `RUST_LOG` environment variable: -print(f'Found HTTP on {ip}:{port}') -# Add your custom logic here +```bash +RUST_LOG=info rustscan -a $IP --scripts none # adds a timing summary +RUST_LOG=debug rustscan -a $IP -p 22 -g 2>&1 | less # everything below ``` -### Shell Script Example -```bash -#!/bin/bash -#tags = ["core_approved", "recon"] -#developer = ["yourname", "https://yoursite.com"] -#trigger_port = "21" -#call_format = "bash {{script}} {{ip}} {{port}}" +```text + RustScan Benchmark Summary + Portscan | 2.475484 s + Scripts | 0.000007959s + RustScan | 2.5862627 s +``` + +`debug` is the fastest way to answer "why did it do that?": + +- `Main() opts arguments are Opts { ... }` shows the final settings after the config merge. Check it + first whenever a flag seems to be ignored. +- `Batch size 128` shows the batch actually used, after the ulimit check. +- `Typical socket connection errors {"Connection refused (os error 61) ::1"}` lists the distinct + errors from the sweep. Refused means closed. Timeouts mean filtered or dead. Errors like + "Network is unreachable" mean the problem is your routing or VPN, not the target. + +--- + +## UDP mode -IP=$1 -PORT=$2 +`--udp` connects a UDP socket to each port, sends one probe per try, and waits `-t` for any reply. For +well-known services (DNS, NTP, SNMP, NetBIOS name service, IKE, IPMI, SSDP, mDNS, memcached, CLDAP and +others, 63 entries in total) the probe is Nmap's own payload from `nmap-payloads`, compiled into the +binary. Every other port gets an empty datagram. -echo "FTP detected on $IP:$PORT" -# Run additional enumeration -nmap -sV -p $PORT --script=ftp-anon,ftp-bounce $IP +- **A reply means open.** An ICMP port-unreachable means closed. **Silence isn't reported at all**, so + open services that ignore an empty or unexpected probe simply vanish. +- **It's slow and lossy over big ranges.** Linux rate-limits ICMP errors by default, so most closed + ports look silent and burn the full timeout. Keep `--udp` to short lists. +- **The default Nmap stage scans those port numbers over TCP**, not UDP. From the lab: + +```text +$ rustscan --udp -a 127.0.0.1 -p 15353 --accessible -- -Pn +Open 127.0.0.1:15353 +... +PORT STATE SERVICE REASON +15353/tcp closed unknown conn-refused ``` -### Script Variables -| Variable | Description | -|----------|-------------| -| `{{script}}` | Path to the script | -| `{{ip}}` | Target IP address | -| `{{port}}` | Discovered port(s) | +`-- -sU` fixes that, but needs root. The clean pattern is to find candidates with RustScan, then +version them with a root Nmap: -### Running External Tools ```bash -# GoBuster on HTTP ports -#call_format = "gobuster dir -u http://{{ip}}:{{port}} -w /usr/share/wordlists/common.txt" +# Ports with built-in probes that matter on HTB / CPTS style boxes +udp=$(rustscan -n -a $IP --udp -p 53,69,111,123,137,161,389,500,623,1900,5353,11211 \ + -t 2000 --tries 2 -g | sed -E 's/.*\[(.*)\]/\1/') +[ -n "$udp" ] && sudo nmap -Pn -sU -sV -p "$udp" $IP -# Nikto on web ports -#call_format = "nikto -h {{ip}} -p {{port}}" +# The broader alternative: let Nmap do UDP properly +sudo nmap -Pn -sU --top-ports 100 -sV --version-intensity 0 $IP ``` --- -## Configuration File +## The config file -Create `~/.rustscan.toml` for persistent defaults: +RustScan reads `~/.rustscan.toml` unless you pass `-c <file>` (use another file) or `-n` (use none). +If the file exists but doesn't parse, the run stops with `Found <error> in configuration file. +Aborting scan.` and exit status 1. -```toml -# ~/.rustscan.toml +### The file overrides your command line -# Target addresses (can be overridden via CLI) -addresses = ["127.0.0.1"] +Most guides present the config as defaults, but it doesn't behave like defaults. Any key it sets +**replaces** the matching command-line flag. Tested with a config that set +`addresses = ["127.0.0.1"]`, `batch_size = 50`, `timeout = 900`, `greppable = false` and +`command = ["-sV"]`: -# Performance settings -batch_size = 4500 -timeout = 1500 -tries = 1 -ulimit = 5000 +```text +$ RUST_LOG=debug rustscan -c cfg.toml -a 127.0.0.2 -b 2000 -g ... +addresses: ["127.0.0.1"] batch_size: 50 timeout: 900 greppable: false command: ["-sV"] + +$ RUST_LOG=debug rustscan -n -c cfg.toml -a 127.0.0.2 -b 2000 -g ... +addresses: ["127.0.0.2"] batch_size: 2000 greppable: true command: [] +``` -# Scan settings -scan_order = "serial" # or "random" -greppable = false -accessible = false +With that file in place, every scan goes to 127.0.0.1 whatever you type after `-a`, ignores `-b`, +and ignores your `--` arguments. Configs copied from older guides (this page's previous version +included) set `addresses = ["127.0.0.1"]` for exactly this reason. Delete that line. -# Port configuration -# ports = {80 = 1, 443 = 1, 8080 = 1} -# range = { start = 1, end = 1000 } +| Key | Type | Notes | +|---|---|---| +| `addresses` | array of strings | Replaces `-a`. **Never put this in a config** | +| `batch_size`, `timeout`, `tries` | integers | Replace `-b`, `-t`, `--tries` | +| `ulimit` | integer | Replaces `-u`. The one key worth setting | +| `greppable`, `accessible`, `udp` | booleans | Replace `-g`, `--accessible`, `--udp`. `greppable = false` beats `-g` | +| `scan_order` | `"Serial"` or `"Random"` | **Capitalised.** `"random"` is a parse error that aborts every scan | +| `scripts` | `"None"`, `"Default"` or `"Custom"` | **Capitalised**, same rule | +| `command` | array of strings | Replaces your `--` arguments entirely | +| `range` | `{ start = 1, end = 1000 }` | Replaces `-r` (`-p` still wins) | +| `exclude_ports`, `exclude_addresses` | arrays | Replace `-e`, `-x` | +| `resolver` | string | Replaces `--resolver` | +| `[ports]` table | `80 = 1` lines | Only read by `--top`; doesn't affect normal scans | -# Nmap arguments -command = ["-sV", "-sC"] +Keys you don't set leave the command line alone. Unknown keys are ignored, so the `ip = "127.0.0.1"` +line at the top of upstream's example file does nothing. -# Script level -scripts = "default" -``` +### A config that helps instead of hurting -### Minimal Config (Speed Focused) ```toml -batch_size = 5000 -timeout = 1000 -tries = 1 +# ~/.rustscan.toml: only settings you'd never want to change per scan ulimit = 5000 -scan_order = "serial" -``` -### Stealth Config -```toml -batch_size = 500 -timeout = 3000 -tries = 2 -scan_order = "random" +# Paste upstream's [ports] table below this line if you want --top to work (see Ports). ``` -### Ignore Config File -```bash -rustscan -n -a 192.168.1.1 -``` +Everything that changes per target (`-b`, `-t`, `--tries`, the Nmap arguments) belongs on the command +line or in a shell function like `rs`, where you can see it. --- -## Output Options +## The scripting engine -```bash -# Quiet mode (ports only) -rustscan -a 192.168.1.1 -q -# Output: 22,80,443 -rustscan -a 192.168.1.1 -q > ports.txt +`--scripts custom` swaps the built-in Nmap call for your own scripts. It's powerful, but the parser +is strict, and almost every mistake fails silently. Everything below was tested by planting scripts +in a sandboxed home directory. -# Greppable output -rustscan -a 192.168.1.1 -g +### Where things live -# Accessible mode (screen readers) -rustscan -a 192.168.1.1 --accessible +| Path | Purpose | +|---|---| +| `~/.rustscan_scripts.toml` | **Required.** Holds `tags = [...]`. If it's missing: `[!] Initiating scripts failed!` and exit status 1 | +| `~/.rustscan_scripts/` | Script files. Every file directly inside is parsed, subdirectories are not | -# Combine with Nmap output formats -rustscan -a 192.168.1.1 -- -oN results.txt -rustscan -a 192.168.1.1 -- -oX results.xml -rustscan -a 192.168.1.1 -- -oA results -``` +Custom mode **replaces** the default Nmap run. If you still want Nmap, ship it as a script (example 1 +below). + +### Which scripts get picked + +A script runs only if **every tag in the script also appears in `~/.rustscan_scripts.toml`**. The +config is an allow-list. A script tagged `["htb", "web"]` doesn't run when the config says +`tags = ["htb"]`, and it does once the config says `tags = ["htb", "web"]`. Upstream's docs describe +the opposite rule. The code does this one. + +### The header format -### Pipeline Examples ```bash -# Feed ports to another tool -rustscan -a 192.168.1.1 -q | xargs -I {} echo "Port: {}" +#!/usr/bin/env bash +#tags = ["htb"] +#developer = ["you"] +#ports_separator = " " +#call_format = "bash {{script}} {{ip}} {{port}}" -# Use with grep -rustscan -a 192.168.1.1 -g | grep "80" +echo "the script body starts after the first line that isn't a # line" ``` ---- +How RustScan reads it: -## Troubleshooting +1. **Line 1 is skipped** (the shebang, or anything else). +2. **From line 2, every consecutive line starting with `#` is collected.** All `#` characters are + stripped out of it and the result is parsed as TOML. +3. **The block ends at the first line that doesn't start with `#`.** Normally that's a blank line. -### Error: "Too Many Open Files" +What goes wrong: -This is the **most common error**. Solutions: +- **A normal comment directly under the header kills the script.** `# enumerate SMB` becomes the TOML + line `enumerate SMB`, the parse fails, and the script is dropped with no message (visible only with + `RUST_LOG=debug`). Always leave a blank line after the header. +- **`#` inside `call_format` is deleted**, because every `#` on the line goes. +- **`tags` must be an array.** `#tags = "htb"` fails to parse and the script is dropped. -```bash -# 1. Decrease batch size -rustscan -a 192.168.1.1 -b 500 +| Field | Effect | +|---|---| +| `tags` | Matched against the config's allow-list. A script without tags never runs | +| `call_format` | The command, run with `sh -c` after the placeholders are filled. Without it: `Error Failed to parse execution format.` | +| `ports_separator` | Joins the open ports for `{{port}}`. Default `,`. Use `" "` to get them as separate arguments | +| `port` | **Replaces `{{port}}` with this literal value for every host that had any open port.** It doesn't check that the port was open | +| `developer` | Parsed and ignored | +| `trigger_port` | **Not a field in 2.4.1.** It's silently ignored and the script receives every open port. Upstream's docs and example scripts still use it | -# 2. Increase ulimit -ulimit -Sn # Soft limit -ulimit -Hn # Hard limit -ulimit -n 5000 -rustscan -a 192.168.1.1 -# Or use RustScan's flag -rustscan -a 192.168.1.1 -u 5000 +Placeholders: `{{script}}` (the script's full path), `{{ip}}`, `{{port}}` (the joined open ports, or +the `port` value), `{{ipversion}}` (`4` or `6`). -# 3. Use Docker (best solution) -docker run -it --rm --ulimit nofile=5000:5000 rustscan/rustscan:latest -a 192.168.1.1 -``` +### How scripts run -### Error: Missing Ports / Inaccurate Results -```bash -# Increase timeout -rustscan -a 192.168.1.1 -t 3000 +- **One at a time, per host, after the sweep.** The order is directory order, not alphabetical, so + numbering files `10-`, `20-` doesn't sequence them. +- **`--` arguments are appended to every script's command line.** +- **Only stdout is shown, and only when the script exits.** stderr is discarded. Add `2>&1` inside + the script if you want it. +- **A non-zero exit throws the output away** and prints `[!] Error Exit code = N`. End scripts with + `exit 0` unless you really want that. -# Increase retries -rustscan -a 192.168.1.1 --tries 3 +### Three scripts that work -# Both -rustscan -a 192.168.1.1 -t 3000 --tries 2 -b 2000 -``` +All three were run together against the lab listeners. -### WSL Issues -WSL doesn't support ulimit properly. Use Docker, a native Linux VM, or WSL2 with Docker. +**1. Nmap with your defaults, saved per host.** This replaces the built-in call. -### macOS Low Limits -```bash -# macOS default is ~255 — increase it: -sudo launchctl limit maxfiles 65535 65535 -ulimit -n 5000 -rustscan -a 192.168.1.1 -b 4500 +```text +#!nmap +#tags = ["htb"] +#ports_separator = "," +#call_format = "nmap -vvv -Pn -sCV -p {{port}} -{{ipversion}} -oA recon/rs_{{ip}} {{ip}}" ``` -### Nmap Not Found +Save it as `~/.rustscan_scripts/10-nmap.txt`. It needs no interpreter, because `call_format` calls +`nmap` directly. Run it from a directory that contains `recon/`. + +**2. Find every web service, whatever the port.** Since `port` isn't a trigger, the script tests +each open port itself: + ```bash -# Install Nmap -sudo apt install nmap # Debian/Ubuntu -brew install nmap # macOS -sudo pacman -S nmap # Arch +#!/usr/bin/env bash +#tags = ["htb"] +#ports_separator = " " +#call_format = "bash {{script}} {{ip}} {{port}}" -# Or use --scripts none to skip Nmap -rustscan -a 192.168.1.1 --scripts none +# Every open port gets one HTTP and one HTTPS probe; anything that answers is a web service. +ip=$1; shift +for p in "$@"; do + for scheme in http https; do + out=$(curl -sk -m 4 -o /dev/null -w '%{http_code} %{redirect_url}' "$scheme://$ip:$p/" 2>/dev/null) + case $out in + 000*) ;; # nothing spoke HTTP here + *) echo "[web] $scheme://$ip:$p -> $out" ;; + esac + done +done +exit 0 ``` ---- +The redirect URL in the output is often the vhost you need for `/etc/hosts`. -## Real-World Examples +**3. Gate on a port yourself.** This is how to get the "only if 445 is open" behaviour people expect +from `trigger_port`: ```bash -# CTF / HackTheBox quick scan -rustscan -a 10.10.10.1 -b 500 -t 1500 -- -A -sC -sV - -# Bug bounty recon — fast web discovery across subnet, then enumerate -rustscan -a 192.168.1.0/24 -p 80,443,8080,8443 -q > web_servers.txt -cat web_servers.txt | xargs -I {} rustscan -a {} -- -sV --script http-title +#!/usr/bin/env bash +#tags = ["htb"] +#ports_separator = " " +#call_format = "bash {{script}} {{ip}} {{port}}" -# Pentest - full TCP enumeration -rustscan -a target.com -r 1-65535 -b 2500 -t 2000 -- -sV -sC -O -oA full_scan +# RustScan's `port` field is not a trigger, so the script gates itself. +ip=$1; shift +case " $* " in + *" 445 "*) nxc smb "$ip" -u '' -p '' --shares 2>&1; nxc smb "$ip" -u guest -p '' --shares 2>&1 ;; + *) echo "[smb] 445 closed on $ip, skipping" ;; +esac +exit 0 +``` -# Pentest - stealth scan -rustscan -a target.com -b 100 -t 5000 --scan-order random -- -sS -T2 +```bash +printf 'tags = ["htb"]\n' > ~/.rustscan_scripts.toml +mkdir -p recon && rustscan -a $IP -u 5000 --scripts custom +``` -# Internal network — discover hosts then full scan -rustscan -a 10.0.0.0/24 -p 22,445,3389 -q --timeout 2000 -rustscan -a discovered_hosts.txt -- -A +See [NetExec](/sheets/active-directory/netexec) for what to do with the SMB result. -# Web application testing -rustscan -a 192.168.1.1 -p 80,443,8000,8080,8443,9000,9443 -- -sV --script http-enum,http-headers +--- -# Database server discovery -rustscan -a 192.168.1.0/24 -p 1433,1521,3306,5432,27017,6379,9200 -q +## Workflows -# SMB / Windows enumeration -rustscan -a 192.168.1.1 -p 135,139,445 -- --script smb-enum-shares,smb-enum-users,smb-os-discovery +### One HTB / CPTS box -# Multiple targets from file with full analysis -rustscan -a targets.txt -b 1000 -t 2000 -- -sV -sC -O -oA network_audit -``` +```bash +export IP=10.10.11.5 NAME=box +mkdir -p recon ---- +# 1. Fast full sweep with versions and default scripts on the hits +rustscan -a $IP -u 5000 -- -Pn -sCV -oA recon/$NAME -## Advanced & Overlooked Techniques +# 2. If it's a Windows box, or anything looks thin, sweep again gently and compare +rustscan -a $IP -u 5000 -b 1000 -t 3000 --tries 2 -g -### The #1 Mistake: Forgetting `-Pn` on the Nmap Side -```bash -rustscan -a 10.10.10.5 -- -sC -sV -Pn +# 3. Quick UDP pass on the ports that matter (the Nmap half needs root) +udp=$(rustscan -n -a $IP --udp -p 53,69,111,123,137,161,389,500,623,1900,5353,11211 -t 2000 --tries 2 -g | sed -E 's/.*\[(.*)\]/\1/') +[ -n "$udp" ] && sudo nmap -Pn -sU -sV -p "$udp" -oA recon/${NAME}_udp $IP ``` -RustScan has *already proven the port is open* via a raw TCP connect before it ever hands off to Nmap. If Nmap's own host-discovery ping then fails (ICMP blocked, which is extremely common), Nmap reports "0 hosts up" and you **lose every result RustScan just found** — despite already knowing the host is alive. Always append `-Pn` after `--` when piping to Nmap. -### Debug Logging via `RUST_LOG` (Not a CLI Flag) -```bash -RUST_LOG=trace rustscan -a 10.10.10.5 -RUST_LOG=debug rustscan -a 10.10.10.5 -RUST_LOG=error rustscan -a 10.10.10.5 -``` -RustScan is built on Rust's `env_logger` crate, so verbosity is controlled by the `RUST_LOG` **environment variable**, not a documented `-v` flag. Fastest way to see batch sizing decisions, socket errors, and retry logic when a scan behaves unexpectedly. +Next steps by port are in [Service Enumeration](/sheets/pentest-workflow/service-enumeration) and +[Common Ports and Services](/sheets/enumeration/common-ports-and-services). This scan is stage 1 of +[Recon and Host Discovery](/sheets/pentest-workflow/recon-and-host-discovery). -### What "Adaptive Learning" Actually Tunes -The marketing term "Adaptive Learning" specifically means RustScan adjusts its **batch size relative to your detected ulimit** and connection success/failure rate as the scan runs — it is not a general AI/ML feature. On your first run against a new environment, leave batch size at default and let it self-tune; only override `-b` once you've observed how the target/network behaves. +### A Windows domain controller -### Why Full `-A` Piped Through RustScan Is Still Fast -Running `rustscan -a <target> -- -A` looks like it should be slow because `-A` is Nmap's heaviest flag. It isn't, because RustScan only hands Nmap the **specific ports it already found open** — Nmap never re-scans the full 65535-port range. Port discovery and deep enumeration are fully decoupled, which is the entire point of the tool. +A DC typically answers on 53, 88, 135, 139, 389, 445, 464, 593, 636, 3268, 3269, 5985 and 9389, plus a +run of RPC ports in the 49152-65535 range. That's 20-30 ports, and `-sC` against all of them can take +minutes, during which RustScan shows nothing because Nmap's output is held back. Use the two-stage +form, or `rs`, so you can watch Nmap work: -### Clean Port List Extraction for Scripting ```bash -# Extract a comma-separated port list from greppable output -rustscan -a 10.10.10.5 -g | grep -oP '\[\K[^\]]+' - -# Feed straight into a raw nmap command without --scripts overhead -ports=$(rustscan -a 10.10.10.5 -g | grep -oP '\[\K[^\]]+') -nmap -sC -sV -Pn -p $ports 10.10.10.5 +rs $IP dc01 ``` -Bypasses RustScan's built-in Nmap auto-invocation entirely, useful when you want full manual control over the exact Nmap command (custom scripts, output paths, timing). -### Batch Size vs Ulimit — the FD Overhead Nobody Accounts For -Setting `-b` equal to your exact ulimit (`ulimit -Sn`) still sometimes throws "Too many open files." Each scanning socket doesn't operate in isolation — stdin/stdout/stderr and other process-level file descriptors eat into the same limit. Rule of thumb: set batch size to roughly **ulimit minus 100–200** for headroom, e.g. `ulimit -n 5000` paired with `-b 4800`, not `-b 5000`. +### An internal subnet -### Docker Output Vanishes Without a Volume Mount ```bash -# WRONG — output file is inside the removed container, gone forever -docker run -it --rm rustscan/rustscan:latest -a 10.10.10.5 -- -oA scan - -# RIGHT — mount the current directory so output survives container removal -docker run -it --rm -v $(pwd):/data rustscan/rustscan:latest -a 10.10.10.5 -- -oA /data/scan +mkdir -p recon +sudo nmap -sn -PE -PS22,80,443,445,3389 10.10.110.0/24 -oG - | awk '/Up$/{print $2}' > live.txt +rustscan -a live.txt -u 5000 -g > open.txt +sed -E 's/^(.*) -> \[(.*)\]$/\1 \2/' open.txt \ + | xargs -P 4 -n 2 sh -c 'case $0 in *:*) v=-6 ;; *) v= ;; esac; nmap $v -Pn -sCV -p "$1" -oA "recon/$0" "$0"' ``` -`--rm` deletes the container (and anything written inside it) the instant the scan finishes — the single most common "why did my scan output disappear" issue with the Docker workflow. -### Config File Placement Detail -`~/.rustscan.toml` is read from your **home directory**, not the current working directory or the RustScan binary location shown in some older guides — a frequent source of "my config isn't being applied" confusion. Confirm the exact path RustScan is reading with `RUST_LOG=debug rustscan -a 127.0.0.1` and check the startup log lines. +Host discovery first, because RustScan has none. Then one sweep across every live host, then Nmap +in parallel instead of RustScan's one-host-at-a-time default. ---- +### Through a pivot -## RustScan vs Nmap +- **ligolo-ng, or any tun-style pivot:** RustScan works unchanged, because to it the internal network + is just a route. The tunnel's userland TCP stack is the bottleneck, so drop `-b` to a few hundred + and raise `-t` (see [ligolo-ng](/sheets/tunneling-pivoting/ligolo-ng)). +- **A SOCKS proxy (chisel, `ssh -D`) with proxychains:** RustScan has no proxy support, and thousands + of concurrent connects through one SOCKS proxy are slow and unreliable. Scan a short port list with + `proxychains -q nmap -sT -Pn -n` instead. -| Feature | RustScan | Nmap | -|---------|----------|------| -| **Speed (all ports)** | ~3-10 seconds | 15-20+ minutes | -| **Service Detection** | Via Nmap | Native | -| **OS Fingerprinting** | Via Nmap | Native | -| **Scripting** | Python/Lua/Shell/Perl | NSE (Lua) | -| **UDP Scanning** | Basic + Nmap | Full native | -| **Stealth Options** | Limited | Extensive | -| **Output Formats** | Basic + Nmap | Many formats | -| **Learning Curve** | Easy | Moderate | +--- -### Best Practice Workflow -```bash -# 1. Fast discovery with RustScan -rustscan -a target.com -q > ports.txt +## Troubleshooting by symptom + +| You see | Cause | Fix | +|---|---|---| +| `Looks like I didn't find any open ports for X. This is usually caused by a high batch size.` | Really nothing open, packet loss, a dead host, or the wrong address (a hostname that resolved to `::1`) | Pass an IP, try `-b 1000 -t 3000 --tries 2`, check `RUST_LOG=debug` socket errors | +| Different ports on each run | Drops under load: VPN, VM NAT, rate limits | Lower `-b`, raise `-t`, `--tries 2`, diff two runs | +| `[!] Your file limit is very small...` | Soft ulimit under 3000, batch halved | `-u 5000` | +| `-b 65535` scans slower than expected | Limit above 8000 but below B, so the batch dropped to 3000 | Ask for a B below your limit, for example `-b 9900` | +| `[!] Error Exit code = 1` after `Running script` | Nmap refused: root-only flag (`-sS`, `-O`, `-sU`), bad argument, or the `-oA` directory is missing | Two-stage with `sudo nmap`, `mkdir -p recon` | +| `[!] Error Exit code = 127` | `nmap` isn't on PATH | Install Nmap, or check PATH under sudo | +| Nmap says the host seems down, or `0 hosts up` | Nmap's own discovery failed | `-- -Pn` | +| Nmap scanned hosts called `or`, `and`, `not` | A space in an `--` argument got split | `-- --script "'a or b'"` | +| Your `-a`, `-b` or `--` args are ignored | The config file overrides them | `-n`, then fix `~/.rustscan.toml` | +| `Found TOML parse error at line 1, column 14` / `Aborting scan.` | Lowercase `scan_order` or `scripts` value, or other bad TOML | `"Random"`, `"Custom"` (capitalised) | +| `--top` takes as long as a full scan | No `[ports]` table in the config | See [`--top`](#--top-does-nothing-without-a-config) | +| UDP hit, but Nmap shows `/tcp closed` | The default Nmap stage scans TCP | `sudo nmap -sU` on the found ports | +| `Initiating scripts failed! No such file or directory (os error 2)` | `--scripts custom` without `~/.rustscan_scripts.toml` | Create it with a `tags` array | +| A custom script never runs | Tags not all allowed, a comment in the header block, or `tags` not an array | Fix the header, check `RUST_LOG=debug` | +| A port-specific script runs on every host | `port` overrides `{{port}}`, it isn't a trigger, and `trigger_port` doesn't exist | Gate inside the script (example 3) | +| A custom script's output vanished | Non-zero exit, or it wrote to stderr | `exit 0`, `2>&1` | +| A panic: `Too many open files. Please reduce batch size...` | Batch above what the OS actually allows | Lower `-b`, raise `-u` | -# 2. Detailed analysis with Nmap -nmap -sV -sC -p $(cat ports.txt | tr '\n' ',') target.com -oA detailed_scan +--- -# Or combined in one command: -rustscan -a target.com -- -sV -sC -A -oA combined_scan -``` +## Noise, safety and scope + +- **Nothing about RustScan is quiet.** Every open port gets a complete TCP connection that the service + accepts and then sees closed. SSH daemons log it, many web servers log it, and any IDS sees tens of + thousands of connections from one source in seconds. Random order and a small batch only make that + take longer. +- **Fragile targets can fall over.** RustScan's own help text warns that a server may not handle this + many simultaneous connections. Printers, embedded and OT devices, old appliances and small VPSs get + `-b 100` or less. +- **Scope:** use `-x` for out-of-scope addresses inside an in-scope range. A CIDR includes the network + and broadcast addresses. A hostname resolves to one address, which for a CDN-fronted name may not + be the client's. And an unquoted `--` argument can add targets of its own. --- -## Quick Reference Card +## RustScan vs the alternatives -```bash -rustscan -a <target> # Basic scan -rustscan -a <target> -p 22,80,443 # Specific ports -rustscan -a <target> -r 1-1000 # Port range -rustscan -a <target> --top # Top 1000 ports -rustscan --udp -a <target> # UDP scan -rustscan -a <target> -b 5000 -t 1000 # Fast scan -rustscan -a <target> -b 100 -t 5000 --scan-order random # Stealth scan -rustscan -a <target> -- -sV -sC -A # With Nmap scripts -rustscan -a <target> -q # Quiet (ports only) -rustscan -a target1,target2,target3 # Multiple targets -rustscan -a targets.txt # From file -rustscan -a 192.168.1.0/24 # CIDR range -rustscan -a <target> -e 22,80 # Exclude ports -rustscan -a <target> -b 2500 -t 2000 -- -A -sC -sV -O # Full enumeration -``` +| | RustScan | `nmap -p- --min-rate` | masscan | naabu | +|---|---|---|---|---| +| How it scans | async `connect()` window | SYN as root (connect otherwise), with retransmits and adaptive timing | raw SYN from its own TCP stack, at a fixed packet rate | SYN as root, or connect | +| Root needed | no | for SYN | yes | for SYN | +| Host discovery | no | yes | no | yes | +| Reports closed ports | no | yes, and filtered separately | only with `--show closed` | no | +| Nmap hand-off | built in | not needed | manual | built in (`-nmap-cli`) | +| Best at | one box, every port, fast, no root | accuracy, the final word on a port | very large ranges | ProjectDiscovery pipelines | + +Whatever finds the ports, Nmap owns the service and version truth. The plain-Nmap two-stage pattern +is in [Recon and Host Discovery](/sheets/pentest-workflow/recon-and-host-discovery), Nmap itself is in +[Nmap](/sheets/enumeration/nmap) and [NSE Guide](/sheets/enumeration/nse-guide), and parsing Nmap +output is in [Awesome Nmap Grep](/sheets/enumeration/awesome-nmap-grep). --- -## References +## Quick reference -1. [RustScan — GitHub](https://github.com/bee-san/RustScan) -2. [Installation Guide](https://github.com/bee-san/RustScan/wiki/Installation-Guide) -3. [Common Problems and Solutions](https://github.com/bee-san/RustScan/wiki/Common-Problems-and-their-Solutions) -4. [Nmap Custom Flags](https://github.com/bee-san/RustScan/wiki/Nmap-Custom-Flags) -5. [RustScan Scripting Engine](https://github.com/bee-san/RustScan/wiki/RustScan-Scripting-Engine) -6. [Config File](https://github.com/bee-san/RustScan/wiki/Config-File) -7. [Debugging RustScan](https://github.com/bee-san/RustScan/wiki/Debugging-RustScan) +```bash +rustscan -a $IP -u 5000 -- -Pn -sCV -oA recon/$NAME # the default first scan +rustscan -a $IP -u 5000 -g # ports only: ip -> [p,p,p] +rustscan -a $IP -u 5000 -b 1000 -t 3000 --tries 2 -- -Pn -sCV # lossy link or Windows firewall +rustscan -a $IP -p 22,80,443,445 # specific ports (no ranges in -p) +rustscan -a $IP -r 1-10000 # a range +rustscan -a $IP -e 21,23 # exclude ports +rustscan -a 10.10.110.0/24 -x 10.10.110.1 # subnet minus an address +rustscan -a live.txt -g > open.txt # file of targets +rustscan -n -a $IP -g # ignore ~/.rustscan.toml +rustscan -a $IP -- -Pn --script "'default or vuln'" # NSE expressions: double-quote them +rustscan -n -a $IP --udp -p 53,123,161,500 -t 2000 --tries 2 -g # UDP candidates (then sudo nmap -sU) +rustscan -a $IP --scripts custom # run ~/.rustscan_scripts/* +RUST_LOG=debug rustscan -a $IP -p 22 -g # see merged options, batch, errors +``` diff --git a/src/styles/flow.css b/src/styles/flow.css @@ -234,6 +234,8 @@ align-self: stretch; height: 1.6rem; margin-top: 0.1rem; + /* Room for the drop line and arrowhead below; the diagram has no gap. */ + margin-bottom: 1.55rem; border-top: 1px solid var(--foam); border-left: 1px solid var(--foam); border-right: 1px solid var(--foam); diff --git a/src/styles/global.css b/src/styles/global.css @@ -945,7 +945,7 @@ main, .site-header, .site-footer { position: relative; z-index: 2; } reading column laid out on it leaves a dead third between the prose and the contents rail. The sheet pages get their own, narrower one. */ .sheet-layout { - display: grid; grid-template-columns: 1fr; + display: grid; grid-template-columns: minmax(0, 1fr); gap: 2.5rem; margin-top: 2.2rem; max-width: 1080px; }