feat(ssh,telnet): add non-root user account for privesc + enum lure

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.
This commit is contained in:
2026-05-02 19:48:03 -04:00
parent c675bd26cf
commit 3e9c4c29b9
6 changed files with 167 additions and 9 deletions

View File

@@ -17,8 +17,11 @@ class SSHService(BaseService):
RFC 5424 via the rsyslog bridge baked into the image. RFC 5424 via the rsyslog bridge baked into the image.
service_cfg keys: service_cfg keys:
password Root password (default: "admin") password Root password (default: "admin")
hostname Override container hostname 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" name = "ssh"
@@ -34,6 +37,33 @@ class SSHService(BaseService):
secret=True, secret=True,
help="Plaintext root password for the in-container sshd.", 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( ServiceConfigField(
key="hostname", key="hostname",
label="Container hostname", label="Container hostname",
@@ -55,6 +85,13 @@ class SSHService(BaseService):
cfg = service_cfg or {} cfg = service_cfg or {}
env: dict = { env: dict = {
"SSH_ROOT_PASSWORD": cfg.get("password", "admin"), "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@<decky>` 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 # NODE_NAME is the authoritative decky identifier for log
# attribution — matches the host path used for the artifacts # attribution — matches the host path used for the artifacts
# bind mount below. The container hostname (optionally overridden # bind mount below. The container hostname (optionally overridden

View File

@@ -16,8 +16,11 @@ class TelnetService(BaseService):
are logged as RFC 5424 via the same rsyslog bridge used by the SSH service. are logged as RFC 5424 via the same rsyslog bridge used by the SSH service.
service_cfg keys: service_cfg keys:
password Root password (default: "admin") password Root password (default: "admin")
hostname Override container hostname 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" name = "telnet"
@@ -33,6 +36,33 @@ class TelnetService(BaseService):
secret=True, secret=True,
help="Plaintext root password for the in-container telnetd.", 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( ServiceConfigField(
key="hostname", key="hostname",
label="Container hostname", label="Container hostname",
@@ -54,6 +84,13 @@ class TelnetService(BaseService):
cfg = service_cfg or {} cfg = service_cfg or {}
env: dict = { env: dict = {
"TELNET_ROOT_PASSWORD": cfg.get("password", "admin"), "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 # NODE_NAME is the authoritative decky identifier for log
# attribution — matches the host path used for the artifacts # attribution — matches the host path used for the artifacts
# bind mount below. # bind mount below.

View File

@@ -5,6 +5,26 @@ set -e
ROOT_PASSWORD="${SSH_ROOT_PASSWORD:-admin}" ROOT_PASSWORD="${SSH_ROOT_PASSWORD:-admin}"
echo "root:${ROOT_PASSWORD}" | chpasswd 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 # Optional: override hostname inside container
if [ -n "$SSH_HOSTNAME" ]; then if [ -n "$SSH_HOSTNAME" ]; then
echo "$SSH_HOSTNAME" > /etc/hostname echo "$SSH_HOSTNAME" > /etc/hostname

View File

@@ -5,6 +5,23 @@ set -e
ROOT_PASSWORD="${TELNET_ROOT_PASSWORD:-admin}" ROOT_PASSWORD="${TELNET_ROOT_PASSWORD:-admin}"
echo "root:${ROOT_PASSWORD}" | chpasswd 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 # Optional: override hostname inside container
if [ -n "$TELNET_HOSTNAME" ]; then if [ -n "$TELNET_HOSTNAME" ]; then
echo "$TELNET_HOSTNAME" > /etc/hostname echo "$TELNET_HOSTNAME" > /etc/hostname

View File

@@ -96,10 +96,10 @@ def test_field_to_json_omits_unused_enum():
def test_ssh_schema_keys_match_compose_reads(): def test_ssh_schema_keys_match_compose_reads():
# SSHService.compose_fragment reads cfg.get("password") and cfg.get("hostname") # SSHService.compose_fragment reads password / user / user_password /
# — the schema must expose exactly those. # hostname — the schema must expose exactly those.
keys = {f.key for f in SSHService.config_schema} 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(): 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(): 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(): def test_telnet_compose_round_trip():
svc = TelnetService() 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) frag = svc.compose_fragment("decky-test", service_cfg=cfg)
env = frag["environment"] env = frag["environment"]
assert env["TELNET_ROOT_PASSWORD"] == "hunter2" assert env["TELNET_ROOT_PASSWORD"] == "hunter2"
assert env["TELNET_HOSTNAME"] == "mail-01" 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(): def test_rdp_schema_matches_and_bool_coerces():

View File

@@ -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", {}) 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) # Logging pipeline wiring (Dockerfile + entrypoint)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------