SuperAPI AI Directory

aegis-linux-baseline

Security & auditing

View repository

Updated 10-15 17:19

About this skill

Scope & Objectives

Audit the operating-system identity and kernel baseline of one running Linux host (or its config-as-code). Seven domains, in priority order:

  1. Accounts & sudo — UID-0 duplication, stale/unused accounts, empty passwords, sudo grant breadth, sudo brute-force throttling.
  2. SSH daemon — effective sshd_config state, authentication methods, forwarding surface, key hygiene.
  3. PAM & password policy — lockout, password quality, expiry, password hashing.
  4. Kernel & network sysctl — anti-MITM, anti-DoS, information-disclosure and local-privesc-surface controls.
  5. Filesystem & permissions — SUID/SGID inventory, world-writable artifacts, mount flags, cron/at gating, umask, core dumps.
  6. Boot & disk — bootloader protection (contextual), encryption-at-rest presence, protected data mounts.
  7. Time sync — NTP health; forensic prerequisite for every other module's evidence.

Out of scope (cross-references): firewall/exposure map → FW; TLS/proxy edge → TLS; systemd sandboxing, SELinux/AppArmor policy authoring, container runtime → SANDBOX; secret contents/env files → HSECRET; patch level/EOL → PATCH; audit rules/log shipping → LOGMON.

Operating rules:

  • All inspection is read-only; mutating commands appear only under Remediation (Phase 6 approval). Prefer effective-state evidence (sshd -T, sysctl -n KEY) over config files — drop-in dirs (sshd_config.d/, sysctl.d/) make file text misleading.
  • Commands needing root are tagged [ROOT]; without root, audit world-readable state plus the config repo. Repo-only access: judge the rendered config (Ansible templates, cloud-init user-data) using Patterns & Signatures. Distro variance is called out inline — never assume Ubuntu values on RHEL or Alpine; detect first (cat /etc/os-release).

Prerequisites & Vocabulary

Zero-background primer: the terms this module uses, one line each. Deeper plain-language explanations for every class live in the repository GUIDE.md glossary.

  • privesc: the local steps that turn a low-level account into root
  • sudo NOPASSWD:ALL: password-free full-root command rights for an account
  • sysctl: kernel tunables closing redirection and information-leak paths
  • SUID binary: a program that runs as its file's owner (often root) no matter who starts it
  • sticky bit / noexec mount: shared-directory protections against planted trojans
  • ptrace_scope: kernel restriction on one process debugging another user's processes
  • PAM: the pluggable login-policy layer (lockouts, password quality, expiry)
  • taint flow / source→sink: path untrusted data travels from entry point to dangerous function

Mental Model

Attackers do not attack "the baseline" — they chain specific gaps into a path:

Entry point            Amplifier (this module)              Realized outcome
---------------------  -----------------------------------  --------------------------------
internet SSH :22   ──▶ PasswordAuthentication yes +       ▶ online credential stuffing →
                       PermitRootLogin yes                  direct root, no per-user trail
app RCE as svc acct ▶ sudo NOPASSWD:ALL                   ▶ instant takeover + root persistence
on-path attacker    ─▶ accept_redirects=1 /               ▶ route hijack, traffic interception,
                       accept_source_route=1                plaintext credential theft
local low-priv user ─▶ kptr_restrict=0, dmesg open,       ▶ KASLR defeated → reliable kernel-
                       ptrace_scope=0, userns open          exploit privesc to root
any foothold        ─▶ world-writable dir without sticky  ▶ planted SUID trojan = persistent
                       + executable /dev/shm                local root, evades noexec /tmp
post-exploit        ─▶ NTP unsynchronized, coredumps on   ▶ forensics blinded, secrets in cores

Four defensive layers map onto the check sections:

Layer Sections Question it answers
Perimeter identity sshd Can an outsider become a local identity?
Local privilege boundary accounts, sudo, PAM, SUID Can that identity become root?
Containment sysctl, mounts, namespaces How much damage can a non-root foothold do?
Forensics time sync, core dumps Can we reconstruct what happened?

Classify every finding by layer and by what it chains with — a Medium gap becomes Critical once an adjacent entry point exists (see Taint Tracing Guidance).

What To Check

1. Accounts and UID-0 Inventory

Run and verify exactly one result: root with a legitimate shell. A second UID-0 account (toor, backup, svc-root) is a Critical backdoor indicator:

awk -F: '$3==0{printf "%s uid=%d shell=%s\n",$1,$3,$7}' /etc/passwd
awk -F: '($3!=0 && $4==0){print "gid0:",$1}' /etc/passwd

Inventory every interactive-capable account and confirm each against the asset owner's list; check lock state [ROOT] (field 2 of passwd -S: L locked, P hash set, NP empty password). Flag unlocked accounts unused >90 days, service accounts owning shells, and .ssh/authorized_keys nobody claims (cross-check HSECRET):

awk -F: '$7!~/(nologin|false|sync)$/{print $1"\tuid="$3"\thome="$6"\tshell="$7}' /etc/passwd
for u in $(cut -d: -f1 /etc/passwd); do passwd -S "$u"; done; lastlog -b 90

Empty-password check [ROOT] — any hit is a direct-login finding (locked hashes look like !, !!, !*; that is the good state):

awk -F: '($2==""){print $1" EMPTY-PASSWORD"}' /etc/shadow

2. Sudo Delegation

Validate parse integrity first, then enumerate grants and judge breadth:

visudo -c                                   # syntax errors = finding by themselves
grep -RnsE '^[^#]*(NOPASSWD|[[:space:]]ALL=\(ALL' /etc/sudoers /etc/sudoers.d/
grep -RnsE '\(ALL(:\s*ALL)?\)\s+NOPASSWD:\s+(ALL|/bin/su|/bin/bash|/bin/sh|/usr/bin/(find|python[0-9]*|perl|ruby|php|vi[m]?|less|more|tar|zip|chmod|chown))' /etc/sudoers.d/
sudo -l                                     # as your audit identity

Flag: NOPASSWD: ALL for a network-facing service account; grants to su, editors, interpreters, archivers (GTFOBins abuse — https://gtfobins.github.io). Accept as scoped: NOPASSWD: /usr/bin/systemctl restart myapp.service.

Throttle knobs (password prompts route through PAM, so faillock covers sudo once Section 4 is configured):

grep -RhE '^\s*Defaults\s' /etc/sudoers /etc/sudoers.d/ | grep -E 'timestamp_timeout|passwd_tries|use_pty'

Target values: Defaults timestamp_timeout=15, passwd_tries=3, use_pty (use_pty needs sudo ≥1.9).

3. SSH Daemon Effective Configuration

Effective state wins over file text; Include /etc/ssh/sshd_config.d/*.conf is processed where it appears and the first value obtained for a keyword wins, so an early-numbered drop-in silently overrides later main-file lines. Run [ROOT]:

sshd -T | grep -Ei '^(permitrootlogin|passwordauthentication|pubkeyauthentication|authenticationmethods|kbdinteractiveauthentication|maxauthtries|logingracetime|permitemptypasswords|clientaliveinterval|clientalivecountmax|x11forwarding|allowagentforwarding|allowtcpforwarding|allowusers|allowgroups|banner)'

Without root, fall back to the sweep's file-text grep (drop-ins can contradict it — say so in evidence).

Judgement table (risky column = value to flag):

sshd_config key Risky value Hardened value Why
PermitRootLogin yes; prohibit-password on hosts lacking key discipline no root logins skip per-user audit and give brute force the highest-value target
PasswordAuthentication yes no enables online password guessing; keys are not sprayable
KbdInteractiveAuthentication yes (alias ChallengeResponseAuthentication, deprecated name) no second password-ish path bypassing key-only intent
PubkeyAuthentication absent (default yes) or no yes explicit assert the intended mechanism
AuthenticationMethods unset (= any single method) publickey forces key-only even if other methods reappear
MaxAuthTries 6 default or higher 3 caps guessing per connection
LoginGraceTime 120 (default) 30 halves window for pre-auth resource abuse
PermitEmptyPasswords yes (never leave) no pairs with shadow check in §1
X11Forwarding yes (Debian default!) no forwards X authority; server has no business running X clients remotely
AllowAgentForwarding yes no agent socket forwarding lets a compromised jump host reuse operator keys
AllowTcpForwarding yes no or local if tunnels required turns any SSH account into an internal-network pivot
ClientAliveInterval/CountMax 0 (never probes) 300 / 3 reaps dead sessions; do not confuse with TCPKeepAlive
AllowUsers/AllowGroups unset (any valid account logs in) explicit group e.g. AllowGroups ssh-users shrinks auth surface to named identities
Banner absent /etc/issue.net legal deterrent only — Low severity

Protocol note: no Protocol directive is needed — OpenSSH ≥7.6 speaks SSH-2 only; encountering Protocol 2 is harmless legacy, not a finding.

Key hygiene. Enumerate and grade keys:

find /root /home -name authorized_keys -exec sh -c 'echo "--- {}"; awk "{print \$1\" \"\$3}" "{}"' \;
for k in /root/.ssh/*.pub /home/*/.ssh/*.pub; do [ -f "$k" ] && ssh-keygen -lf "$k"; done

Rules: prefer ed25519 (ssh-ed25519); RSA only at ≥3072 bits (size shown by ssh-keygen -lf) — flag smaller and any ssh-dss; passphrase-protect interactive-use private keys (client-side read-only check: ssh-keygen -y -P "" -f ID_FILE printing output = unencrypted). Automation keys must carry restrictions instead of passphrases they cannot type:

restrict,command="/usr/local/bin/deploy-sync",from="10.0.20.0/24" ssh-ed25519 AAAA... ci-runner

authorized_keys(5) option set: restrict, command="...", from="pattern", no-port-forwarding, no-agent-forwarding, no-x11-forwarding, no-pty, expiry-time="YYYYMMDDHHMM" (OpenSSH ≥7.7). Flag bare unrestricted keys used by CI/automation.

known_hosts trust: prefer hashed entries (HashKnownHosts yes). Treat unexpected host-key mismatch as potential MITM — never advise deleting the line without out-of-band fingerprint verification. In repos, flag -oStrictHostKeyChecking=no (and blind accept-new) against production hosts: trust-on-first-use over an unauthenticated network is standing interception risk.

4. PAM Lockout, Password Quality, Expiry, Hashing

Locate the active stack first — names differ by distro (Debian/Ubuntu: common-auth/common-password includes; RHEL/Rocky: system-auth/password-auth via authselect; Alpine: minimal per-service files):

ls /etc/pam.d/; authselect current 2>/dev/null
grep -RsE 'pam_faillock|pam_tally2' /etc/pam.d/
grep -E '^\s*(deny|unlock_time|even_deny_root|users)\s*=' /etc/security/faillock.conf 2>/dev/null

Prefer pam_faillock — pam_tally2 was removed from RHEL 8+ and Debian 11+; seeing tally2 means old stack, migrate. Target: faillock present in both preauth and authfail phases, deny = 5, unlock_time = 900. Root is exempt unless even_deny_root; exempt fragile automation identities explicitly via users =. Alpine's BusyBox login often ships neither pam_faillock nor pam_pwquality — report control absence, do not pretend a config value exists.

Quality and expiry:

grep -Ev '^\s*(#|$)' /etc/security/pwquality.conf
grep -E '^(PASS_MAX_DAYS|PASS_MIN_DAYS|PASS_WARN_AGE|ENCRYPT_METHOD|UMASK)' /etc/login.defs
chage -l SOMEHUMANUSER

Targets: minlen >= 14 plus minclass = 3 (or three of dcredit/ucredit/lcredit/ocredit negative); PASS_MAX_DAYS <= 365, PASS_MIN_DAYS >= 1, PASS_WARN_AGE >= 7 for human accounts (system/service accounts exempt — record exceptions). login.defs defaults apply to new accounts only; remediate existing users with chage. Hashing: accept SHA512 or YESCRYPT (YESCRYPT is default on Debian 12, RHEL 9, Ubuntu 22.04; SHA512 on older) — flag MD5/DES.

Distro honesty: Alpine's minimal BusyBox login often ships neither pam_faillock nor pam_pwquality; installing them changes the audit target from "value wrong" to "control absent" — report absence, do not pretend a config value exists.

5. Kernel and Network Sysctl

Always read live values; file presence proves nothing:

sysctl kernel.kptr_restrict kernel.dmesg_restrict kernel.unprivileged_bpf_disabled \
       kernel.randomize_va_space kernel.yama.ptrace_scope kernel.perf_event_paranoid \
       fs.protected_symlinks fs.protected_hardlinks fs.protected_fifos fs.protected_regular fs.suid_dumpable
sysctl net.ipv4.ip_forward net.ipv4.icmp_echo_ignore_broadcasts net.ipv4.tcp_syncookies \
       net.ipv4.tcp_max_syn_backlog net.core.somaxconn net.ipv4.tcp_fin_timeout
sysctl -a 2>/dev/null | grep -E '(accept_redirects|send_redirects|accept_source_route|rp_filter|log_martians)'

Network table:

Sysctl key Risky value Hardened value Why
net.ipv4.ip_forward 1 on non-router 0 — context-dependent routers/NAT/Docker/K8s nodes legitimately need 1; verify role before flagging
net.ipv4.icmp_echo_ignore_broadcasts 0 1 (modern default) smurf-amplification refusal
net.ipv4.conf.{all,default}.accept_redirects 1 0 ICMP redirect route injection
net.ipv6.conf.{all,default}.accept_redirects 1 0 same, IPv6
net.ipv4.conf.{all,default}.send_redirects 1 on non-router 0 stops host acting as redirect source
net.{ipv4,ipv6}.conf.{all,default}.accept_source_route 1 0 obsolete source-routed packets are pure attack surface
net.ipv4.conf.{all,default}.rp_filter 0/2 1 strict — caveat multi-homed drops spoofed-source packets; breaks asymmetric routing (BGP/policy-routed hosts) — document exception
net.ipv4.tcp_syncookies 0 1 (default) SYN-flood survival
net.ipv4.tcp_max_syn_backlog 128 legacy default >= 1024 absorbs SYN bursts (cross-ref DOS)
net.core.somaxconn 128 legacy default >= 1024 listen backlog ceiling for apps
net.ipv4.tcp_fin_timeout 60 default <= 30 faster orphaned-FIN reclaim
net.ipv4.conf.{all,default}.log_martians 0 1 logs spoofed/martian packets for FW correlation

conf.all vs conf.default trap: for accept_redirects, per-interface values govern behavior (all is an aggregate guard) while default applies only to interfaces created after being set — verify one live NIC too: sysctl net.ipv4.conf.eth0.accept_redirects. Namespace honesty: kernel.unprivileged_userns_clone exists only on Debian/Ubuntu-patched kernels; mainline exposes user.max_user_namespaces instead. Restricting unprivileged userns hardens kernel surface but breaks rootless podman/toolbox/sandboxed browsers — role-dependent decision, never a silent default fix.

Kernel/self-protection table:

Sysctl key Risky value Hardened value Why
kernel.kptr_restrict 0 2 hides kernel pointers from unprivileged readers (defeats KASLR-leak privesc); 1 = restricted-to-CAP_SYSLOG middle ground
kernel.dmesg_restrict 0 1 blocks unprivileged dmesg (leaks addresses, module info)
kernel.unprivileged_bpf_disabled 0 1 (permanent variant 2 on kernels ≥5.16) unprivileged BPF is a recurring exploit primitive; breaks eBPF observability agents — coordinate
fs.protected_symlinks / _hardlinks 0 1 blocks sticky-dir symlink/hardlink attacks on /tmp-class dirs
fs.protected_fifos / _regular 0 1 (2 stricter) blocks FIFO/regular-file creation races in world-writable sticky dirs
kernel.randomize_va_space 0/1 2 full ASLR incl. heap/stack/libraries
kernel.yama.ptrace_scope 0 >= 1 (2 typical server target) stops arbitrary process injection/credential scraping via ptrace
kernel.perf_event_paranoid < 2 2 (3 = fully disable user perf_event_open) perf-based side-channel and address-leak surface

6. Filesystem and Permissions

SUID/SGID inventory. Drop -xdev (or iterate mountpoints) when /usr or /var are separate filesystems:

find / -xdev \( -type f -o -type d \) \( -perm -4000 -o -perm -2000 \) -printf '%m %u:%g %p\n' 2>/dev/null | sort -k3
getcap -r / 2>/dev/null | grep -v '^/proc'

Expected-vs-unexpected judgement table:

Path Normal on Action if missing/unexpected
/usr/bin/sudo, /usr/bin/su, /usr/bin/passwd, /usr/bin/chage, /usr/bin/gpasswd, /usr/bin/chsh, /usr/bin/chfn, /usr/bin/newgrp all distros expected suid root
/usr/bin/mount, /usr/bin/umount util-linux installs (often cap-based now) expected; absence fine on modern
/usr/bin/pkexec, /usr/bin/fusermount3 desktop/systemd distros unexpected on headless servers → investigate
/usr/bin/crontab sgid crontab (Debian) / suid root (RHEL) mode varies — compare to package DB
/usr/lib/openssh/ssh-keysign, /sbin/unix_chkpwd OpenSSH/util-linux helpers expected suid root
anything under /home, /tmp, /var/tmp, /usr/local never expected planted binary — treat as incident (see Exploitation §3)

Verify provenance of every entry outside the table: dpkg -S FILE (Debian) / rpm -qf FILE (RHEL); "not owned by any package" = immediate escalation. Modern distros move capabilities instead of SUID (getent ping uses cap_net_raw) — getcap hits like cap_setuid deserve the same scrutiny as SUID.

World-writable artifacts and ownership gaps:

find / -xdev -type d -perm -0002 ! -perm -1000 -print      # writable dirs MISSING sticky bit
find / -xdev -type f -perm -0002 -print                    # any hit is a finding
find / -xdev \( -nouser -o -nogroup \) -print 2>/dev/null | head -20
stat -c '%a %n' /tmp /var/tmp /dev/shm                     # expect 1777 (sticky)
findmnt -no TARGET,SOURCE,FSTYPE,OPTIONS /dev/shm /tmp /var/tmp 2>/dev/null

Require /dev/shm mounted nosuid,nodev,noexec (and dedicated /tmp, /var/tmp partitions likewise). Single-partition cloud hosts: document residual risk; at minimum persist hardened /dev/shm options in fstab.

cron/at gating (allow-list model preferred), umask, homes, core dumps:

ls -l /etc/cron.allow /etc/at.allow 2>/dev/null || echo "ALLOW-LISTS-MISSING"
stat -c '%a %U:%G %n' /etc/crontab /etc/cron.d /etc/cron.daily 2>/dev/null
ls -la /var/spool/cron /var/spool/cron/atjobs 2>/dev/null   # Debian paths; RHEL: /var/spool/at
grep -E '^\s*UMASK' /etc/login.defs
grep -RhsE '^\s*umask\s+00[02]\b' /etc/profile /etc/bash.bashrc /etc/profile.d/ 2>/dev/null
stat -c '%a %U:%G %n' /home/* 2>/dev/null                   # expect <= 750 each
grep -RhE '^\s*\*\s+hard\s+core\b' /etc/security/limits.conf /etc/security/limits.d/ 2>/dev/null
grep -E '^\s*(Storage|ProcessSizeMax)' /etc/systemd/coredump.conf /etc/systemd/coredump.conf.d/*.conf 2>/dev/null
ulimit -c                                                   # expect 0 in a login shell

Flag when neither allow nor deny files exist (everyone may schedule), when deny-only model is used, or when cron dirs are group/world-writable. Targets: UMASK 027 (077 high-sensitivity); home dirs without group/world write (and without world read on sensitive boxes); * hard core 0; coredump.conf Storage=none plus ProcessSizeMax=0; fs.suid_dumpable=0 (Section 5 table block).

7. Boot and Disk

GRUB password is contextual: required when console/KVM/cloud-serial access is reachable; fleet behind tight IAM may carry a documented exception. LUKS absence on hosts holding regulated data is an architectural gap (severity scales with data classification; remediation is rebuild/migration, not inline change). fstab protection applies nodev,nosuid (+noexec where nothing executes) to dedicated data/removable mounts — never demand them on / or /usr.

grep -l password_pbkdf2 /boot/grub/grub.cfg /boot/grub2/grub.cfg 2>/dev/null   # GRUB pw present?
stat -c '%a %U:%G %n' /boot/grub*/grub.cfg /boot/grub2/user.cfg 2>/dev/null
lsblk -o NAME,FSTYPE,MOUNTPOINT | grep -i crypto_luks || echo "NO-LUKS-VISIBLE"
cat /etc/crypttab 2>/dev/null
awk '$3!~/^(swap|proc|sysfs|devpts|tmpfs|overlay|iso9660)$/ {print $1,$2,$3,"opts="$4}' /etc/fstab
findmnt -no TARGET,PARTLABEL,OPTIONS -t ext4,xfs,btrfs,vfat,exfat,ntfs3

8. Time Synchronization

timedatectl show -p NTPSynchronized -p NTP -p TimeUSec
systemctl is-active chronyd chrony systemd-timesyncd ntpd 2>/dev/null
chronyc sources 2>/dev/null || ntpq -p 2>/dev/null
grep -RhE '^(server|pool|NTP=|FallbackNTP)' /etc/chrony/chrony.conf /etc/chrony.conf \
      /etc/systemd/timesyncd.conf /etc/ntp.conf 2>/dev/null

Flag: no active sync daemon, NTPSynchronized=no, sources pointing at unroutable or vendor-example default addresses. Every timestamp in every other module's evidence depends on this — unsynchronized clocks make LOGMON correlation unreliable and break TLS/Kerberos time-sensitive flows.

Where To Look

Evidence collection: tools/sweeps/sweep-baseline.sh captures [BASE-nn] sections verbatim; judge them against this module's rubrics, never against raw output alone. The paste-ready sweep below covers the same ground for interactive use.

Paste-ready audit sweep — highest-yield checks, strictly read-only, safe on production; [ROOT] lines need root, everything else works unprivileged:

#!/usr/bin/env bash
# Baseline sweep — READ-ONLY. [ROOT] lines need root.
echo "== OS =="; head -2 /etc/os-release; uname -r
echo "== UID-0 accounts (expect only root) =="; awk -F: '$3==0{print $1,$7}' /etc/passwd
echo "== interactive accounts =="; awk -F: '$7!~/(nologin|false|sync)$/{print $1":"$3":"$7}' /etc/passwd
echo "== empty passwords [ROOT] =="; awk -F: '($2==""){print $1}' /etc/shadow 2>/dev/null || echo "need-root"
echo "== NOPASSWD sudo =="; grep -RsE '^[^#].*NOPASSWD' /etc/sudoers /etc/sudoers.d/ 2>/dev/null || echo none
echo "== sshd effective [ROOT] =="; sshd -T 2>/dev/null | grep -Ei '^(permitrootlogin|passwordauthentication|pubkeyauthentication|authenticationmethods|maxauthtries|x11forwarding|allowtcpforwarding|allowagentforwarding|permitemptypasswords)' || grep -RhE '^\s*(PermitRootLogin|PasswordAuthentication|PubkeyAuthentication|AuthenticationMethods|MaxAuthTries|PermitEmptyPasswords|X11Forwarding|AllowTcpForwarding|AllowAgentForwarding|LoginGraceTime)' /etc/ssh/sshd_config /etc/ssh/sshd_config.d/ 2>/dev/null
echo "== PAM/policy =="; grep -E '^\s*(deny|unlock_time)\s*=' /etc/security/faillock.conf 2>/dev/null; grep -RsE 'pam_(faillock|tally2)' /etc/pam.d/ 2>/dev/null | head -3; grep -E '^\s*(minlen|minclass)\s*=' /etc/security/pwquality.conf 2>/dev/null; grep -E '^(PASS_MAX_DAYS|ENCRYPT_METHOD|UMASK)' /etc/login.defs
echo "== sysctls =="; sysctl kernel.kptr_restrict kernel.dmesg_restrict kernel.randomize_va_space kernel.yama.ptrace_scope kernel.unprivileged_bpf_disabled fs.protected_symlinks fs.protected_hardlinks fs.suid_dumpable net.ipv4.ip_forward net.ipv4.tcp_syncookies net.ipv4.conf.all.accept_redirects net.ipv6.conf.all.accept_redirects net.ipv4.conf.all.send_redirects 2>&1
echo "== SUID =="; find / -xdev -type f -perm -4000 2>/dev/null
echo "== world-writable dirs w/o sticky =="; find / -xdev -type d -perm -0002 ! -perm -1000 -print 2>/dev/null
echo "== world-writable files =="; find / -xdev -type f -perm -0002 -print 2>/dev/null
echo "== tmp modes (expect 1777) =="; stat -c '%a %n' /tmp /var/tmp /dev/shm 2>/dev/null
echo "== /dev/shm opts (want nosuid,nodev,noexec) =="; findmnt -no OPTIONS /dev/shm 2>/dev/null
echo "== cron/at allow-lists =="; ls -l /etc/cron.allow /etc/at.allow 2>/dev/null || echo missing
echo "== coredumps =="; ulimit -c; grep -hE '^\s*\*\s+hard\s+core' /etc/security/limits.conf /etc/security/limits.d/* 2>/dev/null; grep -hE '^\s*(Storage|ProcessSizeMax)' /etc/systemd/coredump.conf 2>/dev/null
echo "== time sync =="; timedatectl show -p NTPSynchronized -p NTP 2>/dev/null || systemctl is-active systemd-timesyncd chronyd 2>/dev/null

Artifact map:

Evidence Primary path(s) Live command
Accounts/groups /etc/passwd, /etc/shadow [ROOT], /etc/group awk -F: filters above
sudo /etc/sudoers, /etc/sudoers.d/* (mode 0440) visudo -c, sudo -l
sshd config /etc/ssh/sshd_config, /etc/ssh/sshd_config.d/*.conf sshd -T [ROOT] (needs root for host keys; fallback = file text, but say so — drop-ins can contradict it)
Authorized keys /root/.ssh/authorized_keys, /home/*/.ssh/authorized_keys find ... -name authorized_keys
PAM + policy files /etc/pam.d/common-* (Deb), /etc/pam.d/{system,password}-auth (RHEL); /etc/security/faillock.conf, /etc/security/pwquality.conf, /etc/login.defs, /etc/security/limits.conf(.d/) grep -Rs pam_faillock /etc/pam.d/, grep deny /etc/security/faillock.conf
sysctl runtime vs files kernel state; /etc/sysctl.conf, /etc/sysctl.d/*, /usr/lib/sysctl.d/* (last-applied wins — trust sysctl -n KEY, not file text) sysctl KEY; diff files vs live values
Mounts/crypto /etc/fstab, /proc/mounts, /etc/crypttab findmnt, lsblk -o NAME,FSTYPE
cron/at /etc/crontab, /etc/cron.d/, /etc/cron.{allow,deny}, /etc/at.{allow,deny} ls -l
Core dumps / time sync / boot /etc/systemd/coredump.conf(.d/), /etc/chrony/chrony.conf (Deb) or /etc/chrony.conf (RHEL), /etc/systemd/timesyncd.conf, /boot/grub/grub.cfg or /boot/grub2/grub.cfg coredumpctl list, timedatectl, chronyc sources, grep password_pbkdf2

Patterns & Signatures

Host-side signature greps — each should return zero hits on a hardened host; every hit is candidate evidence:

grep -RnsE '^\s*(PermitRootLogin|PasswordAuthentication|PermitEmptyPasswords)\s+yes' /etc/ssh/ 2>/dev/null
grep -RnsE '^\s*X11Forwarding\s+yes' /etc/ssh/sshd_config /etc/ssh/sshd_config.d/ 2>/dev/null
grep -RnsE 'NOPASSWD' /etc/sudoers /etc/sudoers.d/ 2>/dev/null        # judge scope per What To Check §2
grep -RnsE '^\s*umask\s+(000|002)\b' /etc/profile /etc/bash.bashrc /etc/profile.d/ 2>/dev/null
grep -RnsE '(accept_redirects|send_redirects|accept_source_route)\s*=\s*1|(randomize_va_space|yama\.ptrace_scope|kptr_restrict|dmesg_restrict)\s*=\s*0' /etc/sysctl.conf /etc/sysctl.d/ 2>/dev/null
grep -RnsE '^ENCRYPT_METHOD\s+(MD5|DES)' /etc/login.defs

Config-as-code sweeps (repo root; covers Ansible, cloud-init, Terraform, Dockerfile, Packer; requires ripgrep):

rg -n --hidden -g '!.git/' \
   -e '(PermitRootLogin|PasswordAuthentication):\s*(yes|"yes")' \
   -e '(PermitRootLogin|PasswordAuthentication|PermitEmptyPasswords)\s+yes' \
   -e 'NOPASSWD:\s*(ALL\b|/bin/su|/bin/bash|/bin/sh)' \
   -e '/usr/bin/(find|python[0-9.]*|perl|ruby|php|vi[m]?|less|tar)\b.*NOPASSWD'
rg -n --hidden -g '!.git/' \
   -e 'chmod\s+(-R\s+)?(777|666)\b' \
   -e 'umask\s+(000|002)\b' \
   -e 'mode:\s*0?777\b'
rg -n --hidden -g '!.git/' \
   -e 'lock_passwd:\s*[Ff]alse' \
   -e 'plain_text_passwd' \
   -e 'ssh_pwauth:\s*true' \
   -e 'StrictHostKeyChecking=\s*no'
rg -n --hidden -g '!.git/' \
   -e 'net\.(ipv4|ipv6)\.conf\.(all|default)\.(accept_redirects|send_redirects|accept_source_route)\s*=\s*1' \
   -e 'kernel\.(randomize_va_space|yama\.ptrace_scope|kptr_restrict)\s*=\s*0' \
   -e '^ENCRYPT_METHOD\s+MD5'
rg -ln --hidden -g '!.git/' -e '/var/run/docker\.sock'    # chains into SANDBOX module

Rendered-example pair (cloud-init user-data), showing the vulnerable and fixed shapes side by side:

# VULNERABLE — cloud-init user-data
users:
  - { name: deploy, lock_passwd: false }
ssh_pwauth: true                # PasswordAuthentication yes for the whole host
# FIXED — key-only from first boot; no password path exists
users:
  - { name: deploy, lock_passwd: true, ssh_authorized_keys: ["ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... deploy@ci"] }
ssh_pwauth: false               # renders as PasswordAuthentication no

Taint Tracing Guidance

Classify each finding as entry, amplifier, or containment-loss; then report chains, not isolated values. Chain table (taint origin → combines-with → attack path → ceiling severity):

# Origin misconfig Chains with Attack path Ceiling
1 PermitRootLogin yes + PasswordAuthentication yes on :22 internet-exposed absent faillock/fail2ban botnet credential spray reaches root directly; no intermediate identity to lock out Critical 9.8
2 Broad NOPASSWD: ALL for web/service user app-layer RCE (any module) svc shell → silent root → authorized_keys persistence in /root/.ssh, cron implants High 8.8
3 AllowAgentForwarding yes on jump hosts operator habit of agent reuse compromised jump steals forwarded agent socket → lateral movement with operator's keys High
4 AcceptRedirects=1 (v4/v6) attacker on-path (rogue AP/hostile LAN) forged ICMP redirect rewrites routes → interception of plaintext protocols Medium–High by position
5 kptr_restrict=0 + dmesg_restrict=0 + ptrace_scope=0 (+ open userns) any local foothold; public kernel exploit KASLR leak + process injection converts unreliable bug into deterministic privesc amplifier to Critical
6 World-writable dir w/o sticky + planted SUID any foothold drop SUID shell in writable PATH dir → persistent local root High 7.7
7 Executable /dev/shm app file-write primitive stage ELF outside hardened /tmp; memory-resident malware amplifier
8 Empty-password account any local shell access su ACCOUNT with empty string = instant identity theft High 7.9
9 No cron/at allow-list low-priv foothold schedule persistence tasks without admin visibility Low–Medium
10 NTP unsynchronized every other module timestamps untrustworthy → detection gaps, replay-window failures, forensic ambiguity force multiplier

Guidance rules:

  • Position beats absolute value: identical PasswordAuthentication yes is Medium behind a VPN allowlist, Critical on an edge host — pull the socket map (ss -tlnp | grep ':22') and FW module output before assigning. Count the chain, not the finding: rows 1, 2, 6 together mean one web-app bug yields full host compromise with persistence — say so in SUMMARY.md chains (Phase 4).
  • Amplifier findings (rows 4, 5, 7) get severity from what they enable, not their own CVSS base; annotate "enabler" rather than inflating standalone scores.

Exploitation & Reproduction

Reproductions are READ-ONLY commands demonstrating weak state; attacker paths are described, not scripted. Attach actual output to each finding.

1. Internet-exposed root password login (Critical).

sudo sshd -T | grep -E '^(permitrootlogin|passwordauthentication)'
# permitrootlogin yes
# passwordauthentication yes
ss -tlnp | grep ':22 '        # 0.0.0.0/[::] binding = internet-reachable (confirm with FW module)

Attacker path: mass scanners fingerprint :22 continuously; botnets spray breached credential pairs against root — one hit yields a root shell with no local foothold and no per-user trail.

2. Service-account sudo takeover (High).

sudo -u www-data sudo -n -l    # or run as the service account
# User www-data may run the following commands: (ALL) NOPASSWD: ALL

Attacker path: web-app RCE lands as www-data; sudo -n succeeds silently; attacker spawns a root shell and drops an SSH key into /root/.ssh/authorized_keys for persistence.

3. Planted SUID binary (High).

find / -xdev -type f -perm -4000 -printf '%m %u:%g %TY-%Tm-%Td %p\n' 2>/dev/null | sort -k4
dpkg -S /usr/local/bin/backup-runner 2>&1    # 'no path found' = not from any package; expect -rwsr-xr-x root root

Attacker path: any local user executes the planted binary → EUID 0. /usr/local plus absent package provenance is the tell — treat as active compromise (quarantine mode-bit, preserve evidence).

4. ICMP redirect acceptance, pointer leaks, executable shm (Medium amplifiers).

sysctl net.ipv4.conf.all.accept_redirects net.ipv6.conf.all.accept_redirects   # = 1 both
dmesg | tail -5                                # works unprivileged when dmesg_restrict=0
grep -m3 ' [tT] ' /proc/kallsyms               # resolved addresses when kptr_restrict=0
cat /proc/sys/kernel/randomize_va_space        # 0 or 1 = weakened ASLR
findmnt -no OPTIONS /dev/shm                   # lacks noexec,nosuid,nodev

Attacker paths: a rogue segment host sends an ICMP redirect announcing a "better" route through itself — the host installs it and connections traverse the attacker. Readable dmesg/kallsyms defeat KASLR first, converting crash-prone public kernel exploits into one-shot privesc. With any app-level file-write primitive, an ELF staged in executable /dev/shm runs while sidestepping hardened /tmp.

5. Empty-password identity theft (High).

sudo awk -F: '($2==""){print $1}' /etc/shadow
# backupsvc
su - backupsvc      # empty string at prompt succeeds for any local user

Attacker path: every local account holder instantly becomes backupsvc — inheriting its cron jobs, service credentials, group memberships.

Remediation

All commands in this section are mutating: Phase 6 only, after approval, with rollback notes. Lockout-risk operations carry inline warnings — honor them.

sshd hardening

# FIXED — /etc/ssh/sshd_config.d/99-hardening.conf
# Check no lower-numbered drop-in re-enables these; first value obtained wins.
PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
KbdInteractiveAuthentication no
AuthenticationMethods publickey
MaxAuthTries 3
LoginGraceTime 30
PermitEmptyPasswords no
X11Forwarding no
AllowAgentForwarding no
AllowTcpForwarding no            # use 'local' only if tunnels are a stated requirement
ClientAliveInterval 300
ClientAliveCountMax 3
AllowGroups ssh-users            # populate BEFORE reload

Apply protocol (order matters): populate ssh-users and verify membership (id) → confirm key login from a second terminal (ssh -i ~/.ssh/id_ed25519 host true) → sshd -t → systemctl reload sshd (reload keeps established sessions; never restart from a session you cannot afford to lose). Confirm console/out-of-band access exists before touching sshd on any remote host.

Accounts and sudo

usermod -L BACKDOOR_ACCT && usermod -s /usr/sbin/nologin BACKDOOR_ACCT   # duplicate UID-0: also alert/incident
passwd -l STALE_ACCT                                                     # lock unused accounts
chage -E 1 STALE_ACCT                                                    # dated retirement alternative
passwd SERVICE_ACCT                                                      # replace empty password; prefer locking + key access
chage -m 1 -M 365 -W 7 HUMAN_USER                                        # apply expiry policy to existing users
# FIXED — /etc/sudoers.d/010-hardening  (0440 root:root; edit ONLY via visudo -f)
Defaults timestamp_timeout=15
Defaults passwd_tries=3
Defaults use_pty
%sysadmins ALL=(ALL:ALL) ALL
deploy ALL=(root) NOPASSWD: /usr/bin/systemctl restart myapp.service

Remove offending grant lines from their original files rather than layering overrides; validate with visudo -c before ending your session.

PAM and password policy

# FIXED — /etc/security/faillock.conf
deny = 5
unlock_time = 900
# users = svc-backup           # explicit exemptions for automation identities only
# Wire-up: Debian/Ubuntu add pam_faillock.so preauth/authfail to /etc/pam.d/common-auth;
# RHEL/Rocky: authselect enable-feature with-faillock && authselect apply-changes
# FIXED — /etc/security/pwquality.conf
minlen = 14
minclass = 3
dictcheck = 1

# FIXED — /etc/login.defs (new-account defaults)
PASS_MAX_DAYS   365
PASS_MIN_DAYS   1
PASS_WARN_AGE   7
UMASK           027
ENCRYPT_METHOD  SHA512      # keep distro default YESCRYPT where present — do not downgrade

sysctl

# FIXED — /etc/sysctl.d/99-hardening.conf
net.ipv4.ip_forward = 0                    # DELETE this line on routers/NAT/container hosts
net.ipv4.icmp_echo_ignore_broadcasts = 1
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.default.accept_redirects = 0
net.ipv6.conf.all.accept_redirects = 0
net.ipv6.conf.default.accept_redirects = 0
net.ipv4.conf.all.send_redirects = 0
net.ipv4.conf.default.send_redirects = 0
net.ipv4.conf.all.accept_source_route = 0
net.ipv4.conf.default.accept_source_route = 0
net.ipv6.conf.all.accept_source_route = 0
net.ipv6.conf.default.accept_source_route = 0
net.ipv4.conf.all.rp_filter = 1
net.ipv4.conf.default.rp_filter = 1
net.ipv4.tcp_syncookies = 1
net.ipv4.tcp_max_syn_backlog = 2048
net.core.somaxconn = 1024
net.ipv4.tcp_fin_timeout = 30
net.ipv4.conf.all.log_martians = 1
net.ipv4.conf.default.log_martians = 1
kernel.kptr_restrict = 2
kernel.dmesg_restrict = 1
kernel.unprivileged_bpf_disabled = 1       # coordinate with eBPF observability owners first
kernel.randomize_va_space = 2
kernel.yama.ptrace_scope = 1               # 2 where no dev/debug workflows exist
kernel.perf_event_paranoid = 2             # 3 to disable user perf_event_open entirely
fs.protected_symlinks = 1
fs.protected_hardlinks = 1
fs.protected_fifos = 1
fs.protected_regular = 2
fs.suid_dumpable = 0

Apply with sysctl --system, then sweep per-interface leftovers that predate the default settings:

for i in $(ls /sys/class/net | grep -v lo); do
  sysctl -w "net.ipv4.conf.$i.accept_redirects=0"
  sysctl -w "net.ipv4.conf.$i.send_redirects=0"
done

Filesystem, mounts, cron/at, core dumps

chmod o-w /path/from/world-writable-scan      # each world-writable FILE
chmod +t /var/tmp                              # restore missing sticky
chmod u-s /usr/local/bin/SUSPECT_BINARY        # quarantine planted SUID — do NOT delete evidence
touch /etc/cron.allow /etc/at.allow && chmod 600 /etc/cron.allow /etc/at.allow && chown root:root /etc/cron.allow /etc/at.allow
rm -f /etc/cron.deny /etc/at.deny              # switch deny-model -> allow-model (populate allow files FIRST)
# FIXED — /etc/fstab line (then: mount -o remount /dev/shm && findmnt /dev/shm)
tmpfs /dev/shm tmpfs rw,nosuid,nodev,noexec,mode=1777 0 0
# FIXED — /etc/security/limits.d/99-core.conf
* hard core 0

# FIXED — /etc/systemd/coredump.conf
[Coredump]
Storage=none
ProcessSizeMax=0
# then: systemctl daemon-reload

Time sync

# FIXED — /etc/chrony/chrony.conf   (Debian path; RHEL drops the chrony/ dir level)
pool 2.debian.pool.ntp.org iburst          # or: server ntp.internal.example.com iburst prefer
makestep 1.0 3

Enable the distro daemon: systemctl enable --now chrony (Alpine), chronyd (RHEL), or systemd-timesyncd (Debian minimal).

Verification & Validation

Post-fix proof list — run each command, compare to the expected output; every fix from Remediation maps to exactly one line here:

sudo sshd -T | grep -E '^(permitrootlogin|passwordauthentication|authenticationmethods|pubkeyauthentication)'
# permitrootlogin no / passwordauthentication no / authenticationmethods publickey / pubkeyauthentication yes
awk -F: '$3==0{print $1}' /etc/passwd                          # root only
sudo awk -F: '($2==""){print $1}' /etc/shadow                  # (no output)
grep -RsE 'NOPASSWD:\s*ALL' /etc/sudoers /etc/sudoers.d/ && visudo -c   # no hits; parsed OK (scoped grants reviewed)
sysctl -n kernel.kptr_restrict kernel.dmesg_restrict kernel.randomize_va_space fs.protected_symlinks fs.protected_hardlinks fs.suid_dumpable   # 2 1 2 1 1 0
sysctl -n net.ipv4.conf.all.accept_redirects net.ipv6.conf.all.accept_redirects  # 0 / 0
find / -xdev -type f -perm -4000 2>/dev/null | sort > /tmp/suid-post
diff /tmp/suid-pre /tmp/suid-post                               # only intentional deltas vs pre-change baseline
findmnt -no OPTIONS /dev/shm | tr ',' '\n' | grep -E 'nosuid|nodev|noexec'        # all three present
ls -l /etc/cron.allow /etc/at.allow                             # exist, 600, root:root
timedatectl show -p NTPSynchronized                             # NTPSynchronized=yes
chronyc sources                                                 # >=1 ^* synced source
su - NOLOGIN_TEST -c true 2>&1 | grep -q nologin && echo "shell-gating OK"        # throwaway sanity check
# Session survival before releasing the window: from a SECOND terminal run `sudo -k && sudo -l`
# and a fresh key-based ssh login; only then close the original session.

Regression notes — what breaks when hardening is applied wrongly:

Change Classic breakage Precaution
AllowUsers/AllowGroups typo every SSH login refused, including admins confirm console/out-of-band access first; keep current session open; sshd -t; reload not restart
PasswordAuthentication no deployed before keys distributed password-only colleagues and break-glass automation locked out distribute+test keys first, flip switch second
kernel.unprivileged_bpf_disabled=1 eBPF-based monitoring/tracing agents (Cilium, bcc-style profilers) silently die coordinate with observability owners; schedule with their deploy
forcing ip_forward=0 on infra roles Docker/Kubernetes NAT breaks; containers lose egress exempt routers/NAT/container nodes explicitly in findings
rp_filter=1 on asymmetric/multi-homed hosts BGP/VRRP/policy-routed traffic dropped documented exception instead of blanket value
fs.protected_regular=2 legacy daemons pre-creating shared files in /tmp fail on write canary one host first; drop to 1 if an app breaks, record which
faillock without exemptions automation with rotating/typo-prone creds locks itself out repeatedly use users= exemptions + monitor /var/log/faillock
Storage=none for coredumps crash debugging loses dumps journald still records metadata; raise ProcessSizeMax temporarily per-unit when triaging

Regression hygiene: store the sweep output (Where To Look block) as the pre-change baseline; post-change diff is your regression report. Re-run the full sweep after kernel updates — new kernels occasionally reset sysctl defaults.

Severity Assessment

Anchors with CVSS v3.1 example vectors. Adjust for exposure (internet vs internal), compensating controls, and host role; document every adjustment.

# Finding class Anchor severity Example vector (CVSS:3.1) Score Context modifiers
1 PermitRootLogin yes + PasswordAuthentication yes, SSH internet-exposed Critical AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H 9.8 VPN/allowlist-only → downgrade one tier with evidence
2 Unknown/duplicate UID-0 account or planted SUID root binary High (treat as incident) AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H 7.7 C2/persistence evidence → escalate Critical, hand to IR
3 Broad NOPASSWD: ALL on network-facing service account High AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H 8.8 requires adjacent app RCE — note as chain enabler too
4 Empty-password account with login shell High AV:L/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H 7.9 UID-0 member or multi-user host → Critical
5 Missing sysctl hardening (redirects, kptr/dmesg, ASLR) internal host Medium AV:A/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N 5.7 edge/internet-exposed position raises tier
6 Executable /dev/shm (+ app write primitive) Medium AV:A/AC:L/PR:L/UI:N/S:U/C:H/I:L/A:N 6.3 amplifier — pair with taint row 7
7 cron/at unrestricted (no allow-list) Medium AV:A/AC:L/PR:L/UI:N/S:U/C:L/I:H/A:N 6.3 single-admin box → Low acceptable with note
8 Time sync absent/unsynchronized Low standalone AV:A/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:N 3.5 force multiplier — annotate forensic impact regardless of score
9 Missing login banner, lax LoginGraceTime/MaxAuthTries only Low AV:N/AC:H/PR:L/UI:N/S:U/C:L/I:N/A:N 3.0 never aggregate up; keep cosmetic findings honest

Severity discipline: anchors 1–4 are reachable paths, not theoretical — verify exposure via FW module before finalizing; amplifier findings inherit narrative weight from Taint Tracing but keep their own measured CVSS.

Common False Positives

Suppress or re-scope these before writing findings:

  1. Cloud-init owns sshd. Cloud images rewrite SSH settings at boot (ssh_pwauth, cc_ssh module, ssh_deletekeys). A manual fix to /etc/ssh/sshd_config may be reverted on rebuild. Verify where truth lives; if user-data/image is the source, file the finding against the repo artifact, not just the live file.
  2. Silent directive ≠ risky when distro default is safe. PermitRootLogin absent means OpenSSH default prohibit-password (key-only root) — not a finding unless effective value is yes. Ubuntu cloud images commonly ship /etc/ssh/sshd_config.d/60-cloudimg-settings.conf with PasswordAuthentication no; judge sshd -T, never file absence.
  3. Containers are not VMs. Docker/Podman images lack systemd, PAM, timesync daemons, and often run root by design inside a namespace. Applying this baseline inside a container generates noise; audit the container host here, and container-specific risks via SANDBOX.
  4. Modern kernel defaults already satisfy several sysctl targets (tcp_syncookies=1, icmp_echo_ignore_broadcasts=1, protected_* enabled on recent kernels). Missing config files are not misconfigurations — compare live sysctl -n values against the tables only.
  5. kptr_restrict=1 is partial protection, not wide-open. Distinguish 0 (open), 1 (restricted to CAP_SYSLOG), 2 (always hidden); flagging 1 as "KASLR defeated" overstates it.
  6. Scoped NOPASSWD is legitimate. NOPASSWD: /usr/bin/systemctl restart myapp.service is an accepted pattern; flag only wildcard grants and GTFOBins-abusable commands (interpreters, editors, su, archivers, find).
  7. Missing SUID on legacy binaries is progress, not anomaly. ping/mount lose SUID in favor of file capabilities on modern distros; check getcap output instead of expecting the old inventory verbatim.
  8. cron.allow absent can be correct on hosts running exclusively systemd timers — confirm which schedulers exist (systemctl list-timers, /etc/cron.d/). WSL/devcontainers/laptops are out of server scope per the engagement agreement; do not report their divergent baselines.

References