- asyncio.Protocol (TCP): _transport: asyncio.Transport | None = None + cast() in connection_made; assert guards in every method that directly accesses the field. Files: pop3, smtp, mqtt, postgres, mssql, mongodb, imap, ldap, redis, mysql, sip, vnc. - asyncio.DatagramProtocol (UDP): _transport: asyncio.DatagramTransport | None = None. Files: snmp, tftp, SIPUDPProtocol. - RDP: assert new_transport is not None after start_tls() to narrow Transport | None. - FTP (Twisted): assert self.transport is not None + targeted type: ignore for imprecise Twisted stubs (misc/override/arg-type/attr-defined), IReactorTCP cast for listenTCP. - conpot: proc.stdout is None guard before iteration. - Bonus fixes surfaced by annotation: - smtp: get_payload(decode=True) bytes narrowing (arg-type on sha256) - postgres: rename shadowed `msg` param to `err_msg` in _handle_startup - mongodb: base64.binascii.Error → import binascii; binascii.Error - imap: result: list[int] = [] (var-annotated)
150 lines
5.6 KiB
Python
150 lines
5.6 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
FTP server using Twisted's FTP server infrastructure.
|
|
Accepts any credentials, logs all commands and file requests,
|
|
forwards events as JSON to LOG_TARGET if set.
|
|
"""
|
|
|
|
import os
|
|
from pathlib import Path
|
|
from typing import cast
|
|
|
|
from twisted.internet import defer, reactor
|
|
from twisted.internet.interfaces import IReactorTCP
|
|
from twisted.protocols.ftp import FTP, FTPFactory, FTPAnonymousShell
|
|
from twisted.python.failure import Failure
|
|
from twisted.python.filepath import FilePath
|
|
from twisted.python import log as twisted_log
|
|
|
|
import instance_seed as _seed
|
|
from syslog_bridge import (
|
|
encode_secret,
|
|
forward_syslog,
|
|
syslog_line,
|
|
write_syslog_file,
|
|
)
|
|
|
|
NODE_NAME = os.environ.get("NODE_NAME", "ftpserver")
|
|
SERVICE_NAME = "ftp"
|
|
LOG_TARGET = os.environ.get("LOG_TARGET", "")
|
|
PORT = int(os.environ.get("PORT", "21"))
|
|
|
|
# Per-instance daemon identity. Fleet-wide "vsFTPd 3.0.3" is an instant
|
|
# fingerprint of an unmaintained honeypot — real shops run a mix.
|
|
_FTP_BANNER_CHOICES = [
|
|
"220 (vsFTPd 3.0.3)",
|
|
"220 (vsFTPd 3.0.5)",
|
|
"220 ProFTPD 1.3.7a Server ready.",
|
|
"220 ProFTPD 1.3.6 Server ready.",
|
|
"220 Pure-FTPd Service ready.",
|
|
]
|
|
BANNER = os.environ.get("FTP_BANNER") or _seed.pick(_FTP_BANNER_CHOICES)
|
|
|
|
# Accept approximately this fraction of logins. Real anon-accessible
|
|
# servers succeed often; credential-harvesting scanners hitting every
|
|
# possible user/pass pair should still see plausible failures.
|
|
_LOGIN_SUCCESS_RATE = float(os.environ.get("FTP_LOGIN_SUCCESS_RATE", "0.9"))
|
|
|
|
# Optional override — if set to "never", ALL logins fail (realistic for a
|
|
# server with anonymous disabled). Handy for producing server diversity
|
|
# across the fleet.
|
|
_LOGIN_MODE = os.environ.get("FTP_LOGIN_MODE", "").strip().lower()
|
|
|
|
|
|
def _log(event_type: str, severity: int = 6, **kwargs) -> None:
|
|
line = syslog_line(SERVICE_NAME, NODE_NAME, event_type, severity, **kwargs)
|
|
write_syslog_file(line)
|
|
forward_syslog(line, LOG_TARGET)
|
|
|
|
|
|
def _setup_bait_fs() -> str:
|
|
"""Generate a per-instance bait filesystem.
|
|
|
|
No shared paths across deckies (/tmp/ftp_bait was identical on every
|
|
host), no tell-tale 'super_secret_admin_pw' strings. Filenames, byte
|
|
counts, and inline values are all derived from the per-decky seed, so
|
|
two honeypots never serve byte-identical files yet each stays stable
|
|
across restarts."""
|
|
bait_dir = Path(f"/tmp/ftp-{_seed.instance_hex(6, 'ftp-bait-dir')}")
|
|
bait_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
company = _seed.pick(["acme", "contoso", "northwind", "initech", "globex", "hooli"])
|
|
env = _seed.pick(["prod", "stage", "backup", "archive"])
|
|
year = _seed.rng.randint(2022, 2024)
|
|
month = _seed.rng.randint(1, 12)
|
|
|
|
# Realistic-looking rotating backups. Sizes vary per instance.
|
|
for idx in range(_seed.rng.randint(2, 5)):
|
|
tag = f"{year}{month:02d}{_seed.rng.randint(1, 28):02d}"
|
|
size = _seed.rng.randint(2048, 32768)
|
|
(bait_dir / f"{company}-{env}-{tag}.tar.gz").write_bytes(
|
|
b"\x1f\x8b\x08\x00" + _seed.random_bytes(size - 4, f"tar-{idx}")
|
|
)
|
|
|
|
# A plausible README that looks like legacy ops notes, NOT a credential
|
|
# dump. No "password = ..." strings — those are a dead giveaway.
|
|
(bait_dir / "README.txt").write_text(
|
|
f"{company} {env} drop area\n"
|
|
f"Rotation: keep last 14, nightly rsync from db{_seed.rng.randint(1,9)}.{env}\n"
|
|
f"Contact: ops-{env}@{company}.internal\n"
|
|
)
|
|
(bait_dir / ".htaccess").write_text("Options -Indexes\n")
|
|
|
|
return str(bait_dir)
|
|
|
|
|
|
_BAIT_PATH = _setup_bait_fs()
|
|
|
|
|
|
class ServerFTP(FTP):
|
|
def connectionMade(self):
|
|
assert self.transport is not None
|
|
peer = self.transport.getPeer() # type: ignore[misc]
|
|
_log("connection", src_ip=peer.host, src_port=peer.port)
|
|
super().connectionMade()
|
|
|
|
def ftp_USER(self, username):
|
|
self._server_user = username
|
|
_log("user", username=username)
|
|
return super().ftp_USER(username)
|
|
|
|
def ftp_PASS(self, password):
|
|
_u = getattr(self, "_server_user", "?")
|
|
_log("auth_attempt", username=_u, principal=_u, **encode_secret(password))
|
|
# Decide whether this attempt succeeds. Unseeded randomness so
|
|
# scanners can't predict which creds will "work".
|
|
import random as _rand
|
|
if _LOGIN_MODE == "never":
|
|
accept = False
|
|
elif _LOGIN_MODE == "always":
|
|
accept = True
|
|
else:
|
|
accept = _rand.random() < _LOGIN_SUCCESS_RATE
|
|
if not accept:
|
|
return defer.succeed((530, "Login incorrect."))
|
|
self.state = self.AUTHED
|
|
self._user = getattr(self, "_server_user", "anonymous")
|
|
self.shell = FTPAnonymousShell(FilePath(_BAIT_PATH)) # type: ignore[assignment]
|
|
return defer.succeed((230, "Login successful."))
|
|
|
|
def ftp_RETR(self, path):
|
|
_log("download_attempt", path=path)
|
|
return super().ftp_RETR(path)
|
|
|
|
def connectionLost(self, reason: Failure) -> None: # type: ignore[override]
|
|
assert self.transport is not None
|
|
peer = self.transport.getPeer() # type: ignore[misc]
|
|
_log("disconnect", src_ip=peer.host, src_port=peer.port)
|
|
super().connectionLost(reason)
|
|
|
|
|
|
class ServerFTPFactory(FTPFactory):
|
|
protocol = ServerFTP
|
|
welcomeMessage = BANNER
|
|
|
|
if __name__ == "__main__":
|
|
twisted_log.startLoggingWithObserver(lambda e: None, setStdout=False)
|
|
_log("startup", msg=f"FTP server starting as {NODE_NAME} on port {PORT}")
|
|
cast(IReactorTCP, reactor).listenTCP(PORT, ServerFTPFactory()) # type: ignore[arg-type]
|
|
reactor.run() # type: ignore[attr-defined]
|