Skip to main content

tcpsweep

A TCP connect sweep built for proxychains, in a single dependency-free Python file.

proxychains4 tcpsweep 10.0.0.0/24 -p 22,80,443

A connect scan is the only kind of scan that survives a SOCKS proxy — no raw sockets, no SYN, no ICMP — so it is the right primitive. But a connect scan through a proxy behaves nothing like a direct one, and most scanners report confidently wrong results because of it. This tool is designed around the difference.

Authorized use only. Only scan hosts and networks you own or have explicit permission to test.

What running through a chain actually does

These are measured against a fault-injecting SOCKS5 server, not assumed:

SOCKS outcome what Python sees elapsed
success connected 0.01s
refused (0x05) ECONNREFUSED 0.00s
host unreachable (0x04) ECONNREFUSED 0.00s
network unreachable (0x03) ECONNREFUSED 0.00s
denied by ruleset (0x02) ECONNREFUSED 0.00s
general failure (0x01) ECONNREFUSED 0.00s
proxy hung ECONNREFUSED = tcp_read_time_out
proxy dead ECONNREFUSED 0.00s

Three consequences drive the whole design:

errno carries no information. Everything is ECONNREFUSED. Only elapsed time separates a definitive answer from a black hole, so every classification here is time-based. A negative that came back instantly is the chain's real answer; one that consumed half the read timeout or more is a stall, and is reported filtered rather than closed.

Your timeout is inert. The SOCKS handshake happens inside proxychains' hooked connect(), so settimeout() does not bound a probe — the config's tcp_read_time_out does. tcpsweep reads the live config and shows the real budget instead of pretending -w applies. Raise -c to go faster, not lower -w.

A dead proxy is identical to "everything closed." Both are an instant ECONNREFUSED. Without a control target a sweep cannot tell a clean negative result from a broken chain, so tcpsweep keeps a canary — the first open port it finds, or one you name with --canary — and re-probes it after a long run of negatives, and once more when the sweep ends, so the last stretch of results is not believed on no evidence — provided there is a control target to ask (an open port found along the way, or yours). If it has stopped answering, every unverified result is withdrawn and re-run, and if the chain never returns the tool exits 3 rather than reporting a tidy, wrong, empty result. A control target that keeps dropping without anything being proven in between (ten times running) ends the run the same way instead of going round for ever.

This is chain policing, so it applies under a proxy — or whenever you name a --canary. A direct scan has no chain to police: a refusal is the target's own, and treating a service that stops answering as "chain down" would only turn a one-shot listener or an IPS ban into a two-minute wait. Direct results are trusted, and journalled, as they arrive.

Nothing open is only a clean negative if something confirmed the chain. Through a proxy, a sweep that probed something but found no open port of its own and never got an answer from a control target is exactly what a dead proxy looks like, so it exits 3 rather than 1 — and an open port carried over from a --resume journal does not turn that into a 0: an earlier run proved it, and it is this run's probes that are in doubt. Name a --canary — an open port you know is reachable — to make a negative conclusive.

A proxy can also lie. A canary proves the chain is alive; it cannot prove it is honest. Some SOCKS servers answer every CONNECT with success regardless of the target — every port on every host then reads as open, the canary passes trivially, and the scan looks perfect while being entirely fabricated. Observed on a real chain: 192.0.2.1, an RFC 5737 address that cannot be routed, came back open on 22, 80 and 12345 alike.

So tcpsweep probes an address that must never be connectable, in parallel with the sweep. If it answers, the run stops and exits 3 with every result marked untrustworthy. This is the failure mode behind the classic "the scanner said port 80 was open but curl times out" — -b exposes it for protocols that speak first, like SSH, but HTTP never speaks first.

Nothing is written to stdout or to the --resume journal until that probe has concluded, so a | while read pipeline never acts on a fabricated hit and a later resume never replays one. The price is that, on an honest chain, output is released the moment the probe concludes — about one tcp_read_time_out after the start — instead of at once. If the probe never concludes, open ports are shown with a warning but nothing is journalled, since a journal is replayed without being probed again. Ctrl+C, SIGTERM and SIGHUP flush what is held; a kill -9 inside that window loses it. --no-sanity skips the probe and streams immediately; the chain_sanity field of --json records how the check ended (passed, failed, unconfirmed or skipped).

Efficiency

Through a chain the cost model is lopsided: an open port and a fast negative are nearly free, while a stalled probe costs the entire read timeout. Sweep time is therefore dominated by stalls.

So a multi-host sweep runs a short discovery pass first. Hosts that answer anything — open or a fast negative — are kept, because sweeping them is cheap. Hosts where every discovery probe stalled are skipped, because those are exactly the ones that would burn the full timeout on every port. Discovery results are reused, never re-probed, and discovery only uses ports you asked for: -p 445 costs one probe per host, not six. The liveness ports default to 80,443,22,3389,445,8080 where -p includes them, and to the first few of your list where it does not; --discover-ports names them explicitly.

Measured against 8 hosts where 4 black-hole everything: 4.1s with discovery, 8.1s without, identical findings. The gap widens with more ports per host.

Concurrency is the one lever that matters, and it scales linearly (16 stalling probes: 64.1s serial → 4.0s at -c 16).

Install

pipx install tcpsweep      # isolated, recommended
pip install tcpsweep

No third-party dependencies, so you can also just copy tcpsweep.py onto a host. It identifies itself by whatever name you invoke it under.

Usage

proxychains4 tcpsweep 10.0.0.0/24                  # top 20 ports, discovery on
proxychains4 tcpsweep 10.0.0.0/16 -p 445 -c 64     # one port, wide, fast
proxychains4 tcpsweep 10.0.0.5 -p 1-1024 --no-discover
proxychains4 tcpsweep -iL targets.txt -p 22,80 --canary 10.0.0.1:22
proxychains4 tcpsweep -iL targets.txt --scope scope.txt --dry-run   # check first, send nothing
tcpsweep 10.0.0.0/24 -p 80 --json out.json         # direct, no proxy

Targets accept 10.0.0.1, 10.0.0.0/24 (every address of the block, network and broadcast included), 10.0.0.1-20, 10.0.0.{1,5-9}, hostnames, -iL FILE (- for stdin) and --exclude. Run --help for the full option list.

Open ports stream to stdout as host port, flushed, so the tool pipes:

proxychains4 tcpsweep 10.0.0.0/24 -p 445 | tee found.txt | while read ip port; do
  echo "hit $ip:$port"
done

Progress, warnings and the summary go to stderr, so they never contaminate the pipeline. An open port is never withdrawn, so anything that reaches stdout is final even if the run is interrupted. Under a proxy, open ports are held back until the honesty probe has concluded (see above).

Scanning over ssh -D

A dynamic forward is a SOCKS5 proxy, so it works with proxychains as is (socks5 127.0.0.1 1080 in proxychains.conf):

ssh -fN -D 1080 pivot
proxychains4 tcpsweep 10.0.0.0/24 -p 22,80,443

Quieting the log flood. Every closed port makes ssh log channel N: open failed: connect failed: Connection refused, and proxychains prints a [proxychains] ... <--socket error or timeout! line for every failed connect. On a /24 that buries the results. They are only messages — a loopback test gave identical results with them muted:

ssh -fN -D 1080 -o LogLevel=ERROR pivot        # no "channel N: open failed" lines
proxychains4 -q tcpsweep 10.0.0.0/24           # or `quiet_mode` in proxychains.conf

ssh -D reports what the remote connect() really did, so it is an honest chain: if you trust the tunnel, --no-sanity skips the fabrication probe and lets open ports stream at once instead of after about one read timeout.

Or scan from the pivot. tcpsweep is a single, dependency-free file, so ssh can feed it straight to the pivot's Python — nothing to install or copy:

ssh pivot python3 - 10.0.0.0/24 -p 22,80,443 -w 1 -c 128 < tcpsweep.py | tee found.txt

Open ports stream back on stdout, progress and the summary on stderr, and the exit code passes through (0 found, 1 nothing open; 255 is ssh itself failing). Nothing is forwarded, so there is nothing to log and no per-probe round trip through the tunnel. The scan also runs direct: -w applies, and closed versus filtered comes from the real errno rather than from timing alone — through a tunnel, refused, unreachable and denied all look alike — and with no chain to police, results are trusted and journalled as they arrive. The targets see the same thing either way, connects from the pivot, so this changes where the noise lands and how fast the sweep goes, not what is sent; use --rate if your rules of engagement cap it.

  • Needs python3 (3.8+) and command execution on the pivot. A forwarding-only account will not do; use the quiet tunnel above.
  • stdin carries the script, so -iL - cannot be used. Pass CIDRs as arguments, or -iL FILE with a file that is on the pivot.
  • If the SSH session drops, the run carries on: writes to the vanished stdout and stderr are ignored, a SIGHUP is treated like Ctrl+C (it stops the sweep and writes the report), and --json and --resume files are still written. With neither of them the findings have nowhere to go, so add one to long sweeps. Both files live on the pivot and hold scan results: scp them back and delete them.
  • With | tee, $? is tee's. Read the tool's status from ${PIPESTATUS[0]} (bash; $pipestatus[1] in zsh) or use set -o pipefail.

Saving results. stdout is the finding list, so | tee found.txt keeps it. --json out.json writes the full structured report (atomically, 0600; it records how the run was made — argv, ports, scope — and how it ended — complete, exit_code, chain_*), and --resume sweep.jsonl journals as it goes.

Guard rails

Mistakes in a scan are cheap to make and slow to notice, so a few are refused before anything is sent.

--dry-run checks everything a real run would — targets, --exclude, --scope, --max-probes, the --json and --resume paths — prints what would be scanned, and sends no probe (a hostname is still resolved, since it has to be to be checked). It exits 0. Run it first:

proxychains4 tcpsweep -iL targets.txt --scope scope.txt --dry-run

--scope FILE is an allowlist: networks, single addresses and ranges (10.0.0.1-20, 10.0.0.1-10.0.0.9), one per line, # for comments. Every target, and every --canary (it is probed too), has to fall inside it, and the check happens before a single probe goes out (a hostname is resolved first, so DNS does happen). A target outside is an error, not something quietly dropped. A name cannot be checked — under proxy_dns what it resolves to is the hook's answer, not the host's address, even when that looks like one inside the scope — so it is refused: give addresses. The one probe outside the scope is the fabrication check's, to 192.0.2.1 (reserved, never routable); --no-sanity skips it. An empty --scope "" (an unset shell variable) is an error, not a way to turn the allowlist off — the same goes for --json and --resume.

--require-proxy refuses to run unless the proxychains hook is loaded — actually mapped into the process, not merely named in LD_PRELOAD, which the dynamic linker ignores when the file is not there — against forgetting proxychains4 and scanning from your own address. export TCPSWEEP_REQUIRE_PROXY=1 (only 1, true, yes or on count) makes it the default on your workstation and --no-require-proxy overrides that for one run. Do not set it where you scan direct on purpose, such as a pivot.

--max-probes N (default 5,000,000; 0 removes it) refuses a sweep of more than N probes — hosts × ports — before anything is expanded. Everything is held in memory — measured at about 340 bytes a host plus 160 a probe — and a network becomes a list before the first packet: a /8 typed for a /28 costs tens of seconds and gigabytes. --exclude of a network does not expand it either, so excluding a /8 from a /16 is free.

A network covers every address of its block, network and broadcast included, as nmap does: in a supernet or a mid-range block such as 10.0.0.128/25 they are ordinary hosts, and dropping them silently missed real machines.

Resuming an interrupted sweep

With a 30s tcp_read_time_out, a wide sweep runs for hours, so losing it to one Ctrl+C is not acceptable. --resume FILE journals the sweep as it goes and, on a re-run, skips what the file already holds:

proxychains4 tcpsweep 10.0.0.0/16 -p 445 --resume sweep.jsonl
# ^C, or the box reboots, or the chain dies
proxychains4 tcpsweep 10.0.0.0/16 -p 445 --resume sweep.jsonl   # picks up where it stopped

Each line is flushed as it is written. The journal holds only what the chain has vouched for: an open port goes in as soon as the honesty check allows (at once when scanning direct or with --no-sanity), because it is its own proof, but a negative is written only once a later open port or control-target probe shows the chain was alive after it. So an interruption — or a chain that died a moment before one — costs the negatives since the last proof (fewer than --canary-after of them) and they are probed again, instead of being carried forward as fake "closed" results. The other side of that: a sweep with no canary and no open port has proven nothing and journals no negatives, so pass --canary for long sweeps. Like stdout, the journal is not written until the honesty probe has concluded, so a fabricating proxy leaves nothing to replay. Open ports are re-emitted to stdout on a resumed run, so a pipeline still sees the complete finding set.

Resume is never automatic — there is no default path and no auto-discovery. An earlier version resumed whatever state file sat next to the output name, which meant a finished scan's results were replayed as though they were live, reporting ports open with no packet sent. Here you name the file, and the number of carried results is printed where you cannot miss it.

Carried results are scoped to the current run (a journal from a wider sweep cannot smuggle in hosts you did not ask about), a journal over a day old draws a warning, and carried results never count as proof the chain works — that has to be earned by a live probe.

Exit codes

Code Meaning
0 completed, at least one open port
1 completed, nothing open (under a proxy: and something confirmed the chain)
2 bad arguments
3 the run cannot be trusted: the chain failed and never came back, the proxy fabricates connections, probes could not be sent, the tool itself crashed, or a proxied sweep probed something, found no open port of its own and never confirmed the chain
130 interrupted (Ctrl+C, SIGTERM or SIGHUP; a second Ctrl+C aborts at once, without the report)

0/1 are grep-style, so if tcpsweep ...; then branches on "found something". 3 is the one that matters for automation: it means don't believe this run.

Upgrading from 0.4

What behaves differently, in the order you are likely to notice:

  • Under a proxy, stdout and the --resume journal wait for the fabrication check (about one tcp_read_time_out). --no-sanity restores streaming.
  • A proxied run that probed something, found no open port of its own and never confirmed the chain exits 3, not 1 (or 0 on carried results); add a --canary. A crash, lost probes and a fabricating proxy exit 3 too.
  • A network covers its first and last address, and -p no longer widens discovery.
  • Refused up front: numeric shorthand (010.0.0.1), an --exclude that cannot work under proxy_dns, a directory for --json, a sweep over --max-probes.
  • Direct scans are trusted as they arrive; the chain checks apply under a proxy or with --canary.
  • The proxy is detected by its hook, not by PROXYCHAINS_CONF_FILE.
  • SIGTERM and SIGHUP stop the sweep and write the report; a second Ctrl+C aborts.

Notes

  • IPv4 only, deliberately: proxychains handles IPv4 TCP, and silently scanning something else would be worse than refusing.
  • With proxy_dns, a hostname resolves to a synthetic placeholder — 224.0.0.x by default, or an address in the remote_dns_subnet the config names. The sweep reaches the right host but results are labelled with that address; tcpsweep warns when it sees one. Scan literal IPs if the output matters.
  • Through a chain, closed only means the proxy answered fast — it cannot be told apart from host-unreachable or a ruleset denial. The summary says so.
  • --json output is written atomically and 0600, since scan results are sensitive, and the path is checked before the sweep starts rather than after it (a directory is refused). A link to a regular file is replaced, not followed, so a symlink planted where the report goes cannot make the tool overwrite something else; a pipe, a tty or a device is written into, and nothing directly under /dev is ever replaced.
  • The proxy is detected by the hook itself (LD_PRELOAD, or the library being mapped into the process), not by PROXYCHAINS_CONF_FILE alone: a stale exported variable with no hook runs direct, and says so, instead of having direct connects classified as proxied.
  • A target the resolver would read as a different address is refused rather than resolved: 010.0.0.1 is octal for 8.0.0.1 and 192.168.1 is 192.168.0.1 — so an address must be written out in full, without leading zeros, whichever Python you run (older ipaddress versions accept them). Under proxy_dns, --exclude by hostname is refused too — the name resolves to a placeholder that can never match a real target, so the exclusion would silently do nothing — and so is any --exclude while a target is a hostname: its placeholder cannot be matched by an address either. Give addresses.
  • Ctrl+C, SIGTERM and SIGHUP stop the sweep and write the report. A signal that is already set to be ignored stays ignored: nohup does that to SIGHUP, so a nohup sweep still outlives its terminal.
  • -c beyond the file-descriptor limit raises the limit as far as the hard limit allows and otherwise lowers -c with a warning. Under proxychains each in-flight probe holds two descriptors (it dials the chain on a second socket), and running short makes the hook's own socket() fail — which the scanner sees as an instant refusal, a fake "closed" — so the budget counts both. A probe that cannot even get a socket is retried, then reported missing and the run exits 3; it is never recorded as a stall, and a run of them stops the sweep.
  • A crash exits 3, not Python's 1 (which here means "nothing open"), and a closed stdout no longer changes the exit status either.
  • Banners (-b) are reduced to printable ASCII before they touch your terminal or the JSON.

Development

python3 -m pytest test_scan.py -q

The suite needs no root and no network beyond loopback, and it never writes under /dev. Run it as an ordinary user.

Metadata

Release files for tcpsweep 0.5.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for tcpsweep 0.5.0
File Size Uploaded
tcpsweep-0.5.0.tar.gz 71.9 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for tcpsweep 0.5.0
File Interpreter ABI Platform
tcpsweep-0.5.0-py3-none-any.whl Python 3 none any Details

Total release size: 113.9 kB

Release files / tcpsweep-0.5.0.tar.gz

Download URL tcpsweep-0.5.0.tar.gz
Size 71.9 kB
Tags Source
SHA-256 checksum
How to use checksums
4f5e411fc5d1b1c2b64d6a9240a35c0da22822c50bd26078ad08427238dfe2a0
BLAKE2b-256 checksum
How to use checksums
8740a273e710719f3997964056405dc0d2dda384e7cdf6c133f53f0fd4c89a89
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 30, 2026.

Transparency log

Release files / tcpsweep-0.5.0-py3-none-any.whl

Download URL tcpsweep-0.5.0-py3-none-any.whl
Size 42.0 kB
Tags Python 3
SHA-256 checksum
How to use checksums
7d2459804c328ec0b7c5ae93e6114ba462d1df569218f81f3b11ad6682fb5612
BLAKE2b-256 checksum
How to use checksums
a8deacdf632ad847a437ae41d7e8c6108e3dd4a810768d02eeeaaa5ecf74b803
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 30, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.5.0 This release

2 release files

0.4.0

2 release files

0.3.1

2 release files

0.3.0

2 release files

0.2.1

2 release files

0.2.0

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page