From 3e9c4c29b9187f325c9167e8ff0307d1f2b248e1 Mon Sep 17 00:00:00 2001 From: anti Date: Sat, 2 May 2026 19:48:03 -0400 Subject: [PATCH] feat(ssh,telnet): add non-root user account for privesc + enum lure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real Linux deployments (especially Ubuntu cloud images) ship a non- root admin user; honeypots that only accept root logins are a tell. Add a second account on both SSH and Telnet decoys, configurable via service_cfg keys `user` / `user_password`, defaulting to `ubuntu` / `admin` so the lure is live on every fresh deploy. * `decnet/services/{ssh,telnet}.py` — two new ServiceConfigFields (`user` string, `user_password` secret) and matching env vars (`SSH_USER` / `SSH_USER_PASSWORD`, mirror for telnet) propagated via the compose fragment. * `decnet/templates/ssh/entrypoint.sh` — runtime `useradd -m -s /usr/libexec/login-session -G sudo "$SSH_USER"` so the new user inherits the same sessrec pty-recording shell as root and lands in the sudo group. Privesc attempts (`sudo`) flow through the existing sudo-log capture; network-enum from the user's shell rides the recorded transcript. * `decnet/templates/telnet/entrypoint.sh` — same useradd pattern (no sudo group — busybox+login telnet image has no sudo package; privesc rides `su -` which itself flows through the existing PAM auth-helper at /etc/pam.d/login). * New tests for default + custom user / password + independence from root password. Updated the schema-keys assertion to match the four-field shape. The new account is ALSO the natural home for the body-aware predicates that were previously gated on root-only sessions — attackers who land on `ubuntu@host` and run network-recon / privesc commands now generate the same structured TTP-rule events as root sessions did, captured via the same auth-helper + sessrec + sudo-log pipes. --- decnet/services/ssh.py | 41 +++++++++++++++++++++++++-- decnet/services/telnet.py | 41 +++++++++++++++++++++++++-- decnet/templates/ssh/entrypoint.sh | 20 +++++++++++++ decnet/templates/telnet/entrypoint.sh | 17 +++++++++++ tests/services/test_config_schema.py | 27 ++++++++++++++---- tests/services/test_ssh.py | 30 ++++++++++++++++++++ 6 files changed, 167 insertions(+), 9 deletions(-) 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) # ---------------------------------------------------------------------------