2026-03-08 19:00:33 -04:00
2026-03-08 19:00:33 -04:00
2026-03-08 17:55:19 -04:00
2026-03-08 13:42:58 -04:00
2026-03-08 13:28:31 -04:00
2026-03-08 13:28:31 -04:00
2026-03-08 13:42:58 -04:00

portspoof-py

A Python asyncio rewrite of portspoof — a TCP honeypot that makes every port on a machine appear open and running a real service.

When a port scanner connects to any TCP port, it receives a plausible fake service banner (SSH, HTTP, FTP, etc.). The banners are randomised from a signature library at startup, so the scanner cannot fingerprint the deception from banner patterns alone.


Table of contents

  1. How it works
  2. Requirements
  3. Installation
  4. Quick start
  5. CLI reference
  6. Signature file
  7. Config file
  8. iptables rules
  9. JSON log
  10. Web admin interface
  11. Email alerts
  12. systemd service
  13. Recipes
  14. Troubleshooting

How it works

attacker                   kernel                    portspoof-py
   │                          │                           │
   │─── TCP SYN → port 443 ──►│                           │
   │                          │  iptables REDIRECT        │
   │                          │──► port 4444 ────────────►│
   │◄── TCP SYN-ACK ──────────│◄──────────────────────────│
   │─── data ────────────────►│──────────────────────────►│
   │                          │    SO_ORIGINAL_DST        │
   │                          │    recovers port 443      │
   │◄── fake HTTPS banner ────│◄──────────────────────────│
   │─── FIN ─────────────────►│──────────────────────────►│
  1. iptables PREROUTING REDIRECT — on startup, three rules are added to the nat table. All incoming TCP traffic (except port 22 and the listener port itself) is transparently redirected to the single listener port.
  2. Single asyncio listener — one coroutine handles every connection. There is no thread pool and no polling loop.
  3. SO_ORIGINAL_DST — the Linux kernel preserves the originally-intended destination port in the socket. The handler reads it with getsockopt(SOL_IP, SO_ORIGINAL_DST).
  4. Pre-computed banner map — at startup, each of the 65,536 ports is assigned a banner generated by processing a randomly-chosen regex pattern from the signature file. Banner generation is done once; serving is instant.
  5. Graceful shutdown — SIGTERM/SIGINT triggers the stop event, the server drains, the log queue is flushed, and the iptables rules are removed.

Requirements

Requirement Notes
Python 3.11+ Uses asyncio, struct, socket, pathlib, stdlib only
Linux SO_ORIGINAL_DST and iptables are Linux-specific
Root Required for iptables management; --no-iptables bypasses this
iptables Must be installed (apt install iptables or equivalent)

No PyPI packages are needed at runtime.


Installation

Run directly from the source tree (recommended for a lab machine):

git clone <repo>
cd portspoof_py
sudo python3 -m portspoof_py -s tools/portspoof_signatures

Install as a package:

pip install .          # installs the portspoof-py entry point
portspoof-py --help

Install build tool only if needed:

pip install flit
flit build             # produces a wheel in dist/

Quick start

Test mode (no iptables, no root)

Useful for verifying the installation or developing.

python3 -m portspoof_py \
    --no-iptables \
    -p 4444 \
    -s tools/portspoof_signatures \
    -v

# In another terminal — should receive a random service banner
nc localhost 4444

Full honeypot mode (as root)

sudo python3 -m portspoof_py \
    -p 4444 \
    -s /etc/portspoof/portspoof_signatures \
    -c /etc/portspoof/portspoof.conf \
    -l /var/log/portspoof/portspoof.jsonl

After startup you can verify with:

sudo iptables -t nat -L PREROUTING --line-numbers
# Should show three portspoof rules

nmap -p 1-1000 <your-ip>
# All ports should appear open

Stop with Ctrl-C or kill <pid>. Rules are removed automatically.

With the web admin interface

# Create a credentials file first
echo "admin:changeme" > admin.passwd

sudo python3 -m portspoof_py \
    -p 4444 \
    -s /etc/portspoof/portspoof_signatures \
    -l /var/log/portspoof/portspoof.jsonl \
    --admin-port 8080

# Open http://127.0.0.1:8080 in a browser (HTTP Basic Auth prompt will appear)

CLI reference

python3 -m portspoof_py [OPTIONS]
portspoof-py [OPTIONS]

Listener

Flag Default Description
-p PORT / --port PORT 4444 TCP port the honeypot listens on. This port is excluded from the iptables REDIRECT rule so it does not redirect to itself.
-i IP / --bind-ip IP all interfaces Bind the listener to a specific IP address.

Signatures and configuration

Flag Default Description
-s FILE / --signatures FILE Path to the signature file. Each line is a regex-like pattern used to generate a banner. At startup, one pattern is chosen randomly for each of the 65,536 ports. Without this flag every port returns an empty banner (the port appears open but silent).
-c FILE / --config FILE Path to the config file. Overrides specific ports or port ranges with a fixed payload after the signature map is built.

Logging

Flag Default Description
-l FILE / --log-file FILE Append JSON connection events to this file. One line per connection.
-v / --verbose off Print each connection event as JSON to stdout.

iptables

Flag Default Description
--iface IFACE all interfaces Restrict the iptables REDIRECT rule to a specific network interface (e.g. eth0). Useful when one interface is internal and should not be intercepted.
--no-iptables off Skip iptables setup and teardown entirely. The listener still runs on -p PORT. Use for local testing or when you manage iptables externally.

Web admin

Flag Default Description
--admin-port PORT disabled Start the web admin interface on this port.
--admin-host HOST 127.0.0.1 Address the admin interface binds to. Set to 0.0.0.0 to expose it on all interfaces (protect with a firewall).
--admin-passwd FILE admin.passwd File containing username:password on a single line. Required when --admin-port is used.
--admin-ssl off Serve the admin interface over HTTPS.
--admin-ssl-cert FILE admin.crt TLS certificate PEM file. If the file does not exist it is auto-generated as a self-signed cert (requires openssl on PATH).
--admin-ssl-key FILE admin.key TLS private key PEM file. Auto-generated alongside the cert if missing.
--email-config FILE email.json JSON file where email alert settings are stored. Created automatically when you first save settings from the admin UI.

Signature file

The signature file contains one pattern per line. Blank lines are ignored. The bundled file tools/portspoof_signatures contains 8,962 patterns derived from the nmap service detection database.

Each pattern is passed through a three-pass regex materialiser at startup to produce a concrete banner string:

Pass 1 — group and bracket expansion

  • (...) groups are stripped, their content kept.
  • [abc], [a-z], [0-9], [^\0] etc. are expanded: a random character (or random-length sequence for */+ quantifiers) is chosen from the defined set.

Pass 2 — special sequence substitution

  • \w → a random lowercase letter az
  • \d → a random digit 09
  • . → a random lowercase letter
  • \n, \r, \t → literal newline, carriage return, tab

Pass 3 — hex escape decoding

  • \xNN → the byte with hex value NN

Example patterns and what they can produce:

# Input pattern                         → Example output
SSH-2.0-OpenSSH_([\w._-]+)\r\n         → SSH-2g0-OpenSSH_QpKz\r\n
220 ([-.\w]+) ESMTP\r\n                → 220 mxprod ESMTP\r\n
HTTP/1.\d \d\d\d [\w ]+\r\n            → HTTP/1.4 200 OK\r\n
\x80\x01\x00\x80                       → (four literal bytes)
AMServer                               → AMServer

The file is read with latin-1 encoding so binary-like content in patterns does not cause decode errors.


Config file

The config file lets you pin specific ports or port ranges to a fixed payload, overriding the random assignment from the signature file. It is applied after the signature map is built.

Format

Lines beginning with # are comments and are ignored. Each active line has the form:

PORT "payload"
START-END "payload"

The payload is everything between the first and last double-quote on the line. It is processed through the same three-pass materialiser as signature patterns, so hex escapes and regex metacharacters work.

Example

# Single port — port 22 returns a specific SSH banner
22 "SSH-2.0-OpenSSH_8.9p1 Ubuntu-3ubuntu0.6\r\n"

# Port range — ports 80 through 443 return an HTTP 200
80-443 "HTTP/1.1 200 OK\r\nServer: nginx/1.24.0\r\nContent-Length: 0\r\n\r\n"

# Hex payload — raw bytes
9200 "\x7b\x22\x6e\x61\x6d\x65\x22\x3a\x22\x65\x6c\x61\x73\x74\x69\x63\x22\x7d"

The bundled tools/portspoof.conf shows the full comment syntax and more examples.


iptables rules

When running without --no-iptables, three rules are appended to the nat PREROUTING chain at startup and removed at shutdown.

Rules added (example with -p 4444 --admin-port 8080)

iptables -t nat -A PREROUTING -p tcp --dport 22   -j RETURN
iptables -t nat -A PREROUTING -p tcp --dport 8080 -j RETURN
iptables -t nat -A PREROUTING -p tcp --dport 4444 -j RETURN
iptables -t nat -A PREROUTING -p tcp              -j REDIRECT --to-port 4444

The exempt ports are always added before the REDIRECT rule in this order:

  1. Port 22 (SSH) — always first, so you cannot lock yourself out.
  2. --admin-port — exempted automatically if specified, so the admin interface remains reachable.
  3. The listener port (-p) — prevents a redirect loop.

Without --admin-port, the middle rule is omitted.

With --iface eth0, each rule gains --in-interface eth0.

Verifying the rules

sudo iptables -t nat -L PREROUTING -v --line-numbers

Manual cleanup

If the process is killed with SIGKILL (skipping the shutdown handler), the systemd ExecStopPost directives remove the rules. You can also remove them manually:

sudo iptables -t nat -D PREROUTING -p tcp --dport 22   -j RETURN
sudo iptables -t nat -D PREROUTING -p tcp --dport 4444 -j RETURN
sudo iptables -t nat -D PREROUTING -p tcp              -j REDIRECT --to-port 4444

Persistence across reboots

iptables rules are not persistent by default. The systemd service re-adds them on start. If you want them to survive without the service running, use iptables-save / iptables-restore or netfilter-persistent.


JSON log

With -l FILE, each accepted connection appends one JSON object followed by a newline to the log file.

Fields

Field Type Description
timestamp string ISO 8601 UTC timestamp with microseconds
src_ip string Source IP address of the scanner
src_port integer Source (ephemeral) port of the scanner
dst_port integer Port the scanner originally connected to (recovered via SO_ORIGINAL_DST)
banner_hex string Hex-encoded bytes of the banner that was sent
banner_len integer Length of the banner in bytes

Example

{"timestamp": "2025-06-01T14:32:07.841203+00:00", "src_ip": "198.51.100.42", "src_port": 54312, "dst_port": 443, "banner_hex": "485454502f312e31203230300d0a", "banner_len": 14}

Querying the log

# Show all source IPs sorted by frequency
jq -r .src_ip portspoof.jsonl | sort | uniq -c | sort -rn | head -20

# Show all targeted ports sorted by frequency
jq -r .dst_port portspoof.jsonl | sort | uniq -c | sort -rn | head -20

# Show connections from a specific IP
jq 'select(.src_ip == "198.51.100.42")' portspoof.jsonl

# Decode a banner from hex
jq -r .banner_hex portspoof.jsonl | head -1 | xxd -r -p

Web admin interface

Enable with --admin-port PORT. The interface binds to 127.0.0.1 by default; use --admin-host 0.0.0.0 to expose it externally (ensure it is firewalled).

Authentication

Every request requires HTTP Basic Auth. Create a credentials file before starting:

echo "admin:changeme" > admin.passwd

The file must contain a single line in username:password format. Pass a custom path with --admin-passwd FILE.

HTTPS / TLS

Add --admin-ssl to serve the interface over HTTPS:

sudo python3 -m portspoof_py \
    -p 4444 -s tools/portspoof_signatures \
    --admin-port 8080 --admin-ssl

On first run, a self-signed certificate (admin.crt / admin.key) is generated automatically using openssl. Your browser will show an untrusted-certificate warning — add an exception or use a real cert.

To use your own certificate:

--admin-ssl --admin-ssl-cert /etc/ssl/mycert.pem --admin-ssl-key /etc/ssl/mykey.pem

Dashboard — GET /

A dark-themed HTML page that auto-refreshes every 5 seconds. Sections:

  • Stat cards — total connections, connections in the last 60 seconds, uptime, ports mapped, and last connection time.
  • Top source IPs — the 10 most active scanner addresses since startup.
  • Top targeted ports — the 10 most-probed ports since startup.
  • Banner lookup — enter any port number (065535) and see the hex and text preview of the banner that port will send. The result persists across auto-refreshes when using the ?port=N query parameter.
  • Recent connections — the 50 most-recent connections, newest first, with timestamp, source, destination port, banner hex excerpt, and banner length.

Banner lookup shortcut

Append ?port=N to the dashboard URL to pre-populate the lookup:

http://127.0.0.1:8080/?port=443

JSON API

All endpoints require Basic Auth and return Content-Type: application/json.

GET /api/stats

Current statistics snapshot.

{
  "uptime": "2h 14m 37s",
  "total_connections": 18432,
  "last_connection": "2025-06-01T14:32:07.841203+00:00",
  "connections_per_min": 312,
  "ports_mapped": 65536,
  "top_ips": [
    {"ip": "198.51.100.42", "count": 9821},
    {"ip": "203.0.113.7",   "count": 4102}
  ],
  "top_ports": [
    {"port": 80,   "count": 3201},
    {"port": 443,  "count": 2987},
    {"port": 22,   "count": 2104}
  ]
}

GET /api/connections?limit=N

The N most-recent connection events, newest first. limit defaults to 50, maximum 500.

[
  {
    "timestamp": "2025-06-01T14:32:07.841203+00:00",
    "src_ip":    "198.51.100.42",
    "src_port":  54312,
    "dst_port":  443,
    "banner_hex": "485454502f312e31203230300d0a",
    "banner_len": 14
  }
]

GET /api/banner?port=N

The pre-computed banner for port N.

{
  "port":        443,
  "banner_hex":  "485454502f312e31203230300d0a",
  "banner_len":  14,
  "banner_text": "HTTP/1.1 200\r\n"
}

banner_text is the banner decoded as latin-1. Non-printable bytes will appear as replacement characters in some terminals.

GET /api/email/config

Current email alert configuration (password is masked).


Email alerts

portspoof can send email digest alerts when connections are detected. Configuration is done entirely through the admin UI — no restart required.

Setup

  1. Start with --admin-port (the email config page is always available).
  2. Open http://127.0.0.1:8080/config (or click email alerts in the dashboard header).
  3. Fill in your SMTP details and click Save.
  4. Click Send test email to verify delivery.

How batching works

Rather than sending one email per connection, portspoof collects events and sends a single digest:

  1. When a matching connection arrives, it is added to a pending list and a flush timer starts.
  2. Any further matching connections within the batch delay window are added to the same list.
  3. After the batch delay elapses, one email is sent summarising all collected events.
  4. The cooldown prevents a new batch from being sent until that many seconds have passed since the last one, avoiding email floods during sustained scans.

Configuration options

Option Default Description
Enabled off Master switch — no emails are sent when disabled.
SMTP host Hostname of your SMTP server (e.g. smtp.gmail.com).
SMTP port 587 Port to connect to.
Use STARTTLS on Upgrade the connection with STARTTLS. Disable for plain SMTP or SSL-only servers.
SMTP username Login username for SMTP authentication.
SMTP password Login password. Stored in the config file; masked in the UI.
From address Sender address. Defaults to the SMTP username if blank.
To address Recipient address for alert emails.
Trigger ports (all) Comma-separated list of ports that trigger alerts. Leave blank to alert on every port.
Batch delay 60 s How long to wait and collect events before sending one digest email.
Cooldown 300 s Minimum time between batch emails.

Config file

Settings are saved to email.json (or the path given by --email-config) as JSON. The file is created on first save. You can edit it directly; changes take effect at the next batch send.

{
  "enabled": true,
  "smtp_host": "smtp.gmail.com",
  "smtp_port": 587,
  "smtp_starttls": true,
  "smtp_user": "you@gmail.com",
  "smtp_password": "app-password-here",
  "from_addr": "",
  "to_addr": "alerts@example.com",
  "trigger_ports": [22, 3389],
  "batch_delay_seconds": 60,
  "cooldown_seconds": 300
}

Example digest email

Subject: [portspoof] 3 connections detected

portspoof alert — 3 connections

  2025-06-01 14:32:07  198.51.100.42:54312  →  port 22   (14 B  485353482d322e302d4f70656e5353…)
  2025-06-01 14:32:09  198.51.100.42:54398  →  port 23   (6 B   deadbeef0102)
  2025-06-01 14:32:11  203.0.113.7:61204    →  port 3389 (0 B   )

systemd service

The included portspoof.service file runs portspoof as a system service that starts after the network is up, restarts on failure, and removes iptables rules even if it is killed with SIGKILL.

Installation

# Copy files
sudo cp portspoof.service /etc/systemd/system/
sudo mkdir -p /etc/portspoof /var/log/portspoof
sudo cp tools/portspoof_signatures /etc/portspoof/
sudo cp tools/portspoof.conf       /etc/portspoof/

# Enable and start
sudo systemctl daemon-reload
sudo systemctl enable portspoof
sudo systemctl start portspoof

# Check status
sudo systemctl status portspoof
sudo journalctl -u portspoof -f

Adding the admin interface to the service

# Create credentials file
echo "admin:changeme" | sudo tee /etc/portspoof/admin.passwd
sudo chmod 600 /etc/portspoof/admin.passwd

Edit /etc/systemd/system/portspoof.service and append the flags to ExecStart:

ExecStart=/usr/bin/python3 -m portspoof_py \
    -s /etc/portspoof/portspoof_signatures \
    -c /etc/portspoof/portspoof.conf \
    -l /var/log/portspoof/portspoof.jsonl \
    --admin-port 8080 \
    --admin-passwd /etc/portspoof/admin.passwd \
    --email-config /etc/portspoof/email.json

Then reload:

sudo systemctl daemon-reload
sudo systemctl restart portspoof

Stopping

sudo systemctl stop portspoof
# iptables rules are removed during graceful shutdown
# ExecStopPost directives handle the SIGKILL case

Recipes

Honeypot on a dedicated interface only

sudo python3 -m portspoof_py \
    -p 4444 \
    --iface eth1 \
    -s /etc/portspoof/portspoof_signatures \
    -l /var/log/portspoof/portspoof.jsonl

Only traffic arriving on eth1 is redirected. Traffic on other interfaces (e.g. a management interface) is unaffected.

Monitor in real time with the admin API

# Poll stats every second
watch -n1 'curl -su admin:changeme http://127.0.0.1:8080/api/stats | python3 -m json.tool'

# Tail recent connections
watch -n2 'curl -su admin:changeme "http://127.0.0.1:8080/api/connections?limit=10" | python3 -m json.tool'

Inspect what a specific port would send

curl -su admin:changeme "http://127.0.0.1:8080/api/banner?port=22" | python3 -m json.tool

# Decode banner bytes directly
curl -su admin:changeme "http://127.0.0.1:8080/api/banner?port=22" \
  | python3 -c "import sys,json; print(json.load(sys.stdin)['banner_text'])"

Custom banners for a port range

Create /etc/portspoof/portspoof.conf:

# Make ports 8080-8090 look like nginx
8080-8090 "HTTP/1.1 200 OK\r\nServer: nginx/1.24.0\r\nContent-Length: 612\r\n\r\n"

# Make port 3306 look like MySQL
3306 "\x4a\x00\x00\x00\x0a\x38\x2e\x30\x2e\x33\x36\x00"

Analyse a scan after the fact

# Top 10 IPs
jq -r .src_ip /var/log/portspoof/portspoof.jsonl \
  | sort | uniq -c | sort -rn | head -10

# All ports targeted in the last hour
jq -r 'select(.timestamp > "2025-06-01T13:00:00") | .dst_port' \
  /var/log/portspoof/portspoof.jsonl | sort -n | uniq -c | sort -rn | head -20

# Check if a specific IP hit you
grep '"src_ip": "198.51.100.42"' /var/log/portspoof/portspoof.jsonl | wc -l

Troubleshooting

ERROR: root required for iptables management

Run with sudo, or pass --no-iptables if you are testing locally.

ERROR: iptables setup failed

Check that iptables is installed and the nat table is available:

sudo iptables -t nat -L

On some minimal systems you may need:

sudo apt install iptables        # Debian/Ubuntu
sudo modprobe ip_tables
sudo modprobe iptable_nat

Banners contain 000 instead of null bytes

This is expected. The signature file uses \0 to represent null bytes in the nmap probe format, but the banner materialiser does not interpret \0 as a null byte — it outputs the ASCII character 0 (0x30). Use \x00 in signature patterns or config payloads when you need an actual null byte.

Port 22 is redirected and SSH breaks

The iptables setup adds a RETURN rule for port 22 before the REDIRECT rule, so SSH should always be exempt. If you are still locked out, verify the rule order:

sudo iptables -t nat -L PREROUTING --line-numbers

The RETURN rules for port 22 and the listener port must appear before the REDIRECT rule. If they are in the wrong order, remove all portspoof rules and restart the service.

SO_ORIGINAL_DST returns the listener port instead of the original port

This happens when there is no REDIRECT rule in effect (e.g. --no-iptables mode, or the rule is missing). The server falls back to reporting the listener port as the destination. Banners are still sent; the logged dst_port will be the listener port rather than the port the scanner connected to.

Admin interface is not accessible from a remote machine

By default --admin-host is 127.0.0.1 (localhost only). To expose it remotely:

--admin-host 0.0.0.0 --admin-port 8080

Then allow the port in your firewall and restrict access to trusted IPs:

sudo iptables -A INPUT -p tcp --dport 8080 -s <your-ip> -j ACCEPT
sudo iptables -A INPUT -p tcp --dport 8080 -j DROP

Startup is slow (~45 seconds)

The banner map is built synchronously at startup: 65,536 ports × one signature materialisation each. This is expected and is a one-time cost. After startup, every connection is served from the pre-computed map with no per-connection processing.

Description
A Python asyncio rewrite of portspoof - a TCP honeypot that makes every port on a machine appear open and running a real service.
Readme 566 KiB
Languages
Python 100%