diff --git a/decnet/services/ssh.py b/decnet/services/ssh.py index 81736401..311233ec 100644 --- a/decnet/services/ssh.py +++ b/decnet/services/ssh.py @@ -17,8 +17,11 @@ class SSHService(BaseService): RFC 5424 via the rsyslog bridge baked into the image. service_cfg keys: - password Root password (default: "admin") - hostname Override container hostname + password Root password (default: "admin") + user Non-root user name (default: "ubuntu") for + realistic "ssh user@host" lures + privesc capture + user_password Non-root user's password (default: "admin") + hostname Override container hostname """ name = "ssh" @@ -34,6 +37,33 @@ class SSHService(BaseService): secret=True, help="Plaintext root password for the in-container sshd.", ), + ServiceConfigField( + key="user", + label="Non-root user", + type="string", + default="ubuntu", + help=( + "Username for the second account on the decoy. Real Linux " + "boxes (especially Ubuntu cloud images) ship a non-root " + "admin user — having one makes the decoy more lifelike, " + "captures attackers who try `ssh user@host` for network " + "enumeration, and surfaces sudo/privesc behaviour the root-" + "only path misses." + ), + ), + ServiceConfigField( + key="user_password", + label="Non-root user password", + type="password", + default="admin", + secret=True, + help=( + "Password for the non-root user. Captured at PAM auth time " + "via the same auth-helper that handles root logins. The " + "user is in the `sudo` group; subsequent privesc attempts " + "fan out through the existing sudo-log capture." + ), + ), ServiceConfigField( key="hostname", label="Container hostname", @@ -55,6 +85,13 @@ class SSHService(BaseService): cfg = service_cfg or {} env: dict = { "SSH_ROOT_PASSWORD": cfg.get("password", "admin"), + # Non-root user account — created at runtime by the entrypoint + # iff SSH_USER is non-empty. Defaults to "ubuntu"/"admin" so + # `ssh ubuntu@` works out of the box (the conventional + # cloud-init account on Ubuntu cloud images, very low-friction + # for attackers running ssh enumeration scripts). + "SSH_USER": cfg.get("user", "ubuntu"), + "SSH_USER_PASSWORD": cfg.get("user_password", "admin"), # NODE_NAME is the authoritative decky identifier for log # attribution — matches the host path used for the artifacts # bind mount below. The container hostname (optionally overridden diff --git a/decnet/services/telnet.py b/decnet/services/telnet.py index 6ffef68c..c9c3d517 100644 --- a/decnet/services/telnet.py +++ b/decnet/services/telnet.py @@ -16,8 +16,11 @@ class TelnetService(BaseService): are logged as RFC 5424 via the same rsyslog bridge used by the SSH service. service_cfg keys: - password Root password (default: "admin") - hostname Override container hostname + password Root password (default: "admin") + user Non-root user name (default: "ubuntu") for + realistic "telnet user@host" lures + privesc capture + user_password Non-root user's password (default: "admin") + hostname Override container hostname """ name = "telnet" @@ -33,6 +36,33 @@ class TelnetService(BaseService): secret=True, help="Plaintext root password for the in-container telnetd.", ), + ServiceConfigField( + key="user", + label="Non-root user", + type="string", + default="ubuntu", + help=( + "Username for the second account on the decoy. The telnet " + "image is busybox + real /bin/login (PAM-aware), so a " + "non-root user widens the attack surface — captures " + "enumeration scripts that only try common usernames " + "(`telnet ubuntu@host`) and post-login `su -` privesc " + "attempts via the existing PAM auth-helper." + ), + ), + ServiceConfigField( + key="user_password", + label="Non-root user password", + type="password", + default="admin", + secret=True, + help=( + "Password for the non-root user. Captured at PAM auth " + "time via the same auth-helper that handles root logins. " + "Telnet has no sudo (busybox+login image); privesc rides " + "`su -` which itself flows through PAM." + ), + ), ServiceConfigField( key="hostname", label="Container hostname", @@ -54,6 +84,13 @@ class TelnetService(BaseService): cfg = service_cfg or {} env: dict = { "TELNET_ROOT_PASSWORD": cfg.get("password", "admin"), + # Non-root user account — created at runtime by the + # entrypoint iff TELNET_USER is non-empty. Defaults to + # "ubuntu"/"admin" to mirror the SSH service shape and + # match the Ubuntu-flavoured motd already baked into the + # telnet image. + "TELNET_USER": cfg.get("user", "ubuntu"), + "TELNET_USER_PASSWORD": cfg.get("user_password", "admin"), # NODE_NAME is the authoritative decky identifier for log # attribution — matches the host path used for the artifacts # bind mount below. diff --git a/decnet/templates/ssh/entrypoint.sh b/decnet/templates/ssh/entrypoint.sh index 9e91e3a2..1a5bc12b 100644 --- a/decnet/templates/ssh/entrypoint.sh +++ b/decnet/templates/ssh/entrypoint.sh @@ -5,6 +5,26 @@ set -e ROOT_PASSWORD="${SSH_ROOT_PASSWORD:-admin}" echo "root:${ROOT_PASSWORD}" | chpasswd +# Non-root user — gives the decoy a realistic "ssh user@host" surface +# so attackers running enumeration scripts find a plausible second +# account, AND so post-login privesc (sudo) flows through the +# existing sudo-log capture pipe. SSH_USER blank means "no second +# user" (legacy single-account behaviour); the compose fragment +# defaults SSH_USER to "ubuntu" so this branch is the live path on +# fresh deploys. +SSH_USER="${SSH_USER:-}" +SSH_USER_PASSWORD="${SSH_USER_PASSWORD:-admin}" +if [ -n "${SSH_USER}" ] && [ "${SSH_USER}" != "root" ]; then + if ! id -u "${SSH_USER}" >/dev/null 2>&1; then + # Login shell points at the same sessrec wrapper root uses, + # so the new user's pty session is recorded — privesc and + # network-enum behaviour ride the existing capture pipe + # without a parallel implementation. + useradd -m -s /usr/libexec/login-session -G sudo "${SSH_USER}" + fi + echo "${SSH_USER}:${SSH_USER_PASSWORD}" | chpasswd +fi + # Optional: override hostname inside container if [ -n "$SSH_HOSTNAME" ]; then echo "$SSH_HOSTNAME" > /etc/hostname diff --git a/decnet/templates/telnet/entrypoint.sh b/decnet/templates/telnet/entrypoint.sh index a85b989b..f0a968c2 100644 --- a/decnet/templates/telnet/entrypoint.sh +++ b/decnet/templates/telnet/entrypoint.sh @@ -5,6 +5,23 @@ set -e ROOT_PASSWORD="${TELNET_ROOT_PASSWORD:-admin}" echo "root:${ROOT_PASSWORD}" | chpasswd +# Non-root user — gives the decoy a realistic "telnet user@host" +# surface so enumeration scripts that only try common usernames find +# a plausible second account, AND so post-login `su -` privesc flows +# through the existing PAM auth-helper. Login shell is the sessrec +# wrapper so the user's pty session is recorded in the same shape +# as root's. TELNET_USER blank disables the second account; the +# compose fragment defaults TELNET_USER to "ubuntu" so this branch +# is the live path on fresh deploys. +TELNET_USER="${TELNET_USER:-}" +TELNET_USER_PASSWORD="${TELNET_USER_PASSWORD:-admin}" +if [ -n "${TELNET_USER}" ] && [ "${TELNET_USER}" != "root" ]; then + if ! id -u "${TELNET_USER}" >/dev/null 2>&1; then + useradd -m -s /usr/libexec/login-session "${TELNET_USER}" + fi + echo "${TELNET_USER}:${TELNET_USER_PASSWORD}" | chpasswd +fi + # Optional: override hostname inside container if [ -n "$TELNET_HOSTNAME" ]; then echo "$TELNET_HOSTNAME" > /etc/hostname diff --git a/tests/services/test_config_schema.py b/tests/services/test_config_schema.py index cf4b838e..80bfaa04 100644 --- a/tests/services/test_config_schema.py +++ b/tests/services/test_config_schema.py @@ -96,10 +96,10 @@ def test_field_to_json_omits_unused_enum(): def test_ssh_schema_keys_match_compose_reads(): - # SSHService.compose_fragment reads cfg.get("password") and cfg.get("hostname") - # — the schema must expose exactly those. + # SSHService.compose_fragment reads password / user / user_password / + # hostname — the schema must expose exactly those. keys = {f.key for f in SSHService.config_schema} - assert keys == {"password", "hostname"} + assert keys == {"password", "user", "user_password", "hostname"} def test_ssh_compose_round_trip_through_validator(): @@ -144,16 +144,33 @@ def test_https_schema_includes_tls_fields(): def test_telnet_schema_keys_match_compose_reads(): - assert {f.key for f in TelnetService.config_schema} == {"password", "hostname"} + assert {f.key for f in TelnetService.config_schema} == { + "password", "user", "user_password", "hostname", + } def test_telnet_compose_round_trip(): svc = TelnetService() - cfg = svc.validate_cfg({"password": "hunter2", "hostname": "mail-01"}) + cfg = svc.validate_cfg({ + "password": "hunter2", "hostname": "mail-01", + "user": "deploy", "user_password": "Tr0ub4dor", + }) frag = svc.compose_fragment("decky-test", service_cfg=cfg) env = frag["environment"] assert env["TELNET_ROOT_PASSWORD"] == "hunter2" assert env["TELNET_HOSTNAME"] == "mail-01" + assert env["TELNET_USER"] == "deploy" + assert env["TELNET_USER_PASSWORD"] == "Tr0ub4dor" + + +def test_telnet_default_non_root_user(): + """Defaults to ubuntu/admin so a fresh decoy lures `telnet + ubuntu@host` enumeration scripts without operator setup.""" + svc = TelnetService() + frag = svc.compose_fragment("decky-test", service_cfg={}) + env = frag["environment"] + assert env["TELNET_USER"] == "ubuntu" + assert env["TELNET_USER_PASSWORD"] == "admin" def test_rdp_schema_matches_and_bool_coerces(): diff --git a/tests/services/test_ssh.py b/tests/services/test_ssh.py index 2f6d724d..0d2c389a 100644 --- a/tests/services/test_ssh.py +++ b/tests/services/test_ssh.py @@ -123,6 +123,36 @@ def test_no_log_target_in_env(): assert "LOG_TARGET" not in _fragment(log_target="10.0.0.1:5140").get("environment", {}) +def test_default_non_root_user_and_password(): + """The compose fragment defaults SSH_USER to "ubuntu" and + SSH_USER_PASSWORD to "admin" — privesc + network-enum lure on + every fresh deploy without an operator intervention.""" + env = _fragment()["environment"] + assert env["SSH_USER"] == "ubuntu" + assert env["SSH_USER_PASSWORD"] == "admin" + + +def test_custom_non_root_user_and_password(): + env = _fragment(service_cfg={ + "user": "deploy", + "user_password": "Tr0ub4dor", + })["environment"] + assert env["SSH_USER"] == "deploy" + assert env["SSH_USER_PASSWORD"] == "Tr0ub4dor" + + +def test_non_root_user_independent_of_root_password(): + """User account password must be a distinct knob from the root + password — operators routinely set them to different values to + exercise multi-credential capture.""" + env = _fragment(service_cfg={ + "password": "rootpw", + "user_password": "userpw", + })["environment"] + assert env["SSH_ROOT_PASSWORD"] == "rootpw" + assert env["SSH_USER_PASSWORD"] == "userpw" + + # --------------------------------------------------------------------------- # Logging pipeline wiring (Dockerfile + entrypoint) # ---------------------------------------------------------------------------