Building a Home Lab Honeypot: Watching What Actually Knocks on Your Door
A lighter entry than the recent offensive-tooling run — this one’s the defender’s-eye-view companion piece, and it’s the one post in this batch that doesn’t need the CONTOSO.LOCAL lab, because the whole point is watching a real network rather than a simulated one.
Why bother with a honeypot at home
A honeypot’s value isn’t stopping an attack — a low-interaction listener on a spare port stops nothing. Its value is signal: any connection to a service that has no legitimate reason to exist is, by definition, either a misconfiguration you didn’t know about or someone probing you, and there’s no third category. Point one at a couple of commonly-scanned ports on Herald and every hit in the log is worth looking at, which is a much stronger guarantee than anything a production service’s logs can offer, where legitimate and malicious traffic are mixed together and have to be told apart after the fact.
A honeypot in under 50 lines
This is a real script, tested in a sandboxed environment against real local connections before writing this — the log lines below are genuine output, not illustrative examples:
import socket, threading, datetime, json, sys
LOGFILE = "honeypot.log"
FAKE_SSH_PORT = 2222 # nftables DNAT'd from real port 22 externally
FAKE_TELNET_PORT = 2323
def log_event(event):
line = json.dumps(event)
print(line)
with open(LOGFILE, "a") as f:
f.write(line + "\n")
def handle_conn(conn, addr, port):
ts = datetime.datetime.utcnow().isoformat() + "Z"
banner_sent = None
try:
if port == FAKE_SSH_PORT:
banner = b"SSH-2.0-OpenSSH_8.9p1 Ubuntu-3ubuntu0.6\r\n"
conn.sendall(banner)
banner_sent = banner.decode(errors="replace").strip()
conn.settimeout(2)
data = conn.recv(1024)
payload = data.decode(errors="replace")
except Exception:
payload = None
log_event({
"ts": ts, "src_ip": addr[0], "src_port": addr[1],
"dst_port": port, "banner_sent": banner_sent, "payload": payload
})
conn.close()
def listener(port):
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("0.0.0.0", port))
s.listen(5)
while True:
conn, addr = s.accept()
threading.Thread(target=handle_conn, args=(conn, addr, port), daemon=True).start()
if __name__ == "__main__":
for p in (FAKE_SSH_PORT, FAKE_TELNET_PORT):
threading.Thread(target=listener, args=(p,), daemon=True).start()
print(f"honeypot listening on {FAKE_SSH_PORT}, {FAKE_TELNET_PORT}", file=sys.stderr)
threading.Event().wait()
Running it and simulating a probe with nc produced this, verbatim:
{"ts": "2026-07-18T07:20:55.414065Z", "src_ip": "127.0.0.1", "src_port": 54770, "dst_port": 2222, "banner_sent": null, "payload": "root\r\nadmin123\r\n"}
That single line already tells you the shape of what a real scanner sends: a connection, no wait for the banner, immediate credential-shaped bytes down the wire — exactly the blind-spray behaviour of mass SSH scanners that don’t bother reading a banner before guessing root/admin123 against anything that answers on 22.
Running it on unprivileged ports without running as root
Binding directly to 22 or 23 needs root, which is exactly the kind of privilege a script whose entire job is “accept connections from strangers on the internet” shouldn’t have. The fix is the same one the site’s nftables post already covers — a DNAT rule that redirects the real low ports to the honeypot’s unprivileged listener, so the process itself never needs elevated capabilities:
# nftables: redirect real port 22/23 to the honeypot's unprivileged listeners,
# leaving Herald's actual SSH daemon on its real (non-22) port untouched
table ip nat {
chain prerouting {
type nat hook prerouting priority dstnat;
tcp dport 22 redirect to :2222
tcp dport 23 redirect to :2323
}
}
This only works cleanly if Herald’s real SSH daemon already lives on a non-standard port — which it should regardless of whether a honeypot sits behind it, since “SSH on 22” is itself one of the first things any scanner checks.
What’s actually worth doing with the log
honeypot.log is newline-delimited JSON, which means it’s already in the shape jq was built for — jq -s 'group_by(.src_ip) | map({ip: .[0].src_ip, hits: length}) | sort_by(-.hits)' turns a week of noise into a ranked list of the most persistent source IPs in about the time it takes to type it. Feed a running tail of it into the FortiAnalyzer-style dashboard approach from Watching the Fabric and the honeypot stops being a novelty and becomes one more telemetry source alongside the rest of Herald’s logging — cheap to run, unambiguous to interpret, and the one place on the network where every single hit is worth a look by construction.
Where this sits
This is the mirror image of everything the pivoting, password cracking, and Impacket series walked through from the attacker’s chair — a home-lab-scale way to watch the same class of behaviour arrive from the other side of the glass, and a nice low-effort addition to Herald that pays for itself the first time something other than you connects to it.