Files
portspoof_py/README.md
2026-03-10 20:00:51 -04:00

715 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# portspoof-py
A Python asyncio rewrite of [portspoof](https://github.com/drk1wi/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](#how-it-works)
2. [Requirements](#requirements)
3. [Installation](#installation)
4. [Quick start](#quick-start)
5. [CLI reference](#cli-reference)
6. [Signature file](#signature-file)
7. [Config file](#config-file)
8. [iptables rules](#iptables-rules)
9. [JSON log](#json-log)
10. [Web admin interface](#web-admin-interface)
11. [Email alerts](#email-alerts)
12. [systemd service](#systemd-service)
13. [Recipes](#recipes)
14. [Troubleshooting](#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):**
```bash
git clone <repo>
cd portspoof_py
sudo python3 -m portspoof_py -s tools/portspoof_signatures
```
**Install as a package:**
```bash
pip install . # installs the portspoof-py entry point
portspoof-py --help
```
**Install build tool only if needed:**
```bash
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.
```bash
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)
```bash
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:
```bash
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
```bash
# 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 `a``z`
- `\d` → a random digit `0``9`
- `.` → 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
```bash
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:
```bash
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
```json
{"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
```bash
# 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:
```bash
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:
```bash
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:
```bash
--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, each annotated with a two-letter country code fetched from the geo lookup service.
- **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 IP (with country code), destination port, and banner length.
#### IP geolocation
Country codes are resolved asynchronously on each dashboard render using `https://www.daprogs.com/ip/?raw=1&ip=<IP>`. Results are cached in memory for the lifetime of the process, so each IP is only looked up once. Private or unroutable addresses (e.g. `127.0.0.1`, `192.168.x.x`) that return no result are displayed without a country code — no crash, no placeholder.
### 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.
```json
{
"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 100, maximum 500.
```json
[
{
"timestamp": "2025-06-01T14:32:07.841203+00:00",
"src_ip": "198.51.100.42",
"src_port": 54312,
"dst_port": 443,
"banner_len": 14
}
]
```
#### `GET /api/banner?port=N`
The pre-computed banner for port N.
```json
{
"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.
```json
{
"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
```bash
# 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
```bash
# 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`:
```ini
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:
```bash
sudo systemctl daemon-reload
sudo systemctl restart portspoof
```
### Stopping
```bash
sudo systemctl stop portspoof
# iptables rules are removed during graceful shutdown
# ExecStopPost directives handle the SIGKILL case
```
---
## Recipes
### Honeypot on a dedicated interface only
```bash
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
```bash
# Poll stats every second
watch -n1 'curl -su admin:changeme http://127.0.0.1:8080/api/stats | python3 -m json.tool'
# Tail the 10 most 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
```bash
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
```bash
# 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:
```bash
sudo iptables -t nat -L
```
On some minimal systems you may need:
```bash
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:
```bash
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:
```bash
--admin-host 0.0.0.0 --admin-port 8080
```
Then allow the port in your firewall and restrict access to trusted IPs:
```bash
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.