Files
DECNET/decnet/services/ssh.py
anti 3e9c4c29b9 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.
2026-05-02 19:48:03 -04:00

121 lines
4.7 KiB
Python

import os
from pathlib import Path
from decnet.services.base import BaseService, ServiceConfigField
TEMPLATES_DIR = Path(__file__).parent.parent / "templates" / "ssh"
ARTIFACTS_ROOT = os.environ.get("DECNET_ARTIFACTS_ROOT", "/var/lib/decnet/artifacts")
class SSHService(BaseService):
"""
Interactive OpenSSH server for general-purpose deckies.
Replaced Cowrie emulation with a real sshd so fingerprinting tools and
experienced attackers cannot trivially identify the honeypot. Auth events,
sudo activity, and interactive commands are all forwarded to stdout as
RFC 5424 via the rsyslog bridge baked into the image.
service_cfg keys:
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"
ports = [22]
default_image = "build"
config_schema = [
ServiceConfigField(
key="password",
label="Root password",
type="password",
default="admin",
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",
type="string",
help=(
"Cosmetic override for the SSH banner/PS1 — keeps the decoy "
"looking heterogeneous. Decky identity (NODE_NAME) is unaffected."
),
placeholder="e.g. mail-01.corp.local",
),
]
def compose_fragment(
self,
decky_name: str,
log_target: str | None = None,
service_cfg: dict | None = None,
) -> dict:
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@<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
# attribution — matches the host path used for the artifacts
# bind mount below. The container hostname (optionally overridden
# via SSH_HOSTNAME) is cosmetic and may differ to keep the
# decoy looking heterogeneous.
"NODE_NAME": decky_name,
}
if "hostname" in cfg:
env["SSH_HOSTNAME"] = cfg["hostname"]
# File-catcher quarantine: bind-mount a per-decky host dir so attacker
# drops (scp/sftp/wget) are mirrored out-of-band for forensic analysis.
# The in-container path masquerades as systemd-coredump so `mount`/`df`
# from inside the container looks benign.
quarantine_host = f"{ARTIFACTS_ROOT}/{decky_name}/ssh"
return {
"build": {"context": str(TEMPLATES_DIR)},
"container_name": f"{decky_name}-ssh",
"restart": "unless-stopped",
"cap_add": ["NET_BIND_SERVICE"],
"environment": env,
"volumes": [f"{quarantine_host}:/var/lib/systemd/coredump:rw"],
}
def dockerfile_context(self) -> Path:
return TEMPLATES_DIR